ci: write large actions/cache entries only on push to main - #5973
Conversation
sunchao
left a comment
There was a problem hiding this comment.
Correctness
Reviewed b40cf670 against 4c2ab968. The nine-file change affects CI cache policy and its checker. It does not change Spark expressions, operators, native code or dependency versions. I found no P1/P2 issues.
All ten converted Maven caches retain their restore paths and primary keys. Their explicit saves use matching paths and keys, skip exact hits and remain disabled on PR and merge-queue refs. Cold and prefix-hit cases still run the build and resolve missing dependencies. The java-test action preserves its precomputed hash and macOS exclusion. The added java-maven- fallbacks connect the benchmark and Spark SQL readers to the existing TPC-H/TPC-DS writers on main. I checked the upstream restore implementation, save implementation and cache-version calculation across the action versions used here.
The rule is main-ref-only rather than strictly push-event-only. A manual dispatch on main can also save. The existing snapshot schedule/manual exception remains. The Rust Maven save runs after common compilation but before Rust tests, and the Spark builder save precedes its callers' later tests. These do not preserve whole-job-success timing exactly, but they save an already populated dependency repository. I found no invalid-cache consequence. The two registry-only restores lose their writers, but their Java callers use the downloaded native library and skip Cargo.
Local validation passed the repository CI checker, verified all restore/save bindings and rejected 22 in-memory mutations. A bounded model passed 720 combinations of ref, event, OS, cache result and success state. These are configuration checks, not cache-service execution.
Current CI executed merge f393aab9, whose parents are exactly this base and head and whose whole tree equals the reviewed head. The inspected logs confirm successful preflight/actionlint checks, cold Maven restores and skipped PR saves. At September 15, 22:53 UTC, there are 24 successful checks and 13 skipped, with all four current workflows complete and successful. Push-to-main saves and the new cross-prefix cache hits have not been exercised by this PR run.
Performance
Restricting large-cache writes removes the PR/queue upload and storage duplication at these sites while preserving main's Linux Cargo and shared Maven writers. Queue-only Maven jobs trade their dedicated entries for a shared base plus downloads of profile-specific dependencies. The TPC dataset caches and small Maven-distribution caches retain their existing behavior.
That is a plausible reduction in cache pressure, not a measured speedup from this revision. Same-PR reruns can reuse PR-scoped caches, so removing those writes has a real trade-off. The reported historical timings do not establish that cache state alone caused the difference. Actual hit rates, retained bytes and end-to-end savings still need a main writer followed by representative readers. GitHub cache scoping and key matching support the sharing mechanism.
Design
Separating restore and save is a direct way to restrict writers without suppressing reads. The existing push tier still reaches the Linux Cargo, Rust Maven and shared Java Maven producers. TPC-H and TPC-DS intentionally share one immutable Maven key, so a same-key save race does not require both uploads to succeed. Missing or older caches remain build inputs to refresh, rather than prerequisites for correctness. No additional coordination layer is needed.
Abstraction & complexity
The new check follows the existing dependency-free configuration-checking approach. It catches bare large-cache actions and missing main-ref guards in the current workflow forms. It scans text rather than evaluating arbitrary GitHub expressions, and it does not itself prove restore/save pairing. I checked those pairs separately against parsed workflow structures. Within this change's scope, I found no additional abstraction or simplification that needs to block merging.
An actions/cache entry is scoped to the ref that wrote it: a run restores from its own ref and from the default branch, and nothing else. So an entry written from refs/pull/*/merge is visible only to another run of the same pull request, and one written from the merge queue's gh-readonly-queue/* branch is visible to nobody, because the queue deletes that branch when it is finished with it. Both still consume the repository's shared cache budget, which is evicted least-recently-used. On 2026-09-15 the repository held 12.27 GiB across 14 entries: 9.22 GiB on one gh-readonly-queue/* branch, 3.01 GiB on refs/pull/*/merge, and nothing at all on main. Seven near-identical Linux-java-maven-* Maven repositories accounted for 9.06 GiB of it, every one of them a write-only copy. With main's cargo-ci entry evicted, all eight native builds in merge_group run 34977717057 missed their cache and paid a cold compile of 20 to 28 minutes each, 259 runner-minutes in a single run. The same build on the push run two hours later restored in 56s and compiled in 2m21s. The two cargo saves in pr_build_linux.yml carried `if: github.ref == 'refs/heads/main'` when this was written. #5963 has since replaced that guard with `if: github.event_name == 'push'`, which also excludes the scheduled run at the same sha, so both conditions are now spelled out together and check_cache_save_scope can see the ref half. Hold every other large cache to the same rule: a cache of ~/.m2/repository or of a cargo tree is restored everywhere and saved only on push to main. The bare actions/cache@vN form cannot express that, since it saves in an implicit post step no `if:` can reach, so those sites split into actions/cache/restore plus a guarded actions/cache/save. The TPC-H and TPC-DS datasets keep the read-write form: they are a few hundred MB, they are keyed on the workflow file, and a pull request editing that file would otherwise regenerate them on every run. publish_snapshot.yml keeps it too, since it runs from main on a schedule already. check_cache_save_scope in dev/ci/check-ci-config.py pins both halves, and rejects a new bare actions/cache@vN on any of those paths. Six mutations were used to confirm it fails: reverting a site to the read-write form, dropping the github.ref guard from each of four saves, and adding a fresh unguarded cargo cache. pyarrow_udf_test.yml now lists itself in its paths filter. It sits outside the ci.yml umbrella, so compute-changes.py does not route it, and an edit to the file merged without the workflow ever running -- including the cache step changed here.
b40cf67 to
cdc9a5d
Compare
sunchao
left a comment
There was a problem hiding this comment.
Rechecked cdc9a5df against previously approved b40cf670 and base 8c229a70. The original cache changes remain intact after the rebase. The two Linux Cargo saves now require both a push event and the main ref. PR and merge-queue writes remain disabled. Eligible nightly and manual runs on main can still populate the Maven caches, whose guards remain main-ref-only. No new or remaining P1/P2 finding.
The CI configuration checker passes. Validation verified the ten restore/save pairs, rejected 23 in-memory mutations, and passed 868 bounded guard cases, including the new nightly routing. These are configuration checks, not cache-service execution.
Current CI uses merge aa3ef600, whose whole tree equals the reviewed head. Inspected preflight/actionlint, Spark 4.1 compilation and Spark 3.5 lint jobs passed. Their Maven restores missed and PR saves were skipped. At September 16, 15:43 UTC, 14 checks are successful, 13 skipped and 2 in progress. The pipeline is still running. Main-writer/fallback-hit behavior and end-to-end savings remain unmeasured.
Which issue does this PR close?
None; filed directly.
Rationale for this change
An
actions/cacheentry is scoped to the ref that wrote it. A run restoresfrom its own ref and from the default branch, and nothing else. So an entry
written from
refs/pull/*/mergeis visible only to another run of that samepull request, and one written from the merge queue's
gh-readonly-queue/*branch is visible to nobody at all, because the queue deletes that branch when
it is finished with it. Both still count against the repository's shared cache
budget, which is evicted least-recently-used.
That is where the budget has been going. On 2026-09-15 the repository held
12.27 GiB across 14 entries:
gh-readonly-queue/*refs/pull/*/mergerefs/heads/mainSeven near-identical
Linux-java-maven-*Maven repositories accounted for9.06 GiB of it (
lint,spark-4.1-build,celeborn-0.6.0,celeborn-0.7.0,pyarrow-udf-4.0.4,pyarrow-udf-4.1.3,pyarrow-udf-4.2.0), every one awrite-only copy that nothing would ever restore. Nothing at all was on main,
which is the only scope a future pull request can read.
The cost lands on the native build. In merge_group run
34977717057
every one of the ten
Restore Cargo cachesteps completed in 1 second -- astraight miss -- and the eight Linux
cargo build --profile cijobs then paida cold compile of 20 to 28 minutes each, 259 runner-minutes in a single run.
Two hours later the push-to-main run
34992959611
restored in 56s and compiled in 2m21s. Same build, same profile, 10x apart
on nothing but cache state.
Note this is orthogonal to sharing one native build across callers (#5841) and
to moving suites nightly: those reduce how many cold builds there are and how
often they run, this one decides whether a build is cold at all. It matters
more, not less, once #5841 lands, because a single shared native build sits on
the critical path of every Spark and Iceberg job.
What changes are included in this PR?
and saved only on push to main, matching the
if: github.ref == 'refs/heads/main'guard the cargo caches already carried. The bareactions/cache@vNform cannot express that -- its save runs in an implicitpost step no
if:can reach -- so those sites split intoactions/cache/restoreplus a guardedactions/cache/save. Placementpreserves today's semantics: the save is the last step, so a red job does
not write, exactly as the implicit post step behaved.
benchmark-maven-andspark-sql-gainjava-maven-as a secondrestore-keysentry. Both jobs are queue-only, so nothing writes their ownprefix any more;
java-maven-is what the TPC-H/TPC-DS jobs write on pushto main, and a Maven repository is always safe to start from a superset or a
subset of itself.
check_cache_save_scopeindev/ci/check-ci-config.pypins both halves andrejects a new bare
actions/cache@vNon any of those paths. Its pathmatching is on substrings rather than
~/.cargo/...literals, becausepublish_snapshot.ymlwrites${{ env.CARGO_HOME }}/registry, which aliteral would have missed.
scope:
./tpchand./tpcds-sf-1are a few hundred MB, are not dependencytrees, and are keyed on the workflow file, so a pull request editing that
file would regenerate the data on every run rather than once.
publish_snapshot.ymlis exempt inCACHE_SAVE_SCOPE_EXEMPT, since it runsfrom main on a schedule already.
The expected trade-off, stated plainly: jobs that only ever run on a pull
request or in the queue no longer get a dedicated cache entry. They restore
main's shared
java-mavenbase throughrestore-keysand download theirprofile delta each run, which is what a cold pull request already did. I
expect that to cost a minute or two per job against the ~184 runner-minutes
recovered on the native builds, but I have not measured the delta -- the first
queue run after this lands will show it.
How are these changes tested?
Rebased onto
4c2ab9686. #5939 landed mid-review and movedpyarrow_udf_test.ymlunder theci.ymlumbrella; the conflict is resolvedin favour of that form, and the
pyarrow_udfentry inFILTERSalready liststhe workflow file, so the routing gap I had patched separately is already
closed.
Passing locally on this revision:
python3 dev/ci/check-ci-config.py, including the new check. The checkerwalks 17 large-cache sites across every workflow and composite action.
check-suites.py,check-benchmark-runner.py,test-iceberg-shards.py,node --test dev/ci/pr-type-label.test.mjs,actionlint --shellcheck=off,and
prettier --checkon the edited README.savehas a matchingrestorein the same job,that no job declares the
maven-cachestep id twice, and that every savereferencing
steps.maven-cache.outputshas that id declared.check_cache_save_scopewas mutation-tested eight ways, each expected to failand each observed to fail with the right message: reverting a site to the
read-write form; dropping the
github.refguard from thejava-test,setup-spark-builder,rust-testandpr_build_linuxsaves; adding a freshunguarded cargo cache to a workflow; and removing each of the two
CACHE_SAVE_SCOPE_EXEMPTentries, which confirmed both are live rather thandead config. Two further exempt entries for the TPC datasets turned out to be
dead -- their paths were never in scope -- and were dropped.
Not verified locally: the actual cache hit rate after this lands. That needs a
push to main to write the first entries and a queue run afterwards to read
them. I will check
actions/cache/usageand theRestore Cargo cachestepdurations on the first few runs and report back on this PR.