@checkstack/healthcheck-backend 1.19.0 → 1.20.1
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/CHANGELOG.md +214 -0
- package/drizzle/0019_chemical_frightful_four.sql +8 -0
- package/drizzle/0020_certain_mordo.sql +2 -0
- package/drizzle/meta/0019_snapshot.json +661 -0
- package/drizzle/meta/0020_snapshot.json +711 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +21 -21
- package/src/ai/system-signals-contributor.test.ts +33 -9
- package/src/ai/system-signals-contributor.ts +38 -16
- package/src/cache-test-stub.ts +26 -0
- package/src/cache.test.ts +291 -0
- package/src/cache.ts +204 -34
- package/src/health-notification-content.test.ts +22 -0
- package/src/health-notification-content.ts +7 -0
- package/src/healthcheck-gitops-kinds.test.ts +14 -0
- package/src/healthcheck-gitops-kinds.ts +27 -0
- package/src/index.ts +31 -12
- package/src/queue-executor.test.ts +13 -26
- package/src/queue-executor.ts +94 -44
- package/src/retention-job.ts +8 -0
- package/src/rollup-consumer.test.ts +19 -8
- package/src/router-config-secrets.test.ts +2 -7
- package/src/router-create-and-assign.test.ts +2 -7
- package/src/router-pause-recompute.test.ts +2 -7
- package/src/router.test.ts +3 -8
- package/src/router.ts +7 -15
- package/src/schema.ts +74 -31
- package/src/service.ts +45 -102
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -0
- package/src/status-page/widgets.test.ts +84 -0
- package/src/status-page/widgets.ts +87 -6
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 {
|
|
4
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
5
|
+
import { statusVectorChanged } from "./status-fingerprint";
|
|
8
6
|
|
|
9
7
|
/**
|
|
10
|
-
* TTL
|
|
11
|
-
*
|
|
12
|
-
* a
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
24
|
-
|
|
91
|
+
/** Read-through cache for one (system, environment) status (RAW, pre-override). */
|
|
92
|
+
read(
|
|
25
93
|
systemId: string,
|
|
26
|
-
|
|
27
|
-
)
|
|
94
|
+
environmentId?: string,
|
|
95
|
+
): Promise<SystemHealthStatusResponse>;
|
|
28
96
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
|
|
108
|
+
readMatrix(systemIds: string[]): Promise<SystemHealthMatrix>;
|
|
38
109
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
468
|
-
// `
|
|
469
|
-
//
|
|
470
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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
|
|
1538
|
-
//
|
|
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 ===
|
|
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 ===
|
|
1557
|
+
if (selectCallCount === 2) {
|
|
1571
1558
|
return {
|
|
1572
1559
|
from: mock(() => ({
|
|
1573
1560
|
where: mock(() => ({
|