Skip to content

Commit 93c6995

Browse files
committed
fix(folders): fail safe when the reorder closure goes stale under lock
Greptile P1: the cycle-check fix's closure walk discovers ancestors with plain reads before the FOR UPDATE lock is acquired. If a concurrent transaction reparents one of those discovered members to point outside the closure in that gap, the locked read reveals a parentId the cycle walk has no data for. The walk's `?? null` fallback treated "not in closure" the same as "reached root", so it could silently stop short of a real cycle instead of detecting it -- e.g. A moves under B, the locked read shows a concurrently-committed B -> C, and C already points back to A; since C was never discovered by the pre-lock walk, the walk incorrectly terminates at B instead of continuing to C and finding the cycle back to A. A fully general fix would need to lock-and-expand the closure in rounds until it's provably closed under FOR UPDATE, which reintroduces real deadlock-ordering complexity. Took the simpler, still-correct path instead: after acquiring the lock, verify every locked row's parentId is either null or already a member of the locked closure. If not, the closure went stale between discovery and locking -- fail the whole batch (errorCode: conflict, 409) rather than risk running the cycle walk on data it can't fully vouch for. The client can retry, and a second attempt's closure walk will see the now-settled state. Added a regression test reproducing the exact scenario Greptile described. Full local verification passed (typecheck, full vitest suite 11438 tests, check:api-validation, lint all clean, 303 tests across the folders/locking surface).
1 parent 9f28f57 commit 93c6995

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,51 @@ describe('PUT /api/folders/reorder', () => {
426426
expect(mockTxUpdate).not.toHaveBeenCalled()
427427
})
428428

429+
it('rejects the write when a locked row points outside the discovered closure', async () => {
430+
// Regression test: the ancestor-closure walk discovers members with PLAIN reads
431+
// before the FOR UPDATE lock is acquired. If a concurrent request reparents one
432+
// of those discovered members (target-1 here) to point at a folder outside the
433+
// closure in that gap, the locked read reveals a parentId the cycle walk has no
434+
// data for -- silently treating "not in closure" as root could let a real cycle
435+
// through undetected. The fix fails the whole batch safely instead.
436+
mockWhere
437+
.mockReturnValueOnce([
438+
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
439+
])
440+
.mockReturnValueOnce([{ id: 'folder-1', parentId: null }])
441+
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
442+
.mockReturnValueOnce({ limit: mockLimit })
443+
mockLimit.mockReturnValueOnce([
444+
{ workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null },
445+
])
446+
447+
queueTxSelectResults(
448+
// closure walk, level 1: both starting ids resolve as roots (no further
449+
// ancestors) at discovery time -- the walk correctly stops here.
450+
[
451+
{ id: 'folder-1', parentId: null },
452+
{ id: 'target-1', parentId: null },
453+
],
454+
[
455+
{ id: 'folder-1', locked: false, deletedAt: null },
456+
// Concurrently reparented to a folder never discovered by the walk above.
457+
{ id: 'target-1', locked: false, deletedAt: null, parentId: 'outside-closure' },
458+
]
459+
)
460+
461+
const req = createMockRequest('PUT', {
462+
workspaceId: 'workspace-123',
463+
updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'target-1' }],
464+
})
465+
466+
const response = await PUT(req)
467+
468+
expect(response.status).toBe(409)
469+
const data = await response.json()
470+
expect(data.error).toBe('Folder tree changed concurrently, please retry')
471+
expect(mockTxUpdate).not.toHaveBeenCalled()
472+
})
473+
429474
it('rejects the write when a concurrent request created a cycle between the pre-check and the transaction', async () => {
430475
// Regression test: the route's own cycle check reads an unlocked snapshot of
431476
// the folder tree before this transaction opens. Two concurrent requests can

apps/sim/lib/folders/orchestration.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,8 @@ class FolderParentNotFoundError extends Error {}
936936
class FolderReorderNotFoundError extends Error {}
937937
/** Marks a cycle caught inside the reorder transaction as a 400, not a 500. */
938938
class FolderReorderCycleError extends Error {}
939+
/** Marks a closure that went stale between discovery and locking, caught inside the reorder transaction, as a 409, not a 500. */
940+
class FolderReorderStaleClosureError extends Error {}
939941

940942
export async function performReorderFolders(params: PerformReorderFoldersParams): Promise<{
941943
success: boolean
@@ -1078,6 +1080,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
10781080
throw new ResourceLockedError(resourceType, false, 'Folder is locked')
10791081
}
10801082

1083+
// The ancestor-closure walk above discovers members with PLAIN reads before
1084+
// this FOR UPDATE lock is acquired -- a concurrent transaction could reparent
1085+
// one of those ancestors to point outside the discovered closure in that gap.
1086+
// If so, the locked read below reveals a parentId the cycle walk has no data
1087+
// for, and treating "not in closure" as "reached root" (as `?? null` would)
1088+
// could silently let a real cycle through undetected. Fail the whole batch
1089+
// safely -- the client can retry -- rather than risk that.
1090+
const closureIds = new Set(lockedRows.map((row) => row.id))
1091+
if (lockedRows.some((row) => row.parentId && !closureIds.has(row.parentId))) {
1092+
throw new FolderReorderStaleClosureError('Folder tree changed concurrently, please retry')
1093+
}
1094+
10811095
// The route's own cycle check reads an unlocked snapshot of the folder tree
10821096
// before this transaction opens -- two concurrent reorder requests can each
10831097
// move one end of what becomes an A<->B cycle, both passing their own
@@ -1137,6 +1151,9 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
11371151
if (error instanceof FolderReorderCycleError) {
11381152
return { success: false, updated: 0, error: error.message, errorCode: 'validation' }
11391153
}
1154+
if (error instanceof FolderReorderStaleClosureError) {
1155+
return { success: false, updated: 0, error: error.message, errorCode: 'conflict' }
1156+
}
11401157
// An unexpected DB/transaction failure, not a client-caused validation error --
11411158
// log the real cause but don't leak internal error details to the response.
11421159
logger.error('Unexpected error reordering folders', { error })

0 commit comments

Comments
 (0)