feat: reactive proxy recovery reaps a confirmed-dead upstream (#1018 slice A) - #1019
Merged
Conversation
…slice A) Between an external `docker rm -f` and the next ~10s scaler tick, a request routed to the vanished replica returned `502 upstream error` and the sticky cookie kept the visitor pinned there. This adds reactive recovery at the two transport-failure points in the proxy (HTTP forward error and WS handshake failure): - reap_if_confirmed_dead() asks the backend (ReplicaLiveness) whether the selected replica is authoritatively gone. Only an authoritative `Missing` reaps — through the same scaler::cleanup_replica_state the periodic reconcile uses (registry + sessions + metrics + in-flight). A `Stopped` container may be mid-`docker restart`, owned by the scaler grace window (#1016), so it is NOT reactively pruned; an app 5xx is an Ok response and never reaches this arm; a transient daemon error or a multi-host `Unknown` never reaps. - On a reap, a coalesced replacement spawn is kicked, and a GET document navigation recovers on the familiar cold-start splash instead of a raw 502. Non-idempotent methods are never blindly replayed — the reap lets the next call recover. The stale sticky binding is cleared server-side immediately, so recovery no longer waits out the scaler tick. Out of scope (slice B, #1018): the Docker events listener for near-instant recovery. The reactive path here already collapses the recovery window without it; the periodic reconcile remains the fallback. Tests: unit coverage for every classification (Missing reaps + drops sessions; Stopped/Unknown/Running/backend-error all spare the replica), plus a docker-it smoke (new `docker-it` feature on ruscker-admin) that spawns a real container, removes it externally, and asserts the production glue reaps it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
Review (adversarial pass) — MERGE-OKFocused pass on the hot-path risk areas (reactive recovery runs on the busiest handler in the product):
Real smoke: new No blockers. |
milkway
marked this pull request as ready for review
July 20, 2026 18:08
3 tasks
milkway
added a commit
that referenced
this pull request
Jul 20, 2026
…slice B) (#1020) The reactive proxy recovery (#1019) collapses the recovery window for a request that hits a dead upstream, but a spec with no incoming traffic (or a min-replicas warm pool) still waited up to a full ~10s scaler tick to notice an external `docker rm -f` / `docker restart`. This subscribes to the Docker event stream so the runtime reconciles within ~1s. - core: `ContainerEvent`/`ContainerEventKind` + `ContainerEventStream` and a `ContainerBackend::container_events()` trait method. Default returns an empty stream — events are a latency optimization, never the source of truth, so a backend without support (mocks, future backends) just relies on the periodic reconcile. - ruscker-docker: LocalDockerBackend streams bollard events filtered server-side to our containers' lifecycle actions (type=container, label=ruscker.replica_id, event in start/die/stop/kill/destroy/oom); the stream ends on a transport error so the consumer reconnects. MultiHostDockerBackend merges per-host streams; a host that can't open one is skipped (periodic reconcile covers it) and never fails the whole call. - ruscker-admin: `scaler::reconcile_liveness_once` factors the #1017 reconcile pass so the events watcher and the periodic tick take the exact same authoritative, idempotent path. `spawn_event_watcher` consumes the stream, coalesces a burst (a restart is die+start; a rm is die+destroy) into one reconcile, is leader-gated like the tick, and reconnects with backoff. Wired into serve alongside the scaler; the periodic reconcile remains the fallback for anything missed during a reconnect gap. The event is only a "reconcile now" nudge — the authoritative `replica_liveness` pass decides the action — so a missed or duplicated event is always safe. Tests: unit for the action->kind map and the EventMessage->ContainerEvent mapping (non-container / unlabelled ignored); a scaler test that `reconcile_liveness_once` prunes a Missing replica (the watcher's entry point); and a docker-it real smoke that opens the stream and asserts Start on spawn + die/destroy on external removal against a live daemon. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 tasks
milkway
added a commit
that referenced
this pull request
Jul 20, 2026
Self-healing when a managed container changes outside Ruscker: authoritative + bidirectional liveness reconcile (#1017, closes #1015/#1016), reactive proxy recovery on a confirmed-dead upstream (#1019), and a Docker events watcher for near-instant reconcile (#1020, both close #1018). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
What & why
Slice A of #1018 (the follow-up to #1015/#1016). The merged #1017 made the scaler's
liveness reconcile authoritative + bidirectional, but recovery still waited for the
periodic ~10 s tick: between an external
docker rm -fand the next tick, arequest routed to the vanished replica returned
502 upstream error, and the stickycookie kept the visitor pinned to the dead upstream.
This adds reactive recovery at the two transport-failure points in the proxy —
the HTTP forward error and the WS handshake failure (which connects the upstream
before the 101, so it's the same signal).
How
reap_if_confirmed_dead(state, replica)asks the backend(
ContainerBackend::replica_liveness, from fix: authoritative + bidirectional liveness reconcile for external docker changes (#1015, #1016) #1017) whether the selected replica isauthoritatively gone, and reaps it through the same
scaler::cleanup_replica_statethe periodic reconcile uses (registry + sessions +metrics-cache + in-flight gauge) — so the reactive and periodic paths can't drift.
Missingreaps. AStoppedcontainer may be mid-docker restart,which is owned by the scaler's grace window (External docker restart can orphan a running container because runtime reconciliation is one-way #1016) — the reactive path must not
prune it and risk a duplicate replacement.
Okupstream response and never reaches this arm.Unknown(owning hostunreachable) returns
false— a live replica behind a hiccup is never pruned.can still scale), and a GET document navigation recovers on the familiar
cold-start splash (which polls readiness and opens the app once the replacement is
up) instead of a raw 502. Subresources/XHR get the 502 and the browser retries
onto the fresh replica. Non-idempotent methods are never blindly replayed —
the reap lets the next call recover.
The stale sticky binding is cleared server-side immediately, so recovery no longer
waits out the scaler tick.
Acceptance criteria (slice A)
docker rm -f'd recovers without waiting forthe tick and without a persistent 502 (bounced to the Starting… splash).
on the next pick.
after the reap.
Unknown(host unreachable) does not prune.Out of scope (slice B, tracked in #1018)
The Docker
die/destroy/startevents listener for near-instant recovery. Thereactive path here already collapses the recovery window; the periodic reconcile
remains the fallback either way.
Tests
Missingreaps + drops the sticky session;Stopped,Unknown,Running, and a whole-call backend error all spare thereplica and its session.
docker-itfeature onruscker-admin): spawns a realcontainer, seeds the registry + a session, removes it externally
(
ContainerBackend::remove_container), and asserts the production glue(
reap_if_confirmed_dead→replica_liveness→cleanup_replica_state) reaps it.Run:
cargo test -p ruscker-admin --features docker-it.Gate
cargo test· clippy (clean on touched files) ·./scripts/i18n-check.sh·cargo test -p ruscker-admin --features docker-it(real Docker 29.6.1) — all green.🤖 Generated with Claude Code