Skip to content

perf(box): speed cold docker builds and fix update --check estimates - #271

Open
danielrmerskine wants to merge 5 commits into
mainfrom
de/pr260-rebase
Open

perf(box): speed cold docker builds and fix update --check estimates#271
danielrmerskine wants to merge 5 commits into
mainfrom
de/pr260-rebase

Conversation

@danielrmerskine

Copy link
Copy Markdown
Collaborator

Summary

Speeds up cold box image builds, and fixes a lager update --check estimate that
could be wrong by an order of magnitude.

Authored by @juul-charles-w. Supersedes #260 — same commits, rebased onto current
main, plus one reconciliation commit described below.

What changed

Cold build time (box.Dockerfile):

  • Node/npm from the official upstream tarball, verified against SHASUMS256.txt
    before extraction, instead of Debian's nodejs npm meta-packages — which pull in
    roughly 400 node-* packages the box never touches.
  • cryptography 38.0.4 -> 43.0.3. The old pin has no cp312 wheel, so every cold pip
    layer compiled it from Rust source.
  • flex/bison move into the uldaq stage, the only one whose autoreconf needs
    them. ccache/ninja-build are dropped outright — nothing in this repo invokes
    either.
  • BluFi switches from algorithms.AES128 to algorithms.AES, required by the crypto
    bump and byte-identical for BluFi's fixed 16-byte key.

--check accuracy. The preview hashed the box's current Dockerfile,
requirements and source — but an update pulls first. A box a long way behind, whose
current tree still matched /etc/lager/build-hash, was told ~90s (cached build) and
then waited six minutes. --check now hashes those same inputs at the target ref,
read straight out of the object DB with no checkout (one extra SSH round-trip, on the
--check path only).

Progress bar shows live BuildKit detail during the container build instead of one
frozen label.

Reconciliation with main (6865111)

b5ccab7c landed on main after this branch was cut and refactored the same
function, extracting _deps_preview() with the needs_flatten rebuild trigger and a
measured/unmeasurable distinction. This branch had independently introduced
_preview_deps_status() for the same job, with neither.

Taking the branch's helper would have silently reverted that fix. It never reads
needs_flatten, so a box still on the box/ subdir layout that is also behind would
go back to printing "cache valid" in front of a ten-minute rebuild — precisely the
case the flatten fix was written for.

_deps_preview() is kept as the single helper and gains target_hash / needs_pull.
A measured target ref replaces the working tree as the basis for the whole preview,
which also turns the old "unknown until pull (older ref may differ)" guess into a
measured answer; needs_flatten still forces a rebuild whatever that digest says.
_preview_deps_status is removed, its four tests move onto _deps_preview, and a
fifth pins the flatten-beats-matching-target case this reconciliation could have
regressed unnoticed.

Test status

Verified:

  • All six unit suites green (unit (cli) 1274, unit (box) 1596)
  • Full static-checks set green: pinned shellcheck 0.11.0, bash -n, compileall,
    MCP collect, ruff, zizmor, actionlint, COVERAGE counts
  • test/COVERAGE.md regenerated — unit (cli) 1258 -> 1274, total gated
    3342 -> 3358

Not yet verified — no Docker image has been built from this branch. Everything
above exercises Python and linters; none of it builds an image, so the three Dockerfile
claims are still unproven:

  • Cold build on real hardware: node --version is v20.18.1, cryptography is
    43.0.3, uldaq still imports after the flex/bison move
  • --check against a forward jump that changes the Dockerfile reports a fresh
    build, not ~90s (cached build)
  • Bench: Box Lifecycle dispatch against this ref
  • npm-volume regression check (below)

Operational note

A box carrying globally-installed npm packages should be updated once with --force
after this ships. Node's major version moves 18 -> 20, and the lager-npm-global
volume holding those packages survives an ordinary image rebuild — only --force
wipes it. Any package with a compiled native module needs reinstalling under the new
ABI. This is recorded in the CHANGELOG entry.

juul-charles-w and others added 4 commits August 14, 2026 15:01
Replace apt nodejs meta-packages with the official Node binary, bump
cryptography to a cp312 wheel, and hash the target-ref Dockerfile so
forward version jumps no longer report a false ~90s cached estimate.
Also surface live docker-build detail on the progress bar and use
algorithms.AES for BluFi so the crypto bump stays compatible.

Co-authored-by: Cursor <cursoragent@cursor.com>
The at-ref hasher used bash pattern substitution and `printf -v`, both of
which are silently wrong under dash — the digest would never match the
stored working-tree hash on a box whose login shell is /bin/sh, making
every --check report a spurious rebuild.

Rewrite with `git show | sha256sum | sed` and the same `out=$(...)` +
`echo "$out" | sha256sum` composition as the working-tree hasher, so the
two are byte-identical by construction. Add tests that execute both
snippets under `sh` against a fake box layout rather than asserting on
substrings.

