Skip to content

Commit 012df75

Browse files
committed
fix(storage): stop workspace ledger locks from deadlocking on FK key-share
Workspace storage accounting locked the workspace, organization, and user_stats rows with SELECT ... FOR UPDATE. Those rows are foreign-key parents, so a transaction that has already written a billable child row holds an implicit FOR KEY SHARE on the parent, and the stronger lock is an upgrade that two concurrent uploads take on each other. Take FOR NO KEY UPDATE instead. It does not conflict with FOR KEY SHARE, still conflicts with itself, and is the lock a plain UPDATE of these non-key counters takes anyway, so the ledgers stay serialized.
1 parent 2795922 commit 012df75

11 files changed

Lines changed: 113 additions & 27 deletions

File tree

apps/sim/app/api/workspaces/[id]/permissions/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function queuePersonalWorkspace(
7373
) {
7474
const workspaceRow = { ownerId: OWNER_ID, billedAccountUserId, organizationId: null }
7575
queueTableRows(schemaMock.workspace, [workspaceRow])
76-
/** The in-transaction re-read of the same row, taken `FOR UPDATE`. */
76+
/** The in-transaction re-read of the same row, taken `FOR NO KEY UPDATE`. */
7777
permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({
7878
id: WORKSPACE_ID,
7979
...workspaceRow,

apps/sim/lib/billing/storage/payer-transfer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ describe('changeOrganizationWorkspaceBilledAccountsInTx', () => {
769769
expect(returning).toHaveBeenCalledWith({ id: 'workspace.id' })
770770
expect(select).toHaveBeenCalledWith({ id: 'workspace.id' })
771771
expect(orderBy).toHaveBeenCalledTimes(1)
772-
expect(lock).toHaveBeenCalledWith('update')
772+
expect(lock).toHaveBeenCalledWith('no key update')
773773
expect(lock.mock.invocationCallOrder[0]).toBeLessThan(update.mock.invocationCallOrder[0])
774774
expect(execute).not.toHaveBeenCalled()
775775
})

apps/sim/lib/billing/storage/payer-transfer.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,17 @@ async function getExactWorkspaceStorageBytes(tx: DbOrTx, workspaceId: string): P
125125
/**
126126
* Locks a payer row and returns its current aggregate. A missing source can be
127127
* historical drift and is represented as `null`; callers must reject a
128-
* missing destination.
128+
* missing destination. `FOR NO KEY UPDATE` avoids upgrading the implicit
129+
* foreign-key `FOR KEY SHARE` this transaction may already hold; see the
130+
* module header of `lib/billing/storage/tracking.ts`.
129131
*/
130132
async function lockStoragePayer(tx: DbOrTx, payer: BillingEntity): Promise<number | null> {
131133
if (payer.type === 'organization') {
132134
const [row] = await tx
133135
.select({ storageUsedBytes: organization.storageUsedBytes })
134136
.from(organization)
135137
.where(eq(organization.id, payer.id))
136-
.for('update')
138+
.for('no key update')
137139
.limit(1)
138140
return row?.storageUsedBytes ?? null
139141
}
@@ -142,7 +144,7 @@ async function lockStoragePayer(tx: DbOrTx, payer: BillingEntity): Promise<numbe
142144
.select({ storageUsedBytes: userStats.storageUsedBytes })
143145
.from(userStats)
144146
.where(eq(userStats.userId, payer.id))
145-
.for('update')
147+
.for('no key update')
146148
.limit(1)
147149
return row?.storageUsedBytes ?? null
148150
}
@@ -257,7 +259,7 @@ async function lockStoragePayers(
257259
.from(userStats)
258260
.where(inArray(userStats.userId, userIds))
259261
.orderBy(asc(userStats.userId))
260-
.for('update')
262+
.for('no key update')
261263
for (const row of rows) {
262264
usageByKey.set(getPayerKey({ type: 'user', id: row.id }), row.storageUsedBytes)
263265
}
@@ -269,7 +271,7 @@ async function lockStoragePayers(
269271
.from(organization)
270272
.where(inArray(organization.id, organizationIds))
271273
.orderBy(asc(organization.id))
272-
.for('update')
274+
.for('no key update')
273275
for (const row of rows) {
274276
usageByKey.set(getPayerKey({ type: 'organization', id: row.id }), row.storageUsedBytes)
275277
}
@@ -371,7 +373,7 @@ export async function changeWorkspaceStoragePayersInTx(
371373
.from(workspace)
372374
.where(inArray(workspace.id, workspaceIds))
373375
.orderBy(asc(workspace.id))
374-
.for('update')
376+
.for('no key update')
375377

376378
const workspaceById = new Map(lockedWorkspaces.map((row) => [row.id, row]))
377379
for (const workspaceId of workspaceIds) {
@@ -562,7 +564,7 @@ export async function changeOrganizationWorkspaceBilledAccountsInTx(
562564
)
563565
)
564566
.orderBy(asc(workspace.id))
565-
.for('update')
567+
.for('no key update')
566568

567569
const rows = await tx
568570
.update(workspace)
@@ -604,7 +606,7 @@ export async function changeWorkspaceStoragePayerInTx(
604606
})
605607
.from(workspace)
606608
.where(eq(workspace.id, params.workspaceId))
607-
.for('update')
609+
.for('no key update')
608610
.limit(1)
609611

610612
if (!lockedWorkspace) {

apps/sim/lib/billing/storage/tracking.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
mockMaybeNotifyLimit,
1414
mockOrderedLockRows,
1515
mockSql,
16+
mockTxFor,
1617
mockTxFrom,
1718
mockTxLimit,
1819
mockTxOrderBy,
@@ -32,6 +33,7 @@ const {
3233
mockMaybeNotifyLimit: vi.fn(),
3334
mockOrderedLockRows: { queue: [] as unknown[][] },
3435
mockSql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })),
36+
mockTxFor: vi.fn(),
3537
mockTxFrom: vi.fn(),
3638
mockTxLimit: vi.fn(),
3739
mockTxOrderBy: vi.fn(),
@@ -138,9 +140,10 @@ describe('workspace storage counter mutations', () => {
138140

139141
mockOrderedLockRows.queue = []
140142
mockTxSelect.mockReturnValue({ from: mockTxFrom })
143+
mockTxFor.mockReturnValue({ limit: mockTxLimit })
141144
mockTxFrom.mockReturnValue({
142145
where: vi.fn(() => ({
143-
for: vi.fn(() => ({ limit: mockTxLimit })),
146+
for: mockTxFor,
144147
limit: mockTxLimit,
145148
orderBy: mockTxOrderBy,
146149
})),
@@ -183,6 +186,47 @@ describe('workspace storage counter mutations', () => {
183186
expect(mockMaybeNotifyLimit).not.toHaveBeenCalled()
184187
})
185188

189+
/**
190+
* `FOR UPDATE` on these rows deadlocked in production: `workspace`,
191+
* `organization`, and `user_stats` are foreign-key parents, so the calling
192+
* transaction already holds an implicit `FOR KEY SHARE` on them from the
193+
* billable child row it just wrote, and the stronger lock is an upgrade that
194+
* two concurrent uploads take on each other. `FOR NO KEY UPDATE` still
195+
* conflicts with itself, so the ledgers stay serialized.
196+
*/
197+
it('takes every ledger lock as FOR NO KEY UPDATE so it never upgrades a key-share lock', async () => {
198+
await incrementStorageUsageForBillingContextInTx(mockTx as unknown as DbOrTx, ORG_CONTEXT, 100)
199+
200+
expect(mockTxFor).toHaveBeenCalled()
201+
for (const call of mockTxFor.mock.calls) {
202+
expect(call).toEqual(['no key update'])
203+
}
204+
})
205+
206+
it('takes batch ledger locks as FOR NO KEY UPDATE', async () => {
207+
mockOrderedLockRows.queue = [
208+
[
209+
{
210+
id: 'workspace-1',
211+
billedAccountUserId: 'workspace-owner',
212+
organizationId: 'workspace-org',
213+
storageUsedBytes: 1_000,
214+
},
215+
],
216+
[{ id: 'workspace-org', storageUsedBytes: 1_000 }],
217+
]
218+
219+
await applyStorageUsageDeltasInTx(mockTx as unknown as DbOrTx, {
220+
workspaceDeltas: [{ context: ORG_CONTEXT, deltaBytes: 100 }],
221+
legacyDeltas: [],
222+
})
223+
224+
expect(mockTxOrderedFor).toHaveBeenCalled()
225+
for (const call of mockTxOrderedFor.mock.calls) {
226+
expect(call).toEqual(['no key update'])
227+
}
228+
})
229+
186230
it('serializes quota admission on the locked payer ledger', async () => {
187231
mockGetStorageLimitForBillingContext.mockReturnValue(1_050)
188232
mockTxLimit

apps/sim/lib/billing/storage/tracking.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
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

520
import { 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
*/
128144
async 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) {

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ async function executeSave(
140140

141141
try {
142142
transition = await db.transaction(async (tx) => {
143-
await tx.execute(sql`SELECT 1 FROM workspace WHERE id = ${workspaceId} FOR UPDATE`)
143+
/** `FOR NO KEY UPDATE`: see the module header of `lib/billing/storage/tracking.ts`. */
144+
await tx.execute(sql`SELECT 1 FROM workspace WHERE id = ${workspaceId} FOR NO KEY UPDATE`)
144145

145146
const [updated] = await tx
146147
.update(workspaceFiles)

apps/sim/lib/credentials/environment.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ export async function getCredentialCreationWorkspaceContext(params: {
7878
})
7979
.from(workspace)
8080
.where(and(eq(workspace.id, params.workspaceId), isNull(workspace.archivedAt)))
81+
/** `FOR NO KEY UPDATE`: see the module header of `lib/billing/storage/tracking.ts`. */
8182
const [workspaceRow] = params.forUpdate
82-
? await workspaceQuery.for('update').limit(1)
83+
? await workspaceQuery.for('no key update').limit(1)
8384
: await workspaceQuery.limit(1)
8485
if (!workspaceRow) return null
8586

apps/sim/lib/table/service.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,12 +613,16 @@ export async function createTable(
613613
})
614614
}
615615

616-
// Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE
617-
// to prevent TOCTOU race on the table count limit
616+
// Wrap count check, duplicate check, and insert in a transaction with FOR NO KEY UPDATE
617+
// to prevent TOCTOU race on the table count limit. The weaker lock still conflicts with
618+
// itself, so table creations stay serialized, but it does not block unrelated inserts
619+
// into the workspace's other child tables. See lib/billing/storage/tracking.ts.
618620
try {
619621
await db.transaction(async (trx) => {
620622
await setTableTxTimeouts(trx)
621-
await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`)
623+
await trx.execute(
624+
sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR NO KEY UPDATE`
625+
)
622626

623627
const [{ count: existingCount }] = await trx
624628
.select({ count: count() })

apps/sim/lib/workspaces/admin-move.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,12 @@ export async function moveWorkspaceToOrganization(params: {
502502
throw new InvitationSetChangedError(currentInvitationIds)
503503
}
504504

505+
/**
506+
* `FOR NO KEY UPDATE`, not `FOR UPDATE`: the workspace row is a
507+
* foreign-key parent, so concurrent writers hold an implicit
508+
* `FOR KEY SHARE` on it. See the module header of
509+
* `lib/billing/storage/tracking.ts`.
510+
*/
505511
const [workspaceRow] = await tx
506512
.select({
507513
id: workspace.id,
@@ -513,7 +519,7 @@ export async function moveWorkspaceToOrganization(params: {
513519
})
514520
.from(workspace)
515521
.where(eq(workspace.id, params.workspaceId))
516-
.for('update')
522+
.for('no key update')
517523
.limit(1)
518524

519525
if (!workspaceRow) {

apps/sim/lib/workspaces/organization-workspaces.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,20 @@ export function ownedAttachableWorkspacesWhere({
9494
)
9595
}
9696

97-
/** Locks workspace rows before any payer or membership mutation. */
97+
/**
98+
* Locks workspace rows before any payer or membership mutation. `FOR NO KEY
99+
* UPDATE` keeps this compatible with the implicit foreign-key `FOR KEY SHARE`
100+
* concurrent writers hold; see the module header of
101+
* `lib/billing/storage/tracking.ts`.
102+
*/
98103
async function lockWorkspaceRowsForPayerChanges(tx: DbOrTx, workspaceIds: string[]): Promise<void> {
99104
if (workspaceIds.length === 0) return
100105
await tx
101106
.select({ id: workspace.id })
102107
.from(workspace)
103108
.where(inArray(workspace.id, [...workspaceIds].sort()))
104109
.orderBy(asc(workspace.id))
105-
.for('update')
110+
.for('no key update')
106111
}
107112

108113
interface AttachOwnedWorkspacesToOrganizationParams {
@@ -243,7 +248,7 @@ export async function attachOwnedWorkspacesToOrganizationTx(
243248
)
244249
)
245250
.orderBy(asc(workspace.id))
246-
.for('update')
251+
.for('no key update')
247252

248253
if (ownedWorkspaces.length === 0) {
249254
return {

0 commit comments

Comments
 (0)