perf(deploy): cut a quarter of a deploy's SSH round trips - #159
Merged
Conversation
The deploy report's per-phase command counts (#155-#158) showed where dash was paying for connections it did not need. Every reduction here is one the table measured, and the table is how each was verified afterwards. A three-host integration deploy, before -> after (distinct round trips; the "Acquire server lock" rows nest inside the phase above them): Build and push app image 19 -> 15 Acquire deploy lock 4 -> 4 Ensure dash-proxy 32 -> 29 (server lock 5 -> 2) Detect stale containers 4 -> 4 Boot 30 -> 24 Prune 21 -> 10 (server lock 5 -> 2) total 110 -> 86 (-22%) A redeploy goes 67 -> 57. What changed: - Dash::Commands::App#boot_state asks the clash check and the running-version read in one command. The running version is therefore read before a clashing container is renamed, so when the clash IS the running container the version to stop afterwards is the name it was renamed to - otherwise the boot would stop the container it just started. - Dash::Commands::Auditor#record_then puts an audit line and the action it describes in one shell string, still in that order. Used by the boot, the latest-image tag, the image pull and both prune sweeps. - Dash::Commands::Builder::Base#clean_then_pull drops the stale image and pulls in one command; the removal stays best-effort. validate_image keeps its own round trip, or a failed pull would report a missing service label. - Dash::Cli::Base#ensure_run_directory sweeps each host once per process rather than once per lock acquire. Tracked per host, not as a flag: `dash upgrade` narrows the host set between acquires. Folded audit lines now print at INFO as part of the command they lead; the audit log itself and `dash audit` are unchanged. Also fixes test/cli/build_test.rb's build-report assertions, which only held on a dirty checkout: `stdouted` strips, eating the indent of a ` Build` header printed first, and CI hid it by running `rm Gemfile.lock` before the suite. Refs #154 Claude-Session: https://claude.ai/code/session_01MKyeeYAuG4fR11sBmJa8dm
There was a problem hiding this comment.
All reported issues were addressed across 17 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Review findings on #159. `clean_then_pull` returned `clean || true && pull`. `&&` and `||` bind equally and associate left, so once record_then prepended the audit the chain read `(((run_dir && audit) && clean) || true) && pull` - a FAILED audit write fell into the same `|| true` and the pull ran anyway, exit status 0, where two separate executes would have raised. Parenthesise the best-effort clean so it confines the `|| true` to itself. Also from the review, all test-only: - The "single round trip" boot assertion counted Printer#execute_command, but both reads are captures and a stubbed capture_with_info never reaches that layer - it passed on the unfolded code too. Count capture_with_info instead; it now fails when the two reads are split. - Assert the pull's exact total command count rather than an integer-divided per-host average, which rounded away one extra command on a single host. - Move recorded_commands to CliTestCase; main_test keeps the lock-details redaction as recorded_deploy_commands on top of it. Refs #154 Claude-Session: https://claude.ai/code/session_01MKyeeYAuG4fR11sBmJa8dm
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Review finding on #159. The Printer stub returns nil instead of printing, and mocha leaves it standing until the end of the test - so every command issued after the block was silently invisible, while the helper's comment promised recording only "during the block". Probed on a prune run: after the block, 0 of 4 `Running ...` lines printed; with the ensure, all 4 do. No caller ran commands after the block today, so this was a trap for the next one rather than a live bug. Refs #154 Claude-Session: https://claude.ai/code/session_01MKyeeYAuG4fR11sBmJa8dm
This was referenced Sep 12, 2026
Closed
mhenrixon
added a commit
that referenced
this pull request
Sep 12, 2026
…ip (#165) `dash build pull` paid four SSH round trips per app host: a `docker login`, a `docker info` mirror probe, the audited clean-and-pull (#159), and `validate_image`. The first two carried no work the other could not carry — the probe runs before the pulls it shapes, and nothing between the login and the probe depends on ordering across hosts. `Dash::Commands::Registry#login_then` composes them into one command per host: docker login <server> -u … -p … > /dev/null && docker info --format '…' The login's stdout is redirected away so the capture returns the mirror answer alone. Mirror seeding is unchanged — a host with no mirror still fails the `docker info` half with docker's index error, which is what "no mirror" means, and a rejected login short-circuits the `&&` and raises as before. The two single-command cases stay single: one app host issues only the login (nothing to seed), a local registry only the probe (`Registry#login` returns nil). Four app hosts: 16 round trips → 12. This is a fold, not a skip; every command that ran before still runs, in the same order. Refs #161
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
PR 5 of #154: fix the overhead the deploy report's per-phase command counts revealed. Every change here is a reduction the table measured, and the table is how each was verified afterwards — no fix landed that the numbers did not justify.
Measured on the integration harness (3 hosts, 2 app hosts), same stack before and after. The
Acquire server lockrows nest inside the phase above them, so they are not summed twice:A redeploy goes 67 → 57 (−15%):
Build and push25 → 21,Boot34 → 28.What changed
Dash::Commands::App#boot_state— the container-clash check and the running-version read become one command, chained with;and split on a separator.Boot−1 per host.Dash::Commands::Auditor#record_then— an audit line and the action it describes in one shell string, still in that order. Used by the boot, the latest-image tag, the image pull and both prune sweeps.Dash::Commands::Builder::Base#clean_then_pull— drop the stale image and pull in one command; the removal stays best-effort (|| true).Dash::Cli::Base#ensure_run_directory— sweep each host once per process instead of once per lock acquire. BothAcquire server lockrows 5 → 2.Nothing new is executed and no ordering semantics change: the audit is still written before the action it describes, the health barrier is untouched, and no
captureis folded into anything.One user-visible change: folded audit lines now print at INFO as part of the command they lead, where they used to run at
verbosity: :debugand were invisible. The audit log content anddash auditare unchanged.Measured, not fixed — follow-up
Ensure dash-proxyis the single largest row (32 ssh, 12.8s, ahead of Boot and Prune) and is deliberately left alone. Its round trips are the staged-rename migrations —LegacyRename,copy_legacy_config_volume, thekamal-proxycontainer andkamal-proxy-netteardown — fourexecutes per host of pure legacy checking on every boot, plus drift detection. Folding them changes migration semantics thatCLAUDE.mdfreezes until stage 3c, and the issue's candidate list does not include them. Worth its own issue once 3c lands.Test plan
bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].grep_v(/integration/).each { |f| require File.expand_path(f) }'— 1873 runs, 0 failuresbundle exec rubocop --parallel— no offensesbin/test(full suite, Docker + published proxy image) — 1892 runs, 6046 assertions, 0 failuresprune_test,build_testandapp_testpin the reductionstest/cli/main_test.rbis unchangedDeviations & judgment calls
Deviations
deployissues itself (subcommands are stubbed), and every reduction here lands in a subcommand. Round-trip-count assertions were added to the suites that do own those commands, so the reductions are pinned where the guard cannot see them.validate_imagekeeps its own round trip. The issue asked forclean+pullin one command; foldingvalidate_imagein as well would put the pull under that command's trailing|| (echo "... missing the 'service' label" && exit 1), so a failed pull would report a missing label. 4 → 2 per host rather than 4 → 1.Discoveries
old_version_renamed_if_clashinghad an ordering dependency the issue did not name. Combining the two captures means the running version is read before the clashing container is renamed. When the clash IS the running container, the old version to stop afterwards is the name it was renamed to — stopping the name that was read would stop the container the boot just started. Handled by substitutingrenamed_versionwhen the read version equals the version being deployed, which is exactly what the second capture used to return. Unit-tested, and verified against the harness with a same-version redeploy.ensure_run_directoryhad to memoise per host, not per process. The issue said "memoise per process (DASH.run_directory_ensured)". A boolean is wrong:dash upgradenarrowsDASH.hostswithwith_specific_hostsbetween lock acquires, so a flag would skip the sweep for a host that was never in scope when it was set. Tracks the host set instead.test/cli/build_test.rb:142failed on any clean checkout, and has since feat(report): measure every build step from the buildx stream dash already reads #156.stdoutedstrips, eating the two-space indent ofBuildwhen that header is the first line printed. CI only passes because the workflow runsrm Gemfile.lockbefore the suite, soDash::Git.uncommitted_changesprints a line above it — exactly the host-dependence.claude/rules/testing.mdforbids. In path (this PR edits thepull_on_hostsexpectations in the same file), so fixed here: the capture no longer strips, and the two report tests stubuncommitted_changesso the checkout's state cannot decide the output.Judgment calls
Auditor#record_then(line, *commands)rather than acombineat each call site: five call sites wanted the same shape, and it keeps shell building in the Commands layer where.claude/rules/coding-style.mdputs it.BOOT_STATE_SEPARATOR = "--%--":%is not legal in a docker tag and container ids are hex, so neither answer can forge the separator line.Refs #154
https://claude.ai/code/session_01MKyeeYAuG4fR11sBmJa8dm
Summary by cubic
Cuts a deploy's SSH round trips by 22% (110 → 86), the reductions #154's report exposed, by folding independent commands into single shell strings and sweeping the run directory once per host per process. A redeploy goes 67 → 57.
Notes for review
dash auditare unchanged.clean_then_pullgroups the best-effort clean in parentheses so a failed audit write cannot fall into the|| trueand pull anyway.validate_imagekeeps its own round trip so a failed pull doesn't report a missing service label.Ensure dash-proxyis left alone deliberately — its round trips are frozen migration logic.build_testreport assertions only held on a dirty checkout, andrecorded_commandsnow stops recording where its block ends.Written for commit e137dfb. Summary will update on new commits.