Skip to content

Commit 8c9c92f

Browse files
committed
fix(supervisor): only hold the verdict when the read itself failed
1 parent 1939ad2 commit 8c9c92f

4 files changed

Lines changed: 37 additions & 11 deletions

File tree

.server-changes/backpressure-hold-last-verdict.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: supervisor
33
type: fix
44
---
55

6-
Self-hosted Kubernetes deployments no longer resume pulling work the moment the safety check can't be read. It now holds its last decision for a grace period instead of releasing after a few seconds.
6+
Self-hosted Kubernetes deployments no longer resume pulling work as soon as the safety check becomes unreadable. It now holds its last decision for a grace period while the check recovers.

apps/supervisor/src/backpressure/backpressureMetrics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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 reads that failed; the previous verdict is held until it ages out. */
11+
/** Verdict source reads that failed (threw). */
1212
readonly readFailuresTotal: Counter<string>;
1313

1414
constructor(opts: { register: Registry; prefix?: string }) {
@@ -35,7 +35,7 @@ export class BackpressureMetrics {
3535

3636
this.readFailuresTotal = new Counter({
3737
name: `${prefix}_read_failures_total`,
38-
help: "Verdict source reads that failed; the previous verdict is held until it ages out",
38+
help: "Verdict source reads that threw",
3939
registers: [opts.register],
4040
});
4141
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,29 @@ describe("BackpressureMonitor", () => {
120120
monitor.stop();
121121
});
122122

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+
123146
it("fails open when the source reports unknown (null)", async () => {
124147
const { source } = countingSource(null);
125148
const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 });

apps/supervisor/src/backpressure/backpressureMonitor.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ export type BackpressureVerdict = {
1212
};
1313

1414
/**
15-
* Source of the current backpressure verdict. `read()` returns `null` when the
16-
* verdict is unknown (missing/unreadable) - the monitor treats unknown as
17-
* "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`.
1819
*/
1920
export interface BackpressureSignalSource {
2021
read(): Promise<BackpressureVerdict | null>;
@@ -163,18 +164,20 @@ export class BackpressureMonitor {
163164
readError = error;
164165
}
165166

166-
if (next) {
167-
this.verdict = next;
167+
if (readError === undefined) {
168+
this.verdict = next; // an explicit null means "no pressure", so honour it
168169
this.readFailing = false;
169170
} else {
170-
if (this.opts.maxVerdictAgeMs === undefined) {
171+
const held = this.opts.maxVerdictAgeMs !== undefined;
172+
if (!held) {
171173
this.verdict = null; // unbounded hold could pin the brake forever
172174
}
173175
this.opts.metrics?.readFailuresTotal.inc();
174176
if (!this.readFailing) {
175177
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+
this.opts.logger?.error("backpressure read failed", {
179+
reason: String(readError),
180+
heldPreviousVerdict: held,
178181
engaged: this.computeEngaged(),
179182
});
180183
}

0 commit comments

Comments
 (0)