Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/actions/java-test/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
145 changes: 145 additions & 0 deletions .github/actions/upload-artifact-retry/action.yaml
Original file line number Diff line number Diff line change
@@ -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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness

[P2] Give parallel producers distinct artifact names before retrying with overwrite

ci.yml runs the Linux native build and the Spark 3.5/4.1 builds in parallel, and all three upload native-lib-linux. The v7 delete selector selects the newest artifact with that name and sends its producer's job ID to DeleteArtifact. The pinned action documentation also explicitly supports overwriting an artifact from a different job. If one producer finishes while another fails before creating its artifact, this retry can delete the completed producer's artifact. Consumers depend only on their own producer and can then fail lookup during the replacement upload, or if that upload also fails. Please make the producer and download names unique per workflow/version before enabling forced overwrite. The parallel Iceberg versions also share native-lib-iceberg when those jobs are enabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5db3fc7. Each producer now qualifies the name with its version inputs, and the consumers follow:

  • spark_sql_test_reusable.yml: native-lib-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }}, matching the jvm-compiled-spark-<full>-jdk<N> the same job already publishes.
  • iceberg_spark_test_reusable.yml: native-lib-iceberg-${{ inputs.iceberg-full }}-spark-${{ inputs.spark-full }}-jdk${{ inputs.java }}.

native-lib-linux in pr_build_linux.yml and native-lib-macos in pr_build_macos.yml keep their bare names: ci.yml calls each of those workflows exactly once, so after the rename each has a single producer in the run.

Since a shared name is invisible until a retry lands on it, the invariant is now enforced rather than just fixed. dev/ci/check-ci-config.py (new, run from preflight) fails when a reusable workflow that ci.yml calls more than once uploads an artifact whose name carries no inputs. reference, and when a download-artifact name is not produced by an upload in the same workflow. I checked it by fault injection: reverting just the Spark producer back to native-lib-linux reports both the shared name and the now-orphaned consumer.

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 }}
60 changes: 60 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions .github/workflows/iceberg_spark_test_reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr_build_linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pr_build_macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pyarrow_udf_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading