Skip to content

Commit 411570a

Browse files
committed
fix(folders): close parent-delete race in restore, fix file-folder errorCodes
Two more findings this round: 1. Greptile P1: performRestoreFolder (workflow) and performRestoreResourceFolder (kb/table) both resolve the target parent by reading its deletedAt inside the transaction, but that read was a plain SELECT, not SELECT ... FOR UPDATE. A parent could still be soft-deleted by a concurrent transaction between that read and the restore write below it, leaving the restored folder pointing at a now-deleted parent. Added FOR UPDATE to both reads. Also closed the same class of gap in performReorderFolders: the target-parent active check ran as a separate, unlocked pre-check before the closure-based lock query introduced last round. Folded it into the closure-lock query instead of adding a second incremental lock -- the closure query now also selects deletedAt and re-validates every target parent resolved to an active row only after FOR UPDATE is held on it, which is the only point where the check is actually race-free (a row's deletedAt can't change while the lock is held). A second, separately-ordered lock acquisition here would have reintroduced the exact deadlock class fixed last round. 2. Cursor: performUpdateFileFolder only recognized 'Folder not found' from updateWorkspaceFileFolder's thrown errors; the other three messages it can throw ('Folder cannot be its own parent', 'Target folder not found', 'Cannot move a folder into one of its descendants') fell through to the generic errorCode: 'internal' bucket, so the folder PUT route (now using statusForOrchestrationError) returned 500 for what are actually validation failures. Mapped all three to errorCode: 'validation'. Found and fixed the same gap in performCreateFileFolder, which had an identical unhandled 'Target folder not found' case from createWorkspaceFileFolder. Added a regression test for the reorder parent-delete race. Full local verification passed (typecheck, full vitest suite 11432 tests, check:api-validation, lint all clean).
1 parent b4a55a2 commit 411570a

3 files changed

Lines changed: 91 additions & 18 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,49 @@ describe('PUT /api/folders/reorder', () => {
383383
expect(mockTxUpdate).not.toHaveBeenCalled()
384384
})
385385

