Skip to content

Commit 20c0105

Browse files
committed
fix(cleanup): treat the deduplicated re-root name as a hint, not a guarantee
allocateUniqueWorkspaceFileName fails OPEN — fileExistsInWorkspace swallows query errors and returns false — so it can return a name already taken at the workspace root. Only the UPDATE discovers that, and the previous guard wrapped just the name lookup, so the resulting 23505 escaped and aborted the batch: the same permanent retention stall this hook exists to prevent. Both re-root paths now retry with an id-suffixed name, which cannot collide, and a failure of that retry is logged rather than thrown so one unfixable row does not stop the rest of the batch being made safe.
1 parent 91e8336 commit 20c0105

2 files changed

Lines changed: 102 additions & 19 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,32 @@ describe('folder cleanup target', () => {
433433
})
434434
})
435435

436+
it('recovers when the allocator RETURNS a colliding name and the update raises', async () => {
437+
/**
438+
* `allocateUniqueWorkspaceFileName` fails open — `fileExistsInWorkspace` swallows query
439+
* errors and returns false — so it can hand back a name already taken at the root. Only
440+
* the UPDATE discovers that, and an uncaught 23505 aborts the batch: the exact stall this
441+
* hook prevents. Guarding the name lookup alone is not enough.
442+
*/
443+
const onBatch = await getFolderOnBatch()
444+
queueTableRows(schemaMock.folder, [{ id: 'folder-1' }])
445+
queueTableRows(schemaMock.workflow, [])
446+
queueTableRows(schemaMock.workspaceFiles, [
447+
{ id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' },
448+
])
449+
mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('taken.pdf')
450+
dbChainMockFns.update.mockImplementationOnce(() => ({
451+
set: () => ({ where: () => Promise.reject(new Error('duplicate key value (23505)')) }),
452+
}))
453+
454+
await expect(onBatch([{ id: 'folder-1' }])).resolves.toBeUndefined()
455+
456+
expect(dbChainMockFns.set).toHaveBeenCalledWith({
457+
folderId: null,
458+
originalName: 'report.pdf (f1)',
459+
})
460+
})
461+
436462
it('leaves children alone when the folder was restored between select and onBatch', async () => {
437463
/**
438464
* The DELETE re-asserts eligibility and so correctly skips a restored folder. Without the

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

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,42 @@ interface CleanupBatchContext {
432432
label: string
433433
}
434434

435+
/**
436+
* Applies one re-root, treating the deduplicated name as a HINT rather than a guarantee.
437+
*
438+
* Both name allocators can hand back a name that is already taken: `fileExistsInWorkspace`
439+
* swallows query errors and returns `false`, so `allocateUniqueWorkspaceFileName` fails OPEN,
440+
* and `deduplicateWorkflowName`'s lookups can throw outright. Either way the UPDATE raises
441+
* 23505, and an uncaught 23505 here aborts the batch — precisely the permanent retention stall
442+
* this whole hook exists to prevent. So any failure retries with the row id, which is unique by
443+
* construction and cannot collide.
444+
*
445+
* A failure of that retry is swallowed too: one unfixable row must not stop the other children
446+
* from being made safe. It is logged at error level because the folder's DELETE can then still
447+
* stall on that row via the FK's SET NULL.
448+
*/
449+
async function reRootOne(
450+
preferred: () => Promise<unknown>,
451+
withUniqueName: () => Promise<unknown>,
452+
subject: string,
453+
label: string
454+
): Promise<void> {
455+
try {
456+
await preferred()
457+
return
458+
} catch (error) {
459+
logger.warn(`[${label}] Re-rooting ${subject} under its deduplicated name failed; retrying`, {
460+
error,
461+
})
462+
}
463+
464+
try {
465+
await withUniqueName()
466+
} catch (error) {
467+
logger.error(`[${label}] Could not re-root ${subject}; its folder delete may stall`, { error })
468+
}
469+
}
470+
435471
/**
436472
* Re-roots any still-active workflow or workspace file filed under a folder that is about to be
437473
* hard-deleted, giving it a collision-free name first.
@@ -485,9 +521,24 @@ async function reRootActiveFolderChildren(
485521
.where(and(inArray(workflow.folderId, expiredIds), isNull(workflow.archivedAt)))
486522

487523
for (const row of workflows) {
488-
if (!row.workspaceId) continue
489-
const name = await deduplicateWorkflowName(row.name, row.workspaceId, null, cleanupDb)
490-
await cleanupDb.update(workflow).set({ folderId: null, name }).where(eq(workflow.id, row.id))
524+
const workspaceId = row.workspaceId
525+
if (!workspaceId) continue
526+
await reRootOne(
527+
async () => {
528+
const name = await deduplicateWorkflowName(row.name, workspaceId, null, cleanupDb)
529+
await cleanupDb
530+
.update(workflow)
531+
.set({ folderId: null, name })
532+
.where(eq(workflow.id, row.id))
533+
},
534+
() =>
535+
cleanupDb
536+
.update(workflow)
537+
.set({ folderId: null, name: `${row.name} (${row.id})` })
538+
.where(eq(workflow.id, row.id)),
539+
`workflow ${row.id}`,
540+
label
541+
)
491542
}
492543

493544
const files = await cleanupDb
@@ -506,22 +557,28 @@ async function reRootActiveFolderChildren(
506557
)
507558

508559
for (const row of files) {
509-
if (!row.workspaceId) continue
510-
/**
511-
* `allocateUniqueWorkspaceFileName` throws once the copy-suffix range is exhausted. Falling
512-
* back to the row id keeps the sweep running on a name that cannot collide — the opposite
513-
* failure (letting it throw) is the stall this function exists to prevent.
514-
*/
515-
let originalName: string
516-
try {
517-
originalName = await allocateUniqueWorkspaceFileName(row.workspaceId, row.originalName, null)
518-
} catch {
519-
originalName = `${row.originalName} (${row.id})`
520-
}
521-
await cleanupDb
522-
.update(workspaceFiles)
523-
.set({ folderId: null, originalName })
524-
.where(eq(workspaceFiles.id, row.id))
560+
const workspaceId = row.workspaceId
561+
if (!workspaceId) continue
562+
await reRootOne(
563+
async () => {
564+
const originalName = await allocateUniqueWorkspaceFileName(
565+
workspaceId,
566+
row.originalName,
567+
null
568+
)
569+
await cleanupDb
570+
.update(workspaceFiles)
571+
.set({ folderId: null, originalName })
572+
.where(eq(workspaceFiles.id, row.id))
573+
},
574+
() =>
575+
cleanupDb
576+
.update(workspaceFiles)
577+
.set({ folderId: null, originalName: `${row.originalName} (${row.id})` })
578+
.where(eq(workspaceFiles.id, row.id)),
579+
`workspace file ${row.id}`,
580+
label
581+
)
525582
}
526583

527584
if (workflows.length > 0 || files.length > 0) {

0 commit comments

Comments
 (0)