@@ -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 */
7677async 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