Skip to content

Commit 320843e

Browse files
improvement(cron): run stale execution and file version cleanup off the request path (#8535)
* improvement(cron): run stale execution cleanup as a background task * fix(cron): run sync-log retention test against the cleanup task * fix(cron): assert stale cleanup route by its response only * fix(cron): cover stale cleanup dispatch window through the route response * improvement(cron): dispatch file version cleanup from a background task * fix(cron): retry and serialize cleanup dispatch on the task definition
1 parent 004d3d3 commit 320843e

10 files changed

Lines changed: 1426 additions & 1232 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createMockRequest } from '@sim/testing'
2+
import { asyncJobsMock, asyncJobsMockFns } from '@sim/testing/mocks/async-jobs.mock'
3+
import { authInternalMock, authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/auth/internal', () => authInternalMock)
7+
vi.mock('@/lib/core/async-jobs', () => asyncJobsMock)
8+
9+
import { GET } from '@/app/api/cron/cleanup-file-versions/route'
10+
11+
const { mockVerifyCronAuth } = authInternalMockFns
12+
13+
const mockEnqueue = asyncJobsMockFns.mockJobQueue.enqueue
14+
15+
function request() {
16+
return createMockRequest(
17+
'GET',
18+
undefined,
19+
{},
20+
'http://localhost:3000/api/cron/cleanup-file-versions'
21+
)
22+
}
23+
24+
/** The job id a dispatch was keyed to, as the queue reports it back. */
25+
async function dispatchedJobId(): Promise<string> {
26+
const response = await GET(request())
27+
expect(response.status).toBe(200)
28+
const body = (await response.json()) as { jobId: string }
29+
return body.jobId
30+
}
31+
32+
describe('file version cleanup route', () => {
33+
beforeEach(() => {
34+
vi.useFakeTimers()
35+
vi.setSystemTime(new Date('2026-10-01T03:30:00Z'))
36+
mockVerifyCronAuth.mockReturnValue(null)
37+
mockEnqueue.mockReset()
38+
})
39+
40+
afterEach(() => {
41+
vi.useRealTimers()
42+
})
43+
44+
it('fails the cron invocation when the dispatch cannot be enqueued', async () => {
45+
mockEnqueue.mockRejectedValueOnce(new Error('queue unavailable'))
46+
47+
const response = await GET(request())
48+
49+
expect(response.status).toBe(500)
50+
})
51+
52+
describe('with a queue that keys each job by the id it is given', () => {
53+
beforeEach(() => {
54+
mockEnqueue.mockImplementation(
55+
async (_type: string, _payload: unknown, options: { jobId: string }) => options.jobId
56+
)
57+
})
58+
59+
it('dispatches a retry on the same day as the same job', async () => {
60+
const first = await dispatchedJobId()
61+
vi.setSystemTime(new Date('2026-10-01T23:59:59.999Z'))
62+
63+
expect(await dispatchedJobId()).toBe(first)
64+
})
65+
66+
it('dispatches a new job on the next day', async () => {
67+
const first = await dispatchedJobId()
68+
vi.setSystemTime(new Date('2026-10-02T00:00:00.000Z'))
69+
70+
expect(await dispatchedJobId()).not.toBe(first)
71+
})
72+
})
73+
})

‎apps/sim/app/api/cron/cleanup-file-versions/route.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,41 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { verifyCronAuth } from '@/lib/auth/internal'
4-
import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
4+
import { getJobQueue } from '@/lib/core/async-jobs'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { CLEANUP_DISPATCH_MAX_ATTEMPTS } from '@/background/cleanup-dispatch'
67

78
export const dynamic = 'force-dynamic'
89

910
const logger = createLogger('FileVersionCleanupAPI')
11+
const FILE_VERSION_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
1012

1113
/** GET /api/cron/cleanup-file-versions — dispatch retention for superseded workspace file versions. */
1214
export const GET = withRouteHandler(async (request: NextRequest) => {
1315
try {
1416
const authError = verifyCronAuth(request, 'file version cleanup')
1517
if (authError) return authError
1618

17-
const result = await dispatchCleanupJobs('cleanup-file-versions')
19+
const queue = await getJobQueue()
20+
const scheduleWindow = Math.floor(Date.now() / FILE_VERSION_CLEANUP_INTERVAL_MS)
21+
const jobId = await queue.enqueue(
22+
'cleanup-dispatch',
23+
{ jobType: 'cleanup-file-versions' },
24+
{
25+
maxAttempts: CLEANUP_DISPATCH_MAX_ATTEMPTS,
26+
jobId: `cleanup-dispatch:cleanup-file-versions:${scheduleWindow}`,
27+
name: 'File version cleanup dispatch',
28+
concurrencyKey: 'cleanup-dispatch:cleanup-file-versions',
29+
concurrencyLimit: 1,
30+
runner: async () => {
31+
const { dispatchCleanupJobs } = await import('@/lib/billing/cleanup-dispatcher')
32+
return dispatchCleanupJobs('cleanup-file-versions')
33+
},
34+
}
35+
)
1836

19-
logger.info('File version cleanup jobs dispatched', result)
20-
21-
return NextResponse.json({ triggered: true, ...result })
37+
logger.info('File version cleanup dispatch enqueued', { jobId })
38+
return NextResponse.json({ triggered: true, jobId })
2239
} catch (error) {
2340
logger.error('Failed to dispatch file version cleanup jobs:', { error })
2441
return NextResponse.json({ error: 'Failed to dispatch file version cleanup' }, { status: 500 })

0 commit comments

Comments
 (0)