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