@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
@@ -11,6 +11,10 @@ import {
11
11
  type CollectorRunContext,
12
12
  type AdvisoryLockService,
13
13
  renderTemplatableConfig,
14
+ withScopedTransaction,
15
+ healthcheckExecutionHistogram,
16
+ healthcheckPhaseHistogram,
17
+ healthcheckDeferredCounter,
14
18
  } from "@checkstack/backend-api";
15
19
  import type { RunTimings } from "@checkstack/healthcheck-common";
16
20
  import { QueueManager } from "@checkstack/queue-api";
@@ -20,7 +24,7 @@ import {
20
24
  healthCheckRuns,
21
25
  } from "./schema";
22
26
  import * as schema from "./schema";
23
- import { eq, and, max } from "drizzle-orm";
27
+ import { eq, and, desc, isNull } from "drizzle-orm";
24
28
  import { type SignalService } from "@checkstack/signal-common";
25
29
  import {
26
30
  HEALTH_CHECK_RUN_COMPLETED,
@@ -55,6 +59,12 @@ import { HealthCheckService } from "./service";
55
59
  import { healthCheckHooks } from "./hooks";
56
60
  import { incrementHourlyAggregate } from "./realtime-aggregation";
57
61
  import type { HealthCheckCache } from "./cache";
62
+ import {
63
+ resolveSlowCheckRuntime,
64
+ type SlowCheckRuntime,
65
+ } from "./slow-check-config";
66
+ import type { RecentRun } from "./slow-check-classifier";
67
+ import { evaluateSlowCheckAdmission } from "./slow-check-admission";
58
68
  import {
59
69
  classifyTransition,
60
70
  shouldNotifyTransition,
@@ -98,6 +108,48 @@ function toHealthEntityView(state: AggregatedHealth): HealthEntityState {
98
108
  };
99
109
  }
100
110
 
111
+ /**
112
+ * Read the most recent runs for ONE (config, system, environment) slice,
113
+ * newest-first, projected to the fields the slow-check classifier needs. Used
114
+ * only when the slow-check bulkhead is enabled; keyed on the SAME
115
+ * `environmentId` the job runs (an env-less job reads the `environment_id IS
116
+ * NULL` slice), so the classification reflects exactly this slice's streak.
117
+ */
118
+ async function fetchRecentRunsForSlice(props: {
119
+ db: Db;
120
+ configId: string;
121
+ systemId: string;
122
+ environmentId: string | null;
123
+ limit: number;
124
+ }): Promise<RecentRun[]> {
125
+ const { db, configId, systemId, environmentId, limit } = props;
126
+ const rows = await db
127
+ .select({
128
+ environmentId: healthCheckRuns.environmentId,
129
+ status: healthCheckRuns.status,
130
+ latencyMs: healthCheckRuns.latencyMs,
131
+ timestamp: healthCheckRuns.timestamp,
132
+ })
133
+ .from(healthCheckRuns)
134
+ .where(
135
+ and(
136
+ eq(healthCheckRuns.configurationId, configId),
137
+ eq(healthCheckRuns.systemId, systemId),
138
+ environmentId === null
139
+ ? isNull(healthCheckRuns.environmentId)
140
+ : eq(healthCheckRuns.environmentId, environmentId),
141
+ ),
142
+ )
143
+ .orderBy(desc(healthCheckRuns.timestamp))
144
+ .limit(limit);
145
+ return rows.map((r) => ({
146
+ environmentId: r.environmentId,
147
+ status: r.status,
148
+ latencyMs: r.latencyMs,
149
+ timestamp: r.timestamp,
150
+ }));
151
+ }
152
+
101
153
  /** The known transport timing phase keys, in transport order. */
102
154
  const RUN_TIMING_KEYS = [
103
155
  "dnsMs",
@@ -185,11 +237,38 @@ async function emitCheckCompletedHook({
185
237
  }
186
238
 
187
239
  /**
188
- * Payload for health check queue jobs
240
+ * Payload for health check queue jobs. Every job runs EXACTLY ONE environment
241
+ * slice - there is no in-job fan-out:
242
+ * - `environmentId: null` - the single ENV-LESS run of a system that has no
243
+ * environments. Its write IS the system rollup, so it notifies directly.
244
+ * - `environmentId: <id>` - the run for that specific environment. The system
245
+ * rollup is recomputed by the event-driven rollup consumer, not inline.
246
+ *
247
+ * The scheduling reconciler owns which (config, system, env) jobs exist; the
248
+ * `run_now` action enqueues one job per effective environment.
189
249
  */
190
250
  export interface HealthCheckJobPayload {
191
251
  configId: string;
192
252
  systemId: string;
253
+ environmentId: string | null;
254
+ }
255
+
256
+ /** Prefix every health-check recurring jobId shares (used for orphan scans). */
257
+ export const HEALTH_CHECK_JOB_PREFIX = "healthcheck:";
258
+
259
+ /**
260
+ * Build the recurring jobId for a check. The env-less form keeps the historical
261
+ * `healthcheck:${configId}:${systemId}` shape (so env-less systems' jobs are
262
+ * unchanged across the per-env migration); an env-scoped job appends the env id.
263
+ */
264
+ export function encodeHealthCheckJobId(props: {
265
+ configId: string;
266
+ systemId: string;
267
+ environmentId: string | null;
268
+ }): string {
269
+ const { configId, systemId, environmentId } = props;
270
+ const base = `${HEALTH_CHECK_JOB_PREFIX}${configId}:${systemId}`;
271
+ return environmentId === null ? base : `${base}:${environmentId}`;
193
272
  }
194
273
 
195
274
  /**
@@ -230,7 +309,11 @@ export async function scheduleHealthCheck(props: {
230
309
  const queue =
231
310
  queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
232
311
 
233
- const jobId = `healthcheck:${payload.configId}:${payload.systemId}`;
312
+ const jobId = encodeHealthCheckJobId({
313
+ configId: payload.configId,
314
+ systemId: payload.systemId,
315
+ environmentId: payload.environmentId,
316
+ });
234
317
 
235
318
  logger?.debug(
236
319
  `Scheduling recurring health check ${jobId} with interval ${intervalSeconds}s, startDelay ${startDelay}s`,
@@ -272,16 +355,45 @@ export async function recomputeSystemRollupHealth(args: {
272
355
  getHealthEntity?: () => EntityHandle<HealthEntityState> | undefined;
273
356
  advisoryLock: AdvisoryLockService;
274
357
  logger: Logger;
275
- }): Promise<void> {
276
- const { systemId, service, getHealthEntity, advisoryLock, logger } = args;
358
+ /**
359
+ * When provided, a real rollup status change (prev โ†’ next) also invalidates
360
+ * the per-system cache and broadcasts `SYSTEM_STATUS_CHANGED`, matching the
361
+ * system-level signal the pre-per-env inline rollup fired. Omit for the pure
362
+ * entity-only recompute (the framework's `ENTITY_CHANGED` still drives
363
+ * SLO/dependency/triggers regardless).
364
+ */
365
+ signalService?: SignalService;
366
+ cache?: HealthCheckCache;
367
+ }): Promise<{ previousStatus: HealthCheckStatus; newStatus: HealthCheckStatus } | undefined> {
368
+ const {
369
+ systemId,
370
+ service,
371
+ getHealthEntity,
372
+ advisoryLock,
373
+ logger,
374
+ signalService,
375
+ cache,
376
+ } = args;
277
377
  const rollupEntityId = encodeHealthEntityId({ systemId });
278
378
  const makeHealthSerializer = createHealthEntitySerializer({ advisoryLock });
379
+ // The system-level signal + cache invalidation are only needed when a caller
380
+ // wants them (the rollup consumer). The framework snapshots its OWN prev
381
+ // inside `handle.mutate` for the authoritative `ENTITY_CHANGED`, so the
382
+ // pure entity-recompute path (pause/resume) skips the extra prev read.
383
+ const wantsSignal = signalService !== undefined || cache !== undefined;
279
384
  try {
385
+ let previousStatus: HealthCheckStatus | undefined;
386
+ if (wantsSignal) {
387
+ const previousState = await service.getSystemHealthStatus(systemId);
388
+ previousStatus = previousState.status;
389
+ }
390
+ let newStatus: HealthCheckStatus | undefined = previousStatus;
280
391
  await writeHealthEntity({
281
392
  handle: getHealthEntity?.(),
282
393
  entityId: rollupEntityId,
283
394
  apply: async () => {
284
395
  const rollupState = await service.getSystemHealthStatus(systemId);
396
+ newStatus = rollupState.status;
285
397
  return toHealthEntityView(rollupState);
286
398
  },
287
399
  serialize: makeHealthSerializer(rollupEntityId),
@@ -291,6 +403,23 @@ export async function recomputeSystemRollupHealth(args: {
291
403
  error,
292
404
  ),
293
405
  });
406
+
407
+ if (
408
+ wantsSignal &&
409
+ previousStatus !== undefined &&
410
+ newStatus !== undefined &&
411
+ newStatus !== previousStatus
412
+ ) {
413
+ await cache?.invalidateSystem(systemId);
414
+ await signalService?.broadcast(SYSTEM_STATUS_CHANGED, {
415
+ systemId,
416
+ previousStatus,
417
+ newStatus,
418
+ });
419
+ }
420
+ return previousStatus !== undefined && newStatus !== undefined
421
+ ? { previousStatus, newStatus }
422
+ : undefined;
294
423
  } catch (error) {
295
424
  // A recompute failure must never break the pause/resume RPC. The
296
425
  // durable tables still hold the authoritative runs; the next run tick
@@ -299,6 +428,7 @@ export async function recomputeSystemRollupHealth(args: {
299
428
  `Failed to recompute system rollup health for ${systemId}`,
300
429
  error,
301
430
  );
431
+ return undefined;
302
432
  }
303
433
  }
304
434
 
@@ -323,6 +453,13 @@ export async function recomputeSystemRollupHealth(args: {
323
453
  * Policy is resolved per-assignment (per system+configuration) โ€” the
324
454
  * just-ran check is the one driving any aggregate transition in this
325
455
  * execution, so its policy is the authoritative one.
456
+ *
457
+ * Returns `true` when a subscriber notification was actually delivered, and
458
+ * `false` when it was skipped (no-op transition, policy/maintenance/incident
459
+ * suppression) or the delivery threw. Callers use this to deduplicate the
460
+ * system-rollup notification against the per-environment ones fired in the
461
+ * same tick: when any environment already notified, the rollup notification
462
+ * (which describes the same underlying outage) is redundant and suppressed.
326
463
  */
327
464
  async function notifyStateChange(props: {
328
465
  systemId: string;
@@ -351,7 +488,7 @@ async function notifyStateChange(props: {
351
488
  maintenanceClient: MaintenanceClient;
352
489
  incidentClient: IncidentClient;
353
490
  logger: Logger;
354
- }): Promise<void> {
491
+ }): Promise<boolean> {
355
492
  const {
356
493
  systemId,
357
494
  systemName,
@@ -373,7 +510,7 @@ async function notifyStateChange(props: {
373
510
 
374
511
  const transition = classifyTransition(previousStatus, newStatus);
375
512
  if (transition === "none") {
376
- return;
513
+ return false;
377
514
  }
378
515
 
379
516
  // Per-assignment notification policy. Failure to load defaults to
@@ -396,7 +533,7 @@ async function notifyStateChange(props: {
396
533
  logger.debug(
397
534
  `Skipping notification for ${systemId}: ${transition} suppressed by policy`,
398
535
  );
399
- return;
536
+ return false;
400
537
  }
401
538
 
402
539
  // Check if notifications should be suppressed due to active maintenance
@@ -407,7 +544,7 @@ async function notifyStateChange(props: {
407
544
  logger.debug(
408
545
  `Skipping notification for ${systemId}: active maintenance with suppression enabled`,
409
546
  );
410
- return;
547
+ return false;
411
548
  }
412
549
  } catch (error) {
413
550
  // Log but continue with notification - suppression check failure shouldn't block notifications
@@ -425,7 +562,7 @@ async function notifyStateChange(props: {
425
562
  logger.debug(
426
563
  `Skipping notification for ${systemId}: active incident with suppression enabled`,
427
564
  );
428
- return;
565
+ return false;
429
566
  }
430
567
  } catch (error) {
431
568
  // Log but continue with notification - suppression check failure shouldn't block notifications
@@ -502,12 +639,16 @@ async function notifyStateChange(props: {
502
639
  logger.debug(
503
640
  `Notified subscribers: ${previousStatus} โ†’ ${newStatus} for system ${systemId}`,
504
641
  );
642
+ return true;
505
643
  } catch (error) {
506
- // Log but don't fail the operation - notifications are best-effort
644
+ // Log but don't fail the operation - notifications are best-effort. A
645
+ // delivery that threw did NOT inform the user, so report `false` and let
646
+ // the caller's rollup fallback still fire.
507
647
  logger.warn(
508
648
  `Failed to notify subscribers for health state change on system ${systemId}:`,
509
649
  error,
510
650
  );
651
+ return false;
511
652
  }
512
653
  }
513
654
 
@@ -551,6 +692,12 @@ async function executeHealthCheckJob(props: {
551
692
  * without it, marker-bearing configs fail their runs clearly.
552
693
  */
553
694
  internalSecrets?: InternalSecretsService;
695
+ /**
696
+ * Slow-check bulkhead + adaptive-timeout runtime, resolved once at worker
697
+ * startup. `null`/`undefined` disables the feature: the classification read
698
+ * is skipped and the run executes exactly as before (full timeout, no lane).
699
+ */
700
+ slowCheckRuntime?: SlowCheckRuntime | null;
554
701
  }): Promise<void> {
555
702
  const {
556
703
  payload,
@@ -569,6 +716,7 @@ async function executeHealthCheckJob(props: {
569
716
  cache,
570
717
  secretResolver,
571
718
  internalSecrets,
719
+ slowCheckRuntime,
572
720
  } = props;
573
721
  const { configId, systemId } = payload;
574
722
 
@@ -592,6 +740,10 @@ async function executeHealthCheckJob(props: {
592
740
  const rollupPreviousState = await service.getSystemHealthStatus(systemId);
593
741
  const rollupPreviousStatus = rollupPreviousState.status;
594
742
 
743
+ // Slow-check lane admission (set when this run was admitted to the suspect
744
+ // lane); released in the outer finally so the slot frees on any exit path.
745
+ let laneKey: string | undefined;
746
+
595
747
  try {
596
748
  // Fetch configuration (including name for signals)
597
749
  const [configRow] = await db
@@ -705,13 +857,15 @@ async function executeHealthCheckJob(props: {
705
857
  // tables and is re-read every tick via the cross-plugin RPC, so every pod
706
858
  // resolves the same set (state-and-scale: no pod-local env state).
707
859
  let membership: Environment[] = [];
860
+ let catalogResolutionFailed = false;
708
861
  try {
709
862
  membership = await catalogClient.resolveSystemEnvironments({ systemId });
710
863
  } catch (error) {
711
- // Fail-open: a catalog read failure must not wedge the check. Degrade
712
- // to an env-less run (today's behavior) rather than skipping the tick.
864
+ // Fail-open: a catalog read failure must not wedge the check. We keep
865
+ // running the payload's env with degraded fields rather than skipping.
866
+ catalogResolutionFailed = true;
713
867
  logger.warn(
714
- `Could not resolve environments for system ${systemId}, running env-less`,
868
+ `Could not resolve environments for system ${systemId}`,
715
869
  error,
716
870
  );
717
871
  // Observability: a `logger.warn` alone is easy to miss when a durable
@@ -735,28 +889,102 @@ async function executeHealthCheckJob(props: {
735
889
  environmentIds: configRow.environmentIds,
736
890
  membership,
737
891
  });
738
- // `null` env => the single env-less run. Each entry => one run per env.
739
- const runEnvironments: (EffectiveEnvironment | null)[] =
740
- effectiveEnvs.length > 0 ? effectiveEnvs : [null];
741
-
742
- // Execute one run per effective environment. Runs are independent (own
743
- // status / latency / result) and persisted with their own
744
- // `environmentId`. Phase 3b: each env-run mutates its OWN env-qualified
745
- // `health` entity (`<systemId>::<environmentId>`, or the bare `<systemId>`
746
- // for the env-less run) through a per-entity serializer; after the loop a
747
- // single ROLLUP write for the bare `<systemId>` recomputes the worst-status
748
- // rollup so system-level consumers keep firing off the unchanged id.
749
- //
750
- // Track whether ANY per-env run persisted (so the rollup write only runs
751
- // when there is something to roll up โ€” an all-failed loop still leaves the
752
- // durable runs the per-env apply already wrote).
753
- let anyEnvRunPersisted = false;
754
- // Whether this tick fans out into REAL environments (vs. the single
755
- // env-less run). When env-less, the loop's lone write already targets the
756
- // bare `<systemId>` entity โ€” which IS the rollup โ€” so no separate rollup
757
- // write is needed. With real envs, the loop writes `<systemId>::<env>`
758
- // entities and we recompute the bare-`<systemId>` rollup after the loop.
759
- const isFannedOut = effectiveEnvs.length > 0;
892
+
893
+ // Select THE single environment this job runs (payload.environmentId). The
894
+ // reconciler owns which (config, system, env) jobs exist; here we only
895
+ // validate the payload's env against the CURRENT effective set so a stale
896
+ // job (env removed, or an env-less job for a system that has since gained
897
+ // envs) is skipped and the reconciler converges the set.
898
+ const targetEnvironmentId = payload.environmentId;
899
+ let singleEnvironment: EffectiveEnvironment | null;
900
+ if (targetEnvironmentId === null) {
901
+ // Env-less job: valid only while the system has no effective envs.
902
+ if (!catalogResolutionFailed && effectiveEnvs.length > 0) {
903
+ logger.debug(
904
+ `Env-less job for ${configId}/${systemId} is stale (system now has ${effectiveEnvs.length} env(s)); skipping`,
905
+ );
906
+ return;
907
+ }
908
+ singleEnvironment = null;
909
+ } else {
910
+ const found =
911
+ effectiveEnvs.find((env) => env.id === targetEnvironmentId) ?? null;
912
+ if (found) {
913
+ singleEnvironment = found;
914
+ } else if (catalogResolutionFailed) {
915
+ // Transient catalog failure: still run the probe, with degraded (empty)
916
+ // env fields rather than skipping the tick. The next tick recovers.
917
+ singleEnvironment = {
918
+ id: targetEnvironmentId,
919
+ name: targetEnvironmentId,
920
+ fields: {},
921
+ };
922
+ } else {
923
+ logger.debug(
924
+ `Env ${targetEnvironmentId} no longer effective for ${configId}/${systemId}; skipping`,
925
+ );
926
+ return;
927
+ }
928
+ }
929
+
930
+ // โ”€โ”€ Slow-check bulkhead + adaptive timeout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
931
+ // Classify THIS slice's recent runs. A slice whose last K runs were SLOW
932
+ // transport failures (held its slot ~the full timeout) is "suspect": it is
933
+ // admitted to a capped, pod-local lane (or DEFERRED this tick when the lane
934
+ // is full or a prior run of the same slice is still in flight โ€” recording
935
+ // nothing so it can't pile up) and probed with a timeout shrunk toward its
936
+ // OWN healthy-latency baseline, so a stuck target frees its slot fast
937
+ // instead of pinning it for the full timeout. A healthy slice is untouched.
938
+ let effectiveTimeout = executionTimeout;
939
+ const sliceEnvironmentId = singleEnvironment?.id ?? null;
940
+ if (slowCheckRuntime) {
941
+ try {
942
+ const recentRuns = await fetchRecentRunsForSlice({
943
+ db,
944
+ configId,
945
+ systemId,
946
+ environmentId: sliceEnvironmentId,
947
+ limit: slowCheckRuntime.recentRunsLimit,
948
+ });
949
+ const decision = evaluateSlowCheckAdmission({
950
+ runtime: slowCheckRuntime,
951
+ recentRuns,
952
+ configId,
953
+ systemId,
954
+ environmentId: sliceEnvironmentId,
955
+ executionTimeoutMs: executionTimeout,
956
+ });
957
+ if (decision.kind === "defer") {
958
+ healthcheckDeferredCounter().add(1, { reason: decision.reason });
959
+ logger.debug(
960
+ `Deferred suspect health check ${configId}/${systemId}` +
961
+ (sliceEnvironmentId ? ` [${sliceEnvironmentId}]` : "") +
962
+ ` (${decision.reason}); recording nothing this tick`,
963
+ );
964
+ // Record nothing: the recurring job stays scheduled, so the next tick
965
+ // retries once the lane drains / the in-flight run finishes.
966
+ return;
967
+ }
968
+ effectiveTimeout = decision.effectiveTimeoutMs;
969
+ laneKey = decision.laneKey;
970
+ } catch (error) {
971
+ // Classification is best-effort: a read failure must never wedge the
972
+ // check. Fall back to the full timeout with no lane admission.
973
+ logger.warn(
974
+ `Slow-check classification failed for ${configId}/${systemId}; running at full timeout`,
975
+ error,
976
+ );
977
+ }
978
+ }
979
+
980
+ // This job runs exactly this ONE env. `isFannedOut` is now a per-JOB
981
+ // property: an env-scoped run (`isFannedOut === true`) mutates the
982
+ // `<systemId>::<env>` entity and leaves the bare `<systemId>` ROLLUP to the
983
+ // event-driven rollup consumer; an env-less run mutates the bare entity
984
+ // (which IS the rollup) and so notifies + broadcasts SYSTEM_STATUS_CHANGED
985
+ // directly.
986
+ const runEnvironments: (EffectiveEnvironment | null)[] = [singleEnvironment];
987
+ const isFannedOut = targetEnvironmentId !== null;
760
988
  for (const environment of runEnvironments) {
761
989
  const environmentId = environment?.id ?? null;
762
990
  // The env-qualified entity id this run mutates. For the env-less run
@@ -1047,9 +1275,9 @@ async function executeHealthCheckJob(props: {
1047
1275
  setTimeout(
1048
1276
  () =>
1049
1277
  reject(
1050
- new Error(`Execution timeout after ${executionTimeout}ms`),
1278
+ new Error(`Execution timeout after ${effectiveTimeout}ms`),
1051
1279
  ),
1052
- executionTimeout,
1280
+ effectiveTimeout,
1053
1281
  ),
1054
1282
  ),
1055
1283
  ]);
@@ -1081,31 +1309,38 @@ async function executeHealthCheckJob(props: {
1081
1309
  handle: getHealthEntity?.(),
1082
1310
  entityId: envEntityId,
1083
1311
  apply: async () => {
1084
- await db.insert(healthCheckRuns).values({
1085
- configurationId: configId,
1086
- systemId,
1087
- environmentId,
1088
- status: result.status,
1089
- latencyMs: result.latencyMs,
1090
- result: { ...result } as Record<string, unknown>,
1091
- sourceId: undefined,
1092
- sourceLabel: "Local",
1093
- });
1094
-
1095
- await incrementHourlyAggregate({
1096
- db,
1097
- systemId,
1098
- configurationId: configId,
1099
- environmentId,
1100
- status: result.status,
1101
- latencyMs: result.latencyMs,
1102
- runTimestamp: new Date(),
1103
- result: { ...result } as Record<string, unknown>,
1104
- collectorRegistry,
1105
- sourceLabel: "Local",
1312
+ // ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1313
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’ 1),
1314
+ // which also makes the run and its aggregate commit atomically.
1315
+ await withScopedTransaction(db, async (tx) => {
1316
+ await tx.insert(healthCheckRuns).values({
1317
+ configurationId: configId,
1318
+ systemId,
1319
+ environmentId,
1320
+ status: result.status,
1321
+ latencyMs: result.latencyMs,
1322
+ result: { ...result } as Record<string, unknown>,
1323
+ sourceId: undefined,
1324
+ sourceLabel: "Local",
1325
+ });
1326
+
1327
+ await incrementHourlyAggregate({
1328
+ db: tx,
1329
+ systemId,
1330
+ configurationId: configId,
1331
+ environmentId,
1332
+ status: result.status,
1333
+ latencyMs: result.latencyMs,
1334
+ runTimestamp: new Date(),
1335
+ result: { ...result } as Record<string, unknown>,
1336
+ collectorRegistry,
1337
+ sourceLabel: "Local",
1338
+ });
1106
1339
  });
1107
1340
 
1108
1341
  // Env-scoped view: the per-env entity reflects only this env's runs.
1342
+ // Runs as its own batched read AFTER the write commits, so it sees
1343
+ // the just-inserted run.
1109
1344
  newState = await service.getSystemHealthStatus(systemId, environmentId);
1110
1345
  return toHealthEntityView(newState);
1111
1346
  },
@@ -1116,7 +1351,6 @@ async function executeHealthCheckJob(props: {
1116
1351
  error,
1117
1352
  ),
1118
1353
  });
1119
- anyEnvRunPersisted = true;
1120
1354
 
1121
1355
  logger.debug(
1122
1356
  `Health check ${configId} for system ${systemId} failed: ${finalError}`,
@@ -1191,6 +1425,21 @@ async function executeHealthCheckJob(props: {
1191
1425
  // undefined and the frontend falls back to the coarse connection split.
1192
1426
  const timings = extractRunTimings(connectedClient);
1193
1427
 
1428
+ // Metrics (OTel no-ops unless enabled): the probe's total wall-clock and its
1429
+ // network sub-phases. The `phase` breakdown is what tells "slow target"
1430
+ // (`wait` grows) apart from "slow connection establishment" (`connect`/`tls`
1431
+ // grow under a same-host stampede) apart from platform delay.
1432
+ healthcheckExecutionHistogram().record(totalLatencyMs, { status });
1433
+ if (timings) {
1434
+ for (const [phase, value] of Object.entries(timings)) {
1435
+ if (typeof value === "number" && Number.isFinite(value)) {
1436
+ healthcheckPhaseHistogram().record(value, {
1437
+ phase: phase.replace(/Ms$/, ""),
1438
+ });
1439
+ }
1440
+ }
1441
+ }
1442
+
1194
1443
  const result = {
1195
1444
  status: status as "healthy" | "unhealthy",
1196
1445
  latencyMs: totalLatencyMs,
@@ -1217,33 +1466,40 @@ async function executeHealthCheckJob(props: {
1217
1466
  handle: getHealthEntity?.(),
1218
1467
  entityId: envEntityId,
1219
1468
  apply: async () => {
1220
- // Store result (spread to convert structured type to plain record for jsonb)
1221
- await db.insert(healthCheckRuns).values({
1222
- configurationId: configId,
1223
- systemId,
1224
- environmentId,
1225
- status: result.status,
1226
- latencyMs: result.latencyMs,
1227
- result: { ...result } as Record<string, unknown>,
1228
- sourceId: undefined,
1229
- sourceLabel: "Local",
1230
- });
1469
+ // ยงperf: batch the run INSERT + aggregate SELECT/UPSERT under ONE
1470
+ // `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’ 1),
1471
+ // which also makes the run and its aggregate commit atomically.
1472
+ await withScopedTransaction(db, async (tx) => {
1473
+ // Store result (spread to convert structured type to plain record for jsonb)
1474
+ await tx.insert(healthCheckRuns).values({
1475
+ configurationId: configId,
1476
+ systemId,
1477
+ environmentId,
1478
+ status: result.status,
1479
+ latencyMs: result.latencyMs,
1480
+ result: { ...result } as Record<string, unknown>,
1481
+ sourceId: undefined,
1482
+ sourceLabel: "Local",
1483
+ });
1231
1484
 
1232
- // Trigger incremental hourly aggregation
1233
- await incrementHourlyAggregate({
1234
- db,
1235
- systemId,
1236
- configurationId: configId,
1237
- environmentId,
1238
- status: result.status,
1239
- latencyMs: result.latencyMs,
1240
- runTimestamp: new Date(),
1241
- result: { ...result } as Record<string, unknown>,
1242
- collectorRegistry,
1243
- sourceLabel: "Local",
1485
+ // Trigger incremental hourly aggregation
1486
+ await incrementHourlyAggregate({
1487
+ db: tx,
1488
+ systemId,
1489
+ configurationId: configId,
1490
+ environmentId,
1491
+ status: result.status,
1492
+ latencyMs: result.latencyMs,
1493
+ runTimestamp: new Date(),
1494
+ result: { ...result } as Record<string, unknown>,
1495
+ collectorRegistry,
1496
+ sourceLabel: "Local",
1497
+ });
1244
1498
  });
1245
1499
 
1246
1500
  // Env-scoped view: the per-env entity reflects only this env's runs.
1501
+ // Runs as its own batched read AFTER the write commits, so it sees the
1502
+ // just-inserted run.
1247
1503
  newState = await service.getSystemHealthStatus(systemId, environmentId);
1248
1504
  return toHealthEntityView(newState);
1249
1505
  },
@@ -1251,7 +1507,6 @@ async function executeHealthCheckJob(props: {
1251
1507
  onError: (error) =>
1252
1508
  logger.warn(`Failed to mirror health entity for ${envEntityId}`, error),
1253
1509
  });
1254
- anyEnvRunPersisted = true;
1255
1510
 
1256
1511
  logger.debug(
1257
1512
  `Ran health check ${configId} for system ${systemId}: ${result.status}`,
@@ -1343,81 +1598,14 @@ async function executeHealthCheckJob(props: {
1343
1598
  }
1344
1599
  } // end per-environment fan-out loop (for ... of runEnvironments)
1345
1600
 
1346
- // โ”€โ”€ System rollup write (ยง7.4.3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1347
- // With real environments, the per-env writes mutated `<systemId>::<env>`
1348
- // entities; the bare `<systemId>` ROLLUP entity (the worst-status view
1349
- // every existing system-level consumer references) must now recompute so
1350
- // it diffs/emits its OWN `ENTITY_CHANGED`. The rollup `apply` does NO new
1351
- // durable insert (the runs are already persisted by the per-env writes) โ€”
1352
- // it just recomputes + returns the all-runs rollup view so the framework
1353
- // diffs prev โ†’ next. Keyed on the bare `health:<systemId>` lock so it
1354
- // serializes against itself, independent of the per-env locks.
1355
- //
1356
- // Skipped when env-less (the loop's lone write already targeted the bare
1357
- // `<systemId>` entity = the rollup) or when nothing persisted (a fully
1358
- // isolated-failure loop left no new runs to roll up).
1359
- if (isFannedOut && anyEnvRunPersisted) {
1360
- const rollupEntityId = encodeHealthEntityId({ systemId });
1361
- let rollupState!: AggregatedHealth;
1362
- try {
1363
- await writeHealthEntity({
1364
- handle: getHealthEntity?.(),
1365
- entityId: rollupEntityId,
1366
- apply: async () => {
1367
- // No durable insert โ€” recompute the all-runs (rollup) view.
1368
- rollupState = await service.getSystemHealthStatus(systemId);
1369
- return toHealthEntityView(rollupState);
1370
- },
1371
- serialize: makeHealthSerializer(rollupEntityId),
1372
- onError: (error) =>
1373
- logger.warn(
1374
- `Failed to mirror rollup health entity for ${systemId}`,
1375
- error,
1376
- ),
1377
- });
1378
-
1379
- // Record the ROLLUP transition (environmentId = null) so system-level
1380
- // "in status since" reflects the aggregate, and notify on a real
1381
- // rollup status change so existing system-level notifications fire.
1382
- if (rollupState.status !== rollupPreviousStatus) {
1383
- await recordStateTransition({
1384
- db,
1385
- systemId,
1386
- configurationId: configId,
1387
- environmentId: null,
1388
- fromStatus: rollupPreviousStatus,
1389
- toStatus: rollupState.status,
1390
- });
1391
-
1392
- await notifyStateChange({
1393
- notificationClient,
1394
- systemId,
1395
- systemName,
1396
- configurationId: configId,
1397
- previousStatus: rollupPreviousStatus,
1398
- newStatus: rollupState.status,
1399
- service,
1400
- catalogClient,
1401
- maintenanceClient,
1402
- incidentClient,
1403
- logger,
1404
- });
1405
-
1406
- await signalService.broadcast(SYSTEM_STATUS_CHANGED, {
1407
- systemId,
1408
- previousStatus: rollupPreviousStatus as HealthCheckStatus,
1409
- newStatus: rollupState.status,
1410
- });
1411
- }
1412
- } catch (rollupError) {
1413
- // The rollup is best-effort reactivity over already-durable runs; a
1414
- // failure must not wedge the (completed) per-env runs.
1415
- logger.error(
1416
- `Failed to write system rollup health for ${systemId}`,
1417
- rollupError,
1418
- );
1419
- }
1420
- }
1601
+ // The system ROLLUP (bare `<systemId>` entity) for a fanned-out env-scoped
1602
+ // run is recomputed ASYNCHRONOUSLY by the event-driven rollup consumer,
1603
+ // which subscribes to per-env `health` entity changes and debounces per
1604
+ // system (recordSystemRollupChange). Doing it inline per env-job would
1605
+ // multiply the `health:<systemId>` advisory-lock load by the fan-out
1606
+ // factor. An env-less run needs no separate rollup: its write above IS the
1607
+ // bare `<systemId>` entity, and it already recorded its own transition,
1608
+ // notification, and SYSTEM_STATUS_CHANGED signal.
1421
1609
 
1422
1610
  // Note: No manual rescheduling needed - recurring job handles it automatically
1423
1611
  } catch (error) {
@@ -1438,27 +1626,32 @@ async function executeHealthCheckJob(props: {
1438
1626
  handle: getHealthEntity?.(),
1439
1627
  entityId: rollupEntityId,
1440
1628
  apply: async () => {
1441
- // Store failure (no latencyMs for failures)
1442
- await db.insert(healthCheckRuns).values({
1443
- configurationId: configId,
1444
- systemId,
1445
- status: "unhealthy",
1446
- result: { error: String(error) } as Record<string, unknown>,
1447
- sourceId: undefined,
1448
- sourceLabel: "Local",
1449
- });
1629
+ // ยงperf: batch the failure run INSERT + aggregate SELECT/UPSERT under
1630
+ // ONE `SET LOCAL search_path` transaction (3 scoped-db transactions โ†’
1631
+ // 1), which also makes them commit atomically.
1632
+ await withScopedTransaction(db, async (tx) => {
1633
+ // Store failure (no latencyMs for failures)
1634
+ await tx.insert(healthCheckRuns).values({
1635
+ configurationId: configId,
1636
+ systemId,
1637
+ status: "unhealthy",
1638
+ result: { error: String(error) } as Record<string, unknown>,
1639
+ sourceId: undefined,
1640
+ sourceLabel: "Local",
1641
+ });
1450
1642
 
1451
- // Trigger incremental hourly aggregation
1452
- await incrementHourlyAggregate({
1453
- db,
1454
- systemId,
1455
- configurationId: configId,
1456
- status: "unhealthy",
1457
- latencyMs: undefined,
1458
- runTimestamp: new Date(),
1459
- // No collector data for error cases
1460
- collectorRegistry,
1461
- sourceLabel: "Local",
1643
+ // Trigger incremental hourly aggregation
1644
+ await incrementHourlyAggregate({
1645
+ db: tx,
1646
+ systemId,
1647
+ configurationId: configId,
1648
+ status: "unhealthy",
1649
+ latencyMs: undefined,
1650
+ runTimestamp: new Date(),
1651
+ // No collector data for error cases
1652
+ collectorRegistry,
1653
+ sourceLabel: "Local",
1654
+ });
1462
1655
  });
1463
1656
 
1464
1657
  newState = await service.getSystemHealthStatus(systemId);
@@ -1554,6 +1747,11 @@ async function executeHealthCheckJob(props: {
1554
1747
  }
1555
1748
 
1556
1749
  // Note: No manual rescheduling needed - recurring job handles it automatically
1750
+ } finally {
1751
+ // Release the suspect-lane slot (single-flight + capacity) on EVERY exit
1752
+ // path โ€” success, timeout, or a catastrophic throw โ€” so a slow run frees
1753
+ // its slot for the next tick. A no-op when this run was not admitted.
1754
+ if (laneKey && slowCheckRuntime) slowCheckRuntime.lane.release(laneKey);
1557
1755
  }
1558
1756
  }
1559
1757
 
@@ -1574,6 +1772,12 @@ export async function setupHealthCheckWorker(props: {
1574
1772
  cache: HealthCheckCache;
1575
1773
  secretResolver?: SecretResolverService;
1576
1774
  internalSecrets?: InternalSecretsService;
1775
+ /**
1776
+ * Slow-check bulkhead runtime. Omit to resolve it once from `process.env`
1777
+ * (the production path); pass `null` to force the feature OFF (tests that
1778
+ * don't exercise the bulkhead), or a concrete runtime to drive it.
1779
+ */
1780
+ slowCheckRuntime?: SlowCheckRuntime | null;
1577
1781
  }): Promise<void> {
1578
1782
  const {
1579
1783
  db,
@@ -1594,6 +1798,16 @@ export async function setupHealthCheckWorker(props: {
1594
1798
  internalSecrets,
1595
1799
  } = props;
1596
1800
 
1801
+ // Resolve the slow-check runtime once at startup unless the caller supplied
1802
+ // one (including an explicit `null` to disable it).
1803
+ const slowCheckRuntime =
1804
+ props.slowCheckRuntime === undefined
1805
+ ? resolveSlowCheckRuntime(process.env)
1806
+ : props.slowCheckRuntime;
1807
+ if (slowCheckRuntime) {
1808
+ logger.debug("๐Ÿฉบ Slow-check bulkhead + adaptive timeout enabled.");
1809
+ }
1810
+
1597
1811
  const queue =
1598
1812
  queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
1599
1813
 
@@ -1617,6 +1831,7 @@ export async function setupHealthCheckWorker(props: {
1617
1831
  cache,
1618
1832
  secretResolver,
1619
1833
  internalSecrets,
1834
+ slowCheckRuntime,
1620
1835
  });
1621
1836
  },
1622
1837
  {
@@ -1628,117 +1843,3 @@ export async function setupHealthCheckWorker(props: {
1628
1843
  logger.debug("๐ŸŽฏ Health Check Worker subscribed to queue");
1629
1844
  }
1630
1845
 
1631
- /**
1632
- * Bootstrap health checks by enqueueing all enabled checks
1633
- */
1634
- export async function bootstrapHealthChecks(props: {
1635
- db: Db;
1636
- queueManager: QueueManager;
1637
- logger: Logger;
1638
- }): Promise<void> {
1639
- const { db, queueManager, logger } = props;
1640
-
1641
- // Get all enabled health checks
1642
- const enabledChecks = await db
1643
- .select({
1644
- systemId: systemHealthChecks.systemId,
1645
- configId: healthCheckConfigurations.id,
1646
- interval: healthCheckConfigurations.intervalSeconds,
1647
- })
1648
- .from(systemHealthChecks)
1649
- .innerJoin(
1650
- healthCheckConfigurations,
1651
- eq(systemHealthChecks.configurationId, healthCheckConfigurations.id),
1652
- )
1653
- .where(eq(systemHealthChecks.enabled, true));
1654
-
1655
- // Get latest run timestamp for each system+config pair
1656
- // Using Drizzle's max() function for proper timestamp handling (no raw SQL)
1657
- const latestRuns = await db
1658
- .select({
1659
- systemId: healthCheckRuns.systemId,
1660
- configurationId: healthCheckRuns.configurationId,
1661
- maxTimestamp: max(healthCheckRuns.timestamp),
1662
- })
1663
- .from(healthCheckRuns)
1664
- .groupBy(healthCheckRuns.systemId, healthCheckRuns.configurationId);
1665
-
1666
- // Create a lookup map for fast access
1667
- const lastRunMap = new Map<string, Date>();
1668
- for (const run of latestRuns) {
1669
- if (run.maxTimestamp) {
1670
- const key = `${run.systemId}:${run.configurationId}`;
1671
- lastRunMap.set(key, run.maxTimestamp);
1672
- }
1673
- }
1674
-
1675
- logger.debug(`Bootstrapping ${enabledChecks.length} health checks`);
1676
-
1677
- for (const check of enabledChecks) {
1678
- // Look up the last run from the map
1679
- const lastRunKey = `${check.systemId}:${check.configId}`;
1680
- const lastRun = lastRunMap.get(lastRunKey);
1681
-
1682
- // Calculate delay for first run based on time since last run
1683
- let startDelay = 0;
1684
- if (lastRun) {
1685
- const elapsedSeconds = Math.floor(
1686
- (Date.now() - lastRun.getTime()) / 1000,
1687
- );
1688
- if (elapsedSeconds < check.interval) {
1689
- // Not overdue yet - schedule with remaining time
1690
- startDelay = check.interval - elapsedSeconds;
1691
- }
1692
- // Otherwise it's overdue - run immediately (startDelay = 0)
1693
- logger.debug(
1694
- `Health check ${check.configId}:${
1695
- check.systemId
1696
- } - lastRun: ${lastRun.toISOString()}, elapsed: ${elapsedSeconds}s, interval: ${
1697
- check.interval
1698
- }s, startDelay: ${startDelay}s`,
1699
- );
1700
- } else {
1701
- logger.debug(
1702
- `Health check ${check.configId}:${check.systemId} - no lastRun found, running immediately`,
1703
- );
1704
- }
1705
-
1706
- await scheduleHealthCheck({
1707
- queueManager,
1708
- payload: {
1709
- configId: check.configId,
1710
- systemId: check.systemId,
1711
- },
1712
- intervalSeconds: check.interval,
1713
- startDelay,
1714
- logger,
1715
- });
1716
- }
1717
-
1718
- logger.debug(`โœ… Bootstrapped ${enabledChecks.length} health checks`);
1719
-
1720
- // Clean up orphaned jobs
1721
- const queue =
1722
- queueManager.getQueue<HealthCheckJobPayload>(HEALTH_CHECK_QUEUE);
1723
- const allRecurringJobs = await queue.listRecurringJobs();
1724
- const expectedJobIds = new Set(
1725
- enabledChecks.map(
1726
- (check) => `healthcheck:${check.configId}:${check.systemId}`,
1727
- ),
1728
- );
1729
-
1730
- const orphanedJobs = allRecurringJobs.filter(
1731
- (jobId) => jobId.startsWith("healthcheck:") && !expectedJobIds.has(jobId),
1732
- );
1733
-
1734
- for (const jobId of orphanedJobs) {
1735
- await queue.cancelRecurring(jobId);
1736
- logger.debug(`Removed orphaned job scheduler: ${jobId}`);
1737
- }
1738
-
1739
- if (orphanedJobs.length > 0) {
1740
- logger.info(
1741
- `๐Ÿงน Cleaned up ${orphanedJobs.length} orphaned health check jobs`,
1742
- );
1743
- }
1744
- }