Skip to content

Commit ef198ef

Browse files
committed
fix(lifecycle): order workspace locks before file mutations
1 parent 08398e7 commit ef198ef

6 files changed

Lines changed: 233 additions & 62 deletions

File tree

‎apps/sim/background/cleanup-soft-deletes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm'
1919
import { type CleanupJobPayload, runCleanupWithLimits } from '@/lib/billing/cleanup-dispatcher'
2020
import {
2121
decrementStorageUsageForBillingContextInTx,
22+
lockWorkspaceStorageForMutationInTx,
2223
resolveStorageBillingContext,
2324
type StorageBillingContext,
2425
} from '@/lib/billing/storage'
@@ -334,6 +335,7 @@ async function deleteExpiredBillableWorkspaceFileRows(
334335
for (const batch of chunkArray(workspaceRows, DEFAULT_DELETE_CHUNK_SIZE)) {
335336
try {
336337
const deletedCount = await db.transaction(async (tx) => {
338+
await lockWorkspaceStorageForMutationInTx(tx, workspaceId)
337339
await releaseWorkspaceFileVersionsForPurgeInTx(
338340
tx,
339341
batch.map(({ id }) => id),

‎apps/sim/lib/billing/storage/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
incrementAdmittedStorageUsageForBillingContextInTx,
1616
incrementStorageUsageForBillingContextInTx,
1717
type LegacyStorageUsageDelta,
18+
lockWorkspaceStorageForMutationInTx,
1819
maybeNotifyStorageLimitForBillingContext,
1920
type WorkspaceStorageUsageDelta,
2021
} from './tracking'

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

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,29 @@ export async function applyStorageUsageDeltasInTx(
430430
return destinationUpdatedUsage
431431
}
432432

433+
/** Locks the canonical workspace before existing-file mutations that later change its storage ledger. */
434+
export async function lockWorkspaceStorageForMutationInTx(
435+
tx: DbOrTx,
436+
workspaceId: string
437+
): Promise<LockedWorkspaceStorage> {
438+
const [workspacePayer] = await tx
439+
.select({
440+
billedAccountUserId: workspace.billedAccountUserId,
441+
organizationId: workspace.organizationId,
442+
storageUsedBytes: workspace.storageUsedBytes,
443+
})
444+
.from(workspace)
445+
.where(eq(workspace.id, workspaceId))
446+
.for('no key update')
447+
.limit(1)
448+
449+
if (!workspacePayer) {
450+
throw new Error(`Workspace ${workspaceId} not found for storage accounting`)
451+
}
452+
453+
return { id: workspaceId, ...workspacePayer }
454+
}
455+
433456
/**
434457
* Mutates the durable workspace total and its current routed payer as one
435458
* transaction. The workspace row is the serialization point shared with payer
@@ -447,25 +470,9 @@ async function mutateWorkspaceStorageUsage(
447470
maximumUsage: number | undefined,
448471
context: StorageBillingContext
449472
): Promise<WorkspaceStorageMutationResult> {
450-
const [workspacePayer] = await tx
451-
.select({
452-
billedAccountUserId: workspace.billedAccountUserId,
453-
organizationId: workspace.organizationId,
454-
storageUsedBytes: workspace.storageUsedBytes,
455-
})
456-
.from(workspace)
457-
.where(eq(workspace.id, workspaceId))
458-
.for('no key update')
459-
.limit(1)
460-
461-
if (!workspacePayer) {
462-
throw new Error(`Workspace ${workspaceId} not found for storage accounting`)
463-
}
473+
const workspacePayer = await lockWorkspaceStorageForMutationInTx(tx, workspaceId)
464474

465-
const billingEntity = assertWorkspaceStorageContext(
466-
{ id: workspaceId, ...workspacePayer },
467-
context
468-
)
475+
const billingEntity = assertWorkspaceStorageContext(workspacePayer, context)
469476
const currentPayerUsage = await lockStorageUsageForMutation(tx, billingEntity)
470477

471478
if (mutation === 'decrement' && workspacePayer.storageUsedBytes < bytes) {

‎apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts‎

Lines changed: 199 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { db, dbFor } from '@sim/db'
77
import {
88
organization,
99
outboxEvent,
10+
permissions,
1011
user,
1112
workspace,
1213
workspaceFileSecretProvenance,
@@ -40,6 +41,7 @@ import {
4041
fetchWorkspaceFileBuffer,
4142
getWorkspaceFile,
4243
getWorkspaceFileWithCurrentVersion,
44+
purgeCreatedWorkspaceFile,
4345
updateWorkspaceFileContent,
4446
uploadWorkspaceFile,
4547
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -61,7 +63,9 @@ import {
6163
readWorkspaceFileVersionText,
6264
revertWorkspaceFileVersion,
6365
} from '@/lib/workspace-files/application/file-versions'
66+
import { revokeWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-access'
6467
import { runCleanupFileVersions } from '@/background/cleanup-file-versions'
68+
import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes'
6569
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
6670
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
6771

@@ -120,6 +124,50 @@ describe('workspace file version history in PostgreSQL', () => {
120124
return (await fetchWorkspaceFileBuffer({ ...file, key }, { maxBytes: 1024 })).toString()
121125
}
122126

127+
async function runContendedFileOperations(
128+
fileId: string,
129+
operations: (() => Promise<unknown>)[]
130+
) {
131+
const ready = createDeferred<number>()
132+
const release = createDeferred<void>()
133+
const blocker = db.transaction(async (tx) => {
134+
await tx.select().from(workspaceFiles).where(eq(workspaceFiles.id, fileId)).for('update')
135+
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
136+
ready.resolve(connection.pid)
137+
await release.promise
138+
})
139+
const blockerPid = await ready.promise
140+
const pending: Promise<PromiseSettledResult<unknown>[]>[] = []
141+
try {
142+
for (const operation of operations) {
143+
pending.push(Promise.allSettled([operation()]))
144+
await expect
145+
.poll(
146+
async () =>
147+
(
148+
await db.execute(
149+
sql`WITH RECURSIVE blocked(pid) AS (
150+
SELECT pid FROM pg_stat_activity
151+
WHERE ${blockerPid} = ANY(pg_blocking_pids(pid))
152+
UNION
153+
SELECT activity.pid FROM pg_stat_activity activity
154+
JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid))
155+
) SELECT pid FROM blocked`
156+
)
157+
).length,
158+
{ timeout: 5000 }
159+
)
160+
.toBe(pending.length)
161+
}
162+
} finally {
163+
release.resolve()
164+
await blocker
165+
expect(
166+
(await Promise.all(pending)).flat().filter((result) => result.status === 'rejected')
167+
).toEqual([])
168+
}
169+
}
170+
123171
it('lists a never-rewritten file as an implicit version 1 attributed to its uploader', async () => {
124172
const fixture = await seedFile('original')
125173
const file = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
@@ -402,26 +450,24 @@ describe('workspace file version history in PostgreSQL', () => {
402450
{ first: 'handoff', content: '' },
403451
{ first: 'write', content: '' },
404452
] as const)(
405-
'serializes creator handoff with a content write ($first first, original=$content)',
453+
'serializes full member revocation with a size-changing content write ($first first, original=$content)',
406454
async ({ first, content }) => {
407455
const fixture = await seedFile(content)
408-
const ready = createDeferred<number>()
409-
const release = createDeferred<void>()
410-
const blocker = db.transaction(async (tx) => {
411-
await tx
412-
.select()
413-
.from(workspaceFiles)
414-
.where(eq(workspaceFiles.id, fixture.fileId))
415-
.for('update')
416-
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
417-
ready.resolve(connection.pid)
418-
await release.promise
419-
})
420-
const blockerPid = await ready.promise
456+
await db
457+
.update(workspaceFiles)
458+
.set({ userId: fixture.bobId })
459+
.where(eq(workspaceFiles.id, fixture.fileId))
460+
const [usageBefore] = await db
461+
.select({ bytes: workspace.storageUsedBytes })
462+
.from(workspace)
463+
.where(eq(workspace.id, fixture.workspaceId))
421464
const operations = {
422465
handoff: () =>
423466
db.transaction((tx) =>
424-
handoffFileCreatorsInTx(tx, eq(workspaceFiles.id, fixture.fileId), fixture.bobId)
467+
revokeWorkspaceAccessTx(tx, {
468+
workspaceId: fixture.workspaceId,
469+
userId: fixture.bobId,
470+
}).then((result) => expect(result.revoked).toBe(true))
425471
),
426472
write: () =>
427473
updateWorkspaceFileContent(
@@ -433,38 +479,15 @@ describe('workspace file version history in PostgreSQL', () => {
433479
{ version: { source: 'api', authorUserId: fixture.aliceId } }
434480
),
435481
}
436-
const pending: Promise<unknown>[] = []
437-
try {
438-
for (const operation of [first, first === 'handoff' ? 'write' : 'handoff'] as const) {
439-
pending.push(operations[operation]())
440-
await expect
441-
.poll(
442-
async () =>
443-
(
444-
await db.execute(
445-
sql`WITH RECURSIVE blocked(pid) AS (
446-
SELECT pid FROM pg_stat_activity
447-
WHERE ${blockerPid} = ANY(pg_blocking_pids(pid))
448-
UNION
449-
SELECT activity.pid FROM pg_stat_activity activity
450-
JOIN blocked ON blocked.pid = ANY(pg_blocking_pids(activity.pid))
451-
) SELECT pid FROM blocked`
452-
)
453-
).length,
454-
{ timeout: 5000 }
455-
)
456-
.toBe(pending.length)
457-
}
458-
} finally {
459-
release.resolve()
460-
await blocker
461-
await Promise.all(pending)
462-
}
482+
await runContendedFileOperations(fixture.fileId, [
483+
operations[first],
484+
operations[first === 'handoff' ? 'write' : 'handoff'],
485+
])
463486
const rows = await versionRows(fixture.fileId)
464487
expect(rows.map((row) => [row.version, row.authorUserIds])).toEqual(
465488
content
466489
? [
467-
[1, [fixture.aliceId]],
490+
[1, [fixture.bobId]],
468491
[2, [fixture.aliceId]],
469492
]
470493
: [[1, [fixture.aliceId]]]
@@ -474,8 +497,140 @@ describe('workspace file version history in PostgreSQL', () => {
474497
content || 'second'
475498
)
476499
expect((await getWorkspaceFile(fixture.workspaceId, fixture.fileId))?.uploadedBy).toBe(
477-
fixture.bobId
500+
fixture.aliceId
478501
)
502+
const [usageAfter] = await db
503+
.select({ bytes: workspace.storageUsedBytes })
504+
.from(workspace)
505+
.where(eq(workspace.id, fixture.workspaceId))
506+
expect(usageAfter.bytes).toBe(
507+
usageBefore.bytes + Buffer.byteLength('second') - Buffer.byteLength(content)
508+
)
509+
expect(
510+
await db
511+
.select({ id: permissions.id })
512+
.from(permissions)
513+
.where(
514+
and(
515+
eq(permissions.entityId, fixture.workspaceId),
516+
eq(permissions.entityType, 'workspace'),
517+
eq(permissions.userId, fixture.bobId)
518+
)
519+
)
520+
).toEqual([])
521+
const current = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
522+
if (!current) throw new Error('file missing')
523+
expect((await fetchWorkspaceFileBuffer(current, { maxBytes: 1024 })).toString()).toBe(
524+
'second'
525+
)
526+
}
527+
)
528+
529+
it.each(['handoff', 'purge'] as const)(
530+
'serializes member revocation with archive rollback (%s first)',
531+
async (first) => {
532+
const fixture = await seedFile('original')
533+
await db
534+
.update(workspaceFiles)
535+
.set({ userId: fixture.bobId })
536+
.where(eq(workspaceFiles.id, fixture.fileId))
537+
const file = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
538+
if (!file) throw new Error('file missing')
539+
const [usageBefore] = await db
540+
.select({ bytes: workspace.storageUsedBytes })
541+
.from(workspace)
542+
.where(eq(workspace.id, fixture.workspaceId))
543+
const operations = {
544+
handoff: () =>
545+
db.transaction(async (tx) => {
546+
const result = await revokeWorkspaceAccessTx(tx, {
547+
workspaceId: fixture.workspaceId,
548+
userId: fixture.bobId,
549+
})
550+
expect(result.revoked).toBe(true)
551+
}),
552+
purge: async () => {
553+
const purged = await purgeCreatedWorkspaceFile({
554+
workspaceId: fixture.workspaceId,
555+
fileId: fixture.fileId,
556+
key: file.key,
557+
expectedName: file.name,
558+
expectedFolderId: file.folderId ?? null,
559+
expectedUpdatedAt: file.updatedAt,
560+
})
561+
expect(purged).toBe(first === 'purge')
562+
},
563+
}
564+
await runContendedFileOperations(fixture.fileId, [
565+
operations[first],
566+
operations[first === 'handoff' ? 'purge' : 'handoff'],
567+
])
568+
const retained = await getWorkspaceFile(fixture.workspaceId, fixture.fileId)
569+
const [usageAfter] = await db
570+
.select({ bytes: workspace.storageUsedBytes })
571+
.from(workspace)
572+
.where(eq(workspace.id, fixture.workspaceId))
573+
expect(usageAfter.bytes).toBe(
574+
usageBefore.bytes - (first === 'purge' ? Buffer.byteLength('original') : 0)
575+
)
576+
expect(await objectExists(fixture.firstKey)).toBe(first === 'handoff')
577+
if (first === 'purge') {
578+
expect(retained).toBeNull()
579+
expect(await versionRows(fixture.fileId)).toEqual([])
580+
} else {
581+
expect(retained?.uploadedBy).toBe(fixture.aliceId)
582+
expect((await versionRows(fixture.fileId)).map((row) => row.authorUserIds)).toEqual([
583+
[fixture.bobId],
584+
])
585+
}
586+
}
587+
)
588+
589+
it.each(['handoff', 'cleanup'] as const)(
590+
'serializes member revocation with retention cleanup (%s first)',
591+
async (first) => {
592+
const fixture = await seedFile('original')
593+
await db
594+
.update(workspaceFiles)
595+
.set({ userId: fixture.bobId, deletedAt: new Date(0) })
596+
.where(eq(workspaceFiles.id, fixture.fileId))
597+
const [usageBefore] = await db
598+
.select({ bytes: workspace.storageUsedBytes })
599+
.from(workspace)
600+
.where(eq(workspace.id, fixture.workspaceId))
601+
const operations = {
602+
handoff: () =>
603+
db.transaction(async (tx) => {
604+
const result = await revokeWorkspaceAccessTx(tx, {
605+
workspaceId: fixture.workspaceId,
606+
userId: fixture.bobId,
607+
})
608+
expect(result.revoked).toBe(true)
609+
}),
610+
cleanup: () =>
611+
runCleanupSoftDeletes({
612+
workspaceIds: [fixture.workspaceId],
613+
plan: 'free',
614+
retentionHours: 1,
615+
label: 'file-handoff-lock-regression',
616+
}),
617+
}
618+
await runContendedFileOperations(fixture.fileId, [
619+
operations[first],
620+
operations[first === 'handoff' ? 'cleanup' : 'handoff'],
621+
])
622+
expect(
623+
await db
624+
.select({ id: workspaceFiles.id })
625+
.from(workspaceFiles)
626+
.where(eq(workspaceFiles.id, fixture.fileId))
627+
).toEqual([])
628+
expect(await versionRows(fixture.fileId)).toEqual([])
629+
const [usageAfter] = await db
630+
.select({ bytes: workspace.storageUsedBytes })
631+
.from(workspace)
632+
.where(eq(workspace.id, fixture.workspaceId))
633+
expect(usageAfter.bytes).toBe(usageBefore.bytes - Buffer.byteLength('original'))
479634
}
480635
)
481636

0 commit comments

Comments
 (0)