@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/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
  }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { buildHealthTransitionNotification } from "./health-notification-content";
3
+
4
+ describe("buildHealthTransitionNotification", () => {
5
+ const base = {
6
+ systemId: "sys-1",
7
+ systemName: "Payments API",
8
+ configurationId: "cfg-9",
9
+ checkName: "HTTP 200 probe",
10
+ newStatus: "unhealthy" as const,
11
+ };
12
+
13
+ it("names the failing check in the body for an unhealthy transition", () => {
14
+ const payload = buildHealthTransitionNotification({
15
+ ...base,
16
+ transition: "escalation",
17
+ });
18
+ expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
19
+ expect(payload.body).toContain("**Payments API**");
20
+ expect(payload.importance).toBe("critical");
21
+ });
22
+
23
+ it("names the failing check for a degraded transition", () => {
24
+ const payload = buildHealthTransitionNotification({
25
+ ...base,
26
+ newStatus: "degraded",
27
+ transition: "escalation",
28
+ });
29
+ expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
30
+ expect(payload.importance).toBe("warning");
31
+ });
32
+
33
+ it("pushes a healthcheck.healthcheck subject alongside the system subject", () => {
34
+ const payload = buildHealthTransitionNotification({
35
+ ...base,
36
+ transition: "escalation",
37
+ });
38
+ const subjects = payload.subjects ?? [];
39
+ expect(subjects).toHaveLength(2);
40
+ expect(subjects[0]).toMatchObject({
41
+ kind: "catalog.system",
42
+ id: "sys-1",
43
+ name: "Payments API",
44
+ });
45
+ expect(subjects[1]).toMatchObject({
46
+ kind: "healthcheck.healthcheck",
47
+ id: "cfg-9",
48
+ name: "HTTP 200 probe",
49
+ status: "unhealthy",
50
+ });
51
+ // Check subject deep-links to its run history.
52
+ expect(subjects[1]?.url).toContain("sys-1");
53
+ expect(subjects[1]?.url).toContain("cfg-9");
54
+ });
55
+
56
+ it("falls back to the configuration id when no name is resolved", () => {
57
+ const payload = buildHealthTransitionNotification({
58
+ ...base,
59
+ checkName: "cfg-9",
60
+ transition: "escalation",
61
+ });
62
+ expect(payload.body).toContain('Health check **"cfg-9"**');
63
+ expect((payload.subjects ?? [])[1]).toMatchObject({ name: "cfg-9" });
64
+ });
65
+
66
+ it("qualifies the body with the environment name when env-scoped", () => {
67
+ const payload = buildHealthTransitionNotification({
68
+ ...base,
69
+ transition: "escalation",
70
+ environmentId: "env-prod",
71
+ environmentName: "Production",
72
+ });
73
+ expect(payload.body).toContain("in environment **Production**");
74
+ expect(payload.title).toContain("(Production)");
75
+ });
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
+
99
+ it("stays system-level and omits the check subject on recovery", () => {
100
+ const payload = buildHealthTransitionNotification({
101
+ ...base,
102
+ newStatus: "healthy",
103
+ transition: "recovery",
104
+ });
105
+ expect(payload.body).not.toContain("Health check **");
106
+ expect(payload.importance).toBe("info");
107
+ const subjects = payload.subjects ?? [];
108
+ expect(subjects).toHaveLength(1);
109
+ expect(subjects[0]).toMatchObject({ kind: "catalog.system" });
110
+ });
111
+ });
@@ -0,0 +1,145 @@
1
+ import { resolveRoute, type InferClient } from "@checkstack/common";
2
+ import { catalogRoutes, createSystemSubject } from "@checkstack/catalog-common";
3
+ import type { NotificationApi } from "@checkstack/notification-common";
4
+ import {
5
+ createHealthcheckSubject,
6
+ healthcheckRoutes,
7
+ systemHealthCollapseKey,
8
+ healthcheckSystemSubscription,
9
+ type HealthCheckStatus,
10
+ } from "@checkstack/healthcheck-common";
11
+ import type { TransitionKind } from "./notification-policy";
12
+
13
+ /** The subset of `notifyForSubscription`'s input this builder produces. */
14
+ type NotifyForSubscriptionInput = Parameters<
15
+ InferClient<typeof NotificationApi>["notifyForSubscription"]
16
+ >[0];
17
+
18
+ /**
19
+ * Inputs to {@link buildHealthTransitionNotification}. Pure data only - the
20
+ * catalog client is unused here (parents are resolved server-side) and thus
21
+ * omitted; every field is derived before the call site in the queue executor.
22
+ */
23
+ export interface HealthTransitionNotificationInput {
24
+ transition: Exclude<TransitionKind, "none">;
25
+ systemId: string;
26
+ systemName: string;
27
+ configurationId: string;
28
+ /** Resolved display name of the check that drove the transition. */
29
+ checkName: string;
30
+ newStatus: HealthCheckStatus;
31
+ /** Concrete env id for a per-env slice, null/undefined for the system rollup. */
32
+ environmentId?: string | null;
33
+ /** Human-readable env name for the body/title. */
34
+ environmentName?: string;
35
+ }
36
+
37
+ /**
38
+ * Build the notification payload for a health-state transition. Pure and
39
+ * side-effect free so it can be unit-tested directly. Extracted from
40
+ * `notifyStateChange` so the body/title/subject wording (which now NAMES the
41
+ * failing check and pushes a `healthcheck.healthcheck` subject) is verifiable
42
+ * without booting the whole queue executor.
43
+ *
44
+ * Recovery bodies stay system-level (the whole system is green again; naming
45
+ * one check would mislead) and omit the check subject. Failing transitions
46
+ * (escalation / de-escalation) name the check in the body and add it as a
47
+ * subject deep-linked to its run history.
48
+ */
49
+ export function buildHealthTransitionNotification(
50
+ input: HealthTransitionNotificationInput,
51
+ ): NotifyForSubscriptionInput {
52
+ const {
53
+ transition,
54
+ systemId,
55
+ systemName,
56
+ configurationId,
57
+ checkName,
58
+ newStatus,
59
+ environmentId,
60
+ environmentName,
61
+ } = input;
62
+
63
+ const envScoped = typeof environmentId === "string";
64
+ const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
65
+ const envQualifier = envScoped
66
+ ? ` in environment **${environmentName ?? environmentId}**`
67
+ : "";
68
+
69
+ let title: string;
70
+ let body: string;
71
+ let importance: "info" | "warning" | "critical";
72
+
73
+ if (transition === "recovery") {
74
+ title = `System health restored${envSuffix}: ${systemName}`;
75
+ body = envScoped
76
+ ? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
77
+ : `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
78
+ importance = "info";
79
+ } else if (newStatus === "unhealthy") {
80
+ title = `System health critical${envSuffix}: ${systemName}`;
81
+ body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system is unhealthy and may be down${envScoped ? " in that environment" : ""}.`;
82
+ importance = "critical";
83
+ } else {
84
+ // degraded - either an escalation from healthy or a partial recovery
85
+ title = `System health degraded${envSuffix}: ${systemName}`;
86
+ body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system may be experiencing issues${envScoped ? " in that environment" : ""}.`;
87
+ importance = "warning";
88
+ }
89
+
90
+ const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
91
+ systemId,
92
+ });
93
+ // Recovery lands on the default (all) view; failing transitions deep-link
94
+ // operators into the failing-checks filter so they can debug immediately.
95
+ const actionUrl =
96
+ transition === "recovery"
97
+ ? systemDetailPath
98
+ : `${systemDetailPath}?filter=failing`;
99
+ const actionLabel =
100
+ transition === "recovery" ? "View System" : "View failing checks";
101
+
102
+ return {
103
+ specId: healthcheckSystemSubscription.specId,
104
+ resourceKeys: [systemId],
105
+ title,
106
+ body,
107
+ importance,
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
+ : {}),
116
+ // Env-qualified collapse key so two failing envs of one system generate
117
+ // two independent notification cards (one per env) instead of merging.
118
+ collapseKey: envScoped
119
+ ? systemHealthCollapseKey(systemId, environmentId)
120
+ : systemHealthCollapseKey(systemId),
121
+ subjects: [
122
+ createSystemSubject({
123
+ id: systemId,
124
+ name: systemName,
125
+ url: systemDetailPath,
126
+ status: newStatus,
127
+ }),
128
+ // Name the failing check as its own subject for every non-recovery
129
+ // transition, deep-linked to its run history. Omitted on recovery.
130
+ ...(transition === "recovery"
131
+ ? []
132
+ : [
133
+ createHealthcheckSubject({
134
+ id: configurationId,
135
+ name: checkName,
136
+ url: resolveRoute(healthcheckRoutes.routes.historyDetail, {
137
+ systemId,
138
+ configurationId,
139
+ }),
140
+ status: newStatus,
141
+ }),
142
+ ]),
143
+ ],
144
+ };
145
+ }
@@ -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
  }