Repository navigation
ci: cache FRR's compiles in the CI image build with ccache - #136
Conversation
Every Build leg recompiles all of FRR because FRR_SOURCE_REV changes per commit, even when a commit touches one file. Carry a ccache between runs: - docker/ubuntu-ci/Dockerfile installs ccache 4.14.1 (musl-static, sha256-pinned per arch). Ubuntu's 4.5.1/4.9.1 reject --enable-gcov's -fprofile-update=atomic as an unknown profiling option, which left 1360 of ~1490 compiles uncached. Only make and make check run through it (PATH masquerade), so the image's Makefiles keep plain gcc and configure is not slowed down. The cache sits in a BuildKit cache mount, seeded from a --build-context and exported via a ccache-out target; after make check, entries this build did not use are evicted so the saved cache stays one build's worth (258 MB on 22.04). - github-ci.yml Build and Build-LTTng restore it from actions/cache; only master Build runs export and save it. buildcache-seed.yml restores and saves it for all three legs, including LTTng. Measured on devbox, 4-CPU builders, 22.04, master plus a one-line .c change, warm and no-ccache builds side by side: compile step 278 s vs 368 s, make check 234 s vs 281 s (138 s per Build leg). A cold build, with no cache to restore, is slower than today (compile step 469 s). Signed-off-by: Omar Ramadan <omar@blockcast.net> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (applied directly; nested CLI not launched in the k8s Job runtime).
Reviewed head: 2cdb4e2
The design is sound and unusually well evidenced — the preprocessor-mode reasoning about -DCONFDATE is correct, and the eviction-by-mtime trick is the right way to keep the exported entry one build wide. All three findings are about what happens when the optimization misbehaves, which this change leaves unguarded in three places.
Critical Issues (0)
None.
Important Issues (3)
-
[gstack/review]
docker/ubuntu-ci/Dockerfile:363— The safety ofCCACHE_IGNOREOPTIONS=-DCONFDATE=*rests entirely onCCACHE_NODIRECTbeing in effect, and nothing in the build asserts that it is. The comment atDockerfile:342-348states the invariant precisely and correctly — direct mode plus an ignored-DCONFDATEserves a stale object across FRR's#if CONFDATE > Ndeprecation thresholds — but it is enforced only by anARGdefault sitting three lines away. Both are overridable from the command line, and the two are coupled in a way a later editor has no way to see from either line alone. The failure mode is the worst one available here: not a build error, but a silently stale.o(and.gcno) inside an image that feedsUnit-Test, the topotest shards and coverage. It is also undetectable after the fact, because the cached object compiles and links cleanly.- Assert it where it is used, next to the existing
ccache -zin the compileRUN(Dockerfile:375):ccache --get-config direct_mode | grep -qx false(orccache -p | grep -q 'direct_mode = false'). One line, fails loudly at build time, and it keeps the comment and the behaviour from drifting apart. - Secondary, worth confirming before relying on the ARG as a switch:
CCACHE_NODIRECTis aCCACHE_NO*-form boolean, for which ccache keys on the variable being set rather than on its value. If so,--build-arg CCACHE_NODIRECT=0reads to a human as "direct mode on" and still leaves it off — harmless in itself, but it means the ARG cannot be used to flip the invariant and the assertion above is the only honest guard.
- Assert it where it is used, next to the existing
-
[native-codex]
.github/workflows/github-ci.yml:456,.github/workflows/buildcache-seed.yml:239—Save ccachehas no guard thatExport ccacheactually produced anything.du -shatgithub-ci.yml:455prints the size but does not gate on it, so an empty export is saved as a normal entry. Because every consumer reaches the cache throughrestore-keys: frr-ccache-v1-<platform>-(github-ci.yml:367), which returns the newest prefix match, one empty entry becomes the entry every subsequent PR restores. The Dockerfile then takes the no-seed branch (Dockerfile:371) and compiles cold — which by your own measurement is ~100 s slower per leg than before this PR (469 s vs 368 s), not a neutral fallback. It persists silently until the next master save.- The sha-keyed
key:protects the common collision cases, but not all of them. Reachable chain: master is quiet for several days (plausible at ~2 merges/day against the 10 GB budget and ~2.6 GB/day of new ccache entries); the actions/cache entry for the current master sha is evicted by LRU;buildcache-seedruns again on that same unchanged sha with an empty restored seed, so the compile layer is a fullcache-from: type=registryhit (buildcache-seed.yml:216), thefrr-ccachemount on the fresh builder is never written, the export is empty — and the previously-occupied key is now free, so it saves. - Guard the save rather than the export:
if: ... && hashFiles(format('{0}/frr-ccache/**', runner.temp)) != '', or set a step output from the export ([ -n "$(ls -A "$dir")" ]) and gate on it. Failing to save is the correct outcome when there is nothing to save.
- The sha-keyed
-
[pr-review-toolkit:errors]
.github/workflows/github-ci.yml:435—Export ccacheis a pure optimization, but it can turn masterBuildred, and its blast radius is larger than the cache. It carries nocontinue-on-error, and it sits beforeClear any previous results(github-ci.yml:461) and theSave cleared previous results (shard k)uploads. So a transient failure in the export build — a builder hiccup, adocker buildxerror, no space for the 258 MB local output — fails the job after the image has already been built and pushed, and skips the shard seeds thatTestdepends on for its re-run semantics. This inverts the principle the restore step states atgithub-ci.yml:365: "A miss is a cold compile … never a failure." That should hold on the write side too.continue-on-error: trueonExport ccache(the same treatmentbuildcache-seed.yml:174already gives its MIB restore), paired with the non-empty guard above so a half-finished export cannot then be saved. Same inbuildcache-seed.yml:218.
Suggestions (3)
- [pr-review-toolkit:code]
.github/workflows/github-ci.yml:446,.github/workflows/buildcache-seed.yml:229— Therun:block does not harden the shell. GitHub's defaultbash -egives errexit but notnounsetorpipefail; elsewhere in this workflow (e.g. the MIB populate step) the repo opts into all three explicitly. - [pr-review-toolkit:comments]
.github/scripts/test_buildcache_freshness.py:476— The docstring still reads "a hand copy of Build's first five steps" while the workflow comment it documents is updated to "first seven" (github-ci.yml:508). The assertions themselves are prefix-based and count-agnostic, so nothing breaks — only the docstring drifts. - [gstack/review]
docker/ubuntu-ci/Dockerfile:367— The cache mountid=frr-ccacheis not keyed by Ubuntu release or LTTng flag. This is correct exactly as long as each matrix leg gets its own builder, which is whatarc-dindplus a per-jobsetup-buildx-actiongives you today. Since that assumption is load-bearing — two legs sharing a daemon would mix 22.04 and 24.04 entries into one mount, serialize onsharing=locked, and have each leg's--evict-older-thandelete the other's work — it is worth one line of comment recording it next to the mount.
Strengths
- The
-DCONFDATEanalysis is the strongest part of this change: identifying that preprocessor mode makes the ignore safe because#if CONFDATE > Nalters preprocessed output, and that direct mode would not, is exactly the distinction that matters, and it is written down where the next reader will need it. - Pinning ccache by sha256 per arch rather than taking the distro package, with the measurement that motivated it (1360 of ~1490 compiles running uncached on jammy's 4.5.1), turns what would read as gratuitous vendoring into an obvious necessity.
--evict-older-thanagainst a start marker is a neat solution to a real problem — keeping the saved entry one build wide instead of letting it accrete towardCCACHE_MAXSIZE— andcp -R --preserve=timestampsfor the seed is the non-obvious detail that makes it work.- The PR description states the costs honestly, including that this PR's own CI is a cold run and that a header-wide change approaches the cold number. Reviewing against measurements rather than claims is what made the three findings above tractable.
test_shared_steps_are_identicalgenuinely enforces the Build/Build-LTTng lockstep the comments assert, so the two new steps cannot drift between the jobs.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
|
Lease: kkroo drive session 0f0c8bcc, 2026-10-05T09:1xZ, ~45m, at
Fast-forward push, no force. |
Ally's three Importants on 2cdb4e2: - The Dockerfile compile RUN now asserts `ccache --get-config direct_mode` is `false` next to `ccache -z`. Ignoring -DCONFDATE is safe only in preprocessor mode, and that was held by an ARG default three lines away. Checked on ubuntu 22.04 (ccache 4.5.1) and 24.04 (4.9.1): the assert passes with CCACHE_NODIRECT=1 and fails with the variable unset. ccache also rejects CCACHE_NODIRECT=0 as an invalid boolean, and the assert catches that too. - `Export ccache` gets `continue-on-error: true` in github-ci.yml and buildcache-seed.yml. A failed export can no longer turn master `Build` red after the image is pushed, or skip the shard seeds that follow it. - `Save ccache` requires the export's `nonempty` output. The export writes it only after a complete export that produced files, so an empty or half-written directory is never saved as the newest entry that every restore-keys prefix match returns. actionlint reports the same findings as on the parent commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@allyblockcast please review exact head This disposes of the three 08:57Z Importants on
actionlint reports the same 9 findings as on |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (applied directly; nested CLI not launched in the k8s Job runtime).
Reviewed head: b86224e
All three Importants from the previous head are fixed, and each fix is the one that was asked for rather than a restatement. The direct_mode assert in particular is fail-closed by construction: if a future ccache changed --get-config output, grep -qx false stops matching and the build fails loudly — it cannot degrade into silently permitting direct mode, which is the only way this guard could have been worth less than the comment it replaces.
Prior Findings Dispositioned (3)
- prior:2cdb4e2 important 1 — fixed —
docker/ubuntu-ci/Dockerfile:379— The compileRUNnow asserts the invariant where it is used:ccache --get-config direct_mode | grep -qx false ||→exit 1with a message namingCCACHE_IGNOREOPTIONS=-DCONFDATE=*and stale objects. It sits afterccache -z(:378) and before./bootstrap.sh, so it fires before any compile reads the cache, and the comment at:348-350now states both that the assert exists and thatCCACHE_NODIRECTis set-based rather than an on/off switch. Themake checkRUN(:411) carries no second copy, which is correct —CCACHE_NODIRECTis a stage-scopedARG(:365), so bothRUNs see one value and one assert covers both. - prior:2cdb4e2 important 2 — fixed —
.github/workflows/github-ci.yml:471—Save ccacheis now gated onsteps.ccache-export.outputs.nonempty == 'true', and that output is written only inside theif [ -n "$(ls -A ...)" ]branch at:466, after the export build returned. Same pair inbuildcache-seed.yml:254/:249. The empty branch emits a::warning::instead, so the no-save is visible rather than silent. The composition with the fix below is the part that matters: under GitHub's defaultbash -e, a failed export exits before theGITHUB_OUTPUTwrite, sononemptyis unset andSaveis skipped — a half-finished export cannot become the newest entry that everyrestore-keysprefix match returns. - prior:2cdb4e2 important 3 — fixed —
.github/workflows/github-ci.yml:454—Export ccachecarriescontinue-on-error: true, andbuildcache-seed.yml:237likewise. The step keeps its position beforeClear any previous results, which is now harmless: a transient export failure no longer fails the job after the image is built and pushed, so the shard-seed uploads thatTestdepends on still run. This restores the symmetry the restore step asserts atgithub-ci.yml:365— a miss is never a failure — on the write side.
Critical Issues (0)
None.
Important Issues (0)
None.
Suggestions (3)
- [pr-review-toolkit:comments]
.github/workflows/github-ci.yml:522— The comment now reads "a COPY of Build's first seven", butBuild-LTTnghas eight steps (Checkout,Ensure local MIB cache directory exists,Restore cached MIB files,Ensure ccache seed directory exists,Restore ccache,Set up Docker Buildx,Log in to Harbor,Build docker image (cached)), and they are a prefix of Build's fourteen. The count was already off by one before this PR ("five" against six), so incrementing it by two preserved the error rather than introducing it — buttest_buildcache_freshness.py:476still says "first five", so the two statements now disagree with each other as well as with the code. The assertions are prefix-based and count-agnostic (list(build)[: len(names)]), so nothing breaks; this is only the number a reader checks against. - [pr-review-toolkit:code]
.github/workflows/github-ci.yml:455,.github/workflows/buildcache-seed.yml:238— The newrun:blocks do not harden the shell. GitHub's defaultbash -egives errexit — which thenonemptyguard above correctly relies on — but notnounsetorpipefail; elsewhere in this workflow the repo opts into all three explicitly. Carried from the previous head; you noted it is deliberately deferred. - [gstack/review]
docker/ubuntu-ci/Dockerfile:370— The cache mountid=frr-ccacheis still not keyed by Ubuntu release or LTTng flag. Correct exactly as long as each matrix leg gets its own builder, whicharc-dindplus a per-jobsetup-buildx-actiongives you today. Worth one line of comment recording that assumption next to the mount, since two legs sharing a daemon would mix 22.04 and 24.04 entries, serialize onsharing=locked, and have each leg's--evict-older-thandelete the other's work. Also carried and deliberately deferred.
Strengths
- The three fixes compose rather than merely coexisting:
continue-on-erroralone would have let a half-written directory be saved, and thenonemptyguard alone would have left the job red. Fixing them as a pair, and saying so in the comment atgithub-ci.yml:448-451, is what makes the write side genuinely optional. - The assert is fail-closed.
ccache --get-config direct_mode | grep -qx falsecan only stop matching, never match while direct mode is on, so no future ccache version can turn this guard into a silent no-op — which is the right property for something standing in front of a stale-object failure that compiles and links cleanly. - Documenting that
CCACHE_NODIRECT=0is rejected as an invalid boolean (Dockerfile:349-350) closes the secondary point honestly: it records that theARGis not a switch, instead of leaving a reader to assume it is. - The
Build-LTTnglockstep survived the change — I rantest_buildcache_freshness.pyagainst this head's three workflow files and all 85 tests pass, with the two new steps byte-identical betweenBuildandBuild-LTTngand still a prefix of Build's. - Deriving the eviction window from the
frr-build-startmarker degrades safely: if the marker is ever absent (compile layer cache-hit,make checkmiss), bash coerces the emptystatsubstitution to zero and the window becomes ~56 years, so the command evicts nothing rather than evicting everything just written. I checked this rather than assuming it; the failure direction is the harmless one.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Why
Every Build leg recompiles all of FRR.
FRR_SOURCE_REVchanges per commit, so the image's compile step (bootstrap, configure,make -j) and itsmake checkstep rerun from scratch even when a commit touches one file. On a 22.04 leg that is ~355-370 s of compile and ~265-280 s ofmake check, out of an 11-12 min leg, now that the dependency layers above them come from the registry cache (#134).What
docker/ubuntu-ci/Dockerfileinstalls ccache 4.14.1 (musl-static, sha256-pinned per arch, fetched like strace). Not Ubuntu's package:--enable-gcovcompiles everything with-fprofile-update=atomic, which ccache only accepts from 4.10. jammy's 4.5.1 and noble's 4.9.1 log "Unknown profiling option" and run the compiler uncached (1360 of ~1490 compiles on a 22.04 build). The first attempt at this used the distro package and saved nothing; that is how this was found.makeandmake checkrun with ccache first in PATH (/usr/local/lib/ccachemasquerade links). configure runs without it, so the image's Makefiles record plaingccand Unit-Test'smake checkfrom the image never touches ccache. configure through ccache cost 55 s on a 4-CPU builder.-DCONFDATE=*ignored. configure stamps today's date on every-devcompile line, which would make yesterday's cache useless. Ignoring it is only safe in preprocessor mode:bgpd/bgp_route.candzebra/zebra_cli.ctest#if CONFDATE > N(FRR's deprecated-code expiry), and their preprocessed output changes when the date crosses N, so they still miss. Direct mode would serve a stale object and hide the expiry.ccache-seedbuild context (empty by default, so a plaindocker buildstill works) and exported by accache-outtarget. Aftermake check, every entry this build did not hit or write is evicted (--evict-older-thansince a start marker; a hit refreshes the mtime, and the seed copy keeps its timestamps), so the saved cache is one build's worth: 258 MB on 22.04, unchanged from one build to the next.github-ci.yml: Build and Build-LTTng restore the cache from actions/cache (frr-ccache-v1-<platform>-<sha>, falling back to the newest entry for the platform) and pass it as the build context. Only master Build runs export and save it; pull requests only restore. Build-LTTng stays a step-prefix copy of Build (test_buildcache_freshness), so it never saves.buildcache-seed.yml: the seed's three legs restore and save the same way, which is what keepsamd64_u24_lttngfresh.Measurements
devbox,
docker-containerbuilders pinned to 4 CPUs / 16 GB like arc-dind, 22.04, cache injected through--build-contextexactly as CI does. Warm = cache from a build of master68f9499907, then a build of master plus a one-line change inbgpd/bgp_route.con a fresh builder. Warm and baseline ran at the same time on disjoint CPUs so both saw the same host load.make check~138 s per Build leg when the cache is warm. Eviction after the warm build removed exactly one entry, the edited file's old object.
Costs and limits
Testing
.github/scriptsunit tests: all modules pass, includingtest_buildcache_freshness(85 tests).test_require_ally_reviewwas not run to completion: it hangs on macOS on master as well, and this change does not touch it.check-runner-labels.pyOK; both workflow files parse as YAML.--build-context) builds; warm path verified as above..gcnofiles byte-identical on a hit (checked on FRR's own compile lines with--coverage -fprofile-update=atomic).🤖 Generated with Claude Code