QAO-358: gate calypso.live proxy on container /health readiness#250
Open
lucatume wants to merge 16 commits into
Open
QAO-358: gate calypso.live proxy on container /health readiness#250lucatume wants to merge 16 commits into
lucatume wants to merge 16 commits into
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restart recovery: refreshContainers now kicks off a health probe for any running dserve-managed container not yet marked healthy, so containers surviving a dserve restart become proxyable again. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New isContainerReadyToServe selector wraps isContainerHealthy and short-circuits to isContainerRunning when the healthGateEnabled config is false. Controlled via the DSERVE_HEALTH_GATE_ENABLED env var (default enabled; set to "false" to disable). Requires a dserve restart to take effect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ensureHealthProbeFor is now a short orchestrator (~15 lines) that
delegates the pollUntilHealthy call to firePollLoop. Both it and
ensureHealthProbesForRunningContainers now return Promise<void>,
letting tests await the full chain without setImmediate bookkeeping.
Production call sites continue to ignore the returned promise; their
fire-and-forget semantics are unchanged.
Uses Promise.allSettled (not Promise.all) so a future change that
breaks the "probe promise never rejects" invariant won't orphan
sibling probes via fail-fast. Terminal .catch(() => {}) on the chain
is a belt-and-suspenders guard against a future edit that
reintroduces a throw in the handlers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pins the "refreshContainers must not await probes" invariant so a future maintainer who adds `await` in front of ensureHealthProbesForRunningContainers inside refreshContainers would immediately break this test. Without the guard, a slow /health endpoint would stretch the 5s refresh-loop cadence to 30s (the probe ceiling) and starve other refreshes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Counters (with the automatic dserve. prefix from src/stats.ts):
- health.probe.started — every probe fire
- health.probe.healthy — pollUntilHealthy resolved healthy
- health.probe.fail_open — ceiling-exceeded (marked healthy anyway)
- health.probe.aborted — container disappeared mid-probe
- health.probe.error — pollUntilHealthy rejected unexpectedly
Operator usage: alert on
rate(dserve.health.probe.fail_open[5m])
/ rate(dserve.health.probe.started[5m]) > 0.1
to catch silent /health regressions across the fleet. Also makes
DSERVE_HEALTH_GATE_ENABLED=false trivially observable — health.probe.started
goes to zero when the gate is disabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
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.
Summary
Makes dserve wait until a started container's
/healthendpoint returns 200 OK before proxying user requests to it. While the container is running-but-not-healthy, HTML navigations see the existing auto-refresh loading page; non-HTML requests see503 Retry-After: 2.Addresses QAO-358: users clicking the PR comment link in
Automattic/wp-calypsoPRs were experiencing timeouts and wrong-site redirects (being bounced to jetpack.me etc.) because the link was served before the wp-calypso Node server had finished booting. QAO-281 measured this as a 4–16s window. The/healthendpoint itself was added in Automattic/wp-calypso#109316, but that only fixed E2E test infrastructure — this PR closes the loop for the browser-facing PR-link UX.What changed
src/health.tsexposesprobeContainerHealth(single probe) andpollUntilHealthy(500ms loop, 30s ceiling). A per-containerhealthyContainers: Set<string>inAPIStatetracks probe results.decideRouteActionintosrc/route-actions.ts(pure function, unit-tested). The*handler and/statushandler both gate onisContainerReadyToServeinstead ofisContainerRunning./statusnow distinguishesReady(probed healthy) fromStarting(running but not yet probed).startContainer's success branch kicks off a probe viaensureHealthProbeFor.refreshContainers(runs every 5s) callsensureHealthProbesForRunningContainers, which probes any pre-existing dserve container not yet marked healthy — dedupe via a newprobingContainersset. This is the path that catches containers surviving a dserve process restart; without it they would be stuck on the loading page forever.DSERVE_HEALTH_GATE_ENABLED=falserestores pre-QAO-358 behavior (proxy to any running container). Requires a dserve restart to take effect.WARNlog line is emitted. This restores today's behavior if/healthitself is broken — dserve should never wedge a working container just because its health endpoint regressed.dserve.health.probe.{started,healthy,fail_open,aborted,error}. Alarm suggestion:rate(dserve.health.probe.fail_open[5m]) / rate(dserve.health.probe.started[5m]) > 0.1catches silent/healthregressions across the fleet.Design notes
Id, not commit hash, because multiple envs share a commit hash but have distinct containers.503 Retry-After: 2rather than the loading HTML, so subresource fetches fail cleanly instead of half-rendering.refreshContainerscallsensureHealthProbesForRunningContainers()fire-and-forget (does NOT await it). Awaiting would stretch the 5s refresh cadence to 30s under slow probes. A regression test (test/refresh-fire-and-forget.test.ts) pins this invariant.ensureHealthProbeForreturnsPromise<void>for test determinism. Production callers ignore the returned promise; testsawaitit to drain the chain withoutsetImmediatebookkeeping. Chain ends with a terminal.catch(() => {})as belt-and-suspenders against future drift.ensureHealthProbesForRunningContainersusesPromise.allSettled(notPromise.all) to avoid fail-fast orphaning sibling probes.startContainer's post-success lookup now passesenvtogetRunningContainerForHash— arguably a latent bug fix (previously could return the wrong env's container when multiple envs shared a commit).Out of scope
Automattic/wp-calypso. The Linear ticket proposes making the workflow wait for/healthor post-then-update. That's infeasible here because dserve builds are lazy — the container doesn't exist until someone clicks the link. The fix delivered is equivalent: the link can be posted immediately, and dserve won't proxy until the container is actually ready.src/image-runner.ts(the/api/images/*runner): separate code path, different UX expectations.Test plan
Automated (56 tests, 11 suites)
yarn test— 56 tests passing, including dedicated suites for probe helpers, state selectors, route decisions, restart recovery, and the fire-and-forget invariant.yarn run build-tsclean.grep -rn "health.probe" src/shows exactly the 5 expected counters.Local e2e (all scenarios passed)
Tested locally using mock Docker containers and a non-default port (38400), following the README's "e2e test locally" pattern. Port 3000 was temporarily patched to read
process.env.DSERVE_PORT || 3000for the session.Mock container setup: Built a Python-based Docker image tagged with dserve's expected prefix (
dserve-wpcalypso:<hex-sha>). The mock server returns 503 from/healthfor the first 15 seconds after process start, then 200 — simulating a real Calypso container's startup delay. All other paths return200 <html>mock app</html>. Three identical images were built with distinct hex SHA tags for isolation between scenarios. Containers were triggered via?hash=<sha>againstcalypso.localhost:38400.Scenario A — Starting → Ready transition: ✅
After triggering the container,
/statusreturnedStartingat the ~8s mark (no "Container reported healthy" in logs yet). After ~18s total,/statusreturnedReadyand the log showed"msg":"Container reported healthy"— matching the mock's 15s/healthdelay.Scenario B — Non-HTML requests get 503 during unhealthy window: ✅
During the unhealthy window,
curl -H 'Accept: */*'returned HTTP503,Retry-After: 2, bodyContainer not ready. After the container became healthy, the same curl returned HTTP200with mock HTML.Scenario C — Restart recovery: ✅
Killed dserve while containers from A and B were still running, then restarted. Within ~10s (one refresh cycle), logs showed
"msg":"Container reported healthy"for both existing containers, and/statusreturnedReady— without any user request triggeringstartContainer. ConfirmsensureHealthProbesForRunningContainersre-discovers and re-probes containers surviving a dserve restart.Scenario D — Kill-switch
DSERVE_HEALTH_GATE_ENABLED=false: ✅Started dserve with
DSERVE_HEALTH_GATE_ENABLED=false. Triggered a fresh container.curl -H 'Accept: */*'returned HTTP200with mock HTML immediately, even though/healthwould still return 503 at that time./statusreturnedReady. The background health probe still ran (one "Container reported healthy" entry appeared later), which is expected — the kill switch disables the gating policy inisContainerReadyToServe, not the probe itself.Post-merge monitoring (production =
calypso.live, auto-deployed from master every ~15 minutes)https://calypso.live/logfor the first 30 minutes after deploy. Watch forContainer /health did not return 200 before ceiling; failing openwarnings — a handful is normal (slow boots), a flood means something is wrong.Rollback
Two options, pick based on urgency:
DSERVE_HEALTH_GATE_ENABLED=falsein dserve's environment and restart the process. Proxy behavior returns to pre-QAO-358 immediately. No code change needed.git revertthe merge commit and merge — auto-deploys within the next 15-minute poll.No DB migrations, no data format changes, no cross-service coordination required.
🤖 Generated with Claude Code