@checkstack/healthcheck-backend 1.19.0 → 1.20.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,195 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bd41130: perf(healthcheck): stop recomputing the full system rollup on every check run
8
+
9
+ The queue run executor captured the system-wide rollup health
10
+ (`getSystemHealthStatus(systemId)`) at the start of EVERY check tick - a
11
+ worst-wins aggregate that fans out an N+1 of windowed `health_check_runs` reads
12
+ across every check × environment of the system. That value was only ever
13
+ consumed on the rare catastrophic-failure path (a job that throws before running
14
+ any probe); the normal success/failure paths record their transition from the
15
+ per-environment pre-read and never touched it. Under load this was one of the
16
+ heaviest repeated reads on the hot path.
17
+
18
+ The rollup pre-status is now computed lazily, only inside the catastrophic-
19
+ failure branch that actually uses it. Behavior is unchanged - the catastrophic
20
+ path reads the same pre-tick rollup (it is reached only when the run threw before
21
+ inserting anything, so nothing changed in between) - but every normal check tick
22
+ no longer pays for a full rollup recompute it discards.
23
+
24
+ - bd41130: perf(healthcheck): add system-leading aggregate and config-reverse indexes
25
+
26
+ Add two Postgres indexes (migration 0020) to serve reads that the existing
27
+ keys cannot cover:
28
+
29
+ - `health_check_aggregates_system_bucket_idx` on
30
+ `(system_id, bucket_size, bucket_start)`. The health-state read omits
31
+ `configuration_id`, so the leading-`configuration_id` unique index could
32
+ not be used and the query scanned the aggregates table. This index leads
33
+ with `system_id` so those reads use an index instead.
34
+ - `system_health_checks_config_enabled_idx` on `(configuration_id, enabled)`.
35
+ The reverse lookup in `getSystemIdsForConfiguration` (config-change
36
+ recompute) filters by `configuration_id`, but the primary key leads with
37
+ `system_id` and could not serve it. This index makes the config-scoped
38
+ lookup an index scan.
39
+
40
+ - bd41130: perf(healthcheck): add the missing composite indexes on health_check_runs
41
+
42
+ The status read path reads the last N runs for a (system, check[, environment])
43
+ slice ordered by `timestamp DESC` on every status read AND on every check
44
+ execution, but `health_check_runs` had NO secondary indexes - only its primary
45
+ key. Every such read was a full sequential scan of the (multi-million-row) table
46
+ plus an in-memory sort, so point reads averaged 50-320 ms and dominated total DB
47
+ time. Two composite indexes now back these access patterns:
48
+
49
+ - `health_check_runs_check_recent_idx` (system_id, configuration_id, timestamp) -
50
+ the cross-environment newest-run reads and the retention `DELETE`.
51
+ - `health_check_runs_slice_recent_idx` (system_id, configuration_id,
52
+ environment_id, timestamp) - the env-scoped slice reads, the per-check
53
+ DISTINCT-environment discovery, and the per-env last-healthy `max(timestamp)`
54
+ group-by.
55
+
56
+ Both turn full-table seq-scans into index range scans (Postgres scans the btree
57
+ backward for the `DESC` order).
58
+
59
+ > [!IMPORTANT]
60
+ > Deploy note: the migration builds the indexes with a plain (non-CONCURRENT)
61
+ > `CREATE INDEX`, which briefly locks writes to `health_check_runs` while each
62
+ > index builds (the migrator runs every migration in one transaction, so
63
+ > `CREATE INDEX CONCURRENTLY` is not possible through it). On a very large table
64
+ > you can build them `CONCURRENTLY` by hand (same names) before deploying; the
65
+ > migration uses `IF NOT EXISTS`, so it then no-ops.
66
+
67
+ - bd41130: perf(healthcheck): cache system health status on the shared distributed cache with per-check-vector invalidation
68
+
69
+ The per-system derived health status (`getSystemHealthStatus`) is an N+1 over
70
+ `health_check_runs` across every check × environment, and it backs the highest
71
+ call-count read paths: the dashboard badges, the bulk status endpoint, the
72
+ per-(system, check, environment) matrix the dependency map and status-page
73
+ widgets consume, and the AI system-signals scan. It was only cached for the
74
+ single/bulk rollup, was invalidated UNCONDITIONALLY on every check run (so a
75
+ steady-state healthy system evicted its own cache every tick), the matrix
76
+ endpoint was not cached at all, and the AI signals scan bypassed the cache with
77
+ its own uncached N+1.
78
+
79
+ All four reads now go through a single `HealthCheckCache` facade - built on the
80
+ **platform `CacheManager`** - that is the ONE sanctioned reader AND invalidator
81
+ of a system's status:
82
+
83
+ - **Reads** (`read` / `readBulk` / `readMatrix`) are served read-through, keyed
84
+ per `(system, environment)`, holding the RAW (pre-incident-override) status;
85
+ the router folds incident overrides downstream, so an incident change never
86
+ touches this cache. The matrix reuses the same per-environment entries the
87
+ badge path warms. The AI signals contributor now scans candidate systems from
88
+ the durable table and resolves their statuses through `readBulk`, reusing the
89
+ warm cache instead of a fresh N+1.
90
+ - **Invalidation is change-gated on the per-check status VECTOR**, not the run:
91
+ `reconcile(previous, next)` evicts only when a check actually flipped status
92
+ (or its slice-failure composition changed) - a `statusFingerprint` invariant
93
+ to the volatile `evaluatedAt` / `lastRunAt` / `runsConsidered`. A run that
94
+ leaves the vector unchanged keeps the cache warm. This also catches a per-check
95
+ flip that leaves the rollup enum unchanged (which the reactive `health` entity
96
+ view would miss). A per-environment run that changes its slice evicts BOTH its
97
+ env key AND the system rollup key (the slice feeds the worst-wins rollup), so a
98
+ simultaneous slice swap - one env recovering as another fails, which the
99
+ rollup's own fingerprint is blind to - still refreshes the rollup. Sibling
100
+ environment keys stay warm.
101
+
102
+ Cross-pod coherence comes from the SHARED cache backend, not from an application
103
+ broadcast: with a distributed provider (Redis) an eviction is a `delete` every
104
+ pod sees immediately. On the default in-memory backend the cache is per-pod and
105
+ therefore single-instance-only (the Infrastructure Cache UI now warns about
106
+ this). The cached value is a derivation of the shared `health_check_runs` tables,
107
+ so a miss recomputes the same answer on every pod; the 15s TTL is only a
108
+ natural-refresh safety net.
109
+
110
+ Enforced by design, not convention:
111
+
112
+ - Every status-mutating writer invalidates through the facade: the run executor,
113
+ the router config/assignment/satellite handlers, the system/satellite lifecycle
114
+ hooks, AND the GitOps apply path (create/update/delete/associate/disassociate),
115
+ which writes configs directly on the service rather than through the router and
116
+ would otherwise have stranded a stale status until the TTL.
117
+ - A `checkstack/no-direct-system-status-read` lint rule (error) forbids raw
118
+ `service.getSystemHealthStatus(...)` reads anywhere except the cache facade and
119
+ the executor / entity-compute paths that must read live to detect a transition.
120
+ - A `checkstack/no-direct-health-run-insert` lint rule (error) forbids raw
121
+ `insert(healthCheckRuns)` outside the executor / service run writers.
122
+
123
+ The executor's per-run change-gate reads its pre-run baseline INSIDE the
124
+ per-(system, environment) advisory-lock critical section (not before the probe),
125
+ so a concurrent same-slice run cannot commit between the baseline read and the
126
+ insert and cause the gate to miss a real transition.
127
+
128
+ Behavior is unchanged for readers (same values, strictly fresher than the prior
129
+ 15s-stale-on-quiet-systems behavior). The `getSystemHealthStatus` /
130
+ `getBulkSystemHealthStatus` / `getBulkSystemHealthMatrix` RPC contracts are
131
+ untouched, so cross-plugin callers (dependency, SLO, status-page) need no change.
132
+
133
+ - bd41130: fix(status-page): scope email subscriptions to published environments and author-selected systems
134
+
135
+ Two correctness fixes to status-page email subscriptions:
136
+
137
+ - **Health notifications now respect the page's published environments.** A
138
+ per-environment health transition carries the environment it happened in
139
+ (`originEnvironmentId`, threaded through `notifyForSubscription` ->
140
+ `NotificationAudienceEvent` -> the status-page fan-out). A page that publishes
141
+ a specific environment set is now skipped for a change in an environment it
142
+ does not publish - so a `development` failure never emails a prod-only page's
143
+ subscribers, even for a system that is also shown in prod. Pages publishing all
144
+ environments, and env-less sources (incident, maintenance, whole-system health
145
+ rollup), are unaffected.
146
+ - **Notifications are scoped per category to the widgets the author placed.** The
147
+ send-time fan-out now surfaces a notification only through widgets of its own
148
+ category: a health status change reaches a page only through a HEALTH widget
149
+ (`banner` / `systemHealth` / `groupStatus` / `uptime`, which now implement
150
+ `resolveScopedSystems` and declare `subscriptionCategory: "health"`), an
151
+ incident only through an incident widget, and so on. A page that lists a
152
+ system's incidents but never its health no longer emails health subscribers
153
+ about it, and a health-only page now correctly surfaces its systems for
154
+ subscription. Health widgets also participate in the public subscribe picker.
155
+
156
+ BREAKING CHANGE: on a page publishing a specific environment set, health
157
+ subscribers now only receive changes that occurred in a published environment
158
+ (previously any environment of a surfaced system triggered a notification), and a
159
+ notification is surfaced only by a widget of its own category (previously any
160
+ scoping widget on the page could surface any category). Legacy subscribers (NULL
161
+ categories) and all-environment pages are unchanged; no data migration is needed.
162
+
163
+ ### Patch Changes
164
+
165
+ - Updated dependencies [bd41130]
166
+ - Updated dependencies [bd41130]
167
+ - Updated dependencies [bd41130]
168
+ - Updated dependencies [bd41130]
169
+ - Updated dependencies [bd41130]
170
+ - Updated dependencies [bd41130]
171
+ - Updated dependencies [bd41130]
172
+ - Updated dependencies [bd41130]
173
+ - @checkstack/backend-api@0.32.0
174
+ - @checkstack/cache-utils@0.3.0
175
+ - @checkstack/catalog-backend@1.8.0
176
+ - @checkstack/ai-backend@0.10.11
177
+ - @checkstack/incident-backend@1.13.0
178
+ - @checkstack/notification-common@1.7.0
179
+ - @checkstack/status-page-backend@0.6.0
180
+ - @checkstack/automation-backend@0.11.2
181
+ - @checkstack/command-backend@0.2.23
182
+ - @checkstack/gitops-backend@0.5.23
183
+ - @checkstack/satellite-backend@0.8.5
184
+ - @checkstack/script-packages-backend@0.4.2
185
+ - @checkstack/secrets-backend@0.3.5
186
+ - @checkstack/catalog-common@2.7.1
187
+ - @checkstack/sdk@0.128.1
188
+ - @checkstack/healthcheck-common@1.16.1
189
+ - @checkstack/incident-common@1.10.1
190
+ - @checkstack/maintenance-common@1.10.1
191
+ - @checkstack/status-page-common@0.6.1
192
+
3
193
  ## 1.19.0
