@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +265 -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 +58 -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 +11 -14
  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 +255 -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/suspect-lane.test.ts +50 -0
  36. package/src/suspect-lane.ts +61 -0
@@ -2,11 +2,12 @@ import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import {
3
3
  setupHealthCheckWorker,
4
4
  scheduleHealthCheck,
5
- bootstrapHealthChecks,
6
5
  recomputeSystemRollupHealth,
7
6
  type HealthCheckJobPayload,
8
7
  } from "./queue-executor";
9
8
  import type { HealthCheckCache } from "./cache";
9
+ import { SuspectLane } from "./suspect-lane";
10
+ import type { SlowCheckRuntime } from "./slow-check-config";
10
11
 
11
12
  const passthroughCache: HealthCheckCache = {
12
13
  wrapSystemHealthStatus: (_systemId, loader) => loader(),
@@ -141,6 +142,7 @@ describe("Queue-Based Health Check Executor", () => {
141
142
  const payload: HealthCheckJobPayload = {
142
143
  configId: "config-1",
143
144
  systemId: "system-1",
145
+ environmentId: null,
144
146
  };
145
147
 
146
148
  await scheduleHealthCheck({
@@ -162,6 +164,7 @@ describe("Queue-Based Health Check Executor", () => {
162
164
  const payload: HealthCheckJobPayload = {
163
165
  configId: "config-1",
164
166
  systemId: "system-1",
167
+ environmentId: null,
165
168
  };
166
169
 
167
170
  const result = await scheduleHealthCheck({
@@ -214,6 +217,7 @@ describe("Queue-Based Health Check Executor", () => {
214
217
  >[0]["incidentClient"],
215
218
  getEmitHook: () => undefined,
216
219
  cache: passthroughCache,
220
+ slowCheckRuntime: null,
217
221
  });
218
222
 
219
223
  expect(mockLogger.debug).toHaveBeenCalledWith(
@@ -222,97 +226,11 @@ describe("Queue-Based Health Check Executor", () => {
222
226
  });
223
227
  });
224
228
 
225
- describe("bootstrapHealthChecks", () => {
226
- beforeEach(() => {
227
- // Reset all mocks between tests
228
- });
229
-
230
- it("should enqueue all enabled health checks", async () => {
231
- const mockQueueManager = createMockQueueManager();
232
- const mockLogger = createMockLogger();
233
- const mockDb = createMockDb();
234
-
235
- // Configure the mock database to return some enabled checks
236
- const mockData = [
237
- {
238
- systemId: "system-1",
239
- configId: "config-1",
240
- interval: 30,
241
- lastRun: null,
242
- },
243
- {
244
- systemId: "system-2",
245
- configId: "config-2",
246
- interval: 60,
247
- lastRun: null,
248
- },
249
- ];
250
-
251
- // Override select to return a chain that handles subquery with groupBy
252
- // First call: for enabledChecks query (innerJoin().where)
253
- // Second call: for latestRuns query (groupBy)
254
- let selectCallCount = 0;
255
- (mockDb.select as any) = mock(() => {
256
- selectCallCount++;
257
- if (selectCallCount === 1) {
258
- // enabledChecks query
259
- return {
260
- from: mock(() => ({
261
- innerJoin: mock(() => ({
262
- where: mock(() => Promise.resolve(mockData)),
263
- })),
264
- })),
265
- };
266
- } else {
267
- // latestRuns query
268
- return {
269
- from: mock(() => ({
270
- groupBy: mock(() => Promise.resolve([])),
271
- })),
272
- };
273
- }
274
- });
275
-
276
- await bootstrapHealthChecks({
277
- db: mockDb as any,
278
- queueManager: mockQueueManager,
279
- logger: mockLogger,
280
- });
281
-
282
- expect(mockLogger.debug).toHaveBeenCalledWith(
283
- "Bootstrapping 2 health checks",
284
- );
285
- expect(mockLogger.debug).toHaveBeenCalledWith(
286
- "✅ Bootstrapped 2 health checks",
287
- );
288
- });
289
-
290
- it("should handle empty health check list", async () => {
291
- const mockQueueManager = createMockQueueManager();
292
- const mockLogger = createMockLogger();
293
- const mockDb = createMockDb();
294
-
295
- // Override to return empty array
296
- const mockSelectChain = mockDb.select();
297
- const mockFromResult = (mockSelectChain as any).from();
298
- Object.assign(mockFromResult, {
299
- then: (resolve: any) => resolve([]),
300
- });
301
-
302
- await bootstrapHealthChecks({
303
- db: mockDb as any,
304
- queueManager: mockQueueManager,
305
- logger: mockLogger,
306
- });
307
-
308
- expect(mockLogger.debug).toHaveBeenCalledWith(
309
- "Bootstrapping 0 health checks",
310
- );
311
- expect(mockLogger.debug).toHaveBeenCalledWith(
312
- "✅ Bootstrapped 0 health checks",
313
- );
314
- });
315
- });
229
+ // NOTE: `bootstrapHealthChecks` was removed in the per-environment-jobs
230
+ // migration. Boot-time scheduling is now owned by `reconcileHealthCheckJobs`
231
+ // (schedule-reconciler.ts), which converges the desired per-env recurring
232
+ // job set from the durable tables + catalog membership; see
233
+ // schedule-reconciler.test.ts for its coverage.
316
234
 
317
235
  describe("executeHealthCheckJob - paused behavior", () => {
318
236
  it("should skip execution when configuration is paused", async () => {
@@ -412,12 +330,13 @@ describe("Queue-Based Health Check Executor", () => {
412
330
  >[0]["incidentClient"],
413
331
  getEmitHook: () => undefined,
414
332
  cache: passthroughCache,
333
+ slowCheckRuntime: null,
415
334
  });
416
335
 
417
336
  // Execute a paused health check
418
337
  if (capturedHandler) {
419
338
  await capturedHandler({
420
- data: { configId: "config-1", systemId: "system-1" },
339
+ data: { configId: "config-1", systemId: "system-1", environmentId: null },
421
340
  });
422
341
  }
423
342
 
@@ -549,6 +468,7 @@ describe("Queue-Based Health Check Executor", () => {
549
468
  >[0]["incidentClient"],
550
469
  getEmitHook: () => undefined,
551
470
  cache: passthroughCache,
471
+ slowCheckRuntime: null,
552
472
  });
553
473
 
554
474
  if (capturedHandler) {
@@ -557,7 +477,7 @@ describe("Queue-Based Health Check Executor", () => {
557
477
  // doesn't model, so tolerate a later throw — the run-context we
558
478
  // assert on is captured synchronously at collector-execute time.
559
479
  await capturedHandler({
560
- data: { configId: "config-1", systemId: "system-1" },
480
+ data: { configId: "config-1", systemId: "system-1", environmentId: null },
561
481
  }).catch(() => {});
562
482
  }
563
483
 
@@ -753,11 +673,12 @@ describe("Queue-Based Health Check Executor", () => {
753
673
  >[0]["incidentClient"],
754
674
  getEmitHook: () => undefined,
755
675
  cache: passthroughCache,
676
+ slowCheckRuntime: null,
756
677
  });
757
678
 
758
679
  if (capturedHandler) {
759
680
  await capturedHandler({
760
- data: { configId: "config-1", systemId: "system-1" },
681
+ data: { configId: "config-1", systemId: "system-1", environmentId: null },
761
682
  }).catch(() => {});
762
683
  }
763
684
 
@@ -770,19 +691,27 @@ describe("Queue-Based Health Check Executor", () => {
770
691
  });
771
692
  });
772
693
 
773
- describe("executeHealthCheckJob - per-environment fan-out", () => {
694
+ describe("executeHealthCheckJob - per-environment jobs (single env per job)", () => {
774
695
  /**
775
- * Drive one job with a configurable assignment `environmentIds` + catalog
776
- * membership, capturing the run-context handed to the collector on EACH
777
- * run. The collector executes once per fanned-out run, so the captured
778
- * list is a faithful witness of "one run per effective environment".
696
+ * Drive ONE job for a single (config, system, environment) slice, capturing
697
+ * the run-context handed to the collector. Under the per-environment-jobs
698
+ * model each recurring job runs EXACTLY ONE environment (its
699
+ * `payload.environmentId`); the executor validates that env against the
700
+ * CURRENT effective set (from the assignment `environmentIds` + catalog
701
+ * membership) and SKIPS the tick when the slice is stale. So the captured
702
+ * run list is either one run (the payload's env) or empty (skipped).
779
703
  */
780
- async function runFanOut({
704
+ async function runSingleEnv({
705
+ payloadEnvironmentId,
781
706
  environmentIds,
782
707
  membership,
783
708
  collectorConfig = {},
784
709
  collectorConfigSchema = z.object({}),
710
+ failCatalogResolution = false,
785
711
  }: {
712
+ /** The env this job runs (`null` = an env-less job). */
713
+ payloadEnvironmentId: string | null;
714
+ /** The assignment's stored `environmentIds` selector. */
786
715
  environmentIds: string[] | null;
787
716
  membership: Array<{
788
717
  id: string;
@@ -793,8 +722,10 @@ describe("Queue-Based Health Check Executor", () => {
793
722
  collectorConfig?: Record<string, unknown>;
794
723
  /** Schema used to detect `x-templatable` fields for the render pass. */
795
724
  collectorConfigSchema?: z.ZodType<unknown>;
725
+ /** When true, the catalog membership read REJECTS (fail-open path). */
726
+ failCatalogResolution?: boolean;
796
727
  }): Promise<{
797
- /** Run-context captured per fanned-out run (env + rendered config). */
728
+ /** Run-context captured for the single run (empty when skipped). */
798
729
  runs: Array<{ environment?: unknown; config?: unknown }>;
799
730
  /** Payloads broadcast on `healthcheck.run.completed`, in order. */
800
731
  runCompletedPayloads: Array<Record<string, unknown>>;
@@ -812,15 +743,16 @@ describe("Queue-Based Health Check Executor", () => {
812
743
  id: "system-1",
813
744
  name: "web-01",
814
745
  }));
815
- (mockCatalogClient as any).resolveSystemEnvironments = mock(async () =>
816
- membership.map((m) => ({
746
+ (mockCatalogClient as any).resolveSystemEnvironments = mock(async () => {
747
+ if (failCatalogResolution) throw new Error("catalog unavailable");
748
+ return membership.map((m) => ({
817
749
  ...m,
818
750
  description: null,
819
751
  systemIds: [],
820
752
  createdAt: new Date(),
821
753
  updatedAt: new Date(),
822
- })),
823
- );
754
+ }));
755
+ });
824
756
 
825
757
  // The default full select chain (from().where(), groupBy, orderBy, ...)
826
758
  // so the durable persist path (aggregate read + rollup) resolves instead
@@ -934,14 +866,19 @@ describe("Queue-Based Health Check Executor", () => {
934
866
  >[0]["incidentClient"],
935
867
  getEmitHook: () => undefined,
936
868
  cache: passthroughCache,
869
+ slowCheckRuntime: null,
937
870
  });
938
871
 
939
872
  if (capturedHandler) {
940
873
  // Downstream persistence touches DB surfaces the lightweight mock
941
- // doesn't fully model; tolerate a later throw — run-contexts are
942
- // captured synchronously at collector-execute time, one per run.
874
+ // doesn't fully model; tolerate a later throw - run-contexts are
875
+ // captured synchronously at collector-execute time.
943
876
  await capturedHandler({
944
- data: { configId: "config-1", systemId: "system-1" },
877
+ data: {
878
+ configId: "config-1",
879
+ systemId: "system-1",
880
+ environmentId: payloadEnvironmentId,
881
+ },
945
882
  }).catch(() => {});
946
883
  }
947
884
 
@@ -952,8 +889,9 @@ describe("Queue-Based Health Check Executor", () => {
952
889
  return { runs: captured, runCompletedPayloads };
953
890
  }
954
891
 
955
- it("runs once per effective environment with that env in run-context (null selector = all)", async () => {
956
- const { runs: captured } = await runFanOut({
892
+ it("runs the payload's environment with that env in run-context", async () => {
893
+ const { runs: captured } = await runSingleEnv({
894
+ payloadEnvironmentId: "prod",
957
895
  environmentIds: null,
958
896
  membership: [
959
897
  { id: "prod", name: "Production", metadata: { baseUrl: "p" } },
@@ -961,21 +899,17 @@ describe("Queue-Based Health Check Executor", () => {
961
899
  ],
962
900
  });
963
901
 
964
- expect(captured).toHaveLength(2);
902
+ expect(captured).toHaveLength(1);
965
903
  expect(captured[0]?.environment).toEqual({
966
904
  id: "prod",
967
905
  name: "Production",
968
906
  fields: { baseUrl: "p" },
969
907
  });
970
- expect(captured[1]?.environment).toEqual({
971
- id: "staging",
972
- name: "Staging",
973
- fields: { baseUrl: "s" },
974
- });
975
908
  });
976
909
 
977
- it("broadcasts the fanned-out environment on run.completed for each env", async () => {
978
- const { runCompletedPayloads } = await runFanOut({
910
+ it("broadcasts the environment on run.completed for an env-scoped run", async () => {
911
+ const { runCompletedPayloads } = await runSingleEnv({
912
+ payloadEnvironmentId: "staging",
979
913
  environmentIds: null,
980
914
  membership: [
981
915
  { id: "prod", name: "Production", metadata: { baseUrl: "p" } },
@@ -983,19 +917,16 @@ describe("Queue-Based Health Check Executor", () => {
983
917
  ],
984
918
  });
985
919
 
986
- expect(runCompletedPayloads).toHaveLength(2);
920
+ expect(runCompletedPayloads).toHaveLength(1);
987
921
  expect(runCompletedPayloads[0]).toMatchObject({
988
- environmentId: "prod",
989
- environmentName: "Production",
990
- });
991
- expect(runCompletedPayloads[1]).toMatchObject({
992
922
  environmentId: "staging",
993
923
  environmentName: "Staging",
994
924
  });
995
925
  });
996
926
 
997
927
  it("omits the environment on run.completed for an env-less run", async () => {
998
- const { runCompletedPayloads } = await runFanOut({
928
+ const { runCompletedPayloads } = await runSingleEnv({
929
+ payloadEnvironmentId: null,
999
930
  environmentIds: [],
1000
931
  membership: [{ id: "prod", name: "Production", metadata: {} }],
1001
932
  });
@@ -1006,8 +937,9 @@ describe("Queue-Based Health Check Executor", () => {
1006
937
  expect(runCompletedPayloads[0]?.environmentName).toBeUndefined();
1007
938
  });
1008
939
 
1009
- it("renders x-templatable config fields per environment against environment.*", async () => {
1010
- const { runs: captured } = await runFanOut({
940
+ it("renders x-templatable config fields against the payload environment", async () => {
941
+ const { runs: captured } = await runSingleEnv({
942
+ payloadEnvironmentId: "prod",
1011
943
  environmentIds: null,
1012
944
  membership: [
1013
945
  {
@@ -1027,18 +959,16 @@ describe("Queue-Based Health Check Executor", () => {
1027
959
  }),
1028
960
  });
1029
961
 
1030
- expect(captured).toHaveLength(2);
1031
- // Each env gets its own rendered config (per-env render pass, §6.3.3).
962
+ expect(captured).toHaveLength(1);
963
+ // The run renders against ITS env (prod), not staging (per-env render).
1032
964
  expect((captured[0]?.config as { url: string }).url).toBe(
1033
965
  "https://prod.example.com/healthz",
1034
966
  );
1035
- expect((captured[1]?.config as { url: string }).url).toBe(
1036
- "https://staging.example.com/healthz",
1037
- );
1038
967
  });
1039
968
 
1040
969
  it("renders environment.* to empty string for an env-less run (render-empty, §11.6)", async () => {
1041
- const { runs: captured } = await runFanOut({
970
+ const { runs: captured } = await runSingleEnv({
971
+ payloadEnvironmentId: null,
1042
972
  environmentIds: [],
1043
973
  membership: [
1044
974
  { id: "prod", name: "Production", metadata: { baseUrl: "x" } },
@@ -1051,13 +981,14 @@ describe("Queue-Based Health Check Executor", () => {
1051
981
 
1052
982
  expect(captured).toHaveLength(1);
1053
983
  expect(captured[0]?.environment).toBeUndefined();
1054
- // Missing path renders empty (strict: false) — the HTTP collector's
984
+ // Missing path renders empty (strict: false) - the HTTP collector's
1055
985
  // post-render .url() check turns this into a clear config error.
1056
986
  expect((captured[0]?.config as { url: string }).url).toBe("/healthz");
1057
987
  });
1058
988
 
1059
- it("runs only the explicit subset, intersected with membership", async () => {
1060
- const { runs: captured } = await runFanOut({
989
+ it("runs the explicit-subset environment the payload targets", async () => {
990
+ const { runs: captured } = await runSingleEnv({
991
+ payloadEnvironmentId: "staging",
1061
992
  environmentIds: ["staging"],
1062
993
  membership: [
1063
994
  { id: "prod", name: "Production", metadata: {} },
@@ -1069,202 +1000,108 @@ describe("Queue-Based Health Check Executor", () => {
1069
1000
  expect((captured[0]?.environment as { id: string }).id).toBe("staging");
1070
1001
  });
1071
1002
 
1072
- it("runs exactly once with no environment when opting out ([] selector)", async () => {
1073
- const { runs: captured } = await runFanOut({
1074
- environmentIds: [],
1075
- membership: [{ id: "prod", name: "Production", metadata: {} }],
1003
+ it("runs env-less when the system has no environments (null selector, empty membership)", async () => {
1004
+ const { runs: captured } = await runSingleEnv({
1005
+ payloadEnvironmentId: null,
1006
+ environmentIds: null,
1007
+ membership: [],
1076
1008
  });
1077
1009
 
1078
1010
  expect(captured).toHaveLength(1);
1079
1011
  expect(captured[0]?.environment).toBeUndefined();
1080
1012
  });
1081
1013
 
1082
- it("runs exactly once env-less when the system has no environments (null selector, empty membership)", async () => {
1083
- const { runs: captured } = await runFanOut({
1014
+ it("skips a stale env-less job once the system has environments", async () => {
1015
+ const { runs: captured } = await runSingleEnv({
1016
+ payloadEnvironmentId: null,
1084
1017
  environmentIds: null,
1085
- membership: [],
1018
+ membership: [
1019
+ { id: "prod", name: "Production", metadata: {} },
1020
+ { id: "staging", name: "Staging", metadata: {} },
1021
+ ],
1086
1022
  });
1087
1023
 
1088
- expect(captured).toHaveLength(1);
1089
- expect(captured[0]?.environment).toBeUndefined();
1024
+ // The env-less slice is stale (the system now has effective envs); the
1025
+ // reconciler owns converging to per-env jobs, so this tick is skipped.
1026
+ expect(captured).toHaveLength(0);
1090
1027
  });
1091
1028
 
1092
- /**
1093
- * Per-environment ISOLATION regression (§7.2). When the FIRST
1094
- * environment's run throws (here: its durable persist rejects, which —
1095
- * with no health-entity handle bound — propagates out of
1096
- * `writeHealthEntity` to the per-env catch), the loop MUST log and
1097
- * continue so the SECOND environment still produces a run. One env's
1098
- * failure must never abort its siblings.
1099
- */
1100
- it("continues to the next environment when the first environment's run throws", async () => {
1101
- const mockDb = createMockDb();
1102
- const mockRegistry = createMockRegistry();
1103
- const mockLogger = createMockLogger();
1104
- const mockQueueManager = createMockQueueManager();
1105
- const mockCatalogClient = createMockCatalogClient();
1106
- const mockMaintenanceClient = createMockMaintenanceClient();
1107
- const mockIncidentClient = createMockIncidentClient();
1108
- const mockSignalService = createMockSignalService();
1109
-
1110
- (mockCatalogClient.getSystem as any) = mock(async () => ({
1111
- id: "system-1",
1112
- name: "web-01",
1113
- }));
1114
- const membership = [
1115
- { id: "prod", name: "Production", metadata: {} },
1116
- { id: "staging", name: "Staging", metadata: {} },
1117
- ];
1118
- (mockCatalogClient as any).resolveSystemEnvironments = mock(async () =>
1119
- membership.map((m) => ({
1120
- ...m,
1121
- description: null,
1122
- systemIds: [],
1123
- createdAt: new Date(),
1124
- updatedAt: new Date(),
1125
- })),
1126
- );
1127
-
1128
- let selectCallCount = 0;
1129
- (mockDb.select as any) = mock(() => {
1130
- selectCallCount++;
1131
- if (selectCallCount === 2) {
1132
- return {
1133
- from: mock(() => ({
1134
- innerJoin: mock(() => ({
1135
- where: mock(() =>
1136
- Promise.resolve([
1137
- {
1138
- configId: "config-1",
1139
- configName: "Check",
1140
- strategyId: "test-strategy",
1141
- config: { timeout: 5000 },
1142
- collectors: [
1143
- {
1144
- id: "col-1",
1145
- collectorId: "test-collector",
1146
- config: {},
1147
- },
1148
- ],
1149
- interval: 45,
1150
- enabled: true,
1151
- paused: false,
1152
- includeLocal: true,
1153
- satelliteIds: [],
1154
- environmentIds: null,
1155
- },
1156
- ]),
1157
- ),
1158
- })),
1159
- })),
1160
- };
1161
- }
1162
- return {
1163
- from: mock(() => ({
1164
- innerJoin: mock(() => ({
1165
- where: mock(() => Promise.resolve([])),
1166
- })),
1167
- })),
1168
- };
1029
+ it("skips a job whose environment is no longer effective", async () => {
1030
+ const { runs: captured } = await runSingleEnv({
1031
+ payloadEnvironmentId: "prod",
1032
+ environmentIds: ["staging"],
1033
+ membership: [
1034
+ { id: "prod", name: "Production", metadata: {} },
1035
+ { id: "staging", name: "Staging", metadata: {} },
1036
+ ],
1169
1037
  });
1170
1038
 
1171
- // The first environment's run insert REJECTS; the second succeeds.
1172
- // With no health-entity handle bound, a failed `apply` propagates out
1173
- // of `writeHealthEntity`, so this throw reaches the per-env catch.
1174
- let insertCalls = 0;
1175
- (mockDb.insert as any) = mock(() => ({
1176
- values: mock(() => {
1177
- insertCalls++;
1178
- if (insertCalls === 1) {
1179
- return Promise.reject(new Error("env-1 persist failed"));
1180
- }
1181
- return Promise.resolve();
1182
- }),
1183
- }));
1184
-
1185
- const envSeen: Array<string | undefined> = [];
1186
- const collectorExecute = mock(
1187
- async (params: { runContext?: { environment?: { id?: string } } }) => {
1188
- envSeen.push(params.runContext?.environment?.id);
1189
- return { result: {} };
1190
- },
1191
- );
1192
- const mockCollectorRegistry = {
1193
- register: mock(() => {}),
1194
- getCollector: mock(() => ({
1195
- collector: {
1196
- id: "test-collector",
1197
- execute: collectorExecute,
1198
- config: new Versioned({ version: 1, schema: z.object({}) }),
1199
- mergeResult: mock(() => ({})),
1200
- },
1201
- })),
1202
- getCollectors: mock(() => []),
1203
- };
1039
+ // `prod` is no longer in the effective subset ({staging}); skip the tick
1040
+ // and let the reconciler cancel this orphaned job.
1041
+ expect(captured).toHaveLength(0);
1042
+ });
1204
1043
 
1205
- const queue =
1206
- mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
1207
- let capturedHandler:
1208
- | ((job: { data: HealthCheckJobPayload }) => Promise<void>)
1209
- | undefined;
1210
- (queue.consume as any) = mock(
1211
- async (
1212
- handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
1213
- ) => {
1214
- capturedHandler = handler;
1044
+ /**
1045
+ * Fail-open OBSERVABILITY (P3 review item 2). When the catalog
1046
+ * `resolveSystemEnvironments` read fails, an env-SCOPED job keeps running
1047
+ * its own env with degraded (empty) fields rather than skipping, and MUST
1048
+ * emit a counter-style signal (not just a `logger.warn`) so durable catalog
1049
+ * misconfig / outage is observable.
1050
+ */
1051
+ it("runs a degraded env-scoped probe and broadcasts ENVIRONMENT_RESOLUTION_FAILED when the catalog read fails", async () => {
1052
+ const mockSignalService = createMockSignalService();
1053
+ const { runs: captured, runCompletedPayloads } = await runSingleEnvWithSignals(
1054
+ {
1055
+ payloadEnvironmentId: "prod",
1056
+ environmentIds: null,
1057
+ membership: [],
1058
+ failCatalogResolution: true,
1059
+ signalService: mockSignalService,
1215
1060
  },
1216
1061
  );
1217
1062
 
1218
- await setupHealthCheckWorker({
1219
- db: mockDb as unknown as Parameters<
1220
- typeof setupHealthCheckWorker
1221
- >[0]["db"],
1222
- advisoryLock: mockAdvisoryLock,
1223
- registry: mockRegistry,
1224
- collectorRegistry: mockCollectorRegistry as unknown as Parameters<
1225
- typeof setupHealthCheckWorker
1226
- >[0]["collectorRegistry"],
1227
- logger: mockLogger,
1228
- queueManager: mockQueueManager,
1229
- signalService: mockSignalService,
1230
- catalogClient: mockCatalogClient as unknown as Parameters<
1231
- typeof setupHealthCheckWorker
1232
- >[0]["catalogClient"],
1233
- notificationClient: {
1234
- notifyForSubscription: () => Promise.resolve({ notifiedCount: 0 }),
1235
- } as unknown as Parameters<
1236
- typeof setupHealthCheckWorker
1237
- >[0]["notificationClient"],
1238
- maintenanceClient: mockMaintenanceClient as unknown as Parameters<
1239
- typeof setupHealthCheckWorker
1240
- >[0]["maintenanceClient"],
1241
- incidentClient: mockIncidentClient as unknown as Parameters<
1242
- typeof setupHealthCheckWorker
1243
- >[0]["incidentClient"],
1244
- getEmitHook: () => undefined,
1245
- cache: passthroughCache,
1063
+ // Degraded to exactly one env-scoped run keeping its env id (empty fields).
1064
+ expect(captured).toHaveLength(1);
1065
+ expect(captured[0]?.environment).toEqual({
1066
+ id: "prod",
1067
+ name: "prod",
1068
+ fields: {},
1246
1069
  });
1070
+ expect(runCompletedPayloads).toHaveLength(1);
1247
1071
 
1248
- if (capturedHandler) {
1249
- await capturedHandler({
1250
- data: { configId: "config-1", systemId: "system-1" },
1251
- });
1252
- }
1253
-
1254
- // BOTH environments' collectors ran — the first env's persist failure
1255
- // did not abort the loop.
1256
- expect(envSeen).toEqual(["prod", "staging"]);
1257
- // The failure was logged (isolated), not propagated.
1258
- expect(mockLogger.error).toHaveBeenCalled();
1072
+ const resolutionFailed = mockSignalService.getRecordedSignalsById(
1073
+ "healthcheck.environment.resolution_failed",
1074
+ );
1075
+ expect(resolutionFailed).toHaveLength(1);
1076
+ expect(
1077
+ (resolutionFailed[0]?.payload as { systemId?: string }).systemId,
1078
+ ).toBe("system-1");
1259
1079
  });
1260
1080
 
1261
1081
  /**
1262
- * Fail-open OBSERVABILITY (P3 review item 2). When the catalog
1263
- * `resolveSystemEnvironments` read fails and the executor degrades to a
1264
- * single env-less run, it MUST emit a counter-style signal (not just a
1265
- * `logger.warn`) so durable catalog misconfig / outage is observable.
1082
+ * Same driver as `runSingleEnv` but with a caller-supplied signal service,
1083
+ * so the resolution-failed test can assert on the recorded signals.
1266
1084
  */
1267
- it("broadcasts ENVIRONMENT_RESOLUTION_FAILED and degrades to one env-less run when the catalog read fails", async () => {
1085
+ async function runSingleEnvWithSignals({
1086
+ payloadEnvironmentId,
1087
+ environmentIds,
1088
+ membership,
1089
+ failCatalogResolution = false,
1090
+ signalService,
1091
+ }: {
1092
+ payloadEnvironmentId: string | null;
1093
+ environmentIds: string[] | null;
1094
+ membership: Array<{
1095
+ id: string;
1096
+ name: string;
1097
+ metadata: Record<string, unknown> | null;
1098
+ }>;
1099
+ failCatalogResolution?: boolean;
1100
+ signalService: ReturnType<typeof createMockSignalService>;
1101
+ }): Promise<{
1102
+ runs: Array<{ environment?: unknown; config?: unknown }>;
1103
+ runCompletedPayloads: Array<Record<string, unknown>>;
1104
+ }> {
1268
1105
  const mockDb = createMockDb();
1269
1106
  const mockRegistry = createMockRegistry();
1270
1107
  const mockLogger = createMockLogger();
@@ -1272,19 +1109,25 @@ describe("Queue-Based Health Check Executor", () => {
1272
1109
  const mockCatalogClient = createMockCatalogClient();
1273
1110
  const mockMaintenanceClient = createMockMaintenanceClient();
1274
1111
  const mockIncidentClient = createMockIncidentClient();
1275
- const mockSignalService = createMockSignalService();
1276
1112
 
1277
1113
  (mockCatalogClient.getSystem as any) = mock(async () => ({
1278
1114
  id: "system-1",
1279
1115
  name: "web-01",
1280
1116
  }));
1281
- // The catalog read REJECTS — the executor must fail open.
1282
1117
  (mockCatalogClient as any).resolveSystemEnvironments = mock(async () => {
1283
- throw new Error("catalog unavailable");
1118
+ if (failCatalogResolution) throw new Error("catalog unavailable");
1119
+ return membership.map((m) => ({
1120
+ ...m,
1121
+ description: null,
1122
+ systemIds: [],
1123
+ createdAt: new Date(),
1124
+ updatedAt: new Date(),
1125
+ }));
1284
1126
  });
1285
1127
 
1128
+ const defaultSelect = mockDb.select;
1286
1129
  let selectCallCount = 0;
1287
- (mockDb.select as any) = mock(() => {
1130
+ (mockDb.select as any) = mock((...args: unknown[]) => {
1288
1131
  selectCallCount++;
1289
1132
  if (selectCallCount === 2) {
1290
1133
  return {
@@ -1309,7 +1152,7 @@ describe("Queue-Based Health Check Executor", () => {
1309
1152
  paused: false,
1310
1153
  includeLocal: true,
1311
1154
  satelliteIds: [],
1312
- environmentIds: null,
1155
+ environmentIds,
1313
1156
  },
1314
1157
  ]),
1315
1158
  ),
@@ -1317,19 +1160,19 @@ describe("Queue-Based Health Check Executor", () => {
1317
1160
  })),
1318
1161
  };
1319
1162
  }
1320
- return {
1321
- from: mock(() => ({
1322
- innerJoin: mock(() => ({
1323
- where: mock(() => Promise.resolve([])),
1324
- })),
1325
- })),
1326
- };
1163
+ return (defaultSelect as (...a: unknown[]) => unknown)(...args);
1327
1164
  });
1328
1165
 
1329
- const envSeen: Array<string | undefined> = [];
1166
+ const captured: Array<{ environment?: unknown; config?: unknown }> = [];
1330
1167
  const collectorExecute = mock(
1331
- async (params: { runContext?: { environment?: { id?: string } } }) => {
1332
- envSeen.push(params.runContext?.environment?.id);
1168
+ async (params: {
1169
+ runContext?: { environment?: unknown };
1170
+ config?: unknown;
1171
+ }) => {
1172
+ captured.push({
1173
+ environment: params.runContext?.environment,
1174
+ config: params.config,
1175
+ });
1333
1176
  return { result: {} };
1334
1177
  },
1335
1178
  );
@@ -1370,7 +1213,7 @@ describe("Queue-Based Health Check Executor", () => {
1370
1213
  >[0]["collectorRegistry"],
1371
1214
  logger: mockLogger,
1372
1215
  queueManager: mockQueueManager,
1373
- signalService: mockSignalService,
1216
+ signalService,
1374
1217
  catalogClient: mockCatalogClient as unknown as Parameters<
1375
1218
  typeof setupHealthCheckWorker
1376
1219
  >[0]["catalogClient"],
@@ -1387,25 +1230,25 @@ describe("Queue-Based Health Check Executor", () => {
1387
1230
  >[0]["incidentClient"],
1388
1231
  getEmitHook: () => undefined,
1389
1232
  cache: passthroughCache,
1233
+ slowCheckRuntime: null,
1390
1234
  });
1391
1235
 
1392
1236
  if (capturedHandler) {
1393
1237
  await capturedHandler({
1394
- data: { configId: "config-1", systemId: "system-1" },
1238
+ data: {
1239
+ configId: "config-1",
1240
+ systemId: "system-1",
1241
+ environmentId: payloadEnvironmentId,
1242
+ },
1395
1243
  }).catch(() => {});
1396
1244
  }
1397
1245
 
1398
- // Degraded to exactly one env-less run.
1399
- expect(envSeen).toEqual([undefined]);
1400
- // The observability signal was broadcast with the failure detail.
1401
- const resolutionFailed = mockSignalService.getRecordedSignalsById(
1402
- "healthcheck.environment.resolution_failed",
1403
- );
1404
- expect(resolutionFailed).toHaveLength(1);
1405
- expect(
1406
- (resolutionFailed[0]?.payload as { systemId?: string }).systemId,
1407
- ).toBe("system-1");
1408
- });
1246
+ const runCompletedPayloads = signalService
1247
+ .getRecordedSignalsById("healthcheck.run.completed")
1248
+ .map((r) => z.record(z.string(), z.unknown()).parse(r.payload));
1249
+
1250
+ return { runs: captured, runCompletedPayloads };
1251
+ }
1409
1252
  });
1410
1253
  });
1411
1254
 
@@ -1594,13 +1437,14 @@ describe("executeHealthCheckJob - structured assertion outcomes", () => {
1594
1437
  >[0]["incidentClient"],
1595
1438
  getEmitHook: () => undefined,
1596
1439
  cache: passthroughCache,
1440
+ slowCheckRuntime: null,
1597
1441
  });
1598
1442
 
1599
1443
  if (capturedHandler) {
1600
1444
  // Downstream aggregation touches DB surfaces the lightweight mock
1601
1445
  // doesn't model; the run insert we assert on happens before that.
1602
1446
  await capturedHandler({
1603
- data: { configId: "config-1", systemId: "system-1" },
1447
+ data: { configId: "config-1", systemId: "system-1", environmentId: null },
1604
1448
  }).catch(() => {});
1605
1449
  }
1606
1450
 
@@ -1641,3 +1485,212 @@ describe("executeHealthCheckJob - structured assertion outcomes", () => {
1641
1485
  });
1642
1486
  });
1643
1487
  });
1488
+
1489
+ describe("executeHealthCheckJob - slow-check bulkhead wiring", () => {
1490
+ function makeRuntime(lane: SuspectLane): SlowCheckRuntime {
1491
+ return {
1492
+ lane,
1493
+ recentRunsLimit: 20,
1494
+ classifierParams: {
1495
+ consecutiveFailures: 3,
1496
+ slowFraction: 0.8,
1497
+ recoveryProbeEvery: 5,
1498
+ },
1499
+ safetyFactor: 1.5,
1500
+ absoluteFloorMs: 1000,
1501
+ };
1502
+ }
1503
+
1504
+ const TIMEOUT = 5000;
1505
+ const slowFail = () => ({
1506
+ environment_id: null,
1507
+ environmentId: null,
1508
+ status: "unhealthy" as const,
1509
+ latencyMs: TIMEOUT,
1510
+ timestamp: new Date(),
1511
+ });
1512
+
1513
+ /**
1514
+ * Drive ONE env-less job with the slow-check bulkhead enabled and a
1515
+ * configurable recent-run history + lane. Captures whether the collector
1516
+ * executed and whether a durable run was inserted.
1517
+ */
1518
+ async function runWithBulkhead({
1519
+ recentRuns,
1520
+ slowCheckRuntime,
1521
+ }: {
1522
+ recentRuns: Array<{ environmentId: string | null; status: "healthy" | "unhealthy"; latencyMs: number | null; timestamp: Date }>;
1523
+ slowCheckRuntime: SlowCheckRuntime;
1524
+ }): Promise<{ collectorRan: boolean; runInserted: boolean }> {
1525
+ const mockDb = createMockDb();
1526
+ const mockRegistry = createMockRegistry();
1527
+ const mockLogger = createMockLogger();
1528
+ const mockQueueManager = createMockQueueManager();
1529
+ const mockCatalogClient = createMockCatalogClient();
1530
+ const mockMaintenanceClient = createMockMaintenanceClient();
1531
+ const mockIncidentClient = createMockIncidentClient();
1532
+ const mockSignalService = createMockSignalService();
1533
+
1534
+ (mockCatalogClient.getSystem as any) = mock(async () => ({ id: "system-1", name: "web-01" }));
1535
+ (mockCatalogClient as any).resolveSystemEnvironments = mock(async () => []);
1536
+
1537
+ // Select call order: #1 getSystemHealthStatus (rollup prev), #2 config,
1538
+ // #3 fetchRecentRunsForSlice. Everything else falls through to default.
1539
+ const defaultSelect = mockDb.select;
1540
+ let selectCallCount = 0;
1541
+ (mockDb.select as any) = mock((...args: unknown[]) => {
1542
+ selectCallCount++;
1543
+ if (selectCallCount === 2) {
1544
+ return {
1545
+ from: mock(() => ({
1546
+ innerJoin: mock(() => ({
1547
+ where: mock(() =>
1548
+ Promise.resolve([
1549
+ {
1550
+ configId: "config-1",
1551
+ configName: "Check",
1552
+ strategyId: "test-strategy",
1553
+ config: { timeout: TIMEOUT },
1554
+ collectors: [
1555
+ { id: "col-1", collectorId: "test-collector", config: {} },
1556
+ ],
1557
+ interval: 45,
1558
+ enabled: true,
1559
+ paused: false,
1560
+ includeLocal: true,
1561
+ satelliteIds: [],
1562
+ environmentIds: null,
1563
+ },
1564
+ ]),
1565
+ ),
1566
+ })),
1567
+ })),
1568
+ };
1569
+ }
1570
+ if (selectCallCount === 3) {
1571
+ return {
1572
+ from: mock(() => ({
1573
+ where: mock(() => ({
1574
+ orderBy: mock(() => ({
1575
+ limit: mock(() => Promise.resolve(recentRuns)),
1576
+ })),
1577
+ })),
1578
+ })),
1579
+ };
1580
+ }
1581
+ return (defaultSelect as (...a: unknown[]) => unknown)(...args);
1582
+ });
1583
+
1584
+ let runInserted = false;
1585
+ (mockDb.insert as any) = mock(() => ({
1586
+ values: mock((vals: Record<string, unknown>) => {
1587
+ if (vals && "status" in vals) runInserted = true;
1588
+ return Promise.resolve();
1589
+ }),
1590
+ }));
1591
+
1592
+ let collectorRan = false;
1593
+ const collectorExecute = mock(async () => {
1594
+ collectorRan = true;
1595
+ return { result: {} };
1596
+ });
1597
+ const mockCollectorRegistry = {
1598
+ register: mock(() => {}),
1599
+ getCollector: mock(() => ({
1600
+ collector: {
1601
+ id: "test-collector",
1602
+ execute: collectorExecute,
1603
+ config: new Versioned({ version: 1, schema: z.object({}) }),
1604
+ mergeResult: mock(() => ({})),
1605
+ },
1606
+ })),
1607
+ getCollectors: mock(() => []),
1608
+ };
1609
+
1610
+ const queue = mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
1611
+ let capturedHandler:
1612
+ | ((job: { data: HealthCheckJobPayload }) => Promise<void>)
1613
+ | undefined;
1614
+ (queue.consume as any) = mock(
1615
+ async (handler: (job: { data: HealthCheckJobPayload }) => Promise<void>) => {
1616
+ capturedHandler = handler;
1617
+ },
1618
+ );
1619
+
1620
+ await setupHealthCheckWorker({
1621
+ db: mockDb as unknown as Parameters<typeof setupHealthCheckWorker>[0]["db"],
1622
+ advisoryLock: mockAdvisoryLock,
1623
+ registry: mockRegistry,
1624
+ collectorRegistry: mockCollectorRegistry as unknown as Parameters<
1625
+ typeof setupHealthCheckWorker
1626
+ >[0]["collectorRegistry"],
1627
+ logger: mockLogger,
1628
+ queueManager: mockQueueManager,
1629
+ signalService: mockSignalService,
1630
+ catalogClient: mockCatalogClient as unknown as Parameters<
1631
+ typeof setupHealthCheckWorker
1632
+ >[0]["catalogClient"],
1633
+ notificationClient: {
1634
+ notifyForSubscription: () => Promise.resolve({ notifiedCount: 0 }),
1635
+ } as unknown as Parameters<typeof setupHealthCheckWorker>[0]["notificationClient"],
1636
+ maintenanceClient: mockMaintenanceClient as unknown as Parameters<
1637
+ typeof setupHealthCheckWorker
1638
+ >[0]["maintenanceClient"],
1639
+ incidentClient: mockIncidentClient as unknown as Parameters<
1640
+ typeof setupHealthCheckWorker
1641
+ >[0]["incidentClient"],
1642
+ getEmitHook: () => undefined,
1643
+ cache: passthroughCache,
1644
+ slowCheckRuntime,
1645
+ });
1646
+
1647
+ if (capturedHandler) {
1648
+ await capturedHandler({
1649
+ data: { configId: "config-1", systemId: "system-1", environmentId: null },
1650
+ }).catch(() => {});
1651
+ }
1652
+
1653
+ return { collectorRan, runInserted };
1654
+ }
1655
+
1656
+ it("DEFERS a suspect slice when the lane is full — records nothing, never probes", async () => {
1657
+ const lane = new SuspectLane(1);
1658
+ lane.tryAdmit("other:slice:_"); // fill the only slot with a different slice
1659
+ const { collectorRan, runInserted } = await runWithBulkhead({
1660
+ recentRuns: [slowFail(), slowFail(), slowFail()],
1661
+ slowCheckRuntime: makeRuntime(lane),
1662
+ });
1663
+
1664
+ // Deferred BEFORE the probe: no collector execution, no durable run row.
1665
+ expect(collectorRan).toBe(false);
1666
+ expect(runInserted).toBe(false);
1667
+ });
1668
+
1669
+ it("runs a suspect slice when the lane has room, and frees the slot after", async () => {
1670
+ const lane = new SuspectLane(1);
1671
+ const { collectorRan, runInserted } = await runWithBulkhead({
1672
+ recentRuns: [slowFail(), slowFail(), slowFail()],
1673
+ slowCheckRuntime: makeRuntime(lane),
1674
+ });
1675
+
1676
+ expect(collectorRan).toBe(true);
1677
+ expect(runInserted).toBe(true);
1678
+ // The slot was released in the executor's finally.
1679
+ expect(lane.active).toBe(0);
1680
+ });
1681
+
1682
+ it("does NOT gate a healthy slice (no recent slow failures)", async () => {
1683
+ const lane = new SuspectLane(1);
1684
+ lane.tryAdmit("other:slice:_"); // lane full, but the slice is NOT suspect
1685
+ const { collectorRan, runInserted } = await runWithBulkhead({
1686
+ recentRuns: [
1687
+ { environmentId: null, status: "healthy", latencyMs: 40, timestamp: new Date() },
1688
+ ],
1689
+ slowCheckRuntime: makeRuntime(lane),
1690
+ });
1691
+
1692
+ // Healthy slices never touch the lane, so a full lane doesn't defer them.
1693
+ expect(collectorRan).toBe(true);
1694
+ expect(runInserted).toBe(true);
1695
+ });
1696
+ });