Skip to content

Commit 1e7c3dc

Browse files
fix(cleanup): preserve log rows when file deletion fails (#7869)
1 parent 5bca037 commit 1e7c3dc

2 files changed

Lines changed: 112 additions & 1 deletion

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockDeleteFiles, mockDeleteFileMetadata } = vi.hoisted(() => ({
9+
mockDeleteFiles: vi.fn(),
10+
mockDeleteFileMetadata: vi.fn(),
11+
}))
12+
13+
vi.mock('@trigger.dev/sdk', () => ({
14+
task: vi.fn((config) => config),
15+
queue: vi.fn((config) => config),
16+
}))
17+
vi.mock('@/lib/billing/cleanup-dispatcher', () => ({ runCleanupWithLimits: vi.fn() }))
18+
vi.mock('@/lib/execution/payloads/large-value-metadata', () => ({
19+
LIVE_PAUSED_REFERENCE_STATUSES: ['paused', 'partially_resumed', 'cancelling'],
20+
markLargeValuesDeleted: vi.fn(),
21+
pruneLargeValueMetadata: vi.fn(async () => ({
22+
referencesDeleted: 0,
23+
dependenciesDeleted: 0,
24+
tombstonesDeleted: 0,
25+
})),
26+
unreferencedLargeValuePredicate: vi.fn(),
27+
}))
28+
vi.mock('@/lib/logs/execution/snapshot/service', () => ({
29+
snapshotService: { cleanupOrphanedSnapshots: vi.fn() },
30+
}))
31+
vi.mock('@/lib/uploads', () => ({
32+
isUsingCloudStorage: vi.fn(() => true),
33+
StorageService: { deleteFiles: mockDeleteFiles },
34+
}))
35+
vi.mock('@/lib/uploads/server/metadata', () => ({
36+
deleteFileMetadata: mockDeleteFileMetadata,
37+
}))
38+
39+
import { createCleanupBudgets } from '@/lib/cleanup/limits'
40+
import { runCleanupLogs } from '@/background/cleanup-logs'
41+
42+
const payload = {
43+
label: 'free/1',
44+
plan: 'free' as const,
45+
retentionHours: 720,
46+
workspaceIds: ['workspace-1'],
47+
}
48+
const rows = [
49+
{ id: 'log-1', files: [{ key: 'file-a' }, { key: 'file-b' }] },
50+
{ id: 'log-2', files: [{ key: 'file-c' }] },
51+
]
52+
53+
describe('bounded log cleanup file failures', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
resetDbChainMock()
57+
dbChainMockFns.limit.mockResolvedValueOnce(rows)
58+
dbChainMockFns.returning.mockResolvedValueOnce(rows.map(({ id }) => ({ id })))
59+
mockDeleteFiles.mockImplementation(async (keys: string[]) => ({
60+
deleted: keys.length,
61+
failed: [],
62+
}))
63+
mockDeleteFileMetadata.mockResolvedValue(true)
64+
})
65+
66+
it.each([
67+
{
68+
name: 'the storage request throws',
69+
fail: () => mockDeleteFiles.mockRejectedValueOnce(new Error('storage unavailable')),
70+
},
71+
{
72+
name: 'storage returns a partial failure',
73+
fail: () =>
74+
mockDeleteFiles.mockResolvedValueOnce({
75+
deleted: 1,
76+
failed: [{ key: 'file-b', error: 'storage unavailable' }],
77+
}),
78+
},
79+
{
80+
name: 'metadata deletion throws',
81+
fail: () => mockDeleteFileMetadata.mockRejectedValueOnce(new Error('database unavailable')),
82+
},
83+
])('keeps the entire log batch retryable when $name', async ({ fail }) => {
84+
fail()
85+
const budgets = createCleanupBudgets({ workflowLogs: 2 })
86+
87+
await expect(runCleanupLogs(payload, budgets)).rejects.toThrow('Log file cleanup failed')
88+
89+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
90+
expect(mockDeleteFiles).toHaveBeenCalledTimes(1)
91+
expect(budgets.workflowLogs.remaining).toBe(0)
92+
93+
dbChainMockFns.limit.mockResolvedValueOnce(rows)
94+
await runCleanupLogs(payload, createCleanupBudgets({ workflowLogs: 2 }))
95+
96+
expect(mockDeleteFiles).toHaveBeenNthCalledWith(2, ['file-a', 'file-b'], 'execution')
97+
expect(mockDeleteFiles).toHaveBeenNthCalledWith(3, ['file-c'], 'execution')
98+
expect(dbChainMockFns.delete).toHaveBeenCalledExactlyOnceWith(schemaMock.workflowExecutionLogs)
99+
})
100+
101+
it('deletes log rows only after every file and its metadata succeeds', async () => {
102+
await runCleanupLogs(payload, createCleanupBudgets({ workflowLogs: 2 }))
103+
104+
expect(mockDeleteFiles).toHaveBeenCalledTimes(2)
105+
expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(3)
106+
expect(dbChainMockFns.delete).toHaveBeenCalledExactlyOnceWith(schemaMock.workflowExecutionLogs)
107+
const deleteOrder = dbChainMockFns.delete.mock.invocationCallOrder[0]
108+
expect(mockDeleteFiles.mock.invocationCallOrder.at(-1)).toBeLessThan(deleteOrder)
109+
expect(mockDeleteFileMetadata.mock.invocationCallOrder.at(-1)).toBeLessThan(deleteOrder)
110+
})
111+
})

apps/sim/background/cleanup-logs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ async function cleanupWorkflowExecutionLogs(
429429
onBatch: async (rows) => {
430430
for (const row of rows) {
431431
await deleteExecutionFiles(row.files, fileStats)
432+
if (budget && fileStats.filesDeleteFailed) throw new Error('Log file cleanup failed')
432433
}
433434
},
434435
batchSize: WORKFLOW_LOG_CLEANUP_BATCH_SIZE,
@@ -486,7 +487,6 @@ export async function runCleanupLogs(
486487
logger.info(
487488
`[${label}] workflow_execution_logs files: ${workflowResults.filesDeleted}/${workflowResults.filesTotal} deleted, ${workflowResults.filesDeleteFailed} failed`
488489
)
489-
if (budgets && workflowResults.filesDeleteFailed) throw new Error('Log file cleanup failed')
490490
const largeValueResults = await cleanupLargeExecutionValues(
491491
workspaceIds,
492492
retentionDate,

0 commit comments

Comments
 (0)