@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,270 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8aae4e2: Count fanned-out environment slices in the dashboard's "X of Y checks failing".
8
+
9
+ The dashboard problem card counted CHECKS, so a system with a single check that
10
+ fans out to three environments showed "Unhealthy 1 of 1 checks failing" even
11
+ when only one of the three environments was failing. It now counts (check ×
12
+ environment) slices: that system reads "1 of 3 checks failing", and a system
13
+ with a three-environment check plus a single-environment check with one
14
+ environment failing reads "1 of 4 checks failing". An env-less check counts as a
15
+ single slice, so a system with no environments reads exactly as before.
16
+
17
+ The per-check status DTO (`SystemCheckStatus`, returned by
18
+ `getSystemHealthStatus` / `getBulkSystemHealthStatus` /
19
+ `getBulkSystemHealthMatrix`) gains two fields: `sliceCount` (environment slices
20
+ this check currently fans out to, always >= 1) and `failingSliceCount` (how many
21
+ of those slices are non-healthy). `deriveHealthcheckSignals` sums them across
22
+ checks for the honest numerator/denominator.
23
+
24
+ - d0eddc9: Rework health-check scheduling to one recurring job per
25
+ `(configuration, system, environment)` slice and add a slow-check bulkhead so a
26
+ slow or unreachable check can no longer starve the healthy ones.
27
+
28
+ Previously a single recurring job per `(configuration, system)` fanned out over
29
+ every environment sequentially inside one tick, so the job held a concurrency
30
+ slot for the sum of all its environments, and a slow environment stalled its
31
+ siblings. Now each environment slice is its own recurring job that holds a slot
32
+ only for its own probe. A convergence reconciler (k8s-controller style) derives
33
+ the desired per-env job set from Postgres + catalog membership and converges the
34
+ queue toward it (schedule missing, cancel orphans, reschedule interval changes),
35
+ so it is self-healing across pods and stays correct as catalog membership
36
+ changes. It runs at boot, and system-scoped after an assignment or GitOps
37
+ change. `run_now` enqueues one one-off job per effective environment.
38
+
39
+ The system rollup (the bare `<systemId>` health entity every badge, SLO rule and
40
+ dependency map reads) is recomputed by an event-driven, debounced consumer that
41
+ subscribes to per-environment health changes and recomputes once per system per
42
+ window, instead of inline on every tick. Notifications stay owned by the
43
+ per-environment runs, so the rollup notification is structurally deduplicated.
44
+
45
+ The bulkhead classifies each slice's recent runs: a slice whose last K runs were
46
+ slow transport failures (held its slot ~the full timeout) is admitted to a
47
+ capped, pod-local lane (single-flight per slice) and probed with a timeout shrunk
48
+ toward its own healthy-latency baseline, or DEFERRED (recording nothing, freeing
49
+ the slot) when the lane is full or a prior run is still in flight. The adaptive
50
+ timeout has four deadlock guardrails: no baseline means no shrink, the baseline
51
+ uses only healthy runs, every Nth suspect run re-probes at the full timeout, and
52
+ an absolute floor. A healthy slice is never gated and always runs at the full
53
+ timeout. A new `checkstack.healthcheck.deferred{reason}` counter records
54
+ bulkhead deferrals.
55
+
56
+ Measured with the scale harness (240 checks, 20% unreachable, concurrency 10, 5s
57
+ timeout, 35s): with the bulkhead off the queue backlog climbs unbounded to 774
58
+ while 60 slow checks pin slots; with it on the backlog stays bounded (drains to
59
+ 0), completions roughly triple (288 → 862), and slot-pinning timeouts drop
60
+ (60 → 12) as 207 suspect runs are deferred.
61
+
62
+ `@checkstack/test-utils-backend` gains a `withTransactionMock` helper that adds a
63
+ `.transaction(cb)` passthrough to a mock database, so tests can exercise code
64
+ that batches reads/writes through `withScopedTransaction`.
65
+
66
+ BREAKING CHANGE: the internal `HealthCheckJobPayload` now requires an
67
+ `environmentId` field and recurring health-check job IDs are per-environment
68
+ (`healthcheck:<config>:<system>[:<env>]`). This is an internal queue contract
69
+ with no external package API surface; on upgrade the reconciler cancels the
70
+ old-format jobs and schedules the per-environment set at boot.
71
+
72
+ - 8aae4e2: Show the last successful run per check (or per check+environment when fanned
73
+ out) in the system overview.
74
+
75
+ Each overview row that is currently degraded or unhealthy now shows when it was
76
+ last healthy (for example "Healthy until 2h ago", or "Never healthy" when it has
77
+ never succeeded), so operators can see at a glance since when a system has been
78
+ degraded or unhealthy without opening the drawer.
79
+
80
+ `getSystemHealthOverview` gains a `lastSuccessfulRunAt` field at both the check
81
+ level (most recent healthy run across all of the check's environments) and per
82
+ environment (`perEnvironment[].lastSuccessfulRunAt`). It is computed with a
83
+ dedicated max-per-environment aggregate query OUTSIDE the bounded sparkline
84
+ window, so it stays accurate even when a check has been failing for far longer
85
+ than the last runs shown in the sparkline.
86
+
87
+ ### Patch Changes
88
+
89
+ - 8aae4e2: Stop sending a duplicate notification when a fanned-out system goes unhealthy.
90
+
91
+ A health check that fans out across environments notified once per environment
92
+ ("... is unhealthy in environment X") AND once more for the system rollup
93
+ ("... is unhealthy") in the same tick, so operators received two notifications
94
+ describing the same outage. The rollup transition is always driven by the very
95
+ environment(s) that already notified, so the rollup notification is now
96
+ suppressed whenever any environment notified this tick. It is still sent as a
97
+ fallback when no environment notified (e.g. every per-env delivery was
98
+ suppressed by policy/maintenance or threw), so a real status change is never
99
+ left entirely unannounced, and a system with no environments is unaffected.
100
+
101
+ Only the redundant user-facing notification is dropped: the rollup state
102
+ transition is still recorded and the `SYSTEM_STATUS_CHANGED` signal is still
103
+ broadcast, so SLO downtime, the dependency graph, the frontend, and automations
104
+ (which subscribe to the per-env and rollup entity changes) are unchanged.
105
+
106
+ - d0eddc9: Cut health-check connection churn and de-cluster the scheduling "thundering
107
+ herd" so per-run durations stop varying wildly for the same check against the
108
+ same target. Grounded in live OpenTelemetry phase histograms: per-run wall time
109
+ was dominated by TCP/TLS connection setup under a self-inflicted burst, not by
110
+ slow targets, CPU, or the database.
111
+
112
+ - **In-memory queue now honors `startDelay` in `scheduleRecurring`.** It was
113
+ silently dropped, so every recurring job (health checks included) fired
114
+ immediately on boot and then on a boot-anchored interval grid - keeping all
115
+ equal-interval checks phase-aligned forever. `scheduleRecurring` now defers the
116
+ first execution by `startDelay` and anchors the recurrence to that first fire,
117
+ matching the queue contract and the BullMQ backend's intent. Jobs scheduled
118
+ without `startDelay` are unchanged (first run is immediate).
119
+ - **The BullMQ queue now honors `startDelay` in `scheduleRecurring` too.** It also
120
+ dropped `startDelay`, and its `every` scheduler captures the grid phase from
121
+ whenever `upsertJobScheduler` first runs - so a bootstrap loop scheduling many
122
+ equal-interval jobs at ~the same instant handed them all the same phase.
123
+ `scheduleRecurring` now pins the first fire to `now + startDelay` via the
124
+ scheduler's `startDate`, which shifts the whole recurrence, so the same jittered
125
+ `startDelay` de-clusters checks on the Redis backend identically to the
126
+ in-memory one. Cron schedules (absolute times) are unaffected.
127
+ - **The health-check scheduler jitters each check's first fire** by a small,
128
+ deterministic fraction of its interval (stable across restarts, keyed on the
129
+ check). A synchronized set of checks now spreads across the interval instead of
130
+ hammering their targets at the same instant. Because the queue anchors the
131
+ recurrence to the first fire, this offset persists for every subsequent run.
132
+ - **The HTTP collector refreshes its TCP/TLS connect-timing probe in the
133
+ background, per origin, and never awaits it.** Bun's `fetch` already pools and
134
+ reuses connections across runs (verified: warm reuse survives 20s+ idle gaps),
135
+ but the timing probe opened a fresh handshake on EVERY run - mis-reporting the
136
+ reused request's real latency and doubling the connection count under a burst.
137
+ The probe now refreshes a per-origin sample at most once per TTL (60s) and runs
138
+ fully in the background: it is NEVER on a request's critical path. Pinned to one
139
+ resolved IP, the probe can be far slower than the reused fetch (e.g. an
140
+ intermittent IPv6 SYN retry the real request never pays), and per the collector
141
+ contract best-effort timing must never delay the check - the previous code
142
+ `await`ed it, so a slow probe's refresh run showed up as a latency outlier. The
143
+ `connect`/`tls` phases are now explicitly a cached, per-host estimate.
144
+ - **The run detail UI now labels the estimate.** The timing-breakdown caption
145
+ clarifies that DNS, wait, and transfer are measured on the request, while
146
+ connection and TLS setup are an estimate sampled from a periodic per-host probe
147
+ and cached briefly (about a minute), so an operator does not read the cached
148
+ connect/TLS value as a per-run measurement.
149
+
150
+ Behaviour is otherwise unchanged: health status and assertions are the same;
151
+ there are simply far fewer connections, the herd is spread out, and the timing
152
+ breakdown can no longer be inflated by a slow best-effort probe. No configuration
153
+ or API changes.
154
+
155
+ - d0eddc9: Cut the per-tick database work of the health-check executor by batching
156
+ scoped-database queries, and fix a dashboard "Recent activity" rendering bug.
157
+
158
+ The scoped-database proxy has to wrap every standalone query in its own
159
+ transaction so `SET LOCAL search_path` applies to it, which means a hot path
160
+ issuing many sequential queries pays the `BEGIN` / `SET LOCAL` / `COMMIT`
161
+ round-trips once per query and checks a connection out that many times. Two
162
+ changes remove most of that overhead on the health-check path:
163
+
164
+ - **New `withScopedTransaction` helper (`@checkstack/backend-api`).** A reusable
165
+ primitive for running several scoped queries under a SINGLE `SET LOCAL
166
+ search_path` transaction, plus `ScopedTransaction` / `ScopedQueryRunner`
167
+ types so a helper can accept either the scoped db or a transaction handle.
168
+ Use it on any scoped-db hot path that issues 2+ queries in sequence.
169
+ - **`getSystemHealthStatus` is now batched.** It was a `1 + N` read fan-out (one
170
+ associations query, then one run-window query per enabled check) run as `1 +
171
+ N` separate proxy transactions. It now runs as ONE transaction. This is the
172
+ hottest read on the platform - each check tick reads it several times, and the
173
+ dashboard, RPC router, and AI system-signals all call it - so the reduction in
174
+ transaction volume and connection churn is broad. The reads are also now a
175
+ single consistent snapshot.
176
+ - **The executor's run + aggregate writes are batched.** Each persisted run
177
+ previously issued the run `INSERT`, the aggregate `SELECT`, and the aggregate
178
+ `UPSERT` as three separate proxy transactions; they now run in one
179
+ transaction and commit atomically (the run and the aggregate it feeds can no
180
+ longer be persisted apart).
181
+
182
+ Behaviour is unchanged: the derived health status, transition detection, and
183
+ signals are identical; only the number of database transactions per tick drops.
184
+
185
+ Also fixes a dashboard bug where the "Recent activity" feed generated React keys
186
+ from `configurationName` plus a millisecond timestamp, so results from different
187
+ systems sharing a check name that completed in the same millisecond collided on
188
+ one key and React mis-reconciled the list (visually duplicated/omitted entries).
189
+ Keys are now derived from the system, configuration, and environment ids.
190
+
191
+ - d0eddc9: Add opt-in OpenTelemetry metrics with a Prometheus exporter so a performance
192
+ investigation can be grounded in real numbers from a running instance instead of
193
+ guesses.
194
+
195
+ The layer is **off by default and free when off**: the instruments are OTel
196
+ no-ops until a `MeterProvider` is registered, so the hot paths pay nothing until
197
+ you opt in.
198
+
199
+ - **`@checkstack/backend-api` gains an `instrumentation` module** exporting lazy,
200
+ memoized instrument accessors any plugin can record through:
201
+ `dbTransactionsCounter`, `dbQueriesCounter`, `healthcheckExecutionHistogram`,
202
+ `healthcheckPhaseHistogram`, `queueEnqueuedCounter`, `queueProcessedCounter`.
203
+ Each looks up its instrument once and is a no-op until the host registers a
204
+ provider, so callers can record unconditionally.
205
+ - **`@checkstack/backend` owns the SDK bootstrap.** `startMetrics()` registers a
206
+ global `MeterProvider` + Prometheus exporter when `CHECKSTACK_METRICS_ENABLED`
207
+ is set (host `127.0.0.1`, port `9464` by default, both overridable via
208
+ `CHECKSTACK_METRICS_HOST` / `CHECKSTACK_METRICS_PORT`). The exporter runs its
209
+ OWN HTTP server, NOT a route on the app, so it carries no app-auth surface. It
210
+ also registers host-owned observable instruments:
211
+ `checkstack.db.pool.connections` (admin/lock pool active/idle/waiting) and
212
+ `checkstack.runtime.event_loop_delay` (setInterval-drift histogram = JS-thread
213
+ block time).
214
+ - **The scoped-DB proxy records DB transactions/queries per plugin schema**, so
215
+ `db_transactions_total` minus `db_queries_total` per schema is exactly the
216
+ number of batched transactions - a live check that `withScopedTransaction`
217
+ batching is taking effect.
218
+ - **The health-check executor records execution + per-phase histograms**
219
+ (`connect`, `wait`, ...) so a high `connect` p95 with a low `wait` points at
220
+ connection establishment rather than a slow target or a CPU-bound platform.
221
+ - **The in-memory queue records enqueued/processed counters** per queue and
222
+ status.
223
+
224
+ No behaviour changes when disabled. Enable with `CHECKSTACK_METRICS_ENABLED=1`
225
+ and scrape `http://127.0.0.1:9464/metrics`. See the backend observability guide
226
+ for the full metric list and interpretation.
227
+
228
+ - Updated dependencies [8aae4e2]
229
+ - Updated dependencies [f93ee7a]
230
+ - Updated dependencies [f93ee7a]
231
+ - Updated dependencies [8aae4e2]
232
+ - Updated dependencies [d0eddc9]
233
+ - Updated dependencies [d0eddc9]
234
+ - Updated dependencies [f93ee7a]
235
+ - Updated dependencies [f93ee7a]
236
+ - Updated dependencies [d0eddc9]
237
+ - Updated dependencies [d0eddc9]
238
+ - Updated dependencies [8aae4e2]
239
+ - Updated dependencies [d0eddc9]
240
+ - Updated dependencies [f93ee7a]
241
+ - @checkstack/healthcheck-common@1.15.0
242
+ - @checkstack/common@0.22.0
243
+ - @checkstack/catalog-common@2.6.3
244
+ - @checkstack/ai-backend@0.10.9
245
+ - @checkstack/backend-api@0.31.0
246
+ - @checkstack/automation-backend@0.11.0
247
+ - @checkstack/incident-common@1.9.0
248
+ - @checkstack/incident-backend@1.11.0
249
+ - @checkstack/maintenance-common@1.9.0
250
+ - @checkstack/script-packages-backend@0.4.0
251
+ - @checkstack/satellite-backend@0.8.3
252
+ - @checkstack/sdk@0.126.1
253
+ - @checkstack/ai-common@0.6.6
254
+ - @checkstack/cache-api@0.3.19
255
+ - @checkstack/catalog-backend@1.6.9
256
+ - @checkstack/command-backend@0.2.21
257
+ - @checkstack/gitops-backend@0.5.21
258
+ - @checkstack/gitops-common@0.7.3
259
+ - @checkstack/notification-common@1.5.3
260
+ - @checkstack/queue-api@0.3.19
261
+ - @checkstack/secrets-backend@0.3.3
262
+ - @checkstack/secrets-common@0.3.2
263
+ - @checkstack/signal-common@0.2.17
264
+ - @checkstack/status-page-backend@0.4.8
265
+ - @checkstack/status-page-common@0.5.3
266
+ - @checkstack/cache-utils@0.2.24
267
+
3
268
  ## 1.17.0
4
269
 
5
270
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.8",
18
- "@checkstack/ai-common": "0.6.5",
19
- "@checkstack/automation-backend": "0.10.10",
20
- "@checkstack/backend-api": "0.30.0",
21
- "@checkstack/cache-api": "0.3.18",
22
- "@checkstack/cache-utils": "0.2.23",
23
- "@checkstack/catalog-backend": "1.6.8",
24
- "@checkstack/catalog-common": "2.6.2",
25
- "@checkstack/command-backend": "0.2.20",
26
- "@checkstack/common": "0.21.0",
27
- "@checkstack/gitops-backend": "0.5.20",
28
- "@checkstack/gitops-common": "0.7.2",
29
- "@checkstack/healthcheck-common": "1.14.0",
30
- "@checkstack/incident-backend": "1.10.0",
31
- "@checkstack/incident-common": "1.8.0",
32
- "@checkstack/maintenance-common": "1.8.2",
33
- "@checkstack/notification-common": "1.5.2",
34
- "@checkstack/queue-api": "0.3.18",
35
- "@checkstack/satellite-backend": "0.8.2",
36
- "@checkstack/script-packages-backend": "0.3.24",
37
- "@checkstack/sdk": "0.125.1",
38
- "@checkstack/secrets-backend": "0.3.2",
39
- "@checkstack/secrets-common": "0.3.1",
40
- "@checkstack/signal-common": "0.2.16",
41
- "@checkstack/status-page-backend": "0.4.7",
42
- "@checkstack/status-page-common": "0.5.2",
17
+ "@checkstack/ai-backend": "0.10.9",
18
+ "@checkstack/ai-common": "0.6.6",
19
+ "@checkstack/automation-backend": "0.11.0",
20
+ "@checkstack/backend-api": "0.31.0",
21
+ "@checkstack/cache-api": "0.3.19",
22
+ "@checkstack/cache-utils": "0.2.24",
23
+ "@checkstack/catalog-backend": "1.6.9",
24
+ "@checkstack/catalog-common": "2.6.3",
25
+ "@checkstack/command-backend": "0.2.21",
26
+ "@checkstack/common": "0.22.0",
27
+ "@checkstack/gitops-backend": "0.5.21",
28
+ "@checkstack/gitops-common": "0.7.3",
29
+ "@checkstack/healthcheck-common": "1.15.0",
30
+ "@checkstack/incident-backend": "1.11.0",
31
+ "@checkstack/incident-common": "1.9.0",
32
+ "@checkstack/maintenance-common": "1.9.0",
33
+ "@checkstack/notification-common": "1.5.3",
34
+ "@checkstack/queue-api": "0.3.19",
35
+ "@checkstack/satellite-backend": "0.8.3",
36
+ "@checkstack/script-packages-backend": "0.4.0",
37
+ "@checkstack/sdk": "0.126.1",
38
+ "@checkstack/secrets-backend": "0.3.3",
39
+ "@checkstack/secrets-common": "0.3.2",
40
+ "@checkstack/signal-common": "0.2.17",
41
+ "@checkstack/status-page-backend": "0.4.8",
42
+ "@checkstack/status-page-common": "0.5.3",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -52,11 +52,12 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.2",
56
- "@checkstack/test-utils-backend": "0.1.54",
55
+ "@checkstack/scripts": "0.7.3",
56
+ "@checkstack/test-utils-backend": "0.1.55",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
59
  "@types/tdigest": "^0.1.5",
60
+ "bullmq": "^5.66.4",
60
61
  "date-fns": "^4.4.0",
61
62
  "drizzle-kit": "^0.31.10",
62
63
  "typescript": "^5.0.0"
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ adaptiveTimeout,
4
+ DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
5
+ } from "./adaptive-timeout";
6
+
7
+ describe("adaptiveTimeout", () => {
8
+ const configuredMs = 30_000;
9
+
10
+ test("guardrail 1: no baseline => never shrink", () => {
11
+ expect(
12
+ adaptiveTimeout({
13
+ configuredMs,
14
+ healthyBaselineMs: undefined,
15
+ isSuspect: true,
16
+ isRecoveryProbe: false,
17
+ }),
18
+ ).toBe(configuredMs);
19
+ });
20
+
21
+ test("non-suspect check keeps the full configured timeout", () => {
22
+ expect(
23
+ adaptiveTimeout({
24
+ configuredMs,
25
+ healthyBaselineMs: 200,
26
+ isSuspect: false,
27
+ isRecoveryProbe: false,
28
+ }),
29
+ ).toBe(configuredMs);
30
+ });
31
+
32
+ test("guardrail 3: recovery probe always uses the full configured timeout", () => {
33
+ expect(
34
+ adaptiveTimeout({
35
+ configuredMs,
36
+ healthyBaselineMs: 200,
37
+ isSuspect: true,
38
+ isRecoveryProbe: true,
39
+ }),
40
+ ).toBe(configuredMs);
41
+ });
42
+
43
+ test("fast healthy check shrinks toward the floor (200ms => ~1s)", () => {
44
+ // 200 * 1.5 = 300 < floor => clamped to the absolute floor.
45
+ expect(
46
+ adaptiveTimeout({
47
+ configuredMs,
48
+ healthyBaselineMs: 200,
49
+ isSuspect: true,
50
+ isRecoveryProbe: false,
51
+ }),
52
+ ).toBe(DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS);
53
+ });
54
+
55
+ test("deadlock guard: a slow-but-healthy check (10s) never shrinks below its own latency", () => {
56
+ // 10_000 * 1.5 = 15_000: a recovering 10s run still passes at 15s.
57
+ const t = adaptiveTimeout({
58
+ configuredMs,
59
+ healthyBaselineMs: 10_000,
60
+ isSuspect: true,
61
+ isRecoveryProbe: false,
62
+ });
63
+ expect(t).toBe(15_000);
64
+ expect(t).toBeGreaterThan(10_000);
65
+ });
66
+
67
+ test("shrink never exceeds the configured timeout", () => {
68
+ // baseline*factor would exceed configured => clamp down to configured.
69
+ expect(
70
+ adaptiveTimeout({
71
+ configuredMs: 5_000,
72
+ healthyBaselineMs: 8_000,
73
+ isSuspect: true,
74
+ isRecoveryProbe: false,
75
+ }),
76
+ ).toBe(5_000);
77
+ });
78
+
79
+ test("honors custom safetyFactor and absoluteFloor", () => {
80
+ expect(
81
+ adaptiveTimeout({
82
+ configuredMs,
83
+ healthyBaselineMs: 1_000,
84
+ isSuspect: true,
85
+ isRecoveryProbe: false,
86
+ safetyFactor: 2,
87
+ absoluteFloorMs: 500,
88
+ }),
89
+ ).toBe(2_000);
90
+ });
91
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Adaptive execution timeout for slot-hogging health checks.
3
+ *
4
+ * A check that hangs holds its concurrency slot for the FULL configured timeout
5
+ * (see `queue-executor.ts` — the per-env `Promise.race` frees the slot only when
6
+ * the timeout fires). When a target is consistently timing out, we can free that
7
+ * slot sooner by probing it with a SHORTER timeout — but only down to a value at
8
+ * which a genuinely HEALTHY run of this same check would still succeed, or we
9
+ * would abort even a recovering target and never let it go healthy again.
10
+ *
11
+ * The floor is therefore derived from the check's OWN measured healthy latency,
12
+ * never a global constant. Four guardrails make a recovery deadlock impossible:
13
+ *
14
+ * 1. No baseline (no recent healthy run) => NO shrink. Without evidence of the
15
+ * target's healthy latency we cannot tell "hung" from "legitimately slow and
16
+ * about to succeed", so we keep the full configured timeout.
17
+ * 2. The baseline is computed from SUCCESSFUL runs only (caller's job), so a
18
+ * timed-out run (latency ~= timeout) can never ratchet the floor downward.
19
+ * 3. The periodic recovery probe passes `isRecoveryProbe: true` and always gets
20
+ * the FULL configured timeout — so a genuinely-slow-but-healthy target (e.g.
21
+ * a 10s computation) is always eventually re-measured at its real latency.
22
+ * This is the by-construction deadlock breaker.
23
+ * 4. A single healthy run clears the suspect classification upstream, so the
24
+ * full timeout is restored immediately (hysteresis).
25
+ */
26
+
27
+ /** Default multiplier applied to the healthy-latency baseline. */
28
+ export const DEFAULT_TIMEOUT_SAFETY_FACTOR = 1.5;
29
+ /** Default absolute lower bound; the timeout is never shrunk below this. */
30
+ export const DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS = 1000;
31
+
32
+ export interface AdaptiveTimeoutInput {
33
+ /** The user-configured execution timeout (ms). The shrink never exceeds it. */
34
+ configuredMs: number;
35
+ /**
36
+ * p95 (or max) latency of this check's recent SUCCESSFUL runs, in ms.
37
+ * `undefined` means "no healthy baseline" => guardrail 1 (never shrink).
38
+ */
39
+ healthyBaselineMs: number | undefined;
40
+ /** Whether this check is classified as a consistent slot-hogging failure. */
41
+ isSuspect: boolean;
42
+ /** Whether this run is the periodic full-timeout recovery probe (guardrail 3). */
43
+ isRecoveryProbe: boolean;
44
+ /** Multiplier on the baseline. Defaults to {@link DEFAULT_TIMEOUT_SAFETY_FACTOR}. */
45
+ safetyFactor?: number;
46
+ /** Hard lower bound. Defaults to {@link DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS}. */
47
+ absoluteFloorMs?: number;
48
+ }
49
+
50
+ /**
51
+ * Resolve the effective execution timeout for a single (env-scoped) run.
52
+ * Returns `configuredMs` unchanged unless the check is a suspect slot-hogger
53
+ * WITH a healthy baseline AND this is not a recovery probe.
54
+ */
55
+ export function adaptiveTimeout(input: AdaptiveTimeoutInput): number {
56
+ const {
57
+ configuredMs,
58
+ healthyBaselineMs,
59
+ isSuspect,
60
+ isRecoveryProbe,
61
+ safetyFactor = DEFAULT_TIMEOUT_SAFETY_FACTOR,
62
+ absoluteFloorMs = DEFAULT_TIMEOUT_ABSOLUTE_FLOOR_MS,
63
+ } = input;
64
+
65
+ // Guardrails 1 & 3, and the non-suspect fast path: use the full timeout.
66
+ if (!isSuspect || isRecoveryProbe || healthyBaselineMs === undefined) {
67
+ return configuredMs;
68
+ }
69
+
70
+ // Shrink toward the target's own healthy latency, clamped to
71
+ // [absoluteFloor, configured]. For a 10s-healthy check this stays >= ~15s;
72
+ // for a 200ms-healthy check it drops to the floor (~1s).
73
+ const shrunk = Math.round(healthyBaselineMs * safetyFactor);
74
+ return Math.min(configuredMs, Math.max(absoluteFloorMs, shrunk));
75
+ }
@@ -20,6 +20,8 @@ const unhealthyStatuses: HealthcheckSignalStatuses = {
20
20
  configurationName: "Ping",
21
21
  status: "unhealthy",
22
22
  runsConsidered: 5,
23
+ sliceCount: 1,
24
+ failingSliceCount: 1,
23
25
  },
24
26
  ],
25
27
  },
@@ -10,6 +10,7 @@ import {
10
10
  assignmentArtifactType,
11
11
  checkFailedTrigger,
12
12
  createHealthCheckActions,
13
+ type HealthCheckActionDeps,
13
14
  healthCheckTriggers,
14
15
  systemDegradedTrigger,
15
16
  systemHealthChangedTrigger,
@@ -146,13 +147,18 @@ describe("assignmentArtifactType", () => {
146
147
 
147
148
  function makeService(args: {
148
149
  setAssignmentEnabledReturn?: boolean;
150
+ enqueueEnvironmentIds?: (string | null)[];
149
151
  }): HealthCheckService & { setMock: ReturnType<typeof mock> } {
150
152
  const setMock = mock(
151
153
  async (_sysId: string, _cfgId: string, _enabled: boolean) =>
152
154
  args.setAssignmentEnabledReturn ?? true,
153
155
  );
156
+ const resolveEnqueueEnvironmentIds = mock(
157
+ async () => args.enqueueEnvironmentIds ?? [null],
158
+ );
154
159
  return {
155
160
  setAssignmentEnabled: setMock,
161
+ resolveEnqueueEnvironmentIds,
156
162
  setMock,
157
163
  } as unknown as HealthCheckService & { setMock: ReturnType<typeof mock> };
158
164
  }
@@ -174,6 +180,11 @@ function makeQueueManager(): QueueEnqueueRecorder {
174
180
  return { queueManager, enqueueMock };
175
181
  }
176
182
 
183
+ // The actions only need a catalog client shape for `run_now`, which delegates
184
+ // environment resolution to the service mock, so a bare stub suffices.
185
+ const catalogClientStub =
186
+ {} as unknown as HealthCheckActionDeps["catalogClient"];
187
+
177
188
  describe("healthcheck.run_now", () => {
178
189
  it("enqueues a one-off job and emits an enqueued=true artifact", async () => {
179
190
  const service = makeService({});
@@ -182,6 +193,7 @@ describe("healthcheck.run_now", () => {
182
193
  const [runNow] = createHealthCheckActions({
183
194
  service,
184
195
  queueManager,
196
+ catalogClient: catalogClientStub,
185
197
  emitHook: emitHook as never,
186
198
  });
187
199
 
@@ -198,10 +210,42 @@ describe("healthcheck.run_now", () => {
198
210
  expect(enqueueMock.mock.calls[0]![0]).toEqual({
199
211
  configId: "cfg-1",
200
212
  systemId: "sys-1",
213
+ environmentId: null,
201
214
  });
202
215
  // run_now doesn't mutate any DB row → no hook to emit.
203
216
  expect(emitHook).not.toHaveBeenCalled();
204
217
  });
218
+
219
+ it("enqueues one job per effective environment slice", async () => {
220
+ const service = makeService({ enqueueEnvironmentIds: ["prod", "staging"] });
221
+ const { queueManager, enqueueMock } = makeQueueManager();
222
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
223
+ const [runNow] = createHealthCheckActions({
224
+ service,
225
+ queueManager,
226
+ catalogClient: catalogClientStub,
227
+ emitHook: emitHook as never,
228
+ });
229
+
230
+ const result = await runNow!.execute({
231
+ ...ctxBase,
232
+ consumedArtifacts: {},
233
+ config: { systemId: "sys-1", configurationId: "cfg-1" } as never,
234
+ });
235
+
236
+ expect(result.success).toBe(true);
237
+ expect(enqueueMock).toHaveBeenCalledTimes(2);
238
+ expect(enqueueMock.mock.calls[0]![0]).toEqual({
239
+ configId: "cfg-1",
240
+ systemId: "sys-1",
241
+ environmentId: "prod",
242
+ });
243
+ expect(enqueueMock.mock.calls[1]![0]).toEqual({
244
+ configId: "cfg-1",
245
+ systemId: "sys-1",
246
+ environmentId: "staging",
247
+ });
248
+ });
205
249
  });
206
250
 
207
251
  describe("healthcheck.enable_assignment", () => {
@@ -212,6 +256,7 @@ describe("healthcheck.enable_assignment", () => {
212
256
  const [, enable] = createHealthCheckActions({
213
257
  service,
214
258
  queueManager,
259
+ catalogClient: catalogClientStub,
215
260
  emitHook: emitHook as never,
216
261
  });
217
262
 
@@ -236,6 +281,7 @@ describe("healthcheck.enable_assignment", () => {
236
281
  const [, enable] = createHealthCheckActions({
237
282
  service,
238
283
  queueManager,
284
+ catalogClient: catalogClientStub,
239
285
  emitHook: emitHook as never,
240
286
  });
241
287
 
@@ -260,6 +306,7 @@ describe("healthcheck.disable_assignment", () => {
260
306
  const [, , disable] = createHealthCheckActions({
261
307
  service,
262
308
  queueManager,
309
+ catalogClient: catalogClientStub,
263
310
  emitHook: emitHook as never,
264
311
  });
265
312