@checkstack/healthcheck-backend 1.22.0 → 1.23.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 CHANGED
@@ -1,5 +1,100 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 88f4333: Per-satellite offline threshold, connectivity notifications, and stop satellite-only checks going silent
8
+
9
+ **A satellite going offline was invisible, and so were its checks.** Three
10
+ related changes:
11
+
12
+ **Per-satellite offline threshold.** The 45-second global constant is now a
13
+ per-satellite override (**Offline after**, 2 minutes to 24 hours), because
14
+ tolerance is a property of the link, not of the platform: a satellite on a flaky
15
+ uplink needs grace that should not be forced on every other satellite. The
16
+ threshold is carried on every row read by `computeStatus`, so the entity read,
17
+ the admin list and the heartbeat monitor cannot disagree about the same
18
+ satellite. Additive, nullable column - existing satellites keep the default.
19
+
20
+ **Connectivity notifications.** Satellites are now a notification target with a
21
+ **Satellite connectivity** subscription: a warning when a satellite stops
22
+ heartbeating, informational when it returns. A reconnect only notifies if the
23
+ satellite was actually offline, so a redeploy is not an event. (The same
24
+ transitions remain available as `satellite.heartbeat_lost` / `.connected`
25
+ automation triggers for anyone wanting different routing.)
26
+
27
+ **Satellite-only checks no longer go silent.** BUG FIX: a check with
28
+ `includeLocal: false` whose satellites were all offline recorded NOTHING, so it
29
+ displayed its last known status indefinitely - a dead probe was indistinguishable
30
+ from a passing one. The core now records a `degraded` run with a clear message.
31
+ Degraded rather than unhealthy because the target may be fine; what failed is our
32
+ ability to observe it. Liveness that cannot be resolved is treated as "executing"
33
+ so a transient lookup failure cannot mark the whole fleet degraded at once.
34
+
35
+ Checks also surface staleness: a last run older than five intervals (minimum ten
36
+ minutes) is highlighted, so an ageing status is visible even with no run to
37
+ explain it. Paused checks are never stale, and neither is a RETIRED slice - one
38
+ whose environment was removed or whose satellite was unassigned - because
39
+ warning about something you retired on purpose trains operators to ignore the
40
+ badge.
41
+
42
+ The unobservable run does NOT notify subscribers. One offline satellite degrades
43
+ every check assigned to it in the same tick, and `healthy -> degraded` is an
44
+ escalation, so notifying per check would turn a single root cause into one alert
45
+ per check. The satellite's own connectivity subscription reports the cause once;
46
+ the runs are still recorded, so health and the UI stay honest.
47
+
48
+ Satellite liveness is cached on the shared platform cache with a 5s TTL. The
49
+ executor asks per tick of every satellite-only check and the read is a full
50
+ scan, so the uncached version scaled with the number of such checks. The TTL is
51
+ well below the smallest offline threshold the schema allows, so a cached answer
52
+ can lag a transition by one tick but never span one.
53
+
54
+ Corrects the user guide, which claimed offline satellites produced failed runs -
55
+ they produced nothing at all.
56
+
57
+ ### Patch Changes
58
+
59
+ - Updated dependencies [88f4333]
60
+ - Updated dependencies [1deaac5]
61
+ - Updated dependencies [88f4333]
62
+ - Updated dependencies [88f4333]
63
+ - Updated dependencies [88f4333]
64
+ - Updated dependencies [88f4333]
65
+ - Updated dependencies [88f4333]
66
+ - Updated dependencies [1deaac5]
67
+ - Updated dependencies [56e5375]
68
+ - Updated dependencies [88f4333]
69
+ - @checkstack/common@0.24.0
70
+ - @checkstack/healthcheck-common@1.19.1
71
+ - @checkstack/status-page-common@0.7.0
72
+ - @checkstack/incident-common@1.11.0
73
+ - @checkstack/maintenance-common@1.11.0
74
+ - @checkstack/command-backend@0.3.0
75
+ - @checkstack/incident-backend@1.14.0
76
+ - @checkstack/satellite-backend@0.10.0
77
+ - @checkstack/status-page-backend@0.7.0
78
+ - @checkstack/notification-common@1.9.0
79
+ - @checkstack/ai-backend@0.11.5
80
+ - @checkstack/backend-api@0.35.0
81
+ - @checkstack/satellite-common@0.12.0
82
+ - @checkstack/automation-backend@0.11.9
83
+ - @checkstack/secrets-backend@0.3.10
84
+ - @checkstack/ai-common@0.6.8
85
+ - @checkstack/cache-api@0.3.21
86
+ - @checkstack/catalog-backend@1.10.2
87
+ - @checkstack/catalog-common@2.8.2
88
+ - @checkstack/gitops-backend@0.5.28
89
+ - @checkstack/gitops-common@0.7.5
90
+ - @checkstack/healthcheck-execution@0.35.1
91
+ - @checkstack/queue-api@0.4.1
92
+ - @checkstack/script-packages-backend@0.4.7
93
+ - @checkstack/sdk@0.136.1
94
+ - @checkstack/secrets-common@0.3.4
95
+ - @checkstack/signal-common@0.3.2
96
+ - @checkstack/cache-utils@0.3.2
97
+
3
98
  ## 1.22.0
4
99
 
