Skip to content

Commit d8f777e

Browse files
committed
fix(tables): decouple stale job cleanup
1 parent 3ff0dba commit d8f777e

2 files changed

Lines changed: 50 additions & 4 deletions

File tree

apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { asyncJobs, workflowExecutionLogs } from '@sim/db/schema'
4+
import { asyncJobs, tableJobs, workflowExecutionLogs } from '@sim/db/schema'
55
import { createMockRequest, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { MAX_JOB_DURATION_SECONDS, MIN_JOB_DURATION_SECONDS } from '@/lib/core/async-jobs'
@@ -141,6 +141,45 @@ describe('stale execution cleanup deadline grace', () => {
141141
)
142142
})
143143

144+
it('keeps table-job heartbeat cleanup independent from workflow timeout policy', async () => {
145+
vi.useFakeTimers()
146+
vi.setSystemTime(new Date('2026-08-03T12:00:00.000Z'))
147+
148+
try {
149+
const response = await GET(createRequest())
150+
151+
expect(response.status).toBe(200)
152+
const expectedThreshold = new Date('2026-08-03T10:25:00.000Z')
153+
const tableJobComparisons = dbChainMockFns.where.mock.calls
154+
.flatMap(([condition]) => flattenConditions(condition))
155+
.filter(
156+
(condition) =>
157+
condition.type === 'lt' &&
158+
condition.left === tableJobs.updatedAt &&
159+
condition.right instanceof Date &&
160+
condition.right.getTime() === expectedThreshold.getTime()
161+
)
162+
163+
expect(tableJobComparisons).toHaveLength(2)
164+
expect(tableJobComparisons.map(({ right }) => right)).toEqual([
165+
expectedThreshold,
166+
expectedThreshold,
167+
])
168+
169+
const tableJobUpdateIndex = dbChainMockFns.update.mock.calls.findIndex(
170+
([table]) => table === tableJobs
171+
)
172+
const update = dbChainMockFns.set.mock.calls[tableJobUpdateIndex]?.[0] as {
173+
error: string
174+
}
175+
expect(update.error).toBe(
176+
'Job terminated: no progress for more than 95 minutes (worker timeout or crash)'
177+
)
178+
} finally {
179+
vi.useRealTimers()
180+
}
181+
})
182+
144183
it('caps every bulk mutation and returns only scalar export cleanup fields', async () => {
145184
const stateBatch = Array.from({ length: 1000 }, (_, index) => ({ id: `state-${index}` }))
146185
const retentionBatch = Array.from({ length: 2000 }, (_, index) => ({

apps/sim/app/api/cron/cleanup-stale-executions/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
3333
const GENERIC_STALE_PROCESSING_ERROR = `Job terminated: stuck in processing for more than ${STALE_THRESHOLD_MINUTES} minutes`
3434
const EXECUTION_DEADLINE_ERROR = getTimeoutErrorMessage(undefined)
3535
const MAX_INT32 = 2_147_483_647
36+
/**
37+
* Table jobs run as detached workers with progress heartbeats, independently of workflow timeout
38+
* policy. Preserve their historical 90-minute task window plus five-minute cleanup grace.
39+
*/
40+
const TABLE_JOB_STALE_THRESHOLD_MINUTES = 95
3641
/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */
3742
const TABLE_JOB_RETENTION_HOURS = 24
3843
/**
@@ -105,6 +110,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
105110
const stalePendingThreshold = new Date(
106111
now.getTime() - JOB_PENDING_RETENTION_HOURS * 60 * 60 * 1000
107112
)
113+
const staleTableJobThreshold = new Date(
114+
now.getTime() - TABLE_JOB_STALE_THRESHOLD_MINUTES * 60 * 1000
115+
)
108116

109117
const staleExecutions = await db
110118
.select({
@@ -269,10 +277,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
269277
// doesn't grow unbounded (the latest job per table is what list/detail reads surface).
270278
let staleTableJobsMarkedFailed = 0
271279
try {
272-
const now = new Date()
273280
const staleTableJobPredicate = and(
274281
eq(tableJobs.status, 'running'),
275-
lt(tableJobs.updatedAt, staleThreshold)
282+
lt(tableJobs.updatedAt, staleTableJobThreshold)
276283
)
277284
const staleTableJobResult = await runBatchedMutation({
278285
batchSize: STATE_MUTATION_BATCH_SIZE,
@@ -288,7 +295,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
288295
.update(tableJobs)
289296
.set({
290297
status: 'failed',
291-
error: `Job terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`,
298+
error: `Job terminated: no progress for more than ${TABLE_JOB_STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`,
292299
completedAt: now,
293300
updatedAt: now,
294301
})

0 commit comments

Comments
 (0)