fix: authoritative + bidirectional liveness reconcile for external docker changes (#1015, #1016) - #1017
Merged
Merged
Conversation
…cker changes (#1015, #1016) The scaler's runtime liveness reconcile was one-way and treated a container's absence from list_managed_containers() as "not stopped", so: - #1015 `docker rm -f` left the replica Ready forever → 502 upstream error, and the phantom kept counting toward max-replicas. - #1016 `docker restart` raced the tick: a tick catching the container stopped pruned the replica, then the same container came back running and was never re-adopted (re-adoption only ran at startup) → orphan + possible duplicate past max-replicas. Adds a ReplicaLiveness { Running, Stopped, Missing, Unknown } contract: ContainerBackend::replica_liveness classifies known replicas with per-host authority (Missing = owning host answered without it; Unknown = host unreachable → never pruned) and returns running labelled containers for re-adoption. Default impl is fail-safe (all Unknown); wait_until_ready is fail-closed and reused from spawn before publishing a replica Ready. The scaler now prunes Missing/Stopped-past-grace through one shared cleanup routine (also used by stop_one), models an external restart as a transient state held for a 30s grace window (keeps its registry slot so min-replicas can't duplicate-spawn during a normal restart), re-adopts the same container once it reports Running again, and enforces max-replicas under the spawn-coalescer lock. Out of scope (fast-follow): reactive proxy-path recovery and Docker event listener — the periodic reconcile is the fallback either way. Tests: core/docker unit (Missing vs Unknown), scaler unit (prune/keep, restart readopt-once, grace no-duplicate-spawn, past-grace prune+replace), and docker-it against a real daemon (rm -f → Missing + clean replacement; restart → same container rediscovered, readiness-gated, no duplicate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
Review (adversarial pass) — MERGE-OKReviewed the seven highest-risk areas directly against the code (an independent codex reviewer was queued but its account hit its usage cap, so this is a manual pass; CI is fully green).
Accepted trade-off (non-blocking): a re-adoption's readiness wait holds the per-spec spawn lock for its duration (bounded by the readiness timeout), which correctly blocks a concurrent duplicate spawn. Gate: |
milkway
marked this pull request as ready for review
July 20, 2026 17:48
This was referenced Jul 20, 2026
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>
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
Fixes #1015 and #1016 — two bugs with one root cause: the scaler's runtime
liveness reconcile was one-way and treated a container's absence from
list_managed_containers()as "not stopped".docker rm -f <ruscker-container>leaves the replicaReadyin theregistry forever (absence ≠ stopped), so routing/sticky keeps hitting a dead
upstream →
502 upstream error, and the phantom still counts towardmax-replicas, blocking a substitute.docker restart <ruscker-container>races the scaler tick: a tickthat catches the container stopped prunes the replica + drops sessions, then
Docker finishes the restart and the same container is running again but is
never re-adopted (runtime re-adoption only happened at Ruscker startup) → an
orphan invisible to proxy/dashboard/scaler, and a later access can spawn a
duplicate past
max-replicas.Approach — authoritative, bidirectional reconcile
New backend contract in
ruscker-core:enum ReplicaLiveness { Running, Stopped, Missing, Unknown }+ aContainerBackend::replica_liveness(&[ReplicaLivenessQuery]) -> ReplicaLivenessReportthat classifies the caller's known replicas and returns running,
Ruscker-labelled containers for re-adoption. The default impl is fail-safe:
it reports every replica
Unknownand discovers nothing, so mocks / non-Dockerbackends can never trigger a false prune (preserves today's behavior).
ContainerBackend::wait_until_ready(&Replica)— fail-closed default; re-adoptionreuses the same readiness check as spawn before publishing a replica
Ready.The key invariant — Missing (authoritative) vs Unknown:
authoritatively
Missing. A whole-call daemon error → the pass is skipped (asbefore).
HostBatch { reported, failed_hosts }/placementmachinery. A replica whose owning host answered without it isMissing; a replica whose owning host failed/timed out (or an unplacedreplica while any host is unreachable) is
Unknown— never pruned, so a degradedfan-out can't forget a live container and spawn duplicates.
Scaler (
tick):MissingandStopped-past-grace through one shared cleanup routine(
cleanup_replica_state: registry + sessions + metrics-cache + in-flight gauge),which
stop_onenow also uses so those states can't drift apart.ReplicaDownalerts/logs name the reason (
missing/removedvsrestart timed out).Stoppedobservation the replica is marked non-routable (
mark_restarting) and held for ashort grace window (
EXTERNAL_RESTART_GRACE_SECS = 30). It keeps its registryslot, so
min-replicasstays satisfied and no duplicate is spawned during anormal restart. If the same container returns
Runningit's re-adopted(after
wait_until_ready); if the grace window elapses it's pruned + replaced.max-replicasunder the spawn-coalescer lock (a runningorphan beyond the cap is removed rather than exceeding max or duplicating).
Out of scope (fast-follow)
Per the issues' "optional" sections, this PR is the reconcile core only. Not
included: the reactive proxy-path recovery (confirm backend state on an upstream
transport failure + GET/HEAD replay) and the Docker
die/destroy/starteventlistener. The transient 502 during an in-progress restart is acknowledged as
self-recovering by #1016; the periodic reconcile is the fallback either way.
Tests
Missing;multi-host absence on a responding host →
Missing, replica on a failed host →Unknown.Missingprunes +drop_replica;Unknownkeeps replica &sessions & blocks a duplicate spawn;
Running → Stopped → Runningon the same idreadopts exactly once (one readiness check, zero spawns);
Stoppedpast the gracewindow is pruned + replaced; restart within grace does not duplicate-spawn
(locks the External docker restart can orphan a running container because runtime reconciliation is one-way #1016 race). Existing
liveness_reconcile_prunes_only_stopped_containersstays green.
docker rm -f→Missing+ clean replacement (nophantom blocks/duplicates);
docker restart→ the same labelled container isrediscovered
Running, passes readiness, and re-enters the pool with no duplicate.Gate
cargo test·cargo clippy --all-targets --all-features -D warnings·./scripts/i18n-check.sh·cargo test -p ruscker-docker --features docker-it(48 passed against local Docker 29.6.1) — all green.
🤖 Generated with Claude Code