perf(proxy): stop paying the stage-3c bridge on a host that has already migrated - #167
Merged
Conversation
…dy migrated ## Summary `Ensure dash-proxy` is the most expensive row of a deploy by round trips, and on a host that has already been through the 3c rename most of what it did was checking, again, that it had. The four bridge commands become one, guarded on a marker in the run directory: `test -f .dash/proxy/.legacy-renamed || ( bridge && copy && replace && mark )`. A migrated host — and a host installed fresh on 4.x that never had a kamal-proxy — runs no docker command for the bridge at all. The command is folded into the apps-config `mkdir -p` the host pays anyway, so the round trip goes too. The marker is written only on verified absence of both legacy containers, never on the chain's exit status: the removals end in `|| true`, and a host whose stop failed must retry next deploy rather than record itself as done. A failed volume copy still aborts the boot through the && chain, exactly as today. `container_id`, `config_digest` and `version` were three docker invocations against the same container; one `docker inspect --format` returns all three, parsed by Dash::Commands::Proxy::State. Drift owns the capture and the minimum-version gate reads the tag off it. `dash doctor`'s drift check gets the same 2 -> 1 for free. Nothing changes for a host still running kamal-proxy: the same three steps, in the same documented order, before anything reads the new container, volume or network. Per proxy host, with a running proxy: 12 -> 6 round trips. On the load balancer host: 10 -> 7. ## Test Coverage - commands/proxy_test: the marker guard, the documented step order, per-step subshells, the verified-absence marker write, the copy left free to fail, the apps-config fold, the inspect format, and State's parsing (tag past a registry port, no container, unlabelled container) - commands/loadbalancer_test: the same for the two-step loadbalancer variant, whose marker is verified on the volume instead - cli/proxy_test: the per-host round-trip sequence pinned exactly, and a still-legacy host getting the whole bridge in order ## Verification - [x] bundle exec rubocop --parallel passes - [x] bin/test (unit + integration) passes - [x] generated shell exercised against real sh and bash with a fake docker Refs #160
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
- Marker check fails closed on a docker error: `mark_legacy_renamed` used a negated `docker container inspect`, whose exit code cannot distinguish "no such container" from "the daemon couldn't be asked." A transient docker error read as confirmed absence and would permanently mark a host migrated with the legacy container still running. Replaced with `confirmed_empty?`, a new Dash::Commands::Base helper built on the existing list-based `container_id_for` (which exits 0 on no match and non-zero only on a genuine failure): `result=$(list) && [ -z "$result" ]` propagates the list's own exit status, verified against real sh and bash. - Documented, and opened #168 for, the loadbalancer's narrower risk: its marker is verified on volume existence (no legacy container to check there), which a `dash proxy reboot` run before the bridge has ever executed on a host could satisfy without the legacy volume ever being copied. Root cause is LoadbalancerReboot#run (and the per-host Reboot) never routing through the bridge - pre-existing on `main`, and a fix belongs in a PR that touches those classes, not this one (scoped to `boot`'s round trips per #160). - Extracted `recorded_commands_and_captures` into CliTestCase, shared by the Printer/capture_with_info stubbing `recorded_commands` and `recorded_captures` already do separately; `recorded_proxy_round_trips` now composes it and keeps only the proxy-specific redaction. Addresses cubic-dev-ai review on PR #167.
This was referenced Sep 12, 2026
mhenrixon
added a commit
that referenced
this pull request
Sep 12, 2026
…t order `on` runs the proxy hosts in parallel threads, and the recorder behind "boot issues no round trip beyond the pinned per-host sequence" appended from both. The pin then spelled out host 1's sequence followed by host 2's, which held only while the two threads happened not to overlap. CI seed 59404 interleaved them (login, bridge, login, bridge, inspect, ...) - same commands, same count per host, different scheduling - and the test failed on a run that issued exactly what it pins. It reproduces standalone here too: 1 in 150. Tag every recorded round trip with the host it went to (the Printer command carries it; a capture reads it off SSHKit::Backend.current, the thread-local the backend sets for its run) and assert each host's own sequence. That is the claim the test was making - the count and the order the gem chooses - minus the one it never meant to: which thread the scheduler ran first. 0 in 300 after. Refs #167
mhenrixon
added a commit
that referenced
this pull request
Sep 12, 2026
…bridge (#169) * fix(proxy): route every container-creating path through the stage-3c bridge `Dash::Cli::Proxy::Reboot`, `Dash::Cli::Proxy::LoadbalancerReboot` and `dash proxy loadbalancer start` all create the renamed container without ever running the stage-3c bridge. `docker run --volume dash-loadbalancer-config:...` auto-creates the named volume empty when it does not exist, so a `dash proxy reboot` against a host that has never been through `dash proxy boot` brings the new volume into existence before the bridge has had any chance to copy the legacy routing table and ACME cache into it. The next boot then finds the new volume already there and skips the copy for good, via `copy_legacy_config_volume`'s own guard - silently. Since #167 that state also writes the `.legacy-renamed` marker, so recovery needs the marker deleted as well as the volume fixed. Fix it at the source rather than making the marker's heuristic smarter: every path that can create the container, the volume or the network now runs `prepare_boot` first. On both reboot paths this is round-trip neutral - they already spent a round trip on `ensure_apps_config_directory`, which `prepare_boot` carries. Refs #168 * docs(proxy): record why the bridge copies the config volume while it is live Both cubic and a human reader will ask whether `cp -a` over a volume the legacy container still mounts can capture a half-written routing table or certificate. It cannot: dash-proxy renames into place on every writer - the routing table via writeFileAtomic, the dynamic domain and redirect state via their own temp + rename, the response cache via CreateTemp + Rename, and the ACME cache via autocert.DirCache. Written at the shared copy rather than at one caller, since `boot` and both reboots all reach it. Refs #168 * test: stop a --quiet CLI test deciding whether later tests see SSHKit output `dash app stale_containers --quiet` in test/cli/app_test.rb leaves :error on both the DASH singleton and SSHKit's global output_verbosity: Cli::Base#initialize_commander sets the commander's verbosity and Commander#configure_sshkit_with mirrors it into SSHKit. Nothing restores either between tests — Commander#reset would, but only `dash alias` calls it — so from that point on every SSHKit.config.output.info in the process is dropped. Whether that mattered depended on the seed. CI run 34709935073 put the quiet test ahead of test/cli/healthcheck/progress_reporter_test.rb on Ruby 3.2 (seed 36230) and three of its assertions saw "", while Ruby 3.3, 3.4 and 4.0 drew seeds that passed the same commit. Reproduced locally with `bin/test --seed 36230`, three failures, same three tests. Pin both to :info in the suite's global setup, beside the Docker pins that answer the same class of problem — a test that wants another verbosity still sets it itself. Refs #166 * test: pin the proxy boot's round trips per host, not in one cross-host order `on` runs the proxy hosts in parallel threads, and the recorder behind "boot issues no round trip beyond the pinned per-host sequence" appended from both. The pin then spelled out host 1's sequence followed by host 2's, which held only while the two threads happened not to overlap. CI seed 59404 interleaved them (login, bridge, login, bridge, inspect, ...) - same commands, same count per host, different scheduling - and the test failed on a run that issued exactly what it pins. It reproduces standalone here too: 1 in 150. Tag every recorded round trip with the host it went to (the Printer command carries it; a capture reads it off SSHKit::Backend.current, the thread-local the backend sets for its run) and assert each host's own sequence. That is the claim the test was making - the count and the order the gem chooses - minus the one it never meant to: which thread the scheduler ran first. 0 in 300 after. Refs #167
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
Ensure dash-proxyis the most expensive row of a deploy by round trips, and on a host that has already been through the stage-3c rename most of what it does is checking, again, that it has been through the stage-3c rename.Two changes, both folds or skips — no new SSH or docker command is added anywhere, and nothing changes for a host still running
kamal-proxy.1. The bridge is one command a migrated host skips inside
Dash::Commands::Proxy#legacy_renamecomposes the four existing builders behind a marker:Each step is wrapped in its own subshell because every one of them already mixes
&&and||at a single precedence level — composing them flat re-associates across the volume copy's guard, which is the chain 4.0.0 got wrong. The bodies are untouched, so the/usr/bin/envand precedence lessons in their comments still hold.The marker is written only on verified absence of both legacy containers, never on the chain's exit status: the two removals end in
|| true, so a host whosedocker container stopfailed would otherwise record itself as migrated and never retry. Its own|| truekeeps that failure as quiet as it is today. A failed volume copy still exits non-zero through the&&chain and aborts the boot, exactly as it does now.Dash::Commands::Proxy#prepare_bootthen carries the apps-configmkdir -pin the same round trip (a || b && cis(a || b) && c, so it runs either way), which is the round trip the host pays anyway. A migrated host now pays zero round trips for the bridge.Same shape for the load-balancer branch, with its own marker under
.dash/loadbalancer/. It replaces no legacy container, so its marker is verified on the volume instead: the new one exists, or there was never a legacy one to adopt.2. One inspect for what were three reads
container_id,config_digestandversionwere threedockerinvocations against the same container.inspect_statereturns id, image tag and digest in one--format, parsed byDash::Commands::Proxy::State.Dash::Cli::Proxy::Driftowns the capture and exposes#version, so the minimum-version gate reads the tag off the inspect the drift check already made.dash doctor's drift check gets the 2 → 1 reduction for free.Drift#drifted?,#expected_digestand the minimum-version error message keep their semantics.Before / after
Measured with the unit harness (
recorded_commandsplus a recording stub oncapture_with_info), not a real fleet — see Deviations. Executes and captures both counted, since both are SSH round trips.One proxy host, migrated, running a non-drifted proxy (
deploy_simple):maindocker loginconnect_legacy_network_containerscopy_legacy_config_volumeremove_legacy_containerremove_legacy_holder_containerensure_apps_config_directorycontainer_id(capture)config_digest(capture)version(capture)boot_config(capture)start_or_runPlus the one shared
docker network create dashper host, unchanged.Dedicated load-balancer host (
deploy_with_loadbalancer): 10 → 7 — the two legacy commands become one that folds into the apps-config mkdir (3 → 1), andcontainer_id+config_digestbecome one inspect (2 → 1).Extrapolated to the 4-host topology in the issue (3
web+ 1job, LB auto-activated), theEnsure dash-proxyrow's 54 round trips fall to roughly half. This is not a measured wall-clock number — no staging fleet was available in this session.The per-host sequence is pinned exactly in
test/cli/proxy_test.rb(PROXY_BOOT_ROUND_TRIPS_PER_HOST), the way #159 pinned its reduction, so a regression cannot land unnoticed.Test plan
bundle exec rubocop --parallel— cleanbin/test— 1939 runs, 0 failures (unit + integration, Docker + published proxy image;MINIMUM_VERSIONdoes not move)test/cli/main_test.rbis unchangedshandbashwith a fakedockeronPATH:dockerremoved fromPATHentirely → exit 0, apps-config created, zero docker callsStill to verify on a real fleet (the issue's step 4)
kamal-proxymigrates and ends with the marker-voutput) and leaves the marker untouchedreboot_on_deploy: falsewith a drifted proxy still prints the stale warningCloses #160
Deviations & judgment calls
Deviations
main. This session has no staging fleet, so the numbers above are measured with the unit harness and pinned as assertions. Commands and captures counted, not wall seconds — the deploy-table extrapolation is stated as such.docker network create dashinto the per-host prepare command) not done. The issue makes it conditional on the measured table still justifying it. It is one round trip per host against four for the bridge, and it would have to branch on "does this host get another proxy-boot command or not". Left for a follow-up, since there is no real table to justify it.Discoveries
&&cannot be done flat: each already mixes&&and||at one precedence level, soBRIDGE && COPY && …re-associates across their internals. Every step is wrapped in its own( … ). Agrouphelper was extracted inDash::Commands::Baseandcopy_legacy_volume's inline subshell migrated onto it.test, not(: SSHKit's command map passestestthrough and prefixes everything else with/usr/bin/env, and/usr/bin/env (is exit 127. Soprepare_bootrelies ona || b && c==(a || b) && crather than parenthesising the guard. This is called out in the method comment.Dash::Commands::Proxy#versionmasked a missing container by accident: the| awkpipeline made the exit status awk's, socapture_with_infowith the defaultraise_on_non_zero_exit: truenever raised on a host with no proxy.inspect_statemakes that explicit withraise_on_non_zero_exit: false; the observable behaviour (no container → no version → gate skipped) is identical.Judgment calls
Dash::Cli::Proxy::LegacyRename#runnow executes one command that also carries the apps-configmkdir -p. The class name covers slightly more than the rename; its header comment says so, and stage 3d keeps the mkdir and deletes the rest.Driftowns the singleinspect_statecapture and exposes#version, rather thanbootcapturing the state and injecting it. Fewer moving parts, anddash doctorgets the reduction for free.mkdir -pnow runs on every proxy host, including one that is drifted and about to reboot — it used to sit inside the not-drifted branch. It costs nothing (it rides in the bridge round trip) andDash::Cli::Proxy::Rebootmakes the directory anyway, so this is strictly fewer conditionals for the same result.Dash::Commands::Proxy#config_digestand the loadbalancer'scontainer_id/config_digestlose their last caller but stay: they are one-line reads of the same labels, and deleting them plus their tests is cleanup unrelated to this diff's story. Same call the issue makes forversion, which doctor still uses.Everything added here is bridge code marked
Stage 3c … 3d deletes, perCLAUDE.md. NoMINIMUM_VERSIONchange, nothing in../kamal-proxy, and the proxy image, container, network and volume names are untouched.Summary by cubic
Reduces proxy boot round trips by having already-migrated hosts skip the stage-3c legacy bridge entirely, and by merging three container reads into one inspect. For hosts still running
kamal-proxy, the bridge runs exactly as before.Changes
.legacy-renamed) that also carries the apps-configmkdir -p, so a migrated host pays zero bridge round trips.docker container ls; a docker error fails closed rather than reading as confirmed absence, keeping a failed stop retryable, while a failed volume copy still aborts the boot.container_id,config_digest, andversionbecome a singledocker inspectparsed byDash::Commands::Proxy::State;Driftowns that capture and exposes#version.Written for commit 1014e5a. Summary will update on new commits.