@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/CHANGELOG.md +190 -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
|
@@ -134,6 +134,20 @@
|
|
|
134
134
|
"when": 1780357035712,
|
|
135
135
|
"tag": "0018_abnormal_preak",
|
|
136
136
|
"breakpoints": true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"idx": 19,
|
|
140
|
+
"version": "7",
|
|
141
|
+
"when": 1783585832198,
|
|
142
|
+
"tag": "0019_chemical_frightful_four",
|
|
143
|
+
"breakpoints": true
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"idx": 20,
|
|
147
|
+
"version": "7",
|
|
148
|
+
"when": 1783607735562,
|
|
149
|
+
"tag": "0020_certain_mordo",
|
|
150
|
+
"breakpoints": true
|
|
137
151
|
}
|
|
138
152
|
]
|
|
139
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -14,32 +14,32 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/ai-backend": "0.10.
|
|
17
|
+
"@checkstack/ai-backend": "0.10.11",
|
|
18
18
|
"@checkstack/ai-common": "0.6.6",
|
|
19
|
-
"@checkstack/automation-backend": "0.11.
|
|
20
|
-
"@checkstack/backend-api": "0.
|
|
19
|
+
"@checkstack/automation-backend": "0.11.2",
|
|
20
|
+
"@checkstack/backend-api": "0.32.0",
|
|
21
21
|
"@checkstack/cache-api": "0.3.19",
|
|
22
|
-
"@checkstack/cache-utils": "0.
|
|
23
|
-
"@checkstack/catalog-backend": "1.
|
|
24
|
-
"@checkstack/catalog-common": "2.7.
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
22
|
+
"@checkstack/cache-utils": "0.3.0",
|
|
23
|
+
"@checkstack/catalog-backend": "1.8.0",
|
|
24
|
+
"@checkstack/catalog-common": "2.7.1",
|
|
25
|
+
"@checkstack/command-backend": "0.2.23",
|
|
26
26
|
"@checkstack/common": "0.22.0",
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
27
|
+
"@checkstack/gitops-backend": "0.5.23",
|
|
28
28
|
"@checkstack/gitops-common": "0.7.3",
|
|
29
|
-
"@checkstack/healthcheck-common": "1.16.
|
|
30
|
-
"@checkstack/incident-backend": "1.
|
|
31
|
-
"@checkstack/incident-common": "1.10.
|
|
32
|
-
"@checkstack/maintenance-common": "1.10.
|
|
33
|
-
"@checkstack/notification-common": "1.
|
|
29
|
+
"@checkstack/healthcheck-common": "1.16.1",
|
|
30
|
+
"@checkstack/incident-backend": "1.13.0",
|
|
31
|
+
"@checkstack/incident-common": "1.10.1",
|
|
32
|
+
"@checkstack/maintenance-common": "1.10.1",
|
|
33
|
+
"@checkstack/notification-common": "1.7.0",
|
|
34
34
|
"@checkstack/queue-api": "0.3.19",
|
|
35
|
-
"@checkstack/satellite-backend": "0.8.
|
|
36
|
-
"@checkstack/script-packages-backend": "0.4.
|
|
37
|
-
"@checkstack/sdk": "0.
|
|
38
|
-
"@checkstack/secrets-backend": "0.3.
|
|
35
|
+
"@checkstack/satellite-backend": "0.8.5",
|
|
36
|
+
"@checkstack/script-packages-backend": "0.4.2",
|
|
37
|
+
"@checkstack/sdk": "0.128.1",
|
|
38
|
+
"@checkstack/secrets-backend": "0.3.5",
|
|
39
39
|
"@checkstack/secrets-common": "0.3.2",
|
|
40
40
|
"@checkstack/signal-common": "0.2.17",
|
|
41
|
-
"@checkstack/status-page-backend": "0.
|
|
42
|
-
"@checkstack/status-page-common": "0.6.
|
|
41
|
+
"@checkstack/status-page-backend": "0.6.0",
|
|
42
|
+
"@checkstack/status-page-common": "0.6.1",
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@orpc/contract": "^1.14.4",
|
|
45
45
|
"@orpc/server": "^1.14.4",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
55
55
|
"@checkstack/scripts": "0.7.3",
|
|
56
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
56
|
+
"@checkstack/test-utils-backend": "0.1.57",
|
|
57
57
|
"@checkstack/tsconfig": "0.0.7",
|
|
58
58
|
"@types/bun": "^1.0.0",
|
|
59
59
|
"@types/pg": "^8.20.0",
|
|
@@ -7,7 +7,8 @@ import type { AuthUser } from "@checkstack/backend-api";
|
|
|
7
7
|
import type { SystemAccessResolver } from "@checkstack/ai-backend";
|
|
8
8
|
import {
|
|
9
9
|
createHealthcheckSignalsContributor,
|
|
10
|
-
type
|
|
10
|
+
type HealthcheckCandidateSource,
|
|
11
|
+
type HealthcheckStatusCacheReader,
|
|
11
12
|
} from "./system-signals-contributor";
|
|
12
13
|
|
|
13
14
|
const unhealthyStatuses: HealthcheckSignalStatuses = {
|
|
@@ -27,10 +28,30 @@ const unhealthyStatuses: HealthcheckSignalStatuses = {
|
|
|
27
28
|
},
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Build a candidate source + cache reader pair from a fixed status map: the
|
|
33
|
+
* candidate ids are the map's keys, and `readBulk` returns the requested subset.
|
|
34
|
+
* This mirrors the real wiring (service scans candidates, cache serves statuses).
|
|
35
|
+
*/
|
|
30
36
|
function sourceReturning(statuses: HealthcheckSignalStatuses): {
|
|
31
|
-
|
|
37
|
+
candidateSource: HealthcheckCandidateSource;
|
|
38
|
+
cache: HealthcheckStatusCacheReader;
|
|
32
39
|
} {
|
|
33
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
candidateSource: {
|
|
42
|
+
getUnhealthyCandidateSystemIds: async () => Object.keys(statuses),
|
|
43
|
+
},
|
|
44
|
+
cache: {
|
|
45
|
+
readBulk: async (systemIds) => {
|
|
46
|
+
const out: HealthcheckSignalStatuses = {};
|
|
47
|
+
for (const id of systemIds) {
|
|
48
|
+
const s = statuses[id];
|
|
49
|
+
if (s) out[id] = s;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
// The per-source gate is owned/tested by createGatedSystemSignalsContributor;
|
|
@@ -47,18 +68,20 @@ const userWith = (accessRules: string[]): AuthUser => ({
|
|
|
47
68
|
|
|
48
69
|
describe("createHealthcheckSignalsContributor", () => {
|
|
49
70
|
it("exposes the shared source id", () => {
|
|
50
|
-
const {
|
|
71
|
+
const { candidateSource, cache } = sourceReturning({});
|
|
51
72
|
const contributor = createHealthcheckSignalsContributor({
|
|
52
|
-
|
|
73
|
+
candidateSource,
|
|
74
|
+
cache,
|
|
53
75
|
resolver: allowAll,
|
|
54
76
|
});
|
|
55
77
|
expect(contributor.sourceId).toBe(HEALTHCHECK_SIGNAL_SOURCE_ID);
|
|
56
78
|
});
|
|
57
79
|
|
|
58
80
|
it("wires the service + shared deriver for an authorized principal", async () => {
|
|
59
|
-
const {
|
|
81
|
+
const { candidateSource, cache } = sourceReturning(unhealthyStatuses);
|
|
60
82
|
const contributor = createHealthcheckSignalsContributor({
|
|
61
|
-
|
|
83
|
+
candidateSource,
|
|
84
|
+
cache,
|
|
62
85
|
resolver: allowAll,
|
|
63
86
|
});
|
|
64
87
|
|
|
@@ -75,9 +98,10 @@ describe("createHealthcheckSignalsContributor", () => {
|
|
|
75
98
|
});
|
|
76
99
|
|
|
77
100
|
it("routes a non-global user through the team gate (no grants -> nothing)", async () => {
|
|
78
|
-
const {
|
|
101
|
+
const { candidateSource, cache } = sourceReturning(unhealthyStatuses);
|
|
79
102
|
const contributor = createHealthcheckSignalsContributor({
|
|
80
|
-
|
|
103
|
+
candidateSource,
|
|
104
|
+
cache,
|
|
81
105
|
resolver: denyAll,
|
|
82
106
|
});
|
|
83
107
|
|
|
@@ -11,36 +11,58 @@ import {
|
|
|
11
11
|
} from "@checkstack/ai-backend";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* The slice of `HealthCheckService` this contributor needs:
|
|
15
|
-
*
|
|
16
|
-
* to an interface so the contributor is unit-testable
|
|
17
|
-
* full service (or a database).
|
|
14
|
+
* The slice of `HealthCheckService` this contributor needs: the candidate set
|
|
15
|
+
* for the global problem scan (systems with at least one enabled check
|
|
16
|
+
* association). Narrowed to an interface so the contributor is unit-testable
|
|
17
|
+
* without constructing the full service (or a database).
|
|
18
18
|
*/
|
|
19
|
-
export interface
|
|
20
|
-
|
|
19
|
+
export interface HealthcheckCandidateSource {
|
|
20
|
+
getUnhealthyCandidateSystemIds(): Promise<string[]>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* The slice of `HealthCheckCache` this contributor reads through: the per-entity
|
|
25
|
+
* bulk status read. Routing the scan through the cache (rather than the service's
|
|
26
|
+
* uncached N+1 `getAllUnhealthySystemStatuses`) means the AI signals poll reuses
|
|
27
|
+
* the warm badge/dashboard cache and does not fan out one DB derivation per
|
|
28
|
+
* system on every poll.
|
|
29
|
+
*/
|
|
30
|
+
export interface HealthcheckStatusCacheReader {
|
|
31
|
+
readBulk(systemIds: string[]): Promise<HealthcheckSignalStatuses>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the healthcheck contributor for the AI `system.issues` aggregator. Scans
|
|
36
|
+
* the candidate systems (durable, pod-independent) and resolves each one's status
|
|
37
|
+
* through the SHARED cache, then runs the SAME deriver the dashboard filler uses
|
|
38
|
+
* (problems only - healthy systems are dropped). The per-source access gate
|
|
39
|
+
* (global `healthcheck.status` plus per-system team grants) is applied by
|
|
28
40
|
* {@link createGatedSystemSignalsContributor}.
|
|
29
41
|
*/
|
|
30
42
|
export function createHealthcheckSignalsContributor({
|
|
31
|
-
|
|
43
|
+
candidateSource,
|
|
44
|
+
cache,
|
|
32
45
|
resolver,
|
|
33
46
|
}: {
|
|
34
|
-
|
|
47
|
+
candidateSource: HealthcheckCandidateSource;
|
|
48
|
+
cache: HealthcheckStatusCacheReader;
|
|
35
49
|
resolver: SystemAccessResolver;
|
|
36
50
|
}): SystemSignalsContributor {
|
|
37
51
|
return createGatedSystemSignalsContributor({
|
|
38
52
|
sourceId: HEALTHCHECK_SIGNAL_SOURCE_ID,
|
|
39
53
|
accessRule: healthCheckAccess.status,
|
|
40
54
|
resolver,
|
|
41
|
-
readSignals: async () =>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
readSignals: async () => {
|
|
56
|
+
const systemIds = await candidateSource.getUnhealthyCandidateSystemIds();
|
|
57
|
+
const all = await cache.readBulk(systemIds);
|
|
58
|
+
// Keep only degraded/unhealthy systems (the same filter the uncached scan
|
|
59
|
+
// applied); the deriver drops healthy/unknown anyway, but filtering here
|
|
60
|
+
// keeps the payload small.
|
|
61
|
+
const statuses: HealthcheckSignalStatuses = {};
|
|
62
|
+
for (const [systemId, status] of Object.entries(all)) {
|
|
63
|
+
if (status.status !== "healthy") statuses[systemId] = status;
|
|
64
|
+
}
|
|
65
|
+
return deriveHealthcheckSignals({ statuses });
|
|
66
|
+
},
|
|
45
67
|
});
|
|
46
68
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mock } from "bun:test";
|
|
2
|
+
import type { HealthCheckCache } from "./cache";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A no-op {@link HealthCheckCache} for tests that need to satisfy the router /
|
|
6
|
+
* executor `cache` dependency without exercising real caching. Every method is a
|
|
7
|
+
* `bun:test` mock so a test can assert calls or override behavior via
|
|
8
|
+
* `overrides`. `read` throws by default (a test that reaches the read path must
|
|
9
|
+
* opt in by overriding it), while the invalidators/reconcile no-op so mutation
|
|
10
|
+
* and run paths run unimpeded.
|
|
11
|
+
*/
|
|
12
|
+
export function createStubHealthCheckCache(
|
|
13
|
+
overrides: Partial<HealthCheckCache> = {},
|
|
14
|
+
): HealthCheckCache {
|
|
15
|
+
return {
|
|
16
|
+
read: mock(async () => {
|
|
17
|
+
throw new Error("stub HealthCheckCache.read not configured");
|
|
18
|
+
}),
|
|
19
|
+
readBulk: mock(async () => ({})),
|
|
20
|
+
readMatrix: mock(async () => ({})),
|
|
21
|
+
reconcile: mock(async () => {}),
|
|
22
|
+
invalidateSystem: mock(async () => {}),
|
|
23
|
+
invalidateAllSystems: mock(async () => 0),
|
|
24
|
+
...overrides,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import type { CacheManager, CacheProvider } from "@checkstack/cache-api";
|
|
3
|
+
import type { SystemHealthStatusResponse } from "@checkstack/healthcheck-common";
|
|
4
|
+
import { createHealthCheckCache, type HealthStatusReader } from "./cache";
|
|
5
|
+
|
|
6
|
+
function createMemoryProvider(): CacheProvider {
|
|
7
|
+
const store = new Map<string, unknown>();
|
|
8
|
+
return {
|
|
9
|
+
get: async <T>(key: string) => store.get(key) as T | undefined,
|
|
10
|
+
set: async (key, value) => {
|
|
11
|
+
store.set(key, value);
|
|
12
|
+
},
|
|
13
|
+
delete: async (key) => {
|
|
14
|
+
store.delete(key);
|
|
15
|
+
},
|
|
16
|
+
deleteByPrefix: async (prefix) => {
|
|
17
|
+
let removed = 0;
|
|
18
|
+
for (const key of [...store.keys()]) {
|
|
19
|
+
if (key.startsWith(prefix)) {
|
|
20
|
+
store.delete(key);
|
|
21
|
+
removed++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return removed;
|
|
25
|
+
},
|
|
26
|
+
has: async (key) => store.has(key),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createManager(provider: CacheProvider): CacheManager {
|
|
31
|
+
return {
|
|
32
|
+
getProvider: () => provider,
|
|
33
|
+
getActivePlugin: () => "test",
|
|
34
|
+
getActiveConfig: () => ({}),
|
|
35
|
+
setActiveBackend: async () => {},
|
|
36
|
+
shutdown: async () => {},
|
|
37
|
+
} as unknown as CacheManager;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const silentLogger = {
|
|
41
|
+
debug: () => {},
|
|
42
|
+
info: () => {},
|
|
43
|
+
warn: () => {},
|
|
44
|
+
error: () => {},
|
|
45
|
+
} as never;
|
|
46
|
+
|
|
47
|
+
function response(
|
|
48
|
+
status: SystemHealthStatusResponse["status"],
|
|
49
|
+
checks: {
|
|
50
|
+
configurationId: string;
|
|
51
|
+
status: SystemHealthStatusResponse["status"];
|
|
52
|
+
failingSliceCount?: number;
|
|
53
|
+
sliceCount?: number;
|
|
54
|
+
}[] = [],
|
|
55
|
+
): SystemHealthStatusResponse {
|
|
56
|
+
return {
|
|
57
|
+
status,
|
|
58
|
+
evaluatedAt: new Date(0),
|
|
59
|
+
checkStatuses: checks.map((c) => ({
|
|
60
|
+
configurationId: c.configurationId,
|
|
61
|
+
configurationName: c.configurationId,
|
|
62
|
+
status: c.status,
|
|
63
|
+
runsConsidered: 1,
|
|
64
|
+
sliceCount: c.sliceCount ?? 1,
|
|
65
|
+
failingSliceCount: c.failingSliceCount ?? 0,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setup(
|
|
71
|
+
opts: {
|
|
72
|
+
reader?: Partial<HealthStatusReader>;
|
|
73
|
+
} = {},
|
|
74
|
+
) {
|
|
75
|
+
const provider = createMemoryProvider();
|
|
76
|
+
const reader: HealthStatusReader = {
|
|
77
|
+
getSystemHealthStatus:
|
|
78
|
+
opts.reader?.getSystemHealthStatus ?? (async () => response("healthy")),
|
|
79
|
+
getSystemEnvironmentIds:
|
|
80
|
+
opts.reader?.getSystemEnvironmentIds ?? (async () => []),
|
|
81
|
+
};
|
|
82
|
+
const cache = createHealthCheckCache({
|
|
83
|
+
cacheManager: createManager(provider),
|
|
84
|
+
logger: silentLogger,
|
|
85
|
+
service: reader,
|
|
86
|
+
});
|
|
87
|
+
return { cache, provider };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("HealthCheckCache.read", () => {
|
|
91
|
+
it("caches the loader value (second read does not re-hit the service)", async () => {
|
|
92
|
+
const getSystemHealthStatus = mock(async () => response("degraded"));
|
|
93
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
94
|
+
|
|
95
|
+
const first = await cache.read("sys-1");
|
|
96
|
+
const second = await cache.read("sys-1");
|
|
97
|
+
|
|
98
|
+
expect(first.status).toBe("degraded");
|
|
99
|
+
expect(second.status).toBe("degraded");
|
|
100
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keys per (system, environment)", async () => {
|
|
104
|
+
const getSystemHealthStatus = mock(
|
|
105
|
+
async (_systemId: string, environmentId?: string | null) =>
|
|
106
|
+
response(environmentId === "prod" ? "unhealthy" : "healthy"),
|
|
107
|
+
);
|
|
108
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
109
|
+
|
|
110
|
+
const rollup = await cache.read("sys-1");
|
|
111
|
+
const prod = await cache.read("sys-1", "prod");
|
|
112
|
+
|
|
113
|
+
expect(rollup.status).toBe("healthy");
|
|
114
|
+
expect(prod.status).toBe("unhealthy");
|
|
115
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("HealthCheckCache.reconcile", () => {
|
|
120
|
+
it("evicts only when the per-check vector changed", async () => {
|
|
121
|
+
let current = response("healthy", [
|
|
122
|
+
{ configurationId: "c1", status: "healthy" },
|
|
123
|
+
]);
|
|
124
|
+
const getSystemHealthStatus = mock(async () => current);
|
|
125
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
126
|
+
|
|
127
|
+
// Warm the cache.
|
|
128
|
+
await cache.read("sys-1");
|
|
129
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(1);
|
|
130
|
+
|
|
131
|
+
// A run whose vector is unchanged: no evict, cache stays warm.
|
|
132
|
+
await cache.reconcile({
|
|
133
|
+
systemId: "sys-1",
|
|
134
|
+
previous: response("healthy", [
|
|
135
|
+
{ configurationId: "c1", status: "healthy" },
|
|
136
|
+
]),
|
|
137
|
+
next: response("healthy", [
|
|
138
|
+
{ configurationId: "c1", status: "healthy" },
|
|
139
|
+
]),
|
|
140
|
+
});
|
|
141
|
+
await cache.read("sys-1");
|
|
142
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(1); // served from cache
|
|
143
|
+
|
|
144
|
+
// A run that flips a check: evict; next read reloads.
|
|
145
|
+
current = response("degraded", [
|
|
146
|
+
{ configurationId: "c1", status: "degraded" },
|
|
147
|
+
]);
|
|
148
|
+
await cache.reconcile({
|
|
149
|
+
systemId: "sys-1",
|
|
150
|
+
previous: response("healthy", [
|
|
151
|
+
{ configurationId: "c1", status: "healthy" },
|
|
152
|
+
]),
|
|
153
|
+
next: current,
|
|
154
|
+
});
|
|
155
|
+
const reloaded = await cache.read("sys-1");
|
|
156
|
+
expect(reloaded.status).toBe("degraded");
|
|
157
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("a per-env change evicts BOTH its env key and the rollup key", async () => {
|
|
161
|
+
const getSystemHealthStatus = mock(
|
|
162
|
+
async (_systemId: string, environmentId?: string | null) =>
|
|
163
|
+
response(environmentId === "prod" ? "unhealthy" : "healthy"),
|
|
164
|
+
);
|
|
165
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
166
|
+
|
|
167
|
+
await cache.read("sys-1"); // warm rollup
|
|
168
|
+
await cache.read("sys-1", "prod"); // warm env
|
|
169
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
|
|
170
|
+
|
|
171
|
+
await cache.reconcile({
|
|
172
|
+
systemId: "sys-1",
|
|
173
|
+
environmentId: "prod",
|
|
174
|
+
previous: response("healthy", [
|
|
175
|
+
{ configurationId: "c1", status: "healthy" },
|
|
176
|
+
]),
|
|
177
|
+
next: response("unhealthy", [
|
|
178
|
+
{ configurationId: "c1", status: "unhealthy" },
|
|
179
|
+
]),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Both the rollup and the prod key reload from the service (sibling env
|
|
183
|
+
// keys would stay warm, but there are none here).
|
|
184
|
+
await cache.read("sys-1");
|
|
185
|
+
await cache.read("sys-1", "prod");
|
|
186
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(4);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles a simultaneous slice swap the rollup fingerprint is blind to", async () => {
|
|
190
|
+
// A: unhealthy->healthy, B: healthy->unhealthy. The ROLLUP fingerprint does
|
|
191
|
+
// not move (failingSliceCount stays 1, worst status stays put), so the
|
|
192
|
+
// rollup is kept fresh by the per-env reconciles, not by a rollup diff.
|
|
193
|
+
let rollupCall = 0;
|
|
194
|
+
const getSystemHealthStatus = mock(
|
|
195
|
+
async (_systemId: string, environmentId?: string | null) => {
|
|
196
|
+
if (environmentId === undefined || environmentId === null) {
|
|
197
|
+
rollupCall++;
|
|
198
|
+
return response("unhealthy", [
|
|
199
|
+
{
|
|
200
|
+
configurationId: "c1",
|
|
201
|
+
status: "unhealthy",
|
|
202
|
+
sliceCount: 2,
|
|
203
|
+
failingSliceCount: 1,
|
|
204
|
+
},
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
return response("healthy");
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
211
|
+
|
|
212
|
+
await cache.read("sys-1"); // warm rollup (rollupCall -> 1)
|
|
213
|
+
await cache.reconcile({
|
|
214
|
+
systemId: "sys-1",
|
|
215
|
+
environmentId: "a",
|
|
216
|
+
previous: response("unhealthy", [
|
|
217
|
+
{ configurationId: "c1", status: "unhealthy" },
|
|
218
|
+
]),
|
|
219
|
+
next: response("healthy", [
|
|
220
|
+
{ configurationId: "c1", status: "healthy" },
|
|
221
|
+
]),
|
|
222
|
+
});
|
|
223
|
+
await cache.reconcile({
|
|
224
|
+
systemId: "sys-1",
|
|
225
|
+
environmentId: "b",
|
|
226
|
+
previous: response("healthy", [
|
|
227
|
+
{ configurationId: "c1", status: "healthy" },
|
|
228
|
+
]),
|
|
229
|
+
next: response("unhealthy", [
|
|
230
|
+
{ configurationId: "c1", status: "unhealthy" },
|
|
231
|
+
]),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await cache.read("sys-1");
|
|
235
|
+
expect(rollupCall).toBe(2); // rollup was evicted + reloaded despite a stable fingerprint
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("HealthCheckCache invalidators", () => {
|
|
240
|
+
it("invalidateSystem evicts the rollup + every per-env key", async () => {
|
|
241
|
+
const getSystemHealthStatus = mock(async () => response("healthy"));
|
|
242
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
243
|
+
|
|
244
|
+
await cache.read("sys-1"); // warm rollup
|
|
245
|
+
await cache.read("sys-1", "prod"); // warm env
|
|
246
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
|
|
247
|
+
|
|
248
|
+
await cache.invalidateSystem("sys-1");
|
|
249
|
+
|
|
250
|
+
await cache.read("sys-1");
|
|
251
|
+
await cache.read("sys-1", "prod");
|
|
252
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(4); // both reloaded
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("invalidateAllSystems evicts every system's status", async () => {
|
|
256
|
+
const getSystemHealthStatus = mock(async () => response("healthy"));
|
|
257
|
+
const { cache } = setup({ reader: { getSystemHealthStatus } });
|
|
258
|
+
|
|
259
|
+
await cache.read("sys-1");
|
|
260
|
+
await cache.read("sys-2");
|
|
261
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(2);
|
|
262
|
+
|
|
263
|
+
const removed = await cache.invalidateAllSystems();
|
|
264
|
+
expect(removed).toBe(2);
|
|
265
|
+
|
|
266
|
+
await cache.read("sys-1");
|
|
267
|
+
await cache.read("sys-2");
|
|
268
|
+
expect(getSystemHealthStatus).toHaveBeenCalledTimes(4); // both reloaded
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("HealthCheckCache.readMatrix", () => {
|
|
273
|
+
it("assembles rollup + per-env slices from the cache", async () => {
|
|
274
|
+
const getSystemHealthStatus = mock(
|
|
275
|
+
async (_systemId: string, environmentId?: string | null) =>
|
|
276
|
+
response(environmentId === "prod" ? "unhealthy" : "healthy", [
|
|
277
|
+
{ configurationId: "c1", status: "healthy" },
|
|
278
|
+
]),
|
|
279
|
+
);
|
|
280
|
+
const { cache } = setup({
|
|
281
|
+
reader: {
|
|
282
|
+
getSystemHealthStatus,
|
|
283
|
+
getSystemEnvironmentIds: async () => ["prod"],
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const matrix = await cache.readMatrix(["sys-1"]);
|
|
288
|
+
expect(matrix["sys-1"]!.status).toBe("healthy");
|
|
289
|
+
expect(matrix["sys-1"]!.environments["prod"]!.status).toBe("unhealthy");
|
|
290
|
+
});
|
|
291
|
+
});
|