Skip to content

[MINOR] Persist the Maven build cache between CI runs - #882

Open
slachiewicz wants to merge 9 commits into
apache:mainfrom
slachiewicz:ci-persist-build-cache
Open

[MINOR] Persist the Maven build cache between CI runs#882
slachiewicz wants to merge 9 commits into
apache:mainfrom
slachiewicz:ci-persist-build-cache

Conversation

@slachiewicz

@slachiewicz slachiewicz commented Aug 5, 2026

Copy link
Copy Markdown
Member

What is the purpose of the pull request

.mvn/extensions.xml enables maven-build-cache-extension, but the cache never survives a CI run, so it currently costs us the bookkeeping and buys nothing. Fixing that turned up two further problems with how CI caches Maven state, both addressed here.

Reviewing this commit-by-commit is easier than as a single diff.

Brief change log

1. Persist the build cacheactions/setup-java's cache: maven covers ~/.m2/repository; the extension writes to ~/.m2/build-cache, which nothing preserved. Every run logged Local build was not found by checksum for all ten modules and rebuilt everything, including for PRs touching only the website or a single leaf module. Only main writes an entry; PRs restore from it.

2. Keep the bundled jars out of that cache — measured, one generation is 1.2 GB, and 1.17 GB of it is three bundled jars:

module cache CI time
xtable-core 1.0 MB 17:42
xtable-service 80 KB 5:05
xtable-hudi-support-extensions 11 MB 34 s
xtable-utilities 1.0 GB 2:30
xtable-hive-metastore 125 MB 28 s
xtable-aws 42 MB 12 s

(times from run 31226195551 on main, 27:11 total)

The three shade modules are 97% of the size and 12% of the time; core and service are 84% of the time and about 1 MB. Saving 1.2 GB per merge into a repository-wide 10 GB budget would evict the very caches it sits beside. They opt out with the extension's per-project property, which disables both restore and save for that module.

Also in this commit: .mvn/maven-build-cache-config.xml (maxBuildsCached=1, remote cache off, strict surefire reconciliation — command-line flags are not part of a module's checksum, so without it a build that skipped tests and one that ran them are indistinguishable); extension 1.1.01.2.3 for two invalidation fixes, MBUILDCACHE-87 and MBUILDCACHE-99; a guard that skips the save and warns above 100 MB; -Dmaven.build.cache.enabled=false on the release publish, since versions:set runs immediately beforehand and cache restoration is version-agnostic; the JDK in the cache key, as the extension does not hash it; and timeout-minutes 30 → 40, since a cold build is already 27:11 against it.

3 and 4. One writer for ~/.m2/repository — both jobs used cache: maven, which restores on an exact key match only; setup-java sets no restore-keys deliberately (#269), so any pom change made every job re-download the whole dependency tree. Same key in both jobs also meant whichever finished first reserved it and the other's save failed — License Check takes ~2 min against the build's 27, so the build logged Unable to reserve cache with key on every run.

That outcome is the right one, so this makes it explicit rather than racing for it: License Check must resolve every dependency to inspect each jar's license, and it stops at package, so it populates exactly what the other jobs need without ever putting this project's own artifacts in the entry. It is now the sole writer; Maven CI Build restores read-only. A prefix fallback warms from the previous entry after a pom change, and both jobs clear ~/.m2/repository/org/apache/xtable before the post-job save, as Source Build Check already does.

Verify this pull request

Build/CI infrastructure only; no production code touched.

Verified locally on Temurin 11 that the build-cache change does what it claims:

cold warm
Result BUILD SUCCESS BUILD SUCCESS
~/.m2/build-cache 12 MB (was 1.2 GB) 12 MB
Modules restored 0 7 of 7 cacheable
Cache is explicitly disabled on project level 3 modules 3 modules

buildinfo.xml confirms the four surefire flags recorded as tracked="true".

Two notes for reviewers. The extension bump renames the cache directory v1v1.1, and this PR touches poms and extensions.xml, which rotates the repository cache key — so the first run after merge is cold on both counts, once. And a cache-restored xtable-service does not reproduce target/quarkus-app; nothing consumes that directory today, and there is a comment in the workflow saying so.

The saving depends on what a PR touches: a change to xtable-core still rebuilds and retests core and everything downstream, while a website, workflow, or docs-only change should hit on everything cacheable.

Comment thread .github/workflows/mvn-ci-build.yml
@slachiewicz
slachiewicz force-pushed the ci-persist-build-cache branch from 5b1c12d to 1d7434f Compare August 9, 2026 22:30
.mvn/extensions.xml enables maven-build-cache-extension, but the cache
never survives a CI run: actions/setup-java's `cache: maven` persists
~/.m2/repository only, while the extension writes to ~/.m2/build-cache.

The effect is visible in any recent build log - ten modules, ten
"Local build was not found by checksum" lines, zero restores - so every
run rebuilds and retests all ten modules from scratch, including for
pull requests that touch only the website or a single module.

Restore ~/.m2/build-cache before the build and save it afterwards. Only
main writes an entry; pull requests restore from main's rather than each
branch consuming the repository-wide 10 GB cache budget.

Source Build Check is deliberately left alone - it passes
-Dmaven.build.cache.enabled=false and skips `cache: maven` on purpose,
to prove the source release builds without prebuilt artifacts.
Persisting ~/.m2/build-cache as the previous commit does is only useful
if the entry is small enough to live alongside the ~/.m2/repository
caches in the repository-wide 10 GB budget. Measured, it is not: one
generation is 1.2 GB, and 1.17 GB of that is three bundled jars -
xtable-utilities (1.0 GB), xtable-hive-metastore (125 MB) and
xtable-aws (42 MB).

Those three modules are worth about 3 minutes of a 27 minute build.
xtable-core and xtable-service are 84% of the time and about 1 MB of
cache between them. Caching is worth it in inverse proportion to
artifact size here, so opt the bundle producers out with the
extension's per-project property, which disables both restore and save
for that module. A cold build then writes 12 MB instead of 1.2 GB while
still caching everything expensive; measured locally, a fully warm run
restores 7 of 7 remaining modules.

Also in this commit:

- Add .mvn/maven-build-cache-config.xml pinning maxBuildsCached to 1,
  disabling the remote cache explicitly, and reconciling the surefire
  skip flags. Command line flags are not part of a module's checksum,
  so without reconciliation a build that skipped tests and one that ran
  them are indistinguishable to the cache. The flags are tracked
  without a skipValue attribute deliberately: skipValue relaxes the
  comparison rather than tightening it.

- Bump maven-build-cache-extension 1.1.0 -> 1.2.3. Two invalidation
  fixes land in 1.2.1: MBUILDCACHE-87, plugin dependencies missing from
  the checksum, and MBUILDCACHE-99, moved files not always detected.
  Both affect whether a restore is correct, so they matter more once
  the cache actually persists.

- Guard the save step on the cache directory staying under 100 MB, and
  warn when it does not, so a future module that starts caching large
  artifacts is visible rather than silently churning the budget.

- Pass -Dmaven.build.cache.enabled=false when publishing a release.
  versions:set rewrites the version immediately beforehand, while cache
  restoration is version-agnostic. That runner keeps no cache directory
  today, so this is a guard against it gaining one, and it matches what
  source-build-check.yml already does deliberately.

- Add the JDK to the Actions cache key, since the extension does not
  hash the JDK, and raise the job timeout from 30 to 40 minutes - a
  cold build is already 27 minutes against it, independent of caching.

- Teach the spotless license-header delimiter about <cache, the root
  element of the new config file, alongside the <extensions entry that
  is already there for .mvn/extensions.xml.
…aved

`mvn install` puts xtable's own artifacts into ~/.m2/repository, and that
includes the bundled jars - about 1.2 GB, of which xtable-utilities alone
is roughly 1 GB. actions/setup-java caches that directory wholesale and
offers no way to exclude a path, so remove them in a final step, which
runs before its post-save hook.

They are rebuilt from source on every run, so they can never produce a
useful cache hit, and a stale SNAPSHOT sitting in the local repository
can shadow a reactor build.

This is a guard rather than a fix. License Check currently reserves the
shared cache key first - it downloads the whole dependency tree to
inspect each jar's license, which is the useful half - and because it
only runs `package`, the saved entry has never contained this project's
artifacts. That is why this job logs "Unable to reserve cache with key"
at the end of every run. The arrangement is the one we want, but it
holds by ordering rather than by construction: if License Check ever
fails or is cancelled before its post step, this job would win the key
and save roughly 2.15 GB into a repository-wide 10 GB budget.
Both jobs used setup-java's `cache: maven`, which has two problems here.

It restores on an exact key match only. setup-java sets no restore-keys
deliberately, "to start with a clear cache after dependency update"
(actions/setup-java#269), so any change to a pom or to extensions.xml
made every job re-download the entire dependency tree from Central.
Keying the same way but adding a prefix fallback warms from the previous
entry and downloads only the delta. This commit changes both files, so
it pays that cost once itself.

It also gave the two jobs the same key, and cache entries are immutable,
so whichever finished first reserved it and the other's save failed.
License Check finishes in about two minutes and the full build takes 27,
so in practice License Check always won and Maven CI Build logged
"Unable to reserve cache with key" on every run.

That outcome was the right one, which is why this makes it explicit
rather than trying to win the race. License Check has to resolve every
dependency in order to inspect each jar's license, so it populates
exactly what the other jobs need, and it stops at `package`, so its
entry never contains this project's own artifacts. It is now the sole
writer; Maven CI Build restores the same key read-only and no longer
attempts a save.

The wrapper distribution was cached separately by setup-java, keyed on
the wrapper properties so it survives pom changes. That is reproduced
here rather than lost.

Both jobs also clear ~/.m2/repository/org/apache/xtable before the
post-job save, as Source Build Check already does. On the writer that
guards the invariant above; on the reader it is belt and braces.
@slachiewicz
slachiewicz force-pushed the ci-persist-build-cache branch from c4290e2 to 4972ec8 Compare August 9, 2026 23:47
The entry measures 981 MB against a repository-wide 10 GB budget, so a
handful of concurrent pull requests with differing pom hashes would
crowd out the entry they all restore from. Split restore and save so
only main writes, matching what this branch already does for the build
cache. Pull requests warm from main through the prefix fallback and
re-download only their own delta.

Saving is also conditional on the exact key having missed. Cache entries
are immutable, so writing over an existing key just fails; that check is
what actions/cache performs internally when restore and save are not
split.

The wrapper cache is left unsplit. At ~9 MB it is not worth gating, and
it is keyed on the wrapper properties, which rarely change.
run is the one skipping, which is exactly the exemption we do not
want on a release build.
-->
<plugin artifactId="maven-surefire-plugin" goal="test">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a similar config for the failsafe plugin?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — and it turned out to matter more than the question implied. Two things came out of checking, both pushed in 3414da6.

Failsafe itself. It is bound in the parent POM to integration-test and verify with <skip>${skipITs}</skip>, and CI runs install, so integration tests do run on every build and a restored module skips them exactly as it skips unit tests. Added, one entry per goal — reconciles are matched per goal, and the two goals do not expose the same parameters (testFailureIgnore is on verify only; naming a parameter a goal does not have earns a warning on every build, which I checked against help:describe for failsafe 3.3.0 rather than guessing).

The one that actually bites. Writing the failsafe entries made me test the surefire block properly instead of assuming it, and it does not cover -Dmaven.test.skip=true. The parent POM pins <skip>${skipUTs}</skip>, and explicit plugin configuration wins over the user property, so surefire's skip parameter reads identically whether the flag is there or not — and the module checksum comes out byte-identical. What the flag actually suppresses is test compilation.

On xtable-api, Temurin 11, before the fix:

$ ./mvnw clean install -pl xtable-api -Dmaven.test.skip=true   # 0 tests run, entry saved
$ ./mvnw clean install -pl xtable-api
[INFO] Found cached build, restoring org.apache.xtable:xtable-api from cache by checksum 70d4788128782ec7

A plain install restoring a build whose tests never ran, reporting success. After adding a maven-compiler-plugin:testCompile reconcile on skip:

[INFO] Plugin parameter mismatch found. Parameter: skip, expected: true, actual: false
[INFO] Mojo cached parameters mismatch with actual, forcing full project build. Mojo: compiler:testCompile
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 - in org.apache.xtable.spi.extractor.TestExtractFromSource
...

Plain-to-plain still restores, so the guard costs nothing in CI. This is a local-developer hazard rather than a CI one today — CI writes the cache only from the plain install on main, and the release job disables the cache outright — but it is precisely the failure mode the reconcile block exists to prevent, and it was open.

buildinfo.xml now records all nine failsafe parameters as tracked="true" with no Cannot find a Mojo parameter warnings.

Failsafe is bound in the parent POM to integration-test and verify, and
CI runs `install`, so integration tests run on every build and a
restored module skips them exactly as it skips unit tests. Reconciles
are matched per goal, and the two goals expose different parameters:
testFailureIgnore is on verify only, and naming a parameter a goal does
not have earns a warning on every build.

The compiler entry closes a hole the surefire block does not reach.
-Dmaven.test.skip=true never gets to surefire here, because the parent
POM pins <skip>${skipUTs}</skip> and explicit plugin configuration wins
over the user property, so surefire's skip parameter reads identically
either way and the module checksum is byte-identical across the two.
What the flag actually suppresses is test compilation.

Verified on xtable-api, Temurin 11: seeding the cache with
-Dmaven.test.skip=true and then running a plain `clean install`
restored the untested build before this commit, and after it logs
"Plugin parameter mismatch found. Parameter: skip, expected: true,
actual: false" and runs the tests. A plain build still restores a plain
build, and buildinfo.xml records all nine failsafe parameters as
tracked="true" with no "Cannot find a Mojo parameter" warnings.
The JDK is in these keys because the build cache extension does not hash
it, and the two workflows have to agree on the key or the reader stops
finding what the writer saved. Four literal "jdk11" strings across two
files could not express that; bumping actions/setup-java's java-version
would have left them asserting a JDK that is no longer in use.
"License Check has to resolve every dependency in order to inspect each
jar's license" is not true, and the previous commit made it load-
bearing. apache-rat:check declares no dependency resolution;
dependency:tree collects poms rather than jars; and the shaded-bundle
step names three modules and reaches only their -am closure. -am makes
dependencies, not dependents, so the two leaves are outside it: nothing
in the job resolves xtable-service's Quarkus stack, xtable-utilities'
bundled dependencies, or maven-install-plugin, which only an `install`
pulls.

Maven CI Build is restore-only by construction, so it cannot fill that
gap itself - it would re-download the delta from Central on every run,
permanently. dependency:go-offline over the full reactor covers what the
license steps do not. It runs only on main, where the save happens, and
its failure must not fail a license check: an incomplete entry costs
download time on the next run and nothing else.

Verified: `./mvnw -B -ntp -q dependency:go-offline
-Dmaven.build.cache.enabled=false` exits 0 across the reactor.
The save gated on `size_mb < 100` while the warning fired above 100, so
a directory of exactly 100 MB - reachable, du -sm rounds up - was
skipped silently, the one outcome the guard exists to make visible. It
also treated the `${size_mb:-0}` fallback for a missing directory as a
pass, handing actions/cache/save a path that is not there.

Saving is now also conditional on the exact key having missed on
restore. Cache entries are immutable, so a re-run of a main commit would
try to write over its own entry and log "Unable to reserve cache with
key" - the message the earlier commits removed by giving the two jobs
separate keys. The repository cache save already carries this check.
@slachiewicz

Copy link
Copy Markdown
Member Author

Pushed four commits after a self-review pass. One of them corrects a claim I made in this PR's own description, so flagging it rather than burying it in a force-push.

38ffe62 — the sole-writer rationale was wrong. I wrote that License Check "must resolve every dependency to inspect each jar's license, so it populates exactly what the other jobs need". It does not. apache-rat:check declares no dependency resolution, dependency:tree collects poms rather than jars, and the shaded-bundle step names three modules and reaches only their -am closure — and -am makes dependencies, not dependents. xtable-utilities and xtable-service are leaves, so nothing in that job resolves the Quarkus stack, xtable-utilities' bundled dependencies, or maven-install-plugin. Since Maven CI Build is restore-only by construction, it could never have filled the gap: it would have re-downloaded that delta from Central on every run, permanently. A dependency:go-offline step over the full reactor, on main only, covers what the license steps do not.

3414da6@the-other-tim-brown's failsafe question, answered in the thread. Short version: yes, failsafe needed it, and testing the surefire block instead of assuming it found that -Dmaven.test.skip=true restores a cached build whose tests never ran. Reproduced and then closed with a compiler testCompile reconcile.

95dad45 — the size guard did not cover its own boundary. Save was gated on < 100, the warning fired above 100, so exactly 100 MB (reachable; du -sm rounds up) was skipped in silence — the one outcome the guard exists to surface. The save is also now conditional on the restore having missed, so re-running a main commit stops writing over its own immutable entry and logging the "Unable to reserve cache with key" these commits removed elsewhere.

16035f4jdk11 was a literal in four cache keys across two files that have to agree on the key; now one JDK_VERSION.

Verification, all on Temurin 11: the before/after and negative control for the reconcile are in the thread; dependency:go-offline exits 0 across the reactor; spotless:check passes; both workflows parse.

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.

3 participants