@checkstack/healthcheck-backend 1.11.1 → 1.12.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 +270 -0
- package/package.json +29 -29
- package/src/automations.test.ts +43 -1
- package/src/automations.ts +22 -3
- package/src/hooks.ts +7 -0
- package/src/index.ts +21 -4
- package/src/queue-executor.test.ts +108 -16
- package/src/queue-executor.ts +116 -9
- package/src/router-pause-recompute.test.ts +142 -0
- package/src/router.ts +47 -0
- package/src/service-env-filter.test.ts +299 -0
- package/src/service-paused-filter.test.ts +391 -0
- package/src/service-rollup-worst-wins.test.ts +205 -0
- package/src/service.ts +274 -28
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { HealthCheckService } from "./service";
|
|
3
|
+
import { evaluateHealthStatus } from "./state-evaluator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression coverage for the system-rollup worst-wins-across-environments
|
|
7
|
+
* fix in `getSystemHealthStatus(systemId)` (the `environmentId === undefined`
|
|
8
|
+
* branch).
|
|
9
|
+
*
|
|
10
|
+
* The original branch flattened every environment's runs into one
|
|
11
|
+
* `timestamp DESC` list and handed the interleaved list to the threshold
|
|
12
|
+
* evaluator (the default `consecutive` mode). Consecutive mode walks
|
|
13
|
+
* newest-first and breaks the streak on the first interleaving env, so the
|
|
14
|
+
* rollup collapsed to whichever env ran last in the batch — masking any
|
|
15
|
+
* permanently-failing sibling env ("the healthy env wins" / latest-wins)
|
|
16
|
+
* and flapping whenever env insertion order drifted across ticks.
|
|
17
|
+
*
|
|
18
|
+
* The fix evaluates the threshold window PER ENVIRONMENT within the
|
|
19
|
+
* association and takes worst-wins across envs (unhealthy > degraded >
|
|
20
|
+
* healthy), making the rollup stable regardless of insertion order or
|
|
21
|
+
* multi-pod racing. These tests pin that behavior with a mocked DB that
|
|
22
|
+
* returns the interleaved mixed-pool the real query would surface.
|
|
23
|
+
*/
|
|
24
|
+
describe("HealthCheckService - system rollup worst-wins across environments", () => {
|
|
25
|
+
/**
|
|
26
|
+
* The mixed-pool query captured by the mock. Ordered DESC (newest first),
|
|
27
|
+
* exactly the shape the real `health_check_runs` query returns. Two envs
|
|
28
|
+
* (`prod`, `staging`) of one assignment, both fanning out every tick, prod
|
|
29
|
+
* permanently unhealthy and staging permanently healthy. The env insertion
|
|
30
|
+
* order in the executor is sequential membership order, so prod lands before
|
|
31
|
+
* staging, making the latest run in the pool a staging-healthy run — the
|
|
32
|
+
* exact scenario that masked prod's outage under flattening.
|
|
33
|
+
*/
|
|
34
|
+
const PROD_RUN = { status: "unhealthy" as const, environmentId: "prod" };
|
|
35
|
+
const STAGE_RUN = { status: "healthy" as const, environmentId: "staging" };
|
|
36
|
+
|
|
37
|
+
function buildMixedPool(ticksPerEnv = 5): { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] {
|
|
38
|
+
const pool: { status: "unhealthy" | "healthy"; timestamp: Date; environmentId: string }[] = [];
|
|
39
|
+
for (let i = 0; i < ticksPerEnv; i++) {
|
|
40
|
+
pool.push({ ...PROD_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i) });
|
|
41
|
+
pool.push({ ...STAGE_RUN, timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5) });
|
|
42
|
+
}
|
|
43
|
+
return pool; // DESC at the DB layer; we return newest-first below.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createMockDb(runsMixedDesc: { status: string; timestamp: Date; environmentId: string }[]) {
|
|
47
|
+
const assocWhere = mock(() => Promise.resolve([
|
|
48
|
+
{
|
|
49
|
+
configurationId: "config-1",
|
|
50
|
+
configName: "HTTP probe",
|
|
51
|
+
enabled: true,
|
|
52
|
+
paused: false,
|
|
53
|
+
stateThresholds: null,
|
|
54
|
+
},
|
|
55
|
+
]));
|
|
56
|
+
const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
|
|
57
|
+
const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
|
|
58
|
+
|
|
59
|
+
const runsLimit = mock(() => Promise.resolve(runsMixedDesc));
|
|
60
|
+
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
61
|
+
const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
|
|
62
|
+
const runsFrom = Object.assign(Promise.resolve(runsMixedDesc), {
|
|
63
|
+
where: runsWhere,
|
|
64
|
+
orderBy: runsOrderBy,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let selectCallCount = 0;
|
|
68
|
+
return {
|
|
69
|
+
select: mock(() => {
|
|
70
|
+
selectCallCount += 1;
|
|
71
|
+
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
72
|
+
return { from: mock(() => runsFrom) };
|
|
73
|
+
}),
|
|
74
|
+
insert: mock(() => ({
|
|
75
|
+
values: mock(() => ({
|
|
76
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
77
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
78
|
+
returning: mock(() => Promise.resolve([])),
|
|
79
|
+
})),
|
|
80
|
+
})),
|
|
81
|
+
update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
|
|
82
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
83
|
+
execute: mock(() => Promise.resolve()),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it("the rollup reports unhealthy when ONE env is permanently unhealthy, the other healthy", async () => {
|
|
88
|
+
// DB returns newest-first interleaved runs. The pre-fix behavior would
|
|
89
|
+
// mask prod's outage because the latest run is staging-healthy; the
|
|
90
|
+
// threshold evaluator (default consecutive mode) walks newest-first from
|
|
91
|
+
// staging-healthy, breaks the streak on the very next prod-unhealthy run,
|
|
92
|
+
// and falls back to `"healthy"`.
|
|
93
|
+
const pool = buildMixedPool(5);
|
|
94
|
+
const runsDesc = pool.toReversed(); // oldest produced first above; reverse to DESC
|
|
95
|
+
const mockDb = createMockDb(runsDesc as never);
|
|
96
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
97
|
+
|
|
98
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
99
|
+
|
|
100
|
+
expect(result.status).toBe("unhealthy");
|
|
101
|
+
expect(result.checkStatuses).toHaveLength(1);
|
|
102
|
+
expect(result.checkStatuses[0].status).toBe("unhealthy");
|
|
103
|
+
expect(result.checkStatuses[0].runsConsidered).toBe(pool.length);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("flattening the same mixed pool through the evaluator (the pre-fix derivation) would have returned `healthy`", async () => {
|
|
107
|
+
// Sanity check: the very data the rollup branch reads, fed directly to
|
|
108
|
+
// `evaluateHealthStatus` as one flat interleaved list, collapses to
|
|
109
|
+
// "healthy" — the precise regression this fix replaces with per-env
|
|
110
|
+
// evaluation. Pinning it here guards against a relax of the test above.
|
|
111
|
+
const pool = buildMixedPool(5);
|
|
112
|
+
const runsDesc = pool.toReversed();
|
|
113
|
+
const flatStatus = evaluateHealthStatus({
|
|
114
|
+
runs: runsDesc as never,
|
|
115
|
+
});
|
|
116
|
+
expect(flatStatus).toBe("healthy");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("reports healthy only when EVERY env is healthy", async () => {
|
|
120
|
+
const allHealthy = Array.from({ length: 10 }, (_, i) => ({
|
|
121
|
+
status: "healthy",
|
|
122
|
+
timestamp: new Date(2025, 0, 1, 0, 0, i),
|
|
123
|
+
environmentId: i % 2 === 0 ? "prod" : "staging",
|
|
124
|
+
})).toReversed();
|
|
125
|
+
const mockDb = createMockDb(allHealthy as never);
|
|
126
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
127
|
+
|
|
128
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
129
|
+
expect(result.status).toBe("healthy");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("degrades (not flaps) when one env is degraded and the other healthy", async () => {
|
|
133
|
+
// Default consecutive thresholds need 2 consecutive failures to escalate
|
|
134
|
+
// to `degraded` (and 5 to escalate to `unhealthy` — so keep prod's streak
|
|
135
|
+
// at exactly 2 degraded runs). Per-env: prod's env-sorted slice =
|
|
136
|
+
// [degraded, degraded] (newest first) → degraded; staging → healthy.
|
|
137
|
+
// Rollup worst-wins = degraded. Flattening would break on the staging
|
|
138
|
+
// interleave and return `healthy` (the masked bug); per-env gives a
|
|
139
|
+
// stable `degraded`.
|
|
140
|
+
const pool: { status: "healthy" | "degraded"; timestamp: Date; environmentId: string }[] = [];
|
|
141
|
+
for (let i = 0; i < 2; i++) {
|
|
142
|
+
pool.push({ status: "degraded", timestamp: new Date(2025, 0, 1, 0, 0, i), environmentId: "prod" });
|
|
143
|
+
pool.push({ status: "healthy", timestamp: new Date(2025, 0, 1, 0, 0, i + 0.5), environmentId: "staging" });
|
|
144
|
+
}
|
|
145
|
+
const runsDesc = pool.toReversed();
|
|
146
|
+
const mockDb = createMockDb(runsDesc as never);
|
|
147
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
148
|
+
|
|
149
|
+
const result = await service.getSystemHealthStatus("system-1");
|
|
150
|
+
expect(result.status).toBe("degraded");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("the per-env slice (concrete environmentId) is unaffected — only the rollup branch changed", async () => {
|
|
154
|
+
// Pass an explicit environmentId; the old per-env branch (string envId)
|
|
155
|
+
// must continue to filter to that env's slice. Here we ask for `prod`
|
|
156
|
+
// and expect unhealthy.
|
|
157
|
+
const prodOnly = Array.from({ length: 5 }, (_, i) => ({
|
|
158
|
+
status: "unhealthy",
|
|
159
|
+
timestamp: new Date(2025, 0, 1, 0, 0, i),
|
|
160
|
+
}));
|
|
161
|
+
// Mock: the runs query mirrors the predicate back to prodOnly.
|
|
162
|
+
const assocWhere = mock(() => Promise.resolve([
|
|
163
|
+
{
|
|
164
|
+
configurationId: "config-1",
|
|
165
|
+
configName: "HTTP probe",
|
|
166
|
+
enabled: true,
|
|
167
|
+
paused: false,
|
|
168
|
+
stateThresholds: null,
|
|
169
|
+
},
|
|
170
|
+
]));
|
|
171
|
+
const assocInnerJoin = Object.assign(Promise.resolve([]), { where: assocWhere });
|
|
172
|
+
const assocFrom = Object.assign(Promise.resolve([]), { innerJoin: mock(() => assocInnerJoin) });
|
|
173
|
+
|
|
174
|
+
const runsLimit = mock(() => Promise.resolve(prodOnly));
|
|
175
|
+
const runsOrderBy = mock(() => ({ limit: runsLimit }));
|
|
176
|
+
const runsWhere = mock(() => ({ orderBy: runsOrderBy, limit: runsLimit }));
|
|
177
|
+
const runsFrom = Object.assign(Promise.resolve(prodOnly), {
|
|
178
|
+
where: runsWhere,
|
|
179
|
+
orderBy: runsOrderBy,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
let selectCallCount = 0;
|
|
183
|
+
const mockDb = {
|
|
184
|
+
select: mock(() => {
|
|
185
|
+
selectCallCount += 1;
|
|
186
|
+
if (selectCallCount === 1) return { from: mock(() => assocFrom) };
|
|
187
|
+
return { from: mock(() => runsFrom) };
|
|
188
|
+
}),
|
|
189
|
+
insert: mock(() => ({
|
|
190
|
+
values: mock(() => ({
|
|
191
|
+
onConflictDoUpdate: mock(() => Promise.resolve()),
|
|
192
|
+
onConflictDoNothing: mock(() => Promise.resolve()),
|
|
193
|
+
returning: mock(() => Promise.resolve([])),
|
|
194
|
+
})),
|
|
195
|
+
})),
|
|
196
|
+
update: mock(() => ({ set: mock(() => ({ where: mock(() => Promise.resolve()) })) })),
|
|
197
|
+
delete: mock(() => ({ where: mock(() => Promise.resolve()) })),
|
|
198
|
+
execute: mock(() => Promise.resolve()),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const service = new HealthCheckService(mockDb as never, {} as never, {} as never);
|
|
202
|
+
const result = await service.getSystemHealthStatus("system-1", "prod");
|
|
203
|
+
expect(result.status).toBe("unhealthy");
|
|
204
|
+
});
|
|
205
|
+
});
|