P2: Operations runbook — SLOs, capacity, incidents, failover (#180) - #197
Conversation
|
Great capstone for the epic — the symptom→cause→action table and the honest "validate for your topology" failover caveat are exactly what on-call wants. One correctness heads-up before merge: almost everything the runbook tells operators to observe or tune ships in sibling PRs that aren't on
Docs PRs tend to merge ahead of feature PRs, and if this one lands first the runbook misdirects on-call mid-incident (curling
One smaller note on Scaling: "Add read replicas and point Nice work overall — just want the doc to be safe to follow the day it merges. |
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @SanabriaRusso — fixed in The "describes unshipped behavior" problem. Agreed this was the important one: a runbook that misdirects on-call mid-incident is worse than no runbook. Of your two options I took a third — scoping by version rather than by in-flight PR numbers:
Reasoning: the merge plan already lands this doc after #182/#185/#187/#188/#191, so a PR-status banner would be stale on arrival and need deleting. The version framing stays true afterwards and is arguably the more useful warning anyway — the published image today is The Scaling claim — you were right, and I can now say why. I checked the driver rather than take it on faith: Worth flagging that this same claim was already on One addition from the #185 fix: I split the |
|
Verdict: MERGEABLE ✅ Docs-only, two new/edited files, zero runtime impact — nothing here can touch What I checked
Non-blocking nits
Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api. |
SanabriaRusso
left a comment
There was a problem hiding this comment.
Approving on the basis of the second-pass review comment above: no mid-to-high severity security, compatibility, or degradation issue found, and the downstream contract with mina-explorer / mina-explorer-api holds — GraphQL validation error text reaches errors[].message verbatim, the browser SPA's cross-origin access is preserved, and the real consumer query shapes (including the 2000-block analytics query and the 500-row page crawl) still pass.
Two things this approval does not mean:
- It does not close the non-blocking items in the review comment. Several are worth fixing before or shortly after merge; they are written up there with patches.
- It does not by itself mean the branch is ready to merge.
mainrequires branches to be up to date, so this needs an update-branch (or a rebase, if the branch is conflicting) first, and a few PRs in this series have cross-PR ordering constraints called out in their review comments.
Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SanabriaRusso
left a comment
There was a problem hiding this comment.
Round 1's blocker is properly fixed and every name in the runbook is real. Approving.
Verified fixed
- Rollback wording (
docs/runbook.md:106-109) now names the version floor concretely: "Roll back within the 1.0.x line… Do not roll back to a0.0.ximage while readiness targets/readiness: that path 404s, no pod goes Ready, and the Service loses all endpoints." That is exactly the failure mode, stated so an on-call engineer can act on it. The scoping banner at:9-15reinforces it. - Every metric, log field, and endpoint exists. Checked against the sibling diffs rather than assumed:
http_requests_total,http_request_duration_seconds,http_requests_in_flightare the literalname:values registered in #191 and asserted by its tests;requestIdanddurationMsare real fields emitted by #190'suseRequestLogging;/readinessis #187;PG_STATEMENT_TIMEOUT,PG_MAX_CONNECTIONS,RATE_LIMIT_MAX,BLOCK_RANGE_SIZEand the fail-fast config validation are all real. Nothing invented — which is the bar for a runbook. - Worth calling out: the
http_requests_in_flightsaturation alert at:58is now trustworthy. Round 1 found that gauge drifted permanently negative; #191 fixed it and pinned it with a test asserting the exact exposition linehttp_requests_in_flight 0after a healthcheck + preflight + POST sequence. Alerting on it would have been actively misleading a round ago. - SLOs are consistent with the README benchmark (targets p99 < 250 ms / p50 < 50 ms against a measured p99 of 39 ms) and with #185:
:63-65correctly states the limiter is per-instance, so the effective global ceiling ≈ replicas ×RATE_LIMIT_MAX. No capacity claim exceeds the shipped limit. - TRUST_PROXY hop count at
:95(GCP external ALB =2) matches #186docs/security.md:98and #185's implementation (forwarded[forwarded.length - trustProxy]). All three agree.
Non-blocking nits
-
:95— wrong diagnosis for "Many 429s." The row blames "TRUST_PROXYunset or too low." Under #185 as shipped, unset meanstrustProxyConfigured === falseanduseRateLimit()returns a no-op plugin — rate limiting is disabled, so unset yields zero 429s, not many. Also the warning fires on the first proxied request, not at startup. Suggested replacement for the cause/action cells:Cause:
TRUST_PROXYset too low behind a gateway, so every client shares the proxy's bucket
Action: setTRUST_PROXYto the exact hop count (a GCP external ALB needs2); the app logs a warning on the first proxied request whenTRUST_PROXY=0but traffic carriesX-Forwarded-For. Note that leavingTRUST_PROXYunset disables rate limiting entirely — that presents as no 429s, not many.I have blocked #186 on the same underlying misconception (
docs/security.md:57statesTRUST_PROXYdefaults to0, and:75lists rate limiting as Defaulton; neither is true of what #185 ships). Fixing all three together is the efficient move — #196deploy/README.md:46is the third. -
:103contradicts the manifest it links to. It saysterminationGracePeriodSeconds: 30; #196'sdeploy/kubernetes.yaml:131sets 45, with a 15 spreStopsleep in front andSHUTDOWN_TIMEOUT_MS: 10000. Suggested:- Rolling update: a 15 s
preStopsleep lets endpoint removal propagate, then the app drains in-flight requests withinSHUTDOWN_TIMEOUT_MS(10 s in the reference manifest) insideterminationGracePeriodSeconds: 45.
- Rolling update: a 15 s
-
State
RATE_LIMIT_MAX's default in the capacity section.:96says "adjustRATE_LIMIT_MAX" without saying from what. The shipped default is 600 per 60 s per client IP. Worth noting alongside it that a known consumer backfilling at ~4 rps already sits at ~40% of that budget from a single IP — that is exactly the sizing decision this section should be driving. -
Worth adding to the incident section, since it is the sharpest downstream failure mode and it is not obvious:
mina-explorer-apidoes not retry 4xx, and it maps a 429 to a circuit-breaker failure (app/upstream/graphql.py). A sustained 429 burst therefore drives that consumer's per-endpoint breaker OPEN, after which it short-circuits every call with zero I/O for the cooldown. So over-tight rate limiting on this service presents downstream as a step function — the archive tier goes dark — rather than as gradual throttling. One line under "Many 429s" would save someone a confusing incident. -
Merge ordering: this PR's README hunk carries the old "point
PG_CONNat multiple read replicas — the server fans queries across them" sentence as unchanged context, while #186, #196 and #198 each rewrite that same paragraph to the corrected failover-not-fan-out wording. Whichever lands second will conflict there; keep the corrected text.
Downstream: none, docs-only. :87 correctly encodes the consumers' 20 s client timeout as the practical latency ceiling, which is the right operational framing now that #182 lowered statement_timeout to 15 s — queries in the 15–20 s band flip from slow-but-successful to a hard error, and this is the document where an operator would look for that.
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was one benchmark data point but no runbook, SLOs, or documented failure-mode response. Add docs/runbook.md: - Starting SLOs (availability, p50/p99 latency, error rate). - What to watch (the /metrics RED signals, readiness, structured logs) and suggested alerts. - Scaling & capacity guidance — stateless horizontal scaling, Postgres as the real ceiling, pool-vs-max_connections math. - Multi-host Postgres failover semantics and recovery expectations. - A common-incidents table mapping symptoms to causes and actions. - Deploy/rollback notes tied to graceful shutdown and readiness gating. Linked from the README. An automated replica-failover test is noted as a follow-up (needs a multi-host DB harness). Closes #180. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
…Y row Scaling told operators to "add read replicas and point PG_CONN at them before scaling the API further", which reads as added read capacity. It isn't: postgres.js scopes hostIndex per Connection, so every pooled connection starts at host[0] and only advances on failure. Extra hosts buy redundancy, not throughput — real read scaling needs a balancer in front of Postgres. The failover section now says so plainly rather than leaving "connects to an available host" open to the throughput reading. Adds a version scope note. Nearly everything the runbook says to observe or tune ships in 1.0.0; on 0.0.x, /readiness 404s, the tuning knobs are no-ops, and SIGTERM skips the drain. A runbook that misdirects mid- incident is worse than no runbook, and the published image today is 0.0.6. Scoping by version rather than by in-flight PR numbers keeps the note true after the merge train lands. Splits the 429 incident row: after #185, mass 429s across unrelated clients most likely means TRUST_PROXY is unset behind a gateway, collapsing every client into one bucket — a different fix from a single client exceeding the limit. Addresses review feedback on #197. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What & why
Part of the production-readiness epic (#163). Closes #180.
There was one benchmark data point but no runbook, SLOs, or documented failure-mode response.
Adds
docs/runbook.md/metricsRED signals, readiness, structured logs, and suggested alert thresholds.replicas × PG_MAX_CONNECTIONSvs DBmax_connectionsmath.Linked from the README. References observability/config features delivered by the sibling PRs. An automated replica-failover test is noted as a follow-up (needs a multi-host DB harness), so the doc is the deliverable here.
Testing
Docs only.
prettier --debug-check .clean. No application code changed.🤖 Generated with Claude Code