@checkstack/healthcheck-backend 1.19.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.
package/src/cache.ts CHANGED
@@ -1,57 +1,145 @@
1
1
  import type { CacheManager } from "@checkstack/cache-api";
2
- import {
3
- createCachedScope,
4
- type CachedScope,
5
- } from "@checkstack/cache-utils";
2
+ import { createCachedScope, type CachedScope } from "@checkstack/cache-utils";
6
3
  import type { Logger } from "@checkstack/backend-api";
7
- import type { HealthCheckService } from "./service";
4
+ import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
5
+ import { statusVectorChanged } from "./status-fingerprint";
8
6
 
9
7
  /**
10
- * TTL chosen to be slightly shorter than the dashboard's 30s `staleTime` so
11
- * that signal-driven invalidation almost always wins, and TTL only acts as
12
- * a safety net for paths that forget to invalidate.
8
+ * TTL for a cached status entry. With a distributed backend (Redis) configured,
9
+ * the TTL is only a natural refresh / safety net — cross-pod coherence comes
10
+ * from the SHARED store (a `delete` on one pod is visible to all), not from the
11
+ * TTL. On the default in-memory backend the entry is per-pod; that backend is
12
+ * for single-instance deployments (see the caching-architecture docs).
13
13
  */
14
14
  const STATUS_TTL_MS = 15_000;
15
15
 
16
+ const STATUS_KEY_PREFIX = "status:";
17
+
18
+ /**
19
+ * Per-(system, environment) cache key.
20
+ *
21
+ * `environmentId` collapses `undefined` (the system ROLLUP, all runs) and `null`
22
+ * (the env-less slice) to the SAME bare key `status:<systemId>`, because an
23
+ * env-less run IS the rollup (it mutates the bare `<systemId>` entity). A real
24
+ * environment id gets its own `status:<systemId>:<environmentId>` key. This
25
+ * matches the (systemId, environmentId) tuple both the reader
26
+ * (`getSystemHealthStatus`) and the executor's per-env write use.
27
+ */
28
+ function statusKey(systemId: string, environmentId?: string | null): string {
29
+ return environmentId === undefined || environmentId === null
30
+ ? `${STATUS_KEY_PREFIX}${systemId}`
31
+ : `${STATUS_KEY_PREFIX}${systemId}:${environmentId}`;
32
+ }
33
+
34
+ /** Prefix covering a single system's rollup key AND all its per-env keys. */
35
+ function systemPrefix(systemId: string): string {
36
+ return `${STATUS_KEY_PREFIX}${systemId}`;
37
+ }
38
+
16
39
  /**
17
- * Per-entity cache helpers for the healthcheck plugin. Wrapping reads
18
- * goes through {@link wrapSystemHealthStatus}; mutations should call
19
- * {@link invalidateSystem} after the DB write but before emitting any
20
- * signal so that frontend refetches see fresh data.
40
+ * Minimal read surface the cache needs. `HealthCheckService` satisfies it
41
+ * structurally; a narrow interface keeps the cache testable with a stub and
42
+ * documents that the cache is the ONLY sanctioned caller of the raw
43
+ * `getSystemHealthStatus` read (enforced by the `no-direct-system-status-read`
44
+ * lint rule everywhere except this module and the executor/entity compute
45
+ * paths).
46
+ */
47
+ export interface HealthStatusReader {
48
+ getSystemHealthStatus(
49
+ systemId: string,
50
+ environmentId?: string | null,
51
+ ): Promise<SystemHealthStatusResponse>;
52
+ /** Distinct environment ids a system currently has runs for (env-less excluded). */
53
+ getSystemEnvironmentIds(systemId: string): Promise<string[]>;
54
+ }
55
+
56
+ /** Per-(system, check, environment) matrix — see {@link HealthCheckCache.readMatrix}. */
57
+ export type SystemHealthMatrix = Record<
58
+ string,
59
+ {
60
+ status: SystemHealthStatusResponse["status"];
61
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
62
+ environments: Record<
63
+ string,
64
+ {
65
+ status: SystemHealthStatusResponse["status"];
66
+ checkStatuses: SystemHealthStatusResponse["checkStatuses"];
67
+ }
68
+ >;
69
+ }
70
+ >;
71
+
72
+ /**
73
+ * The system-health status cache — a platform interface that is the SINGLE
74
+ * sanctioned reader AND invalidator of a system's derived health status.
75
+ *
76
+ * It is built on the platform {@link CacheManager} (via {@link createCachedScope}),
77
+ * so the active backend is a per-deployment choice: the default in-memory
78
+ * backend (per-pod, single-instance) or a distributed backend such as Redis
79
+ * (shared across pods). Cross-pod coherence therefore comes from the SHARED
80
+ * store — an `invalidate` is a `delete` every pod sees — NOT from any
81
+ * application-level broadcast. Horizontal scaling requires a distributed backend
82
+ * (see `docs/.../architecture/caching.md`).
83
+ *
84
+ * Reads (`read` / `readBulk` / `readMatrix`) serve the RAW (pre-incident-override)
85
+ * status; the router folds incident overrides downstream, so an incident change
86
+ * never touches this cache. `reconcile` is the hot-path invalidator: it evicts
87
+ * ONLY when the per-check status vector actually changed ({@link statusVectorChanged}),
88
+ * so a run that merely refreshes timestamps keeps the cache warm.
21
89
  */