5
100
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,33 +14,34 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.11.4",
18
- "@checkstack/ai-common": "0.6.7",
19
- "@checkstack/automation-backend": "0.11.8",
20
- "@checkstack/backend-api": "0.34.1",
21
- "@checkstack/cache-api": "0.3.20",
22
- "@checkstack/cache-utils": "0.3.1",
23
- "@checkstack/catalog-backend": "1.10.1",
24
- "@checkstack/catalog-common": "2.8.1",
25
- "@checkstack/command-backend": "0.2.27",
26
- "@checkstack/common": "0.23.0",
27
- "@checkstack/gitops-backend": "0.5.27",
28
- "@checkstack/gitops-common": "0.7.4",
29
- "@checkstack/healthcheck-common": "1.19.0",
30
- "@checkstack/healthcheck-execution": "0.35.0",
31
- "@checkstack/incident-backend": "1.13.6",
32
- "@checkstack/incident-common": "1.10.5",
33
- "@checkstack/maintenance-common": "1.10.5",
34
- "@checkstack/notification-common": "1.8.0",
35
- "@checkstack/queue-api": "0.4.0",
36
- "@checkstack/satellite-backend": "0.9.4",
37
- "@checkstack/script-packages-backend": "0.4.6",
38
- "@checkstack/sdk": "0.135.1",
39
- "@checkstack/secrets-backend": "0.3.9",
40
- "@checkstack/secrets-common": "0.3.3",
41
- "@checkstack/signal-common": "0.3.1",
42
- "@checkstack/status-page-backend": "0.6.6",
43
- "@checkstack/status-page-common": "0.6.5",
17
+ "@checkstack/ai-backend": "0.11.5",
18
+ "@checkstack/ai-common": "0.6.8",
19
+ "@checkstack/automation-backend": "0.11.9",
20
+ "@checkstack/backend-api": "0.35.0",
21
+ "@checkstack/cache-api": "0.3.21",
22
+ "@checkstack/cache-utils": "0.3.2",
23
+ "@checkstack/catalog-backend": "1.10.2",
24
+ "@checkstack/catalog-common": "2.8.2",
25
+ "@checkstack/command-backend": "0.3.0",
26
+ "@checkstack/common": "0.24.0",
27
+ "@checkstack/gitops-backend": "0.5.28",
28
+ "@checkstack/gitops-common": "0.7.5",
29
+ "@checkstack/healthcheck-common": "1.19.1",
30
+ "@checkstack/healthcheck-execution": "0.35.1",
31
+ "@checkstack/incident-backend": "1.14.0",
32
+ "@checkstack/incident-common": "1.11.0",
33
+ "@checkstack/maintenance-common": "1.11.0",
34
+ "@checkstack/notification-common": "1.9.0",
35
+ "@checkstack/queue-api": "0.4.1",
36
+ "@checkstack/satellite-backend": "0.10.0",
37
+ "@checkstack/satellite-common": "0.12.0",
38
+ "@checkstack/script-packages-backend": "0.4.7",
39
+ "@checkstack/sdk": "0.136.1",
40
+ "@checkstack/secrets-backend": "0.3.10",
41
+ "@checkstack/secrets-common": "0.3.4",
42
+ "@checkstack/signal-common": "0.3.2",
43
+ "@checkstack/status-page-backend": "0.7.0",
44
+ "@checkstack/status-page-common": "0.7.0",
44
45
  "@hono/zod-validator": "^0.7.6",
45
46
  "@orpc/contract": "^1.14.4",
46
47
  "@orpc/server": "^1.14.4",
@@ -52,8 +53,8 @@
52
53
  },
53
54
  "devDependencies": {
54
55
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.6",
56
- "@checkstack/test-utils-backend": "0.1.61",
56
+ "@checkstack/scripts": "0.7.7",
57
+ "@checkstack/test-utils-backend": "0.1.62",
57
58
  "@checkstack/tsconfig": "0.0.7",
58
59
  "@types/bun": "^1.0.0",
59
60
  "@types/pg": "^8.20.0",
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  persistRunAndReact,
5
5
  } from "./queue-executor";
6
6
  import { reconcileHealthCheckJobs } from "./schedule-reconciler";
7
+ import { SatelliteApi } from "@checkstack/satellite-common";
7
8
  import { setupRetentionJob } from "./retention-job";
8
9
  import * as schema from "./schema";
9
10
  import {
@@ -523,6 +524,15 @@ export default createBackendPlugin({
523
524
  cache,
524
525
  secretResolver,
525
526
  internalSecrets,
527
+ // Lets a satellite-ONLY check notice that none of its satellites are
528
+ // online and record a degraded run, instead of silently recording
529
+ // nothing and leaving its last status on screen forever. Resolved by
530
+ // RPC so this plugin keeps no direct dependency on satellite-backend.
531
+ getOnlineSatelliteIds: async () => {
532
+ const satelliteClient = rpcClient.forPlugin(SatelliteApi);
533
+ const result = await satelliteClient.getOnlineSatelliteIds();
534
+ return result.satelliteIds;
535
+ },
526
536
  });
527
537
 
528
538
  // Setup retention job for tiered storage (daily aggregation)
@@ -336,6 +336,276 @@ describe("Queue-Based Health Check Executor", () => {
336
336
  });
337
337
  });
338
338
 
