Skip to content

feat: reactive proxy recovery reaps a confirmed-dead upstream (#1018 slice A) - #1019

Merged
milkway merged 1 commit into
mainfrom
feat/reactive-proxy-recovery-1018
Jul 20, 2026
Merged

feat: reactive proxy recovery reaps a confirmed-dead upstream (#1018 slice A)#1019
milkway merged 1 commit into
mainfrom
feat/reactive-proxy-recovery-1018

Conversation

@milkway

@milkway milkway commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

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 -f and the next tick, a
request routed to the vanished replica returned 502 upstream error, and the sticky
cookie 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 is
    authoritatively gone, and reaps it through the same
    scaler::cleanup_replica_state the periodic reconcile uses (registry + sessions +
    metrics-cache + in-flight gauge) — so the reactive and periodic paths can't drift.
  • The bar is deliberately conservative:
  • On a reap: a coalesced replacement spawn is kicked in the background (if the spec
    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)

  • A GET to a spec whose replica was docker rm -f'd recovers without waiting for
    the tick and without a persistent 502 (bounced to the Starting… splash).
  • The stale sticky is invalidated (server-side reap); a fresh binding is created
    on the next pick.
  • Non-idempotent methods are not blindly replayed; they get an explicit error
    after the reap.
  • An app 5xx or a transient daemon error does not prune the replica.
  • Multi-host Unknown (host unreachable) does not prune.

Out of scope (slice B, tracked in #1018)

The Docker die/destroy/start events listener for near-instant recovery. The
reactive path here already collapses the recovery window; the periodic reconcile
remains the fallback either way.

Tests

  • Unit (every classification): Missing reaps + drops the sticky session;
    Stopped, Unknown, Running, and a whole-call backend error all spare the
    replica and its session.
  • docker-it (new opt-in docker-it feature on ruscker-admin): spawns a real
    container, seeds the registry + a session, removes it externally
    (ContainerBackend::remove_container), and asserts the production glue
    (reap_if_confirmed_deadreplica_livenesscleanup_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

…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>
@milkway

milkway commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Review (adversarial pass) — MERGE-OK

Focused pass on the hot-path risk areas (reactive recovery runs on the busiest handler in the product):

  • Only runs on the error path. reap_if_confirmed_dead is called solely from the two transport-failure arms (HTTP forward Err, WS handshake Err) — zero cost on the success path.
  • No lock held across the backend call. do_forward/ws::connect have already returned before the reap; replica_liveness runs with no registry lock, and cleanup_replica_state then takes replicas.write() cleanly. No lock inversion vs the scaler.
  • Conservative prune bar. Only an authoritative Missing reaps. Stopped is spared (owned by the External docker restart can orphan a running container because runtime reconciliation is one-way #1016 restart grace window → no reactive double-prune); an app 5xx is an Ok response and never reaches the arm; a whole-call daemon error and multi-host Unknown return false. Covered by unit tests for every classification.
  • Move/borrow correctness. req_is_get_visit is captured before req is consumed by do_forward; replica/spec/state are owned in forward and only borrowed afterward.
  • Recovery UX. On reap: a background coalesced respawn is kicked (guarded by at_capacity), a GET navigation returns the existing cold-start splash (polls readiness), non-idempotent methods are never replayed. Stale sticky is cleared server-side, so the next request re-picks without waiting for the tick.

Real smoke: new docker-it test spawns a real container, removes it externally, and asserts the production glue reaps it (replica_livenesscleanup_replica_state). CI 6/6 green.

No blockers.

@milkway
milkway marked this pull request as ready for review July 20, 2026 18:08
@milkway
milkway merged commit faa03bc into main Jul 20, 2026
6 checks passed
@milkway
milkway deleted the feat/reactive-proxy-recovery-1018 branch July 20, 2026 18:08
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant