Skip to content

Commit 3fba045

Browse files
authored
fix(supervisor): hold the last backpressure verdict when a read fails (#4444)
The dequeue brake released the moment its signal became unreadable. `refresh()` caught any error from `source.read()` and set the verdict to `null`, which `computeEngaged()` treats as not-engaged — so a few failed reads dropped an engaged brake, silently, with no log and no metric. That handling was symmetric while the risk is not. A source that has stopped answering correlates with the pressure the brake exists for, so releasing on read failure gives up protection at exactly the wrong moment; holding too long only costs throughput. Now a failed read keeps the last verdict instead of discarding it. The verdict then ages normally, so the existing `maxVerdictAgeMs` check becomes the grace window and still bounds how long a dead source can hold the brake — a permanently unreachable source releases it rather than pinning dequeuing forever. Because `computeEngaged()` only consults staleness for an *engaged* verdict, a released one is unaffected and stays released. The default grace moves from 15s to 120s, comparable to how long the brake normally stays engaged. One guard worth calling out: holding is only safe when something bounds it, so when `maxVerdictAgeMs` is unset the previous discard behaviour is kept. Otherwise an unbounded hold could pin the brake indefinitely. Read failures were previously invisible — the catch block neither logged nor counted. Adds a `read_failures_total` counter, plus an error log on the transition into failure rather than once per tick, since the refresh loop runs every second. The post-release ramp needs no change: it anchors off the engaged-to-released transition, so a grace-window release still ramps back up instead of snapping to full rate, which is what you want after a blind period. Tests cover holding while reads fail, releasing past the max age, and the existing unbounded-config paths are unchanged.
1 parent 8f9db53 commit 3fba045

5 files changed

Lines changed: 103 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: supervisor
3+
type: fix
4+
---
5+
6+
When the capacity signal drops out, the last decision is held for a grace period rather than released.

apps/supervisor/src/backpressure/backpressureMetrics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export class BackpressureMetrics {
88
readonly dryRun: Gauge<string>;
99
/** Dequeue attempts the gate skipped - or would have, in dry-run (labelled). */
1010
readonly skipsTotal: Counter<string>;
11+
/** Verdict source reads that failed (threw). */
12+
readonly readFailuresTotal: Counter<string>;
1113

1214
constructor(opts: { register: Registry; prefix?: string }) {
1315
const prefix = opts.prefix ?? "supervisor_backpressure";
@@ -30,5 +32,11 @@ export class BackpressureMetrics {
3032
labelNames: ["dry_run"],
3133
registers: [opts.register],
3234
});
35+
36+
this.readFailuresTotal = new Counter({
37+
name: `${prefix}_read_failures_total`,
38+
help: "Verdict source reads that threw",
39+
registers: [opts.register],
40+
});
3341
}
3442
}

apps/supervisor/src/backpressure/backpressureMonitor.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,60 @@ describe("BackpressureMonitor", () => {
8989
monitor.stop();
9090
});
9191

