@@ -13,7 +13,7 @@ import { and, asc, eq, inArray, sql } from 'drizzle-orm'
1313import type { BillingEntity } from '@/lib/billing/core/usage-log'
1414import type { ProjectStorageOwnerSnapshot } from '@/lib/billing/storage/context'
1515import type { DbOrTx , DbTransaction } from '@/lib/db/types'
16- import { lockProject } from '@/lib/projects/membership'
16+ import { lockProjects } from '@/lib/projects/membership'
1717
1818const logger = createLogger ( 'WorkspaceStoragePayerTransfer' )
1919
@@ -384,25 +384,13 @@ async function setStoragePayerUsagesBatch(
384384}
385385
386386/**
387- * Changes multiple workspace payers in one short caller-owned transaction.
388- *
389- * Workspace rows are locked in ascending ID order, exact live byte totals are
390- * grouped into one query, and distinct payer rows are locked with all users
391- * before all organizations and ascending IDs within each type. Source and
392- * destination deltas are aggregated separately so an underfunded source is
393- * clamped before incoming bytes are added. No destination quota is enforced.
387+ * Locks workspace rows in ascending ID order and changes their payer metadata and exact
388+ * live byte totals. The caller already holds every affected payer row and applies the
389+ * returned contributions once, so no payer ledger is written here.
394390 */
395- export async function changeWorkspaceStoragePayersInTx (
396- tx : DbOrTx ,
397- changes : ChangeWorkspaceStoragePayerParams [ ]
398- ) : Promise < ChangeWorkspaceStoragePayerResult [ ] > {
399- return changeWorkspaceStoragePayers ( tx , changes , false )
400- }
401-
402391async function changeWorkspaceStoragePayers (
403392 tx : DbOrTx ,
404- changes : ChangeWorkspaceStoragePayerParams [ ] ,
405- deferPayerUpdates : boolean
393+ changes : ChangeWorkspaceStoragePayerParams [ ]
406394) : Promise < ChangeWorkspaceStoragePayerResult [ ] > {
407395 if ( changes . length === 0 ) return [ ]
408396
@@ -442,98 +430,19 @@ async function changeWorkspaceStoragePayers(
442430 }
443431 }
444432
445- const payerByKey = new Map < string , BillingEntity > ( )
446- const payerChangingWorkspaceIds : string [ ] = [ ]
447- for ( const workspaceId of workspaceIds ) {
433+ const payerChangingWorkspaceIds = workspaceIds . filter ( ( workspaceId ) => {
448434 const lockedWorkspace = workspaceById . get ( workspaceId )
449435 const change = changesByWorkspaceId . get ( workspaceId )
450- if ( ! lockedWorkspace || ! change ) continue
451- const oldPayer = getWorkspacePayer ( lockedWorkspace )
452- const newPayer = getWorkspacePayer ( change )
453- if ( getPayerKey ( oldPayer ) === getPayerKey ( newPayer ) ) continue
454- payerChangingWorkspaceIds . push ( workspaceId )
455- payerByKey . set ( getPayerKey ( oldPayer ) , oldPayer )
456- payerByKey . set ( getPayerKey ( newPayer ) , newPayer )
457- }
436+ if ( ! lockedWorkspace || ! change ) return false
437+ return (
438+ getPayerKey ( getWorkspacePayer ( lockedWorkspace ) ) !== getPayerKey ( getWorkspacePayer ( change ) )
439+ )
440+ } )
458441
459442 const exactBytesByWorkspaceId = await getExactWorkspaceStorageBytesBatch (
460443 tx ,
461444 payerChangingWorkspaceIds
462445 )
463- if ( ! deferPayerUpdates ) {
464- const payerUsageByKey = await lockStoragePayers ( tx , payerByKey )
465- const deltaByPayerKey = new Map < string , PayerStorageDelta > ( )
466-
467- for ( const workspaceId of payerChangingWorkspaceIds ) {
468- const lockedWorkspace = workspaceById . get ( workspaceId )
469- const change = changesByWorkspaceId . get ( workspaceId )
470- if ( ! lockedWorkspace || ! change ) continue
471- const exactBytes = exactBytesByWorkspaceId . get ( workspaceId ) ?? 0
472- const oldPayer = getWorkspacePayer ( lockedWorkspace )
473- const newPayer = getWorkspacePayer ( change )
474- const oldPayerKey = getPayerKey ( oldPayer )
475- const newPayerKey = getPayerKey ( newPayer )
476-
477- if ( payerUsageByKey . get ( newPayerKey ) === null ) {
478- throw new Error ( `Storage destination payer ${ newPayerKey } not found` )
479- }
480-
481- const sourceDelta = deltaByPayerKey . get ( oldPayerKey ) ?? {
482- incomingBytes : 0 ,
483- outgoingBytes : 0 ,
484- }
485- sourceDelta . outgoingBytes += exactBytes
486- if ( ! Number . isSafeInteger ( sourceDelta . outgoingBytes ) ) {
487- throw new Error ( `Storage source payer ${ oldPayerKey } delta exceeds the safe integer range` )
488- }
489- deltaByPayerKey . set ( oldPayerKey , sourceDelta )
490-
491- const destinationDelta = deltaByPayerKey . get ( newPayerKey ) ?? {
492- incomingBytes : 0 ,
493- outgoingBytes : 0 ,
494- }
495- destinationDelta . incomingBytes += exactBytes
496- if ( ! Number . isSafeInteger ( destinationDelta . incomingBytes ) ) {
497- throw new Error (
498- `Storage destination payer ${ newPayerKey } delta exceeds the safe integer range`
499- )
500- }
501- deltaByPayerKey . set ( newPayerKey , destinationDelta )
502- }
503-
504- const nextUsageByKey = new Map < string , number > ( )
505- for ( const [ payerKey , delta ] of [ ...deltaByPayerKey . entries ( ) ] . sort ( ( [ left ] , [ right ] ) =>
506- comparePayerKeys ( left , right )
507- ) ) {
508- const currentUsage = payerUsageByKey . get ( payerKey )
509- if ( currentUsage === null || currentUsage === undefined ) {
510- logger . warn ( 'Storage source payer is missing during workspace payer batch change' , {
511- sourcePayer : payerKey ,
512- outgoingBytes : delta . outgoingBytes ,
513- } )
514- continue
515- }
516-
517- const usageAfterOutgoing = Math . max ( 0 , currentUsage - delta . outgoingBytes )
518- if ( currentUsage < delta . outgoingBytes ) {
519- logger . warn (
520- 'Clamping drifted source storage aggregate during workspace payer batch change' ,
521- {
522- sourcePayer : payerKey ,
523- sourceUsage : currentUsage ,
524- outgoingBytes : delta . outgoingBytes ,
525- }
526- )
527- }
528- const nextUsage = usageAfterOutgoing + delta . incomingBytes
529- if ( ! Number . isSafeInteger ( nextUsage ) ) {
530- throw new Error ( `Storage payer ${ payerKey } exceeds the safe integer range` )
531- }
532- nextUsageByKey . set ( payerKey , nextUsage )
533- }
534-
535- await setStoragePayerUsagesBatch ( tx , payerByKey , nextUsageByKey )
536- }
537446
538447 const resultsByWorkspaceId = new Map < string , ChangeWorkspaceStoragePayerResult > ( )
539448 const workspaceUpdates = workspaceIds . map ( ( workspaceId ) => {
@@ -796,9 +705,14 @@ export async function changeProjectStoragePayersInTx(
796705 tx : DbTransaction ,
797706 changes : readonly ChangeProjectStoragePayerParams [ ]
798707) : Promise < ChangeProjectStoragePayerResult [ ] > {
708+ await lockProjects (
709+ tx ,
710+ changes . map ( ( change ) => change . projectId )
711+ )
799712 return changeProjectStoragePayers ( tx , changes , false )
800713}
801714
715+ /** The caller holds every changed Project's lock. */
802716async function changeProjectStoragePayers (
803717 tx : DbTransaction ,
804718 changes : readonly ChangeProjectStoragePayerParams [ ] ,
@@ -809,7 +723,6 @@ async function changeProjectStoragePayers(
809723 if ( changesById . size !== changes . length )
810724 throw new Error ( 'Storage payer batch contains duplicate Project IDs' )
811725 const projectIds = [ ...changesById . keys ( ) ] . sort ( )
812- for ( const id of projectIds ) await lockProject ( tx , id )
813726 const owners = await tx
814727 . select ( { id : project . id , ownerId : project . ownerId , organizationId : project . organizationId } )
815728 . from ( project )
@@ -905,6 +818,7 @@ async function applyPayerContributionTransfers(
905818 const usageByKey = await lockStoragePayers ( tx , payerByKey )
906819 const deltas = new Map < string , PayerStorageDelta > ( )
907820 for ( const result of contributions ) {
821+ if ( result . billableBytes === 0 ) continue
908822 const sourceKey = getPayerKey ( result . oldPayer )
909823 const destinationKey = result . newPayer ? getPayerKey ( result . newPayer ) : null
910824 if ( sourceKey === destinationKey ) continue
@@ -967,7 +881,7 @@ export async function changeProjectAndWorkspaceStoragePayersInTx(
967881 . sort ( )
968882 if ( new Set ( projectIds ) . size !== projectIds . length )
969883 throw new Error ( 'Storage lifecycle batch contains duplicate Project IDs' )
970- for ( const id of projectIds ) await lockProject ( tx , id )
884+ await lockProjects ( tx , projectIds )
971885 const projects = projectIds . length
972886 ? await tx
973887 . select ( {
@@ -1043,7 +957,7 @@ export async function changeProjectAndWorkspaceStoragePayersInTx(
1043957 removal . billableBytes = bytes
1044958 }
1045959 const projectResults = await changeProjectStoragePayers ( tx , changes . projectChanges , true )
1046- const workspaceResults = await changeWorkspaceStoragePayers ( tx , changes . workspaceChanges , true )
960+ const workspaceResults = await changeWorkspaceStoragePayers ( tx , changes . workspaceChanges )
1047961 await applyPayerContributionTransfers ( tx , [
1048962 ...projectResults ,
1049963 ...workspaceResults ,
0 commit comments