Skip to content

Commit c03304f

Browse files
committed
fix(execution): lock cleanup candidate batches
1 parent 96796e9 commit c03304f

2 files changed

Lines changed: 65 additions & 39 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,18 @@ describe('stale execution cleanup deadline grace', () => {
194194
}
195195
})
196196

197+
it('claims every cleanup page without overlapping concurrent workers', async () => {
198+
const response = await GET(createRequest())
199+
200+
expect(response.status).toBe(200)
201+
expect(dbChainMockFns.transaction).toHaveBeenCalledOnce()
202+
expect(dbChainMockFns.for).toHaveBeenCalledTimes(7)
203+
for (const [strength, options] of dbChainMockFns.for.mock.calls) {
204+
expect(strength).toBe('update')
205+
expect(options).toEqual({ skipLocked: true })
206+
}
207+
})
208+
197209
it('caps every bulk mutation and returns only scalar export cleanup fields', async () => {
198210
const stateBatch = Array.from({ length: 1000 }, (_, index) => ({ id: `state-${index}` }))
199211
const retentionBatch = Array.from({ length: 2000 }, (_, index) => ({

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

Lines changed: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ interface RunBatchedMutationOptions<TRow> {
7070
}
7171

7272
/**
73-
* Runs a mutation in bounded pages. Candidate selection happens inside each
74-
* mutation query, so only the bounded RETURNING rows enter application memory.
73+
* Runs a mutation in bounded pages. Each mutation must select candidates inside
74+
* the mutation statement with `FOR UPDATE SKIP LOCKED`, so concurrent cleanup
75+
* workers claim disjoint pages and a short RETURNING page proves exhaustion.
7576
*/
7677
async function runBatchedMutation<TRow>({
7778
batchSize,
@@ -147,46 +148,52 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
147148
WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN - workflowRowsConsidered
148149
)
149150
currentWorkflowBatchSize = 0
150-
const candidates = await db
151-
.select({ id: workflowExecutionLogs.id })
152-
.from(workflowExecutionLogs)
153-
.where(staleExecutionPredicate)
154-
.limit(limit)
155-
currentWorkflowBatchSize = candidates.length
156-
staleExecutionsFound += candidates.length
157-
if (candidates.length === 0) break
151+
const { candidates, updatedExecutions } = await db.transaction(async (tx) => {
152+
const candidates = await tx
153+
.select({ id: workflowExecutionLogs.id })
154+
.from(workflowExecutionLogs)
155+
.where(staleExecutionPredicate)
156+
.limit(limit)
157+
.for('update', { skipLocked: true })
158+
currentWorkflowBatchSize = candidates.length
159+
if (candidates.length === 0) return { candidates, updatedExecutions: [] }
158160

159-
const updatedExecutions = await db
160-
.update(workflowExecutionLogs)
161-
.set({
162-
status: 'failed',
163-
endedAt: now,
164-
executionDeadlineAt: null,
165-
totalDurationMs,
166-
executionData: sql`jsonb_set(
167-
COALESCE(execution_data, '{}'::jsonb),
168-
ARRAY['error'],
169-
to_jsonb(
170-
CASE
171-
WHEN ${workflowExecutionLogs.executionDeadlineAt} IS NOT NULL
172-
THEN ${EXECUTION_DEADLINE_ERROR}::text
173-
ELSE ${'Execution terminated: worker timeout or crash after '}::text
174-
|| ${staleDurationMinutes}::text
175-
|| ' minutes'
176-
END
177-
)
178-
)`,
179-
})
180-
.where(
181-
and(
182-
staleExecutionPredicate,
183-
inArray(
184-
workflowExecutionLogs.id,
185-
candidates.map(({ id }) => id)
161+
const updatedExecutions = await tx
162+
.update(workflowExecutionLogs)
163+
.set({
164+
status: 'failed',
165+
endedAt: now,
166+
executionDeadlineAt: null,
167+
totalDurationMs,
168+
executionData: sql`jsonb_set(
169+
COALESCE(execution_data, '{}'::jsonb),
170+
ARRAY['error'],
171+
to_jsonb(
172+
CASE
173+
WHEN ${workflowExecutionLogs.executionDeadlineAt} IS NOT NULL
174+
THEN ${EXECUTION_DEADLINE_ERROR}::text
175+
ELSE ${'Execution terminated: worker timeout or crash after '}::text
176+
|| ${staleDurationMinutes}::text
177+
|| ' minutes'
178+
END
179+
)
180+
)`,
181+
})
182+
.where(
183+
and(
184+
staleExecutionPredicate,
185+
inArray(
186+
workflowExecutionLogs.id,
187+
candidates.map(({ id }) => id)
188+
)
186189
)
187190
)
188-
)
189-
.returning({ id: workflowExecutionLogs.id })
191+
.returning({ id: workflowExecutionLogs.id })
192+
193+
return { candidates, updatedExecutions }
194+
})
195+
staleExecutionsFound += candidates.length
196+
if (candidates.length === 0) break
190197

191198
cleaned += updatedExecutions.length
192199
workflowRowsConsidered += candidates.length
@@ -202,6 +209,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
202209
logger.error('Failed to clean up stale workflow executions:', {
203210
error: toError(error).message,
204211
})
212+
staleExecutionsFound += currentWorkflowBatchSize
205213
failed += currentWorkflowBatchSize
206214
}
207215

@@ -237,6 +245,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
237245
.from(asyncJobs)
238246
.where(staleProcessingPredicate)
239247
.limit(limit)
248+
.for('update', { skipLocked: true })
240249

241250
return db
242251
.update(asyncJobs)
@@ -292,6 +301,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
292301
.from(tableJobs)
293302
.where(staleTableJobPredicate)
294303
.limit(limit)
304+
.for('update', { skipLocked: true })
295305

296306
return db
297307
.update(tableJobs)
@@ -325,6 +335,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
325335
.from(tableJobs)
326336
.where(terminalTableJobPredicate)
327337
.limit(limit)
338+
.for('update', { skipLocked: true })
328339

329340
return db
330341
.delete(tableJobs)
@@ -379,6 +390,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
379390
.from(asyncJobs)
380391
.where(stalePendingPredicate)
381392
.limit(limit)
393+
.for('update', { skipLocked: true })
382394

383395
return db
384396
.update(asyncJobs)
@@ -426,6 +438,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
426438
.from(asyncJobs)
427439
.where(retainedJobPredicate)
428440
.limit(limit)
441+
.for('update', { skipLocked: true })
429442

430443
return db
431444
.delete(asyncJobs)
@@ -489,6 +502,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
489502
)
490503
)
491504
.limit(DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE)
505+
.for('update', { skipLocked: true })
492506

493507
const deleted = await db
494508
.delete(workflowDeploymentOperation)

0 commit comments

Comments
 (0)