Skip to content

Commit 0976caf

Browse files
committed
fix(folders): recheck cycle-freedom inside the reorder transaction
Greptile P1: the route's circular-reference check reads an unlocked snapshot of the folder tree before the transaction opens. Two concurrent reorder requests can each move one end of what becomes an A<->B cycle -- each request's own cycle check only sees its own proposed edge merged with a stale snapshot of the other folder's parentId, so both can pass independently and, once both commit, leave a genuine cycle in the tree that the write path never re-validates. A cycle can only newly appear through an edge this batch is writing, and every folder whose parentId is being changed is already a `startId` that the closure-lock query (from an earlier round's deadlock fix) walks and locks. So the fix reuses that same locked, transaction-consistent data: after acquiring FOR UPDATE on the closure, build a parentId map from the locked rows (overridden by this batch's own proposed new parentIds) and re-run the identical cycle-walk the route already does, but against data that can no longer change out from under it. A cycle found here now rolls back the whole transaction with the same 400/'Cannot create circular folder reference' response the pre-check already returns for the same-request case. Added a regression test simulating the race: the route's pre-check snapshot shows the target parent's parentId as still null, while the locked read inside the transaction reflects a concurrent request that already committed the other half of the cycle. Full local verification passed (typecheck, full vitest suite 11437 tests, check:api-validation, lint all clean).
1 parent 8ac1ab6 commit 0976caf

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

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

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

429+
it('rejects the write when a concurrent request created a cycle between the pre-check and the transaction', async () => {
430+
// Regression test: the route's own cycle check reads an unlocked snapshot of
431+
// the folder tree before this transaction opens. Two concurrent requests can
432+
// each move one end of what becomes an A<->B cycle, both passing their own
433+
// (mutually stale) check -- simulated here by having the pre-check snapshot
434+
// show B's parentId as still null (stale), while the locked, tx-consistent
435+
// read inside the transaction reflects a concurrent request that already
436+
// committed B.parentId = A.
437+
mockWhere
438+
.mockReturnValueOnce([{ id: 'A', workspaceId: 'workspace-123', resourceType: 'workflow' }])
439+
.mockReturnValueOnce([
440+
{ id: 'A', parentId: null },
441+
{ id: 'B', parentId: null },
442+
])
443+
.mockReturnValueOnce([{ id: 'A', workspaceId: 'workspace-123' }])
444+
.mockReturnValueOnce({ limit: mockLimit })
445+
mockLimit.mockReturnValueOnce([
446+
{ workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null },
447+
])
448+
449+
queueTxSelectResults(
450+
[
451+
{ id: 'A', parentId: null },
452+
{ id: 'B', parentId: null },
453+
], // closure walk, level 1
454+
[
455+
{ id: 'A', parentId: null, locked: false, deletedAt: null },
456+
{ id: 'B', parentId: 'A', locked: false, deletedAt: null }, // committed by a concurrent request
457+
]
458+
)
459+
460+
const req = createMockRequest('PUT', {
461+
workspaceId: 'workspace-123',
462+
updates: [{ id: 'A', sortOrder: 0, parentId: 'B' }],
463+
})
464+
465+
const response = await PUT(req)
466+
467+
expect(response.status).toBe(400)
468+
const data = await response.json()
469+
expect(data.error).toBe('Cannot create circular folder reference')
470+
expect(mockTxUpdate).not.toHaveBeenCalled()
471+
})
472+
429473
it('rejects a batch that would form a cycle', async () => {
430474
mockWhere
431475
.mockReturnValueOnce([

apps/sim/lib/folders/orchestration.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,8 @@ export async function performRestoreFolder(
841841

842842
/** Marks a concurrent-deletion race caught inside the reorder transaction as a 404, not a 500. */
843843
class FolderReorderNotFoundError extends Error {}
844+
/** Marks a cycle caught inside the reorder transaction as a 400, not a 500. */
845+
class FolderReorderCycleError extends Error {}
844846

845847
export async function performReorderFolders(params: PerformReorderFoldersParams): Promise<{
846848
success: boolean
@@ -954,6 +956,7 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
954956
const lockedRows = await tx
955957
.select({
956958
id: folderTable.id,
959+
parentId: folderTable.parentId,
957960
locked: folderTable.locked,
958961
deletedAt: folderTable.deletedAt,
959962
})
@@ -982,6 +985,32 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
982985
throw new ResourceLockedError(resourceType, false, 'Folder is locked')
983986
}
984987

988+
// The route's own cycle check reads an unlocked snapshot of the folder tree
989+
// before this transaction opens -- two concurrent reorder requests can each
990+
// move one end of what becomes an A<->B cycle, both passing their own
991+
// (mutually stale) check. A cycle can only newly appear through an edge this
992+
// batch is writing, and every folder whose parentId this batch changes is a
993+
// `startId`, so re-walking from every `startId` using this transaction's own
994+
// locked, tx-consistent `parentId` values (closure already contains every
995+
// node such a walk could reach) reliably catches it before the write below.
996+
const lockedParentById = new Map(lockedRows.map((row) => [row.id, row.parentId]))
997+
for (const update of validUpdates) {
998+
if (update.parentId !== undefined) {
999+
lockedParentById.set(update.id, update.parentId || null)
1000+
}
1001+
}
1002+
for (const update of validUpdates) {
1003+
const visited = new Set<string>()
1004+
let cursor: string | null = update.id
1005+
while (cursor) {
1006+
if (visited.has(cursor)) {
1007+
throw new FolderReorderCycleError('Cannot create circular folder reference')
1008+
}
1009+
visited.add(cursor)
1010+
cursor = lockedParentById.get(cursor) ?? null
1011+
}
1012+
}
1013+
9851014
for (const update of validUpdates) {
9861015
const updateData: Record<string, unknown> = {
9871016
sortOrder: update.sortOrder,
@@ -1012,6 +1041,9 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
10121041
if (error instanceof FolderReorderNotFoundError) {
10131042
return { success: false, updated: 0, error: error.message, errorCode: 'not_found' }
10141043
}
1044+
if (error instanceof FolderReorderCycleError) {
1045+
return { success: false, updated: 0, error: error.message, errorCode: 'validation' }
1046+
}
10151047
// An unexpected DB/transaction failure, not a client-caused validation error --
10161048
// log the real cause but don't leak internal error details to the response.
10171049
logger.error('Unexpected error reordering folders', { error })

0 commit comments

Comments
 (0)