@checkstack/healthcheck-backend 1.17.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/package.json +30 -29
  3. package/src/adaptive-timeout.test.ts +91 -0
  4. package/src/adaptive-timeout.ts +75 -0
  5. package/src/ai/system-signals-contributor.test.ts +2 -0
  6. package/src/automations.test.ts +47 -0
  7. package/src/automations.ts +19 -3
  8. package/src/healthcheck-gitops-kinds.test.ts +34 -2
  9. package/src/healthcheck-gitops-kinds.ts +17 -13
  10. package/src/index.ts +58 -6
  11. package/src/migration-chain-contract.test.ts +7 -1
  12. package/src/notification-policy.test.ts +19 -0
  13. package/src/notification-policy.ts +26 -0
  14. package/src/queue-executor.test.ts +391 -338
  15. package/src/queue-executor.ts +395 -294
  16. package/src/realtime-aggregation.ts +9 -2
  17. package/src/rollup-consumer.test.ts +191 -0
  18. package/src/rollup-consumer.ts +160 -0
  19. package/src/router.ts +11 -14
  20. package/src/schedule-jitter.test.ts +69 -0
  21. package/src/schedule-jitter.ts +50 -0
  22. package/src/schedule-reconciler.it.test.ts +453 -0
  23. package/src/schedule-reconciler.test.ts +418 -0
  24. package/src/schedule-reconciler.ts +304 -0
  25. package/src/service-batching.test.ts +98 -0
  26. package/src/service-ordering.test.ts +4 -0
  27. package/src/service-paused-filter.test.ts +14 -7
  28. package/src/service-rollup-worst-wins.test.ts +37 -4
  29. package/src/service.ts +255 -145
  30. package/src/slow-check-admission.test.ts +184 -0
  31. package/src/slow-check-admission.ts +101 -0
  32. package/src/slow-check-classifier.test.ts +155 -0
  33. package/src/slow-check-classifier.ts +137 -0
  34. package/src/slow-check-config.ts +102 -0
  35. package/src/suspect-lane.test.ts +50 -0
  36. package/src/suspect-lane.ts +61 -0
@@ -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
- await queue.enqueue({
260
- configId: config.configurationId,
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: () => ({ getQueue: () => ({ scheduleRecurring: async () => "job-123" }) } as any),
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: () => ({ getQueue: () => ({ scheduleRecurring: async () => "job-123" }) } as any),
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 { scheduleHealthCheck } from "./queue-executor";
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
- // Retrieve config to get the interval for scheduling
386
- const config = await service.getConfiguration(configId);
387
- if (config) {
388
- await scheduleHealthCheck({
389
- queueManager: deps.getQueueManager(),
390
- payload: {
391
- configId,
392
- systemId: systemEntityId,
393
- },
394
- intervalSeconds: config.intervalSeconds,
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`
@@ -553,8 +575,8 @@ export default createBackendPlugin({
553
575
  return mapped;
554
576
  },
555
577
  },
556
- recomputeSystemRollupHealth: (systemId) =>
557
- recomputeSystemRollupHealth({
578
+ recomputeSystemRollupHealth: async (systemId) => {
579
+ await recomputeSystemRollupHealth({
558
580
  systemId,
559
581
  // Reuse the COMPUTE-ON-READ service instance bound to the
560
582
  // `health` entity read accessor — it's the same db/registry
@@ -563,7 +585,8 @@ export default createBackendPlugin({
563
585
  getHealthEntity: () => healthEntity,
564
586
  advisoryLock,
565
587
  logger,
566
- }),
588
+ });
589
+ },
567
590
  });
568
591
  rpc.registerRouter(healthCheckRouter, healthCheckContract);
569
592
 
@@ -607,11 +630,17 @@ export default createBackendPlugin({
607
630
  }) => {
608
631
  // Store emitHook for the queue worker (Closure-based Hook Getter pattern)
609
632
  storedEmitHook = emitHook;
610
- // Bootstrap all enabled health checks
611
- await bootstrapHealthChecks({
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({
612
639
  db: database,
613
640
  queueManager,
641
+ catalogClient: reconcileCatalogClient,
614
642
  logger,
643
+ advisoryLock: resolvedAdvisoryLock,
615
644
  });
616
645
 
617
646
  // Notification subscription specs. Per-resource group lifecycle
@@ -649,11 +678,34 @@ export default createBackendPlugin({
649
678
  for (const action of createHealthCheckActions({
650
679
  service,
651
680
  queueManager,
681
+ catalogClient: reconcileCatalogClient,
652
682
  emitHook,
653
683
  })) {
654
684
  automationActions.registerAction(action, pluginMetadata);
655
685
  }
656
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
+
657
709
  // React to catalog system deletion (tombstone) via the reactive
658
710
  // `catalog-system` entity instead of the (removed) `system.deleted`
659
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 { createHealthCheckActions } from "./automations";
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
+ }