Skip to content

Commit a18eec1

Browse files
fix(cleanup): persist storage retries and complete test fixtures
1 parent af6598d commit a18eec1

13 files changed

Lines changed: 302 additions & 42 deletions

File tree

apps/sim/app/api/webhooks/outbox/process/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-own
99
import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning'
1010
import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation'
1111
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
12+
import { retentionStorageOutboxHandlers } from '@/lib/cleanup/storage-outbox'
1213
import { processOutboxEvents } from '@/lib/core/outbox/service'
1314
import { DeadlineExceededError } from '@/lib/core/utils/deadline'
1415
import { generateRequestId } from '@/lib/core/utils/request'
@@ -33,6 +34,7 @@ export const dynamic = 'force-dynamic'
3334
export const maxDuration = 800
3435

3536
const handlers = {
37+
...retentionStorageOutboxHandlers,
3638
...slackSearchOutboxHandlers,
3739
...adminInvitationOperationOutboxHandlers,
3840
...adminMemberOperationOutboxHandlers,

apps/sim/background/cleanup-bounded.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
schemaMock,
88
} from '@sim/testing'
99
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
import type { OutboxHandlerRegistry } from '@/lib/core/outbox/service'
1011

1112
const { storage, prepareChat, executeChat, hardDelete, billing, decrement, reRoot } = vi.hoisted(
1213
() => ({
@@ -19,6 +20,35 @@ const { storage, prepareChat, executeChat, hardDelete, billing, decrement, reRoo
1920
reRoot: vi.fn(),
2021
})
2122
)
23+
const outbox = vi.hoisted(() => new Map<string, { eventType: string; payload: unknown }>())
24+
vi.mock('@/lib/core/outbox/service', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('@/lib/core/outbox/service')>()
26+
return {
27+
...actual,
28+
enqueueOutboxEvent: vi.fn(async (...args: Parameters<typeof actual.enqueueOutboxEvent>) => {
29+
const id = await actual.enqueueOutboxEvent(...args)
30+
outbox.set(id, { eventType: args[1], payload: args[2] })
31+
return id
32+
}),
33+
processOutboxEventById: vi.fn(async (id: string, handlers: OutboxHandlerRegistry) => {
34+
const event = outbox.get(id)
35+
if (!event) throw new Error('Missing test outbox event')
36+
try {
37+
await handlers[event.eventType](event.payload, {
38+
eventId: id,
39+
eventType: event.eventType,
40+
attempts: 0,
41+
maxAttempts: 48,
42+
signal: new AbortController().signal,
43+
checkpointPayload: async () => {},
44+
})
45+
return 'completed'
46+
} catch {
47+
return 'pending'
48+
}
49+
}),
50+
}
51+
})
2252
vi.mock('@/background/cleanup-logs', () => ({ legacyLargeValuePredicate: vi.fn() }))
2353
vi.mock('@/background/cleanup-soft-deletes', () => ({
2454
reRootActiveFolderChildrenUnguarded: reRoot,
@@ -57,6 +87,7 @@ function control(type: CleanupType, dryRun = true, limit = 1) {
5787
beforeEach(() => {
5888
vi.clearAllMocks()
5989
resetDbChainMock()
90+
outbox.clear()
6091
storage.mockResolvedValue({ deleted: 1, failed: [] })
6192
prepareChat.mockResolvedValue({ execute: executeChat })
6293
})
@@ -125,7 +156,7 @@ describe('requested cleanup stages', () => {
125156
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'one', files: [{ key: 'blob' }] }])
126157
storage.mockResolvedValue({ deleted: 0, failed: [{ key: 'blob', error: 'unavailable' }] })
127158
const run = control('workflowLogs', false)
128-
await expect(runBoundedLogScope(scope, run)).rejects.toThrow('storage deletions failed')
159+
await expect(runBoundedLogScope(scope, run)).rejects.toThrow('storage cleanup is incomplete')
129160
expect(dbChainMockFns.delete).toHaveBeenCalledOnce()
130161
expect(run.progress.stages.workflowLogs).toMatchObject({
131162
selected: 1,
@@ -215,7 +246,7 @@ describe('bounded file billing', () => {
215246
{ id: 'file-one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: 100 },
216247
])
217248
billing.mockResolvedValue({ workspaceId: 'ws-one' })
218-
dbChainMockFns.returning.mockResolvedValue([{ id: 'file-one', sizeBytes: 40 }])
249+
dbChainMockFns.returning.mockResolvedValue([{ id: 'file-one', key: 'blob', sizeBytes: 40 }])
219250
const run = control('files', false)
220251
await runBoundedSoftDeleteScope({ ...scope, workspaceIds: ['ws-one', 'ws-two'] }, run)
221252
expect(decrement).toHaveBeenCalledWith(

apps/sim/background/cleanup-logs-bounded.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher'
1212
import type { BoundedCleanup } from '@/lib/cleanup/bounded'
1313
import { boundedDelete } from '@/lib/cleanup/bounded-delete'
1414
import { pruneBoundedLargeValueMetadata } from '@/lib/cleanup/bounded-large-value-metadata'
15-
import { deleteBoundedStorage, tombstoneBoundedFiles } from '@/lib/cleanup/bounded-storage'
15+
import {
16+
enqueueRetentionStorageCleanup,
17+
processRetentionStorageCleanup,
18+
} from '@/lib/cleanup/storage-outbox'
1619
import {
1720
LIVE_PAUSED_REFERENCE_STATUSES,
1821
unreferencedLargeValuePredicate,
@@ -51,8 +54,8 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
5154
),
5255
(row) => row.id,
5356
async (rows) => {
54-
const deleted = await control.query(async (tx) =>
55-
tx
57+
const { deleted, events } = await control.query(async (tx) => {
58+
const deleted = await tx
5659
.delete(workflowExecutionLogs)
5760
.where(
5861
and(
@@ -64,19 +67,28 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
6467
)
6568
)
6669
.returning({ id: workflowExecutionLogs.id, files: workflowExecutionLogs.files })
67-
)
68-
await control.deleted('workflowLogs', deleted.length)
69-
for (const row of deleted) {
70-
const keys = Array.isArray(row.files)
71-
? row.files.flatMap((file) =>
72-
file && typeof file === 'object' && 'key' in file && typeof file.key === 'string'
73-
? [file.key]
74-
: []
70+
const keys = deleted.flatMap((row) =>
71+
Array.isArray(row.files)
72+
? row.files.flatMap((file) =>
73+
file && typeof file === 'object' && 'key' in file && typeof file.key === 'string'
74+
? [file.key]
75+
: []
76+
)
77+
: []
78+
)
79+
const events = isUsingCloudStorage()
80+
? await enqueueRetentionStorageCleanup(
81+
tx,
82+
keys,
83+
'execution',
84+
control.options.batchSize,
85+
true
7586
)
7687
: []
77-
await deleteBoundedStorage(control, 'workflowLogs', keys, 'execution')
78-
if (isUsingCloudStorage()) await tombstoneBoundedFiles(control, keys)
79-
}
88+
return { deleted, events }
89+
})
90+
await control.deleted('workflowLogs', deleted.length)
91+
await processRetentionStorageCleanup(control, 'workflowLogs', events)
8092
}
8193
)
8294
await boundedDelete(
@@ -123,7 +135,7 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
123135
async (rows) => {
124136
if (!isUsingCloudStorage()) return
125137
const selectedKeys = rows.map((row) => row.key)
126-
const keys = await control.query(async (tx) => {
138+
const { keys, events } = await control.query(async (tx) => {
127139
await tx
128140
.select({ key: table.key })
129141
.from(table)
@@ -135,11 +147,18 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
135147
.set({ deletedAt: new Date() })
136148
.where(and(eligible, inArray(table.key, selectedKeys)))
137149
.returning({ key: table.key })
138-
return claimed.map((row) => row.key)
150+
const keys = claimed.map((row) => row.key)
151+
const events = await enqueueRetentionStorageCleanup(
152+
tx,
153+
keys,
154+
'execution',
155+
control.options.batchSize,
156+
true
157+
)
158+
return { keys, events }
139159
})
140-
await deleteBoundedStorage(control, type, keys, 'execution')
160+
await processRetentionStorageCleanup(control, type, events)
141161
await control.deleted(type, keys.length)
142-
await tombstoneBoundedFiles(control, keys)
143162
}
144163
)
145164
}

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

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,20 @@ import {
2121
} from '@/lib/billing/storage'
2222
import { type BoundedCleanup, setCleanupTimeouts } from '@/lib/cleanup/bounded'
2323
import { boundedDelete } from '@/lib/cleanup/bounded-delete'
24-
import { deleteBoundedStorage } from '@/lib/cleanup/bounded-storage'
2524
import type { CleanupType } from '@/lib/cleanup/bounded-types'
2625
import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup'
2726
import {
2827
type CleanupOwnerScope,
2928
cleanupOwnerCondition,
3029
resolveCleanupOwnerScope,
3130
} from '@/lib/cleanup/resource-scope'
31+
import {
32+
enqueueRetentionStorageCleanup,
33+
processRetentionStorageCleanup,
34+
} from '@/lib/cleanup/storage-outbox'
3235
import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
3336
import { cleanupKnowledgeStorageBinding } from '@/lib/knowledge/documents/storage-cleanup'
34-
import type { StorageContext } from '@/lib/uploads'
37+
import { isUsingCloudStorage, type StorageContext } from '@/lib/uploads'
3538
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
3639
import { reRootActiveFolderChildrenUnguarded } from '@/background/cleanup-soft-deletes'
3740

@@ -207,8 +210,8 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
207210
),
208211
(row) => row.id,
209212
async (rows) => {
210-
const deleted = await control.query(async (tx) =>
211-
tx
213+
const { deleted, events } = await control.query(async (tx) => {
214+
const deleted = await tx
212215
.delete(workspaceFile)
213216
.where(
214217
and(
@@ -220,14 +223,18 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
220223
)
221224
)
222225
.returning({ id: workspaceFile.id, key: workspaceFile.key })
223-
)
226+
const events = isUsingCloudStorage()
227+
? await enqueueRetentionStorageCleanup(
228+
tx,
229+
deleted.map((row) => row.key),
230+
'workspace',
231+
control.options.batchSize
232+
)
233+
: []
234+
return { deleted, events }
235+
})
224236
await control.deleted('legacyFiles', deleted.length)
225-
await deleteBoundedStorage(
226-
control,
227-
'legacyFiles',
228-
deleted.map((row) => row.key),
229-
'workspace'
230-
)
237+
await processRetentionStorageCleanup(control, 'legacyFiles', events)
231238
}
232239
)
233240
}
@@ -289,21 +296,24 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
289296
billing,
290297
deleted.reduce((sum, file) => sum + getWorkspaceFileSize(file), 0)
291298
)
292-
return deleted
299+
const events = isUsingCloudStorage()
300+
? await enqueueRetentionStorageCleanup(
301+
tx,
302+
deleted.map((file) => file.key),
303+
row.context as StorageContext,
304+
control.options.batchSize
305+
)
306+
: []
307+
return { deleted, events }
293308
}
294-
const deleted = billing
309+
const { deleted, events } = billing
295310
? await db.transaction(async (tx) => {
296311
await setCleanupTimeouts(tx)
297312
return remove(tx)
298313
})
299314
: await control.query(remove)
300315
await control.deleted('files', deleted.length)
301-
await deleteBoundedStorage(
302-
control,
303-
'files',
304-
deleted.map((file) => file.key),
305-
row.context as StorageContext
306-
)
316+
await processRetentionStorageCleanup(control, 'files', events)
307317
}
308318
}
309319
)