92+
it("holds an engaged verdict while reads fail, then releases past the max age", async () => {
93+
let call = 0;
94+
const source: BackpressureSignalSource = {
95+
read: async () => {
96+
call++;
97+
if (call === 1) {
98+
return { engaged: true, ts: Date.now() };
99+
}
100+
throw new Error("signal source unreachable");
101+
},
102+
};
103+
const monitor = new BackpressureMonitor({
104+
enabled: true,
105+
source,
106+
refreshIntervalMs: 1000,
107+
maxVerdictAgeMs: 15_000,
108+
});
109+
110+
monitor.start();
111+
await vi.advanceTimersByTimeAsync(0);
112+
expect(monitor.shouldSkipDequeue()).toBe(true);
113+
114+
await vi.advanceTimersByTimeAsync(5000);
115+
expect(monitor.shouldSkipDequeue()).toBe(true); // read failing, verdict held
116+
117+
await vi.advanceTimersByTimeAsync(11_000);
118+
expect(monitor.shouldSkipDequeue()).toBe(false); // past max age, released
119+
120+
monitor.stop();
121+
});
122+
123+
it("releases immediately on an explicit null even when a grace window is configured", async () => {
124+
let engaged: boolean | null = true;
125+
const source: BackpressureSignalSource = {
126+
read: async () => (engaged === null ? null : { engaged, ts: Date.now() }),
127+
};
128+
const monitor = new BackpressureMonitor({
129+
enabled: true,
130+
source,
131+
refreshIntervalMs: 1000,
132+
maxVerdictAgeMs: 15_000,
133+
});
134+
135+
monitor.start();
136+
await vi.advanceTimersByTimeAsync(0);
137+
expect(monitor.shouldSkipDequeue()).toBe(true);
138+
139+
engaged = null;
140+
await vi.advanceTimersByTimeAsync(1000);
141+
expect(monitor.shouldSkipDequeue()).toBe(false); // null is an answer, not a failure
142+
143+
monitor.stop();
144+
});
145+
92146
it("fails open when the source reports unknown (null)", async () => {
93147
const { source } = countingSource(null);
94148
const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 });
@@ -292,6 +346,7 @@ describe("BackpressureMonitor", () => {
292346
const logs: Array<{ message: string; meta?: Record<string, unknown> }> = [];
293347
const logger = {
294348
info: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
349+
error: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
295350
};
296351
const monitor = new BackpressureMonitor({
297352
enabled: true,

apps/supervisor/src/backpressure/backpressureMonitor.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { BackpressureMetrics } from "./backpressureMetrics.js";
22

33
export interface BackpressureLogger {
44
info(message: string, meta?: Record<string, unknown>): void;
5+
error(message: string, meta?: Record<string, unknown>): void;
56
}
67

78
export type BackpressureVerdict = {
@@ -11,9 +12,10 @@ export type BackpressureVerdict = {
1112
};
1213

1314
/**
14-
* Source of the current backpressure verdict. `read()` returns `null` when the
15-
* verdict is unknown (missing/unreadable) - the monitor treats unknown as
16-
* "not engaged" (fail-open).
15+
* Source of the current backpressure verdict. `read()` returns `null` when the source
16+
* answered but there is no verdict - the monitor treats that as "not engaged"
17+
* (fail-open). A thrown error is different: the read itself failed, so the monitor
18+
* keeps the previous verdict until it ages past `maxVerdictAgeMs`.
1719
*/
1820
export interface BackpressureSignalSource {
1921
read(): Promise<BackpressureVerdict | null>;
@@ -24,8 +26,9 @@ export type BackpressureMonitorOptions = {
2426
source: BackpressureSignalSource;
2527
refreshIntervalMs?: number;
2628
/**
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).
29+
* If set, an engaged verdict older than this is released (fail-open), bounding how
30+
* long a dead source can hold the brake. Reads that fail keep the last verdict, so
31+
* this doubles as the grace window for riding out a transient source outage.
2932
*/
3033
maxVerdictAgeMs?: number;
3134
/**
@@ -54,6 +57,7 @@ export class BackpressureMonitor {
5457
private refreshInFlight = false;
5558
private wasEngaged = false;
5659
private releasedAt?: number;
60+
private readFailing = false;
5761

5862
constructor(private readonly opts: BackpressureMonitorOptions) {
5963
this.opts.metrics?.dryRun.set(this.opts.dryRun ? 1 : 0);
@@ -152,12 +156,31 @@ export class BackpressureMonitor {
152156
}
153157

154158
private async refresh(): Promise<void> {
159+
let next: BackpressureVerdict | null = null;
160+
let readError: unknown;
155161
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;
162+
next = await this.opts.source.read();
163+
} catch (error) {
164+
readError = error;
165+
}
166+
167+
if (readError === undefined) {
168+
this.verdict = next; // an explicit null means "no pressure", so honour it
169+
this.readFailing = false;
170+
} else {
171+
const held = this.opts.maxVerdictAgeMs !== undefined;
172+
if (!held) {
173+
this.verdict = null; // unbounded hold could pin the brake forever
174+
}
175+
this.opts.metrics?.readFailuresTotal.inc();
176+
if (!this.readFailing) {
177+
this.readFailing = true; // log once per outage, not once per tick
178+
this.opts.logger?.error("backpressure read failed", {
179+
reason: String(readError),
180+
heldPreviousVerdict: held,
181+
engaged: this.computeEngaged(),
182+
});
183+
}
161184
}
162185

163186
// Track the engaged→released transition to anchor the resume ramp. Use the

apps/supervisor/src/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const Env = z
7979
.number()
8080
.int()
8181
.positive()
82-
.default(15_000), // Stale verdict → fail-open (treat as not engaged)
82+
.default(120_000), // Grace window: held verdict older than this → fail-open
8383
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST: z.string().optional(),
8484
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PORT: z.coerce.number().int().optional(),
8585
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_USERNAME: z.string().optional(),

0 commit comments

Comments
 (0)