Skip to content

Commit 085da44

Browse files
committed
fix(cleanup): re-root child folders too, and guard the hook's own reads
Two gaps a cold review found in the re-root hook: - folder.parentId is itself ON DELETE SET NULL and folder_workspace_resource_parent_name_active_unique keys on coalesce(parent_id,''), so purging a parent can collide a surviving active subfolder at the root — the identical 23505 stall. The hook covered workflows and files only while its TSDoc presented the class as closed. - the hook's own SELECTs sat outside any guard, so a transient read failure rejected onBatch and produced exactly the aborted batch it exists to prevent. Also adds the first tests for lib/cleanup: nothing verified that onBatch is forwarded through batchDeleteByWorkspaceAndTimestamp's ...rest, or that chunkedBatchDelete runs it BEFORE the delete. Every existing test invokes the captured callback directly with that module mocked, so a regression in either would have left the whole suite green.
1 parent 461ddd4 commit 085da44

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ const {
2626
mockSelectRowsByIdChunks,
2727
mockDeduplicateWorkflowName,
2828
mockAllocateUniqueWorkspaceFileName,
29+
mockDeduplicateFolderName,
2930
} = vi.hoisted(() => ({
31+
mockDeduplicateFolderName: vi.fn(async (_tx, _ws, _parent, name: string) => name),
3032
mockDeduplicateWorkflowName: vi.fn(async (name: string) => name),
3133
mockAllocateUniqueWorkspaceFileName: vi.fn(async (_ws: string, name: string) => name),
3234
mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })),
@@ -80,6 +82,8 @@ vi.mock('@/lib/workflows/utils', () => ({
8082
deduplicateWorkflowName: mockDeduplicateWorkflowName,
8183
}))
8284

85+
vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName }))
86+
8387
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
8488
allocateUniqueWorkspaceFileName: mockAllocateUniqueWorkspaceFileName,
8589
}))
@@ -433,6 +437,35 @@ describe('folder cleanup target', () => {
433437
})
434438
})
435439