apps/sim/executor/handlers/variables/variables-handler.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ const { mockUploadFile } = vi.hoisted(() => ({
1313
mockUploadFile: vi.fn(),
1414
}))
1515

16+
vi.mock('@/lib/execution/payloads/large-value-metadata', async (importOriginal) => ({
17+
...(await importOriginal<typeof import('@/lib/execution/payloads/large-value-metadata')>()),
18+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
19+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
20+
}))
21+
1622
vi.mock('@/lib/uploads', () => ({
1723
StorageService: {
1824
uploadFile: mockUploadFile,

apps/sim/executor/orchestrators/loop.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const mockLogger =
2222
vi.mocked(createLogger).mock.calls.findIndex(([name]) => name === 'LoopOrchestrator')
2323
].value
2424

25+
vi.mock('@/lib/execution/payloads/large-value-metadata', async (importOriginal) => ({
26+
...(await importOriginal<typeof import('@/lib/execution/payloads/large-value-metadata')>()),
27+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
28+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
29+
}))
30+
2531
vi.mock('@/lib/execution/isolated-vm', () => ({
2632
executeInIsolatedVM: mockExecuteInIsolatedVM,
2733
}))

apps/sim/executor/variables/resolvers/block.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { navigatePathAsync } from '@/executor/variables/resolvers/reference-asyn
77
import { BlockResolver } from './block'
88
import { RESOLVED_EMPTY, type ResolutionContext } from './reference'
99

10+
vi.mock('@/lib/execution/payloads/large-value-metadata', async (importOriginal) => ({
11+
...(await importOriginal<typeof import('@/lib/execution/payloads/large-value-metadata')>()),
12+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
13+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
14+
}))
15+
1016
vi.mock('@/lib/uploads/server/metadata', () => ({
1117
insertImmutableFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }),
1218
insertFileMetadata: vi.fn().mockResolvedValue({ id: 'execution-payload-file' }),

apps/sim/lib/cleanup/bounded-cleanup.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ Limits count **selected roots**, including roots restored before deletion. They
5353
- Bounded dispatch sets one attempt and a 180-second hard maximum. The worker stops starting new root batches after 120 seconds; cancellable child preparation also observes that work deadline.
5454
- Cleanup SQL uses transaction-local **500ms lock_timeout** and **5s statement_timeout**. The billable-file delete and storage decrement remain atomic. Orphan knowledge-base storage cleanup reuses the binding lock held by document creation, with a 15-second storage deadline. Large-value reference writers lock the value before registering references; cleanup locks and rechecks it before claiming a tombstone.
5555
- Trigger `cleanup` metadata reports each requested type's `selected`, `deleted`, `skipped`, `filesDeleted`, and `filesFailed`, plus stage, duration, and stop reason: `budgets_exhausted`, `scopes_exhausted`, `time_budget`, or `failed`.
56-
- A failure stops subsequent stages, preserves completed progress, and fails the run. External deletion is not transactional with Postgres. A hard process termination may leave the last metadata checkpoint behind actual effects. Do not blindly replay failed runs: inspect the failed stage and any external work already completed. Log and file storage is removed only for rows returned by the guarded delete. A storage failure after that commit can leave orphaned objects; a failed large-value storage deletion can leave a tombstone with remaining bytes. This change does not add recovery for those gaps or for chat backend/storage cleanup failures after parent deletion. Inspect failed runs before resubmitting.
56+
- A failure stops subsequent stages, preserves completed progress, and fails the run. External deletion is not transactional with Postgres. A hard process termination may leave the last metadata checkpoint behind actual effects. Do not blindly replay failed runs: inspect the failed stage and any external work already completed. Log and file storage is removed only for rows returned by the guarded delete. Their keys, and claimed large-value keys, enter `retention.storage.cleanup` outbox events in the same database transaction. The run attempts those events immediately; the existing outbox worker retries failures without selecting more roots. Inspect pending/dead-letter events when a run fails. Chat backend/storage cleanup still has its existing post-parent-delete recovery gap.
5757

5858
## Rollout
5959

60-
1. Deploy Trigger workers first, then the API. Keep the existing production cleanup schedules disabled.
60+
1. Deploy Trigger workers first, then the API. Keep the existing production cleanup schedules disabled. Ensure the existing outbox processor is running so persisted storage failures can retry.
6161
2. Let old cleanup runs finish or cancel them before manual draining. Old queued jobs may retain their previous queue/version settings; the new queue cannot serialize against those runs.
6262
3. Run a preview, then one small delete invocation. Wait for the Trigger run to finish. Check database CPU, query latency, lock waits, replication lag, and application errors against their normal baseline.
6363
4. Repeat with a fresh request ID. Increase either the per-call budget or call frequency gradually, holding the other steady. Pause submissions on timeouts, failures, or database/application degradation. Avoid overlapping caller loops even though the Trigger queue serializes these jobs.

0 commit comments

Comments
 (0)