Skip to content

Commit 139a6c3

Browse files
feat(cleanup): add bounded manual retention runs
1 parent fb92772 commit 139a6c3

31 files changed

Lines changed: 2432 additions & 304 deletions

apps/sim/app/api/cron/cleanup-soft-deletes/route.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { softDeletesCleanupContract } from '@/lib/api/contracts/cleanup'
4+
import { parseRequest } from '@/lib/api/server/validation'
35
import { verifyCronAuth } from '@/lib/auth/internal'
4-
import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
6+
import { dispatchBoundedCleanup, dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
57
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
68

79
export const dynamic = 'force-dynamic'
810

911
const logger = createLogger('SoftDeleteCleanupAPI')
1012

13+
/** Cron-secret maintenance protocol is global; workspace principal authorization does not apply. */
1114
export const GET = withRouteHandler(async (request: NextRequest) => {
1215
try {
1316
const authError = verifyCronAuth(request, 'soft-delete cleanup')
1417
if (authError) return authError
1518

19+
const parsed = await parseRequest(
20+
softDeletesCleanupContract,
21+
request,
22+
{},
23+
{
24+
rejectDuplicateQueryValues: true,
25+
rejectBlankQueryValues: true,
26+
}
27+
)
28+
if (!parsed.success) return parsed.response
29+
if (parsed.data.query) {
30+
const result = await dispatchBoundedCleanup('cleanup-soft-deletes', parsed.data.query)
31+
return NextResponse.json(result, { status: 202 })
32+
}
33+
1634
const result = await dispatchCleanupJobs('cleanup-soft-deletes')
1735

1836
logger.info('Soft-delete cleanup jobs dispatched', result)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { createMockRequest } from '@sim/testing'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const { auth, bounded, scheduled } = vi.hoisted(() => ({
5+
auth: vi.fn(),
6+
bounded: vi.fn(),
7+
scheduled: vi.fn(),
8+
}))
9+
vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: auth }))
10+
vi.mock('@/lib/billing/cleanup-dispatcher', () => ({
11+
dispatchBoundedCleanup: bounded,
12+
dispatchCleanupJobs: scheduled,
13+
}))
14+
15+
import { GET as softDeletes } from '@/app/api/cron/cleanup-soft-deletes/route'
16+
import { GET as logs } from '@/app/api/logs/cleanup/route'
17+
18+
for (const [path, GET, type, limit] of [
19+
['/api/logs/cleanup', logs, 'cleanup-logs', 'workflowLogs'],
20+
['/api/cron/cleanup-soft-deletes', softDeletes, 'cleanup-soft-deletes', 'workflows'],
21+
] as const) {
22+
describe(path, () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
auth.mockReturnValue(null)
26+
bounded.mockResolvedValue({ runId: 'run-one', mode: 'bounded' })
27+
scheduled.mockResolvedValue({
28+
jobIds: ['batch-one'],
29+
jobCount: 1,
30+
chunkCount: 2,
31+
workspaceCount: 3,
32+
})
33+
})
34+
const request = (query = '') =>
35+
createMockRequest('GET', undefined, {}, `http://localhost:3000${path}${query}`)
36+
it('authenticates before parsing invalid limits', async () => {
37+
auth.mockReturnValue(new Response(null, { status: 401 }))
38+
expect((await GET(request('?unknown=1'))).status).toBe(401)
39+
expect(bounded).not.toHaveBeenCalled()
40+
expect(scheduled).not.toHaveBeenCalled()
41+
})
42+
it('keeps no-parameter scheduled dispatch unchanged', async () => {
43+
const response = await GET(request())
44+
expect(response.status).toBe(200)
45+
expect(scheduled).toHaveBeenCalledWith(type)
46+
expect(bounded).not.toHaveBeenCalled()
47+
})
48+
it('accepts one bounded run', async () => {
49+
const response = await GET(request(`?${limit}=2&requestId=wave-1&dryRun=true`))
50+
expect(response.status).toBe(202)
51+
expect(bounded).toHaveBeenCalledWith(
52+
type,
53+
expect.objectContaining({
54+
limits: expect.objectContaining({ [limit]: 2 }),
55+
requestId: 'wave-1',
56+
dryRun: true,
57+
batchSize: 25,
58+
})
59+
)
60+
expect(scheduled).not.toHaveBeenCalled()
61+
})
62+
it.each(['?dryRun=true', '?unknown=1', '?batchSize=3', `?${limit}=2&${limit}=3&requestId=r`])(
63+
'rejects invalid query %s',
64+
async (query) => {
65+
expect((await GET(request(query))).status).toBe(400)
66+
expect(bounded).not.toHaveBeenCalled()
67+
expect(scheduled).not.toHaveBeenCalled()
68+
}
69+
)
70+
it('reports a dispatch failure', async () => {
71+
bounded.mockRejectedValue(new Error('Trigger unavailable'))
72+
expect((await GET(request(`?${limit}=2&requestId=r`))).status).toBe(500)
73+
})
74+
})
75+
}

