Skip to content

Commit b4a55a2

Browse files
committed
fix(folders): lock the full ancestor closure in one ordered statement
Two more findings this round: 1. Greptile (correctly pushing back on the previous round's fix): sorting the *starting* ids before calling assertFolderMutable on each doesn't actually prevent deadlocks in a reorder batch. Each call still walks and locks its own ancestor chain one row at a time, and two concurrent batches whose chains interleave differently (e.g. one locks folder A then their shared ancestor C, the other locks folder B then also wants C) can still each end up holding a row the other needs. Sorting the starting ids only orders the entry points, not the actual sequence of rows locked. The only way to make batch-vs-batch lock acquisition deadlock-safe is to compute the full set of rows that need locking up front and lock all of them in a single statement, ordered by id. Replaced the per-id assertFolderMutable loop in performReorderFolders with: a read-only breadth-first walk (no locks) to collect the full ancestor closure of every folder being reordered plus every target parent, then one `WHERE id IN (closure) ORDER BY id FOR UPDATE` statement that locks the entire closure atomically. Concurrent batches that do the same always converge on the same acquisition order and cannot form a cycle. 2. Cursor: the folder-duplicate route's assertTargetParentFolderMutable walks the target parent's ancestor chain with the same pattern the shared engine had before the previous round's fix -- a plain SELECT, not SELECT ... FOR UPDATE, so a parent could still be locked between this read and the new folder row being inserted. This one is a single linear ancestor walk (not a multi-root batch), so it doesn't have the deadlock risk above -- just needed the same FOR UPDATE fix already applied to the shared engine. Updated the reorder route's tests for the new locking shape (a closure walk plus one ordered lock read instead of N assertFolderMutable calls), added a regression test for locking an ancestor beyond the batch's explicit ids, and fixed the duplicate route's test mock to support the FOR UPDATE chain. Full local verification passed (typecheck, full vitest suite 11431 tests, check:api-validation, lint all clean).
1 parent 056e33c commit b4a55a2

4 files changed

Lines changed: 125 additions & 27 deletions

File tree

apps/sim/app/api/folders/[id]/duplicate/route.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@ describe('assertTargetParentFolderMutable', () => {
1919
*/
2020
function buildTx(chain: Array<Record<string, unknown> | undefined>) {
2121
let call = 0
22+
const limit = vi.fn().mockImplementation(() => {
23+
const row = chain[call]
24+
call += 1
25+
return row ? [row] : []
26+
})
2227
return {
2328
select: vi.fn().mockReturnValue({
2429
from: vi.fn().mockReturnValue({
2530
where: vi.fn().mockReturnValue({
26-
limit: vi.fn().mockImplementation(() => {
27-
const row = chain[call]
28-
call += 1
29-
return row ? [row] : []
30-
}),
31+
for: vi.fn().mockReturnValue({ limit }),
32+
limit,
3133
}),
3234
}),
3335
}),

apps/sim/app/api/folders/[id]/duplicate/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ export async function assertTargetParentFolderMutable(
269269

270270
while (currentFolderId && !visited.has(currentFolderId)) {
271271
visited.add(currentFolderId)
272+
// FOR UPDATE row-locks each ancestor for the rest of this transaction -- see the
273+
// same comment on getFolderLockStatus in packages/platform-authz/src/resource-lock.ts.
272274
const [folder] = await tx
273275
.select({
274276
id: folderTable.id,
@@ -280,6 +282,7 @@ export async function assertTargetParentFolderMutable(
280282
})
281283
.from(folderTable)
282284
.where(eq(folderTable.id, currentFolderId))
285+
.for('update')
283286
.limit(1)
284287

285288
if (

apps/sim/app/api/folders/reorder/route.test.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
* @vitest-environment node
55
*/
66

7-
import { ResourceLockedError } from '@sim/platform-authz/resource-lock'
87
import {
98
authMockFns,
109
createMockRequest,
@@ -49,6 +48,30 @@ describe('PUT /api/folders/reorder', () => {
4948
const mockTxUpdate = vi.fn()
5049
const mockTxSelect = vi.fn()
5150

51+
/**
52+
* `performReorderFolders` issues several distinct `tx.select(...).from(...).where(...)`
53+
* calls inside the transaction (the active-parents recheck, the ancestor-closure
54+
* walk, and the final `ORDER BY id FOR UPDATE` lock read) -- this queue returns
55+
* each call's result in order and makes the returned builder both directly
56+
* awaitable and chainable via `.orderBy()`/`.for()` (both no-ops that return the
57+
* same thenable), matching how drizzle's query builder supports either usage.
58+
*/
59+
function queueTxSelectResults(...results: unknown[][]) {
60+
let call = 0
61+
mockTxSelect.mockImplementation(() => ({
62+
from: vi.fn().mockReturnValue({
63+
where: vi.fn().mockImplementation(() => {
64+
const result = results[call] ?? []
65+
call += 1
66+
const thenable: any = Promise.resolve(result)
67+
thenable.orderBy = vi.fn().mockReturnValue(thenable)
68+
thenable.for = vi.fn().mockReturnValue(thenable)
69+
return thenable
70+
}),
71+
}),
72+
}))
73+
}
74+
5275
beforeEach(() => {
5376
vi.clearAllMocks()
5477
resourceLockMockFns.mockAssertFolderMutable.mockReset()
@@ -67,9 +90,9 @@ describe('PUT /api/folders/reorder', () => {
6790
}),
6891
}),
6992
})
70-
mockTxSelect.mockReturnValue({
71-
from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
72-
})
93+
// Default: closure walk finds no parent (chain ends immediately), lock read
94+
// finds the folder unlocked.
95+
queueTxSelectResults([{ id: 'folder-1', parentId: null }], [{ id: 'folder-1', locked: false }])
7396
mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
7497
cb({ update: mockTxUpdate, select: mockTxSelect })
7598
)
@@ -302,19 +325,16 @@ describe('PUT /api/folders/reorder', () => {
302325
// update.parentId, since parentId is `null` not `undefined` -- succeed below,
303326
// simulating the folder being unlocked at that moment), but that's a separate
304327
// round-trip -- an admin could lock the folder in the window between that
305-
// check and the transaction. The third assertFolderMutable call is the new
306-
// in-transaction recheck this test targets.
328+
// check and the transaction. The in-transaction ORDER BY id FOR UPDATE lock
329+
// read is the recheck this test targets.
307330
mockWhere
308331
.mockReturnValueOnce([
309332
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
310333
])
311334
.mockReturnValueOnce([{ id: 'folder-1', parentId: null }])
312335
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
313336

314-
resourceLockMockFns.mockAssertFolderMutable
315-
.mockResolvedValueOnce(undefined)
316-
.mockResolvedValueOnce(undefined)
317-
.mockRejectedValueOnce(new ResourceLockedError('workflow', false, 'Folder is locked'))
337+
queueTxSelectResults([{ id: 'folder-1', parentId: null }], [{ id: 'folder-1', locked: true }])
318338

319339
const req = createMockRequest('PUT', {
320340
workspaceId: 'workspace-123',
@@ -324,11 +344,45 @@ describe('PUT /api/folders/reorder', () => {
324344
const response = await PUT(req)
325345

326346
expect(response.status).toBe(423)
327-
expect(resourceLockMockFns.mockAssertFolderMutable).toHaveBeenCalledTimes(3)
328347
expect(mockDb.transaction).toHaveBeenCalledTimes(1)
329348
expect(mockTxUpdate).not.toHaveBeenCalled()
330349
})
331350

351+
it('rejects the write when an ancestor beyond the starting folder is locked', async () => {
352+
// Regression test: the lock check must walk to ancestors not directly named in
353+
// the batch (folder-1's own parent) and include them in the single ordered
354+
// lock read -- not just the ids explicitly present in the batch. This also
355+
// guards the closure-based fix for the deadlock finding: locking each
356+
// starting id's ancestor chain separately can still deadlock a concurrent
357+
// batch, so the whole closure must be computed first and locked in one
358+
// ORDER BY id FOR UPDATE statement.
359+
mockWhere
360+
.mockReturnValueOnce([
361+
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
362+
])
363+
.mockReturnValueOnce([{ id: 'folder-1', parentId: 'ancestor-1' }])
364+
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
365+
366+
queueTxSelectResults(
367+
[{ id: 'folder-1', parentId: 'ancestor-1' }], // closure walk, level 1
368+
[{ id: 'ancestor-1', parentId: null }], // closure walk, level 2 (chain ends)
369+
[
370+
{ id: 'ancestor-1', locked: true },
371+
{ id: 'folder-1', locked: false },
372+
] // single ordered lock read over the whole closure
373+
)
374+
375+
const req = createMockRequest('PUT', {
376+
workspaceId: 'workspace-123',
377+
updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }],
378+
})
379+
380+
const response = await PUT(req)
381+
382+
expect(response.status).toBe(423)
383+
expect(mockTxUpdate).not.toHaveBeenCalled()
384+
})
385+
332386
it('rejects a batch that would form a cycle', async () => {
333387
mockWhere
334388
.mockReturnValueOnce([

apps/sim/lib/folders/orchestration.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -872,18 +872,57 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
872872
// The route checks lock state before calling this function, but that's a
873873
// separate round-trip -- an admin could lock a source folder or a target
874874
// parent in the window between that check and this transaction. Re-check
875-
// both inside the transaction (joining `tx` so the read is part of the
876-
// same atomic unit as the writes below) before applying anything.
875+
// inside the transaction (joining `tx` so the read is part of the same
876+
// atomic unit as the writes below) before applying anything.
877877
//
878-
// assertFolderMutable now row-locks (FOR UPDATE) every folder it walks, so two
879-
// concurrent reorder batches touching an overlapping set of folders must acquire
880-
// those locks in the same order or they can deadlock. Sorting both id lists into
881-
// a single deterministic order before locking guarantees that.
882-
const lockOrder = Array.from(
883-
new Set([...validUpdates.map((u) => u.id), ...targetParentIds])
884-
).sort()
885-
for (const id of lockOrder) {
886-
await assertFolderMutable(id, resourceType, tx)
878+
// A batch can touch several independent folders (not one linear ancestor
879+
// chain), so locking each one's ancestor chain separately -- even in a
880+
// consistently sorted order of the *starting* ids -- can still deadlock
881+
// against a concurrent batch: each chain walk acquires its own rows one at a
882+
// time, and two batches whose chains interleave differently can each end up
883+
// holding a row the other needs. The only way to make batch-vs-batch lock
884+
// acquisition safe is to compute the full set of rows that need locking
885+
// up front and lock all of them in a single statement, ordered by id --
886+
// concurrent transactions that do the same always converge on the same
887+
// acquisition order and cannot form a cycle.
888+
const startIds = Array.from(new Set([...validUpdates.map((u) => u.id), ...targetParentIds]))
889+
const closure = new Set(startIds)
890+
let frontier = new Set(startIds)
891+
while (frontier.size > 0) {
892+
const rows = await tx
893+
.select({ id: folderTable.id, parentId: folderTable.parentId })
894+
.from(folderTable)
895+
.where(
896+
and(
897+
inArray(folderTable.id, Array.from(frontier)),
898+
eq(folderTable.resourceType, resourceType),
899+
isNull(folderTable.deletedAt)
900+
)
901+
)
902+
const nextFrontier = new Set<string>()
903+
for (const row of rows) {
904+
if (row.parentId && !closure.has(row.parentId)) {
905+
closure.add(row.parentId)
906+
nextFrontier.add(row.parentId)
907+
}
908+
}
909+
frontier = nextFrontier
910+
}
911+
912+
const lockedRows = await tx
913+
.select({ id: folderTable.id, locked: folderTable.locked })
914+
.from(folderTable)
915+
.where(
916+
and(
917+
inArray(folderTable.id, Array.from(closure).sort()),
918+
eq(folderTable.resourceType, resourceType)
919+
)
920+
)
921+
.orderBy(folderTable.id)
922+
.for('update')
923+
924+
if (lockedRows.some((row) => row.locked)) {
925+
throw new ResourceLockedError(resourceType, false, 'Folder is locked')
887926
}
888927

889928
for (const update of validUpdates) {

0 commit comments

Comments
 (0)