@checkstack/healthcheck-backend 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -0
- package/package.json +30 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +58 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +395 -294
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +11 -14
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +98 -0
- package/src/service-ordering.test.ts +4 -0
- package/src/service-paused-filter.test.ts +14 -7
- package/src/service-rollup-worst-wins.test.ts +37 -4
- package/src/service.ts +255 -145
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slow-check bulkhead + adaptive-timeout DECISION for one run of a
|
|
3
|
+
* `(configId, systemId, environmentId)` slice. Pure except for the lane
|
|
4
|
+
* admission side effect (a semaphore acquire), so it is directly unit-testable
|
|
5
|
+
* without the executor's DB/queue machinery. The executor supplies the slice's
|
|
6
|
+
* recent runs (read from durable `health_check_runs`) and acts on the verdict:
|
|
7
|
+
* a `defer` records nothing this tick, a `run` uses the (possibly shrunk)
|
|
8
|
+
* `effectiveTimeoutMs` and releases `laneKey` when set.
|
|
9
|
+
*/
|
|
10
|
+
import type { SlowCheckRuntime } from "./slow-check-config";
|
|
11
|
+
import {
|
|
12
|
+
classifySlowCheck,
|
|
13
|
+
ENV_LESS_KEY,
|
|
14
|
+
type RecentRun,
|
|
15
|
+
} from "./slow-check-classifier";
|
|
16
|
+
import { adaptiveTimeout } from "./adaptive-timeout";
|
|
17
|
+
|
|
18
|
+
export type SlowCheckDecision =
|
|
19
|
+
| {
|
|
20
|
+
kind: "run";
|
|
21
|
+
/** Timeout to probe with (shrunk toward the healthy baseline when suspect). */
|
|
22
|
+
effectiveTimeoutMs: number;
|
|
23
|
+
/** Set when a suspect run was admitted to the lane; release it after the run. */
|
|
24
|
+
laneKey?: string;
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
kind: "defer";
|
|
28
|
+
/** `lane_full` (pod at capacity) or `in_flight` (prior run of this slice). */
|
|
29
|
+
reason: "lane_full" | "in_flight";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the lane single-flight key for a slice. Keyed on `(config, system, env)`
|
|
34
|
+
* so distinct envs of one system get independent slots and a slice can never be
|
|
35
|
+
* in flight against itself.
|
|
36
|
+
*/
|
|
37
|
+
export function slowCheckLaneKey(props: {
|
|
38
|
+
configId: string;
|
|
39
|
+
systemId: string;
|
|
40
|
+
environmentId: string | null;
|
|
41
|
+
}): string {
|
|
42
|
+
const { configId, systemId, environmentId } = props;
|
|
43
|
+
return `${configId}:${systemId}:${environmentId ?? ENV_LESS_KEY}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decide whether to run this slice and with what timeout. A non-suspect slice
|
|
48
|
+
* always runs at the full timeout with no lane involvement. A suspect slice is
|
|
49
|
+
* admitted to the capped, pod-local lane (returning `laneKey` to release after
|
|
50
|
+
* the run) and probed with an adaptive timeout, OR deferred when the lane is
|
|
51
|
+
* full / a prior run of the same slice is still in flight.
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateSlowCheckAdmission(props: {
|
|
54
|
+
runtime: SlowCheckRuntime;
|
|
55
|
+
recentRuns: RecentRun[];
|
|
56
|
+
configId: string;
|
|
57
|
+
systemId: string;
|
|
58
|
+
environmentId: string | null;
|
|
59
|
+
executionTimeoutMs: number;
|
|
60
|
+
}): SlowCheckDecision {
|
|
61
|
+
const {
|
|
62
|
+
runtime,
|
|
63
|
+
recentRuns,
|
|
64
|
+
configId,
|
|
65
|
+
systemId,
|
|
66
|
+
environmentId,
|
|
67
|
+
executionTimeoutMs,
|
|
68
|
+
} = props;
|
|
69
|
+
|
|
70
|
+
const { perEnv } = classifySlowCheck({
|
|
71
|
+
runs: recentRuns,
|
|
72
|
+
params: {
|
|
73
|
+
...runtime.classifierParams,
|
|
74
|
+
configuredTimeoutMs: executionTimeoutMs,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const classification = perEnv.get(environmentId ?? ENV_LESS_KEY);
|
|
78
|
+
|
|
79
|
+
if (!classification?.suspect) {
|
|
80
|
+
return { kind: "run", effectiveTimeoutMs: executionTimeoutMs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const laneKey = slowCheckLaneKey({ configId, systemId, environmentId });
|
|
84
|
+
const admission = runtime.lane.tryAdmit(laneKey);
|
|
85
|
+
if (!admission.admitted) {
|
|
86
|
+
return { kind: "defer", reason: admission.reason };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
kind: "run",
|
|
91
|
+
laneKey,
|
|
92
|
+
effectiveTimeoutMs: adaptiveTimeout({
|
|
93
|
+
configuredMs: executionTimeoutMs,
|
|
94
|
+
healthyBaselineMs: classification.healthyBaselineMs,
|
|
95
|
+
isSuspect: true,
|
|
96
|
+
isRecoveryProbe: classification.isRecoveryProbe,
|
|
97
|
+
safetyFactor: runtime.safetyFactor,
|
|
98
|
+
absoluteFloorMs: runtime.absoluteFloorMs,
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifySlowCheck,
|
|
4
|
+
ENV_LESS_KEY,
|
|
5
|
+
type RecentRun,
|
|
6
|
+
} from "./slow-check-classifier";
|
|
7
|
+
import type { HealthCheckStatus } from "@checkstack/healthcheck-common";
|
|
8
|
+
|
|
9
|
+
const TIMEOUT = 30_000;
|
|
10
|
+
const SLOW = TIMEOUT * 0.8; // 24_000
|
|
11
|
+
|
|
12
|
+
// Build newest-first runs for one env.
|
|
13
|
+
function runs(
|
|
14
|
+
entries: Array<{ status: HealthCheckStatus; latencyMs: number | null; env?: string | null }>,
|
|
15
|
+
): RecentRun[] {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
return entries.map((e, i) => ({
|
|
18
|
+
status: e.status,
|
|
19
|
+
latencyMs: e.latencyMs,
|
|
20
|
+
environmentId: e.env === undefined ? null : e.env,
|
|
21
|
+
timestamp: new Date(now - i * 60_000),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("classifySlowCheck", () => {
|
|
26
|
+
test("env-less: 3 slow timeouts => suspect", () => {
|
|
27
|
+
const r = runs([
|
|
28
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
29
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
30
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
31
|
+
]);
|
|
32
|
+
const c = classifySlowCheck({ runs: r, params: { configuredTimeoutMs: TIMEOUT } });
|
|
33
|
+
expect(c.perEnv.get(ENV_LESS_KEY)?.suspect).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("fast connect-refused (low latency) is NOT suspect - it frees its slot", () => {
|
|
37
|
+
const r = runs([
|
|
38
|
+
{ status: "unhealthy", latencyMs: 5 },
|
|
39
|
+
{ status: "unhealthy", latencyMs: 8 },
|
|
40
|
+
{ status: "unhealthy", latencyMs: 3 },
|
|
41
|
+
]);
|
|
42
|
+
const c = classifySlowCheck({ runs: r, params: { configuredTimeoutMs: TIMEOUT } });
|
|
43
|
+
expect(c.perEnv.get(ENV_LESS_KEY)?.suspect).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("one recent healthy run clears suspect (streak broken)", () => {
|
|
47
|
+
const r = runs([
|
|
48
|
+
{ status: "healthy", latencyMs: 200 },
|
|
49
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
50
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
51
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
52
|
+
]);
|
|
53
|
+
const c = classifySlowCheck({ runs: r, params: { configuredTimeoutMs: TIMEOUT } });
|
|
54
|
+
expect(c.perEnv.get(ENV_LESS_KEY)?.suspect).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("mixed envs: only the failing env is suspect; the healthy sibling is not", () => {
|
|
58
|
+
// The common multi-stage case: one env down, siblings healthy. The failing
|
|
59
|
+
// env is isolated per-env; the healthy sibling keeps running untouched.
|
|
60
|
+
const suspectEnv = runs([
|
|
61
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
62
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
63
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
64
|
+
]);
|
|
65
|
+
const healthyEnv = runs([
|
|
66
|
+
{ status: "healthy", latencyMs: 150, env: "staging" },
|
|
67
|
+
{ status: "healthy", latencyMs: 160, env: "staging" },
|
|
68
|
+
]);
|
|
69
|
+
const c = classifySlowCheck({
|
|
70
|
+
runs: [...suspectEnv, ...healthyEnv],
|
|
71
|
+
params: { configuredTimeoutMs: TIMEOUT },
|
|
72
|
+
});
|
|
73
|
+
expect(c.perEnv.get("prod")?.suspect).toBe(true);
|
|
74
|
+
expect(c.perEnv.get("staging")?.suspect).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("each env is classified independently", () => {
|
|
78
|
+
const prod = runs([
|
|
79
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
80
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
81
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "prod" },
|
|
82
|
+
]);
|
|
83
|
+
const staging = runs([
|
|
84
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "staging" },
|
|
85
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "staging" },
|
|
86
|
+
{ status: "unhealthy", latencyMs: SLOW, env: "staging" },
|
|
87
|
+
]);
|
|
88
|
+
const c = classifySlowCheck({
|
|
89
|
+
runs: [...prod, ...staging],
|
|
90
|
+
params: { configuredTimeoutMs: TIMEOUT },
|
|
91
|
+
});
|
|
92
|
+
expect(c.perEnv.get("prod")?.suspect).toBe(true);
|
|
93
|
+
expect(c.perEnv.get("staging")?.suspect).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("healthyBaselineMs is p95 of HEALTHY runs only (excludes timed-out runs)", () => {
|
|
97
|
+
const r = runs([
|
|
98
|
+
{ status: "unhealthy", latencyMs: SLOW }, // excluded from baseline
|
|
99
|
+
{ status: "healthy", latencyMs: 100 },
|
|
100
|
+
{ status: "healthy", latencyMs: 120 },
|
|
101
|
+
{ status: "healthy", latencyMs: 110 },
|
|
102
|
+
]);
|
|
103
|
+
const c = classifySlowCheck({ runs: r, params: { configuredTimeoutMs: TIMEOUT } });
|
|
104
|
+
const env = c.perEnv.get(ENV_LESS_KEY);
|
|
105
|
+
expect(env?.healthyBaselineMs).toBeDefined();
|
|
106
|
+
expect(env?.healthyBaselineMs).toBeLessThanOrEqual(120);
|
|
107
|
+
expect(env?.healthyBaselineMs).toBeGreaterThanOrEqual(100);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("no healthy runs => healthyBaselineMs undefined (adaptive timeout won't shrink)", () => {
|
|
111
|
+
const r = runs([
|
|
112
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
113
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
114
|
+
{ status: "unhealthy", latencyMs: SLOW },
|
|
115
|
+
]);
|
|
116
|
+
const c = classifySlowCheck({ runs: r, params: { configuredTimeoutMs: TIMEOUT } });
|
|
117
|
+
expect(c.perEnv.get(ENV_LESS_KEY)?.healthyBaselineMs).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("recovery probe fires on the Nth consecutive suspect run", () => {
|
|
121
|
+
// 5 consecutive slow failures, recoveryProbeEvery=5 => 5 % 5 === 0 => probe.
|
|
122
|
+
const r = runs(
|
|
123
|
+
Array.from({ length: 5 }, () => ({
|
|
124
|
+
status: "unhealthy" as HealthCheckStatus,
|
|
125
|
+
latencyMs: SLOW,
|
|
126
|
+
})),
|
|
127
|
+
);
|
|
128
|
+
const c = classifySlowCheck({
|
|
129
|
+
runs: r,
|
|
130
|
+
params: { configuredTimeoutMs: TIMEOUT, recoveryProbeEvery: 5 },
|
|
131
|
+
});
|
|
132
|
+
expect(c.perEnv.get(ENV_LESS_KEY)?.isRecoveryProbe).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("no probe when streak is not a multiple of the cadence", () => {
|
|
136
|
+
const r = runs(
|
|
137
|
+
Array.from({ length: 3 }, () => ({
|
|
138
|
+
status: "unhealthy" as HealthCheckStatus,
|
|
139
|
+
latencyMs: SLOW,
|
|
140
|
+
})),
|
|
141
|
+
);
|
|
142
|
+
const c = classifySlowCheck({
|
|
143
|
+
runs: r,
|
|
144
|
+
params: { configuredTimeoutMs: TIMEOUT, recoveryProbeEvery: 5, consecutiveFailures: 3 },
|
|
145
|
+
});
|
|
146
|
+
const env = c.perEnv.get(ENV_LESS_KEY);
|
|
147
|
+
expect(env?.suspect).toBe(true);
|
|
148
|
+
expect(env?.isRecoveryProbe).toBe(false); // 3 % 5 !== 0
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("empty history => no classified envs", () => {
|
|
152
|
+
const c = classifySlowCheck({ runs: [], params: { configuredTimeoutMs: TIMEOUT } });
|
|
153
|
+
expect(c.perEnv.size).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a health check's recent runs to drive the slow-check bulkhead and the
|
|
3
|
+
* adaptive timeout. Pure and derived entirely from durable `health_check_runs`
|
|
4
|
+
* rows, so every pod computes the same classification (state-and-scale: no
|
|
5
|
+
* pod-local classification state).
|
|
6
|
+
*
|
|
7
|
+
* Granularities, matching the executor's execution model (one `(configId,
|
|
8
|
+
* systemId)` JOB holds one concurrency slot and runs its environments
|
|
9
|
+
* SEQUENTIALLY, each with its own timeout):
|
|
10
|
+
*
|
|
11
|
+
* Classification is PER ENV: each env's `suspect`, `healthyBaselineMs`, and
|
|
12
|
+
* `isRecoveryProbe` drive its OWN lane admission and adaptive timeout inside the
|
|
13
|
+
* job's execution loop. A failing env is admitted to the capped suspect lane (or
|
|
14
|
+
* skipped when the lane is full) and probed with a shrunk timeout, while a
|
|
15
|
+
* healthy sibling env in the SAME job runs normally at its full timeout in its
|
|
16
|
+
* own loop iteration. So one failing env is isolated without ever starving or
|
|
17
|
+
* dropping a healthy sibling env - and the bulkhead engages in the common
|
|
18
|
+
* mixed-stage case (only some envs down), not just when every env is down.
|
|
19
|
+
*/
|
|
20
|
+
import type { HealthCheckStatus } from "@checkstack/healthcheck-common";
|
|
21
|
+
|
|
22
|
+
/** A recent run row, projected to the fields classification needs. */
|
|
23
|
+
export interface RecentRun {
|
|
24
|
+
environmentId: string | null;
|
|
25
|
+
status: HealthCheckStatus;
|
|
26
|
+
latencyMs: number | null;
|
|
27
|
+
timestamp: Date;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SlowCheckParams {
|
|
31
|
+
/** The check's configured execution timeout (ms) - the slot-hog latency ref. */
|
|
32
|
+
configuredTimeoutMs: number;
|
|
33
|
+
/** Consecutive slot-hog failures required to classify an env suspect. */
|
|
34
|
+
consecutiveFailures?: number;
|
|
35
|
+
/** Fraction of the timeout a failed run's latency must reach to count as a
|
|
36
|
+
* slot-hog (vs a fast connect-refused that frees its slot instantly). */
|
|
37
|
+
slowFraction?: number;
|
|
38
|
+
/** Every Nth consecutive suspect run is a full-timeout recovery probe. */
|
|
39
|
+
recoveryProbeEvery?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EnvClassification {
|
|
43
|
+
suspect: boolean;
|
|
44
|
+
/** p95 latency (ms) of this env's recent HEALTHY runs; `undefined` if none. */
|
|
45
|
+
healthyBaselineMs: number | undefined;
|
|
46
|
+
/** This run should use the full configured timeout to re-measure recovery. */
|
|
47
|
+
isRecoveryProbe: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SlowCheckClassification {
|
|
51
|
+
/** Per-env classification, keyed by `environmentId ?? ENV_LESS_KEY`. */
|
|
52
|
+
perEnv: Map<string, EnvClassification>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_CONSECUTIVE_FAILURES = 3;
|
|
56
|
+
export const DEFAULT_SLOW_FRACTION = 0.8;
|
|
57
|
+
export const DEFAULT_RECOVERY_PROBE_EVERY = 5;
|
|
58
|
+
/** Map key used for the env-less run (`environmentId === null`). */
|
|
59
|
+
export const ENV_LESS_KEY = "_";
|
|
60
|
+
|
|
61
|
+
/** p95 of a non-empty list (nearest-rank). */
|
|
62
|
+
function p95(values: number[]): number {
|
|
63
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
64
|
+
const idx = Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1);
|
|
65
|
+
return sorted[Math.max(0, idx)]!;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when a run held its slot ~for the timeout (a slow failure), not a fast
|
|
69
|
+
* fail. Both connect-timeout and post-connect-timeout land here; a fast
|
|
70
|
+
* connection-refused (low latency) does not. */
|
|
71
|
+
function isSlotHogFailure(run: RecentRun, slowLatencyMs: number): boolean {
|
|
72
|
+
return run.status !== "healthy" && (run.latencyMs ?? 0) >= slowLatencyMs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function classifyEnv(
|
|
76
|
+
runsNewestFirst: RecentRun[],
|
|
77
|
+
params: Required<SlowCheckParams>,
|
|
78
|
+
): EnvClassification {
|
|
79
|
+
const { configuredTimeoutMs, consecutiveFailures, slowFraction, recoveryProbeEvery } =
|
|
80
|
+
params;
|
|
81
|
+
const slowLatencyMs = configuredTimeoutMs * slowFraction;
|
|
82
|
+
|
|
83
|
+
// Count leading consecutive slot-hog failures from the most recent run.
|
|
84
|
+
let leadingSlowFailures = 0;
|
|
85
|
+
for (const run of runsNewestFirst) {
|
|
86
|
+
if (isSlotHogFailure(run, slowLatencyMs)) leadingSlowFailures++;
|
|
87
|
+
else break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const suspect = leadingSlowFailures >= consecutiveFailures;
|
|
91
|
+
|
|
92
|
+
const healthyLatencies = runsNewestFirst
|
|
93
|
+
.filter((r) => r.status === "healthy" && r.latencyMs !== null)
|
|
94
|
+
.map((r) => r.latencyMs!);
|
|
95
|
+
const healthyBaselineMs =
|
|
96
|
+
healthyLatencies.length > 0 ? p95(healthyLatencies) : undefined;
|
|
97
|
+
|
|
98
|
+
// Guardrail 3: every Nth consecutive suspect run is a full-timeout probe.
|
|
99
|
+
// Uses the failure streak BEFORE this run, so the streak lengths that trip a
|
|
100
|
+
// probe are recoveryProbeEvery, 2x, 3x, ...
|
|
101
|
+
const isRecoveryProbe =
|
|
102
|
+
suspect && leadingSlowFailures % recoveryProbeEvery === 0;
|
|
103
|
+
|
|
104
|
+
return { suspect, healthyBaselineMs, isRecoveryProbe };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Classify recent runs for one `(configId, systemId)` across all environments.
|
|
109
|
+
* `runs` must be newest-first (as `ORDER BY timestamp DESC` returns them).
|
|
110
|
+
*/
|
|
111
|
+
export function classifySlowCheck(props: {
|
|
112
|
+
runs: RecentRun[];
|
|
113
|
+
params: SlowCheckParams;
|
|
114
|
+
}): SlowCheckClassification {
|
|
115
|
+
const params: Required<SlowCheckParams> = {
|
|
116
|
+
consecutiveFailures: DEFAULT_CONSECUTIVE_FAILURES,
|
|
117
|
+
slowFraction: DEFAULT_SLOW_FRACTION,
|
|
118
|
+
recoveryProbeEvery: DEFAULT_RECOVERY_PROBE_EVERY,
|
|
119
|
+
...props.params,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Bucket by env, preserving newest-first order within each bucket.
|
|
123
|
+
const byEnv = new Map<string, RecentRun[]>();
|
|
124
|
+
for (const run of props.runs) {
|
|
125
|
+
const key = run.environmentId ?? ENV_LESS_KEY;
|
|
126
|
+
const bucket = byEnv.get(key);
|
|
127
|
+
if (bucket) bucket.push(run);
|
|
128
|
+
else byEnv.set(key, [run]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const perEnv = new Map<string, EnvClassification>();
|
|
132
|
+
for (const [key, envRuns] of byEnv) {
|
|
133
|
+
perEnv.set(key, classifyEnv(envRuns, params));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { perEnv };
|
|
137
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration for the slow-check bulkhead + adaptive timeout, resolved
|
|
3
|
+
* once at worker startup from environment variables. When disabled (the kill
|
|
4
|
+
* switch, or an invalid capacity), the executor skips the classification read
|
|
5
|
+
* and the whole feature is inert - health checks run exactly as before.
|
|
6
|
+
*
|
|
7
|
+
* The bulkhead lane is pod-local infrastructure (like the queue's own
|
|
8
|
+
* concurrency semaphore): total suspect concurrency across the cluster is
|
|
9
|
+
* `capacity x pods`, scaling the same way queue concurrency already does.
|
|
10
|
+
*/
|
|
11
|
+
import { SuspectLane } from "./suspect-lane";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_CONSECUTIVE_FAILURES,
|
|
14
|
+
DEFAULT_RECOVERY_PROBE_EVERY,
|
|
15
|
+
DEFAULT_SLOW_FRACTION,
|
|
16
|
+
type SlowCheckParams,
|
|
17
|
+
} from "./slow-check-classifier";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
|
|
20
|
+
DEFAULT_TIMEOUT_SAFETY_FACTOR,
|
|
21
|
+
} from "./adaptive-timeout";
|
|
22
|
+
|
|
23
|
+
export interface SlowCheckRuntime {
|
|
24
|
+
/** Pod-local admission control for suspect (slot-hogging) env-runs. */
|
|
25
|
+
lane: SuspectLane;
|
|
26
|
+
/** How many recent runs (per config+system, across envs) to classify over. */
|
|
27
|
+
recentRunsLimit: number;
|
|
28
|
+
/** Classifier tuning (consecutive-failure count, slow fraction, probe cadence). */
|
|
29
|
+
classifierParams: Omit<SlowCheckParams, "configuredTimeoutMs">;
|
|
30
|
+
/** Adaptive-timeout multiplier on the healthy-latency baseline. */
|
|
31
|
+
safetyFactor: number;
|
|
32
|
+
/** Adaptive-timeout absolute lower bound (ms). */
|
|
33
|
+
absoluteFloorMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SLOW_LANE_CAPACITY = 3;
|
|
37
|
+
export const DEFAULT_RECENT_RUNS_LIMIT = 20;
|
|
38
|
+
|
|
39
|
+
function numberFrom(raw: string | undefined, fallback: number): number {
|
|
40
|
+
if (raw === undefined) return fallback;
|
|
41
|
+
const parsed = Number(raw);
|
|
42
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isDisabled(raw: string | undefined): boolean {
|
|
46
|
+
return raw === "0" || raw?.toLowerCase() === "false";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the slow-check runtime from env vars. Returns `null` when the feature
|
|
51
|
+
* is disabled (kill switch, or a non-positive capacity), which the executor
|
|
52
|
+
* treats as "run exactly as before".
|
|
53
|
+
*/
|
|
54
|
+
export function resolveSlowCheckRuntime(
|
|
55
|
+
env: Record<string, string | undefined>,
|
|
56
|
+
): SlowCheckRuntime | null {
|
|
57
|
+
if (isDisabled(env.CHECKSTACK_HEALTHCHECK_SLOW_LANE_ENABLED)) return null;
|
|
58
|
+
|
|
59
|
+
const capacity = Math.floor(
|
|
60
|
+
numberFrom(env.CHECKSTACK_HEALTHCHECK_SLOW_LANE_CAPACITY, DEFAULT_SLOW_LANE_CAPACITY),
|
|
61
|
+
);
|
|
62
|
+
if (!Number.isInteger(capacity) || capacity < 1) return null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
lane: new SuspectLane(capacity),
|
|
66
|
+
recentRunsLimit: Math.max(
|
|
67
|
+
1,
|
|
68
|
+
Math.floor(
|
|
69
|
+
numberFrom(env.CHECKSTACK_HEALTHCHECK_SLOW_RECENT_RUNS, DEFAULT_RECENT_RUNS_LIMIT),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
classifierParams: {
|
|
73
|
+
consecutiveFailures: Math.max(
|
|
74
|
+
1,
|
|
75
|
+
Math.floor(
|
|
76
|
+
numberFrom(
|
|
77
|
+
env.CHECKSTACK_HEALTHCHECK_SLOW_CONSECUTIVE_FAILURES,
|
|
78
|
+
DEFAULT_CONSECUTIVE_FAILURES,
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
slowFraction: numberFrom(env.CHECKSTACK_HEALTHCHECK_SLOW_FRACTION, DEFAULT_SLOW_FRACTION),
|
|
83
|
+
recoveryProbeEvery: Math.max(
|
|
84
|
+
1,
|
|
85
|
+
Math.floor(
|
|
86
|
+
numberFrom(
|
|
87
|
+
env.CHECKSTACK_HEALTHCHECK_SLOW_RECOVERY_PROBE_EVERY,
|
|
88
|
+
DEFAULT_RECOVERY_PROBE_EVERY,
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
safetyFactor: numberFrom(
|
|
94
|
+
env.CHECKSTACK_HEALTHCHECK_SLOW_SAFETY_FACTOR,
|
|
95
|
+
DEFAULT_TIMEOUT_SAFETY_FACTOR,
|
|
96
|
+
),
|
|
97
|
+
absoluteFloorMs: numberFrom(
|
|
98
|
+
env.CHECKSTACK_HEALTHCHECK_SLOW_FLOOR_MS,
|
|
99
|
+
DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
|
|
100
|
+
),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SuspectLane } from "./suspect-lane";
|
|
3
|
+
|
|
4
|
+
describe("SuspectLane", () => {
|
|
5
|
+
test("admits up to capacity, then denies with lane_full", () => {
|
|
6
|
+
const lane = new SuspectLane(2);
|
|
7
|
+
expect(lane.tryAdmit("a")).toEqual({ admitted: true });
|
|
8
|
+
expect(lane.tryAdmit("b")).toEqual({ admitted: true });
|
|
9
|
+
expect(lane.active).toBe(2);
|
|
10
|
+
expect(lane.tryAdmit("c")).toEqual({ admitted: false, reason: "lane_full" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("single-flight: a second admit of the same key is denied with in_flight", () => {
|
|
14
|
+
const lane = new SuspectLane(4);
|
|
15
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: true });
|
|
16
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: false, reason: "in_flight" });
|
|
17
|
+
expect(lane.active).toBe(1); // the duplicate did not consume a slot
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("in_flight is checked before capacity", () => {
|
|
21
|
+
const lane = new SuspectLane(1);
|
|
22
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: true });
|
|
23
|
+
// lane is full AND k is in-flight; in_flight wins (more specific reason).
|
|
24
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: false, reason: "in_flight" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("release frees the slot and clears single-flight", () => {
|
|
28
|
+
const lane = new SuspectLane(1);
|
|
29
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: true });
|
|
30
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: false, reason: "in_flight" });
|
|
31
|
+
lane.release("k");
|
|
32
|
+
expect(lane.active).toBe(0);
|
|
33
|
+
expect(lane.tryAdmit("k")).toEqual({ admitted: true }); // re-admittable
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("release is idempotent for unknown/duplicate keys", () => {
|
|
37
|
+
const lane = new SuspectLane(2);
|
|
38
|
+
lane.tryAdmit("a");
|
|
39
|
+
lane.release("a");
|
|
40
|
+
lane.release("a"); // no-op
|
|
41
|
+
lane.release("never-admitted"); // no-op
|
|
42
|
+
expect(lane.active).toBe(0);
|
|
43
|
+
expect(lane.tryAdmit("a")).toEqual({ admitted: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects a non-positive capacity", () => {
|
|
47
|
+
expect(() => new SuspectLane(0)).toThrow();
|
|
48
|
+
expect(() => new SuspectLane(-1)).toThrow();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pod-local bulkhead for slot-hogging ("suspect") health-check jobs.
|
|
3
|
+
*
|
|
4
|
+
* A correlated outage can turn hundreds of checks into slot-hoggers at once. The
|
|
5
|
+
* work-conserving bulkhead caps how many suspect jobs may HOLD a worker slot
|
|
6
|
+
* concurrently on this pod, so healthy checks keep draining. It is deliberately
|
|
7
|
+
* NON-BLOCKING: a suspect job that cannot be admitted is skipped for this tick
|
|
8
|
+
* (freeing its worker slot immediately) rather than queued behind the lane -
|
|
9
|
+
* queueing would just move the starvation back into the shared worker pool.
|
|
10
|
+
*
|
|
11
|
+
* Two admission gates, combined:
|
|
12
|
+
* - single-flight: at most ONE in-flight run per `(configId, systemId)` job
|
|
13
|
+
* key, so a recurring fire that arrives while the previous slow run is still
|
|
14
|
+
* executing is dropped instead of stacking up (no buildup).
|
|
15
|
+
* - capacity: at most `capacity` suspect jobs holding a slot at once.
|
|
16
|
+
*
|
|
17
|
+
* This is pod-local infrastructure, exactly like the queue's own concurrency
|
|
18
|
+
* semaphore: total suspect concurrency across the cluster is `capacity x pods`,
|
|
19
|
+
* scaling the same way queue concurrency already does. The CLASSIFICATION that
|
|
20
|
+
* decides which jobs are suspect derives from durable run history (globally
|
|
21
|
+
* consistent); only this admission bookkeeping is per-pod.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type AdmissionDenial = "in_flight" | "lane_full";
|
|
25
|
+
|
|
26
|
+
export type AdmissionResult =
|
|
27
|
+
| { admitted: true }
|
|
28
|
+
| { admitted: false; reason: AdmissionDenial };
|
|
29
|
+
|
|
30
|
+
export class SuspectLane {
|
|
31
|
+
private held = 0;
|
|
32
|
+
private readonly inFlight = new Set<string>();
|
|
33
|
+
|
|
34
|
+
constructor(private readonly capacity: number) {
|
|
35
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
36
|
+
throw new Error(`SuspectLane capacity must be a positive integer, got ${capacity}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Try to admit a suspect job. On `admitted: true` the caller MUST call
|
|
42
|
+
* `release(key)` exactly once (in a `finally`) when the run completes.
|
|
43
|
+
*/
|
|
44
|
+
tryAdmit(key: string): AdmissionResult {
|
|
45
|
+
if (this.inFlight.has(key)) return { admitted: false, reason: "in_flight" };
|
|
46
|
+
if (this.held >= this.capacity) return { admitted: false, reason: "lane_full" };
|
|
47
|
+
this.held++;
|
|
48
|
+
this.inFlight.add(key);
|
|
49
|
+
return { admitted: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Release a previously-admitted key. Idempotent for an unknown key. */
|
|
53
|
+
release(key: string): void {
|
|
54
|
+
if (this.inFlight.delete(key)) this.held--;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Number of suspect jobs currently holding a slot (for metrics/tests). */
|
|
58
|
+
get active(): number {
|
|
59
|
+
return this.held;
|
|
60
|
+
}
|
|
61
|
+
}
|