Co-authored-by: Cursor <cursoragent@cursor.com>
Main's build-hash now walks ~/box/lager; keep the equivalence tests and
preview assertions aligned so a Dockerfile-only at-ref hasher cannot
regress silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two corrections to the same `lager update --check` estimate landed in the
same week against the same code. main extracted `_deps_preview()`, giving it
the `needs_flatten` rebuild trigger and a measured/unmeasurable distinction;
the three commits below this one added target-ref build hashing so that a
forward jump stops reporting `~90s (cached build)` off a pre-pull tree.
Neither was written with knowledge of the other, and each shipped its own
helper for the job.

Keeping `_preview_deps_status` would have quietly reverted the flatten fix.
It never reads `needs_flatten`, so a box still on the `box/` subdir layout
that is also behind its target would go back to printing "cache valid"
immediately before a ten-minute rebuild -- precisely the case the flatten fix
was written for.

`_deps_preview()` therefore stays as the single helper and gains `target_hash`
and `needs_pull`. A successfully measured target ref now replaces the working
tree as the basis for the whole preview, which also converts the old "unknown
until pull (older ref may differ)" guess on rollbacks and branch switches into
a measured answer. `needs_flatten` still forces a rebuild whatever that digest
says, and a target that could not be measured reports unknown rather than
falling back to the pre-pull tree and calling the cache valid.
`_preview_deps_status` is removed; its four tests move onto `_deps_preview`,
joined by a fifth that pins the flatten-beats-matching-target case this
reconciliation could have regressed unnoticed.

Also in this commit:

- `test/COVERAGE.md` counts regenerated: unit (cli) 1258 -> 1274, total gated
  3342 -> 3358.

- The new Dockerfile comment claimed flex/bison/ccache/ninja were "build-only
  for uldaq (next stage)". Only flex and bison are, and they do move to that
  stage; nothing in this repo invokes ccache or ninja-build anywhere, so they
  are dropped outright rather than relocated. The comment now says that.

- CHANGELOG entries for the target-ref hashing, the cold-build speedups, and
  the live build detail on the progress bar -- including the operational note
  that a box carrying globally-installed npm packages needs one `--force`
  update, because Node 18 -> 20 is an ABI break and only `--force` wipes the
  `lager-npm-global` volume that survives an ordinary rebuild.

- Two test comments now describe the forward-jump condition they cover, which
  is what tells a reader when the case applies.
@danielrmerskine

Copy link
Copy Markdown
Collaborator Author

Hardware verification (real box, cold build)

Every claim in this PR is now backed by a measurement rather than reasoning.

Cold build: 20m14s, from docker builder prune -af to a serving container. Exit 0.

Before / after on the same box

before after
Node v18.20.4 v20.18.1
npm 9.2.0 10.8.2
cryptography 38.0.4 43.0.3

Read out of the running container, not from the build log.

The claims, each with its evidence

Claim Evidence
Node tarball is verified before extraction node-v20.18.1-linux-x64.tar.xz: OK from sha256sum -c in the build
cryptography no longer compiles from source Collecting cryptography==43.0.3 straight into Collecting cffi; no Building wheel for cryptography anywhere in the log
flex/bison move does not break uldaq import uldaq succeeds in the container; the uldaq stage installs them itself
ccache/ninja were genuinely unused both absent from the image; box builds and serves regardless
BluFi algorithms.AES is byte-identical to AES128 round-trip passes, and the cipher reports AES 128 bit for a 16-byte key — same primitive, not merely a working substitute
Box is healthy afterwards container up, lager hello answers

--check, both directions

Scenario Reported
Forward jump whose target ref changes the Dockerfile will trigger fresh build (target Dockerfile, requirements or box source changed) / ~6 min
In sync with the target cache valid (no rebuild) / ~5s / Nothing to do.

The target Dockerfile wording is emitted only by the at-ref branch, so this confirms
the box really did read the Dockerfile at the target ref out of its object database and
compare digests — which also exercises the POSIX-safe hasher against a real box's login
shell, the failure mode that would otherwise have been invisible until every --check
started predicting a rebuild.

One honest qualification: the box used here was 1 ahead / 8 behind, a shape that would
have hit the pessimistic ~6 min branch under the old code too, via commits_ahead > 0.
So the estimate happens to coincide; what this proves is that the measurement path runs
correctly on real hardware and discriminates in both directions. The behind-only case,
where the old code said ~90s, is covered by the unit tests that execute both hashers
under sh against a fixture repo and assert the digests agree.

Not covered here

The Node 18 -> 20 ABI note in the CHANGELOG remains reasoned rather than measured: this
box has no globally-installed npm packages (.npm-global/lib does not exist,
npm_packages.txt is comments only), so there was nothing for the major-version jump to
break. The caution stands for a box that does carry them.

The comment claimed the build digest covers the image recipe but not every
COPY layer. It covers both: the hash walks all of ~/box/lager, and every COPY
source in box.Dockerfile resolves inside that tree.

The floor is still correct, for a different reason. A matching digest proves
the build inputs are unchanged, not that Docker still holds the layers built
from them -- a `docker builder prune` between updates rebuilds everything
with the digest identical.
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.

2 participants