Skip to content

Commit e3f33d1

Browse files
committed
fix(execution): drain stale workflow backlog
1 parent d8f777e commit e3f33d1

2 files changed

Lines changed: 182 additions & 70 deletions

File tree

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

Lines changed: 121 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,7 @@ describe('stale execution cleanup deadline grace', () => {
4949
it('waits five minutes past a workflow execution deadline in both cleanup predicates', async () => {
5050
vi.useFakeTimers()
5151
vi.setSystemTime(new Date('2026-08-03T12:10:00.000Z'))
52-
queueTableRows(workflowExecutionLogs, [
53-
{
54-
id: 'log-1',
55-
executionId: 'execution-1',
56-
workflowId: 'workflow-1',
57-
startedAt: new Date('2026-08-03T11:00:00.000Z'),
58-
executionDeadlineAt: new Date('2026-08-03T12:00:00.000Z'),
59-
},
60-
])
52+
queueTableRows(workflowExecutionLogs, [{ id: 'log-1' }])
6153
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'log-1' }])
6254

6355
try {
@@ -84,17 +76,39 @@ describe('stale execution cleanup deadline grace', () => {
8476
([table]) => table === workflowExecutionLogs
8577
)
8678
const update = dbChainMockFns.set.mock.calls[executionUpdateIndex]?.[0] as {
79+
endedAt: Date
80+
totalDurationMs: { toSQL: () => { sql: string; params: unknown[] } }
8781
executionData: { toSQL: () => { sql: string; params: unknown[] } }
8882
}
8983
const errorExpression = update.executionData.toSQL()
84+
const staleDurationExpression = errorExpression.params.find(
85+
(value): value is { toSQL: () => { sql: string; params: unknown[] } } =>
86+
typeof value === 'object' &&
87+
value !== null &&
88+
'toSQL' in value &&
89+
value.toSQL().sql.includes('EXTRACT(EPOCH')
90+
)
91+
const totalDurationExpression = update.totalDurationMs.toSQL()
92+
const cleanupTimestamp = totalDurationExpression.params.find(
93+
(value): value is { toSQL: () => { sql: string; params: unknown[] } } =>
94+
typeof value === 'object' && value !== null && 'toSQL' in value
95+
)
9096

9197
expect(errorExpression.sql).toContain('CASE')
9298
expect(errorExpression.sql).toContain('IS NOT NULL')
9399
expect(errorExpression.params).toContain(workflowExecutionLogs.executionDeadlineAt)
94100
expect(errorExpression.params).toContain('Execution timed out')
95101
expect(errorExpression.params).toContain(
96-
'Execution terminated: worker timeout or crash after 70 minutes'
102+
'Execution terminated: worker timeout or crash after '
97103
)
104+
expect(staleDurationExpression?.toSQL().sql).toContain('ROUND')
105+
expect(staleDurationExpression?.toSQL().params).toContain(workflowExecutionLogs.startedAt)
106+
expect(totalDurationExpression.sql).toContain('LEAST')
107+
expect(totalDurationExpression.sql).toContain('ROUND')
108+
expect(totalDurationExpression.params).toContain(2_147_483_647)
109+
expect(totalDurationExpression.params).toContain(workflowExecutionLogs.startedAt)
110+
expect(cleanupTimestamp?.toSQL().params).toEqual([new Date('2026-08-03T12:10:00.000Z')])
111+
expect(update.endedAt).toEqual(new Date('2026-08-03T12:10:00.000Z'))
98112
} finally {
99113
vi.useRealTimers()
100114
}
@@ -190,6 +204,13 @@ describe('stale execution cleanup deadline grace', () => {
190204
resultKey: `workspace/workspace-1/exports/table-1/job-${index}/export.csv`,
191205
}))
192206

207+
for (let batch = 0; batch < 10; batch++) {
208+
const workflowBatch = Array.from({ length: 100 }, (_, index) => ({
209+
id: `workflow-state-${batch}-${index}`,
210+
}))
211+
queueTableRows(workflowExecutionLogs, workflowBatch)
212+
dbChainMockFns.returning.mockResolvedValueOnce(workflowBatch)
213+
}
193214
for (let batch = 0; batch < 10; batch++) {
194215
dbChainMockFns.returning.mockResolvedValueOnce(stateBatch)
195216
}
@@ -211,6 +232,11 @@ describe('stale execution cleanup deadline grace', () => {
211232

212233
expect(response.status).toBe(200)
213234
await expect(response.json()).resolves.toMatchObject({
235+
executions: {
236+
found: 1000,
237+
cleaned: 1000,
238+
failed: 0,
239+
},
214240
asyncJobs: {
215241
staleProcessingMarkedFailed: 10_000,
216242
stalePendingMarkedFailed: 10_000,
@@ -223,14 +249,98 @@ describe('stale execution cleanup deadline grace', () => {
223249
expect(mockDeleteFile).toHaveBeenCalledTimes(1000)
224250

225251
const limits = dbChainMockFns.limit.mock.calls.map(([limit]) => limit)
226-
expect(limits.filter((limit) => limit === 100)).toHaveLength(11)
252+
expect(limits.filter((limit) => limit === 100)).toHaveLength(20)
227253
expect(limits.filter((limit) => limit === 1000)).toHaveLength(30)
228254
expect(limits.filter((limit) => limit === 2000)).toHaveLength(11)
229255

256+
const workflowUpdates = dbChainMockFns.update.mock.calls.filter(
257+
([table]) => table === workflowExecutionLogs
258+
)
259+
expect(workflowUpdates).toHaveLength(10)
260+
230261
const returningShapes = dbChainMockFns.returning.mock.calls
231262
.map(([shape]) => shape)
232263
.filter((shape): shape is Record<string, unknown> => Boolean(shape))
233264
expect(returningShapes.some((shape) => 'payload' in shape)).toBe(false)
234265
expect(returningShapes.some((shape) => 'type' in shape && 'resultKey' in shape)).toBe(true)
235266
})
267+
268+
it('drains more than the legacy 100-row workflow cap in one bounded run', async () => {
269+
const firstBatch = Array.from({ length: 100 }, (_, index) => ({
270+
id: `execution-${index}`,
271+
}))
272+
const secondBatch = [{ id: 'execution-100' }]
273+
queueTableRows(workflowExecutionLogs, firstBatch)
274+
queueTableRows(workflowExecutionLogs, secondBatch)
275+
dbChainMockFns.returning.mockResolvedValueOnce(firstBatch).mockResolvedValueOnce(secondBatch)
276+
277+
const response = await GET(createRequest())
278+
279+
expect(response.status).toBe(200)
280+
await expect(response.json()).resolves.toMatchObject({
281+
executions: {
282+
found: 101,
283+
cleaned: 101,
284+
failed: 0,
285+
},
286+
})
287+
expect(
288+
dbChainMockFns.update.mock.calls.filter(([table]) => table === workflowExecutionLogs)
289+
).toHaveLength(2)
290+
expect(dbChainMockFns.limit).toHaveBeenCalledWith(100)
291+
})
292+
293+
it('preserves committed workflow cleanup counts when a later batch fails', async () => {
294+
const firstBatch = Array.from({ length: 100 }, (_, index) => ({
295+
id: `execution-${index}`,
296+
}))
297+
const failedBatch = Array.from({ length: 37 }, (_, index) => ({
298+
id: `failed-execution-${index}`,
299+
}))
300+
queueTableRows(workflowExecutionLogs, firstBatch)
301+
queueTableRows(workflowExecutionLogs, failedBatch)
302+
dbChainMockFns.returning
303+
.mockResolvedValueOnce(firstBatch)
304+
.mockRejectedValueOnce(new Error('database unavailable'))
305+
306+
const response = await GET(createRequest())
307+
308+
expect(response.status).toBe(200)
309+
await expect(response.json()).resolves.toMatchObject({
310+
executions: {
311+
found: 137,
312+
cleaned: 100,
313+
failed: 37,
314+
},
315+
})
316+
expect(
317+
dbChainMockFns.update.mock.calls.filter(([table]) => table === workflowExecutionLogs)
318+
).toHaveLength(2)
319+
expect(dbChainMockFns.update.mock.calls.some(([table]) => table === asyncJobs)).toBe(true)
320+
})
321+
322+
it('continues draining when an atomic race updates fewer rows than were selected', async () => {
323+
const firstCandidates = Array.from({ length: 100 }, (_, index) => ({
324+
id: `execution-${index}`,
325+
}))
326+
const firstUpdated = firstCandidates.slice(0, 99)
327+
const secondBatch = [{ id: 'execution-100' }]
328+
queueTableRows(workflowExecutionLogs, firstCandidates)
329+
queueTableRows(workflowExecutionLogs, secondBatch)
330+
dbChainMockFns.returning.mockResolvedValueOnce(firstUpdated).mockResolvedValueOnce(secondBatch)
331+
332+
const response = await GET(createRequest())
333+
334+
expect(response.status).toBe(200)
335+
await expect(response.json()).resolves.toMatchObject({
336+
executions: {
337+
found: 101,
338+
cleaned: 100,
339+
failed: 0,
340+
},
341+
})
342+
expect(
343+
dbChainMockFns.update.mock.calls.filter(([table]) => table === workflowExecutionLogs)
344+
).toHaveLength(2)
345+
})
236346
})

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

Lines changed: 61 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const TABLE_JOB_RETENTION_HOURS = 24
4848
const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30
4949
const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000
5050
const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10
51+
const WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE = 100
52+
const WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN = 1000
5153
const STATE_MUTATION_BATCH_SIZE = 1000
5254
const STATE_MUTATION_MAX_ROWS_PER_RUN = 10_000
5355
const RETENTION_DELETE_BATCH_SIZE = 2000
@@ -114,45 +116,51 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
114116
now.getTime() - TABLE_JOB_STALE_THRESHOLD_MINUTES * 60 * 1000
115117
)
116118

117-
const staleExecutions = await db
118-
.select({
119-
id: workflowExecutionLogs.id,
120-
executionId: workflowExecutionLogs.executionId,
121-
workflowId: workflowExecutionLogs.workflowId,
122-
startedAt: workflowExecutionLogs.startedAt,
123-
executionDeadlineAt: workflowExecutionLogs.executionDeadlineAt,
124-
})
125-
.from(workflowExecutionLogs)
126-
.where(
127-
and(
128-
eq(workflowExecutionLogs.status, 'running'),
129-
or(
130-
lt(workflowExecutionLogs.executionDeadlineAt, staleDeadlineThreshold),
131-
and(
132-
isNull(workflowExecutionLogs.executionDeadlineAt),
133-
lt(workflowExecutionLogs.startedAt, staleThreshold)
134-
)
135-
)
136-
)
137-
)
138-
.limit(100)
139-
140-
logger.info(`Found ${staleExecutions.length} stale executions to clean up`)
141-
119+
let staleExecutionsFound = 0
142120
let cleaned = 0
143121
let failed = 0
122+
let currentWorkflowBatchSize = 0
144123

145-
for (const execution of staleExecutions) {
146-
try {
147-
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
148-
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
149-
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
150-
151-
const [updatedExecution] = await db
124+
try {
125+
const staleExecutionPredicate = and(
126+
eq(workflowExecutionLogs.status, 'running'),
127+
or(
128+
lt(workflowExecutionLogs.executionDeadlineAt, staleDeadlineThreshold),
129+
and(
130+
isNull(workflowExecutionLogs.executionDeadlineAt),
131+
lt(workflowExecutionLogs.startedAt, staleThreshold)
132+
)
133+
)
134+
)
135+
const cleanupTimestamp = sql.param(now, workflowExecutionLogs.startedAt)
136+
const staleDurationMinutes = sql<number>`ROUND(
137+
EXTRACT(EPOCH FROM (${cleanupTimestamp} - ${workflowExecutionLogs.startedAt})) / 60
138+
)::integer`
139+
const totalDurationMs = sql<number>`LEAST(
140+
${MAX_INT32},
141+
ROUND(EXTRACT(EPOCH FROM (${cleanupTimestamp} - ${workflowExecutionLogs.startedAt})) * 1000)
142+
)::integer`
143+
let workflowRowsConsidered = 0
144+
while (workflowRowsConsidered < WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
145+
const limit = Math.min(
146+
WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE,
147+
WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN - workflowRowsConsidered
148+
)
149+
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
158+
159+
const updatedExecutions = await db
152160
.update(workflowExecutionLogs)
153161
.set({
154162
status: 'failed',
155-
endedAt: new Date(),
163+
endedAt: now,
156164
executionDeadlineAt: null,
157165
totalDurationMs,
158166
executionData: sql`jsonb_set(
@@ -162,45 +170,39 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
162170
CASE
163171
WHEN ${workflowExecutionLogs.executionDeadlineAt} IS NOT NULL
164172
THEN ${EXECUTION_DEADLINE_ERROR}::text
165-
ELSE ${`Execution terminated: worker timeout or crash after ${staleDurationMinutes} minutes`}::text
173+
ELSE ${'Execution terminated: worker timeout or crash after '}::text
174+
|| ${staleDurationMinutes}::text
175+
|| ' minutes'
166176
END
167177
)
168178
)`,
169179
})
170180
.where(
171181
and(
172-
eq(workflowExecutionLogs.id, execution.id),
173-
eq(workflowExecutionLogs.status, 'running'),
174-
or(
175-
lt(workflowExecutionLogs.executionDeadlineAt, staleDeadlineThreshold),
176-
and(
177-
isNull(workflowExecutionLogs.executionDeadlineAt),
178-
lt(workflowExecutionLogs.startedAt, staleThreshold)
179-
)
182+
staleExecutionPredicate,
183+
inArray(
184+
workflowExecutionLogs.id,
185+
candidates.map(({ id }) => id)
180186
)
181187
)
182188
)
183189
.returning({ id: workflowExecutionLogs.id })
184190

185-
if (!updatedExecution) {
186-
logger.debug('Skipped stale execution whose state changed during cleanup', {
187-
executionId: execution.executionId,
188-
})
189-
continue
190-
}
191-
192-
logger.info(`Cleaned up stale execution ${execution.executionId}`, {
193-
workflowId: execution.workflowId,
194-
staleDurationMinutes,
195-
})
191+
cleaned += updatedExecutions.length
192+
workflowRowsConsidered += candidates.length
193+
if (candidates.length < limit) break
194+
}
196195

197-
cleaned++
198-
} catch (error) {
199-
logger.error(`Failed to clean up execution ${execution.executionId}:`, {
200-
error: toError(error).message,
196+
if (workflowRowsConsidered >= WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN) {
197+
logger.info('Deferred remaining stale workflow executions after reaching the per-run cap', {
198+
maxRowsPerRun: WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN,
201199
})
202-
failed++
203200
}
201+
} catch (error) {
202+
logger.error('Failed to clean up stale workflow executions:', {
203+
error: toError(error).message,
204+
})
205+
failed += currentWorkflowBatchSize
204206
}
205207

206208
logger.info(`Stale execution cleanup completed. Cleaned: ${cleaned}, Failed: ${failed}`)
@@ -510,7 +512,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
510512
return NextResponse.json({
511513
success: true,
512514
executions: {
513-
found: staleExecutions.length,
515+
found: staleExecutionsFound,
514516
cleaned,
515517
failed,
516518
thresholdMinutes: STALE_THRESHOLD_MINUTES,

0 commit comments

Comments
 (0)