@@ -19,8 +19,13 @@ vi.mock('@sim/db', () => ({
1919 } ,
2020} ) )
2121
22- import { readyEventTypesQuery } from '@/lib/core/outbox/queries'
2322import {
23+ dueOutboxWorkQuery ,
24+ readyEventTypesQuery ,
25+ STUCK_PROCESSING_THRESHOLD_MS ,
26+ } from '@/lib/core/outbox/queries'
27+ import {
28+ hasDueOutboxWork ,
2429 type OutboxHandler ,
2530 processOutboxEvents ,
2631 withOutboxHandlerTimeout ,
@@ -386,4 +391,62 @@ describe('outbox scheduling in PostgreSQL', () => {
386391 const second = await processOutboxEvents ( { } , { batchSize : 0 } )
387392 expect ( second . reaped ) . toBe ( 5 )
388393 } )
394+
395+ describe ( 'due-work gate' , ( ) => {
396+ const now = new Date ( '2026-09-16T12:34:00.000Z' )
397+
398+ async function insertRow (
399+ status : 'pending' | 'processing' | 'completed' | 'dead_letter' ,
400+ times : { availableAt ?: Date ; lockedAt ?: Date | null }
401+ ) {
402+ eventTypes . add ( 'test.outbox.gate' )
403+ await db . insert ( outboxEvent ) . values ( {
404+ id : generateId ( ) ,
405+ eventType : 'test.outbox.gate' ,
406+ payload : { } ,
407+ status,
408+ createdAt : new Date ( now . getTime ( ) - 60 * 60_000 ) ,
409+ availableAt : times . availableAt ?? new Date ( now . getTime ( ) - 60 * 60_000 ) ,
410+ lockedAt : times . lockedAt ?? null ,
411+ } )
412+ }
413+
414+ it ( 'reports no work for future, freshly leased, completed and dead-lettered rows' , async ( ) => {
415+ expect ( await hasDueOutboxWork ( now ) ) . toBe ( false )
416+ await insertRow ( 'pending' , { availableAt : new Date ( now . getTime ( ) + 1 ) } )
417+ await insertRow ( 'processing' , {
418+ lockedAt : new Date ( now . getTime ( ) - STUCK_PROCESSING_THRESHOLD_MS + 1 ) ,
419+ } )
420+ await insertRow ( 'completed' , { } )
421+ await insertRow ( 'dead_letter' , { } )
422+ expect ( await hasDueOutboxWork ( now ) ) . toBe ( false )
423+ } )
424+
425+ it ( 'reports a pending event due exactly now, as the claim phase would take it' , async ( ) => {
426+ await insertRow ( 'pending' , { availableAt : now } )
427+ expect ( await hasDueOutboxWork ( now ) ) . toBe ( true )
428+ } )
429+
430+ it ( 'reports a lease exactly at the stale threshold, as the reaper would reclaim it' , async ( ) => {
431+ await insertRow ( 'processing' , {
432+ lockedAt : new Date ( now . getTime ( ) - STUCK_PROCESSING_THRESHOLD_MS ) ,
433+ } )
434+ expect ( await hasDueOutboxWork ( now ) ) . toBe ( true )
435+ } )
436+
437+ it ( 'probes both legs through indexes beside a large completed and future backlog' , async ( ) => {
438+ await seedBacklog ( 'test.outbox.gate' , 50_000 , 'completed' )
439+ await seedBacklog ( 'test.outbox.gate' , 50_000 , 'pending' , new Date ( Date . now ( ) + 60 * 60_000 ) )
440+ await connection `VACUUM (ANALYZE) outbox_event`
441+ const gateNow = new Date ( )
442+ expect ( await hasDueOutboxWork ( gateNow ) ) . toBe ( false )
443+
444+ const plans = await db . execute < { 'QUERY PLAN' : { Plan : QueryPlan } [ ] } > ( sql `
445+ EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${ dueOutboxWorkQuery ( gateNow ) }
446+ ` )
447+ const plan = plans [ 0 ] [ 'QUERY PLAN' ] [ 0 ] . Plan
448+ expect ( planNodes ( plan ) . some ( ( node ) => node [ 'Node Type' ] === 'Seq Scan' ) ) . toBe ( false )
449+ expect ( plan [ 'Shared Hit Blocks' ] + plan [ 'Shared Read Blocks' ] ) . toBeLessThan ( 100 )
450+ } , 60_000 )
451+ } )
389452} )
0 commit comments