@checkstack/healthcheck-backend 1.17.0 โ†’ 1.19.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 (44) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/package.json +32 -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/health-notification-content.test.ts +89 -0
  9. package/src/health-notification-content.ts +138 -0
  10. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  11. package/src/healthcheck-gitops-kinds.ts +17 -13
  12. package/src/index.ts +58 -6
  13. package/src/migration-chain-contract.test.ts +7 -1
  14. package/src/notification-policy.test.ts +19 -0
  15. package/src/notification-policy.ts +26 -0
  16. package/src/queue-executor.test.ts +391 -338
  17. package/src/queue-executor.ts +426 -362
  18. package/src/realtime-aggregation.ts +9 -2
  19. package/src/rollup-consumer.test.ts +191 -0
  20. package/src/rollup-consumer.ts +160 -0
  21. package/src/router.ts +46 -13
  22. package/src/schedule-jitter.test.ts +69 -0
  23. package/src/schedule-jitter.ts +50 -0
  24. package/src/schedule-reconciler.it.test.ts +453 -0
  25. package/src/schedule-reconciler.test.ts +418 -0
  26. package/src/schedule-reconciler.ts +304 -0
  27. package/src/service-batching.test.ts +106 -0
  28. package/src/service-bulk-counts.it.test.ts +144 -0
  29. package/src/service-bulk-run-stats.it.test.ts +197 -0
  30. package/src/service-ordering.test.ts +10 -2
  31. package/src/service-paused-filter.test.ts +27 -7
  32. package/src/service-rollup-worst-wins.test.ts +221 -124
  33. package/src/service.ts +557 -266
  34. package/src/slow-check-admission.test.ts +184 -0
  35. package/src/slow-check-admission.ts +101 -0
  36. package/src/slow-check-classifier.test.ts +155 -0
  37. package/src/slow-check-classifier.ts +137 -0
  38. package/src/slow-check-config.ts +102 -0
  39. package/src/status-page/rollup.test.ts +40 -0
  40. package/src/status-page/rollup.ts +27 -0
  41. package/src/status-page/widgets.test.ts +303 -0
  42. package/src/status-page/widgets.ts +155 -39
  43. package/src/suspect-lane.test.ts +50 -0
  44. package/src/suspect-lane.ts +61 -0
@@ -11,6 +11,10 @@ import {
11
11
  type CollectorRunContext,
12
12
  type AdvisoryLockService,
13
13
  renderTemplatableConfig,
14
+ withScopedTransaction,
15
+ healthcheckExecutionHistogram,
16
+ healthcheckPhaseHistogram,
17
+ healthcheckDeferredCounter,
14
18
  } from "@checkstack/backend-api";
15
19
  import type { RunTimings } from "@checkstack/healthcheck-common";
16
20
  import { QueueManager } from "@checkstack/queue-api";
@@ -20,7 +24,7 @@ import {
20
24
  healthCheckRuns,
21
25
  } from "./schema";
22
26
  import * as schema from "./schema";
23
- import { eq, and, max } from "drizzle-orm";
27
+ import { eq, and, desc, isNull } from "drizzle-orm";
24
28
  import { type SignalService } from "@checkstack/signal-common";
