@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.
- package/CHANGELOG.md +484 -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 +23 -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 +111 -0
- package/src/health-notification-content.ts +145 -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 +125 -112
- 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 +43 -15
- package/src/schema.ts +74 -31
- package/src/service-batching.test.ts +8 -0
- package/src/service-bulk-counts.it.test.ts +144 -0
- package/src/service-bulk-run-stats.it.test.ts +197 -0
- package/src/service-ordering.test.ts +6 -2
- package/src/service-paused-filter.test.ts +13 -0
- package/src/service-rollup-worst-wins.test.ts +209 -145
- package/src/service.ts +408 -284
- package/src/status-fingerprint.test.ts +92 -0
- package/src/status-fingerprint.ts +66 -0
- package/src/status-page/rollup.test.ts +40 -0
- package/src/status-page/rollup.ts +27 -0
- package/src/status-page/widgets.test.ts +387 -0
- package/src/status-page/widgets.ts +236 -39
package/src/index.ts
CHANGED
|
@@ -230,6 +230,10 @@ export default createBackendPlugin({
|
|
|
230
230
|
throw new Error("Catalog client not initialized");
|
|
231
231
|
return gitopsCatalogClient;
|
|
232
232
|
},
|
|
233
|
+
// Lazy: the cache is created in init() (after this register() call) and
|
|
234
|
+
// reconcile only runs post-init, so `healthCheckCache` is populated by the
|
|
235
|
+
// time GitOps mutations fire.
|
|
236
|
+
getCache: () => healthCheckCache,
|
|
233
237
|
});
|
|
234
238
|
|
|
235
239
|
env.registerInit({
|
|
@@ -461,18 +465,34 @@ export default createBackendPlugin({
|
|
|
461
465
|
execute: deferredProjectionExecute,
|
|
462
466
|
});
|
|
463
467
|
|
|
468
|
+
// Per-entity status cache shared between the router, queue executor,
|
|
469
|
+
// AI signals contributor, and afterPluginsReady cleanup hooks. It is the
|
|
470
|
+
// single sanctioned reader (compute-on-read via `service`) AND
|
|
471
|
+
// invalidator of a system's derived health status. It runs on the
|
|
472
|
+
// platform CacheManager, so cross-pod coherence comes from the SHARED
|
|
473
|
+
// backend (a distributed provider such as Redis makes an eviction
|
|
474
|
+
// visible to every pod); no application-level broadcast is needed.
|
|
475
|
+
const cache = createHealthCheckCache({
|
|
476
|
+
cacheManager,
|
|
477
|
+
logger,
|
|
478
|
+
service,
|
|
479
|
+
});
|
|
480
|
+
healthCheckCache = cache;
|
|
481
|
+
|
|
464
482
|
// Contribute this plugin's per-system health problems to the AI
|
|
465
483
|
// `system.issues` aggregator. PER-SOURCE access is OUR job: gate on the
|
|
466
484
|
// principal's `healthcheck.status` grant and return {} (never throw)
|
|
467
|
-
// when not satisfied. The
|
|
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(() => ({
|
package/src/queue-executor.ts
CHANGED
|
@@ -35,20 +35,17 @@ import {
|
|
|
35
35
|
} from "@checkstack/healthcheck-common";
|
|
36
36
|
import {
|
|
37
37
|
CatalogApi,
|
|
38
|
-
catalogRoutes,
|
|
39
|
-
createSystemSubject,
|
|
40
38
|
type Environment,
|
|
41
39
|
} from "@checkstack/catalog-common";
|
|
42
40
|
import {
|
|
43
41
|
resolveEffectiveEnvironments,
|
|
44
42
|
type EffectiveEnvironment,
|
|
45
43
|
} from "./effective-environments";
|
|
46
|
-
import {
|
|
44
|
+
import { buildHealthTransitionNotification } from "./health-notification-content";
|
|
47
45
|
import { MaintenanceApi } from "@checkstack/maintenance-common";
|
|
48
46
|
import { IncidentApi } from "@checkstack/incident-common";
|
|
49
47
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
50
|
-
import {
|
|
51
|
-
import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
48
|
+
import { type InferClient, extractErrorMessage} from "@checkstack/common";
|
|
52
49
|
import { secretEnvMappingSchema } from "@checkstack/secrets-common";
|
|
53
50
|
import type {
|
|
54
51
|
SecretResolverService,
|
|
@@ -382,18 +379,20 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
382
379
|
// pure entity-recompute path (pause/resume) skips the extra prev read.
|
|
383
380
|
const wantsSignal = signalService !== undefined || cache !== undefined;
|
|
384
381
|
try {
|
|
385
|
-
|
|
382
|
+
// Capture the FULL rollup states (not just the status enum) so the cache
|
|
383
|
+
// reconcile can gate on the per-check vector while the frontend signal
|
|
384
|
+
// stays gated on the coarser rollup-enum transition.
|
|
385
|
+
let previousState: AggregatedHealth | undefined;
|
|
386
386
|
if (wantsSignal) {
|
|
387
|
-
|
|
388
|
-
previousStatus = previousState.status;
|
|
387
|
+
previousState = await service.getSystemHealthStatus(systemId);
|
|
389
388
|
}
|
|
390
|
-
let
|
|
389
|
+
let newState: AggregatedHealth | undefined = previousState;
|
|
391
390
|
await writeHealthEntity({
|
|
392
391
|
handle: getHealthEntity?.(),
|
|
393
392
|
entityId: rollupEntityId,
|
|
394
393
|
apply: async () => {
|
|
395
394
|
const rollupState = await service.getSystemHealthStatus(systemId);
|
|
396
|
-
|
|
395
|
+
newState = rollupState;
|
|
397
396
|
return toHealthEntityView(rollupState);
|
|
398
397
|
},
|
|
399
398
|
serialize: makeHealthSerializer(rollupEntityId),
|
|
@@ -404,21 +403,23 @@ export async function recomputeSystemRollupHealth(args: {
|
|
|
404
403
|
),
|
|
405
404
|
});
|
|
406
405
|
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
406
|
+
if (wantsSignal && previousState !== undefined && newState !== undefined) {
|
|
407
|
+
// Cache: evict the rollup key + broadcast to the cluster on ANY per-check
|
|
408
|
+
// vector change — a check that flips while the rollup enum stays put still
|
|
409
|
+
// changes the rollup's `checkStatuses`, and a reader gets that vector.
|
|
410
|
+
await cache?.reconcile({ systemId, previous: previousState, next: newState });
|
|
411
|
+
// Frontend signal: only a rollup-enum transition moves the badge, so a
|
|
412
|
+
// per-check-only change needs no SYSTEM_STATUS_CHANGED refetch signal.
|
|
413
|
+
if (newState.status !== previousState.status) {
|
|
414
|
+
await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
|
|
415
|
+
systemId,
|
|
416
|
+
previousStatus: previousState.status,
|
|
417
|
+
newStatus: newState.status,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
419
420
|
}
|
|
420
|
-
return
|
|
421
|
-
? { previousStatus, newStatus }
|
|
421
|
+
return previousState !== undefined && newState !== undefined
|
|
422
|
+
? { previousStatus: previousState.status, newStatus: newState.status }
|
|
422
423
|
: undefined;
|
|
423
424
|
} catch (error) {
|
|
424
425
|
// A recompute failure must never break the pause/resume RPC. The
|
|
@@ -465,6 +466,13 @@ async function notifyStateChange(props: {
|
|
|
465
466
|
systemId: string;
|
|
466
467
|
systemName: string;
|
|
467
468
|
configurationId: string;
|
|
469
|
+
/**
|
|
470
|
+
* Human-readable name of the health check whose run drove this transition.
|
|
471
|
+
* Named in the body and surfaced as a `healthcheck.healthcheck` subject so
|
|
472
|
+
* subscribers see WHICH check failed, not just which system. Best-effort:
|
|
473
|
+
* falls back to the `configurationId` when the name could not be resolved.
|
|
474
|
+
*/
|
|
475
|
+
configurationName?: string;
|
|
468
476
|
previousStatus: HealthCheckStatus;
|
|
469
477
|
newStatus: HealthCheckStatus;
|
|
470
478
|
/**
|
|
@@ -493,6 +501,7 @@ async function notifyStateChange(props: {
|
|
|
493
501
|
systemId,
|
|
494
502
|
systemName,
|
|
495
503
|
configurationId,
|
|
504
|
+
configurationName,
|
|
496
505
|
previousStatus,
|
|
497
506
|
newStatus,
|
|
498
507
|
environmentId,
|
|
@@ -505,8 +514,9 @@ async function notifyStateChange(props: {
|
|
|
505
514
|
logger,
|
|
506
515
|
} = props;
|
|
507
516
|
|
|
508
|
-
|
|
509
|
-
|
|
517
|
+
// The check that just ran is the one driving this aggregate transition, so
|
|
518
|
+
// its name is the authoritative check to blame. Fall back to the id.
|
|
519
|
+
const checkName = configurationName ?? configurationId;
|
|
510
520
|
|
|
511
521
|
const transition = classifyTransition(previousStatus, newStatus);
|
|
512
522
|
if (transition === "none") {
|
|
@@ -572,70 +582,24 @@ async function notifyStateChange(props: {
|
|
|
572
582
|
);
|
|
573
583
|
}
|
|
574
584
|
|
|
575
|
-
let title: string;
|
|
576
|
-
let body: string;
|
|
577
|
-
let importance: "info" | "warning" | "critical";
|
|
578
|
-
|
|
579
|
-
if (transition === "recovery") {
|
|
580
|
-
title = `System health restored${envSuffix}: ${systemName}`;
|
|
581
|
-
body = envScoped
|
|
582
|
-
? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
|
|
583
|
-
: `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
|
|
584
|
-
importance = "info";
|
|
585
|
-
} else if (newStatus === "unhealthy") {
|
|
586
|
-
title = `System health critical${envSuffix}: ${systemName}`;
|
|
587
|
-
body = envScoped
|
|
588
|
-
? `Health checks indicate **${systemName}** is unhealthy in environment **${environmentName ?? environmentId}** and may be down in that environment.`
|
|
589
|
-
: `Health checks indicate **${systemName}** is unhealthy and may be down.`;
|
|
590
|
-
importance = "critical";
|
|
591
|
-
} else {
|
|
592
|
-
// degraded — either an escalation from healthy or a partial recovery
|
|
593
|
-
title = `System health degraded${envSuffix}: ${systemName}`;
|
|
594
|
-
body = envScoped
|
|
595
|
-
? `Some health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are failing. That environment may be experiencing issues.`
|
|
596
|
-
: `Some health checks for **${systemName}** are failing. The system may be experiencing issues.`;
|
|
597
|
-
importance = "warning";
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
|
|
601
|
-
systemId,
|
|
602
|
-
});
|
|
603
|
-
// Recovery lands on the default (all) view; failing transitions deep-link
|
|
604
|
-
// operators into the failing-checks filter so they can debug immediately.
|
|
605
|
-
const actionUrl =
|
|
606
|
-
transition === "recovery"
|
|
607
|
-
? systemDetailPath
|
|
608
|
-
: `${systemDetailPath}?filter=failing`;
|
|
609
|
-
const actionLabel =
|
|
610
|
-
transition === "recovery" ? "View System" : "View failing checks";
|
|
611
|
-
|
|
612
585
|
void catalogClient; // parents are resolved server-side via stored target edges
|
|
613
586
|
|
|
614
587
|
try {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
subjects: [
|
|
631
|
-
createSystemSubject({
|
|
632
|
-
id: systemId,
|
|
633
|
-
name: systemName,
|
|
634
|
-
url: systemDetailPath,
|
|
635
|
-
status: newStatus,
|
|
636
|
-
}),
|
|
637
|
-
],
|
|
638
|
-
});
|
|
588
|
+
// Content (title/body/subjects/collapseKey) is built by a pure, unit-tested
|
|
589
|
+
// helper so the wording - which now NAMES the failing check and pushes a
|
|
590
|
+
// `healthcheck.healthcheck` subject - can be verified without the executor.
|
|
591
|
+
await notificationClient.notifyForSubscription(
|
|
592
|
+
buildHealthTransitionNotification({
|
|
593
|
+
transition,
|
|
594
|
+
systemId,
|
|
595
|
+
systemName,
|
|
596
|
+
configurationId,
|
|
597
|
+
checkName,
|
|
598
|
+
newStatus,
|
|
599
|
+
environmentId,
|
|
600
|
+
environmentName,
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
639
603
|
logger.debug(
|
|
640
604
|
`Notified subscribers: ${previousStatus} → ${newStatus} for system ${systemId}`,
|
|
641
605
|
);
|
|
@@ -732,13 +696,14 @@ async function executeHealthCheckJob(props: {
|
|
|
732
696
|
// other.
|
|
733
697
|
const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
|
|
734
698
|
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
// the
|
|
740
|
-
|
|
741
|
-
|
|
699
|
+
// NOTE: the system-rollup status BEFORE this tick is computed LAZILY, only on
|
|
700
|
+
// the catastrophic-failure path that actually consumes it (see the `catch`
|
|
701
|
+
// below). It used to be captured here on EVERY run - a full worst-wins rollup
|
|
702
|
+
// (`getSystemHealthStatus(systemId)`, an N+1 across every check × environment)
|
|
703
|
+
// - even though the normal success/failure paths record their transition from
|
|
704
|
+
// the per-env pre-read (`previousState`, below) and never touch the rollup
|
|
705
|
+
// pre-state. Deferring it to the rare error path removes that whole recompute
|
|
706
|
+
// from the hot path of every check tick.
|
|
742
707
|
|
|
743
708
|
// Slow-check lane admission (set when this run was admitted to the suspect
|
|
744
709
|
// lane); released in the outer finally so the slot frees on any exit path.
|
|
@@ -994,14 +959,16 @@ async function executeHealthCheckJob(props: {
|
|
|
994
959
|
const envEntityId = encodeHealthEntityId({ systemId, environmentId });
|
|
995
960
|
const serializeEnvWrite = makeHealthSerializer(envEntityId);
|
|
996
961
|
|
|
997
|
-
// Per-env baseline
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
962
|
+
// Per-env baseline: the env-scoped aggregate BEFORE this run. Read INSIDE
|
|
963
|
+
// the serialized `apply` below (assigned to these vars), NOT here — so a
|
|
964
|
+
// concurrent same-slice run cannot commit between the baseline read and
|
|
965
|
+
// our own insert. If it were read here (outside the `health:<envEntityId>`
|
|
966
|
+
// lock), the cache change-gate could compare `next` against a baseline a
|
|
967
|
+
// sibling run already superseded and miss a real transition, stranding a
|
|
968
|
+
// stale cached status until the TTL. Assigned by whichever branch's
|
|
969
|
+
// `apply` runs; used for the transition log AND the cache reconcile.
|
|
970
|
+
let previousState!: AggregatedHealth;
|
|
971
|
+
let previousStatus!: HealthCheckStatus;
|
|
1005
972
|
|
|
1006
973
|
// Curated, read-only run-context metadata exposed to collectors.
|
|
1007
974
|
// Metadata only - never secrets or config. `environment` carries the
|
|
@@ -1309,6 +1276,13 @@ async function executeHealthCheckJob(props: {
|
|
|
1309
1276
|
handle: getHealthEntity?.(),
|
|
1310
1277
|
entityId: envEntityId,
|
|
1311
1278
|
apply: async () => {
|
|
1279
|
+
// In-lock pre-run baseline (see the `previousState` declaration): read
|
|
1280
|
+
// here, inside the serialized critical section, before the insert.
|
|
1281
|
+
previousState = await service.getSystemHealthStatus(
|
|
1282
|
+
systemId,
|
|
1283
|
+
environmentId,
|
|
1284
|
+
);
|
|
1285
|
+
previousStatus = previousState.status;
|
|
1312
1286
|
// §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1313
1287
|
// `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
|
|
1314
1288
|
// which also makes the run and its aggregate commit atomically.
|
|
@@ -1356,9 +1330,18 @@ async function executeHealthCheckJob(props: {
|
|
|
1356
1330
|
`Health check ${configId} for system ${systemId} failed: ${finalError}`,
|
|
1357
1331
|
);
|
|
1358
1332
|
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1333
|
+
// Reconcile this environment's cached status: evict + broadcast to the
|
|
1334
|
+
// cluster ONLY when the per-check vector actually changed (a run that
|
|
1335
|
+
// leaves every check's status unchanged keeps the cache warm instead of
|
|
1336
|
+
// thrashing it every tick). The rollup key is reconciled separately by
|
|
1337
|
+
// the debounced rollup consumer (recomputeSystemRollupHealth), also
|
|
1338
|
+
// vector-gated.
|
|
1339
|
+
await cache.reconcile({
|
|
1340
|
+
systemId,
|
|
1341
|
+
environmentId,
|
|
1342
|
+
previous: previousState,
|
|
1343
|
+
next: newState,
|
|
1344
|
+
});
|
|
1362
1345
|
|
|
1363
1346
|
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
1364
1347
|
systemId,
|
|
@@ -1390,6 +1373,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1390
1373
|
systemId,
|
|
1391
1374
|
systemName,
|
|
1392
1375
|
configurationId: configId,
|
|
1376
|
+
configurationName: configRow.configName,
|
|
1393
1377
|
previousStatus,
|
|
1394
1378
|
newStatus: newState.status,
|
|
1395
1379
|
environmentId,
|
|
@@ -1466,6 +1450,13 @@ async function executeHealthCheckJob(props: {
|
|
|
1466
1450
|
handle: getHealthEntity?.(),
|
|
1467
1451
|
entityId: envEntityId,
|
|
1468
1452
|
apply: async () => {
|
|
1453
|
+
// In-lock pre-run baseline (see the `previousState` declaration): read
|
|
1454
|
+
// here, inside the serialized critical section, before the insert.
|
|
1455
|
+
previousState = await service.getSystemHealthStatus(
|
|
1456
|
+
systemId,
|
|
1457
|
+
environmentId,
|
|
1458
|
+
);
|
|
1459
|
+
previousStatus = previousState.status;
|
|
1469
1460
|
// §perf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
|
|
1470
1461
|
// `SET LOCAL search_path` transaction (3 scoped-db transactions → 1),
|
|
1471
1462
|
// which also makes the run and its aggregate commit atomically.
|
|
@@ -1512,9 +1503,16 @@ async function executeHealthCheckJob(props: {
|
|
|
1512
1503
|
`Ran health check ${configId} for system ${systemId}: ${result.status}`,
|
|
1513
1504
|
);
|
|
1514
1505
|
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1506
|
+
// Reconcile this environment's cached status: evict + broadcast to the
|
|
1507
|
+
// cluster ONLY when the per-check vector actually changed (a steady-state
|
|
1508
|
+
// healthy run keeps the cache warm). The rollup key is reconciled by the
|
|
1509
|
+
// debounced rollup consumer (recomputeSystemRollupHealth), also vector-gated.
|
|
1510
|
+
await cache.reconcile({
|
|
1511
|
+
systemId,
|
|
1512
|
+
environmentId,
|
|
1513
|
+
previous: previousState,
|
|
1514
|
+
next: newState,
|
|
1515
|
+
});
|
|
1518
1516
|
|
|
1519
1517
|
// Broadcast enriched signal for realtime frontend updates (e.g., terminal feed)
|
|
1520
1518
|
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
@@ -1557,6 +1555,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1557
1555
|
systemId,
|
|
1558
1556
|
systemName,
|
|
1559
1557
|
configurationId: configId,
|
|
1558
|
+
configurationName: configRow.configName,
|
|
1560
1559
|
previousStatus,
|
|
1561
1560
|
newStatus: newState.status,
|
|
1562
1561
|
environmentId,
|
|
@@ -1620,12 +1619,19 @@ async function executeHealthCheckJob(props: {
|
|
|
1620
1619
|
// the system-level health change still emits. Reuses the pre-tick
|
|
1621
1620
|
// rollup status captured before the try block.
|
|
1622
1621
|
const rollupEntityId = encodeHealthEntityId({ systemId });
|
|
1623
|
-
|
|
1622
|
+
// The pre-failure rollup baseline. Read INSIDE `apply` (inside the
|
|
1623
|
+
// `health:<systemId>` lock), before the failure-run insert, so a concurrent
|
|
1624
|
+
// catastrophic tick for the same system can't commit between the baseline
|
|
1625
|
+
// read and this insert and make the cache change-gate miss a transition.
|
|
1626
|
+
let rollupPreState!: AggregatedHealth;
|
|
1627
|
+
let previousStatus!: HealthCheckStatus;
|
|
1624
1628
|
let newState!: AggregatedHealth;
|
|
1625
1629
|
await writeHealthEntity({
|
|
1626
1630
|
handle: getHealthEntity?.(),
|
|
1627
1631
|
entityId: rollupEntityId,
|
|
1628
1632
|
apply: async () => {
|
|
1633
|
+
rollupPreState = await service.getSystemHealthStatus(systemId);
|
|
1634
|
+
previousStatus = rollupPreState.status;
|
|
1629
1635
|
// §perf: batch the failure run INSERT + aggregate SELECT/UPSERT under
|
|
1630
1636
|
// ONE `SET LOCAL search_path` transaction (3 scoped-db transactions →
|
|
1631
1637
|
// 1), which also makes them commit atomically.
|
|
@@ -1684,9 +1690,15 @@ async function executeHealthCheckJob(props: {
|
|
|
1684
1690
|
// Use IDs as fallback
|
|
1685
1691
|
}
|
|
1686
1692
|
|
|
1687
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
|
|
1693
|
+
// Reconcile the rollup cache: evict + broadcast only on a real vector
|
|
1694
|
+
// change. This catastrophic path writes the bare `<systemId>` entity (it IS
|
|
1695
|
+
// the rollup), so it owns the rollup key directly — no debounced consumer
|
|
1696
|
+
// runs for it.
|
|
1697
|
+
await cache.reconcile({
|
|
1698
|
+
systemId,
|
|
1699
|
+
previous: rollupPreState,
|
|
1700
|
+
next: newState,
|
|
1701
|
+
});
|
|
1690
1702
|
|
|
1691
1703
|
// Broadcast enriched failure signal for realtime frontend updates
|
|
1692
1704
|
await signalService.broadcast(HEALTH_CHECK_RUN_COMPLETED, {
|
|
@@ -1723,6 +1735,7 @@ async function executeHealthCheckJob(props: {
|
|
|
1723
1735
|
systemId,
|
|
1724
1736
|
systemName,
|
|
1725
1737
|
configurationId: configId,
|
|
1738
|
+
configurationName: configName,
|
|
1726
1739
|
previousStatus,
|
|
1727
1740
|
newStatus: newState.status,
|
|
1728
1741
|
service,
|
package/src/retention-job.ts
CHANGED
|
@@ -153,6 +153,14 @@ async function deleteExpiredRawRuns(params: DeleteExpiredRawRunsParams) {
|
|
|
153
153
|
const cutoffDate = new Date();
|
|
154
154
|
cutoffDate.setDate(cutoffDate.getDate() - rawRetentionDays);
|
|
155
155
|
|
|
156
|
+
// Status-cache note: this delete does NOT invalidate the system-health status
|
|
157
|
+
// cache, and deliberately need not. `evaluateHealthStatus` derives status from
|
|
158
|
+
// the most-recent-N runs (count-based, no time component), and the cutoff is
|
|
159
|
+
// days old, so for any ACTIVELY-running check the deleted rows are already
|
|
160
|
+
// outside the evaluation window — removing them cannot change the current
|
|
161
|
+
// derived status. The only case it could is a check that STOPPED running long
|
|
162
|
+
// enough for its entire history to age past the cutoff; that flip is bounded by
|
|
163
|
+
// the 15s status-cache TTL (an acceptable, rare edge for a stopped check).
|
|
156
164
|
await db
|
|
157
165
|
.delete(healthCheckRuns)
|
|
158
166
|
.where(
|
|
@@ -35,7 +35,7 @@ interface Harness {
|
|
|
35
35
|
changeHandler: (change: EntityChanged) => Promise<void>;
|
|
36
36
|
getSystemHealthStatus: ReturnType<typeof mock>;
|
|
37
37
|
broadcast: ReturnType<typeof mock>;
|
|
38
|
-
|
|
38
|
+
reconcile: ReturnType<typeof mock>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async function setup(opts: {
|
|
@@ -75,7 +75,7 @@ async function setup(opts: {
|
|
|
75
75
|
}) as unknown as OnEntityChanged;
|
|
76
76
|
|
|
77
77
|
const broadcast = mock(async () => {});
|
|
78
|
-
const
|
|
78
|
+
const reconcile = mock(async () => {});
|
|
79
79
|
|
|
80
80
|
await setupRollupConsumer({
|
|
81
81
|
queueManager,
|
|
@@ -85,7 +85,7 @@ async function setup(opts: {
|
|
|
85
85
|
withXactLock: async ({ fn }: { fn: () => Promise<unknown> }) => fn(),
|
|
86
86
|
} as never,
|
|
87
87
|
signalService: { broadcast } as never,
|
|
88
|
-
cache: {
|
|
88
|
+
cache: { reconcile } as never,
|
|
89
89
|
// No entity handle: writeHealthEntity runs `apply` directly.
|
|
90
90
|
getHealthEntity: () => undefined,
|
|
91
91
|
logger: {
|
|
@@ -103,7 +103,7 @@ async function setup(opts: {
|
|
|
103
103
|
changeHandler,
|
|
104
104
|
getSystemHealthStatus,
|
|
105
105
|
broadcast,
|
|
106
|
-
|
|
106
|
+
reconcile,
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -162,12 +162,21 @@ describe("setupRollupConsumer subscription", () => {
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
describe("setupRollupConsumer rollup recompute", () => {
|
|
165
|
-
it("
|
|
165
|
+
it("reconciles the cache + broadcasts SYSTEM_STATUS_CHANGED on a rollup status change", async () => {
|
|
166
166
|
const h = await setup({ statuses: ["healthy", "unhealthy"] });
|
|
167
167
|
await h.consumeHandler({ data: { systemId: "s1" } });
|
|
168
168
|
|
|
169
169
|
expect(h.getSystemHealthStatus).toHaveBeenCalled();
|
|
170
|
-
|
|
170
|
+
// Reconcile is handed the FULL prev/next states; the change-gating (evict +
|
|
171
|
+
// cluster broadcast only on a per-check vector change) lives inside the cache
|
|
172
|
+
// (see cache.test.ts), so recompute always calls it with both states.
|
|
173
|
+
expect(h.reconcile).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(h.reconcile.mock.calls[0]![0]).toMatchObject({
|
|
175
|
+
systemId: "s1",
|
|
176
|
+
previous: { status: "healthy" },
|
|
177
|
+
next: { status: "unhealthy" },
|
|
178
|
+
});
|
|
179
|
+
// The frontend signal IS gated here on the rollup-enum transition.
|
|
171
180
|
expect(h.broadcast).toHaveBeenCalledTimes(1);
|
|
172
181
|
const payload = h.broadcast.mock.calls[0]![1] as {
|
|
173
182
|
systemId: string;
|
|
@@ -181,11 +190,13 @@ describe("setupRollupConsumer rollup recompute", () => {
|
|
|
181
190
|
});
|
|
182
191
|
});
|
|
183
192
|
|
|
184
|
-
it("does NOT broadcast when the rollup status is unchanged", async () => {
|
|
193
|
+
it("does NOT broadcast the frontend signal when the rollup status is unchanged", async () => {
|
|
185
194
|
const h = await setup({ statuses: ["degraded", "degraded"] });
|
|
186
195
|
await h.consumeHandler({ data: { systemId: "s1" } });
|
|
187
196
|
|
|
197
|
+
// No enum transition ⇒ no SYSTEM_STATUS_CHANGED frontend signal. Reconcile is
|
|
198
|
+
// still invoked (its own fingerprint gate no-ops for an unchanged vector).
|
|
188
199
|
expect(h.broadcast).not.toHaveBeenCalled();
|
|
189
|
-
expect(h.
|
|
200
|
+
expect(h.reconcile).toHaveBeenCalledTimes(1);
|
|
190
201
|
});
|
|
191
202
|
});
|