440+
it('re-roots an active SUBFOLDER, which hits the same unique index', async () => {
441+
/**
442+
* `folder.parentId` is also ON DELETE SET NULL and
443+
* `folder_workspace_resource_parent_name_active_unique` keys on `coalesce(parent_id,'')`,
444+
* so purging a parent can collide a surviving child at the root exactly like a workflow
445+
* or file. Covering only those two would leave the class half-closed.
446+
*/
447+
const onBatch = await getFolderOnBatch()
448+
queueTableRows(schemaMock.folder, [{ id: 'folder-1' }])
449+
queueTableRows(schemaMock.workflow, [])
450+
queueTableRows(schemaMock.workspaceFiles, [])
451+
queueTableRows(schemaMock.folder, [
452+
{ id: 'sub-1', name: 'Reports', workspaceId: 'ws-1', resourceType: 'knowledge_base' },
453+
])
454+
mockDeduplicateFolderName.mockResolvedValueOnce('Reports (1)')
455+
456+
await onBatch([{ id: 'folder-1' }])
457+
458+
// Deduped against the ROOT of the child's OWN resourceType, which is where SET NULL lands it.
459+
expect(mockDeduplicateFolderName).toHaveBeenCalledWith(
460+
expect.anything(),
461+
'ws-1',
462+
null,
463+
'Reports',
464+
'knowledge_base'
465+
)
466+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ parentId: null, name: 'Reports (1)' })
467+
})
468+
436469
it('recovers when the allocator RETURNS a colliding name and the update raises', async () => {
437470
/**
438471
* `allocateUniqueWorkspaceFileName` fails open — `fileExistsInWorkspace` swallows query

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
selectRowsByIdChunks,
3030
} from '@/lib/cleanup/batch-delete'
3131
import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup'
32+
import { deduplicateFolderName } from '@/lib/folders/naming'
3233
import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
3334
import type { StorageContext } from '@/lib/uploads'
3435
import { isUsingCloudStorage, StorageService } from '@/lib/uploads'
@@ -488,7 +489,23 @@ async function reRootActiveFolderChildren(
488489
label: string
489490
): Promise<void> {
490491
if (folderIds.length === 0) return
492+
/**
493+
* The SELECTs below are guarded for the same reason `reRootOne` swallows: this hook rejecting
494+
* IS the aborted batch it exists to prevent. A transient read failure should leave the DELETE
495+
* to succeed or fail on its own merits, not turn into a guaranteed stall.
496+
*/
497+
try {
498+
await reRootActiveFolderChildrenUnguarded(folderIds, retentionDate, label)
499+
} catch (error) {
500+
logger.error(`[${label}] Re-rooting children of purged folders failed`, { error })
501+
}
502+
}
491503

504+
async function reRootActiveFolderChildrenUnguarded(
505+
folderIds: string[],
506+
retentionDate: Date,
507+
label: string
508+
): Promise<void> {
492509
/**
493510
* Re-asserted here, not just on the DELETE. `deleteFilter` already skips a folder restored
494511
* between the SELECT and this hook — but without the same check here, this hook would still
@@ -581,6 +598,48 @@ async function reRootActiveFolderChildren(
581598
)
582599
}
583600

601+
/**
602+
* Subfolders are exposed to exactly the same failure. `folder.parentId` is itself
603+
* `ON DELETE SET NULL`, and `folder_workspace_resource_parent_name_active_unique` keys on
604+
* `coalesce(parent_id, '')`, so purging a parent re-roots a surviving active child into a
605+
* namespace where its name may already be taken — the identical 23505 stall. Covering only
606+
* workflows and files would leave the class half-closed.
607+
*/
608+
const childFolders = await cleanupDb
609+
.select({
610+
id: folderTable.id,
611+
name: folderTable.name,
612+
workspaceId: folderTable.workspaceId,
613+
resourceType: folderTable.resourceType,
614+
})
615+
.from(folderTable)
616+
.where(and(inArray(folderTable.parentId, expiredIds), isNull(folderTable.deletedAt)))
617+
618+
for (const row of childFolders) {
619+
await reRootOne(
620+
async () => {
621+
const name = await deduplicateFolderName(
622+
cleanupDb,
623+
row.workspaceId,
624+
null,
625+
row.name,
626+
row.resourceType
627+
)
628+
await cleanupDb
629+
.update(folderTable)
630+
.set({ parentId: null, name })
631+
.where(eq(folderTable.id, row.id))
632+
},
633+
() =>
634+
cleanupDb
635+
.update(folderTable)
636+
.set({ parentId: null, name: `${row.name} (${row.id})` })
637+
.where(eq(folderTable.id, row.id)),
638+
`folder ${row.id}`,
639+
label
640+
)
641+
}
642+
584643
if (workflows.length > 0 || files.length > 0) {
585644
logger.warn(
586645
`[${label}] Re-rooted ${workflows.length} workflow(s) and ${files.length} file(s) out of folders being purged`
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { schemaMock } from '@sim/testing'
6+
import { describe, expect, it, vi } from 'vitest'
7+
import { batchDeleteByWorkspaceAndTimestamp, chunkedBatchDelete } from '@/lib/cleanup/batch-delete'
8+
9+
/**
10+
* Minimal stand-in for the drizzle client `chunkedBatchDelete` calls. Only the DELETE path is
11+
* modelled — the SELECT arrives through the caller-supplied `selectChunk`.
12+
*/
13+
function createDbClient(onDelete: () => void, selectRows: Array<{ id: string }> = []) {
14+
return {
15+
// `batchDeleteByWorkspaceAndTimestamp` builds its own selectChunk on this client.
16+
select: () => ({
17+
from: () => ({ where: () => ({ limit: async () => selectRows }) }),
18+
}),
19+
delete: () => {
20+
onDelete()
21+
return { where: () => ({ returning: async () => [{ id: 'row-1' }] }) }
22+
},
23+
} as never
24+
}
25+
26+
/**
27+
* These two assertions guard the single link that makes the folder re-root hook live. Every
28+
* test in `cleanup-soft-deletes.test.ts` invokes the captured `onBatch` directly with
29+
* `batchDeleteByWorkspaceAndTimestamp` mocked out, so a regression that stopped forwarding the
30+
* hook — or ran it after the DELETE — would leave that whole suite green.
31+
*/
32+
describe('chunkedBatchDelete onBatch contract', () => {
33+
it('runs onBatch BEFORE the delete for the same rows', async () => {
34+
const order: string[] = []
35+
const onBatch = vi.fn(async (rows: Array<{ id: string }>) => {
36+
order.push(`onBatch:${rows.map((r) => r.id).join(',')}`)
37+
})
38+
39+
await chunkedBatchDelete({
40+
tableDef: schemaMock.folder as never,
41+
workspaceIds: ['ws-1'],
42+
tableName: 'test/folder',
43+
dbClient: createDbClient(() => order.push('delete')),
44+
selectChunk: async () => [{ id: 'row-1' }],
45+
onBatch,
46+
batchSize: 1,
47+
maxBatches: 1,
48+
totalRowLimit: 1,
49+
})
50+
51+
expect(onBatch).toHaveBeenCalledWith([{ id: 'row-1' }])
52+
// Ordering is the load-bearing half: renaming children after the DELETE would be useless.
53+
expect(order).toEqual(['onBatch:row-1', 'delete'])
54+
})
55+
56+
it('forwards onBatch through batchDeleteByWorkspaceAndTimestamp', async () => {
57+
const order: string[] = []
58+
const onBatch = vi.fn(async () => {
59+
order.push('onBatch')
60+
})
61+
62+
await batchDeleteByWorkspaceAndTimestamp({
63+
tableDef: schemaMock.folder as never,
64+
workspaceIdCol: schemaMock.folder.workspaceId as never,
65+
timestampCol: schemaMock.folder.deletedAt as never,
66+
workspaceIds: ['ws-1'],
67+
retentionDate: new Date(0),
68+
tableName: 'test/folder',
69+
requireTimestampNotNull: true,
70+
dbClient: createDbClient(() => order.push('delete'), [{ id: 'row-1' }]) as never,
71+
onBatch,
72+
batchSize: 1,
73+
maxBatches: 1,
74+
})
75+
76+
// The wrapper spreads `...rest` into chunkedBatchDelete; `onBatch` must survive that hop.
77+
expect(onBatch).toHaveBeenCalled()
78+
expect(order[0]).toBe('onBatch')
79+
})
80+
})

0 commit comments

Comments
 (0)