@checkstack/healthcheck-backend 1.17.0 → 1.19.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/package.json +32 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/health-notification-content.test.ts +89 -0
  9. package/src/health-notification-content.ts +138 -0
  10. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  11. package/src/healthcheck-gitops-kinds.ts +17 -13
  12. package/src/index.ts +58 -6
  13. package/src/migration-chain-contract.test.ts +7 -1
  14. package/src/notification-policy.test.ts +19 -0
  15. package/src/notification-policy.ts +26 -0
  16. package/src/queue-executor.test.ts +391 -338
  17. package/src/queue-executor.ts +426 -362
  18. package/src/realtime-aggregation.ts +9 -2
  19. package/src/rollup-consumer.test.ts +191 -0
  20. package/src/rollup-consumer.ts +160 -0
  21. package/src/router.ts +46 -13
  22. package/src/schedule-jitter.test.ts +69 -0
  23. package/src/schedule-jitter.ts +50 -0
  24. package/src/schedule-reconciler.it.test.ts +453 -0
  25. package/src/schedule-reconciler.test.ts +418 -0
  26. package/src/schedule-reconciler.ts +304 -0
  27. package/src/service-batching.test.ts +106 -0
  28. package/src/service-bulk-counts.it.test.ts +144 -0
  29. package/src/service-bulk-run-stats.it.test.ts +197 -0
  30. package/src/service-ordering.test.ts +10 -2
  31. package/src/service-paused-filter.test.ts +27 -7
  32. package/src/service-rollup-worst-wins.test.ts +221 -124
  33. package/src/service.ts +557 -266
  34. package/src/slow-check-admission.test.ts +184 -0
  35. package/src/slow-check-admission.ts +101 -0
  36. package/src/slow-check-classifier.test.ts +155 -0
  37. package/src/slow-check-classifier.ts +137 -0
  38. package/src/slow-check-config.ts +102 -0
  39. package/src/status-page/rollup.test.ts +40 -0
  40. package/src/status-page/rollup.ts +27 -0
  41. package/src/status-page/widgets.test.ts +303 -0
  42. package/src/status-page/widgets.ts +155 -39
  43. package/src/suspect-lane.test.ts +50 -0
  44. package/src/suspect-lane.ts +61 -0
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ evaluateSlowCheckAdmission,
4
+ slowCheckLaneKey,
5
+ } from "./slow-check-admission";
6
+ import { SuspectLane } from "./suspect-lane";
7
+ import type { SlowCheckRuntime } from "./slow-check-config";
8
+ import type { RecentRun } from "./slow-check-classifier";
9
+
10
+ function runtime(overrides: Partial<SlowCheckRuntime> = {}): SlowCheckRuntime {
11
+ return {
12
+ lane: new SuspectLane(1),
13
+ recentRunsLimit: 20,
14
+ classifierParams: {
15
+ consecutiveFailures: 3,
16
+ slowFraction: 0.8,
17
+ recoveryProbeEvery: 5,
18
+ },
19
+ safetyFactor: 1.5,
20
+ absoluteFloorMs: 1000,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ const TIMEOUT = 10_000;
26
+
27
+ /** A slow transport failure (held its slot ~the full timeout). */
28
+ function slowFail(): RecentRun {
29
+ return {
30
+ environmentId: null,
31
+ status: "unhealthy",
32
+ latencyMs: TIMEOUT, // >= slowFraction * timeout
33
+ timestamp: new Date(),
34
+ };
35
+ }
36
+
37
+ /** A healthy run with a given latency. */
38
+ function healthy(latencyMs: number): RecentRun {
39
+ return { environmentId: null, status: "healthy", latencyMs, timestamp: new Date() };
40
+ }
41
+
42
+ describe("slowCheckLaneKey", () => {
43
+ it("keys on (config, system, env) with ENV_LESS_KEY for null", () => {
44
+ expect(
45
+ slowCheckLaneKey({ configId: "c", systemId: "s", environmentId: null }),
46
+ ).toBe("c:s:_");
47
+ expect(
48
+ slowCheckLaneKey({ configId: "c", systemId: "s", environmentId: "prod" }),
49
+ ).toBe("c:s:prod");
50
+ });
51
+ });
52
+
53
+ describe("evaluateSlowCheckAdmission", () => {
54
+ it("runs a NON-suspect slice at the full timeout with no lane involvement", () => {
55
+ const rt = runtime();
56
+ const decision = evaluateSlowCheckAdmission({
57
+ runtime: rt,
58
+ recentRuns: [healthy(50), healthy(60)],
59
+ configId: "c",
60
+ systemId: "s",
61
+ environmentId: null,
62
+ executionTimeoutMs: TIMEOUT,
63
+ });
64
+ expect(decision).toEqual({ kind: "run", effectiveTimeoutMs: TIMEOUT });
65
+ // No slot was taken.
66
+ expect(rt.lane.active).toBe(0);
67
+ });
68
+
69
+ it("admits a suspect slice and shrinks the timeout toward its healthy baseline", () => {
70
+ const rt = runtime();
71
+ const decision = evaluateSlowCheckAdmission({
72
+ runtime: rt,
73
+ // 3 leading slow failures ⇒ suspect; one healthy sample gives a baseline.
74
+ recentRuns: [slowFail(), slowFail(), slowFail(), healthy(200)],
75
+ configId: "c",
76
+ systemId: "s",
77
+ environmentId: null,
78
+ executionTimeoutMs: TIMEOUT,
79
+ });
80
+ expect(decision.kind).toBe("run");
81
+ if (decision.kind !== "run") return;
82
+ // adaptiveTimeout: max(floor 1000, 200 * 1.5) = 1000, and < configured.
83
+ expect(decision.effectiveTimeoutMs).toBe(1000);
84
+ expect(decision.laneKey).toBe("c:s:_");
85
+ expect(rt.lane.active).toBe(1);
86
+ });
87
+
88
+ it("keeps the full timeout on a recovery-probe suspect run", () => {
89
+ const rt = runtime();
90
+ // 5 leading slow failures ⇒ suspect AND a recovery probe (every 5th).
91
+ const decision = evaluateSlowCheckAdmission({
92
+ runtime: rt,
93
+ recentRuns: [
94
+ slowFail(),
95
+ slowFail(),
96
+ slowFail(),
97
+ slowFail(),
98
+ slowFail(),
99
+ healthy(200),
100
+ ],
101
+ configId: "c",
102
+ systemId: "s",
103
+ environmentId: null,
104
+ executionTimeoutMs: TIMEOUT,
105
+ });
106
+ expect(decision.kind).toBe("run");
107
+ if (decision.kind !== "run") return;
108
+ // Recovery probe re-measures at the FULL configured timeout.
109
+ expect(decision.effectiveTimeoutMs).toBe(TIMEOUT);
110
+ expect(decision.laneKey).toBe("c:s:_");
111
+ });
112
+
113
+ it("DEFERS a suspect slice when the lane is full (lane_full)", () => {
114
+ const rt = runtime({ lane: new SuspectLane(1) });
115
+ // Fill the single slot with a different slice.
116
+ rt.lane.tryAdmit("other:slice:_");
117
+
118
+ const decision = evaluateSlowCheckAdmission({
119
+ runtime: rt,
120
+ recentRuns: [slowFail(), slowFail(), slowFail()],
121
+ configId: "c",
122
+ systemId: "s",
123
+ environmentId: null,
124
+ executionTimeoutMs: TIMEOUT,
125
+ });
126
+ expect(decision).toEqual({ kind: "defer", reason: "lane_full" });
127
+ });
128
+
129
+ it("DEFERS a suspect slice already in flight (in_flight, single-flight)", () => {
130
+ const rt = runtime({ lane: new SuspectLane(4) });
131
+ // Same slice already holds a slot (a prior tick still running).
132
+ rt.lane.tryAdmit("c:s:_");
133
+
134
+ const decision = evaluateSlowCheckAdmission({
135
+ runtime: rt,
136
+ recentRuns: [slowFail(), slowFail(), slowFail()],
137
+ configId: "c",
138
+ systemId: "s",
139
+ environmentId: null,
140
+ executionTimeoutMs: TIMEOUT,
141
+ });
142
+ expect(decision).toEqual({ kind: "defer", reason: "in_flight" });
143
+ });
144
+
145
+ it("classifies per-env: one env suspect, a sibling env healthy in the same runtime", () => {
146
+ const rt = runtime({ lane: new SuspectLane(2) });
147
+ const prodFail = (): RecentRun => ({
148
+ environmentId: "prod",
149
+ status: "unhealthy",
150
+ latencyMs: TIMEOUT,
151
+ timestamp: new Date(),
152
+ });
153
+ const runs: RecentRun[] = [
154
+ prodFail(),
155
+ prodFail(),
156
+ prodFail(),
157
+ { environmentId: "staging", status: "healthy", latencyMs: 40, timestamp: new Date() },
158
+ ];
159
+
160
+ const prod = evaluateSlowCheckAdmission({
161
+ runtime: rt,
162
+ recentRuns: runs,
163
+ configId: "c",
164
+ systemId: "s",
165
+ environmentId: "prod",
166
+ executionTimeoutMs: TIMEOUT,
167
+ });
168
+ const staging = evaluateSlowCheckAdmission({
169
+ runtime: rt,
170
+ recentRuns: runs,
171
+ configId: "c",
172
+ systemId: "s",
173
+ environmentId: "staging",
174
+ executionTimeoutMs: TIMEOUT,
175
+ });
176
+
177
+ // prod is suspect (admitted, shrunk); staging runs at full timeout, no slot.
178
+ expect(prod.kind).toBe("run");
179
+ if (prod.kind === "run") expect(prod.laneKey).toBe("c:s:prod");
180
+ expect(staging).toEqual({ kind: "run", effectiveTimeoutMs: TIMEOUT });
181
+ // Only the suspect env took a slot.
182
+ expect(rt.lane.active).toBe(1);
183
+ });
184
+ });
@@ -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
+ }