From 58aa2bf85332b28720469d9c6e86d1b981c107de Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 8 Sep 2026 15:32:38 -0700 Subject: [PATCH 1/5] chore: Improve network retry configuration for maven and artifact upload --- .../actions/upload-artifact-retry/action.yaml | 147 ++++++++++++++++++ .github/workflows/README.md | 51 ++++++ .../workflows/iceberg_spark_test_reusable.yml | 2 +- .github/workflows/pr_build_linux.yml | 2 +- .github/workflows/pr_build_macos.yml | 2 +- .github/workflows/spark_sql_test_reusable.yml | 15 +- .mvn/maven.config | 57 +++++++ 7 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 .github/actions/upload-artifact-retry/action.yaml create mode 100644 .mvn/maven.config diff --git a/.github/actions/upload-artifact-retry/action.yaml b/.github/actions/upload-artifact-retry/action.yaml new file mode 100644 index 00000000000..ee0e7807ef7 --- /dev/null +++ b/.github/actions/upload-artifact-retry/action.yaml @@ -0,0 +1,147 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: "Upload Artifact (with retry)" +description: > + Drop-in replacement for actions/upload-artifact that retries the upload up to + three times. + + The artifact client (@actions/artifact) already retries its own HTTP calls, + but only for 429/500/502/503/504. A FinalizeArtifact call that comes back + "(403) Forbidden: Error from intermediary" is classified as non-retryable and + fails the step outright, even though the blob content uploaded fine and a + fresh attempt normally succeeds. There is no input or environment variable to + widen that list, and GitHub Actions has no built-in step-level retry, so the + retry has to live outside the action. + + Third-party retry wrappers (Wandalen/wretry.action and friends) are not on + the Apache org's allowed-actions list, hence this local composite. + +inputs: + # Mirrors of actions/upload-artifact@v7's inputs. Defaults match that action + # so pass-through is transparent; the boolean ones must carry a real + # 'true'/'false' because core.getBooleanInput() throws on an empty string. + name: + description: 'Artifact name' + required: false + default: 'artifact' + path: + description: 'A file, directory or wildcard pattern that describes what to upload' + required: true + if-no-files-found: + description: "Behavior if no files are found: warn, error or ignore" + required: false + default: 'warn' + retention-days: + description: 'Days before the artifact expires (empty means repository default)' + required: false + default: '' + compression-level: + description: 'Zlib compression level 0-9 (empty means the action default)' + required: false + default: '' + overwrite: + description: 'Delete an existing artifact with the same name before uploading' + required: false + default: 'false' + include-hidden-files: + description: 'Include hidden files in the artifact' + required: false + default: 'false' + archive: + description: 'Zip the content before uploading' + required: false + default: 'true' + +outputs: + artifact-id: + description: 'ID of the uploaded artifact, from whichever attempt succeeded' + value: ${{ steps.attempt-1.outputs.artifact-id || steps.attempt-2.outputs.artifact-id || steps.attempt-3.outputs.artifact-id }} + artifact-url: + description: 'Download URL of the uploaded artifact' + value: ${{ steps.attempt-1.outputs.artifact-url || steps.attempt-2.outputs.artifact-url || steps.attempt-3.outputs.artifact-url }} + artifact-digest: + description: 'SHA-256 digest of the uploaded artifact' + value: ${{ steps.attempt-1.outputs.artifact-digest || steps.attempt-2.outputs.artifact-digest || steps.attempt-3.outputs.artifact-digest }} + +runs: + using: "composite" + steps: + # continue-on-error keeps a failed attempt from failing the job while still + # recording outcome == 'failure', which is what the later attempts gate on. + # The last attempt deliberately omits it so a genuinely broken upload still + # fails loudly. + - name: Upload ${{ inputs.name }} (attempt 1 of 3) + id: attempt-1 + uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + compression-level: ${{ inputs.compression-level }} + overwrite: ${{ inputs.overwrite }} + include-hidden-files: ${{ inputs.include-hidden-files }} + archive: ${{ inputs.archive }} + + - name: Wait before retrying ${{ inputs.name }} + if: ${{ steps.attempt-1.outcome == 'failure' }} + shell: bash + run: | + echo "::warning::Upload of '${{ inputs.name }}' failed; retrying in 15s (attempt 2 of 3)." + sleep 15 + + # Retries force overwrite: attempt 1 may have created a server-side artifact + # record before failing, and CreateArtifact rejects a duplicate name. The + # delete is best effort inside upload-artifact, so it is a no-op when + # nothing exists. + - name: Upload ${{ inputs.name }} (attempt 2 of 3) + id: attempt-2 + if: ${{ steps.attempt-1.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + compression-level: ${{ inputs.compression-level }} + overwrite: 'true' + include-hidden-files: ${{ inputs.include-hidden-files }} + archive: ${{ inputs.archive }} + + - name: Wait before final retry of ${{ inputs.name }} + if: ${{ steps.attempt-1.outcome == 'failure' && steps.attempt-2.outcome == 'failure' }} + shell: bash + run: | + echo "::warning::Upload of '${{ inputs.name }}' failed again; retrying in 45s (attempt 3 of 3)." + sleep 45 + + - name: Upload ${{ inputs.name }} (attempt 3 of 3) + id: attempt-3 + if: ${{ steps.attempt-1.outcome == 'failure' && steps.attempt-2.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: ${{ inputs.if-no-files-found }} + retention-days: ${{ inputs.retention-days }} + compression-level: ${{ inputs.compression-level }} + overwrite: 'true' + include-hidden-files: ${{ inputs.include-hidden-files }} + archive: ${{ inputs.archive }} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1953248a8ba..def7149b27a 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -130,6 +130,57 @@ dict at the top of `dev/ci/compute-changes.py`. The `changes` job in `needs.changes.outputs.`. When adding a new test suite or moving sources, update the relevant filter entry there. +## Retrying flaky network operations + +### Maven artifact resolution + +`.mvn/maven.config` tunes the Maven Resolver HTTP transport: six retries +instead of three, `408/429/500/502/503/504` treated as retryable instead of +only `429/503`, a 30s connect timeout, and a 10 minute socket read timeout. +The file lives at the repository root and the wrapper pins +`maven.multiModuleProjectDirectory` there, so it covers every `mvnw` +invocation in CI (including `cd spark && ../mvnw ...`) without touching a +single workflow. Each setting is commented in the file. + +The Wagon transport (`-Dmaven.resolver.transport=wagon`, +`maven.wagon.http.*`) was evaluated for this and rejected: + +- It is deprecated in Maven Resolver 1.9 and removed in Maven 4, so bumping + `.mvn/wrapper/maven-wrapper.properties` past 3.9.x would break the build. +- Its retry knobs mirror the native transport's almost one for one, and its + service-unavailable retry strategy defaults to `none`, so adopting it would + first have to buy back the `429/503` retry we already get. +- It reads configuration from JVM system properties in a static initializer, + so it cannot be scoped per repository the way `aether.connector.*.` + can. + +The one thing Wagon can still do that the native transport cannot is shrink +HttpClient's non-retryable exception list, via +`maven.wagon.http.retryHandler.class=default` plus +`maven.wagon.http.retryHandler.nonRetryableClasses=...`. That is the only way +to retry a connect or read timeout, since HttpClient classifies both as +non-retryable. We avoid needing it by giving those timeouts a budget large +enough not to fire on a busy runner. + +### Artifact upload + +`actions/upload-artifact` fails the job when `FinalizeArtifact` returns +`(403) Forbidden: Error from intermediary`, even though the blob content +already uploaded successfully. The artifact client only retries +`429/500/502/503/504`, exposes no input to widen that list, and GitHub +Actions has no built-in step-level retry. + +Use `./.github/actions/upload-artifact-retry` in place of +`actions/upload-artifact` for any artifact a later job depends on. It accepts +the same inputs, produces the same outputs, and retries up to three times +with a 15s then 45s backoff. + +### Maven wrapper bootstrap + +`./.github/actions/java-test` retries `./mvnw --version` with exponential +backoff before running anything, so a failed download of the Maven +distribution does not surface as a test failure. + ## Branch protection Required-check names changed when these workflows were consolidated. The diff --git a/.github/workflows/iceberg_spark_test_reusable.yml b/.github/workflows/iceberg_spark_test_reusable.yml index c0ae5427e7a..ac12d46cd8a 100644 --- a/.github/workflows/iceberg_spark_test_reusable.yml +++ b/.github/workflows/iceberg_spark_test_reusable.yml @@ -110,7 +110,7 @@ jobs: key: ${{ runner.os }}-cargo-ci-${{ hashFiles('native/**/Cargo.lock', 'native/**/Cargo.toml') }}-${{ hashFiles('native/**/*.rs') }} - name: Upload native library - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: native-lib-iceberg path: native/target/ci/libcomet.so diff --git a/.github/workflows/pr_build_linux.yml b/.github/workflows/pr_build_linux.yml index 35709c7274e..b9781ee93d1 100644 --- a/.github/workflows/pr_build_linux.yml +++ b/.github/workflows/pr_build_linux.yml @@ -255,7 +255,7 @@ jobs: RUSTFLAGS: "-Ctarget-cpu=x86-64-v3 -Clink-arg=-fuse-ld=bfd" - name: Upload native library - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: native-lib-linux path: native/target/ci/libcomet.so diff --git a/.github/workflows/pr_build_macos.yml b/.github/workflows/pr_build_macos.yml index 52e1e277642..b898b6d1dba 100644 --- a/.github/workflows/pr_build_macos.yml +++ b/.github/workflows/pr_build_macos.yml @@ -79,7 +79,7 @@ jobs: RUSTFLAGS: "-Ctarget-cpu=apple-m1" - name: Upload native library - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: native-lib-macos path: native/target/ci/libcomet.dylib diff --git a/.github/workflows/spark_sql_test_reusable.yml b/.github/workflows/spark_sql_test_reusable.yml index 5d4f8f0473e..1b79f992f04 100644 --- a/.github/workflows/spark_sql_test_reusable.yml +++ b/.github/workflows/spark_sql_test_reusable.yml @@ -104,7 +104,7 @@ jobs: key: ${{ runner.os }}-cargo-ci-${{ hashFiles('native/**/Cargo.lock', 'native/**/Cargo.toml') }}-${{ hashFiles('native/**/*.rs') }} - name: Upload native library - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: native-lib-linux path: native/target/ci/libcomet.so @@ -156,11 +156,16 @@ jobs: apache-spark - name: Upload JVM compile artifact - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: jvm-compiled-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: apache-spark.tar.gz retention-days: 1 + # The payload is already gzipped, so re-deflating it at the default + # level 6 costs minutes of CPU on a ~1 GB file for no size win. Store + # it instead: a shorter upload is also a smaller window for the + # FinalizeArtifact flake the retry wrapper exists to absorb. + compression-level: 0 spark-sql-test: needs: build @@ -265,7 +270,7 @@ jobs: DEDICATED_JVM_SBT_TESTS: ${{ inputs.spark-short == '4.0' && 'org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormatV1Suite,org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormatV2Suite,org.apache.spark.sql.execution.datasources.orc.OrcSourceV1Suite,org.apache.spark.sql.execution.datasources.orc.OrcSourceV2Suite' || '' }} - name: Upload fallback log if: ${{ inputs.collect-fallback-logs }} - uses: actions/upload-artifact@v7 + uses: ./.github/actions/upload-artifact-retry with: name: fallback-log-spark-sql-${{ matrix.module.name }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: "**/fallback.log" @@ -284,6 +289,10 @@ jobs: run: | find ./fallback-logs/ -type f -name "fallback.log" -print0 | xargs -0 cat | sort -u > all_fallback.log - name: Upload merged fallback log + # Not wrapped in ./.github/actions/upload-artifact-retry: a local + # composite action needs the repository checked out, and this job + # deliberately skips checkout. The payload is a small diagnostic log, + # so a rare upload flake here costs nothing downstream. uses: actions/upload-artifact@v7 with: name: all-fallback-log-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 00000000000..c07704f6ad3 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,57 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Maven Resolver HTTP transport tuning: make artifact resolution survive the +# transient Maven Central / CDN failures that flake CI. +# +# Every Maven invocation in this repository goes through the wrapper, and the +# wrapper pins `maven.multiModuleProjectDirectory` to the directory holding +# `.mvn` -- the repository root -- so Maven reads this file for `./mvnw` from +# the root, `../mvnw` from `native/`, and `cd spark && ../mvnw` alike. One file +# therefore covers every CI Maven step plus local and release builds. Flags +# passed on the command line still win over anything set here. +# +# These are Maven Resolver 1.9 properties, read by the default ("native") +# HTTP transport that ships with Maven 3.9.x. The Wagon transport +# (`-Dmaven.resolver.transport=wagon`, `maven.wagon.http.*`) exposes an +# equivalent set of knobs, but it is deprecated in Resolver 1.9 and removed in +# Maven 4, its service-unavailable retry strategy is off by default, and it +# cannot be scoped per repository. See .github/workflows/README.md for the +# comparison and for the one case Wagon still covers that this does not. + +# Number of retries per request. Applies both to I/O failures (via HttpClient's +# StandardHttpRequestRetryHandler, which retries idempotent methods -- all +# artifact downloads are GETs) and to the responses listed below. Default: 3. +-Daether.connector.http.retryHandler.count=6 + +# HTTP status codes treated as "retry after a backoff". The default is only +# "429,503", so a 500/502/504 emitted by a CDN edge node in front of Maven +# Central fails the build on the first hit. Backoff is linear in the attempt +# number (5s, 10s, 15s, ...) and honours a Retry-After header when present. +-Daether.connector.http.retryHandler.serviceUnavailable=408,429,500,502,503,504 + +# TCP connect timeout. A connect timeout surfaces as ConnectTimeoutException, +# which HttpClient classifies as non-retryable, so the retry count above cannot +# rescue it -- the budget itself has to be large enough for a busy runner. +# Default: 10000. +-Daether.connector.connectTimeout=30000 + +# Socket read timeout: the idle gap allowed between bytes, not the total +# transfer time. The 30 minute default lets a dead connection burn most of a +# job's budget before failing; 10 minutes is still far more than any live +# transfer needs. Default: 1800000. +-Daether.connector.requestTimeout=600000 From ce5ff4468dd9d93617cb84d7c7cd90c69f5c7663 Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 8 Sep 2026 15:37:53 -0700 Subject: [PATCH 2/5] chore: tighten CI retry comments and note the unwrapped upload sites --- .github/actions/java-test/action.yaml | 3 + .../actions/upload-artifact-retry/action.yaml | 40 ++++------ .github/workflows/README.md | 77 ++++++++----------- .github/workflows/spark_sql_test_reusable.yml | 12 +-- .mvn/maven.config | 45 ++++------- 5 files changed, 66 insertions(+), 111 deletions(-) diff --git a/.github/actions/java-test/action.yaml b/.github/actions/java-test/action.yaml index 1af66f7019d..966536955fb 100644 --- a/.github/actions/java-test/action.yaml +++ b/.github/actions/java-test/action.yaml @@ -136,6 +136,9 @@ runs: MAVEN_OPTS="-Xmx4G -Xms2G -DwildcardSuites=$MAVEN_SUITES -XX:+UnlockDiagnosticVMOptions -XX:+ShowMessageBoxOnError -XX:+HeapDumpOnOutOfMemoryError -XX:ErrorFile=./hs_err_pid%p.log" SPARK_HOME=`pwd` ./mvnw -B -Prelease install ${{ inputs.maven_opts }} - name: Upload crash logs if: failure() + # These three stay on the plain action rather than + # ../upload-artifact-retry: a local action calling another local action + # is untested in this repo, and these only run on already-failing jobs. uses: actions/upload-artifact@v6 with: name: crash-logs-${{ inputs.artifact_name }} diff --git a/.github/actions/upload-artifact-retry/action.yaml b/.github/actions/upload-artifact-retry/action.yaml index ee0e7807ef7..706498b6a46 100644 --- a/.github/actions/upload-artifact-retry/action.yaml +++ b/.github/actions/upload-artifact-retry/action.yaml @@ -17,24 +17,17 @@ name: "Upload Artifact (with retry)" description: > - Drop-in replacement for actions/upload-artifact that retries the upload up to - three times. - - The artifact client (@actions/artifact) already retries its own HTTP calls, - but only for 429/500/502/503/504. A FinalizeArtifact call that comes back - "(403) Forbidden: Error from intermediary" is classified as non-retryable and - fails the step outright, even though the blob content uploaded fine and a - fresh attempt normally succeeds. There is no input or environment variable to - widen that list, and GitHub Actions has no built-in step-level retry, so the - retry has to live outside the action. - - Third-party retry wrappers (Wandalen/wretry.action and friends) are not on - the Apache org's allowed-actions list, hence this local composite. + Drop-in replacement for actions/upload-artifact that retries the upload three + times. The artifact client only retries 429/500/502/503/504, so a + FinalizeArtifact answered "(403) Forbidden: Error from intermediary" fails the + step even though the content uploaded fine. There is no input to widen that + list, Actions has no built-in step retry, and third-party retry wrappers are + not on the Apache allowed-actions list. See .github/workflows/README.md. +# Inputs mirror actions/upload-artifact@v7 one for one. The boolean defaults +# have to be real 'true'/'false' strings because core.getBooleanInput() throws +# on an empty value; the numeric ones default to '' so "unset" round-trips. inputs: - # Mirrors of actions/upload-artifact@v7's inputs. Defaults match that action - # so pass-through is transparent; the boolean ones must carry a real - # 'true'/'false' because core.getBooleanInput() throws on an empty string. name: description: 'Artifact name' required: false @@ -67,9 +60,10 @@ inputs: required: false default: 'true' +# `||` yields the first non-empty operand, so this picks whichever attempt ran. outputs: artifact-id: - description: 'ID of the uploaded artifact, from whichever attempt succeeded' + description: 'ID of the uploaded artifact' value: ${{ steps.attempt-1.outputs.artifact-id || steps.attempt-2.outputs.artifact-id || steps.attempt-3.outputs.artifact-id }} artifact-url: description: 'Download URL of the uploaded artifact' @@ -82,9 +76,8 @@ runs: using: "composite" steps: # continue-on-error keeps a failed attempt from failing the job while still - # recording outcome == 'failure', which is what the later attempts gate on. - # The last attempt deliberately omits it so a genuinely broken upload still - # fails loudly. + # recording outcome == 'failure', which the later attempts gate on. The last + # attempt omits it so a genuinely broken upload still fails loudly. - name: Upload ${{ inputs.name }} (attempt 1 of 3) id: attempt-1 uses: actions/upload-artifact@v7 @@ -106,10 +99,9 @@ runs: echo "::warning::Upload of '${{ inputs.name }}' failed; retrying in 15s (attempt 2 of 3)." sleep 15 - # Retries force overwrite: attempt 1 may have created a server-side artifact - # record before failing, and CreateArtifact rejects a duplicate name. The - # delete is best effort inside upload-artifact, so it is a no-op when - # nothing exists. + # Retries force overwrite: attempt 1 may have created the server-side record + # before failing, and CreateArtifact rejects a duplicate name. The delete is + # best effort inside upload-artifact, so it no-ops when nothing exists. - name: Upload ${{ inputs.name }} (attempt 2 of 3) id: attempt-2 if: ${{ steps.attempt-1.outcome == 'failure' }} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index def7149b27a..8036f94a6fb 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -132,53 +132,36 @@ sources, update the relevant filter entry there. ## Retrying flaky network operations -### Maven artifact resolution - -`.mvn/maven.config` tunes the Maven Resolver HTTP transport: six retries -instead of three, `408/429/500/502/503/504` treated as retryable instead of -only `429/503`, a 30s connect timeout, and a 10 minute socket read timeout. -The file lives at the repository root and the wrapper pins -`maven.multiModuleProjectDirectory` there, so it covers every `mvnw` -invocation in CI (including `cd spark && ../mvnw ...`) without touching a -single workflow. Each setting is commented in the file. - -The Wagon transport (`-Dmaven.resolver.transport=wagon`, -`maven.wagon.http.*`) was evaluated for this and rejected: - -- It is deprecated in Maven Resolver 1.9 and removed in Maven 4, so bumping - `.mvn/wrapper/maven-wrapper.properties` past 3.9.x would break the build. -- Its retry knobs mirror the native transport's almost one for one, and its - service-unavailable retry strategy defaults to `none`, so adopting it would - first have to buy back the `429/503` retry we already get. -- It reads configuration from JVM system properties in a static initializer, - so it cannot be scoped per repository the way `aether.connector.*.` - can. - -The one thing Wagon can still do that the native transport cannot is shrink -HttpClient's non-retryable exception list, via -`maven.wagon.http.retryHandler.class=default` plus -`maven.wagon.http.retryHandler.nonRetryableClasses=...`. That is the only way -to retry a connect or read timeout, since HttpClient classifies both as -non-retryable. We avoid needing it by giving those timeouts a budget large -enough not to fire on a busy runner. - -### Artifact upload - -`actions/upload-artifact` fails the job when `FinalizeArtifact` returns -`(403) Forbidden: Error from intermediary`, even though the blob content -already uploaded successfully. The artifact client only retries -`429/500/502/503/504`, exposes no input to widen that list, and GitHub -Actions has no built-in step-level retry. - -Use `./.github/actions/upload-artifact-retry` in place of -`actions/upload-artifact` for any artifact a later job depends on. It accepts -the same inputs, produces the same outputs, and retries up to three times -with a 15s then 45s backoff. - -### Maven wrapper bootstrap - -`./.github/actions/java-test` retries `./mvnw --version` with exponential -backoff before running anything, so a failed download of the Maven +**Maven.** `.mvn/maven.config` tunes the Maven Resolver HTTP transport: six +retries instead of three, `408/429/500/502/503/504` retryable instead of only +`429/503`, a 30s connect timeout and a 10 minute socket read timeout. The +wrapper pins `maven.multiModuleProjectDirectory` to the directory holding +`.mvn`, so one file covers every `mvnw` invocation in CI (including +`cd spark && ../mvnw ...`) with no per-workflow wiring. + +The Wagon transport (`-Dmaven.resolver.transport=wagon`, `maven.wagon.http.*`) +was evaluated and rejected: it is deprecated in Resolver 1.9 and removed in +Maven 4, its retry knobs mirror the native transport's, and its +service-unavailable retry strategy defaults to `none`, so adopting it would +first have to buy back the `429/503` retry we already get. The one thing it +can still do that the native transport cannot is shrink HttpClient's +non-retryable exception list (`retryHandler.class=default` plus +`retryHandler.nonRetryableClasses=...`), the only way to retry a connect or +read timeout. We size those timeouts not to fire instead. + +**Artifact upload.** `actions/upload-artifact` fails the job when +`FinalizeArtifact` returns `(403) Forbidden: Error from intermediary`, even +though the content already uploaded. Its client only retries +`429/500/502/503/504`, exposes no input to widen that, and Actions has no +built-in step retry. Use `./.github/actions/upload-artifact-retry` instead for +any artifact a later job consumes: same inputs and outputs, three attempts, +15s then 45s backoff. The diagnostic uploads inside +`./.github/actions/java-test` stay on the plain action, since a local action +calling another local action is untested here and those run only on +already-failing jobs. + +**Maven wrapper bootstrap.** `./.github/actions/java-test` retries +`./mvnw --version` with exponential backoff, so a failed download of the Maven distribution does not surface as a test failure. ## Branch protection diff --git a/.github/workflows/spark_sql_test_reusable.yml b/.github/workflows/spark_sql_test_reusable.yml index 1b79f992f04..e3261aaab58 100644 --- a/.github/workflows/spark_sql_test_reusable.yml +++ b/.github/workflows/spark_sql_test_reusable.yml @@ -161,10 +161,8 @@ jobs: name: jvm-compiled-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: apache-spark.tar.gz retention-days: 1 - # The payload is already gzipped, so re-deflating it at the default - # level 6 costs minutes of CPU on a ~1 GB file for no size win. Store - # it instead: a shorter upload is also a smaller window for the - # FinalizeArtifact flake the retry wrapper exists to absorb. + # Already gzipped: re-deflating ~1 GB at level 6 costs minutes for no + # size win, and a shorter upload is a smaller window for the flake. compression-level: 0 spark-sql-test: @@ -289,10 +287,8 @@ jobs: run: | find ./fallback-logs/ -type f -name "fallback.log" -print0 | xargs -0 cat | sort -u > all_fallback.log - name: Upload merged fallback log - # Not wrapped in ./.github/actions/upload-artifact-retry: a local - # composite action needs the repository checked out, and this job - # deliberately skips checkout. The payload is a small diagnostic log, - # so a rare upload flake here costs nothing downstream. + # Not wrapped in upload-artifact-retry: that local action needs a + # checkout, which this job deliberately skips. uses: actions/upload-artifact@v7 with: name: all-fallback-log-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} diff --git a/.mvn/maven.config b/.mvn/maven.config index c07704f6ad3..6c17056dc72 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -15,43 +15,24 @@ # specific language governing permissions and limitations # under the License. # -# Maven Resolver HTTP transport tuning: make artifact resolution survive the -# transient Maven Central / CDN failures that flake CI. -# -# Every Maven invocation in this repository goes through the wrapper, and the -# wrapper pins `maven.multiModuleProjectDirectory` to the directory holding -# `.mvn` -- the repository root -- so Maven reads this file for `./mvnw` from -# the root, `../mvnw` from `native/`, and `cd spark && ../mvnw` alike. One file -# therefore covers every CI Maven step plus local and release builds. Flags -# passed on the command line still win over anything set here. -# -# These are Maven Resolver 1.9 properties, read by the default ("native") -# HTTP transport that ships with Maven 3.9.x. The Wagon transport -# (`-Dmaven.resolver.transport=wagon`, `maven.wagon.http.*`) exposes an -# equivalent set of knobs, but it is deprecated in Resolver 1.9 and removed in -# Maven 4, its service-unavailable retry strategy is off by default, and it -# cannot be scoped per repository. See .github/workflows/README.md for the -# comparison and for the one case Wagon still covers that this does not. +# Maven Resolver HTTP tuning, applied to every wrapper invocation: the wrapper +# pins `maven.multiModuleProjectDirectory` to the directory holding `.mvn`, so +# `./mvnw` from the root and `../mvnw` from `native/` or `spark/` all read this. +# Command-line flags still win. Rationale and the Wagon comparison live in +# .github/workflows/README.md. -# Number of retries per request. Applies both to I/O failures (via HttpClient's -# StandardHttpRequestRetryHandler, which retries idempotent methods -- all -# artifact downloads are GETs) and to the responses listed below. Default: 3. +# Retries per request, for I/O errors and for the status codes below. Default 3. -Daether.connector.http.retryHandler.count=6 -# HTTP status codes treated as "retry after a backoff". The default is only -# "429,503", so a 500/502/504 emitted by a CDN edge node in front of Maven -# Central fails the build on the first hit. Backoff is linear in the attempt -# number (5s, 10s, 15s, ...) and honours a Retry-After header when present. +# Retryable status codes. The default "429,503" fails the build on the first 502 +# or 504 from a CDN edge in front of Maven Central. Backoff is linear (5s, 10s, +# ...) and honours Retry-After. -Daether.connector.http.retryHandler.serviceUnavailable=408,429,500,502,503,504 -# TCP connect timeout. A connect timeout surfaces as ConnectTimeoutException, -# which HttpClient classifies as non-retryable, so the retry count above cannot -# rescue it -- the budget itself has to be large enough for a busy runner. -# Default: 10000. +# TCP connect timeout, default 10s. HttpClient classifies a connect timeout as +# non-retryable, so the budget itself has to survive a busy runner. -Daether.connector.connectTimeout=30000 -# Socket read timeout: the idle gap allowed between bytes, not the total -# transfer time. The 30 minute default lets a dead connection burn most of a -# job's budget before failing; 10 minutes is still far more than any live -# transfer needs. Default: 1800000. +# Idle gap allowed between bytes, not total transfer time. The 30 minute default +# lets a dead connection burn most of a job's budget. -Daether.connector.requestTimeout=600000 From 8664b42e1d73483f3d5ba543f3fafffbd3b31682 Mon Sep 17 00:00:00 2001 From: comphead Date: Tue, 8 Sep 2026 15:43:35 -0700 Subject: [PATCH 3/5] chore: pin java-test artifact uploads to upload-artifact@v7 --- .github/actions/java-test/action.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/java-test/action.yaml b/.github/actions/java-test/action.yaml index 966536955fb..ef640da3e28 100644 --- a/.github/actions/java-test/action.yaml +++ b/.github/actions/java-test/action.yaml @@ -139,7 +139,7 @@ runs: # These three stay on the plain action rather than # ../upload-artifact-retry: a local action calling another local action # is untested in this repo, and these only run on already-failing jobs. - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: crash-logs-${{ inputs.artifact_name }} path: "**/hs_err_pid*.log" @@ -158,14 +158,14 @@ runs: find . -name 'unit-tests.log' - name: Upload unit-tests.log if: failure() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: unit-tests-${{ inputs.artifact_name }} path: "**/target/unit-tests.log" if-no-files-found: ignore - name: Upload test results if: ${{ !cancelled() && inputs.upload-test-reports == 'true' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: java-test-reports-${{ inputs.artifact_name }} path: "**/target/surefire-reports/*.txt" From ed84fee9e04924fe8561506ae50ef55fb60c630a Mon Sep 17 00:00:00 2001 From: comphead Date: Wed, 9 Sep 2026 08:13:05 -0700 Subject: [PATCH 4/5] chore: scope shared CI artifact names per producer, register their change filters Addresses review feedback on #5782. Artifact names are scoped to the run, not to the calling workflow, so ci.yml's four invocations of spark_sql_test_reusable.yml all published `native-lib-linux` and its four invocations of iceberg_spark_test_reusable.yml all published `native-lib-iceberg`. A consumer's `download-artifact` then resolved the name to the highest artifact ID rather than to the producer in its `needs`, and the forced `overwrite` on an upload retry could delete a sibling's finished record. Both producers now qualify the name with their version inputs, matching the existing `jvm-compiled-spark--jdk` convention, and the consumers follow. dev/ci/compute-changes.py gains the paths the Spark SQL and Iceberg jobs actually read: `.github/actions/upload-artifact-retry/**` (missing from every filter, so an action-only edit ran nothing), plus `.mvn/**` and `mvnw`, which setup-spark-builder and the Iceberg jobs invoke. dev/ci/check-ci-config.py, new and run from preflight, pins both: a routing table over the shared build inputs, and the rule that a reusable workflow ci.yml calls more than once must qualify its artifact names and that every download name is produced in the same workflow. --- .../actions/upload-artifact-retry/action.yaml | 8 +- .github/workflows/README.md | 34 +++- .github/workflows/ci.yml | 3 + .../workflows/iceberg_spark_test_reusable.yml | 12 +- .github/workflows/spark_sql_test_reusable.yml | 11 +- dev/ci/check-ci-config.py | 157 ++++++++++++++++++ dev/ci/compute-changes.py | 26 +++ 7 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 dev/ci/check-ci-config.py diff --git a/.github/actions/upload-artifact-retry/action.yaml b/.github/actions/upload-artifact-retry/action.yaml index 706498b6a46..5bba453a907 100644 --- a/.github/actions/upload-artifact-retry/action.yaml +++ b/.github/actions/upload-artifact-retry/action.yaml @@ -24,6 +24,11 @@ description: > list, Actions has no built-in step retry, and third-party retry wrappers are not on the Apache allowed-actions list. See .github/workflows/README.md. + Retries force overwrite, which deletes the newest artifact carrying the name + before re-uploading. `name` must therefore identify exactly one producer in + the run: a reusable workflow ci.yml calls more than once has to qualify the + name with its version inputs. dev/ci/check-ci-config.py enforces that. + # Inputs mirror actions/upload-artifact@v7 one for one. The boolean defaults # have to be real 'true'/'false' strings because core.getBooleanInput() throws # on an empty value; the numeric ones default to '' so "unset" round-trips. @@ -101,7 +106,8 @@ runs: # Retries force overwrite: attempt 1 may have created the server-side record # before failing, and CreateArtifact rejects a duplicate name. The delete is - # best effort inside upload-artifact, so it no-ops when nothing exists. + # best effort inside upload-artifact, so it no-ops when nothing exists, and + # the per-producer naming rule keeps the record it does find our own. - name: Upload ${{ inputs.name }} (attempt 2 of 3) id: attempt-2 if: ${{ steps.attempt-1.outcome == 'failure' }} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 8036f94a6fb..16f0d333731 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -130,6 +130,31 @@ dict at the top of `dev/ci/compute-changes.py`. The `changes` job in `needs.changes.outputs.`. When adding a new test suite or moving sources, update the relevant filter entry there. +A file that a job reads but that no filter lists is silent: the job skips, +and the edit merges with only `preflight` having looked at it. The shared +build inputs (`mvnw`, `.mvn/**`, the local composite actions) are pinned by +a routing table in `dev/ci/check-ci-config.py`, which `preflight` runs. + +## Artifact names must be unique per producer + +Artifact names are scoped to the workflow **run**, not to the calling +workflow. `ci.yml` calls `spark_sql_test_reusable.yml` once per Spark +version and `iceberg_spark_test_reusable.yml` once per Iceberg version, all +inside the same run, so an unqualified name like `native-lib-linux` would be +claimed by several producers at once. That breaks two things: + +- `download-artifact` resolves a name to the highest matching artifact ID. + Nothing ties it to the producer the consumer declared in `needs`. +- `upload-artifact` with `overwrite: true` deletes the newest record with + that name before uploading, which can be a sibling's finished artifact. + The retry wrapper below forces `overwrite` on attempts 2 and 3. + +So every artifact published by a reusable workflow that `ci.yml` calls more +than once carries its version inputs, e.g. +`native-lib-spark-4.1.3-jdk17`. `dev/ci/check-ci-config.py` enforces this, +and also that every `download-artifact` name is produced by an upload in the +same workflow. + ## Retrying flaky network operations **Maven.** `.mvn/maven.config` tunes the Maven Resolver HTTP transport: six @@ -155,10 +180,11 @@ though the content already uploaded. Its client only retries `429/500/502/503/504`, exposes no input to widen that, and Actions has no built-in step retry. Use `./.github/actions/upload-artifact-retry` instead for any artifact a later job consumes: same inputs and outputs, three attempts, -15s then 45s backoff. The diagnostic uploads inside -`./.github/actions/java-test` stay on the plain action, since a local action -calling another local action is untested here and those run only on -already-failing jobs. +15s then 45s backoff. Attempts 2 and 3 force `overwrite: true`, so the name +must belong to exactly one producer in the run (see above). The diagnostic +uploads inside `./.github/actions/java-test` stay on the plain action, since a +local action calling another local action is untested here and those run only +on already-failing jobs. **Maven wrapper bootstrap.** `./.github/actions/java-test` retries `./mvnw --version` with exponential backoff, so a failed download of the Maven diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4484def252..bdc1b1a14f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: - name: Check Iceberg shard inventory validation run: python3 dev/ci/test-iceberg-shards.py + - name: Check CI config invariants + run: python3 dev/ci/check-ci-config.py + - name: Install actionlint run: | curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash diff --git a/.github/workflows/iceberg_spark_test_reusable.yml b/.github/workflows/iceberg_spark_test_reusable.yml index ac12d46cd8a..6a268bc129a 100644 --- a/.github/workflows/iceberg_spark_test_reusable.yml +++ b/.github/workflows/iceberg_spark_test_reusable.yml @@ -112,7 +112,11 @@ jobs: - name: Upload native library uses: ./.github/actions/upload-artifact-retry with: - name: native-lib-iceberg + # Version-qualified: ci.yml calls this workflow once per Iceberg + # version inside a single run, and artifact names are scoped to the + # run, not to the calling workflow. See "Artifact names must be + # unique per producer" in .github/workflows/README.md. + name: native-lib-iceberg-${{ inputs.iceberg-full }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/ci/libcomet.so retention-days: 1 @@ -137,7 +141,7 @@ jobs: - name: Download native library uses: actions/download-artifact@v8 with: - name: native-lib-iceberg + name: native-lib-iceberg-${{ inputs.iceberg-full }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/release/ - name: Build Comet run: | @@ -203,7 +207,7 @@ jobs: - name: Download native library uses: actions/download-artifact@v8 with: - name: native-lib-iceberg + name: native-lib-iceberg-${{ inputs.iceberg-full }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/release/ - name: Build Comet run: | @@ -238,7 +242,7 @@ jobs: - name: Download native library uses: actions/download-artifact@v8 with: - name: native-lib-iceberg + name: native-lib-iceberg-${{ inputs.iceberg-full }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/release/ - name: Build Comet run: | diff --git a/.github/workflows/spark_sql_test_reusable.yml b/.github/workflows/spark_sql_test_reusable.yml index e3261aaab58..52b9d1208cf 100644 --- a/.github/workflows/spark_sql_test_reusable.yml +++ b/.github/workflows/spark_sql_test_reusable.yml @@ -54,13 +54,18 @@ jobs: # Build the native library AND pre-compile Spark sources + Test classes in a # single runner, then publish two artifacts the matrix consumes: - # - native-lib-linux: libcomet.so (~50 MB) + # - native-lib-spark--jdk: libcomet.so (~50 MB) # - jvm-compiled-spark--jdk: apache-spark.tar.gz (sources + # target/ + Zinc state, ~500 MB-1 GB) # Combining them avoids a second runner cold-start and an extra inter-job # artifact round-trip for the native lib, since the JVM build already # depends on it (the Comet Maven install bundles libcomet.so into the # Comet JAR before SBT resolves Spark's classpath). + # + # Both names carry the Spark/JDK version because ci.yml calls this workflow + # once per Spark version inside a single run, and artifact names are scoped + # to the run, not to the calling workflow. See "Artifact names must be unique + # per producer" in .github/workflows/README.md. build: name: Build Native + JVM Test Classes runs-on: ubuntu-24.04 @@ -106,7 +111,7 @@ jobs: - name: Upload native library uses: ./.github/actions/upload-artifact-retry with: - name: native-lib-linux + name: native-lib-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/ci/libcomet.so retention-days: 1 @@ -193,7 +198,7 @@ jobs: - name: Download native library uses: actions/download-artifact@v8 with: - name: native-lib-linux + name: native-lib-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} path: native/target/release/ - name: Download JVM compile artifact uses: actions/download-artifact@v8 diff --git a/dev/ci/check-ci-config.py b/dev/ci/check-ci-config.py new file mode 100644 index 00000000000..fb48400175b --- /dev/null +++ b/dev/ci/check-ci-config.py @@ -0,0 +1,157 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Guards two 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 +# that job skip, so the edit merges with only preflight having looked at +# it. The table below pins the routing for the shared build inputs. +# +# 2. Artifact-name uniqueness. Artifact names are scoped to the *run*, not +# to the calling workflow, and ci.yml calls the Spark SQL and Iceberg +# reusable workflows several times in one run. Two producers sharing a +# name make `download-artifact` pick by highest artifact ID rather than +# by `needs`, and make the forced `overwrite` on an upload retry delete +# a sibling's finished artifact. +# +# Run from the repository root: python3 dev/ci/check-ci-config.py + +import importlib.util +import re +import sys +from pathlib import Path + +WORKFLOWS = Path(".github/workflows") + +# Changed-file list -> the set of outputs compute-changes.py must report true. +# Every other output must be false. Keep one case per shared build input so a +# filter deletion cannot pass unnoticed. +BUILD_JOBS = { + "build_linux", + "build_macos", + "spark_3_4", + "spark_3_5", + "spark_4_0", + "spark_4_1", + "iceberg_1_8", + "iceberg_1_9", + "iceberg_1_10", + "iceberg_1_11", +} + +ROUTING_CASES = [ + # The Maven wrapper and its config feed every job that runs ./mvnw: the + # Linux/macOS builds, setup-spark-builder, and the Iceberg `mvnw install`. + ([".mvn/maven.config"], BUILD_JOBS), + ([".mvn/wrapper/maven-wrapper.properties"], BUILD_JOBS), + (["mvnw"], BUILD_JOBS), + # The upload wrapper is used by every producer of a shared artifact. + ([".github/actions/upload-artifact-retry/action.yaml"], BUILD_JOBS), + # Spot checks that the additions above did not widen unrelated routes. + (["docs/source/user-guide/overview.md"], {"docs"}), + (["native/core/benches/parquet_read.rs"], {"benchmark"}), +] + +# `uses:` values that publish an artifact, and the one that consumes it. +UPLOAD_USES = re.compile(r"uses:\s*(\./\.github/actions/upload-artifact-retry|actions/upload-artifact@)") +DOWNLOAD_USES = re.compile(r"uses:\s*actions/download-artifact@") +# The artifact name is the first `name:` key of the step's `with:` block. A +# following step starts with `- `, which distinguishes it from a `with:` key. +WITH_NAME = re.compile(r"^\s+name:\s*(\S.*?)\s*$") +NEW_STEP = re.compile(r"^\s*-\s") + + +def load_filters(): + spec = importlib.util.spec_from_file_location("compute_changes", "dev/ci/compute-changes.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def check_change_filters(): + module = load_filters() + failures = [] + for files, expected_true in ROUTING_CASES: + for name, patterns in module.FILTERS.items(): + actual = module.matches(patterns, files) + expected = name in expected_true + if actual != expected: + failures.append( + f"{files}: expected {name}={str(expected).lower()}, " + f"got {str(actual).lower()} (see FILTERS in dev/ci/compute-changes.py)" + ) + for failure in failures: + print(f"change filter: {failure}") + return not failures + + +def artifact_names(path): + """Return ([upload names], [download names]) for one workflow file.""" + uploads, downloads = [], [] + lines = path.read_text(encoding="utf-8").splitlines() + for index, line in enumerate(lines): + if UPLOAD_USES.search(line): + bucket = uploads + elif DOWNLOAD_USES.search(line): + bucket = downloads + else: + continue + for following in lines[index + 1:]: + if NEW_STEP.match(following): + break # step ended without a `name:`; download-all, or the default + match = WITH_NAME.match(following) + if match: + bucket.append(match.group(1)) + break + return uploads, downloads + + +def check_artifact_names(): + ci = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8") + call_counts = {} + for called in re.findall(r"uses:\s*\./\.github/workflows/(\S+)", ci): + call_counts[called] = call_counts.get(called, 0) + 1 + + failures = [] + for path in sorted(WORKFLOWS.glob("*.y*ml")): + uploads, downloads = artifact_names(path) + if call_counts.get(path.name, 0) > 1: + for name in uploads: + if "inputs." not in name: + failures.append( + f"{path}: artifact '{name}' is uploaded by a workflow ci.yml calls " + f"{call_counts[path.name]} times; qualify the name with an input " + f"(e.g. ${{{{ inputs.spark-full }}}}) so the parallel producers stay distinct" + ) + for name in downloads: + if name not in uploads: + failures.append( + f"{path}: artifact '{name}' is downloaded but never uploaded in the same " + f"workflow; a producer rename probably missed its consumer" + ) + for failure in failures: + print(f"artifact name: {failure}") + return not failures + + +if __name__ == "__main__": + ok = check_change_filters() + ok = check_artifact_names() and ok + if not ok: + sys.exit(1) + print("CI config checks passed") diff --git a/dev/ci/compute-changes.py b/dev/ci/compute-changes.py index c385fb84120..89d117b06c8 100644 --- a/dev/ci/compute-changes.py +++ b/dev/ci/compute-changes.py @@ -43,6 +43,7 @@ ".github/actions/setup-builder/**", ".github/actions/java-test/**", ".github/actions/rust-test/**", + ".github/actions/upload-artifact-retry/**", "!**.md", "!native/core/benches/**", "!native/spark-expr/benches/**", @@ -65,6 +66,7 @@ ".github/workflows/pr_build_macos.yml", ".github/actions/setup-macos-builder/**", ".github/actions/java-test/**", + ".github/actions/upload-artifact-retry/**", "!**.md", "!native/core/benches/**", "!native/spark-expr/benches/**", @@ -110,6 +112,9 @@ ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "spark_3_5": [ "native/**/src/**", @@ -132,6 +137,9 @@ ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "spark_4_0": [ "native/**/src/**", @@ -154,6 +162,9 @@ ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "spark_4_1": [ "native/**/src/**", @@ -176,6 +187,9 @@ ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "iceberg_1_8": [ "native/**/src/**", @@ -196,6 +210,9 @@ "dev/ci/iceberg-test-shards.gradle", "dev/ci/check-iceberg-shards.py", "dev/ci/test-iceberg-shards.py", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "iceberg_1_9": [ "native/**/src/**", @@ -216,6 +233,9 @@ "dev/ci/iceberg-test-shards.gradle", "dev/ci/check-iceberg-shards.py", "dev/ci/test-iceberg-shards.py", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "iceberg_1_10": [ "native/**/src/**", @@ -236,6 +256,9 @@ "dev/ci/iceberg-test-shards.gradle", "dev/ci/check-iceberg-shards.py", "dev/ci/test-iceberg-shards.py", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], "iceberg_1_11": [ "native/**/src/**", @@ -256,6 +279,9 @@ "dev/ci/iceberg-test-shards.gradle", "dev/ci/check-iceberg-shards.py", "dev/ci/test-iceberg-shards.py", + ".github/actions/upload-artifact-retry/**", + ".mvn/**", + "mvnw", ], } From 94b87ada75e2a9f8a445db644dd202928b3412a0 Mon Sep 17 00:00:00 2001 From: comphead Date: Wed, 9 Sep 2026 13:51:20 -0700 Subject: [PATCH 5/5] chore: pin the last three jobs to ubuntu-24.04 instead of ubuntu-latest `ubuntu-latest` jobs are not getting runners. Every one in the repo over the last several hours sat queued or was cancelled while still queued, including `Deploy Comet site / Build docs` on main. Three samples that did eventually start waited 3h24m, 3h34m and 4h08m between `created_at` and `started_at`. In the same minutes, `ubuntu-24.04` jobs in the same repo were picked up in 6 to 24 seconds, and `ubuntu-slim` was likewise healthy. `ubuntu-latest` is the default label nearly every Apache project uses, so it is the contended pool; the explicitly versioned labels are served from elsewhere. These were the only three jobs left on the floating label out of 34, against 21 already on `ubuntu-24.04` and 8 on `ubuntu-slim`. Pinning them matches the rest of the repo and removes the dependency on whichever version `ubuntu-latest` currently resolves to. `pr_build_linux.yml` keeps the job *display name* `ubuntu-latest/rust-test`. It already runs on `ubuntu-24.04`, and renaming a job renames its check, which would need a branch-protection update. --- .github/workflows/docs.yaml | 2 +- .github/workflows/pr_build_macos.yml | 2 +- .github/workflows/pyarrow_udf_test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index a7dd1acffdf..645b73b3743 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -26,7 +26,7 @@ jobs: build-docs: name: Build docs if: ${{ startsWith(github.repository, 'apache/') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout docs sources uses: actions/checkout@v7 diff --git a/.github/workflows/pr_build_macos.yml b/.github/workflows/pr_build_macos.yml index b898b6d1dba..c729a0cf063 100644 --- a/.github/workflows/pr_build_macos.yml +++ b/.github/workflows/pr_build_macos.yml @@ -31,7 +31,7 @@ jobs: # Fast lint check - gates all other jobs (runs on Linux for cost efficiency) lint: name: Lint - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 container: image: amd64/rust steps: diff --git a/.github/workflows/pyarrow_udf_test.yml b/.github/workflows/pyarrow_udf_test.yml index 6af67aa7e36..d75c5d27744 100644 --- a/.github/workflows/pyarrow_udf_test.yml +++ b/.github/workflows/pyarrow_udf_test.yml @@ -64,7 +64,7 @@ env: jobs: pyarrow-udf: name: PyArrow UDF (${{ matrix.name }}, JDK 17, Python 3.11) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: