@@ -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} )
0 commit comments