@checkstack/healthcheck-backend 1.16.0 → 1.18.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 +399 -0
- package/package.json +30 -29
- package/src/adaptive-timeout.test.ts +91 -0
- package/src/adaptive-timeout.ts +75 -0
- package/src/ai/system-signals-contributor.test.ts +2 -0
- package/src/automations.test.ts +47 -0
- package/src/automations.ts +19 -3
- package/src/healthcheck-gitops-kinds.test.ts +34 -2
- package/src/healthcheck-gitops-kinds.ts +17 -13
- package/src/index.ts +87 -6
- package/src/migration-chain-contract.test.ts +7 -1
- package/src/notification-policy.test.ts +19 -0
- package/src/notification-policy.ts +26 -0
- package/src/queue-executor.test.ts +391 -338
- package/src/queue-executor.ts +395 -294
- package/src/realtime-aggregation.ts +9 -2
- package/src/rollup-consumer.test.ts +191 -0
- package/src/rollup-consumer.ts +160 -0
- package/src/router.ts +103 -19
- package/src/schedule-jitter.test.ts +69 -0
- package/src/schedule-jitter.ts +50 -0
- package/src/schedule-reconciler.it.test.ts +453 -0
- package/src/schedule-reconciler.test.ts +418 -0
- package/src/schedule-reconciler.ts +304 -0
- package/src/service-batching.test.ts +98 -0
- package/src/service-ordering.test.ts +4 -0
- package/src/service-paused-filter.test.ts +14 -7
- package/src/service-rollup-worst-wins.test.ts +37 -4
- package/src/service.ts +348 -145
- package/src/slow-check-admission.test.ts +184 -0
- package/src/slow-check-admission.ts +101 -0
- package/src/slow-check-classifier.test.ts +155 -0
- package/src/slow-check-classifier.ts +137 -0
- package/src/slow-check-config.ts +102 -0
- package/src/status-page/widgets.ts +11 -1
- package/src/suspect-lane.test.ts +50 -0
- package/src/suspect-lane.ts +61 -0
- package/src/system-health-override.test.ts +94 -0
- package/src/system-health-override.ts +93 -0
package/src/automations.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
assignmentArtifactType,
|
|
11
11
|
checkFailedTrigger,
|
|
12
12
|
createHealthCheckActions,
|
|
13
|
+
type HealthCheckActionDeps,
|
|
13
14
|
healthCheckTriggers,
|
|
14
15
|
systemDegradedTrigger,
|
|
15
16
|
systemHealthChangedTrigger,
|
|
@@ -146,13 +147,18 @@ describe("assignmentArtifactType", () => {
|
|
|
146
147
|
|
|
147
148
|
function makeService(args: {
|
|
148
149
|
setAssignmentEnabledReturn?: boolean;
|
|
150
|
+
enqueueEnvironmentIds?: (string | null)[];
|
|
149
151
|
}): HealthCheckService & { setMock: ReturnType<typeof mock> } {
|
|
150
152
|
const setMock = mock(
|
|
151
153
|
async (_sysId: string, _cfgId: string, _enabled: boolean) =>
|
|
152
154
|
args.setAssignmentEnabledReturn ?? true,
|
|
153
155
|
);
|
|
156
|
+
const resolveEnqueueEnvironmentIds = mock(
|
|
157
|
+
async () => args.enqueueEnvironmentIds ?? [null],
|
|
158
|
+
);
|
|
154
159
|
return {
|
|
155
160
|
setAssignmentEnabled: setMock,
|
|
161
|
+
resolveEnqueueEnvironmentIds,
|
|
156
162
|
setMock,
|
|
157
163
|
} as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
|
|
158
164
|
}
|
|
@@ -174,6 +180,11 @@ function makeQueueManager(): QueueEnqueueRecorder {
|
|
|
174
180
|
return { queueManager, enqueueMock };
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
// The actions only need a catalog client shape for `run_now`, which delegates
|
|
184
|
+
// environment resolution to the service mock, so a bare stub suffices.
|
|
185
|
+
const catalogClientStub =
|
|
186
|
+
{} as unknown as HealthCheckActionDeps["catalogClient"];
|
|
187
|
+
|
|
177
188
|
describe("healthcheck.run_now", () => {
|
|
178
189
|
it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
|
|
179
190
|
const service = makeService({});
|
|
@@ -182,6 +193,7 @@ describe("healthcheck.run_now", () => {
|
|
|
182
193
|
const [runNow] = createHealthCheckActions({
|
|
183
194
|
service,
|
|
184
195
|
queueManager,
|
|
196
|
+
catalogClient: catalogClientStub,
|
|
185
197
|
emitHook: emitHook as never,
|
|
186
198
|
});
|
|
187
199
|
|
|
@@ -198,10 +210,42 @@ describe("healthcheck.run_now", () => {
|
|
|
198
210
|
expect(enqueueMock.mock.calls[0]![0]).toEqual({
|
|
199
211
|
configId: "cfg-1",
|
|
200
212
|
systemId: "sys-1",
|
|
213
|
+
environmentId: null,
|
|
201
214
|
});
|
|
202
215
|
// run_now doesn't mutate any DB row → no hook to emit.
|
|
203
216
|
expect(emitHook).not.toHaveBeenCalled();
|
|
204
217
|
});
|
|
218
|
+
|
|
219
|
+
it("enqueues one job per effective environment slice", async () => {
|
|
220
|
+
const service = makeService({ enqueueEnvironmentIds: ["prod", "staging"] });
|
|
221
|
+
const { queueManager, enqueueMock } = makeQueueManager();
|
|
222
|
+
const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
|
|
223
|
+
const [runNow] = createHealthCheckActions({
|
|
224
|
+
service,
|
|
225
|
+
queueManager,
|
|
226
|
+
catalogClient: catalogClientStub,
|
|
227
|
+
emitHook: emitHook as never,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const result = await runNow!.execute({
|
|
231
|
+
...ctxBase,
|
|
232
|
+
consumedArtifacts: {},
|
|
233
|
+
config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.success).toBe(true);
|
|
237
|
+
expect(enqueueMock).toHaveBeenCalledTimes(2);
|
|
238
|
+
expect(enqueueMock.mock.calls[0]![0]).toEqual({
|
|
239
|
+
configId: "cfg-1",
|
|
240
|
+
systemId: "sys-1",
|
|
241
|
+
environmentId: "prod",
|
|
242
|
+
});
|
|
243
|
+
expect(enqueueMock.mock.calls[1]![0]).toEqual({
|
|
244
|
+
configId: "cfg-1",
|
|
245
|
+
systemId: "sys-1",
|
|
246
|
+
environmentId: "staging",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
205
249
|
});
|
|
206
250
|
|
|
207
251
|
describe("healthcheck.enable_assignment", () => {
|
|
@@ -212,6 +256,7 @@ describe("healthcheck.enable_assignment", () => {
|
|
|
212
256
|
const [, enable] = createHealthCheckActions({
|
|
213
257
|
service,
|
|
214
258
|
queueManager,
|
|
259
|
+
catalogClient: catalogClientStub,
|
|
215
260
|
emitHook: emitHook as never,
|
|
216
261
|
});
|
|
217
262
|
|
|
@@ -236,6 +281,7 @@ describe("healthcheck.enable_assignment", () => {
|
|
|
236
281
|
const [, enable] = createHealthCheckActions({
|
|
237
282
|
service,
|
|
238
283
|
queueManager,
|
|
284
|
+
catalogClient: catalogClientStub,
|
|
239
285
|
emitHook: emitHook as never,
|
|
240
286
|
});
|
|
241
287
|
|
|
@@ -260,6 +306,7 @@ describe("healthcheck.disable_assignment", () => {
|
|
|
260
306
|
const [, , disable] = createHealthCheckActions({
|
|
261
307
|
service,
|
|
262
308
|
queueManager,
|
|
309
|
+
catalogClient: catalogClientStub,
|
|
263
310
|
emitHook: emitHook as never,
|
|
264
311
|
});
|
|
265
312
|
|
package/src/automations.ts
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
import { Versioned, type Hook } from "@checkstack/backend-api";
|
|
28
28
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
29
|
+
import type { InferClient } from "@checkstack/common";
|
|
30
|
+
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
29
31
|
import type {
|
|
30
32
|
ActionDefinition,
|
|
31
33
|
TriggerDefinition,
|
|
@@ -234,6 +236,7 @@ export const assignmentArtifactType = {
|
|
|
234
236
|
export interface HealthCheckActionDeps {
|
|
235
237
|
service: HealthCheckService;
|
|
236
238
|
queueManager: QueueManager;
|
|
239
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
237
240
|
emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
|
|
238
241
|
}
|
|
239
242
|
|
|
@@ -256,12 +259,25 @@ export function createHealthCheckActions(
|
|
|
256
259
|
const queue = deps.queueManager.getQueue<HealthCheckJobPayload>(
|
|
257
260
|
HEALTH_CHECK_QUEUE,
|
|
258
261
|
);
|
|
259
|
-
|
|
260
|
-
|
|
262
|
+
// Enqueue one one-off job per effective environment slice so a manual
|
|
263
|
+
// run covers exactly the same slices the recurring schedule does. An
|
|
264
|
+
// assignment with no effective environments enqueues a single env-less
|
|
265
|
+
// run (`environmentId: null`).
|
|
266
|
+
const environmentIds = await deps.service.resolveEnqueueEnvironmentIds({
|
|
261
267
|
systemId: config.systemId,
|
|
268
|
+
configurationId: config.configurationId,
|
|
269
|
+
catalogClient: deps.catalogClient,
|
|
270
|
+
logger,
|
|
262
271
|
});
|
|
272
|
+
for (const environmentId of environmentIds) {
|
|
273
|
+
await queue.enqueue({
|
|
274
|
+
configId: config.configurationId,
|
|
275
|
+
systemId: config.systemId,
|
|
276
|
+
environmentId,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
263
279
|
logger.info(
|
|
264
|
-
`Automation enqueued run for ${config.systemId}:${config.configurationId}`,
|
|
280
|
+
`Automation enqueued ${environmentIds.length} run(s) for ${config.systemId}:${config.configurationId}`,
|
|
265
281
|
);
|
|
266
282
|
return {
|
|
267
283
|
success: true,
|
|
@@ -20,6 +20,34 @@ import { Versioned } from "@checkstack/backend-api";
|
|
|
20
20
|
* System → healthchecks extension in isolation with mock services.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
// The System extension's reconcile converges the per-env recurring jobs via
|
|
24
|
+
// `reconcileHealthCheckJobs`. That path has its own dedicated tests
|
|
25
|
+
// (schedule-reconciler.test.ts); here we feed it EMPTY db/catalog/queue stubs so
|
|
26
|
+
// it is a safe no-op and these tests can focus on the association logic.
|
|
27
|
+
// (A `mock.module` would leak process-wide and break schedule-reconciler.test.)
|
|
28
|
+
function emptyReconcileDbStub() {
|
|
29
|
+
return {
|
|
30
|
+
select: () => ({
|
|
31
|
+
from: () => ({
|
|
32
|
+
innerJoin: () => ({ where: () => Promise.resolve([]) }),
|
|
33
|
+
groupBy: () => Promise.resolve([]),
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
} as never;
|
|
37
|
+
}
|
|
38
|
+
function emptyReconcileQueueManager() {
|
|
39
|
+
return {
|
|
40
|
+
getQueue: () => ({
|
|
41
|
+
scheduleRecurring: async () => "job-123",
|
|
42
|
+
listRecurringJobs: async () => [],
|
|
43
|
+
getRecurringJobDetails: async () => undefined,
|
|
44
|
+
cancelRecurring: async () => {},
|
|
45
|
+
}),
|
|
46
|
+
} as never;
|
|
47
|
+
}
|
|
48
|
+
const emptyCatalogClient = () =>
|
|
49
|
+
({ resolveSystemEnvironments: async () => [] }) as never;
|
|
50
|
+
|
|
23
51
|
// ─── Mock Healthcheck Service ──────────────────────────────────────────────
|
|
24
52
|
|
|
25
53
|
interface MockConfig {
|
|
@@ -231,7 +259,9 @@ describe("Healthcheck GitOps Kind: Healthcheck", () => {
|
|
|
231
259
|
createService: () => mockService as any,
|
|
232
260
|
getHealthCheckRegistry: () => mockHCRegistry as any,
|
|
233
261
|
getCollectorRegistry: () => mockCollectorRegistry as any,
|
|
234
|
-
getQueueManager: () => (
|
|
262
|
+
getQueueManager: () => emptyReconcileQueueManager(),
|
|
263
|
+
getDb: () => emptyReconcileDbStub(),
|
|
264
|
+
getCatalogClient: () => emptyCatalogClient(),
|
|
235
265
|
};
|
|
236
266
|
return buildHealthcheckKind(mockDeps);
|
|
237
267
|
}
|
|
@@ -594,7 +624,9 @@ describe("Healthcheck GitOps Kind: System Extension", () => {
|
|
|
594
624
|
}) as never,
|
|
595
625
|
getHealthCheckRegistry: () => createMockHealthCheckRegistry() as never,
|
|
596
626
|
getCollectorRegistry: () => createMockCollectorRegistry() as never,
|
|
597
|
-
getQueueManager: () => (
|
|
627
|
+
getQueueManager: () => emptyReconcileQueueManager(),
|
|
628
|
+
getDb: () => emptyReconcileDbStub(),
|
|
629
|
+
getCatalogClient: () => emptyCatalogClient(),
|
|
598
630
|
});
|
|
599
631
|
}
|
|
600
632
|
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
enumField,
|
|
26
26
|
} from "@checkstack/backend-api";
|
|
27
27
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
28
|
-
import {
|
|
28
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
29
|
+
import type { InferClient } from "@checkstack/common";
|
|
30
|
+
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
31
|
+
import type * as schema from "./schema";
|
|
32
|
+
import { reconcileHealthCheckJobs } from "./schedule-reconciler";
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* Lazy accessor functions — populated during init(), consumed during reconcile.
|
|
@@ -37,6 +41,8 @@ interface HealthcheckGitOpsKindsDeps {
|
|
|
37
41
|
getHealthCheckRegistry: () => HealthCheckRegistry;
|
|
38
42
|
getCollectorRegistry: () => CollectorRegistry;
|
|
39
43
|
getQueueManager: () => QueueManager;
|
|
44
|
+
getDb: () => SafeDatabase<typeof schema>;
|
|
45
|
+
getCatalogClient: () => InferClient<typeof CatalogApi>;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// ─── Healthcheck Spec Schema ───────────────────────────────────────────────
|
|
@@ -382,18 +388,16 @@ export function buildSystemHealthcheckExtension(
|
|
|
382
388
|
notificationPolicy,
|
|
383
389
|
});
|
|
384
390
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
});
|
|
396
|
-
}
|
|
391
|
+
// Reconcile this system's per-env recurring jobs so the new
|
|
392
|
+
// association starts probing right away (system-scoped: add/update
|
|
393
|
+
// only, orphan cleanup is owned by the periodic full reconcile).
|
|
394
|
+
await reconcileHealthCheckJobs({
|
|
395
|
+
db: deps.getDb(),
|
|
396
|
+
queueManager: deps.getQueueManager(),
|
|
397
|
+
catalogClient: deps.getCatalogClient(),
|
|
398
|
+
logger: context.logger,
|
|
399
|
+
systemId: systemEntityId,
|
|
400
|
+
});
|
|
397
401
|
|
|
398
402
|
context.logger.info(
|
|
399
403
|
`GitOps: associated ${entry.ref.kind} "${entry.ref.name}" (${configId}) with System "${entity.metadata.name}" and scheduled execution`,
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
setupHealthCheckWorker,
|
|
3
|
-
bootstrapHealthChecks,
|
|
4
3
|
recomputeSystemRollupHealth,
|
|
5
4
|
} from "./queue-executor";
|
|
5
|
+
import { reconcileHealthCheckJobs } from "./schedule-reconciler";
|
|
6
6
|
import { setupRetentionJob } from "./retention-job";
|
|
7
7
|
import * as schema from "./schema";
|
|
8
8
|
import {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type SafeDatabase,
|
|
39
39
|
type HealthCheckRegistry,
|
|
40
40
|
type CollectorRegistry,
|
|
41
|
+
type AdvisoryLockService,
|
|
41
42
|
} from "@checkstack/backend-api";
|
|
42
43
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
43
44
|
import {
|
|
@@ -75,6 +76,9 @@ import { IncidentApi } from "@checkstack/incident-common";
|
|
|
75
76
|
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
76
77
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
77
78
|
import { resolveRoute } from "@checkstack/common";
|
|
79
|
+
import type { InferClient } from "@checkstack/common";
|
|
80
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
81
|
+
import { setupRollupConsumer } from "./rollup-consumer";
|
|
78
82
|
import { createHealthCheckCache } from "./cache";
|
|
79
83
|
import { inArray, ilike } from "drizzle-orm";
|
|
80
84
|
|
|
@@ -174,6 +178,12 @@ export default createBackendPlugin({
|
|
|
174
178
|
let gitopsCollectorRegistry: CollectorRegistry | undefined;
|
|
175
179
|
let gitopsQueueManager: QueueManager | undefined;
|
|
176
180
|
let gitopsConfigSecrets: HealthCheckSecretsDeps | undefined;
|
|
181
|
+
let gitopsCatalogClient: InferClient<typeof CatalogApi> | undefined;
|
|
182
|
+
// Resolved AdvisoryLockService captured in init() for use in
|
|
183
|
+
// afterPluginsReady (the boot reconcile serializes across pods on it).
|
|
184
|
+
let resolvedAdvisoryLock: AdvisoryLockService | undefined;
|
|
185
|
+
// SignalService captured in init() for the afterPluginsReady rollup consumer.
|
|
186
|
+
let resolvedSignalService: SignalService | undefined;
|
|
177
187
|
let healthCheckCache:
|
|
178
188
|
| ReturnType<typeof createHealthCheckCache>
|
|
179
189
|
| undefined;
|
|
@@ -211,6 +221,15 @@ export default createBackendPlugin({
|
|
|
211
221
|
throw new Error("QueueManager not initialized");
|
|
212
222
|
return gitopsQueueManager;
|
|
213
223
|
},
|
|
224
|
+
getDb: () => {
|
|
225
|
+
if (!gitopsDb) throw new Error("Healthcheck database not initialized");
|
|
226
|
+
return gitopsDb;
|
|
227
|
+
},
|
|
228
|
+
getCatalogClient: () => {
|
|
229
|
+
if (!gitopsCatalogClient)
|
|
230
|
+
throw new Error("Catalog client not initialized");
|
|
231
|
+
return gitopsCatalogClient;
|
|
232
|
+
},
|
|
214
233
|
});
|
|
215
234
|
|
|
216
235
|
env.registerInit({
|
|
@@ -317,6 +336,9 @@ export default createBackendPlugin({
|
|
|
317
336
|
gitopsHealthCheckRegistry = healthCheckRegistry;
|
|
318
337
|
gitopsCollectorRegistry = collectorRegistry;
|
|
319
338
|
gitopsQueueManager = queueManager;
|
|
339
|
+
gitopsCatalogClient = rpcClient.forPlugin(CatalogApi);
|
|
340
|
+
resolvedAdvisoryLock = advisoryLock;
|
|
341
|
+
resolvedSignalService = signalService;
|
|
320
342
|
|
|
321
343
|
// Bind the COMPUTE-ON-READ accessor's db + service for the `health`
|
|
322
344
|
// entity (defined in register()). From here onward the entity `read`
|
|
@@ -524,8 +546,37 @@ export default createBackendPlugin({
|
|
|
524
546
|
logger,
|
|
525
547
|
signalService,
|
|
526
548
|
configSecrets,
|
|
527
|
-
|
|
528
|
-
|
|
549
|
+
// Fold active incident health overrides into the user-facing system
|
|
550
|
+
// health reads (worst-wins). Reuses the incident client already built
|
|
551
|
+
// above; maps the incident rows to the source-agnostic override shape
|
|
552
|
+
// the fold expects. Kept out of the shared deriver so SLO/AI/entity
|
|
553
|
+
// paths stay checks-only (see router.ts).
|
|
554
|
+
incidentHealthOverrideReader: {
|
|
555
|
+
getActiveOverrides: async (systemIds) => {
|
|
556
|
+
const { overrides } =
|
|
557
|
+
await incidentClient.getActiveHealthOverrides({ systemIds });
|
|
558
|
+
const mapped: Record<
|
|
559
|
+
string,
|
|
560
|
+
{
|
|
561
|
+
status: "degraded" | "unhealthy";
|
|
562
|
+
source: string;
|
|
563
|
+
reason: string;
|
|
564
|
+
sourceId?: string;
|
|
565
|
+
}[]
|
|
566
|
+
> = {};
|
|
567
|
+
for (const [systemId, list] of Object.entries(overrides)) {
|
|
568
|
+
mapped[systemId] = list.map((o) => ({
|
|
569
|
+
status: o.status,
|
|
570
|
+
source: "incident",
|
|
571
|
+
reason: o.incidentTitle,
|
|
572
|
+
sourceId: o.incidentId,
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
return mapped;
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
recomputeSystemRollupHealth: async (systemId) => {
|
|
579
|
+
await recomputeSystemRollupHealth({
|
|
529
580
|
systemId,
|
|
530
581
|
// Reuse the COMPUTE-ON-READ service instance bound to the
|
|
531
582
|
// `health` entity read accessor — it's the same db/registry
|
|
@@ -534,7 +585,8 @@ export default createBackendPlugin({
|
|
|
534
585
|
getHealthEntity: () => healthEntity,
|
|
535
586
|
advisoryLock,
|
|
536
587
|
logger,
|
|
537
|
-
})
|
|
588
|
+
});
|
|
589
|
+
},
|
|
538
590
|
});
|
|
539
591
|
rpc.registerRouter(healthCheckRouter, healthCheckContract);
|
|
540
592
|
|
|
@@ -578,11 +630,17 @@ export default createBackendPlugin({
|
|
|
578
630
|
}) => {
|
|
579
631
|
// Store emitHook for the queue worker (Closure-based Hook Getter pattern)
|
|
580
632
|
storedEmitHook = emitHook;
|
|
581
|
-
//
|
|
582
|
-
|
|
633
|
+
// Converge the per-environment recurring job set at boot (schedule
|
|
634
|
+
// desired (config, system, env) jobs, cancel orphans incl. old-format
|
|
635
|
+
// ones). The periodic reconcile below keeps it converged as catalog
|
|
636
|
+
// membership changes.
|
|
637
|
+
const reconcileCatalogClient = rpcClient.forPlugin(CatalogApi);
|
|
638
|
+
await reconcileHealthCheckJobs({
|
|
583
639
|
db: database,
|
|
584
640
|
queueManager,
|
|
641
|
+
catalogClient: reconcileCatalogClient,
|
|
585
642
|
logger,
|
|
643
|
+
advisoryLock: resolvedAdvisoryLock,
|
|
586
644
|
});
|
|
587
645
|
|
|
588
646
|
// Notification subscription specs. Per-resource group lifecycle
|
|
@@ -620,11 +678,34 @@ export default createBackendPlugin({
|
|
|
620
678
|
for (const action of createHealthCheckActions({
|
|
621
679
|
service,
|
|
622
680
|
queueManager,
|
|
681
|
+
catalogClient: reconcileCatalogClient,
|
|
623
682
|
emitHook,
|
|
624
683
|
})) {
|
|
625
684
|
automationActions.registerAction(action, pluginMetadata);
|
|
626
685
|
}
|
|
627
686
|
|
|
687
|
+
// Phase 2: event-driven debounced system-rollup consumer. Under
|
|
688
|
+
// per-environment jobs each run writes only its own env entity; this
|
|
689
|
+
// subscribes to per-env `health` changes and debounces a recompute of
|
|
690
|
+
// the bare `<systemId>` rollup entity (+ SYSTEM_STATUS_CHANGED). Needs
|
|
691
|
+
// the cache/signal/lock resolved in init().
|
|
692
|
+
if (resolvedAdvisoryLock && resolvedSignalService && healthCheckCache) {
|
|
693
|
+
await setupRollupConsumer({
|
|
694
|
+
queueManager,
|
|
695
|
+
onEntityChanged: entityPoint.onEntityChanged,
|
|
696
|
+
service,
|
|
697
|
+
advisoryLock: resolvedAdvisoryLock,
|
|
698
|
+
signalService: resolvedSignalService,
|
|
699
|
+
cache: healthCheckCache,
|
|
700
|
+
getHealthEntity: () => healthEntity,
|
|
701
|
+
logger,
|
|
702
|
+
});
|
|
703
|
+
} else {
|
|
704
|
+
logger.warn(
|
|
705
|
+
"Health rollup consumer NOT wired: advisoryLock/signalService/cache unresolved after init",
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
628
709
|
// React to catalog system deletion (tombstone) via the reactive
|
|
629
710
|
// `catalog-system` entity instead of the (removed) `system.deleted`
|
|
630
711
|
// hook (§10.4). `work-queue` delivery preserved: association cleanup
|
|
@@ -19,7 +19,10 @@ import { describe, expect, it } from "bun:test";
|
|
|
19
19
|
import type { QueueManager } from "@checkstack/queue-api";
|
|
20
20
|
import type { Hook } from "@checkstack/backend-api";
|
|
21
21
|
import { stateThresholds } from "./state-thresholds-migrations";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createHealthCheckActions,
|
|
24
|
+
type HealthCheckActionDeps,
|
|
25
|
+
} from "./automations";
|
|
23
26
|
import type { HealthCheckService } from "./service";
|
|
24
27
|
|
|
25
28
|
// `createHealthCheckActions` only constructs the action definitions; the deps
|
|
@@ -27,6 +30,8 @@ import type { HealthCheckService } from "./service";
|
|
|
27
30
|
// Stubs are sufficient.
|
|
28
31
|
const stubService = {} as unknown as HealthCheckService;
|
|
29
32
|
const stubQueueManager = {} as unknown as QueueManager;
|
|
33
|
+
const stubCatalogClient =
|
|
34
|
+
{} as unknown as HealthCheckActionDeps["catalogClient"];
|
|
30
35
|
const stubEmitHook = async <T>(_hook: Hook<T>, _payload: T): Promise<void> => {};
|
|
31
36
|
|
|
32
37
|
describe("healthcheck config migration-chain contract", () => {
|
|
@@ -42,6 +47,7 @@ describe("healthcheck config migration-chain contract", () => {
|
|
|
42
47
|
const actions = createHealthCheckActions({
|
|
43
48
|
service: stubService,
|
|
44
49
|
queueManager: stubQueueManager,
|
|
50
|
+
catalogClient: stubCatalogClient,
|
|
45
51
|
emitHook: stubEmitHook,
|
|
46
52
|
});
|
|
47
53
|
expect(actions.length).toBeGreaterThan(0);
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "@checkstack/healthcheck-common";
|
|
6
6
|
import {
|
|
7
7
|
classifyTransition,
|
|
8
|
+
shouldEmitRollupNotification,
|
|
8
9
|
shouldNotifyTransition,
|
|
9
10
|
type TransitionKind,
|
|
10
11
|
} from "./notification-policy";
|
|
@@ -79,6 +80,24 @@ describe("shouldNotifyTransition", () => {
|
|
|
79
80
|
});
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
describe("shouldEmitRollupNotification", () => {
|
|
84
|
+
// Regression guard for the duplicate-notification bug: a fanned-out system
|
|
85
|
+
// whose environment goes unhealthy notified once per env ("... in env X").
|
|
86
|
+
// The rollup notification ("... is unhealthy") describes the same outage, so
|
|
87
|
+
// it must be suppressed whenever an environment already notified this tick.
|
|
88
|
+
it("suppresses the rollup notification when an environment already notified", () => {
|
|
89
|
+
expect(
|
|
90
|
+
shouldEmitRollupNotification({ anyEnvironmentNotified: true }),
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("emits the rollup notification as a fallback when no environment notified", () => {
|
|
95
|
+
expect(
|
|
96
|
+
shouldEmitRollupNotification({ anyEnvironmentNotified: false }),
|
|
97
|
+
).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
82
101
|
describe("flapping scenario from the bug report", () => {
|
|
83
102
|
// healthy → degraded → unhealthy → degraded → healthy
|
|
84
103
|
//
|
|
@@ -54,3 +54,29 @@ export function shouldNotifyTransition(
|
|
|
54
54
|
if (kind === "deescalation" && policy.suppressDeEscalations) return false;
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether the system-ROLLUP notification should fire for a fanned-out
|
|
60
|
+
* system in a given tick.
|
|
61
|
+
*
|
|
62
|
+
* When a check fans out to environments, each environment that changes status
|
|
63
|
+
* emits its own notification ("system unhealthy in env X"). The post-loop
|
|
64
|
+
* rollup transition ("system unhealthy") then describes the SAME underlying
|
|
65
|
+
* outage, so firing it too produces the duplicate notification pair users see.
|
|
66
|
+
*
|
|
67
|
+
* The rollup change is always driven by the very environment(s) that already
|
|
68
|
+
* notified this tick, so the rollup notification is redundant whenever any
|
|
69
|
+
* environment notified. It is only emitted as a fallback when NO environment
|
|
70
|
+
* notified (e.g. every per-env delivery was suppressed or threw) so a real
|
|
71
|
+
* system status change never goes entirely unannounced.
|
|
72
|
+
*
|
|
73
|
+
* The rollup TRANSITION record and the `SYSTEM_STATUS_CHANGED` signal are
|
|
74
|
+
* emitted regardless — only the user-facing notification is deduplicated.
|
|
75
|
+
*/
|
|
76
|
+
export function shouldEmitRollupNotification({
|
|
77
|
+
anyEnvironmentNotified,
|
|
78
|
+
}: {
|
|
79
|
+
anyEnvironmentNotified: boolean;
|
|
80
|
+
}): boolean {
|
|
81
|
+
return !anyEnvironmentNotified;
|
|
82
|
+
}
|