@checkstack/healthcheck-backend 1.18.0 → 1.20.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 (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. package/src/status-page/widgets.ts +236 -39
package/src/index.ts CHANGED
@@ -230,6 +230,10 @@ export default createBackendPlugin({
230
230
  throw new Error("Catalog client not initialized");
231
231
  return gitopsCatalogClient;
232
232
  },
233
+ // Lazy: the cache is created in init() (after this register() call) and
234
+ // reconcile only runs post-init, so `healthCheckCache` is populated by the
235
+ // time GitOps mutations fire.
236
+ getCache: () => healthCheckCache,
233
237
  });
234
238
 
235
239
  env.registerInit({
@@ -461,18 +465,34 @@ export default createBackendPlugin({
461
465
  execute: deferredProjectionExecute,
462
466
  });
463
467
 
468
+ // Per-entity status cache shared between the router, queue executor,
469
+ // AI signals contributor, and afterPluginsReady cleanup hooks. It is the
470
+ // single sanctioned reader (compute-on-read via `service`) AND
471
+ // invalidator of a system's derived health status. It runs on the
472
+ // platform CacheManager, so cross-pod coherence comes from the SHARED
473
+ // backend (a distributed provider such as Redis makes an eviction
474
+ // visible to every pod); no application-level broadcast is needed.
475
+ const cache = createHealthCheckCache({
476
+ cacheManager,
477
+ logger,
478
+ service,
479
+ });
480
+ healthCheckCache = cache;
481
+
464
482
  // Contribute this plugin's per-system health problems to the AI
465
483
  // `system.issues` aggregator. PER-SOURCE access is OUR job: gate on the
466
484
  // principal's `healthcheck.status` grant and return {} (never throw)
467
- // when not satisfied. The read derives from the durable
468
- // `health_check_runs` / `system_health_checks` tables (global, identical
469
- // on every pod) and reuses the SAME pure deriver as the dashboard
470
- // filler, so backend signals match the UI's source/tone/label/detail.
485
+ // when not satisfied. The candidate set derives from the durable
486
+ // `system_health_checks` table (global, identical on every pod); the
487
+ // per-system status reads go through the SHARED CACHE (reusing the warm
488
+ // badge/dashboard entries) and reuse the SAME pure deriver as the
489
+ // dashboard filler, so backend signals match the UI.
471
490
  env
472
491
  .getExtensionPoint(systemSignalsExtensionPoint)
473
492
  .contribute(
474
493
  createHealthcheckSignalsContributor({
475
- service,
494
+ candidateSource: service,
495
+ cache,
476
496
  resolver: createSystemAccessResolver(rpcClient),
477
497
  }),
478
498
  );
@@ -492,13 +512,6 @@ export default createBackendPlugin({
492
512
  // Create gitops client for provenance lock checks
493
513
  const gitOpsClient = rpcClient.forPlugin(GitOpsApi);
494
514
 
495
- // Per-entity status cache shared between the router, queue executor,
496
- // and afterPluginsReady cleanup hooks. Mutations / new check results
497
- // invalidate by systemId BEFORE emitting signals so frontend
498
- // refetches see fresh data.
499
- const cache = createHealthCheckCache({ cacheManager, logger });
500
- healthCheckCache = cache;
501
-
502
515
  // Setup queue-based health check worker
503
516
  await setupHealthCheckWorker({
504
517
  notificationClient,
@@ -630,6 +643,12 @@ export default createBackendPlugin({
630
643
  }) => {
631
644
  // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
632
645
  storedEmitHook = emitHook;
646
+
647
+ // No cross-pod status-cache broadcast: the status cache runs on the
648
+ // platform CacheManager, so a distributed backend (Redis) makes every
649
+ // eviction visible to all pods through the shared store. The prior
650
+ // per-pod-cache + broadcast layer was removed in favour of that.
651
+
633
652
  // Converge the per-environment recurring job set at boot (schedule
634
653
  // desired (config, system, env) jobs, cancel orphans incl. old-format
635
654
  // ones). The periodic reconcile below keeps it converged as catalog
@@ -5,16 +5,11 @@ import {
5
5
  recomputeSystemRollupHealth,
6
6
  type HealthCheckJobPayload,
7
7
  } from "./queue-executor";
8
- import type { HealthCheckCache } from "./cache";
8
+ import { createStubHealthCheckCache } from "./cache-test-stub";
9
9
  import { SuspectLane } from "./suspect-lane";
10
10
  import type { SlowCheckRuntime } from "./slow-check-config";
11
11
 
12
- const passthroughCache: HealthCheckCache = {
13
- wrapSystemHealthStatus: (_systemId, loader) => loader(),
14
- invalidateSystem: async () => {},
15
- invalidateAllSystems: async () => 0,
16
- scope: {} as HealthCheckCache["scope"],
17
- };
12
+ const passthroughCache = createStubHealthCheckCache();
18
13
 
19
14
  // Pass-through advisory lock: these tests don't exercise cross-pod
20
15
  // serialization, so run the critical section directly.
@@ -248,16 +243,7 @@ describe("Queue-Based Health Check Executor", () => {
248
243
  (mockDb.select as any) = mock(() => {
249
244
  selectCallCount++;
250
245
  if (selectCallCount === 1) {
251
- // First call: get previous system health status
252
- return {
253
- from: mock(() => ({
254
- innerJoin: mock(() => ({
255
- where: mock(() => Promise.resolve([])),
256
- })),
257
- })),
258
- };
259
- } else if (selectCallCount === 2) {
260
- // Second call: fetch configuration (return paused config)
246
+ // First call: fetch configuration (return paused config)
261
247
  return {
262
248
  from: mock(() => ({
263
249
  innerJoin: mock(() => ({
@@ -371,7 +357,7 @@ describe("Queue-Based Health Check Executor", () => {
371
357
  let selectCallCount = 0;
372
358
  (mockDb.select as any) = mock(() => {
373
359
  selectCallCount++;
374
- if (selectCallCount === 2) {
360
+ if (selectCallCount === 1) {
375
361
  return {
376
362
  from: mock(() => ({
377
363
  innerJoin: mock(() => ({
@@ -592,7 +578,7 @@ describe("Queue-Based Health Check Executor", () => {
592
578
  let selectCallCount = 0;
593
579
  (mockDb.select as any) = mock(() => {
594
580
  selectCallCount++;
595
- if (selectCallCount === 2) {
581
+ if (selectCallCount === 1) {
596
582
  return {
597
583
  from: mock(() => ({
598
584
  innerJoin: mock(() => ({
@@ -762,7 +748,7 @@ describe("Queue-Based Health Check Executor", () => {
762
748
  let selectCallCount = 0;
763
749
  (mockDb.select as any) = mock((...args: unknown[]) => {
764
750
  selectCallCount++;
765
- if (selectCallCount === 2) {
751
+ if (selectCallCount === 1) {
766
752
  return {
767
753
  from: mock(() => ({
768
754
  innerJoin: mock(() => ({
@@ -1129,7 +1115,7 @@ describe("Queue-Based Health Check Executor", () => {
1129
1115
  let selectCallCount = 0;
1130
1116
  (mockDb.select as any) = mock((...args: unknown[]) => {
1131
1117
  selectCallCount++;
1132
- if (selectCallCount === 2) {
1118
+ if (selectCallCount === 1) {
1133
1119
  return {
1134
1120
  from: mock(() => ({
1135
1121
  innerJoin: mock(() => ({
@@ -1323,7 +1309,7 @@ describe("executeHealthCheckJob - structured assertion outcomes", () => {
1323
1309
  let selectCallCount = 0;
1324
1310
  (mockDb.select as any) = mock(() => {
1325
1311
  selectCallCount++;
1326
- if (selectCallCount === 2) {
1312
+ if (selectCallCount === 1) {
1327
1313
  return {
1328
1314
  from: mock(() => ({
1329
1315
  innerJoin: mock(() => ({
@@ -1534,13 +1520,14 @@ describe("executeHealthCheckJob - slow-check bulkhead wiring", () => {
1534
1520
  (mockCatalogClient.getSystem as any) = mock(async () => ({ id: "system-1", name: "web-01" }));
1535
1521
  (mockCatalogClient as any).resolveSystemEnvironments = mock(async () => []);
1536
1522
 
1537
- // Select call order: #1 getSystemHealthStatus (rollup prev), #2 config,
1538
- // #3 fetchRecentRunsForSlice. Everything else falls through to default.
1523
+ // Select call order: #1 config, #2 fetchRecentRunsForSlice. Everything else
1524
+ // falls through to default. (The eager rollup-prev read that used to be #1
1525
+ // is now computed lazily only on the catastrophic-failure path.)
1539
1526
  const defaultSelect = mockDb.select;
1540
1527
  let selectCallCount = 0;
1541
1528
  (mockDb.select as any) = mock((...args: unknown[]) => {
1542
1529
  selectCallCount++;
1543
- if (selectCallCount === 2) {
1530
+ if (selectCallCount === 1) {
1544
1531
  return {
1545
1532
  from: mock(() => ({
1546
1533
  innerJoin: mock(() => ({
@@ -1567,7 +1554,7 @@ describe("executeHealthCheckJob - slow-check bulkhead wiring", () => {
1567
1554
  })),
1568
1555
  };
1569
1556
  }
1570
- if (selectCallCount === 3) {
1557
+ if (selectCallCount === 2) {
1571
1558
  return {
1572
1559
  from: mock(() => ({
1573
1560
  where: mock(() => ({
@@ -35,20 +35,17 @@ import {
35
35
  } from "@checkstack/healthcheck-common";
36
36
  import {
37
37
  CatalogApi,
38
- catalogRoutes,
39
- createSystemSubject,
40
38
  type Environment,
41
39
  } from "@checkstack/catalog-common";
42
40
  import {
43
41
  resolveEffectiveEnvironments,
44
42
  type EffectiveEnvironment,
45
43
  } from "./effective-environments";
46
- import { systemHealthCollapseKey } from "@checkstack/healthcheck-common";
44
+ import { buildHealthTransitionNotification } from "./health-notification-content";
47
45
  import { MaintenanceApi } from "@checkstack/maintenance-common";
48
46
  import { IncidentApi } from "@checkstack/incident-common";
49
47
  import { NotificationApi } from "@checkstack/notification-common";
50
- import { healthcheckSystemSubscription } from "@checkstack/healthcheck-common";
51
- import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
48
+ import { type InferClient, extractErrorMessage} from "@checkstack/common";
52
49
  import { secretEnvMappingSchema } from "@checkstack/secrets-common";
53
50
  import type {
54
51
  SecretResolverService,
@@ -382,18 +379,20 @@ export async function recomputeSystemRollupHealth(args: {
382
379
  // pure entity-recompute path (pause/resume) skips the extra prev read.
383
380
  const wantsSignal = signalService !== undefined || cache !== undefined;
384
381
  try {
385
- let previousStatus: HealthCheckStatus | undefined;
382
+ // Capture the FULL rollup states (not just the status enum) so the cache
383
+ // reconcile can gate on the per-check vector while the frontend signal
384
+ // stays gated on the coarser rollup-enum transition.
385
+ let previousState: AggregatedHealth | undefined;
386
386
  if (wantsSignal) {
387
- const previousState = await service.getSystemHealthStatus(systemId);
388
- previousStatus = previousState.status;
387
+ previousState = await service.getSystemHealthStatus(systemId);
389
388
  }
390
- let newStatus: HealthCheckStatus | undefined = previousStatus;
389
+ let newState: AggregatedHealth | undefined = previousState;
391
390
  await writeHealthEntity({
392
391
  handle: getHealthEntity?.(),
393
392
  entityId: rollupEntityId,
394
393
  apply: async () => {
395
394
  const rollupState = await service.getSystemHealthStatus(systemId);
396
- newStatus = rollupState.status;
395
+ newState = rollupState;
397
396
  return toHealthEntityView(rollupState);
398
397
  },
399
398
  serialize: makeHealthSerializer(rollupEntityId),
@@ -404,21 +403,23 @@ export async function recomputeSystemRollupHealth(args: {
404
403
  ),
405
404
  });
406
405
 
407
- if (
408
- wantsSignal &&
409
- previousStatus !== undefined &&
410
- newStatus !== undefined &&
411
- newStatus !== previousStatus
412
- ) {
413
- await cache?.invalidateSystem(systemId);
414
- await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
415
- systemId,
416
- previousStatus,
417
- newStatus,
418
- });
406
+ if (wantsSignal && previousState !== undefined && newState !== undefined) {
407
+ // Cache: evict the rollup key + broadcast to the cluster on ANY per-check
408
+ // vector change — a check that flips while the rollup enum stays put still
409
+ // changes the rollup's `checkStatuses`, and a reader gets that vector.
410
+ await cache?.reconcile({ systemId, previous: previousState, next: newState });
411
+ // Frontend signal: only a rollup-enum transition moves the badge, so a
412
+ // per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
413
+ if (newState.status !== previousState.status) {
414
+ await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
415
+ systemId,
416
+ previousStatus: previousState.status,
417
+ newStatus: newState.status,
418
+ });
419
+ }
419
420
  }
420
- return previousStatus !== undefined && newStatus !== undefined
421
- ? { previousStatus, newStatus }
421
+ return previousState !== undefined && newState !== undefined
422
+ ? { previousStatus: previousState.status, newStatus: newState.status }
422
423
  : undefined;
423
424
  } catch (error) {
424
425
  // A recompute failure must never break the pause/resume RPC. The
@@ -465,6 +466,13 @@ async function notifyStateChange(props: {
465
466
  systemId: string;
466
467
  systemName: string;
467
468
  configurationId: string;
469
+ /**
470
+ * Human-readable name of the health check whose run drove this transition.
471
+ * Named in the body and surfaced as a `healthcheck.healthcheck` subject so
472
+ * subscribers see WHICH check failed, not just which system. Best-effort:
473
+ * falls back to the `configurationId` when the name could not be resolved.
474
+ */
475
+ configurationName?: string;
468
476
  previousStatus: HealthCheckStatus;
469
477
  newStatus: HealthCheckStatus;
470
478
  /**
@@ -493,6 +501,7 @@ async function notifyStateChange(props: {
493
501
  systemId,
494
502
  systemName,
495
503
  configurationId,
504
+ configurationName,
496
505
  previousStatus,
497
506
  newStatus,
498
507
  environmentId,
@@ -505,8 +514,9 @@ async function notifyStateChange(props: {
505
514
  logger,
506
515
  } = props;
507
516
 
508
- const envScoped = typeof environmentId === "string";
509
- const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
517
+ // The check that just ran is the one driving this aggregate transition, so
518
+ // its name is the authoritative check to blame. Fall back to the id.
519
+ const checkName = configurationName ?? configurationId;
510
520
 
511
521
  const transition = classifyTransition(previousStatus, newStatus);
512
522
  if (transition === "none") {
@@ -572,70 +582,24 @@ async function notifyStateChange(props: {
572
582
  );
573
583
  }
574
584
 
575
- let title: string;
576
- let body: string;
577
- let importance: "info" | "warning" | "critical";
578
-
579
- if (transition === "recovery") {
580
- title = `System health restored${envSuffix}: ${systemName}`;
581
- body = envScoped
582
- ? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
583
- : `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
584
- importance = "info";
585
- } else if (newStatus === "unhealthy") {
586
- title = `System health critical${envSuffix}: ${systemName}`;
587
- body = envScoped
588
- ? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
589
- : `Health checks indicate **${systemName}** is unhealthy and may be down.`;
590
- importance = "critical";
591
- } else {
592
- // degraded — either an escalation from healthy or a partial recovery
593
- title = `System health degraded${envSuffix}: ${systemName}`;
594
- body = envScoped
595
- ? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
596
- : `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
597
- importance = "warning";
598
- }
599
-
600
- const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
601
- systemId,
602
- });
603
- // Recovery lands on the default (all) view; failing transitions deep-link
604
- // operators into the failing-checks filter so they can debug immediately.
605
- const actionUrl =
606
- transition === "recovery"
607
- ? systemDetailPath
608
- : `${systemDetailPath}?filter=failing`;
609
- const actionLabel =
610
- transition === "recovery" ? "View System" : "View failing checks";
611
-
612
585
  void catalogClient; // parents are resolved server-side via stored target edges
613
586
 
614
587
  try {
615
- await notificationClient.notifyForSubscription({
616
- specId: healthcheckSystemSubscription.specId,
617
- resourceKeys: [systemId],
618
- title,
619
- body,
620
- importance,
621
- action: { label: actionLabel, url: actionUrl },
622
- // Env-qualified collapse key so two failing envs of one system generate
623
- // two independent notification cards (one per env) instead of merging
624
- // -> operators see all env outages. The system-rollup transition
625
- // (`environmentId === null`/undefined) keys on the bare systemId and
626
- // therefore reuses the pre-existing single-card identity.
627
- collapseKey: envScoped
628
- ? systemHealthCollapseKey(systemId, environmentId)
629
- : systemHealthCollapseKey(systemId),
630
- subjects: [
631
- createSystemSubject({
632
- id: systemId,
633
- name: systemName,
634
- url: systemDetailPath,
635
- status: newStatus,
636
- }),
637
- ],
638
- });
588
+ // Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
589
+ // helper so the wording - which now NAMES the failing check and pushes a
590
+ // `healthcheck.healthcheck` subject - can be verified without the executor.
591
+ await notificationClient.notifyForSubscription(
592
+ buildHealthTransitionNotification({
593
+ transition,
594
+ systemId,
595
+ systemName,
596
+ configurationId,
597
+ checkName,
598
+ newStatus,
599
+ environmentId,
600
+ environmentName,
601
+ }),
602
+ );
639
603
  logger.debug(
640
604
  `Notified subscribers: ${previousStatus} → ${newStatus} for system ${systemId}`,
641
605
  );
@@ -732,13 +696,14 @@ async function executeHealthCheckJob(props: {
732
696
  // other.
733
697
  const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
734
698
 
735
- // The system-rollup status BEFORE this tick (all environments + env-less).
736
- // Captured once so the post-loop rollup write (§7.4.3) — and the
737
- // catastrophic-failure path — can record a correct prev → next rollup
738
- // transition (environmentId = null). This is the system-wide aggregate read
739
- // the executor has always taken first.
740
- const rollupPreviousState = await service.getSystemHealthStatus(systemId);
741
- const rollupPreviousStatus = rollupPreviousState.status;
699
+ // NOTE: the system-rollup status BEFORE this tick is computed LAZILY, only on
700
+ // the catastrophic-failure path that actually consumes it (see the `catch`
701
+ // below). It used to be captured here on EVERY run - a full worst-wins rollup
702
+ // (`getSystemHealthStatus(systemId)`, an N+1 across every check × environment)
703
+ // - even though the normal success/failure paths record their transition from
704
+ // the per-env pre-read (`previousState`, below) and never touch the rollup
705
+ // pre-state. Deferring it to the rare error path removes that whole recompute
706
+ // from the hot path of every check tick.
742
707
 
743
708
  // Slow-check lane admission (set when this run was admitted to the suspect
744
709
  // lane); released in the outer finally so the slot frees on any exit path.
@@ -994,14 +959,16 @@ async function executeHealthCheckJob(props: {
994
959
  const envEntityId = encodeHealthEntityId({ systemId, environmentId });
995
960
  const serializeEnvWrite = makeHealthSerializer(envEntityId);
996
961
 
997
- // Per-env baseline status for the transition log: the env-scoped
998
- // aggregate BEFORE this run. Computed per env so a transition row is
999
- // recorded against the right (system, environment) streak.
1000
- const previousState = await service.getSystemHealthStatus(
1001
- systemId,
1002
- environmentId,
1003
- );
1004
- const previousStatus = previousState.status;
962
+ // Per-env baseline: the env-scoped aggregate BEFORE this run. Read INSIDE
963
+ // the serialized `apply` below (assigned to these vars), NOT here — so a
964
+ // concurrent same-slice run cannot commit between the baseline read and
965
+ // our own insert. If it were read here (outside the `health:<envEntityId>`
966
+ // lock), the cache change-gate could compare `next` against a baseline a
967
+ // sibling run already superseded and miss a real transition, stranding a
968
+ // stale cached status until the TTL. Assigned by whichever branch's
969
+ // `apply` runs; used for the transition log AND the cache reconcile.
970
+ let previousState!: AggregatedHealth;
971
+ let previousStatus!: HealthCheckStatus;
1005
972
 
1006
973
  // Curated, read-only run-context metadata exposed to collectors.
1007
974
  // Metadata only - never secrets or config. `environment` carries the
@@ -1309,6 +1276,13 @@ async function executeHealthCheckJob(props: {
1309
1276
  handle: getHealthEntity?.(),
1310
1277
  entityId: envEntityId,
1311
1278
  apply: async () => {
1279
+ // In-lock pre-run baseline (see the `previousState` declaration): read
1280
+ // here, inside the serialized critical section, before the insert.
1281
+ previousState = await service.getSystemHealthStatus(
1282
+ systemId,
1283
+ environmentId,
1284
+ );
1285
+ previousStatus = previousState.status;
1312
1286
  // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1313
1287
  // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1314
1288
  // which also makes the run and its aggregate commit atomically.
@@ -1356,9 +1330,18 @@ async function executeHealthCheckJob(props: {
1356
1330
  `Health check ${configId} for system ${systemId} failed: ${finalError}`,
1357
1331
  );
1358
1332
 
1359
- // Invalidate the per-system status cache before broadcasting so any
1360
- // frontend that refetches in response to the signal gets fresh data.
1361
- await cache.invalidateSystem(systemId);
1333
+ // Reconcile this environment's cached status: evict + broadcast to the
1334
+ // cluster ONLY when the per-check vector actually changed (a run that
1335
+ // leaves every check's status unchanged keeps the cache warm instead of
1336
+ // thrashing it every tick). The rollup key is reconciled separately by
1337
+ // the debounced rollup consumer (recomputeSystemRollupHealth), also
1338
+ // vector-gated.
1339
+ await cache.reconcile({
1340
+ systemId,
1341
+ environmentId,
1342
+ previous: previousState,
1343
+ next: newState,
1344
+ });
1362
1345
 
1363
1346
  await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
1364
1347
  systemId,
@@ -1390,6 +1373,7 @@ async function executeHealthCheckJob(props: {
1390
1373
  systemId,
1391
1374
  systemName,
1392
1375
  configurationId: configId,
1376
+ configurationName: configRow.configName,
1393
1377
  previousStatus,
1394
1378
  newStatus: newState.status,
1395
1379
  environmentId,
@@ -1466,6 +1450,13 @@ async function executeHealthCheckJob(props: {
1466
1450
  handle: getHealthEntity?.(),
1467
1451
  entityId: envEntityId,
1468
1452
  apply: async () => {
1453
+ // In-lock pre-run baseline (see the `previousState` declaration): read
1454
+ // here, inside the serialized critical section, before the insert.
1455
+ previousState = await service.getSystemHealthStatus(
1456
+ systemId,
1457
+ environmentId,
1458
+ );
1459
+ previousStatus = previousState.status;
1469
1460
  // §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1470
1461
  // `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
1471
1462
  // which also makes the run and its aggregate commit atomically.
@@ -1512,9 +1503,16 @@ async function executeHealthCheckJob(props: {
1512
1503
  `Ran health check ${configId} for system ${systemId}: ${result.status}`,
1513
1504
  );
1514
1505
 
1515
- // Invalidate the per-system status cache before broadcasting so any
1516
- // frontend that refetches in response to the signal gets fresh data.
1517
- await cache.invalidateSystem(systemId);
1506
+ // Reconcile this environment's cached status: evict + broadcast to the
1507
+ // cluster ONLY when the per-check vector actually changed (a steady-state
1508
+ // healthy run keeps the cache warm). The rollup key is reconciled by the
1509
+ // debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
1510
+ await cache.reconcile({
1511
+ systemId,
1512
+ environmentId,
1513
+ previous: previousState,
1514
+ next: newState,
1515
+ });
1518
1516
 
1519
1517
  // Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
1520
1518
  await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
@@ -1557,6 +1555,7 @@ async function executeHealthCheckJob(props: {
1557
1555
  systemId,
1558
1556
  systemName,
1559
1557
  configurationId: configId,
1558
+ configurationName: configRow.configName,
1560
1559
  previousStatus,
1561
1560
  newStatus: newState.status,
1562
1561
  environmentId,
@@ -1620,12 +1619,19 @@ async function executeHealthCheckJob(props: {
1620
1619
  // the system-level health change still emits. Reuses the pre-tick
1621
1620
  // rollup status captured before the try block.
1622
1621
  const rollupEntityId = encodeHealthEntityId({ systemId });
1623
- const previousStatus = rollupPreviousStatus;
1622
+ // The pre-failure rollup baseline. Read INSIDE `apply` (inside the
1623
+ // `health:<systemId>` lock), before the failure-run insert, so a concurrent
1624
+ // catastrophic tick for the same system can't commit between the baseline
1625
+ // read and this insert and make the cache change-gate miss a transition.
1626
+ let rollupPreState!: AggregatedHealth;
1627
+ let previousStatus!: HealthCheckStatus;
1624
1628
  let newState!: AggregatedHealth;
1625
1629
  await writeHealthEntity({
1626
1630
  handle: getHealthEntity?.(),
1627
1631
  entityId: rollupEntityId,
1628
1632
  apply: async () => {
1633
+ rollupPreState = await service.getSystemHealthStatus(systemId);
1634
+ previousStatus = rollupPreState.status;
1629
1635
  // §perf: batch the failure run INSERT + aggregate SELECT/UPSERT under
1630
1636
  // ONE `SET LOCAL search_path` transaction (3 scoped-db transactions →
1631
1637
  // 1), which also makes them commit atomically.
@@ -1684,9 +1690,15 @@ async function executeHealthCheckJob(props: {
1684
1690
  // Use IDs as fallback
1685
1691
  }
1686
1692
 
1687
- // Invalidate the per-system status cache before broadcasting so any
1688
- // frontend that refetches in response to the signal gets fresh data.
1689
- await cache.invalidateSystem(systemId);
1693
+ // Reconcile the rollup cache: evict + broadcast only on a real vector
1694
+ // change. This catastrophic path writes the bare `<systemId>` entity (it IS
1695
+ // the rollup), so it owns the rollup key directly — no debounced consumer
1696
+ // runs for it.
1697
+ await cache.reconcile({
1698
+ systemId,
1699
+ previous: rollupPreState,
1700
+ next: newState,
1701
+ });
1690
1702
 
1691
1703
  // Broadcast enriched failure signal for realtime frontend updates
1692
1704
  await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
@@ -1723,6 +1735,7 @@ async function executeHealthCheckJob(props: {
1723
1735
  systemId,
1724
1736
  systemName,
1725
1737
  configurationId: configId,
1738
+ configurationName: configName,
1726
1739
  previousStatus,
1727
1740
  newStatus: newState.status,
1728
1741
  service,
@@ -153,6 +153,14 @@ async function deleteExpiredRawRuns(params: DeleteExpiredRawRunsParams) {
153
153
  const cutoffDate = new Date();
154
154
  cutoffDate.setDate(cutoffDate.getDate() - rawRetentionDays);
155
155
 
156
+ // Status-cache note: this delete does NOT invalidate the system-health status
157
+ // cache, and deliberately need not. `evaluateHealthStatus` derives status from
158
+ // the most-recent-N runs (count-based, no time component), and the cutoff is
159
+ // days old, so for any ACTIVELY-running check the deleted rows are already
160
+ // outside the evaluation window — removing them cannot change the current
161
+ // derived status. The only case it could is a check that STOPPED running long
162
+ // enough for its entire history to age past the cutoff; that flip is bounded by
163
+ // the 15s status-cache TTL (an acceptable, rare edge for a stopped check).
156
164
  await db
157
165
  .delete(healthCheckRuns)
158
166
  .where(
@@ -35,7 +35,7 @@ interface Harness {
35
35
  changeHandler: (change: EntityChanged) => Promise<void>;
36
36
  getSystemHealthStatus: ReturnType<typeof mock>;
37
37
  broadcast: ReturnType<typeof mock>;
38
- invalidateSystem: ReturnType<typeof mock>;
38
+ reconcile: ReturnType<typeof mock>;
39
39
  }
40
40
 
41
41
  async function setup(opts: {
@@ -75,7 +75,7 @@ async function setup(opts: {
75
75
  }) as unknown as OnEntityChanged;
76
76
 
77
77
  const broadcast = mock(async () => {});
78
- const invalidateSystem = mock(async () => {});
78
+ const reconcile = mock(async () => {});
79
79
 
80
80
  await setupRollupConsumer({
81
81
  queueManager,
@@ -85,7 +85,7 @@ async function setup(opts: {
85
85
  withXactLock: async ({ fn }: { fn: () => Promise<unknown> }) => fn(),
86
86
  } as never,
87
87
  signalService: { broadcast } as never,
88
- cache: { invalidateSystem } as never,
88
+ cache: { reconcile } as never,
89
89
  // No entity handle: writeHealthEntity runs `apply` directly.
90
90
  getHealthEntity: () => undefined,
91
91
  logger: {
@@ -103,7 +103,7 @@ async function setup(opts: {
103
103
  changeHandler,
104
104
  getSystemHealthStatus,
105
105
  broadcast,
106
- invalidateSystem,
106
+ reconcile,
107
107
  };
108
108
  }
109
109
 
@@ -162,12 +162,21 @@ describe("setupRollupConsumer subscription", () => {
162
162
  });
163
163
 
164
164
  describe("setupRollupConsumer rollup recompute", () => {
165
- it("broadcasts SYSTEM_STATUS_CHANGED + invalidates cache on a rollup status change", async () => {
165
+ it("reconciles the cache + broadcasts SYSTEM_STATUS_CHANGED on a rollup status change", async () => {
166
166
  const h = await setup({ statuses: ["healthy", "unhealthy"] });
167
167
  await h.consumeHandler({ data: { systemId: "s1" } });
168
168
 
169
169
  expect(h.getSystemHealthStatus).toHaveBeenCalled();
170
- expect(h.invalidateSystem).toHaveBeenCalledWith("s1");
170
+ // Reconcile is handed the FULL prev/next states; the change-gating (evict +
171
+ // cluster broadcast only on a per-check vector change) lives inside the cache
172
+ // (see cache.test.ts), so recompute always calls it with both states.
173
+ expect(h.reconcile).toHaveBeenCalledTimes(1);
174
+ expect(h.reconcile.mock.calls[0]![0]).toMatchObject({
175
+ systemId: "s1",
176
+ previous: { status: "healthy" },
177
+ next: { status: "unhealthy" },
178
+ });
179
+ // The frontend signal IS gated here on the rollup-enum transition.
171
180
  expect(h.broadcast).toHaveBeenCalledTimes(1);
172
181
  const payload = h.broadcast.mock.calls[0]![1] as {
173
182
  systemId: string;
@@ -181,11 +190,13 @@ describe("setupRollupConsumer rollup recompute", () => {
181
190
  });
182
191
  });
183
192
 
184
- it("does NOT broadcast when the rollup status is unchanged", async () => {
193
+ it("does NOT broadcast the frontend signal when the rollup status is unchanged", async () => {
185
194
  const h = await setup({ statuses: ["degraded", "degraded"] });
186
195
  await h.consumeHandler({ data: { systemId: "s1" } });
187
196
 
197
+ // No enum transition ⇒ no SYSTEM_STATUS_CHANGED frontend signal. Reconcile is
198
+ // still invoked (its own fingerprint gate no-ops for an unchanged vector).
188
199
  expect(h.broadcast).not.toHaveBeenCalled();
189
- expect(h.invalidateSystem).not.toHaveBeenCalled();
200
+ expect(h.reconcile).toHaveBeenCalledTimes(1);
190
201
  });
191
202
  });