@@ -2,6 +2,7 @@ import type { BackpressureMetrics } from "./backpressureMetrics.js";
22
33export interface BackpressureLogger {
44 info ( message : string , meta ?: Record < string , unknown > ) : void ;
5+ error ( message : string , meta ?: Record < string , unknown > ) : void ;
56}
67
78export type BackpressureVerdict = {
@@ -24,8 +25,9 @@ export type BackpressureMonitorOptions = {
2425 source : BackpressureSignalSource ;
2526 refreshIntervalMs ?: number ;
2627 /**
27- * If set, a cached verdict older than this is treated as unknown (fail-open).
28- * Guards against the source silently going stale (e.g. hanging reads).
28+ * If set, an engaged verdict older than this is released (fail-open), bounding how
29+ * long a dead source can hold the brake. Reads that fail keep the last verdict, so
30+ * this doubles as the grace window for riding out a transient source outage.
2931 */
3032 maxVerdictAgeMs ?: number ;
3133 /**
@@ -54,6 +56,7 @@ export class BackpressureMonitor {
5456 private refreshInFlight = false ;
5557 private wasEngaged = false ;
5658 private releasedAt ?: number ;
59+ private readFailing = false ;
5760
5861 constructor ( private readonly opts : BackpressureMonitorOptions ) {
5962 this . opts . metrics ?. dryRun . set ( this . opts . dryRun ? 1 : 0 ) ;
@@ -152,12 +155,29 @@ export class BackpressureMonitor {
152155 }
153156
154157 private async refresh ( ) : Promise < void > {
158+ let next : BackpressureVerdict | null = null ;
159+ let readError : unknown ;
155160 try {
156- this . verdict = await this . opts . source . read ( ) ;
157- } catch {
158- // Fail-open: a dead/unreachable source must never pin the brake. Treat as
159- // unknown (no verdict) so dequeue resumes as if backpressure were off.
160- this . verdict = null ;
161+ next = await this . opts . source . read ( ) ;
162+ } catch ( error ) {
163+ readError = error ;
164+ }
165+
166+ if ( next ) {
167+ this . verdict = next ;
168+ this . readFailing = false ;
169+ } else {
170+ if ( this . opts . maxVerdictAgeMs === undefined ) {
171+ this . verdict = null ; // unbounded hold could pin the brake forever
172+ }
173+ this . opts . metrics ?. readFailuresTotal . inc ( ) ;
174+ if ( ! this . readFailing ) {
175+ this . readFailing = true ; // log once per outage, not once per tick
176+ this . opts . logger ?. error ( "backpressure read failed, holding last verdict" , {
177+ reason : readError ? String ( readError ) : "no verdict" ,
178+ engaged : this . computeEngaged ( ) ,
179+ } ) ;
180+ }
161181 }
162182
163183 // Track the engaged→released transition to anchor the resume ramp. Use the
0 commit comments