Skip to content

Commit af6598d

Browse files
fix(cleanup): coordinate deletion with resource eligibility
1 parent 139a6c3 commit af6598d

14 files changed

Lines changed: 413 additions & 177 deletions

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

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,56 @@ describe('requested cleanup stages', () => {
120120
expect(run.progress.stages.jobLogs).toMatchObject({ selected: 2, deleted: 2 })
121121
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2)
122122
})
123-
it('stops before root deletion if attached log storage fails', async () => {
123+
it('records committed log deletion if attached storage cleanup fails', async () => {
124124
queueTableRows(schemaMock.workflowExecutionLogs, [{ id: 'one', files: [{ key: 'blob' }] }])
125+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'one', files: [{ key: 'blob' }] }])
125126
storage.mockResolvedValue({ deleted: 0, failed: [{ key: 'blob', error: 'unavailable' }] })
126127
const run = control('workflowLogs', false)
127128
await expect(runBoundedLogScope(scope, run)).rejects.toThrow('storage deletions failed')
128-
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
129+
expect(dbChainMockFns.delete).toHaveBeenCalledOnce()
129130
expect(run.progress.stages.workflowLogs).toMatchObject({
130131
selected: 1,
131-
deleted: 0,
132+
deleted: 1,
132133
filesFailed: 1,
133134
})
134135
})
136+
it('does not remove files of a log protected after selection', async () => {
137+
queueTableRows(schemaMock.workflowExecutionLogs, [{ id: 'one', files: [{ key: 'blob' }] }])
138+
dbChainMockFns.returning.mockResolvedValueOnce([])
139+
const run = control('workflowLogs', false)
140+
await runBoundedLogScope(scope, run)
141+
expect(storage).not.toHaveBeenCalled()
142+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
143+
expect(run.progress.stages.workflowLogs).toMatchObject({ selected: 1, deleted: 0, skipped: 1 })
144+
})
145+
it.each(['largeValues', 'legacyLargeValues'] as const)(
146+
'does not remove a %s key that fails the final liveness claim',
147+
async (type) => {
148+
queueTableRows(
149+
type === 'largeValues' ? schemaMock.executionLargeValues : schemaMock.workspaceFiles,
150+
[{ key: 'referenced-key' }]
151+
)
152+
dbChainMockFns.returning.mockResolvedValueOnce([])
153+
const run = control(type, false)
154+
await runBoundedLogScope(scope, run)
155+
expect(storage).not.toHaveBeenCalled()
156+
expect(run.progress.stages[type]).toMatchObject({ selected: 1, deleted: 0, skipped: 1 })
157+
}
158+
)
159+
it.each(['files', 'legacyFiles'] as const)(
160+
'does not remove a restored %s object',
161+
async (type) => {
162+
queueTableRows(type === 'files' ? schemaMock.workspaceFiles : schemaMock.workspaceFile, [
163+
{ id: 'one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: 100 },
164+
])
165+
billing.mockResolvedValue({ workspaceId: 'ws-one' })
166+
dbChainMockFns.returning.mockResolvedValueOnce([])
167+
const run = control(type, false)
168+
await runBoundedSoftDeleteScope(scope, run)
169+
expect(storage).not.toHaveBeenCalled()
170+
expect(run.progress.stages[type]).toMatchObject({ selected: 1, deleted: 0, skipped: 1 })
171+
}
172+
)
135173
it('keeps workflow chat side effects even with a zero chat budget', async () => {
136174
queueTableRows(schemaMock.workflow, [{ id: 'workflow-one' }])
137175
queueTableRows(schemaMock.copilotChats, [{ id: 'child-chat' }])

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,6 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
5151
),
5252
(row) => row.id,
5353
async (rows) => {
54-
for (const row of rows) {
55-
const keys = Array.isArray(row.files)
56-
? row.files.flatMap((file) =>
57-
file && typeof file === 'object' && 'key' in file && typeof file.key === 'string'
58-
? [file.key]
59-
: []
60-
)
61-
: []
62-
await deleteBoundedStorage(control, 'workflowLogs', keys, 'execution')
63-
if (isUsingCloudStorage()) await tombstoneBoundedFiles(control, keys)
64-
}
6554
const deleted = await control.query(async (tx) =>
6655
tx
6756
.delete(workflowExecutionLogs)
@@ -74,9 +63,20 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
7463
)
7564
)
7665
)
77-
.returning({ id: workflowExecutionLogs.id })
66+
.returning({ id: workflowExecutionLogs.id, files: workflowExecutionLogs.files })
7867
)
7968
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+
: []
75+
)
76+
: []
77+
await deleteBoundedStorage(control, 'workflowLogs', keys, 'execution')
78+
if (isUsingCloudStorage()) await tombstoneBoundedFiles(control, keys)
79+
}
8080
}
8181
)
8282
await boundedDelete(
@@ -122,15 +122,22 @@ export async function runBoundedLogScope(payload: CleanupJobPayload, control: Bo
122122
(row) => row.key,
123123
async (rows) => {
124124
if (!isUsingCloudStorage()) return
125-
const keys = rows.map((row) => row.key)
125+
const selectedKeys = rows.map((row) => row.key)
126+
const keys = await control.query(async (tx) => {
127+
await tx
128+
.select({ key: table.key })
129+
.from(table)
130+
.where(inArray(table.key, selectedKeys))
131+
.orderBy(asc(table.key))
132+
.for('update')
133+
const claimed = await tx
134+
.update(table)
135+
.set({ deletedAt: new Date() })
136+
.where(and(eligible, inArray(table.key, selectedKeys)))
137+
.returning({ key: table.key })
138+
return claimed.map((row) => row.key)
139+
})
126140
await deleteBoundedStorage(control, type, keys, 'execution')
127-
if (!legacy)
128-
await control.query(async (tx) => {
129-
await tx
130-
.update(executionLargeValues)
131-
.set({ deletedAt: new Date() })
132-
.where(inArray(executionLargeValues.key, keys))
133-
})
134141
await control.deleted(type, keys.length)
135142
await tombstoneBoundedFiles(control, keys)
136143
}

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

Lines changed: 60 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
resolveCleanupOwnerScope,
3131
} from '@/lib/cleanup/resource-scope'
3232
import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
33+
import { cleanupKnowledgeStorageBinding } from '@/lib/knowledge/documents/storage-cleanup'
3334
import type { StorageContext } from '@/lib/uploads'
3435
import { getWorkspaceFileSize } from '@/lib/uploads/shared/types'
3536
import { reRootActiveFolderChildrenUnguarded } from '@/background/cleanup-soft-deletes'
@@ -121,18 +122,16 @@ export async function runBoundedSoftDeleteScope(
121122
lt(knowledgeBase.deletedAt, cutoff)
122123
),
123124
{
124-
before: async (kbIds) => {
125+
beforeDelete: async (kbIds, tx) => {
125126
// Existing ledger/outbox implementation owns document and embedding deletion.
126127
while (true) {
127128
control.assertTimeRemaining()
128-
const rows = await control.query(async (tx) =>
129-
tx
130-
.select({ id: document.id })
131-
.from(document)
132-
.where(inArray(document.knowledgeBaseId, kbIds))
133-
.orderBy(asc(document.id))
134-
.limit(control.options.batchSize)
135-
)
129+
const rows = await tx
130+
.select({ id: document.id })
131+
.from(document)
132+
.where(inArray(document.knowledgeBaseId, kbIds))
133+
.orderBy(asc(document.id))
134+
.limit(control.options.batchSize)
136135
if (rows.length === 0) break
137136
const deleted = await hardDeleteDocuments(
138137
rows.map((row) => row.id),
@@ -141,7 +140,7 @@ export async function runBoundedSoftDeleteScope(
141140
undefined,
142141
undefined,
143142
undefined,
144-
control.query
143+
async (query) => query(tx)
145144
)
146145
if (deleted !== rows.length)
147146
throw new Error('Knowledge-base document cleanup did not delete its selected batch')
@@ -176,10 +175,8 @@ export async function runBoundedSoftDeleteScope(
176175
),
177176
target.type === 'folders'
178177
? {
179-
before: (folderIds) =>
180-
control.query((tx) =>
181-
reRootActiveFolderChildrenUnguarded(folderIds, cutoff, payload.label, tx, true)
182-
),
178+
beforeDelete: (folderIds, tx) =>
179+
reRootActiveFolderChildrenUnguarded(folderIds, cutoff, payload.label, tx, true),
183180
}
184181
: {}
185182
)
@@ -210,12 +207,6 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
210207
),
211208
(row) => row.id,
212209
async (rows) => {
213-
await deleteBoundedStorage(
214-
control,
215-
'legacyFiles',
216-
rows.map((row) => row.key),
217-
'workspace'
218-
)
219210
const deleted = await control.query(async (tx) =>
220211
tx
221212
.delete(workspaceFile)
@@ -228,9 +219,15 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
228219
)
229220
)
230221
)
231-
.returning({ id: workspaceFile.id })
222+
.returning({ id: workspaceFile.id, key: workspaceFile.key })
232223
)
233224
await control.deleted('legacyFiles', deleted.length)
225+
await deleteBoundedStorage(
226+
control,
227+
'legacyFiles',
228+
deleted.map((row) => row.key),
229+
'workspace'
230+
)
234231
}
235232
)
236233
}
@@ -270,7 +267,6 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
270267
return resolveStorageBillingContext(row.workspaceId, { executor })
271268
})
272269
: undefined
273-
await deleteBoundedStorage(control, 'files', [row.key], row.context as StorageContext)
274270
const remove = async (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => {
275271
const deleted = await tx
276272
.delete(workspaceFiles)
@@ -282,22 +278,32 @@ async function cleanupFiles(control: BoundedCleanup, scope: CleanupOwnerScope, c
282278
billing ? eq(workspaceFiles.workspaceId, billing.workspaceId) : undefined
283279
)
284280
)
285-
.returning({ id: workspaceFiles.id, sizeBytes: workspaceFiles.sizeBytes })
281+
.returning({
282+
id: workspaceFiles.id,
283+
key: workspaceFiles.key,
284+
sizeBytes: workspaceFiles.sizeBytes,
285+
})
286286
if (billing)
287287
await decrementStorageUsageForBillingContextInTx(
288288
tx,
289289
billing,
290290
deleted.reduce((sum, file) => sum + getWorkspaceFileSize(file), 0)
291291
)
292-
return deleted.length
292+
return deleted
293293
}
294294
const deleted = billing
295295
? await db.transaction(async (tx) => {
296296
await setCleanupTimeouts(tx)
297297
return remove(tx)
298298
})
299299
: await control.query(remove)
300-
await control.deleted('files', deleted)
300+
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+
)
301307
}
302308
}
303309
)
@@ -317,36 +323,42 @@ async function cleanupOrphanBindings(control: BoundedCleanup, scope: CleanupOwne
317323
(limit, seen) =>
318324
control.query(async (tx) =>
319325
tx
320-
.select({ id: workspaceFiles.id, key: workspaceFiles.key })
326+
.select({
327+
id: workspaceFiles.id,
328+
key: workspaceFiles.key,
329+
contentUpdatedAt: workspaceFiles.contentUpdatedAt,
330+
workspaceId: workspaceFiles.workspaceId,
331+
organizationId: workspaceFiles.organizationId,
332+
userId: workspaceFiles.userId,
333+
})
321334
.from(workspaceFiles)
322335
.where(and(eligible, seen.length ? notInArray(workspaceFiles.id, seen) : undefined))
323336
.orderBy(asc(workspaceFiles.id))
324337
.limit(limit)
325338
),
326339
(row) => row.id,
327340
async (rows) => {
328-
await deleteBoundedStorage(
329-
control,
330-
type,
331-
rows.map((row) => row.key),
332-
'knowledge-base'
333-
)
334-
const deleted = await control.query(async (tx) =>
335-
tx
336-
.update(workspaceFiles)
337-
.set({ deletedAt: new Date() })
338-
.where(
339-
and(
340-
eligible,
341-
inArray(
342-
workspaceFiles.id,
343-
rows.map((row) => row.id)
344-
)
345-
)
346-
)
347-
.returning({ id: workspaceFiles.id })
348-
)
349-
await control.deleted(type, deleted.length)
341+
for (const row of rows) {
342+
control.assertTimeRemaining()
343+
const deleted = await cleanupKnowledgeStorageBinding(
344+
{
345+
version: 1,
346+
documentId: `orphan:${row.id}`,
347+
fileId: row.id,
348+
key: row.key,
349+
contentUpdatedAt: row.contentUpdatedAt.toISOString(),
350+
workspaceId: row.workspaceId,
351+
organizationId: row.organizationId,
352+
userId: row.userId,
353+
},
354+
AbortSignal.timeout(15_000),
355+
control.query
356+
)
357+
if (deleted) {
358+
await control.deleted(type, 1)
359+
await control.files(type, 1, 0)
360+
}
361+
}
350362
}
351363
)
352364
}

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ const { mockUploadFile, mockDownloadFile, mockMaskBatch } = vi.hoisted(() => ({
3131
mockMaskBatch: vi.fn(),
3232
}))
3333