4
194
 
5
195
  ### Minor Changes
@@ -0,0 +1,8 @@
1
+ -- NOTE: plain (non-CONCURRENT) CREATE INDEX takes a SHARE lock that blocks
2
+ -- writes to health_check_runs while each index builds. The migrator wraps every
3
+ -- migration in one transaction, so CREATE INDEX CONCURRENTLY (which cannot run
4
+ -- inside a transaction) is not possible here. On a very large table you can
5
+ -- pre-build these CONCURRENTLY by hand (outside the migrator, same names) before
6
+ -- deploying; the IF NOT EXISTS below then makes this migration a no-op.
7
+ CREATE INDEX IF NOT EXISTS "health_check_runs_check_recent_idx" ON "health_check_runs" USING btree ("system_id","configuration_id","timestamp");--> statement-breakpoint
8
+ CREATE INDEX IF NOT EXISTS "health_check_runs_slice_recent_idx" ON "health_check_runs" USING btree ("system_id","configuration_id","environment_id","timestamp");
@@ -0,0 +1,2 @@
1
+ CREATE INDEX "health_check_aggregates_system_bucket_idx" ON "health_check_aggregates" USING btree ("system_id","bucket_size","bucket_start");--> statement-breakpoint
2
+ CREATE INDEX "system_health_checks_config_enabled_idx" ON "system_health_checks" USING btree ("configuration_id","enabled");