Skip to content

Commit 91e8336

Browse files
committed
fix(cleanup): re-assert folder eligibility inside the re-root hook
The hook runs before the DELETE, which re-asserts eligibility and so correctly skips a folder restored mid-batch. Without the same check in the hook, a restore landing between the SELECT and onBatch left the folder alive with its children stripped to the workspace root and renamed. Every other side effect in this sweep only touches rows on their way out; this one mutates rows that survive, so it is the one place losing that race is visible to the user. Narrows the window to match the DELETE's own re-assertion rather than closing it, which would need a row lock nothing here holds.
1 parent 345278b commit 91e8336

2 files changed

Lines changed: 73 additions & 6 deletions

File tree

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ describe('folder cleanup target', () => {
380380

381381
it('re-roots an active workflow under a deduplicated name', async () => {
382382
const onBatch = await getFolderOnBatch()
383+
queueTableRows(schemaMock.folder, [{ id: 'folder-1' }])
383384
queueTableRows(schemaMock.workflow, [{ id: 'w1', name: 'Report', workspaceId: 'ws-1' }])
384385
queueTableRows(schemaMock.workspaceFiles, [])
385386
mockDeduplicateWorkflowName.mockResolvedValueOnce('Report (2)')
@@ -398,6 +399,7 @@ describe('folder cleanup target', () => {
398399

399400
it('re-roots an active workspace file under a deduplicated name', async () => {
400401
const onBatch = await getFolderOnBatch()
402+
queueTableRows(schemaMock.folder, [{ id: 'folder-1' }])
401403
queueTableRows(schemaMock.workflow, [])
402404
queueTableRows(schemaMock.workspaceFiles, [
403405
{ id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' },
@@ -416,6 +418,7 @@ describe('folder cleanup target', () => {
416418
it('falls back to an id-suffixed name when the copy-suffix range is exhausted', async () => {
417419
// Letting the allocator throw would abort the sweep — the exact stall this guards against.
418420
const onBatch = await getFolderOnBatch()
421+
queueTableRows(schemaMock.folder, [{ id: 'folder-1' }])
419422
queueTableRows(schemaMock.workflow, [])
420423
queueTableRows(schemaMock.workspaceFiles, [
421424
{ id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' },
@@ -430,6 +433,30 @@ describe('folder cleanup target', () => {
430433
})
431434
})
432435

436+
it('leaves children alone when the folder was restored between select and onBatch', async () => {
437+
/**
438+
* The DELETE re-asserts eligibility and so correctly skips a restored folder. Without the
439+
* same re-assertion here, this hook would still strip and rename that folder's children —
440+
* leaving a live folder emptied out. This is the one side effect in the sweep that mutates
441+
* rows which survive, so losing the race is user-visible.
442+
*/
443+
const onBatch = await getFolderOnBatch()
444+
queueTableRows(schemaMock.folder, []) // restored: no longer soft-deleted past retention
445+
// Children ARE queued: without the eligibility re-check these would be re-rooted and
446+
// renamed, so the assertions below fail rather than passing for want of candidate rows.
447+
queueTableRows(schemaMock.workflow, [{ id: 'w1', name: 'Report', workspaceId: 'ws-1' }])
448+
queueTableRows(schemaMock.workspaceFiles, [
449+
{ id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' },
450+
])
451+
dbChainMockFns.update.mockClear()
452+
453+
await onBatch([{ id: 'folder-1' }])
454+
455+
expect(mockDeduplicateWorkflowName).not.toHaveBeenCalled()
456+
expect(mockAllocateUniqueWorkspaceFileName).not.toHaveBeenCalled()
457+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
458+
})
459+
433460
it('touches nothing when the batch is empty', async () => {
434461
const onBatch = await getFolderOnBatch()
435462
dbChainMockFns.select.mockClear()

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

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,12 @@ async function cleanupExpiredKnowledgeBases(
426426
* workspace files → S3 storage) are handled explicitly so the SELECT that drives
427427
* the external cleanup and the SELECT that drives the DB delete see the same rows.
428428
*/
429+
/** Per-run values an `onBatch` hook needs but `CLEANUP_TARGETS` cannot know at module scope. */
430+
interface CleanupBatchContext {
431+
retentionDate: Date
432+
label: string
433+
}
434+
429435
/**
430436
* Re-roots any still-active workflow or workspace file filed under a folder that is about to be
431437
* hard-deleted, giving it a collision-free name first.
@@ -440,13 +446,43 @@ async function cleanupExpiredKnowledgeBases(
440446
* An active child inside a soft-deleted folder is already an anomaly — the delete cascade
441447
* archives children — so this normally selects nothing, which is why the per-row loop is fine.
442448
*/
443-
async function reRootActiveFolderChildren(folderIds: string[], label: string): Promise<void> {
449+
async function reRootActiveFolderChildren(
450+
folderIds: string[],
451+
retentionDate: Date,
452+
label: string
453+
): Promise<void> {
444454
if (folderIds.length === 0) return
445455

456+
/**
457+
* Re-asserted here, not just on the DELETE. `deleteFilter` already skips a folder restored
458+
* between the SELECT and this hook — but without the same check here, this hook would still
459+
* strip and rename the children of a folder that then survives, leaving a live folder
460+
* emptied out. Every other side effect in this sweep only touches rows that are on their way
461+
* out; this one mutates rows that stay, so it is the one place where losing that race is
462+
* visible to the user.
463+
*
464+
* This narrows the window to match the DELETE's own re-assertion rather than closing it.
465+
* Closing it properly means holding a row lock across select → onBatch → delete, which
466+
* nothing in this sweep does today.
467+
*/
468+
const stillExpired = await cleanupDb
469+
.select({ id: folderTable.id })
470+
.from(folderTable)
471+
.where(
472+
and(
473+
inArray(folderTable.id, folderIds),
474+
isNotNull(folderTable.deletedAt),
475+
lt(folderTable.deletedAt, retentionDate)
476+
)
477+
)
478+
479+
const expiredIds = stillExpired.map(({ id }) => id)
480+
if (expiredIds.length === 0) return
481+
446482
const workflows = await cleanupDb
447483
.select({ id: workflow.id, name: workflow.name, workspaceId: workflow.workspaceId })
448484
.from(workflow)
449-
.where(and(inArray(workflow.folderId, folderIds), isNull(workflow.archivedAt)))
485+
.where(and(inArray(workflow.folderId, expiredIds), isNull(workflow.archivedAt)))
450486

451487
for (const row of workflows) {
452488
if (!row.workspaceId) continue
@@ -463,7 +499,7 @@ async function reRootActiveFolderChildren(folderIds: string[], label: string): P
463499
.from(workspaceFiles)
464500
.where(
465501
and(
466-
inArray(workspaceFiles.folderId, folderIds),
502+
inArray(workspaceFiles.folderId, expiredIds),
467503
isNull(workspaceFiles.deletedAt),
468504
eq(workspaceFiles.context, 'workspace')
469505
)
@@ -513,10 +549,11 @@ const CLEANUP_TARGETS = [
513549
'knowledge_base',
514550
'table',
515551
]),
516-
onBatch: (rows: { id: string }[]) =>
552+
onBatch: (rows: { id: string }[], ctx: CleanupBatchContext) =>
517553
reRootActiveFolderChildren(
518554
rows.map(({ id }) => id),
519-
'cleanup'
555+
ctx.retentionDate,
556+
ctx.label
520557
),
521558
name: 'folder',
522559
},
@@ -778,7 +815,10 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise
778815
tableName: `${label}/${target.name}`,
779816
requireTimestampNotNull: true,
780817
additionalPredicate: 'additionalPredicate' in target ? target.additionalPredicate : undefined,
781-
onBatch: 'onBatch' in target ? target.onBatch : undefined,
818+
onBatch:
819+
'onBatch' in target
820+
? (rows: { id: string }[]) => target.onBatch(rows, { retentionDate, label })
821+
: undefined,
782822
dbClient: cleanupDb,
783823
})
784824
totalDeleted += result.deleted

0 commit comments

Comments
 (0)