22
90
  export interface HealthCheckCache {
23
- /** Read-through cache for one system's health status. */
24
- wrapSystemHealthStatus: (
91
+ /** Read-through cache for one (system, environment) status (RAW, pre-override). */
92
+ read(
25
93
  systemId: string,
26
- loader: () => ReturnType<HealthCheckService["getSystemHealthStatus"]>,
27
- ) => ReturnType<HealthCheckService["getSystemHealthStatus"]>;
94
+ environmentId?: string,
95
+ ): Promise<SystemHealthStatusResponse>;
28
96
 
29
- /** Invalidate a single system's cached status. */
30
- invalidateSystem: (systemId: string) => Promise<void>;
97
+ /** Per-entity read-through cache for many systems' rollup status. */
98
+ readBulk(
99
+ systemIds: string[],
100
+ environmentId?: string,
101
+ ): Promise<Record<string, SystemHealthStatusResponse>>;
31
102
 
32
103
  /**
33
- * Invalidate every system's cached status. Used when the change can
34
- * affect many systems at once (e.g. a configuration update with
35
- * cross-system fan-out, or a plugin reload).
104
+ * Per-(system, check, environment) matrix assembled from cached rollup +
105
+ * per-environment reads. The env set is discovered live per system; each
106
+ * slice read hits the same per-env cache the badge path warms.
36
107
  */
37
- invalidateAllSystems: () => Promise<number>;
108
+ readMatrix(systemIds: string[]): Promise<SystemHealthMatrix>;
38
109
 
39
- /** Underlying scope, exposed for advanced callers. */
40
- scope: CachedScope;
41
- }
110
+ /**
111
+ * Change-gated invalidator for the run hot path. Evicts the (system,
112
+ * environment) key ONLY when the derived status vector changed between
113
+ * `previous` and `next`; a no-op otherwise so a timestamp-only run keeps the
114
+ * cache warm. A per-environment change also evicts the system rollup key
115
+ * (the slice feeds the worst-wins rollup).
116
+ */
117
+ reconcile(args: {
118
+ systemId: string;
119
+ /** `null`/absent = the rollup / env-less key; a string = that environment. */
120
+ environmentId?: string | null;
121
+ previous: SystemHealthStatusResponse;
122
+ next: SystemHealthStatusResponse;
123
+ }): Promise<void>;
42
124
 
43
- const STATUS_KEY_PREFIX = "status:";
44
- const statusKey = (systemId: string): string =>
45
- `${STATUS_KEY_PREFIX}${systemId}`;
125
+ /** Evict a system's rollup + every per-env key. */
126
+ invalidateSystem(systemId: string): Promise<void>;
127
+
128
+ /** Evict every system's status. Returns keys removed. */
129
+ invalidateAllSystems(): Promise<number>;
130
+ }
46
131
 
