feat: expose graph-node stats and health-check it - #606
Merged
Conversation
creatornader
force-pushed
the
fix/graph-canary-ordering-and-headroom
branch
from
July 29, 2026 08:34
f4be308 to
06447a6
Compare
graph-node had no health, stats, or metrics endpoint, and no Fly health check. So when the process entered a crash loop on 2026-07-29, Fly had no liveness signal at all: it kept routing to a dead instance and returning 502s, and the only thing that noticed was a post-deploy canary in a different job. The one number that would have predicted the outage, heap headroom, existed solely in Fly logs. Adds GET /v1/stats: record_count, context_count, heap used/limit/percent, uptime, and the last archive replay. Shape mirrors log-node's /v1/stats, which is likewise operational rather than normative, so no spec section: §3.4 is the graph query interface and this must never start returning graph facts. Kept O(1) and tested for it. The Fly check polls it on an interval, so if it ever walked the store it would become a periodic full scan of the thing it exists to protect, which is close to the original bug. Adds the Fly health check pointing at it. timeout is 10s rather than something near the ~0.15s a healthy response takes, because the failure worth catching is a wedged process, not a slow one: during the outage requests took 17-25s while the process was still listening and spending 65-75% of wall-clock in GC. grace_period is 60s because Fly clamps it there. `flyctl config validate` rejects more with "this will be lowered to 1 minute", so a larger value reads as protection that does not exist. That clamp bounds the design, not just the file: main.ts replays the whole archive before binding the port, and at the measured ~20.6k records/sec that gives about 1.24M records of runway. Machine sizing hits that before it hits a memory wall, so raising [[vm]] memory stops working past roughly the 4GB tier for startup reasons rather than heap reasons. Recorded in P059. Adds check-graph-smoke.mjs to the graph-canary job, before the canary because it has no dependencies and fails in seconds with a specific message where the canary would spend its full 90s deadline. It fails on contract violations, on a slow response, and on heap past 95%, that last one high enough not to block the deploy that fixes a memory problem. Runway is reported in MB always and in records only above 10k, since below that Node's fixed baseline dominates and per-record attribution reports nonsense. Kept separate from check-log-smoke.mjs on purpose: folding graph checks into it would re-couple a log-node deploy to graph-node health, which is the coupling the canary was moved out of deploy-log-node to remove.
creatornader
force-pushed
the
fix/graph-canary-ordering-and-headroom
branch
from
July 29, 2026 08:42
06447a6 to
118c63d
Compare
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.
Closes the observability gap the 2026-07-29 outage exposed. Follows #599 (the fix) and #601 (canary ordering + heap watchdog).
The gap
graph-node had no health, stats, or metrics endpoint, and no Fly health check —
[[http_service.checks]]was absent andflyctl statusreturned an empty CHECKS column. During the crash loop Fly had no liveness signal whatsoever: it kept routing to a dead instance and returning 502s. The only thing that noticed was a post-deploy canary in a different job.Meanwhile the one number that would have predicted the outage — heap headroom — lived only in Fly logs.
1.
GET /v1/stats{"service":"atrib-graph-node","record_count":120000,"context_count":900, "heap":{"used_mb":360,"limit_mb":828,"used_pct":43.5}, "uptime_s":23,"replay":{"ms":5462,"records":120000}}Shape mirrors log-node's
/v1/stats. Deliberately not in the spec — I checked, and/v1/statsappears nowhere inatrib-spec.md, so log-node's is operational rather than normative too. §3.4 is the graph query interface; this must never start returning graph facts (§3.6 fact/policy separation).replayis omitted, not null, when no archive is configured, so callers can distinguish "no archive" from "archive replayed zero records".Kept O(1) and there's a test asserting it does not scan the store. That's a real constraint, not a nicety: the Fly check polls it on an interval, so if it ever walked the store it would become a periodic full scan of the thing it exists to protect — close to the original bug.
2. Fly health check
timeout = 10s, far above the ~0.15s a healthy response takes, because the failure worth catching is a wedged process rather than a slow one. During the outage requests took 17–25s while the process was still listening and spending 65–75% of wall-clock in GC.grace_period = 60sbecause Fly clamps it there.flyctl config validateon my first attempt returned:I had written 120s. It would have silently become 60s — protection I thought I had and didn't.
That clamp bounds the design, not just this file. main.ts replays the whole archive before binding the port, so nothing listens until replay finishes. At the measured ~20.6k records/sec, 60s of grace covers ~1.24M records, and machine sizing hits that before it hits a memory wall:
So "just keep raising memory" expires somewhere past the 4GB tier for startup reasons, not heap reasons. Added to P059.
3.
check-graph-smoke.mjsin thegraph-canaryjobRuns before the canary: no dependencies, and it fails in seconds with a specific message where the canary would burn its full 90s deadline discovering the same thing less clearly.
Kept separate from
check-log-smoke.mjson purpose — folding graph checks into it would re-couple a log-node deploy to graph-node's health, the exact coupling #601 removed.Fails on contract violations, on a slow response, and on heap ≥95%. That threshold is high enough not to block the very deploy that fixes a memory problem, and
GRAPH_SMOKE_FAIL_HEAP_PCToverrides it for remediation.Verification
Every path exercised against a real graph-node at production scale (120k records under a 780MB ceiling — 360MB/828MB @ 43.5%, 3.07 KB/record, versus production's 345/780 @ 44.2%, 3.13 KB/record):
record_count: 0(archive did not replay)Exit codes captured directly, not through a pipe —
tailmasked them on my first pass.One thing I fixed mid-verification: the first version reported
per record 59.39 KBon a 500-record store, because dividing total heap by record count attributes Node's ~25MB baseline to records. Runway is now reported in MB always, and in records only above 10k where the baseline is noise.services/graph-node: 132/132 tests (124 before, +8 for/v1/stats).flyctl config validate: valid, no warnings.pnpm doc-sync: 14 checks pass.Not done here
Only graph-node gets a health check. log-node has
/v1/statsand could take one, but its startup profile is different and I haven't measured it, so setting a grace period there would be guessing. directory-node and archive-node have no stats endpoint at all. Worth a follow-up; I didn't want to ship configuration I couldn't justify with a measurement.