This is an automated email from the ASF dual-hosted git repository.
comphead pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 80c8ec37af chore: Improve network retry configuration for maven and
artifact upload (#5782)
80c8ec37af is described below
commit 80c8ec37af422db1952a1128cb23043c9bbfbb72
Author: Oleks V <[email protected]>
AuthorDate: Wed Sep 9 18:27:19 2026 -0700
chore: Improve network retry configuration for maven and artifact upload
(#5782)
---
.github/actions/java-test/action.yaml | 9 +-
.github/actions/upload-artifact-retry/action.yaml | 145 ++++++++++++++++++++
.github/workflows/README.md | 60 +++++++++
.github/workflows/ci.yml | 3 +
.github/workflows/docs.yaml | 2 +-
.github/workflows/iceberg_spark_test_reusable.yml | 14 +-
.github/workflows/pr_build_linux.yml | 2 +-
.github/workflows/pr_build_macos.yml | 4 +-
.github/workflows/pyarrow_udf_test.yml | 2 +-
.github/workflows/spark_sql_test_reusable.yml | 22 ++-
.mvn/maven.config | 38 ++++++
dev/ci/check-ci-config.py | 157 ++++++++++++++++++++++
dev/ci/compute-changes.py | 26 ++++
13 files changed, 465 insertions(+), 19 deletions(-)
diff --git a/.github/actions/java-test/action.yaml
b/.github/actions/java-test/action.yaml
index 1af66f7019..ef640da3e2 100644
--- a/.github/actions/java-test/action.yaml
+++ b/.github/actions/java-test/action.yaml
@@ -136,7 +136,10 @@ 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()
- uses: actions/upload-artifact@v6
+ # 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@v7
with:
name: crash-logs-${{ inputs.artifact_name }}
path: "**/hs_err_pid*.log"
@@ -155,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"
diff --git a/.github/actions/upload-artifact-retry/action.yaml
b/.github/actions/upload-artifact-retry/action.yaml
new file mode 100644
index 0000000000..5bba453a90
--- /dev/null
+++ b/.github/actions/upload-artifact-retry/action.yaml
@@ -0,0 +1,145 @@
+# 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 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.
+
+ 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.
+inputs:
+ 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'
+
+# `||` yields the first non-empty operand, so this picks whichever attempt ran.
+outputs:
+ artifact-id:
+ 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'
+ 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 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
+ 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 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, 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' }}
+ 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 1953248a8b..16f0d33373 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -130,6 +130,66 @@ dict at the top of `dev/ci/compute-changes.py`. The
`changes` job in
`needs.changes.outputs.<name>`. 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
+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. 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
+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/ci.yml b/.github/workflows/ci.yml
index a4484def25..bdc1b1a14f 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/docs.yaml b/.github/workflows/docs.yaml
index a7dd1acffd..645b73b374 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/iceberg_spark_test_reusable.yml
b/.github/workflows/iceberg_spark_test_reusable.yml
index c0ae5427e7..6a268bc129 100644
--- a/.github/workflows/iceberg_spark_test_reusable.yml
+++ b/.github/workflows/iceberg_spark_test_reusable.yml
@@ -110,9 +110,13 @@ 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
+ # 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/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index 35709c7274..b9781ee93d 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 52e1e27764..c729a0cf06 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:
@@ -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/pyarrow_udf_test.yml
b/.github/workflows/pyarrow_udf_test.yml
index 6af67aa7e3..d75c5d2774 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:
diff --git a/.github/workflows/spark_sql_test_reusable.yml
b/.github/workflows/spark_sql_test_reusable.yml
index 5d4f8f0473..52b9d1208c 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-<full>-jdk<N>: libcomet.so (~50 MB)
# - jvm-compiled-spark-<full>-jdk<N>: 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
@@ -104,9 +109,9 @@ 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
+ name: native-lib-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }}
path: native/target/ci/libcomet.so
retention-days: 1
@@ -156,11 +161,14 @@ 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
+ # 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:
needs: build
@@ -190,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
@@ -265,7 +273,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 +292,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 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
new file mode 100644
index 0000000000..6c17056dc7
--- /dev/null
+++ b/.mvn/maven.config
@@ -0,0 +1,38 @@
+# 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 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.
+
+# Retries per request, for I/O errors and for the status codes below. Default
3.
+-Daether.connector.http.retryHandler.count=6
+
+# 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, default 10s. HttpClient classifies a connect timeout as
+# non-retryable, so the budget itself has to survive a busy runner.
+-Daether.connector.connectTimeout=30000
+
+# 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
diff --git a/dev/ci/check-ci-config.py b/dev/ci/check-ci-config.py
new file mode 100644
index 0000000000..fb48400175
--- /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 c385fb8412..89d117b06c 100644
--- a/dev/ci/compute-changes.py
+++ b/dev/ci/compute-changes.py
@@ -43,6 +43,7 @@ FILTERS = {
".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 @@ FILTERS = {
".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 @@ FILTERS = {
".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 @@ FILTERS = {
".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 @@ FILTERS = {
".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 @@ FILTERS = {
".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 @@ FILTERS = {
"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 @@ FILTERS = {
"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 @@ FILTERS = {
"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 @@ FILTERS = {
"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",
],
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]