@checkstack/healthcheck-backend 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/package.json +30 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +87 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +103 -19
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +348 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/status-page/widgets.ts +11 -1
  36. package/src/suspect-lane.test.ts +50 -0
  37. package/src/suspect-lane.ts +61 -0
  38. package/src/system-health-override.test.ts +94 -0
  39. package/src/system-health-override.ts +93 -0
@@ -0,0 +1,418 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import {
3
+ planReconcile,
4
+ reconcileHealthCheckJobs,
5
+ type DesiredJob,
6
+ } from "./schedule-reconciler";
7
+ import {
8
+ encodeHealthCheckJobId,
9
+ type HealthCheckJobPayload,
10
+ } from "./queue-executor";
11
+
12
+ function desiredJob(props: {
13
+ configId: string;
14
+ systemId: string;
15
+ environmentId: string | null;
16
+ intervalSeconds: number;
17
+ }): DesiredJob {
18
+ const jobId = encodeHealthCheckJobId({
19
+ configId: props.configId,
20
+ systemId: props.systemId,
21
+ environmentId: props.environmentId,
22
+ });
23
+ return {
24
+ jobId,
25
+ payload: {
26
+ configId: props.configId,
27
+ systemId: props.systemId,
28
+ environmentId: props.environmentId,
29
+ },
30
+ intervalSeconds: props.intervalSeconds,
31
+ jitterKey: jobId,
32
+ };
33
+ }
34
+
35
+ describe("planReconcile (pure diff)", () => {
36
+ it("schedules desired jobs that do not exist yet", () => {
37
+ const a = desiredJob({
38
+ configId: "c1",
39
+ systemId: "s1",
40
+ environmentId: "prod",
41
+ intervalSeconds: 30,
42
+ });
43
+ const plan = planReconcile({
44
+ desired: [a],
45
+ actualJobIds: new Set(),
46
+ actualIntervalsById: new Map(),
47
+ cancelOrphans: true,
48
+ });
49
+ expect(plan.toSchedule).toEqual([a]);
50
+ expect(plan.toReschedule).toEqual([]);
51
+ expect(plan.toCancel).toEqual([]);
52
+ });
53
+
54
+ it("leaves an unchanged existing job alone (no reschedule)", () => {
55
+ const a = desiredJob({
56
+ configId: "c1",
57
+ systemId: "s1",
58
+ environmentId: "prod",
59
+ intervalSeconds: 30,
60
+ });
61
+ const plan = planReconcile({
62
+ desired: [a],
63
+ actualJobIds: new Set([a.jobId]),
64
+ actualIntervalsById: new Map([[a.jobId, 30]]),
65
+ cancelOrphans: true,
66
+ });
67
+ expect(plan.toSchedule).toEqual([]);
68
+ expect(plan.toReschedule).toEqual([]);
69
+ expect(plan.toCancel).toEqual([]);
70
+ });
71
+
72
+ it("reschedules an existing job only when its interval changed", () => {
73
+ const a = desiredJob({
74
+ configId: "c1",
75
+ systemId: "s1",
76
+ environmentId: "prod",
77
+ intervalSeconds: 60,
78
+ });
79
+ const plan = planReconcile({
80
+ desired: [a],
81
+ actualJobIds: new Set([a.jobId]),
82
+ actualIntervalsById: new Map([[a.jobId, 30]]),
83
+ cancelOrphans: true,
84
+ });
85
+ expect(plan.toSchedule).toEqual([]);
86
+ expect(plan.toReschedule).toEqual([a]);
87
+ expect(plan.toCancel).toEqual([]);
88
+ });
89
+
90
+ it("cancels orphaned health-check jobs on a full reconcile", () => {
91
+ const a = desiredJob({
92
+ configId: "c1",
93
+ systemId: "s1",
94
+ environmentId: "prod",
95
+ intervalSeconds: 30,
96
+ });
97
+ const orphan = encodeHealthCheckJobId({
98
+ configId: "c9",
99
+ systemId: "s9",
100
+ environmentId: "staging",
101
+ });
102
+ const plan = planReconcile({
103
+ desired: [a],
104
+ actualJobIds: new Set([a.jobId, orphan]),
105
+ actualIntervalsById: new Map([[a.jobId, 30]]),
106
+ cancelOrphans: true,
107
+ });
108
+ expect(plan.toSchedule).toEqual([]);
109
+ expect(plan.toCancel).toEqual([orphan]);
110
+ });
111
+
112
+ it("never cancels a non-health-check job even if it is not desired", () => {
113
+ const a = desiredJob({
114
+ configId: "c1",
115
+ systemId: "s1",
116
+ environmentId: null,
117
+ intervalSeconds: 30,
118
+ });
119
+ const foreign = "some-other-plugin:job:42";
120
+ const plan = planReconcile({
121
+ desired: [a],
122
+ actualJobIds: new Set([a.jobId, foreign]),
123
+ actualIntervalsById: new Map([[a.jobId, 30]]),
124
+ cancelOrphans: true,
125
+ });
126
+ expect(plan.toCancel).toEqual([]);
127
+ });
128
+
129
+ it("does NOT cancel orphans on a system-scoped reconcile (add/update only)", () => {
130
+ const a = desiredJob({
131
+ configId: "c1",
132
+ systemId: "s1",
133
+ environmentId: "prod",
134
+ intervalSeconds: 30,
135
+ });
136
+ const orphan = encodeHealthCheckJobId({
137
+ configId: "c9",
138
+ systemId: "s9",
139
+ environmentId: "staging",
140
+ });
141
+ const plan = planReconcile({
142
+ desired: [a],
143
+ actualJobIds: new Set([orphan]),
144
+ actualIntervalsById: new Map(),
145
+ cancelOrphans: false,
146
+ });
147
+ expect(plan.toSchedule).toEqual([a]);
148
+ expect(plan.toCancel).toEqual([]);
149
+ });
150
+ });
151
+
152
+ // ─── Integration: reconcileHealthCheckJobs against in-memory mocks ──────────
153
+
154
+ interface FakeQueue {
155
+ scheduled: Array<{ payload: HealthCheckJobPayload; intervalSeconds: number }>;
156
+ cancelled: string[];
157
+ recurring: Map<string, number>;
158
+ }
159
+
160
+ function makeQueueManager(existing: Map<string, number> = new Map()) {
161
+ const state: FakeQueue = {
162
+ scheduled: [],
163
+ cancelled: [],
164
+ recurring: existing,
165
+ };
166
+ const queue = {
167
+ scheduleRecurring: mock(
168
+ async (
169
+ payload: HealthCheckJobPayload,
170
+ opts: { jobId: string; intervalSeconds: number },
171
+ ) => {
172
+ state.scheduled.push({
173
+ payload,
174
+ intervalSeconds: opts.intervalSeconds,
175
+ });
176
+ state.recurring.set(opts.jobId, opts.intervalSeconds);
177
+ return opts.jobId;
178
+ },
179
+ ),
180
+ cancelRecurring: mock(async (jobId: string) => {
181
+ state.cancelled.push(jobId);
182
+ state.recurring.delete(jobId);
183
+ }),
184
+ listRecurringJobs: mock(async () => [...state.recurring.keys()]),
185
+ getRecurringJobDetails: mock(async (jobId: string) => {
186
+ const intervalSeconds = state.recurring.get(jobId);
187
+ return intervalSeconds === undefined
188
+ ? undefined
189
+ : { intervalSeconds };
190
+ }),
191
+ };
192
+ const queueManager = {
193
+ getQueue: () => queue,
194
+ } as unknown as Parameters<typeof reconcileHealthCheckJobs>[0]["queueManager"];
195
+ return { queueManager, state };
196
+ }
197
+
198
+ /**
199
+ * Mock db whose two select() calls return, in order: (1) the enabled checks
200
+ * join rows, (2) the last-run-per-slice rows. Mirrors the query order in
201
+ * `reconcileHealthCheckJobs` (buildDesiredJobs first, then the lastRun query).
202
+ */
203
+ function makeDb(props: {
204
+ checks: Array<{
205
+ systemId: string;
206
+ configId: string;
207
+ interval: number;
208
+ environmentIds: string[] | null;
209
+ }>;
210
+ lastRuns?: Array<{
211
+ systemId: string;
212
+ configurationId: string;
213
+ environmentId: string | null;
214
+ maxTimestamp: Date | null;
215
+ }>;
216
+ }) {
217
+ let call = 0;
218
+ const db = {
219
+ select: mock(() => {
220
+ call++;
221
+ if (call === 1) {
222
+ return {
223
+ from: () => ({
224
+ innerJoin: () => ({
225
+ where: () => Promise.resolve(props.checks),
226
+ }),
227
+ }),
228
+ };
229
+ }
230
+ return {
231
+ from: () => ({
232
+ groupBy: () => Promise.resolve(props.lastRuns ?? []),
233
+ }),
234
+ };
235
+ }),
236
+ };
237
+ return db as unknown as Parameters<typeof reconcileHealthCheckJobs>[0]["db"];
238
+ }
239
+
240
+ function makeCatalogClient(
241
+ membershipBySystem: Record<
242
+ string,
243
+ Array<{ id: string; name: string; metadata: Record<string, unknown> | null }>
244
+ >,
245
+ ) {
246
+ return {
247
+ resolveSystemEnvironments: mock(
248
+ async ({ systemId }: { systemId: string }) =>
249
+ (membershipBySystem[systemId] ?? []).map((m) => ({
250
+ ...m,
251
+ description: null,
252
+ systemIds: [],
253
+ createdAt: new Date(),
254
+ updatedAt: new Date(),
255
+ })),
256
+ ),
257
+ } as unknown as Parameters<
258
+ typeof reconcileHealthCheckJobs
259
+ >[0]["catalogClient"];
260
+ }
261
+
262
+ const silentLogger = {
263
+ debug: () => {},
264
+ info: () => {},
265
+ warn: () => {},
266
+ error: () => {},
267
+ } as unknown as Parameters<typeof reconcileHealthCheckJobs>[0]["logger"];
268
+
269
+ describe("reconcileHealthCheckJobs (integration)", () => {
270
+ it("schedules one job per effective environment for a full reconcile", async () => {
271
+ const { queueManager, state } = makeQueueManager();
272
+ const db = makeDb({
273
+ checks: [
274
+ {
275
+ systemId: "s1",
276
+ configId: "c1",
277
+ interval: 30,
278
+ environmentIds: null,
279
+ },
280
+ ],
281
+ });
282
+ const catalogClient = makeCatalogClient({
283
+ s1: [
284
+ { id: "prod", name: "Production", metadata: {} },
285
+ { id: "staging", name: "Staging", metadata: {} },
286
+ ],
287
+ });
288
+
289
+ await reconcileHealthCheckJobs({
290
+ db,
291
+ queueManager,
292
+ catalogClient,
293
+ logger: silentLogger,
294
+ now: 1_000_000,
295
+ });
296
+
297
+ expect(state.scheduled).toHaveLength(2);
298
+ const envs = state.scheduled.map((s) => s.payload.environmentId).sort();
299
+ expect(envs).toEqual(["prod", "staging"]);
300
+ expect(state.cancelled).toEqual([]);
301
+ });
302
+
303
+ it("schedules a single env-less job when the system has no environments", async () => {
304
+ const { queueManager, state } = makeQueueManager();
305
+ const db = makeDb({
306
+ checks: [
307
+ { systemId: "s1", configId: "c1", interval: 30, environmentIds: null },
308
+ ],
309
+ });
310
+ const catalogClient = makeCatalogClient({ s1: [] });
311
+
312
+ await reconcileHealthCheckJobs({
313
+ db,
314
+ queueManager,
315
+ catalogClient,
316
+ logger: silentLogger,
317
+ now: 1_000_000,
318
+ });
319
+
320
+ expect(state.scheduled).toHaveLength(1);
321
+ expect(state.scheduled[0]?.payload.environmentId).toBeNull();
322
+ });
323
+
324
+ it("cancels an orphaned health-check job that is no longer desired", async () => {
325
+ const orphan = encodeHealthCheckJobId({
326
+ configId: "old",
327
+ systemId: "gone",
328
+ environmentId: "prod",
329
+ });
330
+ const { queueManager, state } = makeQueueManager(
331
+ new Map([[orphan, 30]]),
332
+ );
333
+ const db = makeDb({
334
+ checks: [
335
+ { systemId: "s1", configId: "c1", interval: 30, environmentIds: [] },
336
+ ],
337
+ });
338
+ const catalogClient = makeCatalogClient({ s1: [] });
339
+
340
+ await reconcileHealthCheckJobs({
341
+ db,
342
+ queueManager,
343
+ catalogClient,
344
+ logger: silentLogger,
345
+ now: 1_000_000,
346
+ });
347
+
348
+ expect(state.cancelled).toEqual([orphan]);
349
+ // The env-less desired job was scheduled.
350
+ expect(state.scheduled).toHaveLength(1);
351
+ });
352
+
353
+ it("takes the health:reconcile advisory lock for a full reconcile", async () => {
354
+ const { queueManager } = makeQueueManager();
355
+ const db = makeDb({ checks: [] });
356
+ const catalogClient = makeCatalogClient({});
357
+ const withXactLock = mock(
358
+ async ({ fn }: { key: string; fn: () => Promise<void> }) => {
359
+ await fn();
360
+ },
361
+ );
362
+ const advisoryLock = {
363
+ withXactLock,
364
+ } as unknown as Parameters<
365
+ typeof reconcileHealthCheckJobs
366
+ >[0]["advisoryLock"];
367
+
368
+ await reconcileHealthCheckJobs({
369
+ db,
370
+ queueManager,
371
+ catalogClient,
372
+ logger: silentLogger,
373
+ advisoryLock,
374
+ now: 1_000_000,
375
+ });
376
+
377
+ expect(withXactLock).toHaveBeenCalledTimes(1);
378
+ expect(withXactLock.mock.calls[0]![0]!.key).toBe("health:reconcile");
379
+ });
380
+
381
+ it("does not cancel orphans on a system-scoped reconcile", async () => {
382
+ const orphan = encodeHealthCheckJobId({
383
+ configId: "old",
384
+ systemId: "gone",
385
+ environmentId: "prod",
386
+ });
387
+ const { queueManager, state } = makeQueueManager(
388
+ new Map([[orphan, 30]]),
389
+ );
390
+ const db = makeDb({
391
+ checks: [
392
+ {
393
+ systemId: "s1",
394
+ configId: "c1",
395
+ interval: 30,
396
+ environmentIds: null,
397
+ },
398
+ ],
399
+ });
400
+ const catalogClient = makeCatalogClient({
401
+ s1: [{ id: "prod", name: "Production", metadata: {} }],
402
+ });
403
+
404
+ await reconcileHealthCheckJobs({
405
+ db,
406
+ queueManager,
407
+ catalogClient,
408
+ logger: silentLogger,
409
+ systemId: "s1",
410
+ now: 1_000_000,
411
+ });
412
+
413
+ // The system's prod job is scheduled; the foreign orphan is left intact.
414
+ expect(state.scheduled).toHaveLength(1);
415
+ expect(state.scheduled[0]?.payload.environmentId).toBe("prod");
416
+ expect(state.cancelled).toEqual([]);
417
+ });
418
+ });
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Convergence reconciler for per-environment health-check recurring jobs.
3
+ *
4
+ * Every health check is scheduled as ONE recurring job per
5
+ * `(configId, systemId, environmentId)` slice (env-less systems use the bare
6
+ * `(configId, systemId)` form). The set of environments is DYNAMIC - it comes
7
+ * from catalog membership, which is not pushed to this backend - so we cannot
8
+ * rely on catching every add/remove event. Instead this reconciler computes the
9
+ * DESIRED job set from the durable tables + current membership and converges the
10
+ * queue's ACTUAL recurring jobs toward it (schedule missing, cancel orphans,
11
+ * reschedule interval changes). It is idempotent and self-healing: run it at
12
+ * boot, periodically, and (scoped to a system) right after a mutation for low
13
+ * latency.
14
+ *
15
+ * Scale-correctness (state-and-scale): desired state derives entirely from
16
+ * Postgres (`system_health_checks`, `health_check_configurations`,
17
+ * `health_check_runs`) + the catalog membership RPC, so every pod computes the
18
+ * same plan. A full reconcile takes the `health:reconcile` advisory lock so only
19
+ * one pod mutates the schedule at a time.
20
+ */
21
+ import { eq, and, max } from "drizzle-orm";
22
+ import type { Logger, AdvisoryLockService } from "@checkstack/backend-api";
23
+ import type { QueueManager } from "@checkstack/queue-api";
24
+ import type { InferClient } from "@checkstack/common";
25
+ import type { CatalogApi } from "@checkstack/catalog-common";
26
+ import type { SafeDatabase } from "@checkstack/backend-api";
27
+ import { healthCheckConfigurations, systemHealthChecks, healthCheckRuns } from "./schema";
28
+ import * as schema from "./schema";
29
+ import { resolveEffectiveEnvironments } from "./effective-environments";
30
+ import { computeScheduleJitterSeconds } from "./schedule-jitter";
31
+ import {
32
+ scheduleHealthCheck,
33
+ encodeHealthCheckJobId,
34
+ HEALTH_CHECK_QUEUE,
35
+ HEALTH_CHECK_JOB_PREFIX,
36
+ type HealthCheckJobPayload,
37
+ } from "./queue-executor";
38
+
39
+ type Db = SafeDatabase<typeof schema>;
40
+ type CatalogClient = InferClient<typeof CatalogApi>;
41
+
42
+ /** A recurring job the reconciler wants to exist. */
43
+ export interface DesiredJob {
44
+ jobId: string;
45
+ payload: HealthCheckJobPayload;
46
+ intervalSeconds: number;
47
+ /** Key for the deterministic first-fire jitter (per env slice). */
48
+ jitterKey: string;
49
+ }
50
+
51
+ export interface ReconcilePlan {
52
+ /** Desired jobs that do not exist yet - schedule them (with jitter). */
53
+ toSchedule: DesiredJob[];
54
+ /** Actual jobIds not desired anymore - cancel them. */
55
+ toCancel: string[];
56
+ /** Desired jobs that exist but whose interval changed - reschedule them. */
57
+ toReschedule: DesiredJob[];
58
+ }
59
+
60
+ /**
61
+ * Pure diff: given the desired jobs and the actual recurring jobs (with their
62
+ * current intervals), decide what to schedule / cancel / reschedule.
63
+ *
64
+ * `cancelOrphans: false` (a system-scoped reconcile) skips cancellation - a
65
+ * scoped run cannot see the whole actual set, so it only ADDS/updates; the
66
+ * periodic FULL reconcile owns orphan cleanup.
67
+ */
68
+ export function planReconcile(props: {
69
+ desired: DesiredJob[];
70
+ actualJobIds: Set<string>;
71
+ actualIntervalsById: Map<string, number>;
72
+ cancelOrphans: boolean;
73
+ }): ReconcilePlan {
74
+ const { desired, actualJobIds, actualIntervalsById, cancelOrphans } = props;
75
+ const desiredById = new Map(desired.map((d) => [d.jobId, d]));
76
+
77
+ const toSchedule: DesiredJob[] = [];
78
+ const toReschedule: DesiredJob[] = [];
79
+ for (const job of desired) {
80
+ if (!actualJobIds.has(job.jobId)) {
81
+ toSchedule.push(job);
82
+ } else if (actualIntervalsById.get(job.jobId) !== job.intervalSeconds) {
83
+ // Interval changed - reschedule (scheduleRecurring updates in place). Only
84
+ // when it actually differs, so we don't reset a job's fire phase every run.
85
+ toReschedule.push(job);
86
+ }
87
+ }
88
+
89
+ const toCancel = cancelOrphans
90
+ ? [...actualJobIds].filter(
91
+ (jobId) =>
92
+ jobId.startsWith(HEALTH_CHECK_JOB_PREFIX) && !desiredById.has(jobId),
93
+ )
94
+ : [];
95
+
96
+ return { toSchedule, toCancel, toReschedule };
97
+ }
98
+
99
+ /**
100
+ * Build the desired per-env recurring jobs for one or all systems from the
101
+ * durable tables + current catalog membership.
102
+ */
103
+ async function buildDesiredJobs(props: {
104
+ db: Db;
105
+ catalogClient: CatalogClient;
106
+ logger: Logger;
107
+ systemId?: string;
108
+ }): Promise<DesiredJob[]> {
109
+ const { db, catalogClient, logger, systemId } = props;
110
+
111
+ const where = systemId
112
+ ? and(
113
+ eq(systemHealthChecks.enabled, true),
114
+ eq(systemHealthChecks.systemId, systemId),
115
+ )
116
+ : eq(systemHealthChecks.enabled, true);
117
+
118
+ const checks = await db
119
+ .select({
120
+ systemId: systemHealthChecks.systemId,
121
+ configId: healthCheckConfigurations.id,
122
+ interval: healthCheckConfigurations.intervalSeconds,
123
+ environmentIds: systemHealthChecks.environmentIds,
124
+ })
125
+ .from(systemHealthChecks)
126
+ .innerJoin(
127
+ healthCheckConfigurations,
128
+ eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
129
+ )
130
+ .where(where);
131
+
132
+ // Resolve each distinct system's membership once.
133
+ const membershipBySystem = new Map<
134
+ string,
135
+ Awaited<ReturnType<CatalogClient["resolveSystemEnvironments"]>>
136
+ >();
137
+ for (const systemIdToResolve of new Set(checks.map((c) => c.systemId))) {
138
+ try {
139
+ membershipBySystem.set(
140
+ systemIdToResolve,
141
+ await catalogClient.resolveSystemEnvironments({ systemId: systemIdToResolve }),
142
+ );
143
+ } catch (error) {
144
+ // Fail-open: a system we cannot resolve keeps its env-less job (no fan-out)
145
+ // rather than being dropped from the schedule entirely.
146
+ logger.warn(
147
+ `Reconcile: could not resolve environments for system ${systemIdToResolve}`,
148
+ error,
149
+ );
150
+ membershipBySystem.set(systemIdToResolve, []);
151
+ }
152
+ }
153
+
154
+ const desired: DesiredJob[] = [];
155
+ for (const check of checks) {
156
+ const effectiveEnvs = resolveEffectiveEnvironments({
157
+ environmentIds: check.environmentIds,
158
+ membership: membershipBySystem.get(check.systemId) ?? [],
159
+ });
160
+ const environmentIds: (string | null)[] =
161
+ effectiveEnvs.length > 0 ? effectiveEnvs.map((e) => e.id) : [null];
162
+
163
+ for (const environmentId of environmentIds) {
164
+ const jobId = encodeHealthCheckJobId({
165
+ configId: check.configId,
166
+ systemId: check.systemId,
167
+ environmentId,
168
+ });
169
+ desired.push({
170
+ jobId,
171
+ payload: {
172
+ configId: check.configId,
173
+ systemId: check.systemId,
174
+ environmentId,
175
+ },
176
+ intervalSeconds: check.interval,
177
+ jitterKey: jobId,
178
+ });
179
+ }
180
+ }
181
+ return desired;
182
+ }
183
+
184
+ /**
185
+ * Compute the startDelay for a newly-scheduled job: keep an in-flight cadence by
186
+ * waiting out the remainder of the interval since the slice's last run, then add
187
+ * the deterministic de-clustering jitter. Overdue slices (no recent run) fire
188
+ * after just the jitter.
189
+ */
190
+ function computeStartDelay(props: {
191
+ intervalSeconds: number;
192
+ lastRun: Date | undefined;
193
+ jitterKey: string;
194
+ now: number;
195
+ }): number {
196
+ const { intervalSeconds, lastRun, jitterKey, now } = props;
197
+ let startDelay = 0;
198
+ if (lastRun) {
199
+ const elapsed = Math.floor((now - lastRun.getTime()) / 1000);
200
+ if (elapsed < intervalSeconds) startDelay = intervalSeconds - elapsed;
201
+ }
202
+ return startDelay + computeScheduleJitterSeconds({ key: jitterKey, intervalSeconds });
203
+ }
204
+
205
+ /**
206
+ * Converge the queue's recurring health-check jobs toward the desired per-env
207
+ * set. Pass `systemId` to reconcile just one system (add/update only, no orphan
208
+ * cleanup); omit it for a full reconcile (also cancels orphaned jobs).
209
+ */
210
+ export async function reconcileHealthCheckJobs(props: {
211
+ db: Db;
212
+ queueManager: QueueManager;
213
+ catalogClient: CatalogClient;
214
+ logger: Logger;
215
+ advisoryLock?: AdvisoryLockService;
216
+ systemId?: string;
217
+ now?: number;
218
+ }): Promise<void> {
219
+ const { db, queueManager, catalogClient, logger, advisoryLock, systemId, now } =
220
+ props;
221
+ const isFullReconcile = systemId === undefined;
222
+
223
+ const run = async (): Promise<void> => {
224
+ const desired = await buildDesiredJobs({ db, catalogClient, logger, systemId });
225
+
226
+ // Last run per (system, config, environment) slice for startDelay.
227
+ const lastRunRows = await db
228
+ .select({
229
+ systemId: healthCheckRuns.systemId,
230
+ configurationId: healthCheckRuns.configurationId,
231
+ environmentId: healthCheckRuns.environmentId,
232
+ maxTimestamp: max(healthCheckRuns.timestamp),
233
+ })
234
+ .from(healthCheckRuns)
235
+ .groupBy(
236
+ healthCheckRuns.systemId,
237
+ healthCheckRuns.configurationId,
238
+ healthCheckRuns.environmentId,
239
+ );
240
+ const lastRunByJobId = new Map<string, Date>();
241
+ for (const row of lastRunRows) {
242
+ if (!row.maxTimestamp) continue;
243
+ lastRunByJobId.set(
244
+ encodeHealthCheckJobId({
245
+ configId: row.configurationId,
246
+ systemId: row.systemId,
247
+ environmentId: row.environmentId,
248
+ }),
249
+ row.maxTimestamp,
250
+ );
251
+ }
252
+
253
+ const queue =
254
+ queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
255
+ const actualJobIds = new Set(await queue.listRecurringJobs());
256
+ const actualIntervalsById = new Map<string, number>();
257
+ for (const job of desired) {
258
+ if (!actualJobIds.has(job.jobId)) continue;
259
+ const details = await queue.getRecurringJobDetails(job.jobId);
260
+ const interval = details?.intervalSeconds;
261
+ if (typeof interval === "number") actualIntervalsById.set(job.jobId, interval);
262
+ }
263
+
264
+ const plan = planReconcile({
265
+ desired,
266
+ actualJobIds,
267
+ actualIntervalsById,
268
+ cancelOrphans: isFullReconcile,
269
+ });
270
+
271
+ const stampNow = now ?? Date.now();
272
+ for (const job of [...plan.toSchedule, ...plan.toReschedule]) {
273
+ await scheduleHealthCheck({
274
+ queueManager,
275
+ payload: job.payload,
276
+ intervalSeconds: job.intervalSeconds,
277
+ startDelay: computeStartDelay({
278
+ intervalSeconds: job.intervalSeconds,
279
+ lastRun: lastRunByJobId.get(job.jobId),
280
+ jitterKey: job.jitterKey,
281
+ now: stampNow,
282
+ }),
283
+ logger,
284
+ });
285
+ }
286
+ for (const jobId of plan.toCancel) {
287
+ await queue.cancelRecurring(jobId);
288
+ logger.debug(`Reconcile: cancelled orphaned job ${jobId}`);
289
+ }
290
+
291
+ logger.debug(
292
+ `Reconcile${systemId ? ` [system ${systemId}]` : ""}: ` +
293
+ `${plan.toSchedule.length} scheduled, ${plan.toReschedule.length} rescheduled, ` +
294
+ `${plan.toCancel.length} cancelled (${desired.length} desired)`,
295
+ );
296
+ };
297
+
298
+ // A full reconcile mutates the whole schedule - serialize across pods. A
299
+ // system-scoped reconcile only adds/updates that system's jobs (jobId-keyed,
300
+ // idempotent), so it needs no cluster lock.
301
+ await (isFullReconcile && advisoryLock
302
+ ? advisoryLock.withXactLock({ key: "health:reconcile", fn: run })
303
+ : run());
304
+ }