11/**
22 * Storage usage tracking for durable workspace and payer ledgers.
3+ *
4+ * Every row lock here is `FOR NO KEY UPDATE`, never `FOR UPDATE`. The
5+ * `workspace`, `organization`, and `user_stats` rows these transactions lock
6+ * are foreign-key parents (49 tables reference `workspace` alone), so any
7+ * insert or update of a child row — a `workspace_files` row in this very
8+ * transaction — implicitly takes `FOR KEY SHARE` on the parent first. A later
9+ * `FOR UPDATE` on the same row is then a lock upgrade, and two concurrent
10+ * uploads or deletes in one workspace deadlock on it. `FOR NO KEY UPDATE`
11+ * does not conflict with `FOR KEY SHARE`, yet still conflicts with itself and
12+ * with `FOR UPDATE`, so writers remain serialized against each other and
13+ * against payer transfers. It is exactly the lock a plain `UPDATE` of these
14+ * non-key counters takes anyway. The only key columns on these tables are
15+ * `workspace.id`, `workspace.inbox_provider_id`, `organization.id`,
16+ * `user_stats.id`, and `user_stats.user_id`, and no path under these locks
17+ * writes any of them or deletes a locked row.
318 */
419
520import { organization , userStats , workspace } from '@sim/db/schema'
@@ -124,6 +139,7 @@ async function mutateStorageUsage(
124139
125140/**
126141 * Locks and reads the payer ledger after the workspace row has been locked.
142+ * `FOR NO KEY UPDATE` for the reason documented at the top of this module.
127143 */
128144async function lockStorageUsageForMutation (
129145 tx : DbOrTx ,
@@ -134,7 +150,7 @@ async function lockStorageUsageForMutation(
134150 . select ( { storageUsedBytes : organization . storageUsedBytes } )
135151 . from ( organization )
136152 . where ( eq ( organization . id , billingEntity . id ) )
137- . for ( 'update' )
153+ . for ( 'no key update' )
138154 . limit ( 1 )
139155 if ( ! row ) throw new Error ( `Storage payer organization:${ billingEntity . id } not found` )
140156 return row . storageUsedBytes
@@ -144,7 +160,7 @@ async function lockStorageUsageForMutation(
144160 . select ( { storageUsedBytes : userStats . storageUsedBytes } )
145161 . from ( userStats )
146162 . where ( eq ( userStats . userId , billingEntity . id ) )
147- . for ( 'update' )
163+ . for ( 'no key update' )
148164 . limit ( 1 )
149165 if ( ! row ) throw new Error ( `Storage payer user:${ billingEntity . id } not found` )
150166 return row . storageUsedBytes
@@ -242,7 +258,7 @@ export async function applyStorageUsageDeltasInTx(
242258 . from ( workspace )
243259 . where ( inArray ( workspace . id , workspaceIds ) )
244260 . orderBy ( asc ( workspace . id ) )
245- . for ( 'update' )
261+ . for ( 'no key update' )
246262 : [ ]
247263 const workspaceById = new Map ( lockedWorkspaces . map ( ( row ) => [ row . id , row ] ) )
248264
@@ -318,7 +334,7 @@ export async function applyStorageUsageDeltasInTx(
318334 . from ( userStats )
319335 . where ( inArray ( userStats . userId , userIds ) )
320336 . orderBy ( asc ( userStats . userId ) )
321- . for ( 'update' )
337+ . for ( 'no key update' )
322338 for ( const row of rows ) {
323339 payerUsageByKey . set ( getPayerKey ( { type : 'user' , id : row . id } ) , row . storageUsedBytes )
324340 }
@@ -329,7 +345,7 @@ export async function applyStorageUsageDeltasInTx(
329345 . from ( organization )
330346 . where ( inArray ( organization . id , organizationIds ) )
331347 . orderBy ( asc ( organization . id ) )
332- . for ( 'update' )
348+ . for ( 'no key update' )
333349 for ( const row of rows ) {
334350 payerUsageByKey . set ( getPayerKey ( { type : 'organization' , id : row . id } ) , row . storageUsedBytes )
335351 }
@@ -439,7 +455,7 @@ async function mutateWorkspaceStorageUsage(
439455 } )
440456 . from ( workspace )
441457 . where ( eq ( workspace . id , workspaceId ) )
442- . for ( 'update' )
458+ . for ( 'no key update' )
443459 . limit ( 1 )
444460
445461 if ( ! workspacePayer ) {
0 commit comments