339
+ describe("executeHealthCheckJob - satellite-only checks with no online satellite", () => {
340
+ /**
341
+ * Builds a worker whose one configuration is satellite-ONLY (includeLocal
342
+ * false, satellites assigned) and captures the queue handler.
343
+ */
344
+ const setupSatelliteOnlyWorker = async (opts: {
345
+ getOnlineSatelliteIds?: () => Promise<string[]>;
346
+ /** Override the assignment shape to model a configuration change. */
347
+ configRow?: Partial<{
348
+ includeLocal: boolean;
349
+ satelliteIds: string[];
350
+ paused: boolean;
351
+ }>;
352
+ /** Records every subscriber notification the run would deliver. */
353
+ onNotify?: (input: unknown) => void;
354
+ }) => {
355
+ const mockDb = createMockDb();
356
+ const mockLogger = createMockLogger();
357
+ const mockQueueManager = createMockQueueManager();
358
+ const mockSignalService = createMockSignalService();
359
+ const mockCatalogClient = createMockCatalogClient();
360
+
361
+ // The shared mock has no `selectDistinct`; the rollup read uses it.
362
+ (mockDb as any).selectDistinct = mock(() => ({
363
+ from: mock(() => ({
364
+ where: mock(() => Promise.resolve([])),
365
+ })),
366
+ }));
367
+
368
+ (mockDb.select as any) = mock(() => ({
369
+ from: mock(() => ({
370
+ innerJoin: mock(() => ({
371
+ where: mock(() =>
372
+ Promise.resolve([
373
+ {
374
+ configId: "config-1",
375
+ configName: "Satellite check",
376
+ strategyId: "test-strategy",
377
+ config: {},
378
+ collectors: [],
379
+ interval: 30,
380
+ enabled: true,
381
+ // The shape this branch exists for: executed by satellites,
382
+ // never by the core. Overridable so a test can model an
383
+ // assignment change.
384
+ paused: false,
385
+ includeLocal: false,
386
+ satelliteIds: ["sat-1", "sat-2"],
387
+ environmentIds: null,
388
+ ...opts.configRow,
389
+ },
390
+ ]),
391
+ ),
392
+ })),
393
+ where: mock(() => Promise.resolve([])),
394
+ })),
395
+ }));
396
+
397
+ const queue =
398
+ mockQueueManager.getQueue<HealthCheckJobPayload>("health-checks");
399
+ let capturedHandler:
400
+ | ((job: { data: HealthCheckJobPayload }) => Promise<void>)
401
+ | undefined;
402
+ (queue.consume as any) = mock(
403
+ async (
404
+ handler: (job: { data: HealthCheckJobPayload }) => Promise<void>,
405
+ ) => {
406
+ capturedHandler = handler;
407
+ },
408
+ );
409
+
410
+ await setupHealthCheckWorker({
411
+ db: mockDb as unknown as Parameters<
412
+ typeof setupHealthCheckWorker
413
+ >[0]["db"],
414
+ advisoryLock: mockAdvisoryLock,
415
+ registry: createMockRegistry(),
416
+ collectorRegistry:
417
+ createMockCollectorRegistry() as unknown as Parameters<
418
+ typeof setupHealthCheckWorker
419
+ >[0]["collectorRegistry"],
420
+ logger: mockLogger,
421
+ queueManager: mockQueueManager,
422
+ signalService: mockSignalService,
423
+ catalogClient: mockCatalogClient as unknown as Parameters<
424
+ typeof setupHealthCheckWorker
425
+ >[0]["catalogClient"],
426
+ notificationClient: {
427
+ notifyForSubscription: (input: unknown) => {
428
+ opts.onNotify?.(input);
429
+ return Promise.resolve({ notifiedCount: 0 });
430
+ },
431
+ } as unknown as Parameters<
432
+ typeof setupHealthCheckWorker
433
+ >[0]["notificationClient"],
434
+ maintenanceClient: createMockMaintenanceClient() as unknown as Parameters<
435
+ typeof setupHealthCheckWorker
436
+ >[0]["maintenanceClient"],
437
+ incidentClient: createMockIncidentClient() as unknown as Parameters<
438
+ typeof setupHealthCheckWorker
439
+ >[0]["incidentClient"],
440
+ getEmitHook: () => undefined,
441
+ cache: passthroughCache,
442
+ slowCheckRuntime: null,
443
+ ...(opts.getOnlineSatelliteIds
444
+ ? { getOnlineSatelliteIds: opts.getOnlineSatelliteIds }
445
+ : {}),
446
+ });
447
+
448
+ return { capturedHandler, mockLogger, mockSignalService };
449
+ };
450
+
451
+ const run = async (
452
+ handler:
453
+ | ((job: { data: HealthCheckJobPayload }) => Promise<void>)
454
+ | undefined,
455
+ ) => {
456
+ await handler?.({
457
+ data: {
458
+ configId: "config-1",
459
+ systemId: "system-1",
460
+ environmentId: null,
461
+ },
462
+ });
463
+ };
464
+
465
+ it("stays silent while at least one assigned satellite is online", async () => {
466
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
467
+ getOnlineSatelliteIds: async () => ["sat-2"],
468
+ });
469
+
470
+ await run(capturedHandler);
471
+
472
+ expect(mockLogger.debug).toHaveBeenCalledWith(
473
+ expect.stringContaining("satellite-only, skipping local execution"),
474
+ );
475
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
476
+ expect.stringContaining("no online satellite"),
477
+ );
478
+ });
479
+
480
+ it("warns and records a run when NO assigned satellite is online", async () => {
481
+ // The regression this guards: the core used to return silently here, so a
482
+ // check whose satellites were all down kept displaying its last known
483
+ // status forever - a dead probe reading exactly like a passing one.
484
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
485
+ getOnlineSatelliteIds: async () => [],
486
+ });
487
+
488
+ // `persistRunAndReact` needs the full insert/aggregate/entity chain, which
489
+ // this file's mock database does not provide - so the call throws HERE and
490
+ // not in production, where it is the same function every successful run
491
+ // goes through. That is a mock limit, not the behaviour under test: what
492
+ // this pins is that the executor took the RECORD path instead of the
493
+ // silent one. What gets recorded is pinned separately and purely by
494
+ // `buildUnobservableRun` in `satellite-liveness.test.ts`.
495
+ await run(capturedHandler).catch(() => {});
496
+
497
+ expect(mockLogger.warn).toHaveBeenCalledWith(
498
+ expect.stringContaining("no online satellite"),
499
+ );
500
+ // It must NOT take the silent path.
501
+ expect(mockLogger.debug).not.toHaveBeenCalledWith(
502
+ expect.stringContaining("satellite-only, skipping local execution"),
503
+ );
504
+ });
505
+
506
+ it("stays silent when satellite liveness cannot be resolved", async () => {
507
+ // A transient failure to reach the satellite service must never mark every
508
+ // satellite-only check in the fleet degraded at once.
509
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
510
+ getOnlineSatelliteIds: async () => {
511
+ throw new Error("satellite service unreachable");
512
+ },
513
+ });
514
+
515
+ await run(capturedHandler);
516
+
517
+ expect(mockLogger.debug).toHaveBeenCalledWith(
518
+ expect.stringContaining("satellite-only, skipping local execution"),
519
+ );
520
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
521
+ expect.stringContaining("no online satellite"),
522
+ );
523
+ });
524
+
525
+ it("stays silent when no liveness resolver is wired at all", async () => {
526
+ // Pre-existing behaviour for a deployment without the resolver: the core
527
+ // says nothing and lets the satellites report.
528
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({});
529
+
530
+ await run(capturedHandler);
531
+
532
+ expect(mockLogger.debug).toHaveBeenCalledWith(
533
+ expect.stringContaining("satellite-only, skipping local execution"),
534
+ );
535
+ });
536
+
537
+ /**
538
+ * An operator CHANGING an assignment must never look like a failure. These
539
+ * are the cases that have historically been got wrong: the platform reacts
540
+ * to a deliberate configuration change as though the check had broken.
541
+ */
542
+ it("records nothing when the satellites are removed from the assignment", async () => {
543
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
544
+ // No satellite is online at all - but the assignment no longer names
545
+ // any, so this check is simply not satellite-only any more.
546
+ getOnlineSatelliteIds: async () => [],
547
+ configRow: { satelliteIds: [] },
548
+ });
549
+
550
+ await run(capturedHandler).catch(() => {});
551
+
552
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
553
+ expect.stringContaining("no online satellite"),
554
+ );
555
+ });
556
+
557
+ it("records nothing when local execution is turned back on", async () => {
558
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
559
+ getOnlineSatelliteIds: async () => [],
560
+ configRow: { includeLocal: true },
561
+ });
562
+
563
+ await run(capturedHandler).catch(() => {});
564
+
565
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
566
+ expect.stringContaining("no online satellite"),
567
+ );
568
+ });
569
+
570
+ it("a PAUSED satellite-only check records nothing, even with every satellite offline", async () => {
571
+ // Paused is checked BEFORE the satellite branch, so a paused check is
572
+ // quiet on purpose and must not manufacture a degraded run.
573
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
574
+ getOnlineSatelliteIds: async () => [],
575
+ configRow: { paused: true },
576
+ });
577
+
578
+ await run(capturedHandler).catch(() => {});
579
+
580
+ expect(mockLogger.debug).toHaveBeenCalledWith(
581
+ expect.stringContaining("is paused, skipping execution"),
582
+ );
583
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
584
+ expect.stringContaining("no online satellite"),
585
+ );
586
+ });
587
+
588
+ it("does NOT notify subscribers for the unobservable run", async () => {
589
+ // One offline satellite degrades EVERY check assigned to it in the same
590
+ // tick, and healthy -> degraded is an escalation, so notifying per check
591
+ // turns a single root cause into one alert per check. The satellite's own
592
+ // connectivity subscription reports the cause once. The RUN is still
593
+ // recorded - only the per-check alert is withheld.
594
+ const notified: unknown[] = [];
595
+ const { capturedHandler, mockLogger } = await setupSatelliteOnlyWorker({
596
+ getOnlineSatelliteIds: async () => [],
597
+ onNotify: (input) => notified.push(input),
598
+ });
599
+
600
+ await run(capturedHandler).catch(() => {});
601
+
602
+ expect(mockLogger.warn).toHaveBeenCalledWith(
603
+ expect.stringContaining("no online satellite"),
604
+ );
605
+ expect(notified).toHaveLength(0);
606
+ });
607
+ });
608
+
339
609
  describe("executeHealthCheckJob - collector run-context", () => {
340
610
  it("passes curated run-context to the collector (name falls back to id when configName is null)", async () => {
341
611
  const mockDb = createMockDb();
@@ -78,6 +78,10 @@ import {
78
78
  type HealthEntityState,
79
79
  } from "./health-entity";
80
80
  import { encodeHealthEntityId } from "./health-entity-id";
81
+ import {
82
+ buildUnobservableRun,
83
+ resolveSatelliteOnlyOutcome,
84
+ } from "./satellite-liveness";
81
85
  import type { EntityHandle } from "@checkstack/automation-backend";
82
86
 
83
87
  type Db = SafeDatabase<typeof schema>;
@@ -628,6 +632,20 @@ export async function persistRunAndReact(params: {
628
632
  sourceLabel: string;
629
633
  /** Timestamp used for the hourly aggregate bucket (the run's execution time). */
630
634
  runTimestamp: Date;
635
+ /**
636
+ * Record the run and its transition, but do NOT notify subscribers.
637
+ *
638
+ * For a run whose cause is a single shared failure that is ALREADY notified
639
+ * elsewhere. The unobservable-run path is the case: one satellite going
640
+ * offline makes every check assigned to it degrade at once, and
641
+ * `healthy -> degraded` is an escalation, so without this a single satellite
642
+ * outage fans out into one notification per check. The satellite's own
643
+ * connectivity subscription names the actual root cause once.
644
+ *
645
+ * The run, the transition and the health state are still written, so the UI
646
+ * stays honest - only the per-check alert is withheld.
647
+ */
648
+ suppressSubscriberNotification?: boolean;
631
649
  }): Promise<void> {
632
650
  const {
633
651
  db,
@@ -655,6 +673,7 @@ export async function persistRunAndReact(params: {
655
673
  sourceId,
656
674
  sourceLabel,
657
675
  runTimestamp,
676
+ suppressSubscriberNotification = false,
658
677
  } = params;
659
678
 
660
679
  const envEntityId = encodeHealthEntityId({ systemId, environmentId });
@@ -762,22 +781,30 @@ export async function persistRunAndReact(params: {
762
781
  toStatus: newState.status,
763
782
  });
764
783
 
765
- await notifyStateChange({
766
- notificationClient,
767
- systemId,
768
- systemName,
769
- configurationId: configId,
770
- configurationName: configName,
771
- previousStatus: previousStatus === "unknown" ? "healthy" : previousStatus,
772
- newStatus: newState.status,
773
- environmentId,
774
- environmentName,
775
- service,
776
- catalogClient,
777
- maintenanceClient,
778
- incidentClient,
779
- logger,
780
- });
784
+ if (suppressSubscriberNotification) {
785
+ logger.debug(
786
+ `Recorded ${newState.status} for ${configId}/${systemId} without notifying: ` +
787
+ "the underlying cause is notified once at its source",
788
+ );
789
+ } else {
790
+ await notifyStateChange({
791
+ notificationClient,
792
+ systemId,
793
+ systemName,
794
+ configurationId: configId,
795
+ configurationName: configName,
796
+ previousStatus:
797
+ previousStatus === "unknown" ? "healthy" : previousStatus,
798
+ newStatus: newState.status,
799
+ environmentId,
800
+ environmentName,
801
+ service,
802
+ catalogClient,
803
+ maintenanceClient,
804
+ incidentClient,
805
+ logger,
806
+ });
807
+ }
781
808
 
782
809
  if (!isFannedOut) {
783
810
  await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
@@ -859,6 +886,15 @@ async function executeHealthCheckJob(props: {
859
886
  * is skipped and the run executes exactly as before (full timeout, no lane).
860
887
  */
861
888
  slowCheckRuntime?: SlowCheckRuntime | null;
889
+ /**
890
+ * Resolves the ids of every currently-online satellite.
891
+ *
892
+ * Injected rather than imported so this module keeps no dependency on the
893
+ * satellite plugin, and so the unobservable-check path is testable without
894
+ * one. When absent, satellite-only checks behave exactly as they did before:
895
+ * the core stays silent and lets the satellites report.
896
+ */
897
+ getOnlineSatelliteIds?: () => Promise<string[]>;
862
898
  }): Promise<void> {
863
899
  const {
864
900
  payload,
@@ -878,6 +914,7 @@ async function executeHealthCheckJob(props: {
878
914
  secretResolver,
879
915
  internalSecrets,
880
916
  slowCheckRuntime,
917
+ getOnlineSatelliteIds,
881
918
  } = props;
882
919
  const { configId, systemId } = payload;
883
920
 
@@ -951,16 +988,91 @@ async function executeHealthCheckJob(props: {
951
988
  return;
952
989
  }
953
990
 
954
- // If includeLocal is false and satellites are assigned, skip local execution
955
- // (satellites handle execution, local core doesn't run this check)
991
+ // If includeLocal is false and satellites are assigned, the SATELLITES
992
+ // execute this check and the core does not.
993
+ //
994
+ // But "the core does not run it" is not the same as "nothing needs to
995
+ // happen". If every assigned satellite is offline, nobody runs it, and
996
+ // returning silently (as this once did) leaves the check displaying its
997
+ // last known status forever - a dead probe reading exactly like a passing
998
+ // one. So an unobservable check records a `degraded` run instead.
956
999
  if (
957
1000
  !configRow.includeLocal &&
958
1001
  configRow.satelliteIds &&
959
1002
  configRow.satelliteIds.length > 0
960
1003
  ) {
961
- logger.debug(
962
- `Health check ${configId} for system ${systemId} is satellite-only, skipping local execution`,
1004
+ const satelliteIds = configRow.satelliteIds;
1005
+ // Left UNSET (not empty) when liveness cannot be resolved: an empty list
1006
+ // would read as "every satellite is offline" and mark the whole fleet's
1007
+ // satellite-only checks degraded on a transient lookup failure.
1008
+ let onlineSatelliteIds: string[] | undefined;
1009
+ if (getOnlineSatelliteIds) {
1010
+ try {
1011
+ onlineSatelliteIds = await getOnlineSatelliteIds();
1012
+ } catch (error) {
1013
+ logger.warn(
1014
+ `Could not resolve satellite liveness for ${configId}/${systemId}; treating as executing`,
1015
+ error,
1016
+ );
1017
+ }
1018
+ }
1019
+
1020
+ const outcome = resolveSatelliteOnlyOutcome({
1021
+ satelliteIds,
1022
+ ...(onlineSatelliteIds === undefined ? {} : { onlineSatelliteIds }),
1023
+ });
1024
+
1025
+ if (outcome === "satellites-executing") {
1026
+ logger.debug(
1027
+ `Health check ${configId} for system ${systemId} is satellite-only, skipping local execution`,
1028
+ );
1029
+ return;
1030
+ }
1031
+
1032
+ logger.warn(
1033
+ `Health check ${configId} for system ${systemId} has no online satellite ` +
1034
+ `(${satelliteIds.length} assigned); recording a degraded run so the gap is visible`,
963
1035
  );
1036
+
1037
+ let unobservableSystemName = systemId;
1038
+ try {
1039
+ const system = await catalogClient.getSystem({ systemId });
1040
+ if (system) unobservableSystemName = system.name;
1041
+ } catch {
1042
+ // Fall back to the id; a missing display name must not swallow the run.
1043
+ }
1044
+
1045
+ await persistRunAndReact({
1046
+ db,
1047
+ service,
1048
+ cache,
1049
+ signalService,
1050
+ notificationClient,
1051
+ catalogClient,
1052
+ maintenanceClient,
1053
+ incidentClient,
1054
+ ...(getHealthEntity ? { getHealthEntity } : {}),
1055
+ getEmitHook,
1056
+ collectorRegistry,
1057
+ advisoryLock,
1058
+ logger,
1059
+ systemId,
1060
+ systemName: unobservableSystemName,
1061
+ configId,
1062
+ ...(configRow.configName ? { configName: configRow.configName } : {}),
1063
+ // The job payload already names the single (config, system, env) slice
1064
+ // this tick owns, so the stale run lands on exactly the slice the
1065
+ // satellites would have reported for.
1066
+ ...buildUnobservableRun({
1067
+ environmentId: payload.environmentId,
1068
+ satelliteIds,
1069
+ }),
1070
+ runTimestamp: new Date(),
1071
+ // One offline satellite degrades EVERY check assigned to it in the same
1072
+ // tick. Notifying per check would turn a single root cause into a
1073
+ // storm; the satellite's connectivity subscription reports it once.
1074
+ suppressSubscriberNotification: true,
1075
+ });
964
1076
  return;
965
1077
  }
966
1078
 
@@ -1772,6 +1884,15 @@ export async function setupHealthCheckWorker(props: {
1772
1884
  * don't exercise the bulkhead), or a concrete runtime to drive it.
1773
1885
  */
1774
1886
  slowCheckRuntime?: SlowCheckRuntime | null;
1887
+ /**
1888
+ * Resolves the ids of every currently-online satellite.
1889
+ *
1890
+ * Injected rather than imported so this module keeps no dependency on the
1891
+ * satellite plugin, and so the unobservable-check path is testable without
1892
+ * one. When absent, satellite-only checks behave exactly as they did before:
1893
+ * the core stays silent and lets the satellites report.
1894
+ */
1895
+ getOnlineSatelliteIds?: () => Promise<string[]>;
1775
1896
  }): Promise<void> {
1776
1897
  const {
1777
1898
  db,
@@ -1790,6 +1911,7 @@ export async function setupHealthCheckWorker(props: {
1790
1911
  cache,
1791
1912
  secretResolver,
1792
1913
  internalSecrets,
1914
+ getOnlineSatelliteIds,
1793
1915
  } = props;
1794
1916
 
1795
1917
  // Resolve the slow-check runtime once at startup unless the caller supplied
@@ -1826,6 +1948,7 @@ export async function setupHealthCheckWorker(props: {
1826
1948
  secretResolver,
1827
1949
  internalSecrets,
1828
1950
  slowCheckRuntime,
1951
+ ...(getOnlineSatelliteIds ? { getOnlineSatelliteIds } : {}),
1829
1952
  });
1830
1953
  },
1831
1954
  {
@@ -0,0 +1,199 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildUnobservableResult,
4
+ buildUnobservableRun,
5
+ resolveSatelliteOnlyOutcome,
6
+ } from "./satellite-liveness";
7
+
8
+ describe("resolveSatelliteOnlyOutcome", () => {
9
+ test("satellites execute the check when any assigned one is online", () => {
10
+ expect(
11
+ resolveSatelliteOnlyOutcome({
12
+ satelliteIds: ["a", "b"],
13
+ onlineSatelliteIds: ["b"],
14
+ }),
15
+ ).toBe("satellites-executing");
16
+ });
17
+
18
+ test("records an unobservable run when NO assigned satellite is online", () => {
19
+ // The bug: this used to return silently, so the check kept displaying its
20
+ // last status forever and a dead probe read exactly like a passing one.
21
+ expect(
22
+ resolveSatelliteOnlyOutcome({
23
+ satelliteIds: ["a", "b"],
24
+ onlineSatelliteIds: [],
25
+ }),
26
+ ).toBe("record-unobservable");
27
+ });
28
+
29
+ test("ignores online satellites this check is not assigned to", () => {
30
+ expect(
31
+ resolveSatelliteOnlyOutcome({
32
+ satelliteIds: ["a"],
33
+ onlineSatelliteIds: ["someone-else"],
34
+ }),
35
+ ).toBe("record-unobservable");
36
+ });
37
+
38
+ test("stays silent when liveness is UNKNOWN", () => {
39
+ // A transient failure to reach the satellite service must never mark every
40
+ // satellite-only check in the fleet degraded at once. Unknown is not the
41
+ // same as offline, and silence is the pre-existing, safe direction.
42
+ expect(
43
+ resolveSatelliteOnlyOutcome({ satelliteIds: ["a"] }),
44
+ ).toBe("satellites-executing");
45
+ });
46
+
47
+ test("a check with no assigned satellites is treated as executing", () => {
48
+ // Not reachable from the caller (the branch requires assignments), but the
49
+ // function must not claim an empty assignment set is unobservable.
50
+ expect(
51
+ resolveSatelliteOnlyOutcome({
52
+ satelliteIds: [],
53
+ onlineSatelliteIds: [],
54
+ }),
55
+ ).toBe("satellites-executing");
56
+ });
57
+
58
+ test("one online satellite out of many is enough", () => {
59
+ expect(
60
+ resolveSatelliteOnlyOutcome({
61
+ satelliteIds: ["a", "b", "c"],
62
+ onlineSatelliteIds: ["c"],
63
+ }),
64
+ ).toBe("satellites-executing");
65
+ });
66
+ });
67
+
68
+ describe("buildUnobservableResult", () => {
69
+ test("says plainly that health is UNKNOWN, not that the target is down", () => {
70
+ // The run is degraded because we could not observe, not because the service
71
+ // failed. The message must not let an operator conclude otherwise.
72
+ const result = buildUnobservableResult({ satelliteIds: ["a", "b"] });
73
+
74
+ expect(String(result.error)).toContain("unknown");
75
+ expect(String(result.error)).toContain("monitoring gap");
76
+ expect(String(result.error)).toContain("not a confirmed outage");
77
+ });
78
+
79
+ test("carries a machine-readable marker and the assignment count", () => {
80
+ const result = buildUnobservableResult({ satelliteIds: ["a", "b"] });
81
+
82
+ expect(result.satelliteOffline).toBe(true);
83
+ expect(result.assignedSatelliteCount).toBe(2);
84
+ });
85
+ });
86
+
87
+ describe("buildUnobservableRun", () => {
88
+ test("records DEGRADED, never unhealthy", () => {
89
+ // Unhealthy would raise incident-grade alarms about services that may be
90
+ // perfectly healthy, every time a satellite host reboots.
91
+ expect(
92
+ buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] }).status,
93
+ ).toBe("degraded");
94
+ });
95
+
96
+ test("lands on the slice the job owns, including a concrete environment", () => {
97
+ // The satellites would have reported for this exact slice, so the gap has
98
+ // to be recorded there and not on the rollup.
99
+ expect(
100
+ buildUnobservableRun({ environmentId: "env-1", satelliteIds: ["a"] })
101
+ .environmentId,
102
+ ).toBe("env-1");
103
+ expect(
104
+ buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] })
105
+ .environmentId,
106
+ ).toBeNull();
107
+ });
108
+
109
+ test("attributes the run to the core, not to a satellite", () => {
110
+ // A satellite reported nothing - the core is what noticed the gap.
111
+ expect(
112
+ buildUnobservableRun({ environmentId: null, satelliteIds: ["a"] })
113
+ .sourceLabel,
114
+ ).toBe("Local");
115
+ });
116
+
117
+ test("carries the explanatory result payload", () => {
118
+ const run = buildUnobservableRun({
119
+ environmentId: null,
120
+ satelliteIds: ["a", "b"],
121
+ });
122
+
123
+ expect(run.result.satelliteOffline).toBe(true);
124
+ expect(run.result.assignedSatelliteCount).toBe(2);
125
+ expect(String(run.result.error)).toContain("unknown");
126
+ });
127
+ });
128
+
129
+ describe("resolveSatelliteOnlyOutcome - assignment changes must not fabricate a gap", () => {
130
+ /**
131
+ * The class of bug these guard: an operator changes an assignment and the
132
+ * platform reacts as though something FAILED. Removing a satellite, adding
133
+ * local execution back, or swapping one satellite for another are all
134
+ * deliberate acts - none of them means "nobody is checking this".
135
+ */
136
+ test("emptying the satellite list is not an outage", () => {
137
+ // The executor's branch requires a non-empty list, but the predicate must
138
+ // agree independently - a future caller must not be able to turn a cleared
139
+ // assignment into a degraded run.
140
+ expect(
141
+ resolveSatelliteOnlyOutcome({
142
+ satelliteIds: [],
143
+ onlineSatelliteIds: [],
144
+ }),
145
+ ).toBe("satellites-executing");
146
+ });
147
+
148
+ test("swapping to a different, online satellite is not an outage", () => {
149
+ expect(
150
+ resolveSatelliteOnlyOutcome({
151
+ satelliteIds: ["new-sat"],
152
+ onlineSatelliteIds: ["new-sat"],
153
+ }),
154
+ ).toBe("satellites-executing");
155
+ });
156
+
157
+ test("one online satellite is enough even when the others were deleted", () => {
158
+ // A deleted satellite simply stops appearing in the online set. As long as
159
+ // ONE assigned satellite is still online, the check is being executed.
160
+ expect(
161
+ resolveSatelliteOnlyOutcome({
162
+ satelliteIds: ["deleted-sat", "live-sat"],
163
+ onlineSatelliteIds: ["live-sat", "unrelated-sat"],
164
+ }),
165
+ ).toBe("satellites-executing");
166
+ });
167
+
168
+ test("an entirely unrelated fleet being online is NOT enough", () => {
169
+ // Guards the inverse mistake: "some satellite somewhere is up" must not be
170
+ // read as "this check is being executed".
171
+ expect(
172
+ resolveSatelliteOnlyOutcome({
173
+ satelliteIds: ["mine"],
174
+ onlineSatelliteIds: ["someone-elses", "another"],
175
+ }),
176
+ ).toBe("record-unobservable");
177
+ });
178
+
179
+ test("an empty online set with no assignment is not an outage", () => {
180
+ expect(
181
+ resolveSatelliteOnlyOutcome({ satelliteIds: [], onlineSatelliteIds: [] }),
182
+ ).toBe("satellites-executing");
183
+ });
184
+
185
+ test("duplicate ids in an assignment do not change the verdict", () => {
186
+ expect(
187
+ resolveSatelliteOnlyOutcome({
188
+ satelliteIds: ["a", "a"],
189
+ onlineSatelliteIds: ["a"],
190
+ }),
191
+ ).toBe("satellites-executing");
192
+ expect(
193
+ resolveSatelliteOnlyOutcome({
194
+ satelliteIds: ["a", "a"],
195
+ onlineSatelliteIds: [],
196
+ }),
197
+ ).toBe("record-unobservable");
198
+ });
199
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What a satellite-only check should do when the core reaches its tick.
3
+ *
4
+ * ## The bug this exists to close
5
+ *
6
+ * A check with `includeLocal: false` and assigned satellites is executed BY
7
+ * those satellites; the core's own tick has nothing to run and returned
8
+ * immediately. If every assigned satellite is offline, nobody executes it - and
9
+ * because the core recorded nothing at all, the check kept displaying whatever
10
+ * status it last had, indefinitely. A dead probe was indistinguishable from a
11
+ * passing one, which is the single worst failure mode a monitoring tool can
12
+ * have.
13
+ *
14
+ * So the core now records a `degraded` run instead of staying silent.
15
+ * `degraded` rather than `unhealthy` because the target may be perfectly
16
+ * healthy - what failed is our ability to observe it. Marking it unhealthy
17
+ * would raise incident-grade alarms about services that are fine, every time a
18
+ * satellite host reboots.
19
+ */
20
+ export type SatelliteOnlyOutcome =
21
+ /** Satellites are executing this check; the core has nothing to do. */
22
+ | "satellites-executing"
23
+ /** No assigned satellite is online - record a stale run so the gap is visible. */
24
+ | "record-unobservable";
25
+
26
+ export function resolveSatelliteOnlyOutcome({
27
+ satelliteIds,
28
+ onlineSatelliteIds,
29
+ }: {
30
+ /** Satellites assigned to this check. */
31
+ satelliteIds: readonly string[];
32
+ /**
33
+ * Currently-online satellite ids. `undefined` when liveness could not be
34
+ * determined at all (no resolver wired, or the lookup failed).
35
+ */
36
+ onlineSatelliteIds?: readonly string[];
37
+ }): SatelliteOnlyOutcome {
38
+ // Unknown liveness must NEVER manufacture a degraded run: a transient failure
39
+ // to reach the satellite service would otherwise mark every satellite-only
40
+ // check degraded across the fleet at once. Staying silent is the pre-existing
41
+ // behaviour and the safe direction for an unknown.
42
+ if (onlineSatelliteIds === undefined) return "satellites-executing";
43
+
44
+ // An empty assignment set has nothing to be offline. `[].some()` is false, so
45
+ // without this guard a check with no satellites would be reported as
46
+ // unobservable - a degraded run for a configuration that cannot produce one.
47
+ if (satelliteIds.length === 0) return "satellites-executing";
48
+
49
+ const online = new Set(onlineSatelliteIds);
50
+ const anyOnline = satelliteIds.some((id) => online.has(id));
51
+
52
+ return anyOnline ? "satellites-executing" : "record-unobservable";
53
+ }
54
+
55
+ /** The result payload recorded for an unobservable run. */
56
+ export function buildUnobservableResult({
57
+ satelliteIds,
58
+ }: {
59
+ satelliteIds: readonly string[];
60
+ }): Record<string, unknown> {
61
+ const count = satelliteIds.length;
62
+ return {
63
+ error:
64
+ `No assigned satellite is online (${count} assigned), so this check could not be executed. ` +
65
+ "The target's actual health is unknown - this is a monitoring gap, not a confirmed outage.",
66
+ satelliteOffline: true,
67
+ assignedSatelliteCount: count,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The `persistRunAndReact` arguments for an unobservable run.
73
+ *
74
+ * Extracted so the RECORDED VALUES - degraded, the payload's environment slice,
75
+ * the local source label - are pinned by a test. The executor's mock database
76
+ * cannot service `persistRunAndReact`'s insert/aggregate chain, so the only way
77
+ * to assert what gets written is to make the decision about what to write a
78
+ * separate, pure step.
79
+ */
80
+ export function buildUnobservableRun({
81
+ environmentId,
82
+ satelliteIds,
83
+ }: {
84
+ /** The single (config, system, env) slice this job owns. */
85
+ environmentId: string | null;
86
+ satelliteIds: readonly string[];
87
+ }): {
88
+ status: "degraded";
89
+ environmentId: string | null;
90
+ sourceLabel: string;
91
+ result: Record<string, unknown>;
92
+ } {
93
+ return {
94
+ // Degraded, NOT unhealthy: the target may be perfectly healthy and what
95
+ // failed is our ability to observe it. Unhealthy would raise
96
+ // incident-grade alarms about healthy services on every satellite reboot.
97
+ status: "degraded",
98
+ // The job payload already names the slice the satellites would have
99
+ // reported for, so the gap lands exactly where the missing runs would have.
100
+ environmentId,
101
+ // Recorded by the CORE, which is what noticed the gap - not by a satellite,
102
+ // which by definition reported nothing.
103
+ sourceLabel: "Local",
104
+ result: buildUnobservableResult({ satelliteIds }),
105
+ };
106
+ }
package/tsconfig.json CHANGED
@@ -67,6 +67,9 @@
67
67
  {
68
68
  "path": "../satellite-backend"
69
69
  },
70
+ {
71
+ "path": "../satellite-common"
72
+ },
70
73
  {
71
74
  "path": "../script-packages-backend"
72
75
  },