@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/package.json +30 -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/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +87 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +103 -19
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +348 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/status-page/widgets.ts +11 -1
  36. package/src/suspect-lane.test.ts +50 -0
  37. package/src/suspect-lane.ts +61 -0
  38. package/src/system-health-override.test.ts +94 -0
  39. 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
+ }