You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by ag...@apache.org on 2026/09/15 21:37:44 UTC
(datafusion-comet) 01/01: ci: write large actions/cache entries only on push to main
This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch ci/cache-save-scope
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
commit b40cf670c8aae87f9dcbbb3a1980f29b6a41362a
Author: Andy Grove <ag...@apache.org>
AuthorDate: Tue Sep 15 12:31:36 2026 -0600
ci: write large actions/cache entries only on push to main
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 cargo caches already carry `if: github.ref == 'refs/heads/main'` on
their save. 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.
---
.github/actions/java-test/action.yaml | 18 +++-
.github/actions/rust-test/action.yaml | 18 +++-
.github/actions/setup-spark-builder/action.yaml | 25 ++++-
.github/workflows/README.md | 67 ++++++++++++
.github/workflows/pr_benchmark_check.yml | 23 ++++-
.github/workflows/pr_build_linux.yml | 94 ++++++++++++++---
.github/workflows/pr_build_macos.yml | 4 +-
.github/workflows/pyarrow_udf_test.yml | 18 +++-
dev/ci/check-ci-config.py | 129 +++++++++++++++++++++++-
9 files changed, 371 insertions(+), 25 deletions(-)
diff --git a/.github/actions/java-test/action.yaml b/.github/actions/java-test/action.yaml
index 42078b3c44..d4cc384a93 100644
--- a/.github/actions/java-test/action.yaml
+++ b/.github/actions/java-test/action.yaml
@@ -62,12 +62,13 @@ runs:
shell: bash
run: echo "hash=${{ hashFiles('**/pom.xml') }}" >> "$GITHUB_OUTPUT"
- - name: Cache Maven dependencies
+ - name: Restore Maven dependencies
# TODO: remove next line after working again
# temporarily work around https://github.com/actions/runner-images/issues/13341
# by disabling caching for macOS
if: ${{ runner.os != 'macOS' }}
- uses: actions/cache@v5
+ id: maven-cache
+ uses: actions/cache/restore@v5
with:
path: |
~/.m2/repository
@@ -176,3 +177,16 @@ runs:
if-no-files-found: ignore
retention-days: 7 # 1 week for test reports
overwrite: true
+
+ # Saved only on main, and only on a green run: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and it
+ # evicts main's from the shared budget. See "Large caches are written on
+ # main only" in ../../workflows/README.md.
+ - name: Save Maven dependencies
+ if: ${{ runner.os != 'macOS' && github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v5
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ steps.maven-cache-key.outputs.hash }}
diff --git a/.github/actions/rust-test/action.yaml b/.github/actions/rust-test/action.yaml
index c39c2dcd4f..2d1fb0bb9a 100644
--- a/.github/actions/rust-test/action.yaml
+++ b/.github/actions/rust-test/action.yaml
@@ -41,8 +41,9 @@ runs:
cd native
cargo install cargo-machete --version 0.7.0 && cargo machete
- - name: Cache Maven dependencies
- uses: actions/cache@v4
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v4
with:
path: |
~/.m2/repository
@@ -57,6 +58,19 @@ runs:
cd common
../mvnw -B clean compile -DskipTests
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and it
+ # evicts main's from the shared budget. See "Large caches are written on
+ # main only" in ../../workflows/README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-rust-maven-${{ hashFiles('**/pom.xml') }}
+
- name: Install nextest
shell: bash
run: |
diff --git a/.github/actions/setup-spark-builder/action.yaml b/.github/actions/setup-spark-builder/action.yaml
index 84804c0a79..40f3a1e4a8 100644
--- a/.github/actions/setup-spark-builder/action.yaml
+++ b/.github/actions/setup-spark-builder/action.yaml
@@ -51,15 +51,21 @@ runs:
cd apache-spark
git apply ../dev/diffs/${{inputs.spark-version}}.diff
- - name: Cache Maven dependencies
- uses: actions/cache@v4
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v4
with:
path: |
~/.m2/repository
/root/.m2/repository
key: ${{ runner.os }}-spark-sql-${{ hashFiles('common/**/pom.xml', 'spark/**/pom.xml') }}
+ # No Spark SQL workflow has a push-to-main tier, so nothing writes
+ # `spark-sql-` any more (see the save step below). `java-maven-` is the
+ # entry the TPC-H/TPC-DS jobs write on push to main; a Maven repository
+ # is always safe to start from a superset or a subset of itself.
restore-keys: |
${{ runner.os }}-spark-sql-
+ ${{ runner.os }}-java-maven-
- name: Build Comet (with native)
if: ${{ inputs.skip-native-build != 'true' }}
@@ -93,3 +99,18 @@ runs:
"$(dirname "$pom")/_remote.repositories"
done
done
+
+ # No Spark SQL workflow has a push-to-main tier today, so in practice this
+ # never fires. The guard is here so the step starts writing if one is ever
+ # added, and never writes from a pull request or a `gh-readonly-queue/*`
+ # branch, where the entry cannot be restored by any later run and only
+ # evicts main's from the shared budget. See "Large caches are written on
+ # main only" in ../../workflows/README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-spark-sql-${{ hashFiles('common/**/pom.xml', 'spark/**/pom.xml') }}
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 93e2b0374d..2dc8cd782f 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -282,6 +282,73 @@ than once carries its version inputs, e.g.
and also that every `download-artifact` name is produced by an upload in the
same workflow.
+## Large caches are written on main only
+
+An `actions/cache` entry is scoped to the ref that wrote it. A run can restore
+entries from its own ref and from the default branch, and nothing else. So a
+cache written from `refs/pull/*/merge` is visible only to another run of that
+same pull 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 done with it.
+
+Both still count against the repository's shared cache budget, which is
+evicted least-recently-used. Writing them therefore has no upside and one
+large downside: it pushes main's entries out, and main's entries are the only
+ones a future pull request can use.
+
+That is what happened. On 2026-09-15 the repository held 12.27 GiB across 14
+entries: 9.22 GiB on a single `gh-readonly-queue/*` branch, 3.01 GiB on
+`refs/pull/*/merge` refs, and nothing whatsoever on main. Every one of the
+seven near-identical `Linux-java-maven-*` Maven repositories was a
+write-only copy. With main's `cargo-ci` entry evicted, all eight native
+builds in a merge-queue run missed their cache and paid a cold ~26 minute
+compile; the same build with a hit takes 2m21s.
+
+The rule, then: any cache holding a Maven repository (`~/.m2/repository`) or a
+cargo tree (`~/.cargo/registry`, `~/.cargo/git`, `native/target`) is
+**restored everywhere and saved only on push to main**:
+
+```yaml
+- name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint
+ restore-keys: |
+ ${{ runner.os }}-java-maven-
+
+# ... the steps that populate it ...
+
+- name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint
+```
+
+The bare `actions/cache@vN` form cannot express this: it saves in an implicit
+post step that no `if:` can reach. `dev/ci/check-ci-config.py` rejects it for
+any of the paths above, and rejects a `save` that is missing the `github.ref`
+guard. `publish_snapshot.yml` is exempt in `CACHE_SAVE_SCOPE_EXEMPT`, because
+it runs from main on a schedule already.
+
+The TPC-H and TPC-DS dataset caches keep the read-write form and are out of
+scope entirely: `./tpch` and `./tpcds-sf-1` are a few hundred MB, they are not
+dependency trees, and they are keyed on this workflow file, so a pull request
+that edits it would regenerate the data on every run rather than once.
+
+A job that only ever runs on a pull request or in the queue keeps the guard
+anyway, and so never writes. That is deliberate — it restores from main's
+entry through `restore-keys` and downloads whatever else it needs, which is
+what a cold pull request already did. See the push-tier discussion above for
+which jobs do run on main and therefore do write.
+
## Retrying flaky network operations
**Maven.** `.mvn/maven.config` tunes the Maven Resolver HTTP transport: six
diff --git a/.github/workflows/pr_benchmark_check.yml b/.github/workflows/pr_benchmark_check.yml
index 933714d9d7..bc2559a57f 100644
--- a/.github/workflows/pr_benchmark_check.yml
+++ b/.github/workflows/pr_benchmark_check.yml
@@ -59,15 +59,21 @@ jobs:
cd native
cargo check --benches
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
/root/.m2/repository
key: ${{ runner.os }}-benchmark-maven-${{ hashFiles('**/pom.xml') }}
+ # This job is queue-only, so nothing writes `benchmark-maven-` any
+ # more (see the save step below). `java-maven-` is the entry the
+ # TPC-H/TPC-DS jobs write on push to main; a Maven repository is
+ # always safe to start from a superset or a subset of itself.
restore-keys: |
${{ runner.os }}-benchmark-maven-
+ ${{ runner.os }}-java-maven-
- name: Check Scala compilation and linting
# Pin to spark-4.0 (Scala 2.13.16) because the default profile is now
@@ -76,3 +82,16 @@ jobs:
# the same exclusion in the main lint matrix.
run: |
./mvnw -B compile test-compile scalafix:scalafix -Dscalafix.mode=CHECK -Psemanticdb -Pspark-4.0 -DskipTests
+
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-benchmark-maven-${{ hashFiles('**/pom.xml') }}
diff --git a/.github/workflows/pr_build_linux.yml b/.github/workflows/pr_build_linux.yml
index 0a4f47c0c3..cb73bcf645 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -183,8 +183,9 @@ jobs:
rust-version: ${{ env.RUST_VERSION }}
jdk-version: ${{ matrix.profile.java_version }}
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -221,6 +222,19 @@ jobs:
run: |
./dev/ci/check-working-tree-clean.sh
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-lint
+
# Compile-only verification for Spark 4.1. Tests are intentionally skipped: the spark-4.1
# profile is currently a build target only, and several runtime/test failures are tracked
# in follow-up PRs. Excluded from lint-java because semanticdb-scalac_2.13.17 is not yet
@@ -241,8 +255,9 @@ jobs:
rust-version: ${{ env.RUST_VERSION }}
jdk-version: 17
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -257,6 +272,19 @@ jobs:
- name: Compile (skip tests)
run: ./mvnw -B install -DskipTests -Dmaven.test.skip=true -Pspark-4.1
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-spark-4.1-build
+
celeborn-reflection-compatibility:
needs: lint
name: Celeborn ${{ matrix.celeborn_version }} reflection compatibility
@@ -279,8 +307,9 @@ jobs:
rust-version: ${{ env.RUST_VERSION }}
jdk-version: 17
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -304,6 +333,19 @@ jobs:
-Dsuites=org.apache.comet.shuffle.CelebornReflectionCompatibilitySuite \
-DfailIfNoTests=false
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-celeborn-${{ matrix.celeborn_version }}
+
# Build native library once and share with all test jobs. Also the owner of
# main's `cargo-ci` cache entry, which is why it runs in cache-refresh-only
# mode: rebuilding native/target from cold costs every pull request ~20 min.
@@ -584,8 +626,8 @@ jobs:
path: native/target/release/
# Restore cargo registry cache (for any cargo commands that might run)
- - name: Cache Cargo registry
- uses: actions/cache@v6
+ - name: Restore Cargo registry
+ uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
@@ -631,8 +673,9 @@ jobs:
name: native-lib-linux
path: native/target/release/
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -667,6 +710,19 @@ jobs:
run: |
SPARK_HOME=`pwd` SPARK_TPCH_DATA=`pwd`/tpch/sf1_parquet ./mvnw -B -Prelease -Dsuites=org.apache.spark.sql.CometTPCHQuerySuite test
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}
+
# TPC-DS correctness tests - verifies benchmark queries produce correct results.
# The three join strategies run sequentially in one job so the project is built
# once. Runs in cache-refresh-only mode for the same reason as the TPC-H job;
@@ -695,8 +751,9 @@ jobs:
name: native-lib-linux
path: native/target/release/
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -765,3 +822,16 @@ jobs:
SPARK_TPCDS_JOIN_CONF: |
spark.sql.autoBroadcastJoinThreshold=-1
spark.sql.join.forceApplyShuffledHashJoin=true
+
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}
diff --git a/.github/workflows/pr_build_macos.yml b/.github/workflows/pr_build_macos.yml
index 20046ff5b1..09853061fd 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -276,8 +276,8 @@ jobs:
path: native/target/release/
# Restore cargo registry cache (for any cargo commands that might run)
- - name: Cache Cargo registry
- uses: actions/cache@v6
+ - name: Restore Cargo registry
+ uses: actions/cache/restore@v6
with:
path: |
~/.cargo/registry
diff --git a/.github/workflows/pyarrow_udf_test.yml b/.github/workflows/pyarrow_udf_test.yml
index a84fe69e42..a3797f913b 100644
--- a/.github/workflows/pyarrow_udf_test.yml
+++ b/.github/workflows/pyarrow_udf_test.yml
@@ -66,8 +66,9 @@ jobs:
rust-version: ${{ env.RUST_VERSION }}
jdk-version: 17
- - name: Cache Maven dependencies
- uses: actions/cache@v6
+ - name: Restore Maven dependencies
+ id: maven-cache
+ uses: actions/cache/restore@v6
with:
path: |
~/.m2/repository
@@ -102,3 +103,16 @@ jobs:
spark/src/test/resources/pyspark/test_pyarrow_udf.py
/tmp/venv/bin/python -m pytest -v \
spark/src/test/resources/pyspark/test_pyarrow_udf_dictionary_shuffle.py
+
+ # Saved only on main: an entry written from a pull request or a
+ # `gh-readonly-queue/*` branch cannot be restored by any later run, and
+ # it evicts main's from the shared budget. See "Large caches are written
+ # on main only" in README.md.
+ - name: Save Maven dependencies
+ if: ${{ github.ref == 'refs/heads/main' && steps.maven-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v6
+ with:
+ path: |
+ ~/.m2/repository
+ /root/.m2/repository
+ key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-pyarrow-udf-${{ matrix.pyspark }}
diff --git a/dev/ci/check-ci-config.py b/dev/ci/check-ci-config.py
index 3b140d27bd..f5c1bdb3c2 100644
--- a/dev/ci/check-ci-config.py
+++ b/dev/ci/check-ci-config.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-# Guards six CI invariants that are silent when broken:
+# Guards seven CI invariants that are silent when broken:
#
# 1. Change-filter routing. dev/ci/compute-changes.py decides which heavy
# jobs run. A file that a job depends on but that no filter lists makes
@@ -56,6 +56,13 @@
# added to that workflow without the guard starts running on every push
# again and nothing fails, so nothing tells you.
#
+# 7. Cache save scope. actions/cache entries are scoped to the ref that
+# wrote them, so one written from a pull request or from the queue's
+# throwaway branch can never be restored again -- but it still counts
+# against the repository's shared budget and evicts main's entries,
+# which is how every native build in the queue came to miss its cargo
+# cache and pay a cold ~26 minute compile.
+#
# Run from the repository root: python3 dev/ci/check-ci-config.py
import importlib.util
@@ -385,6 +392,35 @@ CACHE_REFRESH_INPUT = re.compile(r"^\s+cache-refresh-only:\s*\$\{\{")
# next line, so match the key alone.
PROFILES_INPUT = re.compile(r"^\s+profiles:\s*(>-|\$\{\{)")
+# Cache paths big enough that writing one from a throwaway ref costs main its
+# own entries. The repository's actions/cache budget is shared and evicted
+# least-recently-used, and a Maven repository or a cargo tree runs to gigabytes
+# apiece.
+# Matched as substrings of the step's `path:`, so they have to survive the
+# prefix being an expression: publish_snapshot.yml writes
+# `${{ env.CARGO_HOME }}/registry`, which no `~/.cargo/...` literal would
+# catch.
+LARGE_CACHE_PATHS = (
+ ".m2/repository",
+ ".cargo/registry",
+ ".cargo/git",
+ "CARGO_HOME",
+ "native/target",
+)
+# `actions/cache@vN` saves in an implicit post step that no `if:` can reach, so
+# a large cache has to be split into `restore` plus a guarded `save`.
+CACHE_RW_USES = re.compile(r"^\s*(?:-\s*)?uses:\s*actions/cache@v\d+\s*$")
+CACHE_SAVE_USES = re.compile(r"^\s*(?:-\s*)?uses:\s*actions/cache/save@v\d+\s*$")
+CACHE_MAIN_GUARD = re.compile(r"github\.ref\s*==\s*'refs/heads/main'")
+# publish_snapshot.yml runs from main on a schedule, so its entries already
+# land in the only scope that helps. (The TPC-H/TPC-DS dataset caches need no
+# entry here: `./tpch` and `./tpcds-sf-1` are not dependency trees and are not
+# in LARGE_CACHE_PATHS, so this check never looks at them.)
+CACHE_SAVE_SCOPE_EXEMPT = {
+ ("publish_snapshot.yml", "snapshot-cargo-"),
+ ("publish_snapshot.yml", "snapshot-maven-"),
+}
+
def load_filters():
spec = importlib.util.spec_from_file_location("compute_changes", "dev/ci/compute-changes.py")
@@ -846,6 +882,96 @@ def check_cache_refresh_scope():
return not failures
+def _cache_steps(lines):
+ """Yield (line_no, uses_line, step_lines) for every actions/cache* step.
+
+ A step runs from the `- ` that opens it to the next line indented no
+ further, which is enough structure to read its `if:`, `key:` and `path:`
+ without a YAML parser (no other check here takes that dependency either).
+ """
+ for index, line in enumerate(lines):
+ if not (CACHE_RW_USES.match(line) or CACHE_SAVE_USES.match(line)):
+ continue
+ start = index
+ while start > 0 and not lines[start].lstrip().startswith("- "):
+ start -= 1
+ indent = len(lines[start]) - len(lines[start].lstrip())
+ end = index + 1
+ while end < len(lines):
+ stripped = lines[end].strip()
+ if stripped and not stripped.startswith("#"):
+ if len(lines[end]) - len(lines[end].lstrip()) <= indent:
+ break
+ end += 1
+ yield start + 1, line, lines[start:end]
+
+
+def check_cache_save_scope():
+ """A multi-gigabyte cache is written only on push to main.
+
+ Caches are scoped to the ref that wrote them: a pull request reads its own
+ ref and main, and a `gh-readonly-queue/*` branch takes its entries to the
+ grave when the queue deletes it. An entry written from a queue or
+ pull-request ref can therefore never be restored by a later run, while
+ still counting against the repository's shared budget and pushing main's
+ entries out of it under least-recently-used eviction.
+
+ That is not hypothetical. On 2026-09-15 the repository held 12.27 GiB
+ across 14 entries, of which 9.22 GiB sat on one `gh-readonly-queue/*`
+ branch and 3.01 GiB on `refs/pull/*/merge`. Nothing at all was on main, so
+ every `cargo build --profile ci` in the merge queue missed its cache and
+ paid a cold ~26 minute compile where a hit costs 2m21s -- eight times over
+ in a single run, because each Spark and Iceberg caller builds its own copy.
+
+ The cargo caches already carry `if: github.ref == 'refs/heads/main'` on
+ their save. This holds every other large cache to the same rule, and
+ rejects the bare `actions/cache@vN` form for them outright, since its save
+ runs in an implicit post step that no `if:` can reach.
+ """
+ failures = []
+ sources = sorted(WORKFLOWS.glob("*.yml")) + sorted(WORKFLOWS.glob("*.yaml"))
+ sources += sorted(Path(".github/actions").glob("*/action.yaml"))
+ sources += sorted(Path(".github/actions").glob("*/action.yml"))
+
+ for source in sources:
+ lines = source.read_text(encoding="utf-8").splitlines()
+ for line_no, uses, step in _cache_steps(lines):
+ body = "\n".join(step)
+ if not any(path in body for path in LARGE_CACHE_PATHS):
+ continue
+ key = ""
+ for entry in step:
+ if entry.strip().startswith("key:"):
+ key = entry.split("key:", 1)[1].strip()
+ break
+ if any(
+ source.name == name and key.startswith(prefix)
+ for name, prefix in CACHE_SAVE_SCOPE_EXEMPT
+ ):
+ continue
+ where = f"{source}:{line_no}"
+ if CACHE_RW_USES.match(uses):
+ failures.append(
+ f"{where}: `{key}` caches a large path with "
+ f"`actions/cache@vN`, whose save runs in an implicit post "
+ f"step that no `if:` can reach. Split it into "
+ f"`actions/cache/restore` plus an `actions/cache/save` "
+ f"carrying `if: github.ref == 'refs/heads/main'`, or exempt "
+ f"the key in CACHE_SAVE_SCOPE_EXEMPT with the reason"
+ )
+ elif not CACHE_MAIN_GUARD.search(body):
+ failures.append(
+ f"{where}: `{key}` saves a large path without "
+ f"`if: github.ref == 'refs/heads/main'`, so a pull request "
+ f"or merge-queue run writes an entry no later run can "
+ f"restore, evicting main's from the shared budget"
+ )
+
+ for failure in failures:
+ print(f"cache save scope: {failure}")
+ return not failures
+
+
if __name__ == "__main__":
ok = check_change_filters()
ok = check_event_policy() and ok
@@ -855,6 +981,7 @@ if __name__ == "__main__":
ok = check_local_actions_have_checkout() and ok
ok = check_required_checks() and ok
ok = check_cache_refresh_scope() and ok
+ ok = check_cache_save_scope() and ok
if not ok:
sys.exit(1)
print("CI config checks passed")
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org