@@ -368,6 +368,214 @@ describe('execution event buffer', () => {
368368 expect ( persistedEntries ) . toEqual ( [ ] )
369369 } )
370370
371+ /**
372+ * Requeueing a batch the budget rejected is what grew `pending` for a whole
373+ * run, each retry re-serializing an ever-larger array — the multi-GB heap
374+ * growth seen in production. Rejected bytes must be dropped, not retained.
375+ */
376+ it ( 'drops rejected batches instead of growing a backlog when the Redis budget is exhausted' , async ( ) => {
377+ mockRedis . incrby . mockResolvedValue ( 100000 )
378+ let budgetExhausted = true
379+ mockRedis . eval . mockImplementation ( async ( script : string , ...args : unknown [ ] ) => {
380+ if ( isFlushScript ( script ) ) {
381+ if ( budgetExhausted ) return [ 0 , 'execution_redis_bytes' , 64 * 1024 * 1024 ]
382+ const { zaddArgs } = parseFlushEvalArgs ( args )
383+ for ( let i = 0 ; i < zaddArgs . length ; i += 2 ) {
384+ persistedEntries . push ( JSON . parse ( zaddArgs [ i + 1 ] as string ) as ExecutionEventEntry )
385+ }
386+ return [ 1 , 1 , 0 ]
387+ }
388+ return [ 1 , 'ok' , 0 , 0 ]
389+ } )
390+
391+ const writer = createExecutionEventWriter ( 'exec-1' )
392+
393+ for ( let i = 0 ; i < 2500 ; i ++ ) {
394+ await writer . write ( makeEvent ( `block-${ i } ` ) ) . catch ( ( ) => { } )
395+ }
396+
397+ // Once the budget frees the writer recovers, but only whatever accumulated
398+ // since the last rejection — never a run-length backlog.
399+ budgetExhausted = false
400+ await writer . flush ( )
401+
402+ expect ( persistedEntries . length ) . toBeLessThanOrEqual ( 200 )
403+ } )
404+
405+ /**
406+ * Individual events are capped well below the single-write limit, but a burst
407+ * of large ones coalesces into a batch above it. Splitting is the only way the
408+ * buffer makes progress: no retry can shrink a batch it keeps whole.
409+ */
410+ it ( 'splits a batch that exceeds the single-write cap instead of stalling on it' , async ( ) => {
411+ mockRedis . incrby . mockResolvedValue ( 100 )
412+ // Each event stays under the 8MiB per-event cap; two of them do not.
413+ const bigPayload = 'x' . repeat ( 2_500_000 )
414+
415+ const writer = createExecutionEventWriter ( 'exec-1' )
416+ await writer . write ( makeEvent ( bigPayload ) )
417+ await writer . write ( makeEvent ( bigPayload ) )
418+ await writer . flush ( )
419+
420+ expect ( persistedEntries ) . toHaveLength ( 2 )
421+ expect (
422+ mockRedis . eval . mock . calls . filter ( ( [ script ] ) => isFlushScript ( script as string ) )
423+ ) . toHaveLength ( 2 )
424+ } )
425+
426+ it ( 'drops the terminal entry rather than leaving it queued when the budget is exhausted' , async ( ) => {
427+ mockRedis . incrby . mockResolvedValue ( 100 )
428+ let budgetExhausted = true
429+ mockRedis . eval . mockImplementation ( async ( script : string , ...args : unknown [ ] ) => {
430+ if ( isFlushScript ( script ) ) {
431+ if ( budgetExhausted ) return [ 0 , 'execution_redis_bytes' , 64 * 1024 * 1024 ]
432+ const { zaddArgs } = parseFlushEvalArgs ( args )
433+ for ( let i = 0 ; i < zaddArgs . length ; i += 2 ) {
434+ persistedEntries . push ( JSON . parse ( zaddArgs [ i + 1 ] as string ) as ExecutionEventEntry )
435+ }
436+ return [ 1 , 1 , 0 ]
437+ }
438+ return [ 1 , 'ok' , 0 , 0 ]
439+ } )
440+
441+ const writer = createExecutionEventWriter ( 'exec-1' )
442+
443+ await expect ( writer . writeTerminal ( makeEvent ( 'terminal' ) , 'complete' ) ) . rejects . toThrow (
444+ 'Execution memory limit exceeded'
445+ )
446+
447+ // The failed terminal write stays surfaced through flush(), but its entry must
448+ // not linger in the backlog and reappear once the budget frees up.
449+ budgetExhausted = false
450+ await writer . flush ( ) . catch ( ( ) => { } )
451+
452+ expect ( persistedEntries ) . toEqual ( [ ] )
453+ } )
454+
455+ /**
456+ * A timer-driven flush carries no terminal status of its own. If it is the
457+ * loop that drains the final chunk, the terminal event lands without a status
458+ * and readers poll an `active` stream forever — while `writeTerminal` reports
459+ * success, so nothing degrades.
460+ */
461+ it ( 'applies terminal status even when a concurrent scheduled flush drains the final chunk' , async ( ) => {
462+ mockRedis . incrby . mockResolvedValue ( 100 )
463+ const observedTerminalStatuses : string [ ] = [ ]
464+ let releaseFirstFlush : ( ( ) => void ) | undefined
465+ const firstFlushStarted = new Promise < void > ( ( resolveStarted ) => {
466+ let started = false
467+ mockRedis . eval . mockImplementation ( async ( script : string , ...args : unknown [ ] ) => {
468+ if ( ! isFlushScript ( script ) ) return [ 1 , 'ok' , 0 , 0 ]
469+ const { terminalStatus, zaddArgs } = parseFlushEvalArgs ( args )
470+ observedTerminalStatuses . push ( terminalStatus )
471+ if ( ! started ) {
472+ started = true
473+ resolveStarted ( )
474+ await new Promise < void > ( ( resolve ) => {
475+ releaseFirstFlush = resolve
476+ } )
477+ }
478+ for ( let i = 0 ; i < zaddArgs . length ; i += 2 ) {
479+ persistedEntries . push ( JSON . parse ( zaddArgs [ i + 1 ] as string ) as ExecutionEventEntry )
480+ }
481+ return [ 1 , 1 , 0 ]
482+ } )
483+ } )
484+
485+ const writer = createExecutionEventWriter ( 'exec-1' )
486+ await writer . write ( makeEvent ( 'first' ) )
487+ await firstFlushStarted
488+
489+ const terminalWrite = writer . writeTerminal ( makeEvent ( 'terminal' ) , 'complete' )
490+ // Let writeTerminal's queued body actually enqueue its entry before the
491+ // in-flight flush resolves — otherwise the scheduled loop finds nothing left
492+ // to drain and the race under test never forms.
493+ await new Promise ( ( resolve ) => setTimeout ( resolve , 5 ) )
494+ releaseFirstFlush ?.( )
495+ await terminalWrite
496+
497+ expect ( observedTerminalStatuses ) . toContain ( 'complete' )
498+ } )
499+
500+ /**
501+ * A terminal publish that threw must not be resurrected. Leaving the status
502+ * armed would let the next flush stamp the stream terminal for an event that
503+ * was discarded — telling readers the run ended cleanly while the caller was
504+ * told it failed.
505+ */
506+ it ( 'does not stamp terminal status on a later flush after the terminal publish failed' , async ( ) => {
507+ mockRedis . incrby . mockResolvedValue ( 100 )
508+ const observedTerminalStatuses : string [ ] = [ ]
509+ let failNextFlush = false
510+ mockRedis . eval . mockImplementation ( async ( script : string , ...args : unknown [ ] ) => {
511+ if ( ! isFlushScript ( script ) ) return [ 1 , 'ok' , 0 , 0 ]
512+ if ( failNextFlush ) throw new Error ( 'redis unavailable' )
513+ const { terminalStatus, zaddArgs } = parseFlushEvalArgs ( args )
514+ observedTerminalStatuses . push ( terminalStatus )
515+ for ( let i = 0 ; i < zaddArgs . length ; i += 2 ) {
516+ persistedEntries . push ( JSON . parse ( zaddArgs [ i + 1 ] as string ) as ExecutionEventEntry )
517+ }
518+ return [ 1 , 1 , 0 ]
519+ } )
520+
521+ const writer = createExecutionEventWriter ( 'exec-1' )
522+ await writer . write ( makeEvent ( 'a' ) )
523+
524+ failNextFlush = true
525+ await expect ( writer . writeTerminal ( makeEvent ( 'terminal' ) , 'complete' ) ) . rejects . toThrow ( )
526+
527+ // flush() still surfaces the earlier terminal failure; what matters is that
528+ // the events it drains are not stamped terminal.
529+ failNextFlush = false
530+ await writer . flush ( ) . catch ( ( ) => { } )
531+
532+ expect ( observedTerminalStatuses ) . toEqual ( [ '' ] )
533+ expect (
534+ persistedEntries . map ( ( entry ) => ( entry . event . data as { blockId : string } ) . blockId )
535+ ) . toEqual ( [ 'a' ] )
536+ } )
537+
538+ /**
539+ * A budget rejection must not colour a later, unrelated failure: reporting a
540+ * Redis outage as "reduce payload size" sends the user after the wrong thing.
541+ */
542+ it ( 'reports the generic failure, not a stale budget rejection, on the terminal path' , async ( ) => {
543+ mockRedis . incrby . mockResolvedValue ( 100 )
544+ let mode : 'budget' | 'outage' = 'budget'
545+ mockRedis . eval . mockImplementation ( async ( script : string ) => {
546+ if ( ! isFlushScript ( script ) ) return [ 1 , 'ok' , 0 , 0 ]
547+ if ( mode === 'budget' ) return [ 0 , 'execution_redis_bytes' , 64 * 1024 * 1024 ]
548+ throw new Error ( 'redis unavailable' )
549+ } )
550+
551+ const writer = createExecutionEventWriter ( 'exec-1' )
552+ for ( let i = 0 ; i < 200 ; i ++ ) {
553+ await writer . write ( makeEvent ( `block-${ i } ` ) ) . catch ( ( ) => { } )
554+ }
555+
556+ mode = 'outage'
557+ await expect ( writer . writeTerminal ( makeEvent ( 'terminal' ) , 'complete' ) ) . rejects . toThrow (
558+ 'Failed to flush terminal execution event'
559+ )
560+ } )
561+
562+ it ( 'settles a scheduled flush that hits the budget instead of rejecting later callers' , async ( ) => {
563+ mockRedis . incrby . mockResolvedValue ( 100 )
564+ mockRedis . eval . mockImplementation ( async ( script : string ) => {
565+ if ( isFlushScript ( script ) ) {
566+ return [ 0 , 'execution_redis_bytes' , 64 * 1024 * 1024 ]
567+ }
568+ return [ 1 , 'ok' , 0 , 0 ]
569+ } )
570+
571+ const writer = createExecutionEventWriter ( 'exec-1' )
572+ await writer . write ( makeEvent ( 'a' ) )
573+
574+ await new Promise ( ( resolve ) => setTimeout ( resolve , 60 ) )
575+
576+ await expect ( writer . flush ( ) ) . resolves . toBeUndefined ( )
577+ } )
578+
371579 it ( 'preserves requested UserFile base64 when buffering terminal events' , async ( ) => {
372580 mockRedis . incrby . mockResolvedValue ( 100 )
373581 const base64 = Buffer . from ( 'hello' ) . toString ( 'base64' )
0 commit comments