34+
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
35+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
36+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
37+
}))
38+
3439
vi.mock('@/ee/access-control/utils/permission-check', () => ({
3540
validateBlockType: vi.fn(),
3641
}))

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import { navigatePathAsync } from '@/executor/variables/resolvers/reference-asyn
99
import type { ResolutionContext } from './reference'
1010
import { WorkflowResolver } from './workflow'
1111

12+
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
13+
registerLargeValueOwner: vi.fn().mockResolvedValue(true),
14+
addLargeValueReference: vi.fn().mockResolvedValue(undefined),
15+
}))
16+
1217
vi.mock('@/lib/workflows/variables/variable-manager', () => ({
1318
VariableManager: {
1419
resolveForExecution: vi.fn((value) => value),

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ A call with **no query parameters retains the existing scheduled cleanup behavio
4242

4343
## What the budget means
4444

45-
Limits count **selected roots**, including roots restored before deletion. They do not count all physical rows affected by foreign keys. Two workflows can cascade into thousands of blocks, edges, chats, and messages. Mandatory attached-file and backend cleanup follows selected parents even when its standalone type budget is zero. Knowledge-base deletion retains the existing document accounting and storage-cleanup outbox behavior.
45+
Limits count **selected roots**, including roots restored before deletion. They do not count all physical rows affected by foreign keys. Two workflows can cascade into thousands of blocks, edges, chats, and messages. Mandatory attached-file and backend cleanup follows selected parents even when its standalone type budget is zero. Knowledge-base deletion retains the existing document accounting and storage-cleanup outbox behavior. Its child changes and parent deletion share one locked transaction, as do folder re-rooting and deletion; a failure rolls all of them back.
4646

4747
`largeValues`/`legacyLargeValues` count object keys. `orphanKnowledgeBaseBindings` counts bindings soft-deleted after object cleanup. Metadata pruning counts its selected metadata records. A dry run previews the current state; it does not reserve rows for a later deletion run.
4848

@@ -51,9 +51,9 @@ Limits count **selected roots**, including roots restored before deletion. They
5151
- One coordinator walks owner scopes sequentially with shared budgets. It creates no child cleanup jobs.
5252
- Logs and soft deletes share the named Trigger queue `retention-cleanup`, concurrency 1, including newly dispatched scheduled jobs. No per-type concurrency keys are used.
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.
54-
- Cleanup SQL uses transaction-local **500ms lock_timeout** and **5s statement_timeout**. The billable-file delete and storage decrement remain atomic.
54+
- 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. This change does not add recovery for the existing gap when chat backend/storage cleanup fails after parent rows have committed.
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.
5757

5858
## Rollout
5959

0 commit comments

Comments
 (0)