Skip to content

Commit 1939ad2

Browse files
committed
fix(supervisor): hold the last backpressure verdict when a read fails
1 parent fc69101 commit 1939ad2

5 files changed

Lines changed: 74 additions & 8 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+
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.

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 reads that failed; the previous verdict is held until it ages out. */
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 failed; the previous verdict is held until it ages out",
39+
registers: [opts.register],
40+
});
3341
}
3442
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,37 @@ 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+
92123
it("fails open when the source reports unknown (null)", async () => {
93124
const { source } = countingSource(null);
94125
const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 });
@@ -292,6 +323,7 @@ describe("BackpressureMonitor", () => {
292323
const logs: Array<{ message: string; meta?: Record<string, unknown> }> = [];
293324
const logger = {
294325
info: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
326+
error: (message: string, meta?: Record<string, unknown>) => logs.push({ message, meta }),
295327
};
296328
const monitor = new BackpressureMonitor({
297329
enabled: true,

apps/supervisor/src/backpressure/backpressureMonitor.ts

Lines changed: 27 additions & 7 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 = {
@@ -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

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)