@@ -17,6 +17,7 @@ vi.mock('@sim/db', () => ({
1717 } ,
1818} ) )
1919
20+ import { readyEventTypesQuery } from '@/lib/core/outbox/queries'
2021import {
2122 type OutboxHandler ,
2223 processOutboxEvents ,
@@ -26,6 +27,8 @@ import {
2627interface QueryPlan {
2728 'Node Type' : string
2829 'Index Name' ?: string
30+ 'Shared Hit Blocks' : number
31+ 'Shared Read Blocks' : number
2932 Plans ?: QueryPlan [ ]
3033}
3134
@@ -90,10 +93,10 @@ describe('outbox scheduling in PostgreSQL', () => {
9093 async function seedBacklog (
9194 eventType : string ,
9295 count : number ,
93- status : 'pending' | 'completed' | 'processing'
96+ status : 'pending' | 'completed' | 'processing' ,
97+ availableAt = new Date ( Date . now ( ) - 60_000 )
9498 ) {
9599 const prefix = generateId ( )
96- const availableAt = new Date ( Date . now ( ) - 60_000 )
97100 const createdAt = new Date ( Date . now ( ) - 24 * 60 * 60_000 )
98101 const lockedAt = status === 'processing' ? new Date ( Date . now ( ) - 11 * 60_000 ) : null
99102 eventTypes . add ( eventType )
@@ -110,6 +113,114 @@ describe('outbox scheduling in PostgreSQL', () => {
110113 }
111114 }
112115
116+ async function expectBoundedDiscovery ( now : Date ) {
117+ const plans = await db . execute < { 'QUERY PLAN' : { Plan : QueryPlan } [ ] } > ( sql `
118+ EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${ readyEventTypesQuery ( now ) }
119+ ` )
120+ const plan = plans [ 0 ] [ 'QUERY PLAN' ] [ 0 ] . Plan
121+ const nodes = planNodes ( plan )
122+ expect ( nodes . some ( ( node ) => node [ 'Node Type' ] === 'Recursive Union' ) ) . toBe ( true )
123+ expect ( nodes . some ( ( node ) => node [ 'Node Type' ] === 'Seq Scan' ) ) . toBe ( false )
124+ /** Buffer work, unlike wall-clock time, catches a backlog scan even on a warm local database. */
125+ expect ( plan [ 'Shared Hit Blocks' ] + plan [ 'Shared Read Blocks' ] ) . toBeLessThan ( 1_000 )
126+ }
127+
128+ it ( 'discovers an empty queue without returning a null type' , async ( ) => {
129+ expect ( await db . execute ( readyEventTypesQuery ( new Date ( ) ) ) ) . toEqual ( [ ] )
130+ } )
131+
132+ it ( 'uses earliest availability, inclusive deadlines, and deterministic type ties' , async ( ) => {
133+ const now = new Date ( '2026-01-01T12:00:00.000Z' )
134+ const fixtures = [
135+ { eventType : 'test.outbox.z-first' , availableAt : new Date ( now . getTime ( ) - 1 ) } ,
136+ { eventType : 'test.outbox.z-first' , availableAt : new Date ( now . getTime ( ) + 60_000 ) } ,
137+ { eventType : 'test.outbox.b-tie' , availableAt : now } ,
138+ { eventType : 'test.outbox.a-tie' , availableAt : now } ,
139+ { eventType : 'test.outbox.future' , availableAt : new Date ( now . getTime ( ) + 1 ) } ,
140+ { eventType : 'test.outbox.completed' , availableAt : now , status : 'completed' } ,
141+ { eventType : 'test.outbox.processing' , availableAt : now , status : 'processing' } ,
142+ { eventType : 'test.outbox.dead' , availableAt : now , status : 'dead_letter' } ,
143+ ]
144+ for ( const fixture of fixtures ) eventTypes . add ( fixture . eventType )
145+ await db
146+ . insert ( outboxEvent )
147+ . values ( fixtures . map ( ( row ) => ( { id : generateId ( ) , payload : { } , ...row } ) ) )
148+
149+ expect ( await db . execute ( readyEventTypesQuery ( now ) ) ) . toEqual ( [
150+ { eventType : 'test.outbox.z-first' } ,
151+ { eventType : 'test.outbox.a-tie' } ,
152+ { eventType : 'test.outbox.b-tie' } ,
153+ ] )
154+ } )
155+
156+ it ( 'caps ready types after ordering all heads, including types unknown to this worker' , async ( ) => {
157+ const now = new Date ( )
158+ const rows = Array . from ( { length : 140 } , ( _ , index ) => ( {
159+ id : generateId ( ) ,
160+ eventType : `test.outbox.type-${ String ( index ) . padStart ( 3 , '0' ) } ` ,
161+ payload : { } ,
162+ availableAt : new Date ( now . getTime ( ) - index - 1 ) ,
163+ } ) )
164+ for ( const row of rows ) eventTypes . add ( row . eventType )
165+ await db . insert ( outboxEvent ) . values ( rows )
166+
167+ expect ( await db . execute ( readyEventTypesQuery ( now ) ) ) . toEqual (
168+ [ ...rows ]
169+ . reverse ( )
170+ . slice ( 0 , 128 )
171+ . map ( ( { eventType } ) => ( { eventType } ) )
172+ )
173+ } )
174+
175+ it ( 'skips large future backlogs and more than 128 future types without hiding ready work' , async ( ) => {
176+ const future = new Date ( Date . now ( ) + 48 * 60 * 60_000 )
177+ await seedBacklog ( 'test.outbox.a-expiry' , 100_000 , 'pending' , future )
178+ const futureTypes = Array . from ( { length : 130 } , ( _ , index ) => ( {
179+ id : generateId ( ) ,
180+ eventType : `test.outbox.future-${ index } ` ,
181+ payload : { } ,
182+ availableAt : future ,
183+ } ) )
184+ for ( const row of futureTypes ) eventTypes . add ( row . eventType )
185+ await db . insert ( outboxEvent ) . values ( futureTypes )
186+ await enqueue ( 'test.outbox.z-ready' , 1 )
187+ await connection `VACUUM (ANALYZE) outbox_event`
188+
189+ const now = new Date ( )
190+ expect ( await db . execute ( readyEventTypesQuery ( now ) ) ) . toEqual ( [
191+ { eventType : 'test.outbox.z-ready' } ,
192+ ] )
193+ await expectBoundedDiscovery ( now )
194+ expect ( await processOutboxEvents ( { 'test.outbox.z-ready' : async ( ) => { } } ) ) . toMatchObject ( {
195+ processed : 1 ,
196+ } )
197+ expect ( await db . execute ( readyEventTypesQuery ( new Date ( ) ) ) ) . toEqual ( [ ] )
198+ } , 60_000 )
199+
200+ it ( 'retains bounded retries for a type missing during a rolling deployment' , async ( ) => {
201+ const [ event ] = await enqueue ( 'test.outbox.unknown' , 1 )
202+
203+ expect ( await processOutboxEvents ( { } ) ) . toMatchObject ( { retried : 1 } )
204+ const [ pending ] = await db . select ( ) . from ( outboxEvent ) . where ( eq ( outboxEvent . id , event . id ) )
205+ expect ( pending ) . toMatchObject ( { status : 'pending' , attempts : 1 } )
206+ expect ( pending . availableAt . getTime ( ) ) . toBeGreaterThan ( Date . now ( ) )
207+ } )
208+
209+ it ( 'lets claims skip a locked head without hiding other rows of that type' , async ( ) => {
210+ const [ locked , available ] = await enqueue ( 'test.outbox.locked' , 2 )
211+ const delivered : string [ ] = [ ]
212+ await connection . begin ( async ( transaction ) => {
213+ await transaction `SELECT id FROM outbox_event WHERE id = ${ locked . id } FOR UPDATE`
214+ const result = await processOutboxEvents ( {
215+ 'test.outbox.locked' : async ( _payload , context ) => {
216+ delivered . push ( context . eventId )
217+ } ,
218+ } )
219+ expect ( result . processed ) . toBe ( 1 )
220+ } )
221+ expect ( delivered ) . toEqual ( [ available . id ] )
222+ } )
223+
113224 it ( 'serves newer event types before exhausting an older cleanup backlog' , async ( ) => {
114225 await enqueue ( 'test.outbox.cleanup' , 1_000 )
115226 const [ dispatch ] = await enqueue ( 'test.outbox.dispatch' , 1 , 2_000 )
@@ -191,7 +302,8 @@ describe('outbox scheduling in PostgreSQL', () => {
191302 await seedBacklog ( 'test.outbox.cleanup' , 100_000 , 'pending' )
192303 const [ dispatch ] = await enqueue ( 'test.outbox.dispatch' , 1 )
193304 const [ billing ] = await enqueue ( 'test.outbox.billing' , 1 )
194- await db . execute ( sql `ANALYZE outbox_event` )
305+ await connection `VACUUM (ANALYZE) outbox_event`
306+ await expectBoundedDiscovery ( new Date ( ) )
195307
196308 const plans = await db . execute < { 'QUERY PLAN' : { Plan : QueryPlan } [ ] } > ( sql `
197309 EXPLAIN (FORMAT JSON)
0 commit comments