47
132
  export function createHealthCheckCache({
48
133
  cacheManager,
49
134
  logger,
135
+ service,
50
136
  }: {
51
137
  cacheManager: CacheManager;
52
138
  logger: Logger;
139
+ /** Read source for cache misses. `HealthCheckService` satisfies this. */
140
+ service: HealthStatusReader;
53
141
  }): HealthCheckCache {
54
- const scope = createCachedScope({
142
+ const scope: CachedScope = createCachedScope({
55
143
  cacheManager,
56
144
  pluginId: "healthcheck",
57
145
  defaultTtlMs: STATUS_TTL_MS,
@@ -60,11 +148,93 @@ export function createHealthCheckCache({
60
148
  },
61
149
  });
62
150
 
151
+ const read: HealthCheckCache["read"] = (systemId, environmentId) =>
152
+ scope.wrap(statusKey(systemId, environmentId), () =>
153
+ service.getSystemHealthStatus(systemId, environmentId),
154
+ );
155
+
156
+ const readBulk: HealthCheckCache["readBulk"] = async (
157
+ systemIds,
158
+ environmentId,
159
+ ) => {
160
+ const values = await scope.wrapMany(systemIds, {
161
+ keyFor: (id) => statusKey(id, environmentId),
162
+ loader: (id) => service.getSystemHealthStatus(id, environmentId),
163
+ });
164
+ const out: Record<string, SystemHealthStatusResponse> = {};
165
+ for (const [i, id] of systemIds.entries()) {
166
+ out[id] = values[i]!;
167
+ }
168
+ return out;
169
+ };
170
+
171
+ const readMatrix: HealthCheckCache["readMatrix"] = async (systemIds) => {
172
+ const result: SystemHealthMatrix = {};
173
+ await Promise.all(
174
+ systemIds.map(async (systemId) => {
175
+ const overall = await read(systemId);
176
+ const envIds = await service.getSystemEnvironmentIds(systemId);
177
+ const environments: SystemHealthMatrix[string]["environments"] = {};
178
+ await Promise.all(
179
+ envIds.map(async (environmentId) => {
180
+ const slice = await read(systemId, environmentId);
181
+ environments[environmentId] = {
182
+ status: slice.status,
183
+ checkStatuses: slice.checkStatuses,
184
+ };
185
+ }),
186
+ );
187
+ result[systemId] = {
188
+ status: overall.status,
189
+ checkStatuses: overall.checkStatuses,
190
+ environments,
191
+ };
192
+ }),
193
+ );
194
+ return result;
195
+ };
196
+
197
+ const reconcile: HealthCheckCache["reconcile"] = async ({
198
+ systemId,
199
+ environmentId,
200
+ previous,
201
+ next,
202
+ }) => {
203
+ if (!statusVectorChanged(previous, next)) return; // vector unchanged: keep warm.
204
+ // `scope.invalidate` is a `delete` on the active backend. With a distributed
205
+ // backend that delete is visible to every pod immediately, so no broadcast
206
+ // is needed for cross-pod coherence.
207
+ const isEnvScoped =
208
+ environmentId !== undefined && environmentId !== null;
209
+ if (isEnvScoped) {
210
+ // A per-environment slice changed. Evict its key AND the system rollup:
211
+ // the slice feeds the worst-wins rollup, so the rollup value may have
212
+ // moved even when its OWN per-check fingerprint can't see it (one slice
213
+ // recovering as another fails keeps `failingSliceCount` put). Sibling env
214
+ // keys stay warm.
215
+ await scope.invalidate(statusKey(systemId, environmentId));
216
+ await scope.invalidate(statusKey(systemId));
217
+ } else {
218
+ // Env-less / rollup change: the bare key IS the rollup.
219
+ await scope.invalidate(statusKey(systemId));
220
+ }
221
+ };
222
+
223
+ const invalidateSystem: HealthCheckCache["invalidateSystem"] = async (
224
+ systemId,
225
+ ) => {
226
+ await scope.invalidatePrefix(systemPrefix(systemId));
227
+ };
228
+
229
+ const invalidateAllSystems: HealthCheckCache["invalidateAllSystems"] = () =>
230
+ scope.invalidatePrefix(STATUS_KEY_PREFIX);
231
+
63
232
  return {
64
- wrapSystemHealthStatus: (systemId, loader) =>
65
- scope.wrap(statusKey(systemId), loader),
66
- invalidateSystem: (systemId) => scope.invalidate(statusKey(systemId)),
67
- invalidateAllSystems: () => scope.invalidatePrefix(STATUS_KEY_PREFIX),
68
- scope,
233
+ read,
234
+ readBulk,
235
+ readMatrix,
236
+ reconcile,
237
+ invalidateSystem,
238
+ invalidateAllSystems,
69
239
  };
70
240
  }
@@ -74,6 +74,28 @@ describe("buildHealthTransitionNotification", () => {
74
74
  expect(payload.title).toContain("(Production)");
75
75
  });
76
76
 
77
+ it("carries the origin environment id for an env-scoped transition", () => {
78
+ const payload = buildHealthTransitionNotification({
79
+ ...base,
80
+ transition: "escalation",
81
+ environmentId: "env-prod",
82
+ environmentName: "Production",
83
+ });
84
+ // The status-page fan-out reads this to drop the change for a page that does
85
+ // not publish env-prod.
86
+ expect(payload.originEnvironmentId).toBe("env-prod");
87
+ });
88
+
89
+ it("omits the origin environment id for a system-rollup transition", () => {
90
+ const payload = buildHealthTransitionNotification({
91
+ ...base,
92
+ transition: "escalation",
93
+ });
94
+ // No environmentId => a whole-system rollup; it must reach every page that
95
+ // surfaces the system regardless of environment.
96
+ expect(payload.originEnvironmentId).toBeUndefined();
97
+ });
98
+
77
99
  it("stays system-level and omits the check subject on recovery", () => {
78
100
  const payload = buildHealthTransitionNotification({
79
101
  ...base,
@@ -106,6 +106,13 @@ export function buildHealthTransitionNotification(
106
106
  body,
107
107
  importance,
108
108
  action: { label: actionLabel, url: actionUrl },
109
+ // Carry the failing ENVIRONMENT so the status-page fan-out can drop this
110
+ // change for a page that does not publish that environment (e.g. a
111
+ // `development` failure never reaches a prod-only page's subscribers). Only
112
+ // set for an env-scoped slice; the system-rollup path stays env-less.
113
+ ...(typeof environmentId === "string"
114
+ ? { originEnvironmentId: environmentId }
115
+ : {}),
109
116
  // Env-qualified collapse key so two failing envs of one system generate
110
117
  // two independent notification cards (one per env) instead of merging.
111
118
  collapseKey: envScoped
@@ -5,6 +5,7 @@ import {
5
5
  buildHealthcheckKind,
6
6
  buildSystemHealthcheckExtension,
7
7
  } from "./healthcheck-gitops-kinds";
8
+ import { createStubHealthCheckCache } from "./cache-test-stub";
8
9
  import type {
9
10
  HealthCheckConfiguration,
10
11
  CreateHealthCheckConfiguration,
@@ -248,10 +249,13 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
248
249
  let mockHCRegistry: ReturnType<typeof createMockHealthCheckRegistry>;
249
250
  let mockCollectorRegistry: ReturnType<typeof createMockCollectorRegistry>;
250
251
 
252
+ let mockCache: ReturnType<typeof createStubHealthCheckCache>;
253
+
251
254
  beforeEach(() => {
252
255
  mockService = createMockService();
253
256
  mockHCRegistry = createMockHealthCheckRegistry();
254
257
  mockCollectorRegistry = createMockCollectorRegistry();
258
+ mockCache = createStubHealthCheckCache();
255
259
  });
256
260
 
257
261
  function buildKind() {
@@ -262,6 +266,7 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
262
266
  getQueueManager: () => emptyReconcileQueueManager(),
263
267
  getDb: () => emptyReconcileDbStub(),
264
268
  getCatalogClient: () => emptyCatalogClient(),
269
+ getCache: () => mockCache,
265
270
  };
266
271
  return buildHealthcheckKind(mockDeps);
267
272
  }
@@ -601,6 +606,9 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
601
606
 
602
607
  expect(result.entityId).toBe("hc-1");
603
608
  expect(mockService.createConfiguration).toHaveBeenCalledTimes(1);
609
+ // Regression guard: GitOps must invalidate the status cache (a create could
610
+ // affect any system's rollup), otherwise every pod serves stale health.
611
+ expect(mockCache.invalidateAllSystems).toHaveBeenCalledTimes(1);
604
612
  });
605
613
  });
606
614
 
@@ -608,9 +616,11 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
608
616
 
609
617
  describe("Healthcheck GitOps Kind: System Extension", () => {
610
618
  let mockService: ReturnType<typeof createMockService>;
619
+ let mockCache: ReturnType<typeof createStubHealthCheckCache>;
611
620
 
612
621
  beforeEach(() => {
613
622
  mockService = createMockService();
623
+ mockCache = createStubHealthCheckCache();
614
624
  });
615
625
 
616
626
  function buildExtension() {
@@ -627,6 +637,7 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
627
637
  getQueueManager: () => emptyReconcileQueueManager(),
628
638
  getDb: () => emptyReconcileDbStub(),
629
639
  getCatalogClient: () => emptyCatalogClient(),
640
+ getCache: () => mockCache,
630
641
  });
631
642
  }
632
643
 
@@ -667,6 +678,9 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
667
678
  expect(mockService.associations[0].systemId).toBe("sys-123");
668
679
  expect(mockService.associations[0].configurationId).toBe("hc-1");
669
680
  expect(mockService.associations[1].configurationId).toBe("hc-2");
681
+ // Regression guard: an association change must invalidate THIS system's
682
+ // cached status (rollup + env keys), else pods serve stale health.
683
+ expect(mockCache.invalidateSystem).toHaveBeenCalledWith("sys-123");
670
684
  });
671
685
 
672
686
  it("removes stale associations not in spec", async () => {
@@ -15,6 +15,7 @@ import type {
15
15
  } from "@checkstack/backend-api";
16
16
  import { NotificationPolicySchema } from "@checkstack/healthcheck-common";
17
17
  import { HealthCheckService } from "./service";
18
+ import type { HealthCheckCache } from "./cache";
18
19
  import { validateVersionedConfigStrict } from "./validate-configuration";
19
20
  import {
20
21
  DynamicOperators,
@@ -43,6 +44,15 @@ interface HealthcheckGitOpsKindsDeps {
43
44
  getQueueManager: () => QueueManager;
44
45
  getDb: () => SafeDatabase<typeof schema>;
45
46
  getCatalogClient: () => InferClient<typeof CatalogApi>;
47
+ /**
48
+ * Lazy accessor for the system-health status cache. GitOps writes configs /
49
+ * associations directly on the service (not through the router), so they MUST
50
+ * invalidate the cache themselves — otherwise a `git push` that changes a
51
+ * system's derived status leaves every pod serving the stale cached value
52
+ * until its 15s TTL. Mirrors the router's config-mutation invalidation. May be
53
+ * `undefined` before init completes (reconcile only runs post-init).
54
+ */
55
+ getCache: () => HealthCheckCache | undefined;
46
56
  }
47
57
 
48
58
  // ─── Healthcheck Spec Schema ───────────────────────────────────────────────
@@ -264,6 +274,11 @@ export function buildHealthcheckKind(
264
274
  // silently restored from the stored row (keep-existing is UI-only).
265
275
  { mergeSecrets: false },
266
276
  );
277
+ // A config change (thresholds, assertions, interval) can move the
278
+ // derived status of every system this check is assigned to. Match the
279
+ // router's `updateConfiguration` handler: drop every system's cached
280
+ // status (+ broadcast) so no pod serves stale health.
281
+ await deps.getCache()?.invalidateAllSystems();
267
282
  context.logger.info(
268
283
  `GitOps: updated Healthcheck "${displayName}" (id: ${existingEntityId})`,
269
284
  );
@@ -282,6 +297,9 @@ export function buildHealthcheckKind(
282
297
  assertions: c.assertions,
283
298
  })),
284
299
  });
300
+ // A new configuration could be associated with any system; match the
301
+ // router's `createConfiguration` handler and drop every cached status.
302
+ await deps.getCache()?.invalidateAllSystems();
285
303
  context.logger.info(
286
304
  `GitOps: created Healthcheck "${displayName}" (id: ${config.id})`,
287
305
  );
@@ -299,6 +317,9 @@ export function buildHealthcheckKind(
299
317
  if (!entityId) return;
300
318
  const service = deps.createService();
301
319
  await service.deleteConfiguration(entityId);
320
+ // Match the router's `deleteConfiguration` handler: drop every cached
321
+ // status since the removed check could have affected any system's rollup.
322
+ await deps.getCache()?.invalidateAllSystems();
302
323
  context.logger.info(`GitOps: deleted Healthcheck (id: ${entityId})`);
303
324
  },
304
325
  };
@@ -415,6 +436,12 @@ export function buildSystemHealthcheckExtension(
415
436
  );
416
437
  }
417
438
  }
439
+
440
+ // Every association add/remove above changes THIS system's derived
441
+ // rollup (a check enters/leaves worst-wins), so drop its cached status
442
+ // (rollup + all env keys) and broadcast — mirroring the router's
443
+ // associate/disassociate handlers.
444
+ await deps.getCache()?.invalidateSystem(systemEntityId);
418
445
  },
419
446
  };
420
447
  }
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(() => ({