fix(container): scope shutdown and boot reconciliation to the owning worker - #70
Merged
Conversation
…worker Production runs `--workers 4` against one shared Docker socket, and both lifecycle hooks operated on "every unfinished session". So whenever any one worker restarted or crashed, its shutdown hook CANCELLED every live session on the host and its boot hook marked the survivors FAILED. Unlike the reaper, these were not idempotent -- they were actively destructive to peers. Neither function had a single test, which is how it survived. Shutdown is now scoped by ownership. Every runner carries a `helprs.boot_id` label identifying the process that started it, and `cleanup_own_running` asks Docker which containers carry this process's label. Docker is the authority on what is actually running; the database only records what was intended. Boot reconciliation stops guessing. `reconcile_stale_sessions` asks `container_is_running` per session instead of treating "unfinished" as evidence of death -- at boot, most RUNNING rows belong to a peer that never stopped and is still streaming to a user. `current_boot_id` recomputes when the PID changes rather than caching once, because uvicorn's --workers mode may fork after import and two workers sharing a boot id would each stop the other's containers -- reintroducing the bug through the mechanism meant to fix it. Adds `container_is_running` and `list_runners` to the DockerClient protocol and its doubles, plus `RunnerContainer` so the boundary returns a typed pair rather than a dict. Eight new tests, run against the previous behaviour first: four of them fail on it.
|
helPRs session created for this PR. Skill: |
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.
The most serious item left from the 2026-08-01 audit: with
--workers 4in production, one worker restarting destroyed its peers' live sessions.The bug
Both lifecycle hooks operated on every unfinished session, while all four workers share one Docker socket:
cleanup_all_running(shutdown) → stopped every running container on the host and marked those sessionsCANCELLEDreconcile_stale_sessions(boot) → marked every still-RUNNINGrowFAILEDSo a single worker restarting cancelled three other workers' sessions mid-stream, and its boot hook then failed whatever survived. Unlike the webhook reaper, which claims rows atomically, these two were not idempotent — they were destructive to peers.
Neither had a single test. That is how it survived a full audit round:
grepfor either name intests/returned nothing.The fix
Shutdown is scoped by ownership. Every runner now carries a
helprs.boot_idlabel identifying the process that started it, andcleanup_own_runningasks Docker which containers carry this process's label. Docker is the authority on what is actually running; the database only records what was intended.Boot reconciliation stops guessing.
reconcile_stale_sessionsaskscontainer_is_runningper session rather than treating "unfinished" as evidence of death. At boot, mostRUNNINGrows belong to a peer that never stopped and is still streaming to a user. A row with nocontainer_idnever got that far, so nothing is running for it either way — those are still failed.This is the better signal regardless of worker count: it answers "is this session actually alive?" instead of a proxy for it.
One subtlety worth reviewing
current_boot_id()recomputes when the PID changes instead of caching once:uvicorn's
--workersmode may fork after import, and a plain module-level constant orlru_cachewould then be inherited by every child — two workers sharing a boot id would each stop the other's containers, reintroducing the exact bug through the mechanism meant to fix it. The PID check makes it correct under both fork and spawn, with no plumbing through the lifespan.Protocol changes
DockerClientgainscontainer_is_runningandlist_runners, plus a frozenRunnerContainerdataclass so the boundary returns a typed pair rather than a dict.AioDockerClient.list_runnersfilters server-side on the label and skips any container whose session label is missing or unparseable — guessing which row it belongs to could cancel the wrong session.Verification
ruff+ruff format+mypycleantests/modules/container/test_cleanup.py, covering the multi-worker case explicitly: a peer's session is left alone on shutdown, a live peer session is not failed at boot, the live and the dead are separated, an already-finished session is not re-cancelled, an orphan container with no row is skipped