@checkstack/healthcheck-backend 1.16.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 +399 -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 +87 -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 +103 -19
- 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 +348 -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/status-page/widgets.ts +11 -1
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
- package/src/system-health-override.test.ts +94 -0
- package/src/system-health-override.ts +93 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
applySystemHealthOverrides,
|
|
4
|
+
worstHealthStatus,
|
|
5
|
+
type SystemHealthOverrideInput,
|
|
6
|
+
} from "./system-health-override";
|
|
7
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
8
|
+
|
|
9
|
+
const at = new Date("2026-07-05T00:00:00.000Z");
|
|
10
|
+
|
|
11
|
+
function base(
|
|
12
|
+
status: SystemHealthStatusResponse["status"],
|
|
13
|
+
): SystemHealthStatusResponse {
|
|
14
|
+
return { status, evaluatedAt: at, checkStatuses: [] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function override(
|
|
18
|
+
status: "degraded" | "unhealthy",
|
|
19
|
+
sourceId: string,
|
|
20
|
+
): SystemHealthOverrideInput {
|
|
21
|
+
return {
|
|
22
|
+
status,
|
|
23
|
+
source: "incident",
|
|
24
|
+
reason: `Incident ${sourceId}`,
|
|
25
|
+
sourceId,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("worstHealthStatus", () => {
|
|
30
|
+
it("orders unhealthy > degraded > healthy", () => {
|
|
31
|
+
expect(worstHealthStatus("healthy", "degraded")).toBe("degraded");
|
|
32
|
+
expect(worstHealthStatus("degraded", "unhealthy")).toBe("unhealthy");
|
|
33
|
+
expect(worstHealthStatus("unhealthy", "degraded")).toBe("unhealthy");
|
|
34
|
+
expect(worstHealthStatus("healthy", "healthy")).toBe("healthy");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("applySystemHealthOverrides", () => {
|
|
39
|
+
it("returns the base unchanged (no override field) when there are no overrides", () => {
|
|
40
|
+
const result = applySystemHealthOverrides({
|
|
41
|
+
base: base("healthy"),
|
|
42
|
+
overrides: [],
|
|
43
|
+
});
|
|
44
|
+
expect(result.status).toBe("healthy");
|
|
45
|
+
expect(result.override).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("raises a healthy system to the override status and records the source", () => {
|
|
49
|
+
const result = applySystemHealthOverrides({
|
|
50
|
+
base: base("healthy"),
|
|
51
|
+
overrides: [override("unhealthy", "inc-1")],
|
|
52
|
+
});
|
|
53
|
+
expect(result.status).toBe("unhealthy");
|
|
54
|
+
expect(result.override).toEqual({
|
|
55
|
+
status: "unhealthy",
|
|
56
|
+
source: "incident",
|
|
57
|
+
reason: "Incident inc-1",
|
|
58
|
+
sourceId: "inc-1",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies an override even when the system has no health checks", () => {
|
|
63
|
+
const result = applySystemHealthOverrides({
|
|
64
|
+
base: base("healthy"), // no-checks default is healthy
|
|
65
|
+
overrides: [override("degraded", "inc-2")],
|
|
66
|
+
});
|
|
67
|
+
expect(result.status).toBe("degraded");
|
|
68
|
+
expect(result.override?.status).toBe("degraded");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps the worse HEALTH CHECK status when a check is worse than the override", () => {
|
|
72
|
+
// Override says degraded, but a check reports unhealthy -> unhealthy wins.
|
|
73
|
+
const result = applySystemHealthOverrides({
|
|
74
|
+
base: base("unhealthy"),
|
|
75
|
+
overrides: [override("degraded", "inc-3")],
|
|
76
|
+
});
|
|
77
|
+
expect(result.status).toBe("unhealthy");
|
|
78
|
+
// The override is still surfaced (it contributed), at its own status.
|
|
79
|
+
expect(result.override?.status).toBe("degraded");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("picks the worst override when several incidents override the same system", () => {
|
|
83
|
+
const result = applySystemHealthOverrides({
|
|
84
|
+
base: base("healthy"),
|
|
85
|
+
overrides: [
|
|
86
|
+
override("degraded", "inc-a"),
|
|
87
|
+
override("unhealthy", "inc-b"),
|
|
88
|
+
override("degraded", "inc-c"),
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
expect(result.status).toBe("unhealthy");
|
|
92
|
+
expect(result.override?.sourceId).toBe("inc-b");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HealthCheckStatus,
|
|
3
|
+
SystemHealthStatusResponse,
|
|
4
|
+
SystemHealthOverride,
|
|
5
|
+
} from "@checkstack/healthcheck-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Worst-wins ordering for the derived health vocabulary: a higher rank is a
|
|
9
|
+
* worse status. Mirrors the inline `unhealthy > degraded > healthy` ordering the
|
|
10
|
+
* per-check rollup uses in `service.ts`, extracted so the incident-override fold
|
|
11
|
+
* shares the exact same comparison.
|
|
12
|
+
*/
|
|
13
|
+
const HEALTH_RANK: Record<HealthCheckStatus, number> = {
|
|
14
|
+
healthy: 0,
|
|
15
|
+
degraded: 1,
|
|
16
|
+
unhealthy: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Returns whichever status is worse (ties return `a`). */
|
|
20
|
+
export function worstHealthStatus(
|
|
21
|
+
a: HealthCheckStatus,
|
|
22
|
+
b: HealthCheckStatus,
|
|
23
|
+
): HealthCheckStatus {
|
|
24
|
+
return HEALTH_RANK[b] > HEALTH_RANK[a] ? b : a;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A non-health-check contribution to a system's health, as consumed by the
|
|
29
|
+
* fold. Kept source-agnostic (`source`/`sourceId`) so the health plugin does not
|
|
30
|
+
* hard-code incident semantics; the incident-backed reader maps its rows into
|
|
31
|
+
* this shape.
|
|
32
|
+
*/
|
|
33
|
+
export interface SystemHealthOverrideInput {
|
|
34
|
+
/** The status this contributor forces. Never `healthy` in practice. */
|
|
35
|
+
status: HealthCheckStatus;
|
|
36
|
+
/** Contributor kind, e.g. "incident". */
|
|
37
|
+
source: string;
|
|
38
|
+
/** Human-readable reason, e.g. the incident title. */
|
|
39
|
+
reason: string;
|
|
40
|
+
/** Opaque id of the contributing record, e.g. the incident id. */
|
|
41
|
+
sourceId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads active health overrides for a set of systems. Implemented in the plugin
|
|
46
|
+
* wiring over the incident RPC; injected into the health service so the service
|
|
47
|
+
* stays free of a direct incident dependency and tests can stub it.
|
|
48
|
+
*/
|
|
49
|
+
export interface SystemHealthOverrideReader {
|
|
50
|
+
getActiveOverrides(
|
|
51
|
+
systemIds: string[],
|
|
52
|
+
): Promise<Record<string, SystemHealthOverrideInput[]>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fold a system's active overrides into its health-check-derived status via
|
|
57
|
+
* worst-wins. The overall status becomes the worst of the checks-derived status
|
|
58
|
+
* and every override, so an override that raises a system to `degraded` never
|
|
59
|
+
* masks a health check reporting `unhealthy` (the worse status always wins), and
|
|
60
|
+
* an override applies even when the system has no health checks at all.
|
|
61
|
+
*
|
|
62
|
+
* The worst contributing override (if any) is surfaced on `override` so a UI can
|
|
63
|
+
* explain why a system reads worse than its checks alone. When the overrides are
|
|
64
|
+
* empty the base response is returned unchanged (no `override`).
|
|
65
|
+
*/
|
|
66
|
+
export function applySystemHealthOverrides({
|
|
67
|
+
base,
|
|
68
|
+
overrides,
|
|
69
|
+
}: {
|
|
70
|
+
base: SystemHealthStatusResponse;
|
|
71
|
+
overrides: SystemHealthOverrideInput[];
|
|
72
|
+
}): SystemHealthStatusResponse {
|
|
73
|
+
if (overrides.length === 0) return base;
|
|
74
|
+
|
|
75
|
+
// The override that reads worst wins the `override` slot; a tie keeps the
|
|
76
|
+
// first (query order), which for incidents is a stable, arbitrary pick.
|
|
77
|
+
let worst = overrides[0]!;
|
|
78
|
+
for (const candidate of overrides) {
|
|
79
|
+
if (HEALTH_RANK[candidate.status] > HEALTH_RANK[worst.status]) {
|
|
80
|
+
worst = candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const status = worstHealthStatus(base.status, worst.status);
|
|
85
|
+
const override: SystemHealthOverride = {
|
|
86
|
+
status: worst.status,
|
|
87
|
+
source: worst.source,
|
|
88
|
+
reason: worst.reason,
|
|
89
|
+
sourceId: worst.sourceId,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { ...base, status, override };
|
|
93
|
+
}
|