25
29
  import {
26
30
  HEALTH_CHECK_RUN_COMPLETED,
@@ -31,20 +35,17 @@ import {
31
35
  } from "@checkstack/healthcheck-common";
32
36
  import {
33
37
  CatalogApi,
34
- catalogRoutes,
35
- createSystemSubject,
36
38
  type Environment,
37
39
  } from "@checkstack/catalog-common";
38
40
  import {
39
41
  resolveEffectiveEnvironments,
40
42
  type EffectiveEnvironment,
41
43
  } from "./effective-environments";
42
- import { systemHealthCollapseKey } from "@checkstack/healthcheck-common";
44
+ import { buildHealthTransitionNotification } from "./health-notification-content";
43
45
  import { MaintenanceApi } from "@checkstack/maintenance-common";
44
46
  import { IncidentApi } from "@checkstack/incident-common";
45
47
  import { NotificationApi } from "@checkstack/notification-common";
46
- import { healthcheckSystemSubscription } from "@checkstack/healthcheck-common";
47
- import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
48
+ import { type InferClient, extractErrorMessage} from "@checkstack/common";
48
49
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
49
50
  import type {
50
51
  SecretResolverService,
@@ -55,6 +56,12 @@ import { HealthCheckService } from "./service";
55
56
  import { healthCheckHooks } from "./hooks";
56
57
  import { incrementHourlyAggregate } from "./realtime-aggregation";
57
58
  import type { HealthCheckCache } from "./cache";
59
+ import {
60
+ resolveSlowCheckRuntime,
61
+ type SlowCheckRuntime,
62
+ } from "./slow-check-config";
63
+ import type { RecentRun } from "./slow-check-classifier";
64
+ import { evaluateSlowCheckAdmission } from "./slow-check-admission";
58
65
  import {
59
66
  classifyTransition,
60
67
  shouldNotifyTransition,
@@ -98,6 +105,48 @@ function toHealthEntityView(state: AggregatedHealth): HealthEntityState {
98
105
  };
99
106
  }
100
107
 
108
+ /**
109
+ * Read the most recent runs for ONE (config, system, environment) slice,
110
+ * newest-first, projected to the fields the slow-check classifier needs. Used
111
+ * only when the slow-check bulkhead is enabled; keyed on the SAME
112
+ * `environmentId` the job runs (an env-less job reads the `environment_id IS
113
+ * NULL` slice), so the classification reflects exactly this slice's streak.
114
+ */
115
+ async function fetchRecentRunsForSlice(props: {
116
+ db: Db;
117
+ configId: string;
118
+ systemId: string;
119
+ environmentId: string | null;
120
+ limit: number;
121
+ }): Promise<RecentRun[]> {
122
+ const { db, configId, systemId, environmentId, limit } = props;
123
+ const rows = await db
124
+ .select({
125
+ environmentId: healthCheckRuns.environmentId,
126
+ status: healthCheckRuns.status,
127
+ latencyMs: healthCheckRuns.latencyMs,
128
+ timestamp: healthCheckRuns.timestamp,
129
+ })
130
+ .from(healthCheckRuns)
131
+ .where(
132
+ and(
133
+ eq(healthCheckRuns.configurationId, configId),
134
+ eq(healthCheckRuns.systemId, systemId),
135
+ environmentId === null
136
+ ? isNull(healthCheckRuns.environmentId)
137
+ : eq(healthCheckRuns.environmentId, environmentId),
138
+ ),
139
+ )
140
+ .orderBy(desc(healthCheckRuns.timestamp))
141
+ .limit(limit);
142
+ return rows.map((r) => ({
143
+ environmentId: r.environmentId,
144
+ status: r.status,
145
+ latencyMs: r.latencyMs,
146
+ timestamp: r.timestamp,
147
+ }));
148
+ }
149
+
101
150
  /** The known transport timing phase keys, in transport order. */
102
151
  const RUN_TIMING_KEYS = [
103
152
  "dnsMs",
@@ -185,11 +234,38 @@ async function emitCheckCompletedHook({
185
234
  }
186
235
 
187
236
  /**
188
- * Payload for health check queue jobs
237
+ * Payload for health check queue jobs. Every job runs EXACTLY ONE environment
238
+ * slice - there is no in-job fan-out:
239
+ * - `environmentId: null` - the single ENV-LESS run of a system that has no
240
+ * environments. Its write IS the system rollup, so it notifies directly.
241
+ * - `environmentId: <id>` - the run for that specific environment. The system
242
+ * rollup is recomputed by the event-driven rollup consumer, not inline.
243
+ *
244
+ * The scheduling reconciler owns which (config, system, env) jobs exist; the
245
+ * `run_now` action enqueues one job per effective environment.
189
246
  */
190
247
  export interface HealthCheckJobPayload {
191
248
  configId: string;
192
249
  systemId: string;
250
+ environmentId: string | null;
251
+ }
252
+
253
+ /** Prefix every health-check recurring jobId shares (used for orphan scans). */
254
+ export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
255
+
256
+ /**
257
+ * Build the recurring jobId for a check. The env-less form keeps the historical
258
+ * `healthcheck:${configId}:${systemId}` shape (so env-less systems' jobs are
259
+ * unchanged across the per-env migration); an env-scoped job appends the env id.
260
+ */
261
+ export function encodeHealthCheckJobId(props: {
262
+ configId: string;
263
+ systemId: string;
264
+ environmentId: string | null;
265
+ }): string {
266
+ const { configId, systemId, environmentId } = props;
267
+ const base = `${HEALTH_CHECK_JOB_PREFIX}${configId}:${systemId}`;
268
+ return environmentId === null ? base : `${base}:${environmentId}`;
193
269
  }
194
270
 
195
271
  /**
@@ -230,7 +306,11 @@ export async function scheduleHealthCheck(props: {
230
306
  const queue =
231
307
  queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
232
308
 
233
- const jobId = `healthcheck:${payload.configId}:${payload.systemId}`;
309
+ const jobId = encodeHealthCheckJobId({
310
+ configId: payload.configId,
311
+ systemId: payload.systemId,
312
+ environmentId: payload.environmentId,
313
+ });
234
314
 
235
315
  logger?.debug(
236
316
  `Scheduling recurring health check ${jobId} with interval ${intervalSeconds}s, startDelay ${startDelay}s`,
@@ -272,16 +352,45 @@ export async function recomputeSystemRollupHealth(args: {
272
352
  getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
273
353
  advisoryLock: AdvisoryLockService;
274
354
  logger: Logger;
275
- }): Promise<void> {
276
- const { systemId, service, getHealthEntity, advisoryLock, logger } = args;
355
+ /**
356
+ * When provided, a real rollup status change (prev โ†’ next) also invalidates
357
+ * the per-system cache and broadcasts `SYSTEM_STATUS_CHANGED`, matching the
358
+ * system-level signal the pre-per-env inline rollup fired. Omit for the pure
359
+ * entity-only recompute (the framework's `ENTITY_CHANGED` still drives
360
+ * SLO/dependency/triggers regardless).
361
+ */
362
+ signalService?: SignalService;
363
+ cache?: HealthCheckCache;
364
+ }): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
365
+ const {
366
+ systemId,
367
+ service,
368
+ getHealthEntity,
369
+ advisoryLock,
370
+ logger,
371
+ signalService,
372
+ cache,
373
+ } = args;
277
374
  const rollupEntityId = encodeHealthEntityId({ systemId });
278
375
  const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
376
+ // The system-level signal + cache invalidation are only needed when a caller
377
+ // wants them (the rollup consumer). The framework snapshots its OWN prev
378
+ // inside `handle.mutate` for the authoritative `ENTITY_CHANGED`, so the
379
+ // pure entity-recompute path (pause/resume) skips the extra prev read.
380
+ const wantsSignal = signalService !== undefined || cache !== undefined;
279
381
  try {
382
+ let previousStatus: HealthCheckStatus | undefined;
383
+ if (wantsSignal) {
384
+ const previousState = await service.getSystemHealthStatus(systemId);
385
+ previousStatus = previousState.status;
386
+ }
387
+ let newStatus: HealthCheckStatus | undefined = previousStatus;
280
388
  await writeHealthEntity({
281
389
  handle: getHealthEntity?.(),
282
390
  entityId: rollupEntityId,
283
391
  apply: async () => {
284
392
  const rollupState = await service.getSystemHealthStatus(systemId);
393
+ newStatus = rollupState.status;
285
394
  return toHealthEntityView(rollupState);
286
395
  },
287
396
  serialize: makeHealthSerializer(rollupEntityId),
@@ -291,6 +400,23 @@ export async function recomputeSystemRollupHealth(args: {
291
400
  error,
292
401
  ),
293
402
  });
403
+
404
+ if (
405
+ wantsSignal &&
406
+ previousStatus !== undefined &&
407
+ newStatus !== undefined &&
408
+ newStatus !== previousStatus
409
+ ) {
410
+ await cache?.invalidateSystem(systemId);
411
+ await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
412
+ systemId,
413
+ previousStatus,
414
+ newStatus,
415
+ });
416
+ }
417
+ return previousStatus !== undefined && newStatus !== undefined
418
+ ? { previousStatus, newStatus }
419
+ : undefined;
294
420
  } catch (error) {
295
421
  // A recompute failure must never break the pause/resume RPC. The
296
422
  // durable tables still hold the authoritative runs; the next run tick
@@ -299,6 +425,7 @@ export async function recomputeSystemRollupHealth(args: {
299
425
  `Failed to recompute system rollup health for ${systemId}`,
300
426
  error,
301
427
  );
428
+ return undefined;
302
429
  }
303
430
  }
304
431
 
@@ -323,11 +450,25 @@ export async function recomputeSystemRollupHealth(args: {
323
450
  * Policy is resolved per-assignment (per system+configuration) โ€” the
324
451
  * just-ran check is the one driving any aggregate transition in this
325
452
  * execution, so its policy is the authoritative one.
453
+ *
454
+ * Returns `true` when a subscriber notification was actually delivered, and
455
+ * `false` when it was skipped (no-op transition, policy/maintenance/incident
456
+ * suppression) or the delivery threw. Callers use this to deduplicate the
457
+ * system-rollup notification against the per-environment ones fired in the
458
+ * same tick: when any environment already notified, the rollup notification
459
+ * (which describes the same underlying outage) is redundant and suppressed.
326
460
  */
327
461
  async function notifyStateChange(props: {
328
462
  systemId: string;
329
463
  systemName: string;
330
464
  configurationId: string;
465
+ /**
466
+ * Human-readable name of the health check whose run drove this transition.
467
+ * Named in the body and surfaced as a `healthcheck.healthcheck` subject so
468
+ * subscribers see WHICH check failed, not just which system. Best-effort:
469
+ * falls back to the `configurationId` when the name could not be resolved.
470
+ */
471
+ configurationName?: string;
331
472
  previousStatus: HealthCheckStatus;
332
473
  newStatus: HealthCheckStatus;
333
474
  /**
@@ -351,11 +492,12 @@ async function notifyStateChange(props: {
351
492
  maintenanceClient: MaintenanceClient;
352
493
  incidentClient: IncidentClient;
353
494
  logger: Logger;
354
- }): Promise<void> {
495
+ }): Promise<boolean> {
355
496
  const {
356
497
  systemId,
357
498
  systemName,
358
499
  configurationId,
500
+ configurationName,
359
501
  previousStatus,
360
502
  newStatus,
361
503
  environmentId,
@@ -368,12 +510,13 @@ async function notifyStateChange(props: {
368
510
  logger,
369
511
  } = props;
370
512
 
371
- const envScoped = typeof environmentId === "string";
372
- const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
513
+ // The check that just ran is the one driving this aggregate transition, so
514
+ // its name is the authoritative check to blame. Fall back to the id.
515
+ const checkName = configurationName ?? configurationId;
373
516
 
374
517
  const transition = classifyTransition(previousStatus, newStatus);
375
518
  if (transition === "none") {
376
- return;
519
+ return false;
377
520
  }
378
521
 
379
522
  // Per-assignment notification policy. Failure to load defaults to
@@ -396,7 +539,7 @@ async function notifyStateChange(props: {
396
539
  logger.debug(
397
540
  `Skipping notification for ${systemId}: ${transition} suppressed by policy`,
398
541
  );
399
- return;
542
+ return false;
400
543
  }
401
544
 
402
545
  // Check if notifications should be suppressed due to active maintenance
@@ -407,7 +550,7 @@ async function notifyStateChange(props: {
407
550
  logger.debug(
408
551
  `Skipping notification for ${systemId}: active maintenance with suppression enabled`,
409
552
  );
410
- return;
553
+ return false;
411
554
  }
412
555
  } catch (error) {
413
556
  // Log but continue with notification - suppression check failure shouldn't block notifications
@@ -425,7 +568,7 @@ async function notifyStateChange(props: {
425
568
  logger.debug(
426
569
  `Skipping notification for ${systemId}: active incident with suppression enabled`,
427
570
  );
428
- return;
571
+ return false;
429
572
  }
430
573
  } catch (error) {
431
574
  // Log but continue with notification - suppression check failure shouldn't block notifications
@@ -435,79 +578,37 @@ async function notifyStateChange(props: {
435
578
  );
436
579
  }
437
580
 
438
- let title: string;
439
- let body: string;
440
- let importance: "info" | "warning" | "critical";
441
-
442
- if (transition === "recovery") {
443
- title = `System health restored${envSuffix}: ${systemName}`;
444
- body = envScoped
445
- ? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
446
- : `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
447
- importance = "info";
448
- } else if (newStatus === "unhealthy") {
449
- title = `System health critical${envSuffix}: ${systemName}`;
450
- body = envScoped
451
- ? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
452
- : `Health checks indicate **${systemName}** is unhealthy and may be down.`;
453
- importance = "critical";
454
- } else {
455
- // degraded โ€” either an escalation from healthy or a partial recovery
456
- title = `System health degraded${envSuffix}: ${systemName}`;
457
- body = envScoped
458
- ? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
459
- : `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
460
- importance = "warning";
461
- }
462
-
463
- const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
464
- systemId,
465
- });
466
- // Recovery lands on the default (all) view; failing transitions deep-link
467
- // operators into the failing-checks filter so they can debug immediately.
468
- const actionUrl =
469
- transition === "recovery"
470
- ? systemDetailPath
471
- : `${systemDetailPath}?filter=failing`;
472
- const actionLabel =
473
- transition === "recovery" ? "View System" : "View failing checks";
474
-
475
581
  void catalogClient; // parents are resolved server-side via stored target edges
476
582
 
477
583
  try {
478
- await notificationClient.notifyForSubscription({
479
- specId: healthcheckSystemSubscription.specId,
480
- resourceKeys: [systemId],
481
- title,
482
- body,
483
- importance,
484
- action: { label: actionLabel, url: actionUrl },
485
- // Env-qualified collapse key so two failing envs of one system generate
486
- // two independent notification cards (one per env) instead of merging
487
- // -> operators see all env outages. The system-rollup transition
488
- // (`environmentId === null`/undefined) keys on the bare systemId and
489
- // therefore reuses the pre-existing single-card identity.
490
- collapseKey: envScoped
491
- ? systemHealthCollapseKey(systemId, environmentId)
492
- : systemHealthCollapseKey(systemId),
493
- subjects: [
494
- createSystemSubject({
495
- id: systemId,
496
- name: systemName,
497
- url: systemDetailPath,
498
- status: newStatus,
499
- }),
500
- ],
501
- });
584
+ // Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
585
+ // helper so the wording - which now NAMES the failing check and pushes a
586
+ // `healthcheck.healthcheck` subject - can be verified without the executor.
587
+ await notificationClient.notifyForSubscription(
588
+ buildHealthTransitionNotification({
589
+ transition,
590
+ systemId,
591
+ systemName,
592
+ configurationId,
593
+ checkName,
594
+ newStatus,
595
+ environmentId,
596
+ environmentName,
597
+ }),
598
+ );
502
599
  logger.debug(
503
600
  `Notified subscribers: ${previousStatus} โ†’ ${newStatus} for system ${systemId}`,
504
601
  );
602
+ return true;
505
603
  } catch (error) {
506
- // Log but don't fail the operation - notifications are best-effort
604
+ // Log but don't fail the operation - notifications are best-effort. A
605
+ // delivery that threw did NOT inform the user, so report `false` and let
606
+ // the caller's rollup fallback still fire.
507
607
  logger.warn(
508
608
  `Failed to notify subscribers for health state change on system ${systemId}:`,
509
609
  error,
510
610
  );
611
+ return false;
511
612
  }
512
613
  }
513
614
 
@@ -551,6 +652,12 @@ async function executeHealthCheckJob(props: {
551
652
  * without it, marker-bearing configs fail their runs clearly.
552
653
  */
553
654
  internalSecrets?: InternalSecretsService;
655
+ /**
656
+ * Slow-check bulkhead + adaptive-timeout runtime, resolved once at worker
657
+ * startup. `null`/`undefined` disables the feature: the classification read
658
+ * is skipped and the run executes exactly as before (full timeout, no lane).
659
+ */
660
+ slowCheckRuntime?: SlowCheckRuntime | null;
554
661
  }): Promise<void> {
555
662
  const {
556
663
  payload,
@@ -569,6 +676,7 @@ async function executeHealthCheckJob(props: {
569
676
  cache,
570
677
  secretResolver,
571
678
  internalSecrets,
679
+ slowCheckRuntime,
572
680
  } = props;
573
681
  const { configId, systemId } = payload;
574
682
 
@@ -592,6 +700,10 @@ async function executeHealthCheckJob(props: {
592
700
  const rollupPreviousState = await service.getSystemHealthStatus(systemId);
593
701
  const rollupPreviousStatus = rollupPreviousState.status;
594
702
 
703
+ // Slow-check lane admission (set when this run was admitted to the suspect
704
+ // lane); released in the outer finally so the slot frees on any exit path.
705
+ let laneKey: string | undefined;
706
+
595
707
  try {
596
708
  // Fetch configuration (including name for signals)
597
709
  const [configRow] = await db
@@ -705,13 +817,15 @@ async function executeHealthCheckJob(props: {
705
817
  // tables and is re-read every tick via the cross-plugin RPC, so every pod
706
818
  // resolves the same set (state-and-scale: no pod-local env state).
707
819
  let membership: Environment[] = [];
820
+ let catalogResolutionFailed = false;
708
821
  try {
709
822
  membership = await catalogClient.resolveSystemEnvironments({ systemId });
710
823
  } catch (error) {
711
- // Fail-open: a catalog read failure must not wedge the check. Degrade
712
- // to an env-less run (today's behavior) rather than skipping the tick.
824
+ // Fail-open: a catalog read failure must not wedge the check. We keep
825
+ // running the payload's env with degraded fields rather than skipping.
826
+ catalogResolutionFailed = true;
713
827
  logger.warn(
714
- `Could not resolve environments for system ${systemId}, running env-less`,
828
+ `Could not resolve environments for system ${systemId}`,
715
829
  error,
716
830
  );
717
831
  // Observability: a `logger.warn` alone is easy to miss when a durable
@@ -735,28 +849,102 @@ async function executeHealthCheckJob(props: {
735
849
  environmentIds: configRow.environmentIds,
736
850
  membership,
737
851
  });
738
- // `null` env => the single env-less run. Each entry => one run per env.
739
- const runEnvironments: (EffectiveEnvironment | null)[] =
740
- effectiveEnvs.length > 0 ? effectiveEnvs : [null];
741
-
742
- // Execute one run per effective environment. Runs are independent (own
743
- // status / latency / result) and persisted with their own
744
- // `environmentId`. Phase 3b: each env-run mutates its OWN env-qualified
745
- // `health` entity (`<systemId>::<environmentId>`, or the bare `<systemId>`
746
- // for the env-less run) through a per-entity serializer; after the loop a
747
- // single ROLLUP write for the bare `<systemId>` recomputes the worst-status
748
- // rollup so system-level consumers keep firing off the unchanged id.
749
- //
750
- // Track whether ANY per-env run persisted (so the rollup write only runs
751
- // when there is something to roll up โ€” an all-failed loop still leaves the
752
- // durable runs the per-env apply already wrote).
753
- let anyEnvRunPersisted = false;
754
- // Whether this tick fans out into REAL environments (vs. the single
755
- // env-less run). When env-less, the loop's lone write already targets the
756
- // bare `<systemId>` entity โ€” which IS the rollup โ€” so no separate rollup
757
- // write is needed. With real envs, the loop writes `<systemId>::<env>`
758
- // entities and we recompute the bare-`<systemId>` rollup after the loop.
759
- const isFannedOut = effectiveEnvs.length > 0;
852
+
853
+ // Select THE single environment this job runs (payload.environmentId). The
854
+ // reconciler owns which (config, system, env) jobs exist; here we only
855
+ // validate the payload's env against the CURRENT effective set so a stale
856
+ // job (env removed, or an env-less job for a system that has since gained
857
+ // envs) is skipped and the reconciler converges the set.
858
+ const targetEnvironmentId = payload.environmentId;
859
+ let singleEnvironment: EffectiveEnvironment | null;
860
+ if (targetEnvironmentId === null) {
861
+ // Env-less job: valid only while the system has no effective envs.
862
+ if (!catalogResolutionFailed && effectiveEnvs.length > 0) {
863
+ logger.debug(
864
+ `Env-less job for ${configId}/${systemId} is stale (system now has ${effectiveEnvs.length} env(s)); skipping`,
865
+ );
866
+ return;
867
+ }
868
+ singleEnvironment = null;
869
+ } else {
870
+ const found =
871
+ effectiveEnvs.find((env) => env.id === targetEnvironmentId) ?? null;
872
+ if (found) {
873
+ singleEnvironment = found;
874
+ } else if (catalogResolutionFailed) {
875
+ // Transient catalog failure: still run the probe, with degraded (empty)
876
+ // env fields rather than skipping the tick. The next tick recovers.
877
+ singleEnvironment = {
878
+ id: targetEnvironmentId,
879
+ name: targetEnvironmentId,
880
+ fields: {},
881
+ };
882
+ } else {
883
+ logger.debug(
884
+ `Env ${targetEnvironmentId} no longer effective for ${configId}/${systemId}; skipping`,
885
+ );
886
+ return;
887
+ }
888
+ }
889
+
890
+ // โ”€โ”€ Slow-check bulkhead + adaptive timeout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
891
+ // Classify THIS slice's recent runs. A slice whose last K runs were SLOW
892
+ // transport failures (held its slot ~the full timeout) is "suspect": it is
893
+ // admitted to a capped, pod-local lane (or DEFERRED this tick when the lane
894
+ // is full or a prior run of the same slice is still in flight โ€” recording
895
+ // nothing so it can't pile up) and probed with a timeout shrunk toward its
896
+ // OWN healthy-latency baseline, so a stuck target frees its slot fast
897
+ // instead of pinning it for the full timeout. A healthy slice is untouched.
898
+ let effectiveTimeout = executionTimeout;
899
+ const sliceEnvironmentId = singleEnvironment?.id ?? null;
900
+ if (slowCheckRuntime) {
901
+ try {
902
+ const recentRuns = await fetchRecentRunsForSlice({
903
+ db,
904
+ configId,
905
+ systemId,
906
+ environmentId: sliceEnvironmentId,
907
+ limit: slowCheckRuntime.recentRunsLimit,
908
+ });
909
+ const decision = evaluateSlowCheckAdmission({
910
+ runtime: slowCheckRuntime,
911
+ recentRuns,
912
+ configId,
913
+ systemId,
914
+ environmentId: sliceEnvironmentId,
915
+ executionTimeoutMs: executionTimeout,
916
+ });
917
+ if (decision.kind === "defer") {
918
+ healthcheckDeferredCounter().add(1, { reason: decision.reason });
919
+ logger.debug(
920
+ `Deferred suspect health check ${configId}/${systemId}` +
921
+ (sliceEnvironmentId ? ` [${sliceEnvironmentId}]` : "") +
922
+ ` (${decision.reason}); recording nothing this tick`,
923
+ );
924
+ // Record nothing: the recurring job stays scheduled, so the next tick
925
+ // retries once the lane drains / the in-flight run finishes.
926
+ return;
927
+ }
928
+ effectiveTimeout = decision.effectiveTimeoutMs;
929
+ laneKey = decision.laneKey;
930
+ } catch (error) {
931
+ // Classification is best-effort: a read failure must never wedge the
932
+ // check. Fall back to the full timeout with no lane admission.
933
+ logger.warn(
934
+ `Slow-check classification failed for ${configId}/${systemId}; running at full timeout`,
935
+ error,
936
+ );
937
+ }
938
+ }
939
+
940
+ // This job runs exactly this ONE env. `isFannedOut` is now a per-JOB
941
+ // property: an env-scoped run (`isFannedOut === true`) mutates the
942
+ // `<systemId>::<env>` entity and leaves the bare `<systemId>` ROLLUP to the
943
+ // event-driven rollup consumer; an env-less run mutates the bare entity
944
+ // (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
945
+ // directly.
946
+ const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
947
+ const isFannedOut = targetEnvironmentId !== null;
760
948
  for (const environment of runEnvironments) {
761
949
  const environmentId = environment?.id ?? null;
762
950
  // The env-qualified entity id this run mutates. For the env-less run
@@ -1047,9 +1235,9 @@ async function executeHealthCheckJob(props: {
1047
1235
  setTimeout(
1048
1236
  () =>
1049
1237
  reject(
1050
- new Error(`Execution timeout after ${executionTimeout}ms`),
1238
+ new Error(`Execution timeout after ${effectiveTimeout}ms`),
1051
1239
  ),
1052
- executionTimeout,
1240
+ effectiveTimeout,
1053
1241
  ),
1054
1242
  ),
1055
1243
  ]);
@@ -1081,31 +1269,38 @@ async function executeHealthCheckJob(props: {
1081
1269
  handle: getHealthEntity?.(),
1082
1270
  entityId: envEntityId,
1083
1271
  apply: async () => {
1084
- await db.insert(healthCheckRuns).values({
1085
- configurationId: configId,
1086
- systemId,
1087
- environmentId,
1088
- status: result.status,
1089
- latencyMs: result.latencyMs,
1090
- result: { ...result } as Record<string, unknown>,
1091
- sourceId: undefined,
1092
- sourceLabel: "Local",
1093
- });
1094
-
1095
- await incrementHourlyAggregate({
1096
- db,
1097
- systemId,
1098
- configurationId: configId,
1099
- environmentId,
1100
- status: result.status,
1101
- latencyMs: result.latencyMs,
1102
- runTimestamp: new Date(),
1103
- result: { ...result } as Record<string, unknown>,
1104
- collectorRegistry,
1105
- sourceLabel: "Local",
1272
+ // ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1273
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’ 1),
1274
+ // which also makes the run and its aggregate commit atomically.
1275
+ await withScopedTransaction(db, async (tx) => {
1276
+ await tx.insert(healthCheckRuns).values({
1277
+ configurationId: configId,
1278
+ systemId,
1279
+ environmentId,
1280
+ status: result.status,
1281
+ latencyMs: result.latencyMs,
1282
+ result: { ...result } as Record<string, unknown>,
1283
+ sourceId: undefined,
1284
+ sourceLabel: "Local",
1285
+ });
1286
+
1287
+ await incrementHourlyAggregate({
1288
+ db: tx,
1289
+ systemId,
1290
+ configurationId: configId,
1291
+ environmentId,
1292
+ status: result.status,
1293
+ latencyMs: result.latencyMs,
1294
+ runTimestamp: new Date(),
1295
+ result: { ...result } as Record<string, unknown>,
1296
+ collectorRegistry,
1297
+ sourceLabel: "Local",
1298
+ });
1106
1299
  });
1107
1300
 
1108
1301
  // Env-scoped view: the per-env entity reflects only this env's runs.
1302
+ // Runs as its own batched read AFTER the write commits, so it sees
1303
+ // the just-inserted run.
1109
1304
  newState = await service.getSystemHealthStatus(systemId, environmentId);
1110
1305
  return toHealthEntityView(newState);
1111
1306
  },
@@ -1116,7 +1311,6 @@ async function executeHealthCheckJob(props: {
1116
1311
  error,
1117
1312
  ),
1118
1313
  });
1119
- anyEnvRunPersisted = true;
1120
1314
 
1121
1315
  logger.debug(
1122
1316
  `Health check ${configId} for system ${systemId} failed: ${finalError}`,
@@ -1156,6 +1350,7 @@ async function executeHealthCheckJob(props: {
1156
1350
  systemId,
1157
1351
  systemName,
1158
1352
  configurationId: configId,
1353
+ configurationName: configRow.configName,
1159
1354
  previousStatus,
1160
1355
  newStatus: newState.status,
1161
1356
  environmentId,
@@ -1191,6 +1386,21 @@ async function executeHealthCheckJob(props: {
1191
1386
  // undefined and the frontend falls back to the coarse connection split.
1192
1387
  const timings = extractRunTimings(connectedClient);
1193
1388
 
1389
+ // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1390
+ // network sub-phases. The `phase` breakdown is what tells "slow target"
1391
+ // (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
1392
+ // grow under a same-host stampede) apart from platform delay.
1393
+ healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1394
+ if (timings) {
1395
+ for (const [phase, value] of Object.entries(timings)) {
1396
+ if (typeof value === "number" && Number.isFinite(value)) {
1397
+ healthcheckPhaseHistogram().record(value, {
1398
+ phase: phase.replace(/Ms$/, ""),
1399
+ });
1400
+ }
1401
+ }
1402
+ }
1403
+
1194
1404
  const result = {
1195
1405
  status: status as "healthy" | "unhealthy",
1196
1406
  latencyMs: totalLatencyMs,
@@ -1217,33 +1427,40 @@ async function executeHealthCheckJob(props: {
1217
1427
  handle: getHealthEntity?.(),
1218
1428
  entityId: envEntityId,
1219
1429
  apply: async () => {
1220
- // Store result (spread to convert structured type to plain record for jsonb)
1221
- await db.insert(healthCheckRuns).values({
1222
- configurationId: configId,
1223
- systemId,
1224
- environmentId,
1225
- status: result.status,
1226
- latencyMs: result.latencyMs,
1227
- result: { ...result } as Record<string, unknown>,
1228
- sourceId: undefined,
1229
- sourceLabel: "Local",
1230
- });
1430
+ // ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1431
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’ 1),
1432
+ // which also makes the run and its aggregate commit atomically.
1433
+ await withScopedTransaction(db, async (tx) => {
1434
+ // Store result (spread to convert structured type to plain record for jsonb)
1435
+ await tx.insert(healthCheckRuns).values({
1436
+ configurationId: configId,
1437
+ systemId,
1438
+ environmentId,
1439
+ status: result.status,
1440
+ latencyMs: result.latencyMs,
1441
+ result: { ...result } as Record<string, unknown>,
1442
+ sourceId: undefined,
1443
+ sourceLabel: "Local",
1444
+ });
1231
1445
 
1232
- // Trigger incremental hourly aggregation
1233
- await incrementHourlyAggregate({
1234
- db,
1235
- systemId,
1236
- configurationId: configId,
1237
- environmentId,
1238
- status: result.status,
1239
- latencyMs: result.latencyMs,
1240
- runTimestamp: new Date(),
1241
- result: { ...result } as Record<string, unknown>,
1242
- collectorRegistry,
1243
- sourceLabel: "Local",
1446
+ // Trigger incremental hourly aggregation
1447
+ await incrementHourlyAggregate({
1448
+ db: tx,
1449
+ systemId,
1450
+ configurationId: configId,
1451
+ environmentId,
1452
+ status: result.status,
1453
+ latencyMs: result.latencyMs,
1454
+ runTimestamp: new Date(),
1455
+ result: { ...result } as Record<string, unknown>,
1456
+ collectorRegistry,
1457
+ sourceLabel: "Local",
1458
+ });
1244
1459
  });
1245
1460
 
1246
1461
  // Env-scoped view: the per-env entity reflects only this env's runs.
1462
+ // Runs as its own batched read AFTER the write commits, so it sees the
1463
+ // just-inserted run.
1247
1464
  newState = await service.getSystemHealthStatus(systemId, environmentId);
1248
1465
  return toHealthEntityView(newState);
1249
1466
  },
@@ -1251,7 +1468,6 @@ async function executeHealthCheckJob(props: {
1251
1468
  onError: (error) =>
1252
1469
  logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
1253
1470
  });
1254
- anyEnvRunPersisted = true;
1255
1471
 
1256
1472
  logger.debug(
1257
1473
  `Ran health check ${configId} for system ${systemId}: ${result.status}`,
@@ -1302,6 +1518,7 @@ async function executeHealthCheckJob(props: {
1302
1518
  systemId,
1303
1519
  systemName,
1304
1520
  configurationId: configId,
1521
+ configurationName: configRow.configName,
1305
1522
  previousStatus,
1306
1523
  newStatus: newState.status,
1307
1524
  environmentId,
@@ -1343,81 +1560,14 @@ async function executeHealthCheckJob(props: {
1343
1560
  }
1344
1561
  } // end per-environment fan-out loop (for ... of runEnvironments)
1345
1562
 
1346
- // โ”€โ”€ System rollup write (ยง7.4.3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1347
- // With real environments, the per-env writes mutated `<systemId>::<env>`
1348
- // entities; the bare `<systemId>` ROLLUP entity (the worst-status view
1349
- // every existing system-level consumer references) must now recompute so
1350
- // it diffs/emits its OWN `ENTITY_CHANGED`. The rollup `apply` does NO new
1351
- // durable insert (the runs are already persisted by the per-env writes) โ€”
1352
- // it just recomputes + returns the all-runs rollup view so the framework
1353
- // diffs prev โ†’ next. Keyed on the bare `health:<systemId>` lock so it
1354
- // serializes against itself, independent of the per-env locks.
1355
- //
1356
- // Skipped when env-less (the loop's lone write already targeted the bare
1357
- // `<systemId>` entity = the rollup) or when nothing persisted (a fully
1358
- // isolated-failure loop left no new runs to roll up).
1359
- if (isFannedOut && anyEnvRunPersisted) {
1360
- const rollupEntityId = encodeHealthEntityId({ systemId });
1361
- let rollupState!: AggregatedHealth;
1362
- try {
1363
- await writeHealthEntity({
1364
- handle: getHealthEntity?.(),
1365
- entityId: rollupEntityId,
1366
- apply: async () => {
1367
- // No durable insert โ€” recompute the all-runs (rollup) view.
1368
- rollupState = await service.getSystemHealthStatus(systemId);
1369
- return toHealthEntityView(rollupState);
1370
- },
1371
- serialize: makeHealthSerializer(rollupEntityId),
1372
- onError: (error) =>
1373
- logger.warn(
1374
- `Failed to mirror rollup health entity for ${systemId}`,
1375
- error,
1376
- ),
1377
- });
1378
-
1379
- // Record the ROLLUP transition (environmentId = null) so system-level
1380
- // "in status since" reflects the aggregate, and notify on a real
1381
- // rollup status change so existing system-level notifications fire.
1382
- if (rollupState.status !== rollupPreviousStatus) {
1383
- await recordStateTransition({
1384
- db,
1385
- systemId,
1386
- configurationId: configId,
1387
- environmentId: null,
1388
- fromStatus: rollupPreviousStatus,
1389
- toStatus: rollupState.status,
1390
- });
1391
-
1392
- await notifyStateChange({
1393
- notificationClient,
1394
- systemId,
1395
- systemName,
1396
- configurationId: configId,
1397
- previousStatus: rollupPreviousStatus,
1398
- newStatus: rollupState.status,
1399
- service,
1400
- catalogClient,
1401
- maintenanceClient,
1402
- incidentClient,
1403
- logger,
1404
- });
1405
-
1406
- await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
1407
- systemId,
1408
- previousStatus: rollupPreviousStatus as HealthCheckStatus,
1409
- newStatus: rollupState.status,
1410
- });
1411
- }
1412
- } catch (rollupError) {
1413
- // The rollup is best-effort reactivity over already-durable runs; a
1414
- // failure must not wedge the (completed) per-env runs.
1415
- logger.error(
1416
- `Failed to write system rollup health for ${systemId}`,
1417
- rollupError,
1418
- );
1419
- }
1420
- }
1563
+ // The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
1564
+ // run is recomputed ASYNCHRONOUSLY by the event-driven rollup consumer,
1565
+ // which subscribes to per-env `health` entity changes and debounces per
1566
+ // system (recordSystemRollupChange). Doing it inline per env-job would
1567
+ // multiply the `health:<systemId>` advisory-lock load by the fan-out
1568
+ // factor. An env-less run needs no separate rollup: its write above IS the
1569
+ // bare `<systemId>` entity, and it already recorded its own transition,
1570
+ // notification, and SYSTEM_STATUS_CHANGED signal.
1421
1571
 
1422
1572
  // Note: No manual rescheduling needed - recurring job handles it automatically
1423
1573
  } catch (error) {
@@ -1438,27 +1588,32 @@ async function executeHealthCheckJob(props: {
1438
1588
  handle: getHealthEntity?.(),
1439
1589
  entityId: rollupEntityId,
1440
1590
  apply: async () => {
1441
- // Store failure (no latencyMs for failures)
1442
- await db.insert(healthCheckRuns).values({
1443
- configurationId: configId,
1444
- systemId,
1445
- status: "unhealthy",
1446
- result: { error: String(error) } as Record<string, unknown>,
1447
- sourceId: undefined,
1448
- sourceLabel: "Local",
1449
- });
1591
+ // ยงperf: batch the failure run INSERT + aggregate SELECT/UPSERT under
1592
+ // ONE `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’
1593
+ // 1), which also makes them commit atomically.
1594
+ await withScopedTransaction(db, async (tx) => {
1595
+ // Store failure (no latencyMs for failures)
1596
+ await tx.insert(healthCheckRuns).values({
1597
+ configurationId: configId,
1598
+ systemId,
1599
+ status: "unhealthy",
1600
+ result: { error: String(error) } as Record<string, unknown>,
1601
+ sourceId: undefined,
1602
+ sourceLabel: "Local",
1603
+ });
1450
1604
 
1451
- // Trigger incremental hourly aggregation
1452
- await incrementHourlyAggregate({
1453
- db,
1454
- systemId,
1455
- configurationId: configId,
1456
- status: "unhealthy",
1457
- latencyMs: undefined,
1458
- runTimestamp: new Date(),
1459
- // No collector data for error cases
1460
- collectorRegistry,
1461
- sourceLabel: "Local",
1605
+ // Trigger incremental hourly aggregation
1606
+ await incrementHourlyAggregate({
1607
+ db: tx,
1608
+ systemId,
1609
+ configurationId: configId,
1610
+ status: "unhealthy",
1611
+ latencyMs: undefined,
1612
+ runTimestamp: new Date(),
1613
+ // No collector data for error cases
1614
+ collectorRegistry,
1615
+ sourceLabel: "Local",
1616
+ });
1462
1617
  });
1463
1618
 
1464
1619
  newState = await service.getSystemHealthStatus(systemId);
@@ -1530,6 +1685,7 @@ async function executeHealthCheckJob(props: {
1530
1685
  systemId,
1531
1686
  systemName,
1532
1687
  configurationId: configId,
1688
+ configurationName: configName,
1533
1689
  previousStatus,
1534
1690
  newStatus: newState.status,
1535
1691
  service,
@@ -1554,6 +1710,11 @@ async function executeHealthCheckJob(props: {
1554
1710
  }
1555
1711
 
1556
1712
  // Note: No manual rescheduling needed - recurring job handles it automatically
1713
+ } finally {
1714
+ // Release the suspect-lane slot (single-flight + capacity) on EVERY exit
1715
+ // path โ€” success, timeout, or a catastrophic throw โ€” so a slow run frees
1716
+ // its slot for the next tick. A no-op when this run was not admitted.
1717
+ if (laneKey && slowCheckRuntime) slowCheckRuntime.lane.release(laneKey);
1557
1718
  }
1558
1719
  }
1559
1720
 
@@ -1574,6 +1735,12 @@ export async function setupHealthCheckWorker(props: {
1574
1735
  cache: HealthCheckCache;
1575
1736
  secretResolver?: SecretResolverService;
1576
1737
  internalSecrets?: InternalSecretsService;
1738
+ /**
1739
+ * Slow-check bulkhead runtime. Omit to resolve it once from `process.env`
1740
+ * (the production path); pass `null` to force the feature OFF (tests that
1741
+ * don't exercise the bulkhead), or a concrete runtime to drive it.
1742
+ */
1743
+ slowCheckRuntime?: SlowCheckRuntime | null;
1577
1744
  }): Promise<void> {
1578
1745
  const {
1579
1746
  db,
@@ -1594,6 +1761,16 @@ export async function setupHealthCheckWorker(props: {
1594
1761
  internalSecrets,
1595
1762
  } = props;
1596
1763
 
1764
+ // Resolve the slow-check runtime once at startup unless the caller supplied
1765
+ // one (including an explicit `null` to disable it).
1766
+ const slowCheckRuntime =
1767
+ props.slowCheckRuntime === undefined
1768
+ ? resolveSlowCheckRuntime(process.env)
1769
+ : props.slowCheckRuntime;
1770
+ if (slowCheckRuntime) {
1771
+ logger.debug("๐Ÿฉบ Slow-check bulkhead + adaptive timeout enabled.");
1772
+ }
1773
+
1597
1774
  const queue =
1598
1775
  queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
1599
1776
 
@@ -1617,6 +1794,7 @@ export async function setupHealthCheckWorker(props: {
1617
1794
  cache,
1618
1795
  secretResolver,
1619
1796
  internalSecrets,
1797
+ slowCheckRuntime,
1620
1798
  });
1621
1799
  },
1622
1800
  {
@@ -1628,117 +1806,3 @@ export async function setupHealthCheckWorker(props: {
1628
1806
  logger.debug("๐ŸŽฏ Health Check Worker subscribed to queue");
1629
1807
  }
1630
1808
 
1631
- /**
1632
- * Bootstrap health checks by enqueueing all enabled checks
1633
- */
1634
- export async function bootstrapHealthChecks(props: {
1635
- db: Db;
1636
- queueManager: QueueManager;
1637
- logger: Logger;
1638
- }): Promise<void> {
1639
- const { db, queueManager, logger } = props;
1640
-
1641
- // Get all enabled health checks
1642
- const enabledChecks = await db
1643
- .select({
1644
- systemId: systemHealthChecks.systemId,
1645
- configId: healthCheckConfigurations.id,
1646
- interval: healthCheckConfigurations.intervalSeconds,
1647
- })
1648
- .from(systemHealthChecks)
1649
- .innerJoin(
1650
- healthCheckConfigurations,
1651
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1652
- )
1653
- .where(eq(systemHealthChecks.enabled, true));
1654
-
1655
- // Get latest run timestamp for each system+config pair
1656
- // Using Drizzle's max() function for proper timestamp handling (no raw SQL)
1657
- const latestRuns = await db
1658
- .select({
1659
- systemId: healthCheckRuns.systemId,
1660
- configurationId: healthCheckRuns.configurationId,
1661
- maxTimestamp: max(healthCheckRuns.timestamp),
1662
- })
1663
- .from(healthCheckRuns)
1664
- .groupBy(healthCheckRuns.systemId, healthCheckRuns.configurationId);
1665
-
1666
- // Create a lookup map for fast access
1667
- const lastRunMap = new Map<string, Date>();
1668
- for (const run of latestRuns) {
1669
- if (run.maxTimestamp) {
1670
- const key = `${run.systemId}:${run.configurationId}`;
1671
- lastRunMap.set(key, run.maxTimestamp);
1672
- }
1673
- }
1674
-
1675
- logger.debug(`Bootstrapping ${enabledChecks.length} health checks`);
1676
-
1677
- for (const check of enabledChecks) {
1678
- // Look up the last run from the map
1679
- const lastRunKey = `${check.systemId}:${check.configId}`;
1680
- const lastRun = lastRunMap.get(lastRunKey);
1681
-
1682
- // Calculate delay for first run based on time since last run
1683
- let startDelay = 0;
1684
- if (lastRun) {
1685
- const elapsedSeconds = Math.floor(
1686
- (Date.now() - lastRun.getTime()) / 1000,
1687
- );
1688
- if (elapsedSeconds < check.interval) {
1689
- // Not overdue yet - schedule with remaining time
1690
- startDelay = check.interval - elapsedSeconds;
1691
- }
1692
- // Otherwise it's overdue - run immediately (startDelay = 0)
1693
- logger.debug(
1694
- `Health check ${check.configId}:${
1695
- check.systemId
1696
- } - lastRun: ${lastRun.toISOString()}, elapsed: ${elapsedSeconds}s, interval: ${
1697
- check.interval
1698
- }s, startDelay: ${startDelay}s`,
1699
- );
1700
- } else {
1701
- logger.debug(
1702
- `Health check ${check.configId}:${check.systemId} - no lastRun found, running immediately`,
1703
- );
1704
- }
1705
-
1706
- await scheduleHealthCheck({
1707
- queueManager,
1708
- payload: {
1709
- configId: check.configId,
1710
- systemId: check.systemId,
1711
- },
1712
- intervalSeconds: check.interval,
1713
- startDelay,
1714
- logger,
1715
- });
1716
- }
1717
-
1718
- logger.debug(`โœ… Bootstrapped ${enabledChecks.length} health checks`);
1719
-
1720
- // Clean up orphaned jobs
1721
- const queue =
1722
- queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
1723
- const allRecurringJobs = await queue.listRecurringJobs();
1724
- const expectedJobIds = new Set(
1725
- enabledChecks.map(
1726
- (check) => `healthcheck:${check.configId}:${check.systemId}`,
1727
- ),
1728
- );
1729
-
1730
- const orphanedJobs = allRecurringJobs.filter(
1731
- (jobId) => jobId.startsWith("healthcheck:") && !expectedJobIds.has(jobId),
1732
- );
1733
-
1734
- for (const jobId of orphanedJobs) {
1735
- await queue.cancelRecurring(jobId);
1736
- logger.debug(`Removed orphaned job scheduler: ${jobId}`);
1737
- }
1738
-
1739
- if (orphanedJobs.length > 0) {
1740
- logger.info(
1741
- `๐Ÿงน Cleaned up ${orphanedJobs.length} orphaned health check jobs`,
1742
- );
1743
- }
1744
- }