apps/sim/app/api/logs/cleanup/route.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { logsCleanupContract } from '@/lib/api/contracts/cleanup'
4+
import { parseRequest } from '@/lib/api/server/validation'
35
import { verifyCronAuth } from '@/lib/auth/internal'
4-
import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
6+
import { dispatchBoundedCleanup, dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
57
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
68

79
export const dynamic = 'force-dynamic'
810

911
const logger = createLogger('LogsCleanupAPI')
1012

13+
/** Cron-secret maintenance protocol is global; workspace principal authorization does not apply. */
1114
export const GET = withRouteHandler(async (request: NextRequest) => {
1215
try {
1316
const authError = verifyCronAuth(request, 'logs cleanup')
1417
if (authError) return authError
1518

19+
const parsed = await parseRequest(
20+
logsCleanupContract,
21+
request,
22+
{},
23+
{
24+
rejectDuplicateQueryValues: true,
25+
rejectBlankQueryValues: true,
26+
}
27+
)
28+
if (!parsed.success) return parsed.response
29+
if (parsed.data.query) {
30+
const result = await dispatchBoundedCleanup('cleanup-logs', parsed.data.query)
31+
return NextResponse.json(result, { status: 202 })
32+
}
33+
1634
const result = await dispatchCleanupJobs('cleanup-logs')
1735

1836
logger.info('Log cleanup jobs dispatched', result)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import {
2+
dbChainMock,
3+
dbChainMockFns,
4+
hasMockCondition,
5+
queueTableRows,
6+
resetDbChainMock,
7+
schemaMock,
8+
} from '@sim/testing'
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
11+
const { storage, prepareChat, executeChat, hardDelete, billing, decrement, reRoot } = vi.hoisted(
12+
() => ({
13+
storage: vi.fn(),
14+
prepareChat: vi.fn(),
15+
executeChat: vi.fn(),
16+
hardDelete: vi.fn(),
17+
billing: vi.fn(),
18+
decrement: vi.fn(),
19+
reRoot: vi.fn(),
20+
})
21+
)
22+
vi.mock('@/background/cleanup-logs', () => ({ legacyLargeValuePredicate: vi.fn() }))
23+
vi.mock('@/background/cleanup-soft-deletes', () => ({
24+
reRootActiveFolderChildrenUnguarded: reRoot,
25+
}))
26+
vi.mock('@/lib/uploads', () => ({
27+
isUsingCloudStorage: () => true,
28+
StorageService: { deleteFiles: storage },
29+
}))
30+
vi.mock('@/lib/cleanup/chat-cleanup', () => ({ prepareChatCleanup: prepareChat }))
31+
vi.mock('@/lib/knowledge/documents/service', () => ({ hardDeleteDocuments: hardDelete }))
32+
vi.mock('@/lib/billing/storage', () => ({
33+
resolveStorageBillingContext: billing,
34+
decrementStorageUsageForBillingContextInTx: decrement,
35+
}))
36+
37+
import { BoundedCleanup, type CleanupTransaction } from '@/lib/cleanup/bounded'
38+
import type { CleanupType } from '@/lib/cleanup/bounded-types'
39+
import { runBoundedLogScope } from '@/background/cleanup-logs-bounded'
40+
import { runBoundedSoftDeleteScope } from '@/background/cleanup-soft-deletes-bounded'
41+
42+
const scope = {
43+
plan: 'free' as const,
44+
workspaceIds: ['ws-one'],
45+
retentionHours: 720,
46+
label: 'test',
47+
runGlobalHousekeeping: true,
48+
}
49+
function control(type: CleanupType, dryRun = true, limit = 1) {
50+
return new BoundedCleanup(
51+
{ limits: { [type]: limit }, batchSize: 1, dryRun, requestId: 'test' },
52+
async () => {},
53+
Date.now,
54+
async (query) => query(dbChainMock.db as CleanupTransaction)
55+
)
56+
}
57+
beforeEach(() => {
58+
vi.clearAllMocks()
59+
resetDbChainMock()
60+
storage.mockResolvedValue({ deleted: 1, failed: [] })
61+
prepareChat.mockResolvedValue({ execute: executeChat })
62+
})
63+
64+
describe('requested cleanup stages', () => {
65+
const targets = [
66+
['workflowLogs', schemaMock.workflowExecutionLogs],
67+
['jobLogs', schemaMock.jobExecutionLogs],
68+
['largeValues', schemaMock.executionLargeValues],
69+
['legacyLargeValues', schemaMock.workspaceFiles],
70+
['orphanSnapshots', schemaMock.workflowExecutionSnapshots],
71+
['workflows', schemaMock.workflow],
72+
['chats', schemaMock.copilotChats],
73+
['legacyFiles', schemaMock.workspaceFile],
74+
['files', schemaMock.workspaceFiles],
75+
['knowledgeBases', schemaMock.knowledgeBase],
76+
['folders', schemaMock.folder],
77+
['userTables', schemaMock.userTableDefinitions],
78+
['memories', schemaMock.memory],
79+
['mcpServers', schemaMock.mcpServers],
80+
['workflowMcpServers', schemaMock.workflowMcpServer],
81+
['orphanKnowledgeBaseBindings', schemaMock.workspaceFiles],
82+
] as const
83+
it.each(targets)(
84+
'dry run of %s selects only that stage and has no side effects',
85+
async (type, table) => {
86+
queueTableRows(table, [{ id: 'root-one', key: 'key-one', files: [{ key: 'attached-file' }] }])
87+
const run = control(type)
88+
const runner =
89+
targets.findIndex(([candidate]) => candidate === type) < 5
90+
? runBoundedLogScope
91+
: runBoundedSoftDeleteScope
92+
await runner(scope, run)
93+
expect(run.progress.stages[type]?.selected).toBe(1)
94+
expect(Object.keys(run.progress.stages)).toEqual([type])
95+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
96+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
97+
for (const effect of [storage, prepareChat, hardDelete, billing, decrement, reRoot])
98+
expect(effect).not.toHaveBeenCalled()
99+
}
100+
)
101+
it.each(['staleReferences', 'staleDependencies', 'largeValueTombstones'] as const)(
102+
'dry run of %s uses SELECT only',
103+
async (type) => {
104+
dbChainMockFns.execute.mockResolvedValueOnce([{ id: 'metadata-one' }])
105+
const run = control(type)
106+
await runBoundedLogScope(scope, run)
107+
expect(run.progress.stages[type]?.selected).toBe(1)
108+
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1)
109+
expect(dbChainMockFns.execute.mock.calls[0][0].strings.join('')).toMatch(/^SELECT /)
110+
}
111+
)
112+
it('keeps the same log budget across workspace chunks', async () => {
113+
const ids = Array.from({ length: 51 }, (_, index) => `ws-${index}`)
114+
queueTableRows(schemaMock.jobExecutionLogs, [{ id: 'one' }])
115+
queueTableRows(schemaMock.jobExecutionLogs, [])
116+
queueTableRows(schemaMock.jobExecutionLogs, [{ id: 'two' }])
117+
dbChainMockFns.returning.mockResolvedValue([{ id: 'deleted' }])
118+
const run = control('jobLogs', false, 2)
119+
await runBoundedLogScope({ ...scope, workspaceIds: ids }, run)
120+
expect(run.progress.stages.jobLogs).toMatchObject({ selected: 2, deleted: 2 })
121+
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(2)
122+
})
123+
it('stops before root deletion if attached log storage fails', async () => {
124+
queueTableRows(schemaMock.workflowExecutionLogs, [{ id: 'one', files: [{ key: 'blob' }] }])
125+
storage.mockResolvedValue({ deleted: 0, failed: [{ key: 'blob', error: 'unavailable' }] })
126+
const run = control('workflowLogs', false)
127+
await expect(runBoundedLogScope(scope, run)).rejects.toThrow('storage deletions failed')
128+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
129+
expect(run.progress.stages.workflowLogs).toMatchObject({
130+
selected: 1,
131+
deleted: 0,
132+
filesFailed: 1,
133+
})
134+
})
135+
it('keeps workflow chat side effects even with a zero chat budget', async () => {
136+
queueTableRows(schemaMock.workflow, [{ id: 'workflow-one' }])
137+
queueTableRows(schemaMock.copilotChats, [{ id: 'child-chat' }])
138+
queueTableRows(schemaMock.copilotChats, [])
139+
dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-one' }])
140+
const run = control('workflows', false)
141+
await runBoundedSoftDeleteScope(scope, run)
142+
expect(prepareChat).toHaveBeenCalledWith(
143+
['child-chat'],
144+
'test',
145+
expect.objectContaining({ type: 'workflows' })
146+
)
147+
expect(executeChat).toHaveBeenCalledTimes(1)
148+
expect(run.progress.stages.workflows?.deleted).toBe(1)
149+
expect(run.progress.stages.chats).toBeUndefined()
150+
})
151+
it('counts committed workflow deletion when backend cleanup subsequently fails', async () => {
152+
queueTableRows(schemaMock.workflow, [{ id: 'workflow-one' }])
153+
dbChainMockFns.returning.mockResolvedValue([{ id: 'workflow-one' }])
154+
executeChat.mockRejectedValueOnce(new Error('backend failure'))
155+
const run = control('workflows', false)
156+
await expect(runBoundedSoftDeleteScope(scope, run)).rejects.toThrow('backend failure')
157+
expect(run.progress.stages.workflows?.deleted).toBe(1)
158+
})
159+
it('applies the shared file budget across workspace and organization scopes', async () => {
160+
const run = control('files', true, 2)
161+
queueTableRows(schemaMock.workspaceFiles, [{ id: 'workspace-file' }])
162+
queueTableRows(schemaMock.workspaceFiles, [])
163+
await runBoundedSoftDeleteScope(scope, run)
164+
queueTableRows(schemaMock.workspaceFiles, [{ id: 'organization-file' }])
165+
await runBoundedSoftDeleteScope(
166+
{ ...scope, workspaceIds: [], organizationIds: ['org-one'] },
167+
run
168+
)
169+
expect(run.progress.stages.files?.selected).toBe(2)
170+
expect(storage).not.toHaveBeenCalled()
171+
})
172+
})
173+
174+
describe('bounded file billing', () => {
175+
it('rechecks the exact payer workspace and decrements only bytes actually deleted', async () => {
176+
queueTableRows(schemaMock.workspaceFiles, [
177+
{ id: 'file-one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: 100 },
178+
])
179+
billing.mockResolvedValue({ workspaceId: 'ws-one' })
180+
dbChainMockFns.returning.mockResolvedValue([{ id: 'file-one', sizeBytes: 40 }])
181+
const run = control('files', false)
182+
await runBoundedSoftDeleteScope({ ...scope, workspaceIds: ['ws-one', 'ws-two'] }, run)
183+
expect(decrement).toHaveBeenCalledWith(
184+
expect.anything(),
185+
expect.objectContaining({ workspaceId: 'ws-one' }),
186+
40
187+
)
188+
expect(
189+
dbChainMockFns.where.mock.calls.some(([predicate]) =>
190+
hasMockCondition(
191+
predicate,
192+
(condition) =>
193+
condition.type === 'eq' &&
194+
condition.left === schemaMock.workspaceFiles.workspaceId &&
195+
condition.right === 'ws-one'
196+
)
197+
)
198+
).toBe(true)
199+
expect(run.progress.stages.files?.deleted).toBe(1)
200+
})
201+
it('validates canonical sizes before deleting any storage', async () => {
202+
queueTableRows(schemaMock.workspaceFiles, [
203+
{ id: 'file-one', key: 'blob', context: 'workspace', workspaceId: 'ws-one', sizeBytes: null },
204+
])
205+
await expect(runBoundedSoftDeleteScope(scope, control('files', false))).rejects.toThrow(
206+
'canonical size_bytes'
207+
)
208+
expect(storage).not.toHaveBeenCalled()
209+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
210+
})
211+
})

0 commit comments

Comments
 (0)