@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.
- package/CHANGELOG.md +265 -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 +58 -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 +11 -14
- 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 +255 -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/suspect-lane.test.ts +50 -0
- 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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
694
|
+
describe("executeHealthCheckJob - per-environment jobs (single env per job)", () => {
|
|
774
695
|
/**
|
|
775
|
-
* Drive
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
942
|
-
// captured synchronously at collector-execute time
|
|
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: {
|
|
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
|
|
956
|
-
const { runs: captured } = await
|
|
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(
|
|
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
|
|
978
|
-
const { runCompletedPayloads } = await
|
|
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(
|
|
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
|
|
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
|
|
1010
|
-
const { runs: captured } = await
|
|
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(
|
|
1031
|
-
//
|
|
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
|
|
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)
|
|
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
|
|
1060
|
-
const { runs: captured } = await
|
|
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
|
|
1073
|
-
const { runs: captured } = await
|
|
1074
|
-
|
|
1075
|
-
|
|
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("
|
|
1083
|
-
const { runs: captured } = await
|
|
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
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
*
|
|
1263
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
1166
|
+
const captured: Array<{ environment?: unknown; config?: unknown }> = [];
|
|
1330
1167
|
const collectorExecute = mock(
|
|
1331
|
-
async (params: {
|
|
1332
|
-
|
|
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
|
|
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: {
|
|
1238
|
+
data: {
|
|
1239
|
+
configId: "config-1",
|
|
1240
|
+
systemId: "system-1",
|
|
1241
|
+
environmentId: payloadEnvironmentId,
|
|
1242
|
+
},
|
|
1395
1243
|
}).catch(() => {});
|
|
1396
1244
|
}
|
|
1397
1245
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
+
});
|