386+
it('rejects the write when the target parent is concurrently soft-deleted', async () => {
387+
// Regression test: assertFolderParentValid only validates the target parent at
388+
// request-validation time, using the default (non-tx) db client -- a parent
389+
// soft-deleted after that read but before (or during) this transaction must
390+
// still be caught. The closure-lock query is the only point where this is
391+
// race-free: once FOR UPDATE is held on the parent's row, its deletedAt state
392+
// can't change until this transaction resolves, so that's where the final
393+
// active check must happen -- not an earlier, unlocked pre-check.
394+
mockWhere
395+
.mockReturnValueOnce([
396+
{ id: 'folder-1', workspaceId: 'workspace-123', resourceType: 'workflow' },
397+
])
398+
.mockReturnValueOnce([])
399+
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
400+
.mockReturnValueOnce({ limit: mockLimit })
401+
mockLimit.mockReturnValueOnce([
402+
{ workspaceId: 'workspace-123', resourceType: 'workflow', deletedAt: null },
403+
])
404+
405+
queueTxSelectResults(
406+
[
407+
{ id: 'folder-1', parentId: null },
408+
{ id: 'target-1', parentId: null },
409+
], // closure walk, level 1
410+
[
411+
{ id: 'folder-1', locked: false, deletedAt: null },
412+
{ id: 'target-1', locked: false, deletedAt: new Date() }, // deleted after validation
413+
]
414+
)
415+
416+
const req = createMockRequest('PUT', {
417+
workspaceId: 'workspace-123',
418+
updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'target-1' }],
419+
})
420+
421+
const response = await PUT(req)
422+
423+
expect(response.status).toBe(404)
424+
const data = await response.json()
425+
expect(data.error).toBe('Parent folder not found')
426+
expect(mockTxUpdate).not.toHaveBeenCalled()
427+
})
428+
386429
it('rejects a batch that would form a cycle', async () => {
387430
mockWhere
388431
.mockReturnValueOnce([

apps/sim/lib/folders/orchestration.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ async function performCreateFileFolder(
183183
) {
184184
return { success: false, error: toError(error).message, errorCode: 'conflict' }
185185
}
186+
if (toError(error).message === 'Target folder not found') {
187+
return { success: false, error: 'Target folder not found', errorCode: 'validation' }
188+
}
186189
logger.error('Failed to create file folder', { error })
187190
return { success: false, error: 'Internal server error', errorCode: 'internal' }
188191
}
@@ -230,8 +233,16 @@ async function performUpdateFileFolder(
230233
) {
231234
return { success: false, error: toError(error).message, errorCode: 'conflict' }
232235
}
233-
if (toError(error).message === 'Folder not found') {
234-
return { success: false, error: 'Folder not found', errorCode: 'not_found' }
236+
const message = toError(error).message
237+
if (message === 'Folder not found') {
238+
return { success: false, error: message, errorCode: 'not_found' }
239+
}
240+
if (
241+
message === 'Folder cannot be its own parent' ||
242+
message === 'Target folder not found' ||
243+
message === 'Cannot move a folder into one of its descendants'
244+
) {
245+
return { success: false, error: message, errorCode: 'validation' }
235246
}
236247
logger.error('Failed to update file folder', { error })
237248
return { success: false, error: 'Internal server error', errorCode: 'internal' }
@@ -603,10 +614,16 @@ async function performRestoreResourceFolder<TCountKey extends 'knowledgeBases' |
603614
// leaving the folder orphaned under an archived parent.
604615
let resolvedParentId = raw.parentId
605616
if (resolvedParentId) {
617+
// FOR UPDATE closes the window where a concurrent transaction soft-deletes
618+
// this parent between this read and the write below -- without it, this
619+
// plain read isn't locked, so the parent could be deleted out from under us
620+
// after we've already decided it's active, restoring this folder under a
621+
// parent that no longer exists.
606622
const [parent] = await tx
607623
.select({ deletedAt: folderTable.deletedAt })
608624
.from(folderTable)
609625
.where(eq(folderTable.id, resolvedParentId))
626+
.for('update')
610627
.limit(1)
611628
if (!parent || parent.deletedAt) resolvedParentId = null
612629
}
@@ -855,20 +872,6 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
855872

856873
try {
857874
await db.transaction(async (tx) => {
858-
// Re-check each target parent is still active inside the transaction --
859-
// assertFolderParentValid above only reads at validation time, so a parent
860-
// concurrently soft-deleted before this transaction opens could otherwise
861-
// leave an active folder pointing at a deleted one.
862-
if (targetParentIds.length > 0) {
863-
const activeParents = await tx
864-
.select({ id: folderTable.id })
865-
.from(folderTable)
866-
.where(and(inArray(folderTable.id, targetParentIds), isNull(folderTable.deletedAt)))
867-
if (activeParents.length !== targetParentIds.length) {
868-
throw new FolderReorderNotFoundError('Parent folder not found')
869-
}
870-
}
871-
872875
// The route checks lock state before calling this function, but that's a
873876
// separate round-trip -- an admin could lock a source folder or a target
874877
// parent in the window between that check and this transaction. Re-check
@@ -909,8 +912,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
909912
frontier = nextFrontier
910913
}
911914

915+
// Selecting without an isNull(deletedAt) filter and re-checking it here (rather
916+
// than trusting the closure walk above, which reads before this lock is held)
917+
// is what makes this race-free: a row can still be soft-deleted by another
918+
// transaction right up until this statement acquires FOR UPDATE, but not after
919+
// -- so `deletedAt` on a just-locked row is guaranteed accurate for the rest of
920+
// this transaction.
912921
const lockedRows = await tx
913-
.select({ id: folderTable.id, locked: folderTable.locked })
922+
.select({
923+
id: folderTable.id,
924+
locked: folderTable.locked,
925+
deletedAt: folderTable.deletedAt,
926+
})
914927
.from(folderTable)
915928
.where(
916929
and(
@@ -921,7 +934,18 @@ export async function performReorderFolders(params: PerformReorderFoldersParams)
921934
.orderBy(folderTable.id)
922935
.for('update')
923936

924-
if (lockedRows.some((row) => row.locked)) {
937+
const activeLockedIds = new Set(
938+
lockedRows.filter((row) => !row.deletedAt).map((row) => row.id)
939+
)
940+
// Re-check every target parent resolved to an active row under this lock --
941+
// assertFolderParentValid only reads at validation time, so a parent
942+
// concurrently soft-deleted before (or racing) this transaction could
943+
// otherwise leave an active folder pointing at a deleted one.
944+
if (targetParentIds.some((id) => !activeLockedIds.has(id))) {
945+
throw new FolderReorderNotFoundError('Parent folder not found')
946+
}
947+
948+
if (lockedRows.some((row) => !row.deletedAt && row.locked)) {
925949
throw new ResourceLockedError(resourceType, false, 'Folder is locked')
926950
}
927951

apps/sim/lib/workflows/orchestration/folder-lifecycle.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,10 +509,16 @@ export async function performRestoreFolder(
509509

510510
let resolvedParentId = existingFolder.parentId
511511
if (resolvedParentId) {
512+
// FOR UPDATE closes the window where a concurrent transaction soft-deletes
513+
// this parent between this read and the write below -- without it, this
514+
// plain read isn't locked, so the parent could be deleted out from under us
515+
// after we've already decided it's active, restoring this folder under a
516+
// parent that no longer exists.
512517
const [parentFolder] = await tx
513518
.select({ deletedAt: folder.deletedAt })
514519
.from(folder)
515520
.where(eq(folder.id, resolvedParentId))
521+
.for('update')
516522

517523
if (!parentFolder || parentFolder.deletedAt) {
518524
resolvedParentId = null

0 commit comments

Comments
 (0)