@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
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test (real Redis / BullMQ) for `reconcileHealthCheckJobs`.
|
|
3
|
+
*
|
|
4
|
+
* The reconciler's decisions are DATA-DRIVEN by what it reads back from the
|
|
5
|
+
* queue: `listRecurringJobs()` + `getRecurringJobDetails()` feed `planReconcile`,
|
|
6
|
+
* which then schedules / reschedules / cancels. The unit test
|
|
7
|
+
* (`schedule-reconciler.test.ts`) proves the plan against an in-memory fake, but
|
|
8
|
+
* only a real backend proves the read-back semantics the plan depends on:
|
|
9
|
+
* upsert-in-place (no duplicate scheduler), interval read-back driving a
|
|
10
|
+
* reschedule, and prefix-scoped orphan cancellation actually removing the right
|
|
11
|
+
* schedulers from Redis.
|
|
12
|
+
*
|
|
13
|
+
* To honour the dependency direction (healthcheck-backend must not depend on a
|
|
14
|
+
* queue *implementation* plugin), this drives raw `bullmq` primitives through a
|
|
15
|
+
* thin shim that mirrors the `BullMQQueue` adapter's recurring methods
|
|
16
|
+
* EXACTLY - `upsertJobScheduler` / `getJobSchedulers` / `removeJobScheduler`.
|
|
17
|
+
* The adapter's own conformance to that contract is pinned separately by
|
|
18
|
+
* `plugins/queue-bullmq-backend/src/bullmq-queue.it.test.ts`; here we exercise
|
|
19
|
+
* the RECONCILER against a backend with real persistence between calls.
|
|
20
|
+
*
|
|
21
|
+
* Gated behind `CHECKSTACK_IT`; the `integration` CI job sets it and provides a
|
|
22
|
+
* real Redis (`CHECKSTACK_IT_REDIS_URL`). Each test uses a unique queue name +
|
|
23
|
+
* key prefix and obliterates afterwards.
|
|
24
|
+
*/
|
|
25
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
26
|
+
import { Queue } from "bullmq";
|
|
27
|
+
import type { RecurringJobDetails } from "@checkstack/queue-api";
|
|
28
|
+
import { reconcileHealthCheckJobs } from "./schedule-reconciler";
|
|
29
|
+
import {
|
|
30
|
+
HEALTH_CHECK_QUEUE,
|
|
31
|
+
type HealthCheckJobPayload,
|
|
32
|
+
} from "./queue-executor";
|
|
33
|
+
|
|
34
|
+
type ReconcileProps = Parameters<typeof reconcileHealthCheckJobs>[0];
|
|
35
|
+
|
|
36
|
+
function redisParts(): { host: string; port: number; password?: string } {
|
|
37
|
+
const url = new URL(
|
|
38
|
+
process.env.CHECKSTACK_IT_REDIS_URL ?? "redis://localhost:6379",
|
|
39
|
+
);
|
|
40
|
+
return {
|
|
41
|
+
host: url.hostname,
|
|
42
|
+
port: Number(url.port || 6379),
|
|
43
|
+
password: url.password || undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PREFIX = `it:${crypto.randomUUID().replace(/-/g, "")}`;
|
|
48
|
+
|
|
49
|
+
const silentLogger = {
|
|
50
|
+
debug: () => {},
|
|
51
|
+
info: () => {},
|
|
52
|
+
warn: () => {},
|
|
53
|
+
error: () => {},
|
|
54
|
+
} as unknown as ReconcileProps["logger"];
|
|
55
|
+
|
|
56
|
+
/** Mock db: two selects - (1) enabled checks join, (2) last-run-per-slice. */
|
|
57
|
+
function makeDb(props: {
|
|
58
|
+
checks: Array<{
|
|
59
|
+
systemId: string;
|
|
60
|
+
configId: string;
|
|
61
|
+
interval: number;
|
|
62
|
+
environmentIds: string[] | null;
|
|
63
|
+
}>;
|
|
64
|
+
lastRuns?: Array<{
|
|
65
|
+
systemId: string;
|
|
66
|
+
configurationId: string;
|
|
67
|
+
environmentId: string | null;
|
|
68
|
+
maxTimestamp: Date | null;
|
|
69
|
+
}>;
|
|
70
|
+
}): ReconcileProps["db"] {
|
|
71
|
+
const from = () => ({
|
|
72
|
+
innerJoin: () => ({ where: () => Promise.resolve(props.checks) }),
|
|
73
|
+
groupBy: () => Promise.resolve(props.lastRuns ?? []),
|
|
74
|
+
});
|
|
75
|
+
return { select: () => ({ from }) } as unknown as ReconcileProps["db"];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeCatalogClient(
|
|
79
|
+
membershipBySystem: Record<string, Array<{ id: string; name: string }>>,
|
|
80
|
+
): ReconcileProps["catalogClient"] {
|
|
81
|
+
return {
|
|
82
|
+
resolveSystemEnvironments: async ({ systemId }: { systemId: string }) =>
|
|
83
|
+
(membershipBySystem[systemId] ?? []).map((m) => ({
|
|
84
|
+
...m,
|
|
85
|
+
description: null,
|
|
86
|
+
metadata: {},
|
|
87
|
+
systemIds: [],
|
|
88
|
+
createdAt: new Date(),
|
|
89
|
+
updatedAt: new Date(),
|
|
90
|
+
})),
|
|
91
|
+
} as unknown as ReconcileProps["catalogClient"];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A queue-api shim over raw BullMQ, using the SAME scheduler primitives as the
|
|
96
|
+
* `BullMQQueue` adapter, plus per-method call counters so a test can assert the
|
|
97
|
+
* reconciler did (or did NOT) schedule/cancel.
|
|
98
|
+
*/
|
|
99
|
+
function makeQueueEnv(queueName: string) {
|
|
100
|
+
const raw = new Queue(queueName, { connection: redisParts(), prefix: PREFIX });
|
|
101
|
+
const calls = { scheduleRecurring: 0, cancelRecurring: 0 };
|
|
102
|
+
|
|
103
|
+
const shim = {
|
|
104
|
+
async scheduleRecurring(
|
|
105
|
+
data: HealthCheckJobPayload,
|
|
106
|
+
opts: {
|
|
107
|
+
jobId: string;
|
|
108
|
+
priority?: number;
|
|
109
|
+
startDelay?: number;
|
|
110
|
+
intervalSeconds?: number;
|
|
111
|
+
cronPattern?: string;
|
|
112
|
+
},
|
|
113
|
+
): Promise<string> {
|
|
114
|
+
calls.scheduleRecurring += 1;
|
|
115
|
+
const isCron = Boolean(opts.cronPattern);
|
|
116
|
+
await raw.upsertJobScheduler(
|
|
117
|
+
opts.jobId,
|
|
118
|
+
isCron
|
|
119
|
+
? { pattern: opts.cronPattern! }
|
|
120
|
+
: {
|
|
121
|
+
every: opts.intervalSeconds! * 1000,
|
|
122
|
+
...(opts.startDelay && opts.startDelay > 0
|
|
123
|
+
? { startDate: Date.now() + opts.startDelay * 1000 }
|
|
124
|
+
: {}),
|
|
125
|
+
},
|
|
126
|
+
{ name: queueName, data, opts: { priority: opts.priority } },
|
|
127
|
+
);
|
|
128
|
+
return opts.jobId;
|
|
129
|
+
},
|
|
130
|
+
async cancelRecurring(jobId: string): Promise<void> {
|
|
131
|
+
calls.cancelRecurring += 1;
|
|
132
|
+
await raw.removeJobScheduler(jobId);
|
|
133
|
+
},
|
|
134
|
+
async listRecurringJobs(): Promise<string[]> {
|
|
135
|
+
const schedulers = await raw.getJobSchedulers();
|
|
136
|
+
return schedulers.map((s) => s.key);
|
|
137
|
+
},
|
|
138
|
+
async getRecurringJobDetails(
|
|
139
|
+
jobId: string,
|
|
140
|
+
): Promise<RecurringJobDetails<HealthCheckJobPayload> | undefined> {
|
|
141
|
+
const schedulers = await raw.getJobSchedulers();
|
|
142
|
+
const s = schedulers.find((x) => x.key === jobId);
|
|
143
|
+
if (!s) return undefined;
|
|
144
|
+
const base = {
|
|
145
|
+
jobId,
|
|
146
|
+
// Cross the untyped bullmq template boundary exactly as the adapter does.
|
|
147
|
+
data: s.template?.data as HealthCheckJobPayload,
|
|
148
|
+
priority: s.template?.opts?.priority,
|
|
149
|
+
nextRunAt: s.next ? new Date(s.next) : undefined,
|
|
150
|
+
};
|
|
151
|
+
return s.pattern
|
|
152
|
+
? { ...base, cronPattern: s.pattern }
|
|
153
|
+
: { ...base, intervalSeconds: s.every ? Number(s.every) / 1000 : 0 };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const queueManager = {
|
|
158
|
+
getQueue: () => shim,
|
|
159
|
+
} as unknown as ReconcileProps["queueManager"];
|
|
160
|
+
|
|
161
|
+
return { raw, calls, queueManager, shim };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
165
|
+
"reconcileHealthCheckJobs (real Redis)",
|
|
166
|
+
() => {
|
|
167
|
+
const raws: Queue[] = [];
|
|
168
|
+
function env() {
|
|
169
|
+
const e = makeQueueEnv(`it_reconcile_${crypto.randomUUID().replace(/-/g, "")}`);
|
|
170
|
+
raws.push(e.raw);
|
|
171
|
+
return e;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
afterEach(async () => {
|
|
175
|
+
for (const raw of raws.splice(0)) {
|
|
176
|
+
await raw.obliterate({ force: true }).catch(() => {});
|
|
177
|
+
await raw.close();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("schedules one recurring job per effective environment with the right payload", async () => {
|
|
182
|
+
const { queueManager, shim } = env();
|
|
183
|
+
await reconcileHealthCheckJobs({
|
|
184
|
+
db: makeDb({
|
|
185
|
+
checks: [
|
|
186
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
catalogClient: makeCatalogClient({
|
|
190
|
+
s1: [
|
|
191
|
+
{ id: "prod", name: "Production" },
|
|
192
|
+
{ id: "staging", name: "Staging" },
|
|
193
|
+
],
|
|
194
|
+
}),
|
|
195
|
+
queueManager,
|
|
196
|
+
logger: silentLogger,
|
|
197
|
+
now: 1_000_000,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect((await shim.listRecurringJobs()).toSorted()).toEqual([
|
|
201
|
+
"healthcheck:c1:s1:prod",
|
|
202
|
+
"healthcheck:c1:s1:staging",
|
|
203
|
+
]);
|
|
204
|
+
const prod = await shim.getRecurringJobDetails("healthcheck:c1:s1:prod");
|
|
205
|
+
expect(prod?.intervalSeconds).toBe(30);
|
|
206
|
+
expect(prod?.data).toEqual({
|
|
207
|
+
configId: "c1",
|
|
208
|
+
systemId: "s1",
|
|
209
|
+
environmentId: "prod",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("schedules a single bare job for an env-less system (environmentId null)", async () => {
|
|
214
|
+
const { queueManager, shim } = env();
|
|
215
|
+
await reconcileHealthCheckJobs({
|
|
216
|
+
db: makeDb({
|
|
217
|
+
checks: [
|
|
218
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
219
|
+
],
|
|
220
|
+
}),
|
|
221
|
+
catalogClient: makeCatalogClient({ s1: [] }),
|
|
222
|
+
queueManager,
|
|
223
|
+
logger: silentLogger,
|
|
224
|
+
now: 1_000_000,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(await shim.listRecurringJobs()).toEqual(["healthcheck:c1:s1"]);
|
|
228
|
+
const details = await shim.getRecurringJobDetails("healthcheck:c1:s1");
|
|
229
|
+
expect(details?.data.environmentId).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("is idempotent: a second identical full reconcile schedules and cancels nothing", async () => {
|
|
233
|
+
const { queueManager, shim, calls } = env();
|
|
234
|
+
const args = {
|
|
235
|
+
db: makeDb({
|
|
236
|
+
checks: [
|
|
237
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
238
|
+
],
|
|
239
|
+
}),
|
|
240
|
+
catalogClient: makeCatalogClient({
|
|
241
|
+
s1: [{ id: "prod", name: "Production" }],
|
|
242
|
+
}),
|
|
243
|
+
queueManager,
|
|
244
|
+
logger: silentLogger,
|
|
245
|
+
now: 1_000_000,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await reconcileHealthCheckJobs(args);
|
|
249
|
+
const afterFirst = { ...calls };
|
|
250
|
+
|
|
251
|
+
// Fresh db/catalog (same desired), same real Redis state.
|
|
252
|
+
await reconcileHealthCheckJobs({
|
|
253
|
+
...args,
|
|
254
|
+
db: makeDb({
|
|
255
|
+
checks: [
|
|
256
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
257
|
+
],
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Interval already matches, job already present -> no work the 2nd time.
|
|
262
|
+
expect(calls.scheduleRecurring).toBe(afterFirst.scheduleRecurring);
|
|
263
|
+
expect(calls.cancelRecurring).toBe(afterFirst.cancelRecurring);
|
|
264
|
+
expect(await shim.listRecurringJobs()).toEqual(["healthcheck:c1:s1:prod"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("reschedules in place when the interval changes (no duplicate scheduler)", async () => {
|
|
268
|
+
const { queueManager, shim, calls } = env();
|
|
269
|
+
const catalogClient = makeCatalogClient({
|
|
270
|
+
s1: [{ id: "prod", name: "Production" }],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await reconcileHealthCheckJobs({
|
|
274
|
+
db: makeDb({
|
|
275
|
+
checks: [
|
|
276
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
277
|
+
],
|
|
278
|
+
}),
|
|
279
|
+
catalogClient,
|
|
280
|
+
queueManager,
|
|
281
|
+
logger: silentLogger,
|
|
282
|
+
now: 1_000_000,
|
|
283
|
+
});
|
|
284
|
+
const scheduledAfterFirst = calls.scheduleRecurring;
|
|
285
|
+
|
|
286
|
+
await reconcileHealthCheckJobs({
|
|
287
|
+
db: makeDb({
|
|
288
|
+
checks: [
|
|
289
|
+
{ systemId: "s1", configId: "c1", interval: 60, environmentIds: null },
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
catalogClient,
|
|
293
|
+
queueManager,
|
|
294
|
+
logger: silentLogger,
|
|
295
|
+
now: 1_000_000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Exactly one slice, interval updated in place, no orphan cancels.
|
|
299
|
+
expect(await shim.listRecurringJobs()).toEqual(["healthcheck:c1:s1:prod"]);
|
|
300
|
+
const details = await shim.getRecurringJobDetails("healthcheck:c1:s1:prod");
|
|
301
|
+
expect(details?.intervalSeconds).toBe(60);
|
|
302
|
+
expect(calls.scheduleRecurring).toBe(scheduledAfterFirst + 1);
|
|
303
|
+
expect(calls.cancelRecurring).toBe(0);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("cancels orphaned jobs when a check disappears from the desired set", async () => {
|
|
307
|
+
const { queueManager, shim } = env();
|
|
308
|
+
const catalogClient = makeCatalogClient({
|
|
309
|
+
s1: [{ id: "prod", name: "Production" }],
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
await reconcileHealthCheckJobs({
|
|
313
|
+
db: makeDb({
|
|
314
|
+
checks: [
|
|
315
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
316
|
+
],
|
|
317
|
+
}),
|
|
318
|
+
catalogClient,
|
|
319
|
+
queueManager,
|
|
320
|
+
logger: silentLogger,
|
|
321
|
+
now: 1_000_000,
|
|
322
|
+
});
|
|
323
|
+
expect(await shim.listRecurringJobs()).toEqual(["healthcheck:c1:s1:prod"]);
|
|
324
|
+
|
|
325
|
+
// Check removed (e.g. disabled/deleted) -> desired set empty -> orphan cancel.
|
|
326
|
+
await reconcileHealthCheckJobs({
|
|
327
|
+
db: makeDb({ checks: [] }),
|
|
328
|
+
catalogClient,
|
|
329
|
+
queueManager,
|
|
330
|
+
logger: silentLogger,
|
|
331
|
+
now: 1_000_000,
|
|
332
|
+
});
|
|
333
|
+
expect(await shim.listRecurringJobs()).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("a system-scoped reconcile never cancels another system's jobs", async () => {
|
|
337
|
+
const { queueManager, shim, calls } = env();
|
|
338
|
+
const catalogClient = makeCatalogClient({
|
|
339
|
+
s1: [{ id: "prod", name: "Production" }],
|
|
340
|
+
s2: [{ id: "prod", name: "Production" }],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Full reconcile schedules both systems.
|
|
344
|
+
await reconcileHealthCheckJobs({
|
|
345
|
+
db: makeDb({
|
|
346
|
+
checks: [
|
|
347
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
348
|
+
{ systemId: "s2", configId: "c2", interval: 30, environmentIds: null },
|
|
349
|
+
],
|
|
350
|
+
}),
|
|
351
|
+
catalogClient,
|
|
352
|
+
queueManager,
|
|
353
|
+
logger: silentLogger,
|
|
354
|
+
now: 1_000_000,
|
|
355
|
+
});
|
|
356
|
+
const cancelsAfterFull = calls.cancelRecurring;
|
|
357
|
+
|
|
358
|
+
// Scoped reconcile for s1 only (buildDesiredJobs filters to s1); it must
|
|
359
|
+
// NOT sweep s2's job even though s2 is absent from this scoped desired set.
|
|
360
|
+
await reconcileHealthCheckJobs({
|
|
361
|
+
db: makeDb({
|
|
362
|
+
checks: [
|
|
363
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
364
|
+
],
|
|
365
|
+
}),
|
|
366
|
+
catalogClient,
|
|
367
|
+
queueManager,
|
|
368
|
+
logger: silentLogger,
|
|
369
|
+
systemId: "s1",
|
|
370
|
+
now: 1_000_000,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect((await shim.listRecurringJobs()).toSorted()).toEqual([
|
|
374
|
+
"healthcheck:c1:s1:prod",
|
|
375
|
+
"healthcheck:c2:s2:prod",
|
|
376
|
+
]);
|
|
377
|
+
expect(calls.cancelRecurring).toBe(cancelsAfterFull);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("leaves a foreign (non-healthcheck) recurring job untouched on a full reconcile", async () => {
|
|
381
|
+
const { queueManager, shim, raw } = env();
|
|
382
|
+
// A recurring job owned by some other plugin, not the healthcheck prefix.
|
|
383
|
+
await raw.upsertJobScheduler(
|
|
384
|
+
"otherplugin:job1",
|
|
385
|
+
{ every: 60_000 },
|
|
386
|
+
{ name: "other", data: {} },
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
await reconcileHealthCheckJobs({
|
|
390
|
+
db: makeDb({
|
|
391
|
+
checks: [
|
|
392
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
393
|
+
],
|
|
394
|
+
}),
|
|
395
|
+
catalogClient: makeCatalogClient({
|
|
396
|
+
s1: [{ id: "prod", name: "Production" }],
|
|
397
|
+
}),
|
|
398
|
+
queueManager,
|
|
399
|
+
logger: silentLogger,
|
|
400
|
+
now: 1_000_000,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// The orphan filter is prefix-scoped, so the foreign scheduler survives.
|
|
404
|
+
expect((await shim.listRecurringJobs()).toSorted()).toEqual([
|
|
405
|
+
"healthcheck:c1:s1:prod",
|
|
406
|
+
"otherplugin:job1",
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("cancels the bare env-less job and fans out when a system gains environments", async () => {
|
|
411
|
+
const { queueManager, shim } = env();
|
|
412
|
+
const db = makeDb({
|
|
413
|
+
checks: [
|
|
414
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
415
|
+
],
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// First: no environments -> a single bare job.
|
|
419
|
+
await reconcileHealthCheckJobs({
|
|
420
|
+
db,
|
|
421
|
+
catalogClient: makeCatalogClient({ s1: [] }),
|
|
422
|
+
queueManager,
|
|
423
|
+
logger: silentLogger,
|
|
424
|
+
now: 1_000_000,
|
|
425
|
+
});
|
|
426
|
+
expect(await shim.listRecurringJobs()).toEqual(["healthcheck:c1:s1"]);
|
|
427
|
+
|
|
428
|
+
// Then: the system joins two environments -> the bare job is now an
|
|
429
|
+
// orphan and must be cancelled, replaced by one job per environment.
|
|
430
|
+
await reconcileHealthCheckJobs({
|
|
431
|
+
db: makeDb({
|
|
432
|
+
checks: [
|
|
433
|
+
{ systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
|
|
434
|
+
],
|
|
435
|
+
}),
|
|
436
|
+
catalogClient: makeCatalogClient({
|
|
437
|
+
s1: [
|
|
438
|
+
{ id: "prod", name: "Production" },
|
|
439
|
+
{ id: "staging", name: "Staging" },
|
|
440
|
+
],
|
|
441
|
+
}),
|
|
442
|
+
queueManager,
|
|
443
|
+
logger: silentLogger,
|
|
444
|
+
now: 1_000_000,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect((await shim.listRecurringJobs()).toSorted()).toEqual([
|
|
448
|
+
"healthcheck:c1:s1:prod",
|
|
449
|
+
"healthcheck:c1:s1:staging",
|
|
450
|
+
]);
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
);
|