ci: run the graph canary after both deploys settle - #601
Merged
Conversation
The log-to-graph canary was the last step of deploy-log-node, which depends only on the `changes` job. deploy-graph-node depends on `changes` too, so the two ran in parallel with no ordering between them. A commit touching a shared path (packages/mcp/*, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, tsconfig.base.json, .dockerignore) sets both service flags, and the canary could then poll graph-node while its machine was restarting into the new release. graph-node replays its whole record archive on start, so that window serves 502s and the canary reports a failure on a system that is fine. This did not cause the 2026-07-29 flap, which was a real graph-node OOM fixed in #599. It is a separate latent source of false failures in the same check. The canary now also runs when only graph-node deployed. It exercises log submit through graph index, so a graph-node-only release is exactly the change it should gate. Previously it ran on log-node releases only, which is why the trace regression reached production without the canary seeing it. deploy-log-node loses its pnpm install and workspace build: they existed only for the canary, and check-log-smoke.mjs imports node:crypto alone.
The 2026-07-29 outage gave no warning. graph-node keeps every record and derived index in memory, so the live heap grows linearly with the log; it reached the V8 ceiling at ~112k records and the process began aborting on allocation, which Fly served as 502s. The service had a disk watchdog, but disk was never the constraint: the archive was 68MB on a 1GB volume while the heap sat at 91%. Adds the matching watchdog for the resource that actually fails. It mirrors the disk watchdog's shape, logs only on threshold transitions so a steady state does not train operators to filter it out, and carries record_count so headroom divided by records/day gives runway directly. Thresholds are 70% and 85%, below the disk watchdog's 80/95, because heap has no graceful degradation: crossing the V8 limit aborts the process, per-request allocation sits on top of the live set, and recovery costs a full archive replay that serves errors throughout. One unconditional line per boot leaves a datapoint even while healthy. The threshold logic sits in its own module so it is unit-tested. An alerting path that never fires reads as an all-clear, which is worse than no alert. Files P059 for the structural question #599 did not answer. That PR removed the trigger and raised the ceiling; it did not change the shape. At filing: 112,781 records, 439MB RSS, 780MB ceiling, ingest around 4k to 7k records/day at ~3.4KB of heap each. That is weeks of runway, not quarters. persistence.ts already named ~10^5 records as the boundary and the log passed it in July 2026. The watchdog's warn threshold is the intended trigger for acting on it.
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Both follow-ups deferred from #599, which fixed the graph-node OOM itself.
1. The canary ran in parallel with the deploy it was gating
deploy-log-node(which owned the canary step) anddeploy-graph-nodeboth depend only onchanges, so they ran concurrently with no ordering. A commit touching a shared path —packages/mcp/*,package.json,pnpm-lock.yaml,pnpm-workspace.yaml,tsconfig.base.json,.dockerignore— sets both service flags, and the canary could then poll graph-node while its machine was restarting into the new release. graph-node replays its whole record archive on start, so that window serves 502s and the canary fails on a system that is fine.To be clear about attribution: this did not cause the 2026-07-29 flap. Those runs were
apps/dashboard/*changes, which setlog_nodeonly, so graph-node never deployed. It is a separate latent source of false failures in the same check.The canary now lives in its own
graph-canaryjob thatneedsboth deploys.It also now runs when only graph-node deployed. The canary exercises log submit through graph index, so a graph-node-only release is exactly the change it should gate. Previously it ran on log-node releases only — which is precisely why the trace regression reached production without the canary ever seeing it.
deploy-log-nodeloses its pnpm install and workspace build; they existed only for the canary, andcheck-log-smoke.mjsimportsnode:cryptoalone.The
if:uses!cancelled()with explicit guards, since a skippedneedsentry otherwise cascades:No branch-protection impact: the 8 required checks are all from
ci.ymlandsecurity-scan.yml;deploy-servicescontributes none.2. Headroom is finite, and nothing was watching it
#599 removed the trigger and raised the ceiling. It did not change the shape that produced the outage.
The pointed detail: graph-node had a disk watchdog, warning at 80% and erroring at 95%. Disk was never the constraint — the archive was 68MB on a 1GB volume — while the heap sat at 91% and killed the process. The service was watching the wrong resource.
Adds the matching heap watchdog, mirroring the disk one: threshold-transition logging only,
unref()'d interval, plus one unconditional line per boot so every restart leaves a datapoint.record_countis in the message on purpose — headroom divided by records/day is the runway.Thresholds are 70% / 85%, below disk's 80/95, because heap has no graceful degradation: crossing the V8 limit aborts the process immediately, per-request allocation sits on top of the live set so the usable ceiling is under 100%, and recovery costs a full archive replay that serves errors throughout.
The threshold logic is its own module (
src/heap-watchdog.ts) so it is unit-tested. An alerting path that never fires reads as an all-clear, which is worse than no alert.Filed as P059 rather than fixed here, because the structural choice is a real decision: disk-backed store, bounded working set, sharding by
context_id, or continuing to buy machine size. Measured at filing: 112,781 records, 439MB RSS, 780MB ceiling, ingest ~4k–7k records/day at ~3.4KB heap each — roughly 15–22MB/day, so weeks of runway, not quarters.persistence.tsalready named ~10^5 records as the boundary; the log passed it in July 2026. The watchdog's warn threshold is P059's intended trigger.P059 also records the two properties any successor must preserve: §3.2.4 edge derivation stays deterministic over the full record set, and the §1.9 revocation registry stays a global scan. A bounded working set is the option most in tension with both.
Verification
services/graph-node: 124/124 tests pass (115 before, +9 heap-watchdog).atrib-graph: heap 29MB/168MB (17.1%) at 0 records.if:truth table walked case by case (table above).pnpm doc-sync: 14 checks pass. It caught two bare spec refs in the P059 text, now inline-linked.persistence.tsandfly.tomlboth point at P059 and the watchdog.