@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
@@ -1,4 +1,7 @@
1
- import type { SafeDatabase, CollectorRegistry } from "@checkstack/backend-api";
1
+ import type {
2
+ ScopedQueryRunner,
3
+ CollectorRegistry,
4
+ } from "@checkstack/backend-api";
2
5
  import {
3
6
  ASSERTIONS_AGG_KEY,
4
7
  AssertionOutcomeSchema,
@@ -11,7 +14,11 @@ import * as schema from "./schema";
11
14
  import { healthCheckAggregates } from "./schema";
12
15
  import { eq, and, sql } from "drizzle-orm";
13
16
 
14
- type Db = SafeDatabase<typeof schema>;
17
+ // Accepts either the scoped database OR a transaction handle from it, so the
18
+ // caller can compose the aggregate SELECT + UPSERT inside a single batching
19
+ // transaction (one `SET LOCAL search_path` for the whole write group) — see
20
+ // `withScopedTransaction`.
21
+ type Db = ScopedQueryRunner<typeof schema>;
15
22
 
16
23
  /**
17
24
  * Get the hour bucket start time for a given timestamp.
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import {
3
+ setupRollupConsumer,
4
+ encodeRollupDebounceJobId,
5
+ HEALTH_ROLLUP_QUEUE,
6
+ type HealthRollupJobPayload,
7
+ } from "./rollup-consumer";
8
+ import { encodeHealthEntityId } from "./health-entity-id";
9
+ import type { EntityChanged, OnEntityChanged } from "@checkstack/automation-backend";
10
+
11
+ describe("encodeRollupDebounceJobId", () => {
12
+ it("coalesces changes in the same window to one jobId", () => {
13
+ const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
14
+ const b = encodeRollupDebounceJobId({ systemId: "s1", now: 11_500, windowMs: 2000 });
15
+ expect(a).toBe(b);
16
+ expect(a).toBe("healthrollup:s1:5");
17
+ });
18
+
19
+ it("uses a fresh jobId for the next window", () => {
20
+ const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
21
+ const b = encodeRollupDebounceJobId({ systemId: "s1", now: 12_500, windowMs: 2000 });
22
+ expect(a).not.toBe(b);
23
+ });
24
+
25
+ it("keys distinct systems separately", () => {
26
+ const a = encodeRollupDebounceJobId({ systemId: "s1", now: 10_000, windowMs: 2000 });
27
+ const b = encodeRollupDebounceJobId({ systemId: "s2", now: 10_000, windowMs: 2000 });
28
+ expect(a).not.toBe(b);
29
+ });
30
+ });
31
+
32
+ interface Harness {
33
+ enqueue: ReturnType<typeof mock>;
34
+ consumeHandler: (job: { data: HealthRollupJobPayload }) => Promise<void>;
35
+ changeHandler: (change: EntityChanged) => Promise<void>;
36
+ getSystemHealthStatus: ReturnType<typeof mock>;
37
+ broadcast: ReturnType<typeof mock>;
38
+ invalidateSystem: ReturnType<typeof mock>;
39
+ }
40
+
41
+ async function setup(opts: {
42
+ statuses?: string[]; // successive getSystemHealthStatus results
43
+ now?: number;
44
+ } = {}): Promise<Harness> {
45
+ const statuses = opts.statuses ?? ["healthy", "healthy"];
46
+ let statusCall = 0;
47
+ const getSystemHealthStatus = mock(async () => ({
48
+ status: statuses[Math.min(statusCall++, statuses.length - 1)],
49
+ checkStatuses: [],
50
+ }));
51
+
52
+ const enqueue = mock(async () => "job-id");
53
+ let consumeHandler!: (job: { data: HealthRollupJobPayload }) => Promise<void>;
54
+ const queue = {
55
+ enqueue,
56
+ consume: mock(
57
+ async (
58
+ handler: (job: { data: HealthRollupJobPayload }) => Promise<void>,
59
+ ) => {
60
+ consumeHandler = handler;
61
+ },
62
+ ),
63
+ };
64
+ const queueManager = {
65
+ getQueue: mock((name: string) => {
66
+ expect(name).toBe(HEALTH_ROLLUP_QUEUE);
67
+ return queue;
68
+ }),
69
+ } as unknown as Parameters<typeof setupRollupConsumer>[0]["queueManager"];
70
+
71
+ let changeHandler!: (change: EntityChanged) => Promise<void>;
72
+ const onEntityChanged = mock((input: Parameters<OnEntityChanged>[0]) => {
73
+ changeHandler = input.handler as (c: EntityChanged) => Promise<void>;
74
+ return async () => {};
75
+ }) as unknown as OnEntityChanged;
76
+
77
+ const broadcast = mock(async () => {});
78
+ const invalidateSystem = mock(async () => {});
79
+
80
+ await setupRollupConsumer({
81
+ queueManager,
82
+ onEntityChanged,
83
+ service: { getSystemHealthStatus } as never,
84
+ advisoryLock: {
85
+ withXactLock: async ({ fn }: { fn: () => Promise<unknown> }) => fn(),
86
+ } as never,
87
+ signalService: { broadcast } as never,
88
+ cache: { invalidateSystem } as never,
89
+ // No entity handle: writeHealthEntity runs `apply` directly.
90
+ getHealthEntity: () => undefined,
91
+ logger: {
92
+ debug: () => {},
93
+ info: () => {},
94
+ warn: () => {},
95
+ error: () => {},
96
+ } as never,
97
+ now: () => opts.now ?? 10_000,
98
+ });
99
+
100
+ return {
101
+ enqueue,
102
+ consumeHandler,
103
+ changeHandler,
104
+ getSystemHealthStatus,
105
+ broadcast,
106
+ invalidateSystem,
107
+ };
108
+ }
109
+
110
+ describe("setupRollupConsumer subscription", () => {
111
+ it("enqueues a debounced rollup job for a per-env health change", async () => {
112
+ const h = await setup({ now: 10_000 });
113
+ await h.changeHandler({
114
+ kind: "health",
115
+ id: encodeHealthEntityId({ systemId: "s1", environmentId: "prod" }),
116
+ prev: null,
117
+ next: { status: "unhealthy" },
118
+ } as unknown as EntityChanged);
119
+
120
+ expect(h.enqueue).toHaveBeenCalledTimes(1);
121
+ expect(h.enqueue.mock.calls[0]![0]).toEqual({ systemId: "s1" });
122
+ expect(h.enqueue.mock.calls[0]![1]).toMatchObject({
123
+ jobId: "healthrollup:s1:5",
124
+ startDelay: 2,
125
+ });
126
+ });
127
+
128
+ it("IGNORES a bare-rollup change (feedback-loop guard)", async () => {
129
+ const h = await setup();
130
+ await h.changeHandler({
131
+ kind: "health",
132
+ id: encodeHealthEntityId({ systemId: "s1" }), // bare rollup id
133
+ prev: null,
134
+ next: { status: "unhealthy" },
135
+ } as unknown as EntityChanged);
136
+
137
+ expect(h.enqueue).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("coalesces two per-env changes in the same window to one jobId", async () => {
141
+ const h = await setup({ now: 10_000 });
142
+ const mk = (env: string) =>
143
+ ({
144
+ kind: "health",
145
+ id: encodeHealthEntityId({ systemId: "s1", environmentId: env }),
146
+ prev: null,
147
+ next: { status: "unhealthy" },
148
+ }) as unknown as EntityChanged;
149
+
150
+ await h.changeHandler(mk("prod"));
151
+ await h.changeHandler(mk("staging"));
152
+
153
+ // Both enqueue with the SAME jobId; the queue backend dedupes them.
154
+ expect(h.enqueue).toHaveBeenCalledTimes(2);
155
+ expect(h.enqueue.mock.calls[0]![1]).toMatchObject({
156
+ jobId: "healthrollup:s1:5",
157
+ });
158
+ expect(h.enqueue.mock.calls[1]![1]).toMatchObject({
159
+ jobId: "healthrollup:s1:5",
160
+ });
161
+ });
162
+ });
163
+
164
+ describe("setupRollupConsumer rollup recompute", () => {
165
+ it("broadcasts SYSTEM_STATUS_CHANGED + invalidates cache on a rollup status change", async () => {
166
+ const h = await setup({ statuses: ["healthy", "unhealthy"] });
167
+ await h.consumeHandler({ data: { systemId: "s1" } });
168
+
169
+ expect(h.getSystemHealthStatus).toHaveBeenCalled();
170
+ expect(h.invalidateSystem).toHaveBeenCalledWith("s1");
171
+ expect(h.broadcast).toHaveBeenCalledTimes(1);
172
+ const payload = h.broadcast.mock.calls[0]![1] as {
173
+ systemId: string;
174
+ previousStatus: string;
175
+ newStatus: string;
176
+ };
177
+ expect(payload).toEqual({
178
+ systemId: "s1",
179
+ previousStatus: "healthy",
180
+ newStatus: "unhealthy",
181
+ });
182
+ });
183
+
184
+ it("does NOT broadcast when the rollup status is unchanged", async () => {
185
+ const h = await setup({ statuses: ["degraded", "degraded"] });
186
+ await h.consumeHandler({ data: { systemId: "s1" } });
187
+
188
+ expect(h.broadcast).not.toHaveBeenCalled();
189
+ expect(h.invalidateSystem).not.toHaveBeenCalled();
190
+ });
191
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Event-driven system-rollup consumer (Phase 2 of the per-environment-jobs
3
+ * migration).
4
+ *
5
+ * Under per-environment jobs, each recurring job writes ONLY its own
6
+ * `"<systemId>::<environmentId>"` health entity; the bare `"<systemId>"` ROLLUP
7
+ * entity (the worst-status view every existing system-level consumer, badge,
8
+ * SLO rule and dashboard references) is no longer recomputed inline. This
9
+ * consumer closes that gap WITHOUT re-coupling the executor to the rollup:
10
+ *
11
+ * 1. It subscribes to `health` `ENTITY_CHANGED` with **work-queue** delivery
12
+ * (exactly once per cluster per change) and filters to PER-ENV ids
13
+ * (`environmentId !== null`). A bare-rollup change is IGNORED, so the
14
+ * rollup write this consumer itself performs can never re-trigger it - no
15
+ * feedback loop.
16
+ * 2. Per-env changes are DEBOUNCED per system into a short fixed window: the
17
+ * handler enqueues a `{ systemId }` job on the rollup queue keyed
18
+ * `healthrollup:<systemId>:<window-bucket>`. Both queue backends dedupe on
19
+ * jobId, so a burst of env transitions for one system in the same window
20
+ * coalesces to ONE rollup recompute (and the bucket id is used once, so
21
+ * BullMQ's completed-job retention never drops a later window's job).
22
+ * 3. The rollup-queue consumer recomputes the bare `"<systemId>"` entity via
23
+ * `recomputeSystemRollupHealth`, which diffs prev → next inside the
24
+ * `health:<systemId>` advisory lock, emits the rollup `ENTITY_CHANGED`, and
25
+ * (on a real change) invalidates the cache + broadcasts
26
+ * `SYSTEM_STATUS_CHANGED`.
27
+ *
28
+ * Notifications are intentionally NOT sent here: each env run already notifies
29
+ * its own transition, so a rollup notification would duplicate it. This is the
30
+ * structural form of the #417 rollup-notification dedup - for a fanned-out
31
+ * system the rollup notification is ALWAYS suppressed; an env-less system needs
32
+ * no consumer at all (its run IS the bare-entity write and notifies directly).
33
+ *
34
+ * Scale-correctness (state-and-scale): the rollup is recomputed from Postgres
35
+ * (`getSystemHealthStatus`) on whichever pod claims the debounced job, and the
36
+ * advisory lock serializes concurrent recomputes, so every pod converges to the
37
+ * same rollup regardless of which one ran it.
38
+ */
39
+ import type { Logger, AdvisoryLockService } from "@checkstack/backend-api";
40
+ import type { QueueManager } from "@checkstack/queue-api";
41
+ import type { SignalService } from "@checkstack/signal-common";
42
+ import type {
43
+ OnEntityChanged,
44
+ EntityChanged,
45
+ EntityHandle,
46
+ } from "@checkstack/automation-backend";
47
+ import { HEALTH_ENTITY_KIND, type HealthEntityState } from "./health-entity";
48
+ import { parseHealthEntityId } from "./health-entity-id";
49
+ import { recomputeSystemRollupHealth } from "./queue-executor";
50
+ import type { HealthCheckService } from "./service";
51
+ import type { HealthCheckCache } from "./cache";
52
+
53
+ /** The dedicated queue the debounced rollup recomputes run on. */
54
+ export const HEALTH_ROLLUP_QUEUE = "health-rollup";
55
+
56
+ /** Payload for a debounced rollup recompute job. */
57
+ export interface HealthRollupJobPayload {
58
+ systemId: string;
59
+ }
60
+
61
+ /** Default debounce window (ms) a burst of per-env changes coalesces into. */
62
+ export const DEFAULT_ROLLUP_DEBOUNCE_MS = 2000;
63
+
64
+ /**
65
+ * The dedup jobId for a debounced rollup recompute. Keyed on the system AND a
66
+ * fixed time bucket so a burst within one window coalesces to a single job,
67
+ * while the NEXT window gets a fresh id (so a completed-job retention on the
68
+ * queue backend can never suppress a later window's recompute).
69
+ */
70
+ export function encodeRollupDebounceJobId(props: {
71
+ systemId: string;
72
+ now: number;
73
+ windowMs: number;
74
+ }): string {
75
+ const { systemId, now, windowMs } = props;
76
+ const bucket = Math.floor(now / windowMs);
77
+ return `healthrollup:${systemId}:${bucket}`;
78
+ }
79
+
80
+ export interface RollupConsumerDeps {
81
+ queueManager: QueueManager;
82
+ onEntityChanged: OnEntityChanged;
83
+ service: HealthCheckService;
84
+ advisoryLock: AdvisoryLockService;
85
+ signalService: SignalService;
86
+ cache: HealthCheckCache;
87
+ getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
88
+ logger: Logger;
89
+ /** Debounce window in ms. Defaults to {@link DEFAULT_ROLLUP_DEBOUNCE_MS}. */
90
+ debounceMs?: number;
91
+ /** Injectable clock for tests. Defaults to `Date.now`. */
92
+ now?: () => number;
93
+ }
94
+
95
+ /**
96
+ * Wire the rollup-queue consumer + the per-env `health` change subscription.
97
+ * Returns the `onEntityChanged` unsubscribe handle for teardown.
98
+ */
99
+ export async function setupRollupConsumer(
100
+ deps: RollupConsumerDeps,
101
+ ): Promise<() => Promise<void>> {
102
+ const {
103
+ queueManager,
104
+ onEntityChanged,
105
+ service,
106
+ advisoryLock,
107
+ signalService,
108
+ cache,
109
+ getHealthEntity,
110
+ logger,
111
+ debounceMs = DEFAULT_ROLLUP_DEBOUNCE_MS,
112
+ now = () => Date.now(),
113
+ } = deps;
114
+
115
+ const rollupQueue =
116
+ queueManager.getQueue<HealthRollupJobPayload>(HEALTH_ROLLUP_QUEUE);
117
+
118
+ // Consumer: recompute the bare-system rollup entity for the job's system.
119
+ await rollupQueue.consume(
120
+ async (job) => {
121
+ await recomputeSystemRollupHealth({
122
+ systemId: job.data.systemId,
123
+ service,
124
+ getHealthEntity,
125
+ advisoryLock,
126
+ signalService,
127
+ cache,
128
+ logger,
129
+ });
130
+ },
131
+ { consumerGroup: "health-rollup" },
132
+ );
133
+
134
+ // Subscription: a per-env `health` change debounces a rollup recompute for
135
+ // its system. Bare-rollup changes are ignored (feedback-loop guard).
136
+ const unsubscribe = onEntityChanged({
137
+ kind: HEALTH_ENTITY_KIND,
138
+ delivery: { mode: "work-queue", workerGroup: "health-rollup-debounce" },
139
+ handler: async (change: EntityChanged) => {
140
+ const { systemId, environmentId } = parseHealthEntityId(change.id);
141
+ // Only PER-ENV changes drive a rollup recompute. A bare-`<systemId>`
142
+ // change is either an env-less run (already the rollup) or this
143
+ // consumer's own rollup write - never re-enqueue on it.
144
+ if (environmentId === null) return;
145
+
146
+ const jobId = encodeRollupDebounceJobId({
147
+ systemId,
148
+ now: now(),
149
+ windowMs: debounceMs,
150
+ });
151
+ await rollupQueue.enqueue(
152
+ { systemId },
153
+ { jobId, startDelay: Math.ceil(debounceMs / 1000) },
154
+ );
155
+ },
156
+ });
157
+
158
+ logger.debug("✅ Health rollup consumer wired (per-env → debounced rollup).");
159
+ return unsubscribe;
160
+ }
package/src/router.ts CHANGED
@@ -39,6 +39,11 @@ import { CatalogApi } from "@checkstack/catalog-common";
39
39
  import { MaintenanceApi } from "@checkstack/maintenance-common";
40
40
  import type { Logger } from "@checkstack/backend-api";
41
41
  import type { HealthCheckCache } from "./cache";
42
+ import {
43
+ applySystemHealthOverrides,
44
+ type SystemHealthOverrideReader,
45
+ } from "./system-health-override";
46
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
42
47
 
43
48
  /**
44
49
  * Creates the healthcheck router using contract-based implementation.
@@ -81,6 +86,17 @@ export const createHealthCheckRouter = (opts: {
81
86
  * router MUST receive it or writes would store inline secrets verbatim.
82
87
  */
83
88
  configSecrets?: HealthCheckSecretsDeps;
89
+ /**
90
+ * Reads active incident health overrides and folds them into the two
91
+ * user-facing system-health reads (single + bulk) via worst-wins, so a system
92
+ * shows the status an active incident forces even when its checks look fine.
93
+ * Applied OUTSIDE the status cache (always live, so an override lifts the
94
+ * instant its incident resolves) and ONLY in these RPC handlers - never in the
95
+ * shared `getSystemHealthStatus` deriver, whose other callers (SLO downtime,
96
+ * the AI signals scan, the persisted `health` entity) must stay checks-only.
97
+ * Optional so tests / no-incident deployments simply skip the fold.
98
+ */
99
+ incidentHealthOverrideReader?: SystemHealthOverrideReader;
84
100
  }) => {
85
101
  const {
86
102
  database,
@@ -94,6 +110,7 @@ export const createHealthCheckRouter = (opts: {
94
110
  logger,
95
111
  signalService,
96
112
  recomputeSystemRollupHealth,
113
+ incidentHealthOverrideReader,
97
114
  } = opts;
98
115
  // Create service instance once - shared across all handlers
99
116
  const service = new HealthCheckService(
@@ -105,6 +122,43 @@ export const createHealthCheckRouter = (opts: {
105
122
  opts.configSecrets,
106
123
  );
107
124
 
125
+ /**
126
+ * Fold active incident health overrides into a batch of checks-only system
127
+ * statuses via worst-wins. Reads overrides for all systems in ONE incident RPC
128
+ * (or none, when no reader is wired). Resilient by design: incidents are a
129
+ * best-effort enrichment of health, so if the read fails the checks-only
130
+ * statuses are returned unchanged rather than failing the whole health read.
131
+ */
132
+ const foldIncidentOverrides = async (
133
+ statuses: Record<string, SystemHealthStatusResponse>,
134
+ ): Promise<Record<string, SystemHealthStatusResponse>> => {
135
+ const systemIds = Object.keys(statuses);
136
+ if (!incidentHealthOverrideReader || systemIds.length === 0) {
137
+ return statuses;
138
+ }
139
+ let overridesBySystem: Awaited<
140
+ ReturnType<SystemHealthOverrideReader["getActiveOverrides"]>
141
+ >;
142
+ try {
143
+ overridesBySystem =
144
+ await incidentHealthOverrideReader.getActiveOverrides(systemIds);
145
+ } catch (error) {
146
+ logger.warn(
147
+ "Failed to read incident health overrides; returning checks-only status",
148
+ { error: extractErrorMessage(error) },
149
+ );
150
+ return statuses;
151
+ }
152
+ const folded: Record<string, SystemHealthStatusResponse> = {};
153
+ for (const [systemId, base] of Object.entries(statuses)) {
154
+ folded[systemId] = applySystemHealthOverrides({
155
+ base,
156
+ overrides: overridesBySystem[systemId] ?? [],
157
+ });
158
+ }
159
+ return folded;
160
+ };
161
+
108
162
  // Create contract implementer with context type AND auto auth middleware
109
163
  const os = implement(healthCheckContract)
110
164
  .$context<RpcContext>()
@@ -159,21 +213,18 @@ export const createHealthCheckRouter = (opts: {
159
213
  }) => {
160
214
  await cache.invalidateSystem(args.systemId);
161
215
 
162
- // If enabling the health check, schedule it immediately so it starts
163
- // probing right away.
216
+ // If enabling the health check, reconcile this system's per-env recurring
217
+ // jobs immediately so it starts probing right away. A system-scoped
218
+ // reconcile only adds/updates (no orphan cleanup), so it needs no lock.
164
219
  if (args.enabled) {
165
- const config = await service.getConfiguration(args.configurationId);
166
- if (config) {
167
- const { scheduleHealthCheck } = await import("./queue-executor");
168
- await scheduleHealthCheck({
169
- queueManager: args.queueManager,
170
- payload: {
171
- configId: config.id,
172
- systemId: args.systemId,
173
- },
174
- intervalSeconds: config.intervalSeconds,
175
- });
176
- }
220
+ const { reconcileHealthCheckJobs } = await import("./schedule-reconciler");
221
+ await reconcileHealthCheckJobs({
222
+ db: database,
223
+ queueManager: args.queueManager,
224
+ catalogClient,
225
+ logger,
226
+ systemId: args.systemId,
227
+ });
177
228
  }
178
229
 
179
230
  // Notify subscribers (e.g., satellite-backend) that assignments changed.
@@ -201,6 +252,7 @@ export const createHealthCheckRouter = (opts: {
201
252
  displayName: r.strategy.displayName,
202
253
  description: r.strategy.description,
203
254
  category: (r.strategy.category ?? "other") as StrategyCategory,
255
+ setupInstructions: r.strategy.setupInstructions,
204
256
  configSchema: toJsonSchema(r.strategy.config.schema),
205
257
  resultSchema: r.strategy.result
206
258
  ? toJsonSchemaWithChartMeta(r.strategy.result.schema)
@@ -622,9 +674,13 @@ export const createHealthCheckRouter = (opts: {
622
674
  ),
623
675
  getSystemHealthStatus: os.getSystemHealthStatus.handler(
624
676
  async ({ input }) => {
625
- return cache.wrapSystemHealthStatus(input.systemId, () =>
677
+ const base = await cache.wrapSystemHealthStatus(input.systemId, () =>
626
678
  service.getSystemHealthStatus(input.systemId),
627
679
  );
680
+ const folded = await foldIncidentOverrides({
681
+ [input.systemId]: base,
682
+ });
683
+ return folded[input.systemId]!;
628
684
  },
629
685
  ),
630
686
 
@@ -634,10 +690,7 @@ export const createHealthCheckRouter = (opts: {
634
690
  // and invalidated by id on mutations, so dashboards with overlapping
635
691
  // (but non-identical) system sets share cache entries. See
636
692
  // ./cache.ts for the key/TTL/invalidation contract.
637
- const statuses: Record<
638
- string,
639
- Awaited<ReturnType<typeof service.getSystemHealthStatus>>
640
- > = {};
693
+ const statuses: Record<string, SystemHealthStatusResponse> = {};
641
694
  await Promise.all(
642
695
  input.systemIds.map(async (systemId) => {
643
696
  statuses[systemId] = await cache.wrapSystemHealthStatus(
@@ -646,6 +699,37 @@ export const createHealthCheckRouter = (opts: {
646
699
  );
647
700
  }),
648
701
  );
702
+ return { statuses: await foldIncidentOverrides(statuses) };
703
+ },
704
+ ),
705
+
706
+ getBulkSystemHealthMatrix: os.getBulkSystemHealthMatrix.handler(
707
+ async ({ input }) => {
708
+ const matrix = await service.getBulkSystemHealthMatrix(input.systemIds);
709
+
710
+ // Fold active incident overrides into each system's OVERALL rollup, so
711
+ // an incident-forced status still propagates through dependencies (as
712
+ // it does via getBulkSystemHealthStatus). Per-environment slices track
713
+ // health-check status only - incidents force whole-system health, which
714
+ // any-environment (env=null) dependency cells read from this rollup.
715
+ const overallOnly: Record<string, SystemHealthStatusResponse> = {};
716
+ for (const [systemId, m] of Object.entries(matrix)) {
717
+ overallOnly[systemId] = {
718
+ status: m.status,
719
+ evaluatedAt: new Date(),
720
+ checkStatuses: m.checkStatuses,
721
+ };
722
+ }
723
+ const folded = await foldIncidentOverrides(overallOnly);
724
+
725
+ const statuses: Record<string, (typeof matrix)[string]> = {};
726
+ for (const [systemId, m] of Object.entries(matrix)) {
727
+ statuses[systemId] = {
728
+ status: folded[systemId]?.status ?? m.status,
729
+ checkStatuses: m.checkStatuses,
730
+ environments: m.environments,
731
+ };
732
+ }
649
733
  return { statuses };
650
734
  },
651
735
  ),
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ computeScheduleJitterSeconds,
4
+ DEFAULT_JITTER_WINDOW_SECONDS,
5
+ } from "./schedule-jitter";
6
+
7
+ describe("computeScheduleJitterSeconds", () => {
8
+ it("is deterministic for the same key + interval", () => {
9
+ const a = computeScheduleJitterSeconds({ key: "sys:cfg", intervalSeconds: 60 });
10
+ const b = computeScheduleJitterSeconds({ key: "sys:cfg", intervalSeconds: 60 });
11
+ expect(a).toBe(b);
12
+ });
13
+
14
+ it("stays within [0, min(interval, window))", () => {
15
+ // Short interval: window is the interval itself.
16
+ for (let i = 0; i < 200; i++) {
17
+ const v = computeScheduleJitterSeconds({
18
+ key: `k${i}`,
19
+ intervalSeconds: 10,
20
+ });
21
+ expect(v).toBeGreaterThanOrEqual(0);
22
+ expect(v).toBeLessThan(10);
23
+ }
24
+ // Long interval: capped by the default window.
25
+ for (let i = 0; i < 200; i++) {
26
+ const v = computeScheduleJitterSeconds({
27
+ key: `k${i}`,
28
+ intervalSeconds: 3600,
29
+ });
30
+ expect(v).toBeGreaterThanOrEqual(0);
31
+ expect(v).toBeLessThan(DEFAULT_JITTER_WINDOW_SECONDS);
32
+ }
33
+ });
34
+
35
+ it("spreads a synchronized set across the window (de-clusters)", () => {
36
+ // 50 checks that share an interval must NOT all land on the same offset.
37
+ const offsets = new Set<number>();
38
+ for (let i = 0; i < 50; i++) {
39
+ offsets.add(
40
+ computeScheduleJitterSeconds({
41
+ key: `system-${i}:check-a`,
42
+ intervalSeconds: 60,
43
+ }),
44
+ );
45
+ }
46
+ // Comfortably more than a handful of distinct slots out of a 30s window.
47
+ expect(offsets.size).toBeGreaterThan(10);
48
+ });
49
+
50
+ it("returns 0 for a non-positive interval or window", () => {
51
+ expect(
52
+ computeScheduleJitterSeconds({ key: "k", intervalSeconds: 0 }),
53
+ ).toBe(0);
54
+ expect(
55
+ computeScheduleJitterSeconds({
56
+ key: "k",
57
+ intervalSeconds: 60,
58
+ maxWindowSeconds: 0,
59
+ }),
60
+ ).toBe(0);
61
+ });
62
+
63
+ it("different keys generally produce different offsets", () => {
64
+ const a = computeScheduleJitterSeconds({ key: "alpha", intervalSeconds: 60 });
65
+ const b = computeScheduleJitterSeconds({ key: "beta", intervalSeconds: 60 });
66
+ // Not a hard guarantee for any two strings, but these two must differ.
67
+ expect(a).not.toBe(b);
68
+ });
69
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Deterministic per-check scheduling jitter to de-cluster the health-check
3
+ * "thundering herd".
4
+ *
5
+ * Many checks created together - or all overdue at once on a fresh boot - would
6
+ * otherwise be scheduled with the same `startDelay` and identical intervals, so
7
+ * they fire on the same phase forever: dozens of TCP/TLS handshakes contending
8
+ * at the same instant, which inflates and destabilizes per-run connection setup
9
+ * (the observed "same check, same site, wildly different durations"). Offsetting
10
+ * each check's first fire by a stable fraction of its interval spreads them out;
11
+ * because the queue anchors the recurrence to that first fire, the offset
12
+ * persists for the schedule's whole life.
13
+ *
14
+ * The offset is DETERMINISTIC in the check's key (config + system), so a check
15
+ * keeps the same slot across restarts instead of re-clustering on a fresh random
16
+ * draw each boot.
17
+ */
18
+
19
+ /** Default upper bound on the jitter window, in seconds. */
20
+ export const DEFAULT_JITTER_WINDOW_SECONDS = 30;
21
+
22
+ /**
23
+ * A stable jitter offset in `[0, min(intervalSeconds, maxWindowSeconds))`
24
+ * seconds, derived from `key`. Short intervals spread across the whole interval;
25
+ * long intervals cap the window so a check still starts reasonably promptly.
26
+ */
27
+ export function computeScheduleJitterSeconds({
28
+ key,
29
+ intervalSeconds,
30
+ maxWindowSeconds = DEFAULT_JITTER_WINDOW_SECONDS,
31
+ }: {
32
+ key: string;
33
+ intervalSeconds: number;
34
+ maxWindowSeconds?: number;
35
+ }): number {
36
+ const window = Math.min(
37
+ Math.max(0, Math.floor(intervalSeconds)),
38
+ Math.max(0, Math.floor(maxWindowSeconds)),
39
+ );
40
+ if (window <= 0) return 0;
41
+
42
+ // FNV-1a 32-bit hash of the key -> a stable fraction in [0, 1).
43
+ let hash = 0x81_1C_9D_C5;
44
+ for (let i = 0; i < key.length; i++) {
45
+ hash ^= key.codePointAt(i) ?? 0;
46
+ hash = Math.imul(hash, 0x01_00_01_93);
47
+ }
48
+ const fraction = (hash >>> 0) / 0x1_00_00_00_00;
49
+ return Math.floor(fraction * window);
50
+ }