diff --git a/.github/actions/java-test/action.yaml b/.github/actions/java-test/action.yaml index 1af66f7019d..5a9cae3808e 100644 --- a/.github/actions/java-test/action.yaml +++ b/.github/actions/java-test/action.yaml @@ -76,45 +76,8 @@ runs: restore-keys: | ${{ runner.os }}-java-maven- - # Maven itself is stored outside the dependency repository. Keep its cache - # independent of pom.xml changes and preserve the macOS cache workaround. - - name: Restore Maven distribution - id: maven-distribution - if: ${{ runner.os != 'macOS' }} - uses: actions/cache/restore@v5 - with: - path: | - ~/.m2/wrapper/dists - /root/.m2/wrapper/dists - key: ${{ runner.os }}-${{ runner.arch }}-maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} - - # Retry only the wrapper download, never compilation or test execution. - # Delays use exponential backoff (10s, 20s, 40s) plus 0-4s of random jitter. - - name: Bootstrap Maven - shell: bash - run: | - for attempt in 1 2 3 4; do - if ./mvnw -B --version; then - break - fi - if [ "$attempt" -eq 4 ]; then - echo "::error::Maven bootstrap failed after $attempt attempts; tests were not started." - exit 1 - fi - delay=$((10 * (1 << (attempt - 1)) + RANDOM % 5)) - echo "::warning::Maven bootstrap attempt $attempt failed; retrying in ${delay}s." - sleep "$delay" - done - - # Save a successful bootstrap even when the subsequent tests fail. - - name: Save Maven distribution - if: ${{ runner.os != 'macOS' && steps.maven-distribution.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v5 - with: - path: | - ~/.m2/wrapper/dists - /root/.m2/wrapper/dists - key: ${{ steps.maven-distribution.outputs.cache-primary-key }} + - name: Setup Maven + uses: ./.github/actions/setup-maven - name: Run all tests shell: bash @@ -123,7 +86,7 @@ runs: SPARK_LOCAL_HOSTNAME: "localhost" SPARK_LOCAL_IP: "127.0.0.1" run: | - MAVEN_OPTS="-Xmx4G -Xms2G -XX:+UnlockDiagnosticVMOptions -XX:+ShowMessageBoxOnError -XX:+HeapDumpOnOutOfMemoryError -XX:ErrorFile=./hs_err_pid%p.log" SPARK_HOME=`pwd` ./mvnw -B -Prelease install ${{ inputs.maven_opts }} + MAVEN_OPTS="${MAVEN_OPTS:-} -Xmx4G -Xms2G -XX:+UnlockDiagnosticVMOptions -XX:+ShowMessageBoxOnError -XX:+HeapDumpOnOutOfMemoryError -XX:ErrorFile=./hs_err_pid%p.log" SPARK_HOME=`pwd` ./mvnw -B -Prelease install ${{ inputs.maven_opts }} - name: Run specified tests shell: bash if: ${{ inputs.suites != '' }} @@ -133,7 +96,7 @@ runs: run: | MAVEN_SUITES="$(echo "${{ inputs.suites }}" | paste -sd, -)" echo "Running with MAVEN_SUITES=$MAVEN_SUITES" - 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 }} + MAVEN_OPTS="${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 diff --git a/.github/actions/rust-test/action.yaml b/.github/actions/rust-test/action.yaml index c39c2dcd4f9..49d6680d6d8 100644 --- a/.github/actions/rust-test/action.yaml +++ b/.github/actions/rust-test/action.yaml @@ -51,6 +51,9 @@ runs: restore-keys: | ${{ runner.os }}-rust-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Build common module (pre-requisite for Rust tests) shell: bash run: | diff --git a/.github/actions/setup-maven/action.yaml b/.github/actions/setup-maven/action.yaml new file mode 100644 index 00000000000..51cf015c556 --- /dev/null +++ b/.github/actions/setup-maven/action.yaml @@ -0,0 +1,85 @@ +# 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: Setup Maven +description: 'Restore and bootstrap the Maven wrapper before building or testing' +runs: + using: "composite" + steps: + # Maven 3.9.6 uses Resolver's native HTTP transport. Retry individual + # transfers rather than rerunning Maven goals (and potentially their tests). + # MAVEN_ARGS is not read by our wrapper; carry these JVM properties through + # MAVEN_OPTS, including callers that also configure their heap there. + - name: Configure Maven transfer retries + shell: bash + run: | + retry_options="-Daether.connector.http.retryHandler.count=3" + retry_options+=" -Daether.connector.http.retryHandler.interval=5000" + retry_options+=" -Daether.connector.http.retryHandler.intervalMax=60000" + retry_options+=" -Daether.connector.http.retryHandler.serviceUnavailable=429,500,502,503,504" + # JVM options are whitespace-separated. Normalize multiline YAML env + # values before writing GitHub's single-line environment-file format. + maven_options="${MAVEN_OPTS:-}" + maven_options="${maven_options//$'\r'/ }" + maven_options="${maven_options//$'\n'/ }" + printf 'MAVEN_OPTS=%s %s\n' "$maven_options" "$retry_options" >> "$GITHUB_ENV" + + # Maven itself is stored outside the dependency repository. Keep its cache + # independent of pom.xml changes and preserve the macOS cache workaround. + # Globbing /root on a host runner aborts cache save with EACCES, so select + # only the directory this JVM's wrapper actually uses. + - name: Locate Maven distribution cache + id: maven-cache-path + if: ${{ runner.os != 'macOS' }} + shell: bash + run: | + cache_path=$(bash "$GITHUB_ACTION_PATH/cache-path.sh") + printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT" + + - name: Restore Maven distribution + id: maven-distribution + if: ${{ runner.os != 'macOS' }} + uses: actions/cache/restore@v5 + with: + path: ${{ steps.maven-cache-path.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + # Retry only the wrapper download, never compilation or test execution. + # Delays use exponential backoff (10s, 20s, 40s) plus 0-4s of random jitter. + - name: Bootstrap Maven + shell: bash + run: | + for attempt in 1 2 3 4; do + if ./mvnw -B --version; then + break + fi + if [ "$attempt" -eq 4 ]; then + echo "::error::Maven bootstrap failed after $attempt attempts; tests were not started." + exit 1 + fi + delay=$((10 * (1 << (attempt - 1)) + RANDOM % 5)) + echo "::warning::Maven bootstrap attempt $attempt failed; retrying in ${delay}s." + sleep "$delay" + done + + # Save a successful bootstrap even when the subsequent tests fail. + - name: Save Maven distribution + if: ${{ runner.os != 'macOS' && steps.maven-distribution.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + with: + path: ${{ steps.maven-cache-path.outputs.path }} + key: ${{ steps.maven-distribution.outputs.cache-primary-key }} diff --git a/.github/actions/setup-maven/cache-path.sh b/.github/actions/setup-maven/cache-path.sh new file mode 100755 index 00000000000..42bc77f9bda --- /dev/null +++ b/.github/actions/setup-maven/cache-path.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# 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. + +set -euo pipefail + +# Match mvnw's JVM and option order. In job containers, shell HOME is +# /github/home while the JVM's user.home (and the wrapper cache) is /root. +java_command="${JAVACMD:-${JAVA_HOME:+$JAVA_HOME/bin/}java}" +java_options="${MAVEN_OPTS:-}" +if [ -f .mvn/jvm.config ]; then + java_options="$(tr '\n' ' ' < .mvn/jvm.config) $java_options" +fi +java_options="${java_options//$'\r'/ }" +java_options="${java_options//$'\n'/ }" +read -r -a java_arguments <<< "$java_options" +properties=$("$java_command" "${java_arguments[@]}" -XshowSettings:properties -version 2>&1) || { + status=$? + printf '%s\n' "$properties" >&2 + exit "$status" +} + +# Wrapper 3.2.0 prefers the JVM property, then the environment override, +# then user.home/.m2. Both its distribution and ZIP paths use wrapper/dists. +maven_user_home=$(sed -n 's/^[[:space:]]*maven\.user\.home = //p' <<< "$properties") +maven_user_home="${maven_user_home:-${MAVEN_USER_HOME:-}}" +if [ -z "$maven_user_home" ]; then + jvm_user_home=$(sed -n 's/^[[:space:]]*user\.home = //p' <<< "$properties") + if [ -z "$jvm_user_home" ]; then + echo "Could not determine the Maven wrapper cache directory from JVM properties." >&2 + exit 1 + fi + maven_user_home="$jvm_user_home/.m2" +fi +printf '%s/wrapper/dists\n' "${maven_user_home%/}" diff --git a/.github/actions/setup-spark-builder/action.yaml b/.github/actions/setup-spark-builder/action.yaml index 84804c0a798..582974e986a 100644 --- a/.github/actions/setup-spark-builder/action.yaml +++ b/.github/actions/setup-spark-builder/action.yaml @@ -32,6 +32,10 @@ inputs: description: 'Skip cloning Spark and applying patches (when apache-spark/ is pre-staged from a build-jvm artifact)' required: false default: 'false' + sbt-projects: + description: 'Space-separated Spark projects whose test dependencies are needed' + required: false + default: 'catalyst sql hive' runs: using: "composite" steps: @@ -61,6 +65,9 @@ runs: restore-keys: | ${{ runner.os }}-spark-sql- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Build Comet (with native) if: ${{ inputs.skip-native-build != 'true' }} shell: bash @@ -93,3 +100,52 @@ runs: "$(dirname "$pom")/_remote.repositories" done done + + - name: Remove incomplete Parquet test dependencies + shell: bash + run: | + # Maven installs Parquet POMs and main JARs, but not all test classifiers. + # Coursier treats those entries as local and will not fetch the missing + # tests.jar remotely. Do this before dependency resolution, not tests. + rm -rf "$HOME/.m2/repository/org/apache/parquet" /root/.m2/repository/org/apache/parquet + + - name: Restore SBT dependency cache + id: sbt-dependencies + uses: actions/cache/restore@v6 + with: + path: | + ~/.cache/coursier + /root/.cache/coursier + ~/.sbt/boot + /root/.sbt/boot + ~/.ivy2/cache + /root/.ivy2/cache + key: ${{ runner.os }}-${{ runner.arch }}-spark-sbt-v1-${{ inputs.spark-version }}-${{ hashFiles('**/pom.xml', '!**/target/**', 'apache-spark/project/build.properties', 'apache-spark/project/*.sbt', 'apache-spark/project/*.scala') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-spark-sbt-v1-${{ inputs.spark-version }}- + + - name: Resolve Spark dependencies + shell: bash + env: + SBT_PROJECTS: ${{ inputs.sbt-projects }} + run: | + cd apache-spark + read -r -a projects <<< "$SBT_PROJECTS" + ../dev/ci/resolve-spark-dependencies.sh "${projects[@]}" + + # Keep a successful download even if a later compilation or test fails. + # Only the build job, which resolves all three projects, may publish this + # shared immutable key. A writer-only or shard job must not fill it with + # partial coverage and prevent the full build from saving its downloads. + - name: Save SBT dependency cache + if: inputs.sbt-projects == 'catalyst sql hive' && steps.sbt-dependencies.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ~/.cache/coursier + /root/.cache/coursier + ~/.sbt/boot + /root/.sbt/boot + ~/.ivy2/cache + /root/.ivy2/cache + key: ${{ steps.sbt-dependencies.outputs.cache-primary-key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5243c4aaec4..9f360c67512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: distribution: temurin java-version: 11 + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Apache RAT license check run: ./mvnw -B -N apache-rat:check @@ -88,6 +91,13 @@ jobs: - name: Check micro benchmark runner run: python3 dev/ci/check-benchmark-runner.py + - name: Check CI download handling + env: + COMET_TEST_MAVEN_DOWNLOADS: "1" + run: | + python3 dev/ci/test-download-retry.py + python3 dev/ci/test-delta-gate.py + - name: Install actionlint run: | curl -sSfL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash diff --git a/.github/workflows/delta_build_gate.yml b/.github/workflows/delta_build_gate.yml index 245c2d26738..1f570651ae3 100644 --- a/.github/workflows/delta_build_gate.yml +++ b/.github/workflows/delta_build_gate.yml @@ -87,6 +87,32 @@ jobs: rust-version: ${{ env.RUST_VERSION }} jdk-version: 17 + - name: Compute Delta Maven cache key + id: delta-maven-cache-key + run: echo "hash=${{ hashFiles('**/pom.xml', '.mvn/wrapper/maven-wrapper.properties') }}" >> "$GITHUB_OUTPUT" + + - name: Cache Delta Maven dependencies + uses: actions/cache@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-${{ runner.arch }}-delta-maven-jdk17-${{ steps.delta-maven-cache-key.outputs.hash }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-delta-maven-jdk17- + + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Run dev/verify-contrib-delta-gate.sh run: | dev/verify-contrib-delta-gate.sh + + - name: Upload Delta gate logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: delta-build-gate-logs + path: artifactlog/delta-build-gate/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 1ca45266304..e475de5f140 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -49,6 +49,9 @@ jobs: java-version: '17' cache: 'maven' + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Install dependencies run: | set -x diff --git a/.github/workflows/iceberg_spark_test_reusable.yml b/.github/workflows/iceberg_spark_test_reusable.yml index 4b72cf47c2b..37552810a28 100644 --- a/.github/workflows/iceberg_spark_test_reusable.yml +++ b/.github/workflows/iceberg_spark_test_reusable.yml @@ -129,6 +129,8 @@ jobs: with: name: native-lib-iceberg path: native/target/release/ + - name: Setup Maven + uses: ./.github/actions/setup-maven - name: Build Comet run: | ./mvnw install -Prelease -DskipTests -Pspark-${{ inputs.spark-short }} -Pscala-${{ inputs.scala }} @@ -164,6 +166,8 @@ jobs: with: name: native-lib-iceberg path: native/target/release/ + - name: Setup Maven + uses: ./.github/actions/setup-maven - name: Build Comet run: | ./mvnw install -Prelease -DskipTests -Pspark-${{ inputs.spark-short }} -Pscala-${{ inputs.scala }} @@ -199,6 +203,8 @@ jobs: with: name: native-lib-iceberg path: native/target/release/ + - name: Setup Maven + uses: ./.github/actions/setup-maven - name: Build Comet run: | ./mvnw install -Prelease -DskipTests -Pspark-${{ inputs.spark-short }} -Pscala-${{ inputs.scala }} diff --git a/.github/workflows/pr_benchmark_check.yml b/.github/workflows/pr_benchmark_check.yml index 933714d9d72..91df16ccdee 100644 --- a/.github/workflows/pr_benchmark_check.yml +++ b/.github/workflows/pr_benchmark_check.yml @@ -69,6 +69,9 @@ jobs: restore-keys: | ${{ runner.os }}-benchmark-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Check Scala compilation and linting # Pin to spark-4.0 (Scala 2.13.16) because the default profile is now # spark-4.1 / Scala 2.13.17, and semanticdb-scalac_2.13.17 is not yet diff --git a/.github/workflows/pr_build_linux.yml b/.github/workflows/pr_build_linux.yml index b7c88254c93..42a273879dc 100644 --- a/.github/workflows/pr_build_linux.yml +++ b/.github/workflows/pr_build_linux.yml @@ -119,6 +119,9 @@ jobs: restore-keys: | ${{ runner.os }}-java-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Run scalafix check run: | ./mvnw -B package -DskipTests scalafix:scalafix -Dscalafix.mode=CHECK -Psemanticdb ${{ matrix.profile.maven_opts }} @@ -173,6 +176,9 @@ jobs: restore-keys: | ${{ runner.os }}-java-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Compile (skip tests) run: ./mvnw -B install -DskipTests -Dmaven.test.skip=true -Pspark-4.1 @@ -492,6 +498,9 @@ jobs: restore-keys: | ${{ runner.os }}-java-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Cache TPC-H data id: cache-tpch uses: actions/cache@v6 @@ -506,7 +515,7 @@ jobs: - name: Generate TPC-H data (SF=1) if: steps.cache-tpch.outputs.cache-hit != 'true' run: | - cd spark && MAVEN_OPTS='-Xmx20g' ../mvnw -B -Prelease exec:java -Dexec.mainClass="org.apache.spark.sql.GenTPCHData" -Dexec.classpathScope="test" -Dexec.cleanupDaemonThreads="false" -Dexec.args="--location `pwd`/.. --scaleFactor 1 --numPartitions 1 --overwrite" + cd spark && MAVEN_OPTS="${MAVEN_OPTS:-} -Xmx20g" ../mvnw -B -Prelease exec:java -Dexec.mainClass="org.apache.spark.sql.GenTPCHData" -Dexec.classpathScope="test" -Dexec.cleanupDaemonThreads="false" -Dexec.args="--location `pwd`/.. --scaleFactor 1 --numPartitions 1 --overwrite" - name: Run TPC-H queries run: | @@ -547,6 +556,9 @@ jobs: restore-keys: | ${{ runner.os }}-java-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Cache TPC-DS data id: cache-tpcds uses: actions/cache@v6 @@ -575,7 +587,7 @@ jobs: - name: Generate TPC-DS data (SF=1) if: steps.cache-tpcds.outputs.cache-hit != 'true' run: | - cd spark && MAVEN_OPTS='-Xmx20g' ../mvnw -B -Prelease exec:java -Dexec.mainClass="org.apache.spark.sql.GenTPCDSData" -Dexec.classpathScope="test" -Dexec.cleanupDaemonThreads="false" -Dexec.args="--dsdgenDir `pwd`/../tpcds-kit/tools --location `pwd`/../tpcds-sf-1 --scaleFactor 1 --numPartitions 1" + cd spark && MAVEN_OPTS="${MAVEN_OPTS:-} -Xmx20g" ../mvnw -B -Prelease exec:java -Dexec.mainClass="org.apache.spark.sql.GenTPCDSData" -Dexec.classpathScope="test" -Dexec.cleanupDaemonThreads="false" -Dexec.args="--dsdgenDir `pwd`/../tpcds-kit/tools --location `pwd`/../tpcds-sf-1 --scaleFactor 1 --numPartitions 1" - name: Run TPC-DS queries (Sort merge join) run: | diff --git a/.github/workflows/pyarrow_udf_test.yml b/.github/workflows/pyarrow_udf_test.yml index 1a03962685d..8b52c70e2a2 100644 --- a/.github/workflows/pyarrow_udf_test.yml +++ b/.github/workflows/pyarrow_udf_test.yml @@ -43,6 +43,7 @@ on: - "spark/src/test/resources/pyspark/test_pyarrow_udf.py" - "spark/src/test/spark-3.5/org/apache/spark/sql/comet/CometMapInBatchSuite.scala" - "spark/src/test/spark-4.x/org/apache/spark/sql/comet/CometMapInBatchSuite.scala" + - ".github/actions/setup-maven/**" - ".github/workflows/pyarrow_udf_test.yml" pull_request: paths: *feature-paths @@ -100,6 +101,9 @@ jobs: restore-keys: | ${{ runner.os }}-java-maven- + - name: Setup Maven + uses: ./.github/actions/setup-maven + - name: Build Comet (debug, ${{ matrix.name }} / Scala 2.13) run: | cd native && cargo build diff --git a/.github/workflows/spark_sql_test_reusable.yml b/.github/workflows/spark_sql_test_reusable.yml index 5d4f8f0473e..74f43aebd80 100644 --- a/.github/workflows/spark_sql_test_reusable.yml +++ b/.github/workflows/spark_sql_test_reusable.yml @@ -129,13 +129,7 @@ jobs: - name: Pre-compile Spark Test classes run: | cd apache-spark - # Mirror the workaround from `Run Spark tests` below: Comet's mvn - # install populates partial Parquet entries (main JAR + POM but no - # `*-tests.jar` classifier). Coursier then sees the POM in - # mavenLocal, declares the artifact "found locally", and refuses - # to fall back to Maven Central for the missing test classifier. - # Wiping the parquet cache forces a clean remote fetch. - rm -rf /root/.m2/repository/org/apache/parquet + # setup-spark-builder already resolved these projects' dependencies. # Compile Test sources for the three subprojects the matrix touches. # SBT will transitively compile Compile/compile of their dependencies # plus any Test/compile pulled in by `dependsOn(... % "test->test")`, @@ -167,14 +161,14 @@ jobs: strategy: matrix: module: - - {name: "catalyst", args1: "catalyst/test", args2: ""} + - {name: "catalyst", project: "catalyst", args1: "catalyst/test", args2: ""} # sql_core-* set HEAP_SIZE / METASPACE_SIZE so SparkBuild.scala caps - - {name: "sql_core-1", args1: "", args2: "sql/testOnly * -- -l org.apache.spark.tags.ExtendedSQLTest -l org.apache.spark.tags.SlowSQLTest", heap: "3g", metaspace: "1g"} - - {name: "sql_core-2", args1: "", args2: "sql/testOnly * -- -n org.apache.spark.tags.ExtendedSQLTest", heap: "3g", metaspace: "1g"} - - {name: "sql_core-3", args1: "", args2: "sql/testOnly * -- -n org.apache.spark.tags.SlowSQLTest", heap: "3g", metaspace: "1g"} - - {name: "sql_hive-1", args1: "", args2: "hive/testOnly * -- -l org.apache.spark.tags.ExtendedHiveTest -l org.apache.spark.tags.SlowHiveTest"} - - {name: "sql_hive-2", args1: "", args2: "hive/testOnly * -- -n org.apache.spark.tags.ExtendedHiveTest"} - - {name: "sql_hive-3", args1: "", args2: "hive/testOnly * -- -n org.apache.spark.tags.SlowHiveTest"} + - {name: "sql_core-1", project: "sql", args1: "", args2: "sql/testOnly * -- -l org.apache.spark.tags.ExtendedSQLTest -l org.apache.spark.tags.SlowSQLTest", heap: "3g", metaspace: "1g"} + - {name: "sql_core-2", project: "sql", args1: "", args2: "sql/testOnly * -- -n org.apache.spark.tags.ExtendedSQLTest", heap: "3g", metaspace: "1g"} + - {name: "sql_core-3", project: "sql", args1: "", args2: "sql/testOnly * -- -n org.apache.spark.tags.SlowSQLTest", heap: "3g", metaspace: "1g"} + - {name: "sql_hive-1", project: "hive", args1: "", args2: "hive/testOnly * -- -l org.apache.spark.tags.ExtendedHiveTest -l org.apache.spark.tags.SlowHiveTest"} + - {name: "sql_hive-2", project: "hive", args1: "", args2: "hive/testOnly * -- -n org.apache.spark.tags.ExtendedHiveTest"} + - {name: "sql_hive-3", project: "hive", args1: "", args2: "hive/testOnly * -- -n org.apache.spark.tags.SlowHiveTest"} fail-fast: false name: spark-sql-${{ matrix.module.name }}/spark-${{ inputs.spark-full }}-jdk${{ inputs.java }} runs-on: ubuntu-24.04 @@ -208,10 +202,10 @@ jobs: spark-short-version: ${{ inputs.spark-short }} skip-native-build: true skip-spark-clone: true + sbt-projects: ${{ matrix.module.project }} - name: Run Spark tests run: | cd apache-spark - rm -rf /root/.m2/repository/org/apache/parquet # somehow parquet cache requires cleanups # set SBTOPTS printf -- '-J-Xms1g\n-J-Xmx4g\n-J-XX:MaxMetaspaceSize=1g\n' > .sbtopts export SERIAL_SBT_TESTS=1 diff --git a/.github/workflows/spark_sql_writer_tests.yml b/.github/workflows/spark_sql_writer_tests.yml index c813a8b39e1..f697e17adce 100644 --- a/.github/workflows/spark_sql_writer_tests.yml +++ b/.github/workflows/spark_sql_writer_tests.yml @@ -116,11 +116,11 @@ jobs: spark-version: ${{ steps.resolve.outputs.spark-full }} spark-short-version: ${{ inputs.spark-version }} skip-native-build: true + sbt-projects: sql - name: Run Parquet writer tests run: | cd apache-spark - rm -rf /root/.m2/repository/org/apache/parquet # somehow parquet cache requires cleanups # SERIAL_SBT_TESTS gates SparkParallelTestGrouping in # project/SparkBuild.scala. We always set it to reduce peak memory # on standard 7 GB runners (3.5 and 4.1 are unaffected by the diff --git a/dev/ci/compute-changes.py b/dev/ci/compute-changes.py index 9b7cd2f1691..5e4c977d09c 100644 --- a/dev/ci/compute-changes.py +++ b/dev/ci/compute-changes.py @@ -41,6 +41,7 @@ ".github/workflows/ci.yml", ".github/workflows/pr_build_linux.yml", ".github/actions/setup-builder/**", + ".github/actions/setup-maven/**", ".github/actions/java-test/**", ".github/actions/rust-test/**", "!**.md", @@ -64,6 +65,7 @@ ".github/workflows/ci.yml", ".github/workflows/pr_build_macos.yml", ".github/actions/setup-macos-builder/**", + ".github/actions/setup-maven/**", ".github/actions/java-test/**", "!**.md", "!native/core/benches/**", @@ -72,11 +74,13 @@ "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", ], "benchmark": [ + ".github/actions/setup-maven/**", "native/core/benches/**", "native/spark-expr/benches/**", "spark/src/test/scala/org/apache/spark/sql/benchmark/**", ], "docs": [ + ".github/actions/setup-maven/**", ".asf.yaml", ".github/workflows/docs.yaml", "docs/**", @@ -104,12 +108,15 @@ "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", "spark/pom.xml", "dev/diffs/3.4.3.diff", + "dev/ci/retry-download.sh", + "dev/ci/resolve-spark-dependencies.sh", "pom.xml", "rust-toolchain.toml", ".github/workflows/ci.yml", ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/setup-maven/**", ], "spark_3_5": [ "native/**/src/**", @@ -126,12 +133,15 @@ "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", "spark/pom.xml", "dev/diffs/3.5.9.diff", + "dev/ci/retry-download.sh", + "dev/ci/resolve-spark-dependencies.sh", "pom.xml", "rust-toolchain.toml", ".github/workflows/ci.yml", ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/setup-maven/**", ], "spark_4_0": [ "native/**/src/**", @@ -148,12 +158,15 @@ "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", "spark/pom.xml", "dev/diffs/4.0.4.diff", + "dev/ci/retry-download.sh", + "dev/ci/resolve-spark-dependencies.sh", "pom.xml", "rust-toolchain.toml", ".github/workflows/ci.yml", ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/setup-maven/**", ], "spark_4_1": [ "native/**/src/**", @@ -170,12 +183,15 @@ "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", "spark/pom.xml", "dev/diffs/4.1.3.diff", + "dev/ci/retry-download.sh", + "dev/ci/resolve-spark-dependencies.sh", "pom.xml", "rust-toolchain.toml", ".github/workflows/ci.yml", ".github/workflows/spark_sql_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-spark-builder/**", + ".github/actions/setup-maven/**", ], "iceberg_1_8": [ "native/**/src/**", @@ -193,6 +209,7 @@ ".github/workflows/iceberg_spark_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-iceberg-builder/**", + ".github/actions/setup-maven/**", ], "iceberg_1_9": [ "native/**/src/**", @@ -210,6 +227,7 @@ ".github/workflows/iceberg_spark_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-iceberg-builder/**", + ".github/actions/setup-maven/**", ], "iceberg_1_10": [ "native/**/src/**", @@ -227,6 +245,7 @@ ".github/workflows/iceberg_spark_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-iceberg-builder/**", + ".github/actions/setup-maven/**", ], "iceberg_1_11": [ "native/**/src/**", @@ -244,6 +263,7 @@ ".github/workflows/iceberg_spark_test_reusable.yml", ".github/actions/setup-builder/**", ".github/actions/setup-iceberg-builder/**", + ".github/actions/setup-maven/**", ], } diff --git a/dev/ci/resolve-spark-dependencies.sh b/dev/ci/resolve-spark-dependencies.sh new file mode 100755 index 00000000000..fe3fe11c260 --- /dev/null +++ b/dev/ci/resolve-spark-dependencies.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# 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. + +# Run from the patched Spark checkout after Comet's Maven install. SBT's +# update task resolves dependencies without compiling Spark or running tests. +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 catalyst|sql|hive [...]" >&2 + exit 2 +fi + +update_tasks=() +for project in "$@"; do + case "$project" in + catalyst|sql|hive) update_tasks+=("$project/Test/update") ;; + *) echo "Unsupported Spark dependency project: $project" >&2; exit 2 ;; + esac +done + +script_dir=$(cd "$(dirname "$0")" && pwd) +export NOLINT_ON_COMPILE=true +exec "$script_dir/retry-download.sh" build/sbt -batch -Dsbt.log.noformat=true \ + -mem 1024 "${update_tasks[@]}" diff --git a/dev/ci/retry-download.sh b/dev/ci/retry-download.sh new file mode 100755 index 00000000000..3f2bfde123b --- /dev/null +++ b/dev/ci/retry-download.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# 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. + +# Only wrap dependency acquisition or tool bootstrap, never a build or test. +# A missing artifact, a bad build definition, and other permanent failures must +# still fail immediately. Keep the output visible and preserve the exit status. +set -uo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: $0 command [argument ...]" >&2 + exit 2 +fi + +download_log=$(mktemp "${TMPDIR:-/tmp}/comet-download.XXXXXX") || exit 1 +trap 'rm -f "$download_log"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +for attempt in 1 2 3 4; do + "$@" 2>&1 | tee "$download_log" + command_status=("${PIPESTATUS[@]}") + status=${command_status[0]} + if [ "${command_status[1]}" -ne 0 ]; then + echo "Could not capture dependency download output." >&2 + exit "${command_status[1]}" + fi + if [ "$status" -eq 0 ]; then + exit 0 + fi + + # Signals (including cancellation and OOM kills) are not download failures. + if [ "$status" -ge 128 ] || ! grep -Eiq \ + '(status code:|response code:|HTTP/[0-9.]+|HTTP error|returned error:)[[:space:]]*(429|500|502|503|504)([^0-9]|$)|Connection reset|Connection timed out|ConnectTimeoutException|SocketTimeoutException|Read timed out|Temporary failure in name resolution|Network is unreachable|Remote host terminated the handshake' \ + "$download_log"; then + exit "$status" + fi + if [ "$attempt" -eq 4 ]; then + echo "::error::Dependency download failed after $attempt attempts." + exit "$status" + fi + + delay=$((10 * (1 << (attempt - 1)) + RANDOM % 5)) + echo "::warning::Transient download failure; retrying in ${delay}s (attempt $attempt of 4)." + sleep "$delay" || exit "$?" +done diff --git a/dev/ci/test-delta-gate.py b/dev/ci/test-delta-gate.py new file mode 100644 index 00000000000..8d7ca8e99ed --- /dev/null +++ b/dev/ci/test-delta-gate.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# +# 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. + +"""Run the actual Delta gate and retry helper without network or build tools.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +REPO = Path(__file__).resolve().parents[2] +PROFILES = ("spark-4.1", "spark-4.1,contrib-delta", "spark-3.5,contrib-delta", "spark-4.0,contrib-delta") + +# All fake commands record their arguments. Only Maven model resolution can +# recover; a compiler failure includes the same network text to catch retries +# accidentally being widened to compilation or tests. +FAKE_TOOL = r''' +import json, os, sys +from pathlib import Path + +tool, args = Path(sys.argv[0]).name, sys.argv[1:] +root = Path(os.environ["COMET_TEST_GATE_ROOT"]) +scenario = os.environ["COMET_TEST_GATE_SCENARIO"] +calls_file = root / "calls.jsonl" +calls = [json.loads(line) for line in calls_file.read_text().splitlines()] if calls_file.exists() else [] +with calls_file.open("a") as out: + out.write(json.dumps([tool, *args]) + "\n") + +def compile_stage(name): + if scenario == name: + for stream in (sys.stdout, sys.stderr): + for index in range(100): + print(f"compiler output {index}", file=stream) + print("Connection reset during compiler fixture", file=sys.stderr) + sys.exit(71) + +if tool == "sleep": + sys.exit(0) +if tool == "nm": + if Path(args[0]).read_bytes().endswith(b"C"): + print("000001 T comet_contrib_delta") + sys.exit(0) +if tool == "cargo": + if args[0] == "tree": + print("datafusion-comet 0.1.0") + print("Downloading delta_kernel registry metadata fixture", file=sys.stderr) + if "--features" in args: + print("comet-contrib-delta 0.1.0") + elif args[0] == "build": + contrib = "--features" in args + compile_stage("cargo-build-contrib" if contrib else "cargo-build-default") + lib = root / "native/target/debug/libcomet.so" + lib.parent.mkdir(parents=True, exist_ok=True) + lib.write_bytes(b"N" * 100 + (b"C" * 100 if contrib else b"")) + sys.exit(0) + +assert tool == "mvnw", tool +if "help:effective-pom" in args: + profile = next(arg[2:] for arg in args if arg.startswith("-P")) + attempt = 1 + sum(call[0] == "mvnw" and "-P" + profile in call for call in calls) + if scenario == "permanent:" + profile: + print("Permanent Maven model failure: " + profile, file=sys.stderr) + sys.exit(73) + if profile == "spark-4.1" and (scenario == "exhausted" or (scenario == "recover" and attempt <= 2)): + print(f"Could not transfer artifact: Connection reset attempt {attempt}", file=sys.stderr) + sys.exit(74) + pom = Path(next(arg[len("-Doutput="):] for arg in args if arg.startswith("-Doutput="))) + lines = ["", "", "", "", + "", "org.apache.spark", ""] + if "contrib-delta" in profile: + version = {"spark-4.1": "4.1.0", "spark-3.5": "3.3.2", "spark-4.0": "4.0.0"}[profile.split(",")[0]] + lines += ["", "io.delta", "delta-spark_2.13", + f"{version}", ""] + lines += ["", "", "io.delta", "", ""] + pom.write_text("\n".join(lines) + "\n") + print("effective-pom success: " + profile) +else: + assert "test-compile" in args, args + compile_stage("maven-test-compile") + classes = root / "spark/target/classes" + classes.mkdir(parents=True, exist_ok=True) + if scenario == "leaked-class": + leaked = classes / "org/apache/comet/contrib/Delta.class" + leaked.parent.mkdir(parents=True, exist_ok=True) + leaked.touch() +''' + + +class DeltaGateTest(unittest.TestCase): + def run_gate(self, scenario="success"): + with tempfile.TemporaryDirectory(prefix="comet-delta-gate-test-") as temp: + root = Path(temp) + (root / "dev/ci").mkdir(parents=True) + (root / "native").mkdir() + (root / "bin").mkdir() + for script in ("dev/verify-contrib-delta-gate.sh", "dev/ci/retry-download.sh"): + shutil.copy2(REPO / script, root / script) + for tool in ("mvnw", "cargo", "nm", "sleep"): + path = root / ("mvnw" if tool == "mvnw" else "bin/" + tool) + path.write_text(f"#!{sys.executable}\n" + FAKE_TOOL) + path.chmod(0o755) + env = dict( + os.environ, + PATH=f"{root / 'bin'}{os.pathsep}{os.environ['PATH']}", + COMET_TEST_GATE_ROOT=str(root), + COMET_TEST_GATE_SCENARIO=scenario, + COMET_DELTA_GATE_LOG_DIR=str(root / "logs"), + ) + result = subprocess.run( + ["bash", str(root / "dev/verify-contrib-delta-gate.sh")], + cwd=root, env=env, capture_output=True, text=True, timeout=20, + ) + calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()] + logs = {path.name: path.read_text() for path in (root / "logs").glob("*.log")} + return result, calls, logs + + def test_normal_gate_keeps_download_stderr_out_of_dependency_tree(self): + result, calls, logs = self.run_gate() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("All gate checks passed", result.stdout) + self.assertIn("delta_kernel", logs["cargo-tree-default.stderr.log"]) + self.assertFalse(any(call[0] == "sleep" for call in calls)) + + def test_permanent_model_failures_keep_each_profile_status(self): + for profile in PROFILES: + with self.subTest(profile=profile): + result, calls, _ = self.run_gate("permanent:" + profile) + self.assertEqual(result.returncode, 73, result.stdout + result.stderr) + self.assertIn("Permanent Maven model failure: " + profile, result.stderr) + self.assertEqual(sum("help:effective-pom" in call and "-P" + profile in call for call in calls), 1) + self.assertFalse(any(call[0] == "sleep" or "test-compile" in call for call in calls)) + + def test_transient_model_download_recovers(self): + result, calls, logs = self.run_gate("recover") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + model_calls = [call for call in calls if "help:effective-pom" in call] + self.assertEqual(sum("-Pspark-4.1" in call for call in model_calls), 3) + self.assertTrue(all("-U" in call for call in model_calls)) + self.assertEqual(sum(call[0] == "sleep" for call in calls), 2) + self.assertIn("All gate checks passed", result.stdout) + log = logs["maven-default-spark-4.1.stdout.log"] + for message in ("attempt 1", "attempt 2", "effective-pom success"): + self.assertIn(message, log) + + def test_transient_model_download_exhaustion_stays_fatal(self): + result, calls, logs = self.run_gate("exhausted") + self.assertEqual(result.returncode, 74, result.stdout + result.stderr) + self.assertEqual(sum("help:effective-pom" in call for call in calls), 4) + self.assertEqual(sum(call[0] == "sleep" for call in calls), 3) + self.assertFalse(any("test-compile" in call for call in calls)) + self.assertIn("failed after 4 attempts", result.stderr) + self.assertIn("attempt 1", logs["maven-default-spark-4.1.stdout.log"]) + self.assertIn("attempt 4", logs["maven-default-spark-4.1.stdout.log"]) + + def test_compilation_is_not_retried_and_retains_complete_logs(self): + for stage in ("maven-test-compile", "cargo-build-default", "cargo-build-contrib"): + with self.subTest(stage=stage): + result, calls, logs = self.run_gate(stage) + self.assertEqual(result.returncode, 71, result.stdout + result.stderr) + self.assertFalse(any(call[0] == "sleep" for call in calls)) + if stage == "maven-test-compile": + attempts = [call for call in calls if "test-compile" in call] + else: + attempts = [call for call in calls if call[:2] == ["cargo", "build"] + and ("--features" in call) == (stage == "cargo-build-contrib")] + self.assertEqual(len(attempts), 1) + for stream in ("stdout", "stderr"): + expected = [f"compiler output {index}" for index in range(100)] + if stream == "stderr": + expected.append("Connection reset during compiler fixture") + self.assertEqual(logs[f"{stage}.{stream}.log"].splitlines(), expected) + self.assertIn("Connection reset during compiler fixture", result.stderr) + self.assertNotIn("compiler output 0\n", result.stdout + result.stderr) # Bounded console tail. + + def test_delta_class_leak_still_fails_gate(self): + result, calls, _ = self.run_gate("leaked-class") + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("default Maven build compiled contrib classes", result.stdout) + self.assertFalse(any(call[:2] == ["cargo", "build"] for call in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/ci/test-download-retry.py b/dev/ci/test-download-retry.py new file mode 100644 index 00000000000..12c320a57a8 --- /dev/null +++ b/dev/ci/test-download-retry.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +# +# 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. + +"""Regression tests for CI download handling; cache-path checks require Java.""" + +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import unittest + + +RETRY = Path(__file__).with_name("retry-download.sh") +RESOLVE_SPARK = Path(__file__).with_name("resolve-spark-dependencies.sh") +REPO = Path(__file__).resolve().parents[2] +MAVEN_CACHE_PATH = REPO / ".github/actions/setup-maven/cache-path.sh" + + +class DownloadRetryTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="comet-download-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.calls = self.root / "calls.jsonl" + self.delays = self.root / "delays" + self.command = self.root / "download.py" + self.command.write_text( + f"#!{sys.executable}\n" + "import json, os, pathlib, sys\n" + "calls = pathlib.Path(os.environ['COMET_TEST_CALLS'])\n" + "attempt = len(calls.read_text().splitlines()) if calls.exists() else 0\n" + "with calls.open('a') as out: out.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "failures = int(os.environ['COMET_TEST_FAILURES'])\n" + "print('download output', flush=True)\n" + "if attempt < failures:\n" + " print(os.environ['COMET_TEST_ERROR'], file=sys.stderr)\n" + " sys.exit(int(os.environ['COMET_TEST_STATUS']))\n" + ) + self.command.chmod(0o755) + (self.root / "build").mkdir() + (self.root / "build" / "sbt").symlink_to(self.command) + sleeper = self.root / "sleep" + sleeper.write_text( + '#!/bin/sh\nprintf "%s\\n" "$1" >> "$COMET_TEST_DELAYS"\n' + 'exit "${COMET_TEST_SLEEP_STATUS:-0}"\n' + ) + sleeper.chmod(0o755) + + def run_download( + self, message="Connection reset", failures=0, status=17, + args=(), projects=None, sleep_status=0 + ): + env = dict( + os.environ, + PATH=f"{self.root}{os.pathsep}{os.environ['PATH']}", + COMET_TEST_CALLS=str(self.calls), + COMET_TEST_DELAYS=str(self.delays), + COMET_TEST_ERROR=message, + COMET_TEST_FAILURES=str(failures), + COMET_TEST_STATUS=str(status), + COMET_TEST_SLEEP_STATUS=str(sleep_status), + ) + command = ["bash", str(RETRY), sys.executable, str(self.command), *args] + if projects is not None: + command = ["bash", str(RESOLVE_SPARK), *projects] + result = subprocess.run( + command, + cwd=self.root, + env=env, + capture_output=True, + text=True, + timeout=15, + ) + calls = ( + [json.loads(line) for line in self.calls.read_text().splitlines()] + if self.calls.exists() else [] + ) + delays = ( + [int(line) for line in self.delays.read_text().splitlines()] + if self.delays.exists() else [] + ) + return result, calls, delays + + def assert_backoff(self, delays): + for index, delay in enumerate(delays): + self.assertIn(delay, range(10 * 2**index, 10 * 2**index + 5)) + + def test_success_does_not_retry_and_preserves_arguments(self): + args = ("one argument", "*", "-Dkey=literal $value") + result, calls, delays = self.run_download(args=args) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(calls, [list(args)]) + self.assertEqual(delays, []) + self.assertIn("download output", result.stdout) + + def test_transient_download_errors_retry(self): + messages = ( + "Could not transfer artifact: Connection reset", + "Server returned HTTP response code: 502 for URL: https://repo.invalid/a.jar", + "status code: 429, reason phrase: Too Many Requests (429)", + "status code: 500, reason phrase: Internal Server Error (500)", + "HTTP/1.1 503 Service Unavailable", + "curl: (22) The requested URL returned error: 504", + "java.net.SocketTimeoutException: Read timed out", + "Network is unreachable (os error 101)", + ) + for message in messages: + with self.subTest(message=message): + self.calls.unlink(missing_ok=True) + self.delays.unlink(missing_ok=True) + result, calls, delays = self.run_download(message, failures=2) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(len(calls), 3) + self.assertEqual(len(delays), 2) + self.assert_backoff(delays) + self.assertIn(message, result.stdout) + + def test_fourth_attempt_can_succeed(self): + result, calls, delays = self.run_download(failures=3) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(len(calls), 4) + self.assertEqual(len(delays), 3) + self.assert_backoff(delays) + + def test_exhaustion_preserves_exit_code_without_extra_sleep(self): + result, calls, delays = self.run_download(failures=10, status=23) + self.assertEqual(result.returncode, 23) + self.assertEqual(len(calls), 4) + self.assertEqual(len(delays), 3) + self.assertIn("failed after 4 attempts", result.stdout) + + def test_permanent_failures_do_not_retry(self): + for message in ( + "status code: 404, reason phrase: Not Found (404)", + "status code: 401, reason phrase: Unauthorized (401)", + "[error] Could not find artifact missing:dependency:jar:1.0", + "[error] not found: value invalidBuildSetting", + "[ERROR] COMPILATION ERROR", + "[ERROR] There are test failures.", + ): + with self.subTest(message=message): + self.calls.unlink(missing_ok=True) + self.delays.unlink(missing_ok=True) + result, calls, delays = self.run_download(message, failures=10, status=42) + self.assertEqual(result.returncode, 42) + self.assertEqual(len(calls), 1) + self.assertEqual(delays, []) + + def test_signal_exit_does_not_retry_even_after_network_message(self): + for status in (130, 137, 139, 143): + with self.subTest(status=status): + self.calls.unlink(missing_ok=True) + self.delays.unlink(missing_ok=True) + result, calls, delays = self.run_download(failures=10, status=status) + self.assertEqual(result.returncode, status) + self.assertEqual(len(calls), 1) + self.assertEqual(delays, []) + + def test_missing_command_fails(self): + result = subprocess.run(["bash", str(RETRY)], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn("Usage:", result.stderr) + + def test_interrupted_backoff_does_not_start_another_attempt(self): + result, calls, delays = self.run_download(failures=1, sleep_status=143) + self.assertEqual(result.returncode, 143) + self.assertEqual(len(calls), 1) + self.assertEqual(len(delays), 1) + + def test_spark_retries_dependency_tasks_only(self): + result, calls, delays = self.run_download( + "Server returned HTTP response code: 502", failures=2, projects=("sql", "hive") + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + expected = [ + "-batch", "-Dsbt.log.noformat=true", "-mem", "1024", + "sql/Test/update", "hive/Test/update" + ] + self.assertEqual(calls, [expected] * 3) + self.assertEqual(len(delays), 2) + self.assert_backoff(delays) + + def test_spark_build_definition_failure_does_not_retry(self): + result, calls, delays = self.run_download( + "[error] not found: value invalidBuildSetting", failures=10, projects=("catalyst",) + ) + self.assertEqual(result.returncode, 17) + self.assertEqual(len(calls), 1) + self.assertEqual(delays, []) + + def test_spark_rejects_commands_outside_dependency_resolution(self): + for projects in ((), ("sql/test",), ("sql", "hive/Test/compile")): + with self.subTest(projects=projects): + result, calls, delays = self.run_download(projects=projects) + self.assertEqual(result.returncode, 2) + self.assertEqual(calls, []) + self.assertEqual(delays, []) + + +class MavenCachePathTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="comet-maven-cache-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.java = shutil.which("java") + self.assertIsNotNone(self.java, "Maven cache-path tests require Java") + + def cache_path(self, **overrides): + env = dict(os.environ) + for name in ( + "MAVEN_OPTS", "MAVEN_USER_HOME", "JAVA_HOME", "JAVACMD", + "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS", + ): + env.pop(name, None) + env["JAVACMD"] = self.java + env.update(overrides) + return subprocess.run( + ["bash", str(MAVEN_CACHE_PATH)], cwd=self.root, env=env, + capture_output=True, text=True, timeout=15, + ) + + def test_host_runner_caches_only_its_accessible_distribution(self): + runner_home = self.root / "runner" + distribution = runner_home / ".m2/wrapper/dists" + distribution.mkdir(parents=True) + marker = distribution / "downloaded-maven" + marker.write_text("cached") + result = self.cache_path(MAVEN_OPTS=f"-Duser.home={runner_home}") + self.assertEqual(result.returncode, 0, result.stderr) + paths = result.stdout.splitlines() + self.assertEqual(paths, [str(distribution)]) + # Every emitted path can be traversed without touching /root. The old + # shared list aborted cache save with EACCES on the host runner. + self.assertEqual([p for path in paths for p in Path(path).iterdir()], [marker]) + + def test_container_uses_jvm_home_instead_of_shell_home(self): + result = self.cache_path(MAVEN_OPTS="-Duser.home=/root") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "/root/.m2/wrapper/dists") + + def test_uses_java_home_or_path_when_javacmd_is_unset(self): + runner_home = self.root / "runner" + for java_home in (str(Path(self.java).resolve().parents[1]), ""): + with self.subTest(java_home=java_home): + result = self.cache_path( + JAVACMD="", JAVA_HOME=java_home, + MAVEN_OPTS=f"-Duser.home={runner_home}", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(runner_home / ".m2/wrapper/dists")) + + def test_wrapper_home_overrides_follow_wrapper_precedence(self): + configured_home = self.root / "wrapper" + env_home = self.root / "wrapper with spaces" + (self.root / ".mvn").mkdir() + config = self.root / ".mvn/jvm.config" + config.write_text(f"-Dmaven.user.home={configured_home}\n-Xmx128m\n") + result = self.cache_path(MAVEN_USER_HOME=str(env_home)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(configured_home / "wrapper/dists")) + config.unlink() + result = self.cache_path(MAVEN_USER_HOME=str(env_home)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(env_home / "wrapper/dists")) + + def test_maven_opts_override_jvm_config_and_javacmd_overrides_java_home(self): + configured_home = self.root / "configured" + effective_home = self.root / "effective" + (self.root / ".mvn").mkdir() + (self.root / ".mvn/jvm.config").write_text(f"-Dmaven.user.home={configured_home}\n") + result = self.cache_path( + JAVA_HOME=str(self.root / "missing-jdk"), + MAVEN_OPTS=f"-Xmx128m\n-Dmaven.user.home={effective_home}", + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(effective_home / "wrapper/dists")) + + def test_invalid_jvm_options_fail_instead_of_caching_a_guessed_path(self): + result = self.cache_path(MAVEN_OPTS="-XX:CometInvalidOption") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, "") + self.assertIn("CometInvalidOption", result.stderr) + + +@unittest.skipUnless( + os.environ.get("COMET_TEST_MAVEN_DOWNLOADS") == "1", + "requires Maven bootstrapped/configured by the setup-maven action", +) +class MavenTransferRetryTest(unittest.TestCase): + """Exercise CI's actual MAVEN_OPTS with Maven, not a simulated resolver. + + The local mirror serves a parent POM. `validate` needs no plugins, does not + compile/run tests, and all artifact requests stay on this loopback server. + Preflight already bootstrapped Maven; only shorten the retry delay here. + """ + + def test_transfer_retries_are_bounded_and_permanent_failures_still_fail(self): + parent = ( + '' + '4.0.0comet.ci' + 'parent1' + 'pom' + ).encode() + state = {} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + if self.path.endswith(".pom"): + state["requests"] += 1 + if state["requests"] <= state["failures"]: + self.send_response(state["status"]) + self.send_header("Content-Length", "0") + self.end_headers() + return + body = parent + elif self.path.endswith(".sha1"): + body = hashlib.sha1(parent).hexdigest().encode() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.addCleanup(server.server_close) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.shutdown) + with tempfile.TemporaryDirectory(prefix="comet-maven-test-") as directory: + root = Path(directory) + (root / "pom.xml").write_text( + '' + '4.0.0comet.ci' + 'parent1' + 'childpom' + ) + (root / "settings.xml").write_text( + 'test*' + f'http://127.0.0.1:{server.server_port}' + '' + ) + env = dict(os.environ) + env["MAVEN_OPTS"] = ( + env.get("MAVEN_OPTS", "") + + " -Daether.connector.http.retryHandler.interval=100" + ) + for status, failures, expected_requests, succeeds in ( + (429, 2, 3, True), (502, 2, 3, True), + (503, 10, 4, False), (404, 10, 1, False), + ): + with self.subTest(status=status): + state.update(status=status, failures=failures, requests=0) + result = subprocess.run( + [ + str(REPO / "mvnw"), "-B", "-ntp", + "-f", str(root / "pom.xml"), + "-s", str(root / "settings.xml"), + f"-Dmaven.repo.local={root / str(status)}", "validate", + ], + cwd=REPO, env=env, capture_output=True, text=True, timeout=30, + ) + self.assertEqual( + result.returncode == 0, succeeds, + result.stdout + result.stderr, + ) + self.assertEqual(state["requests"], expected_requests) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/verify-contrib-delta-gate.sh b/dev/verify-contrib-delta-gate.sh index 3ce3a63ae01..5a8a51035fe 100755 --- a/dev/verify-contrib-delta-gate.sh +++ b/dev/verify-contrib-delta-gate.sh @@ -66,20 +66,41 @@ export GIT_CONFIG_COUNT=$((GIT_CONFIG_IDX + 1)) # launch, produce no dependency output, and trip the anti-vacuous guard below. # `./mvnw` self-provisions the pinned Maven version regardless of the image. MVNW="$ROOT/mvnw" +RETRY_DOWNLOAD="$ROOT/dev/ci/retry-download.sh" + +# Keep a fixed set of command logs outside Maven's target directories, which the +# compiled-class gate cleans. CI uploads this directory if any gate fails. +LOG_DIR="${COMET_DELTA_GATE_LOG_DIR:-$ROOT/artifactlog/delta-build-gate}" +mkdir -p "$LOG_DIR" +LOG_DIR="$(cd "$LOG_DIR" && pwd)" red() { printf '\033[31m%s\033[0m\n' "$*"; } green() { printf '\033[32m%s\033[0m\n' "$*"; } hdr() { printf '\n\033[36m==> %s\033[0m\n' "$*"; } +# Keep stdout separate so cargo-tree output can still be inspected without +# mistaking dependency-download messages on stderr for entries in the tree. +run_logged() { # args: log name, command, arguments + local name="$1" + shift + local status + if "$@" >"$LOG_DIR/$name.stdout.log" 2>"$LOG_DIR/$name.stderr.log"; then + cat "$LOG_DIR/$name.stdout.log" + else + status=$? + red "FAIL: $name exited with status $status (logs: $LOG_DIR/$name.*.log)" >&2 + tail -n 60 "$LOG_DIR/$name.stdout.log" "$LOG_DIR/$name.stderr.log" >&2 || true + return "$status" + fi +} + # ---- Cargo gate ----------------------------------------------------------- hdr "Cargo: default build does not depend on comet-contrib-delta / delta_kernel" cd "$NATIVE_DIR" -TREE_DEFAULT="$(cargo tree -p datafusion-comet --no-default-features 2>/dev/null)" -# Anti-vacuous (mirrors the Maven gate below): a failing `cargo tree` yields empty output, and the -# command-substitution failure doesn't trip `set -e` in an assignment -- so assert the root crate we -# KNOW is always present before concluding "no Delta deps", otherwise a broken cargo-tree run would -# pass the leak check vacuously. (`datafusion-comet ` with a trailing space matches only the root +TREE_DEFAULT="$(run_logged cargo-tree-default cargo tree -p datafusion-comet --no-default-features)" +# Also reject unexpectedly empty output after a successful command before concluding +# "no Delta deps". (`datafusion-comet ` with a trailing space matches only the root # crate line, not `datafusion-comet-proto`/`-common`.) if ! grep -q 'datafusion-comet ' <<<"$TREE_DEFAULT"; then red "FAIL: default cargo tree produced no datafusion-comet entry (cargo tree likely failed;" @@ -93,7 +114,7 @@ if grep -qE 'comet-contrib-delta|delta_kernel|delta-kernel' <<<"$TREE_DEFAULT"; fi green "OK: cargo tree default is clean of contrib + kernel" -TREE_CONTRIB="$(cargo tree -p datafusion-comet --features contrib-delta 2>/dev/null)" +TREE_CONTRIB="$(run_logged cargo-tree-contrib cargo tree -p datafusion-comet --features contrib-delta)" # The build-gate unit ships a STUB contrib crate, so the gated tree pulls in # `comet-contrib-delta` but not yet the heavy `delta_kernel` (that arrives with the # native read-path unit). Require the contrib crate to be present; the symbol check @@ -111,37 +132,57 @@ hdr "Maven: default profile excludes io.delta:* dependencies" cd "$ROOT" # `dependency:list` can't run in a fresh CI checkout: it needs the sibling reactor JARs # (comet-common, the shims) which aren't built, so it fails with a resolution error and no -# output. `help:effective-pom` only merges POM models (no artifact resolution), so it works -# without a build. We extract the ACTIVE top-level -- after , +# output. `help:effective-pom` only merges POM models (no reactor JAR resolution), so it works +# without a build, but its Maven plugins still need to be downloaded. We extract the ACTIVE +# top-level -- after , # before the listing -- which is exactly what dependency:list would have shown for the # active profiles (and excludes the inactive contrib-delta profile's own io.delta declaration). -# Last effective-pom invocation's combined output, kept so the anti-vacuous guard can SHOW why -# mvn failed instead of swallowing it. -EPOM_LOG="$(mktemp)" -delta_active_deps() { # args: -P / -D flags - local epom - epom="$(mktemp)" - "$MVNW" help:effective-pom -Djava.version=17 -Dmaven.gitcommitid.skip -pl spark \ - -Doutput="$epom" "$@" >"$EPOM_LOG" 2>&1 || true - awk '/<\/dependencyManagement>/{f=1} //{f=0} f' "$epom" - rm -f "$epom" +delta_active_deps() { # args: log name, -P / -D flags + local name="$1" + shift + local epom="$LOG_DIR/$name.pom.log" + : >"$epom" + # Retry only transient download failures during model/plugin resolution. -U + # lets a retry resolve an artifact whose earlier transfer failure was cached. + # Compilation and tests below are deliberately not retried. + if run_logged "$name" "$RETRY_DOWNLOAD" "$MVNW" -U help:effective-pom \ + -Djava.version=17 -Dmaven.gitcommitid.skip -pl spark \ + -Doutput="$epom" "$@" >/dev/null; then + awk '/<\/dependencyManagement>/{f=1} //{f=0} f' "$epom" + else + return $? + fi } # Resolved delta-spark version from the active deps (empty if absent). -delta_spark_version() { # args: -P flags - delta_active_deps "$@" | - grep -A2 'artifactId>delta-spark' | - grep -oE '[^<]+' | sed -n '1s///p' +delta_spark_version() { # args: log name, -P flags + local deps + deps="$(delta_active_deps "$@")" || return $? + # Keep an absent version empty so the profile-specific assertion below reports + # it, while an actual Maven failure retains its status and diagnostic log. + awk ' + /artifactId>delta-spark/ { remaining = 3 } + remaining > 0 { + if (//) { + sub(/.*/, "") + sub(/<.*/, "") + print + exit + } + remaining-- + } + ' <<<"$deps" } -DEPS_DEFAULT="$(delta_active_deps -Pspark-4.1)" -# Anti-vacuous: a broken mvn run yields empty output; assert a dep we KNOW is always present so a -# broken run fails loudly instead of "passing" the io.delta check by finding nothing. +DEPS_DEFAULT="$(delta_active_deps maven-default-spark-4.1 -Pspark-4.1)" +# Even after Maven succeeds, assert a dep we KNOW is always present rather than +# "passing" the io.delta check on unexpectedly empty effective-pom output. if ! grep -q 'org.apache.spark' <<<"$DEPS_DEFAULT"; then - red "FAIL: default effective-pom produced no org.apache.spark deps (mvn likely failed;" - red " refusing to conclude 'zero io.delta' vacuously)" + red "FAIL: default effective-pom produced no org.apache.spark deps;" + red " refusing to conclude 'zero io.delta' vacuously" red " --- mvnw: $MVNW (java=${JAVA_HOME:-unset}) ---" red " --- effective-pom output (last 60 lines) ---" - tail -60 "$EPOM_LOG" >&2 || true + tail -n 60 "$LOG_DIR/maven-default-spark-4.1.stdout.log" \ + "$LOG_DIR/maven-default-spark-4.1.stderr.log" >&2 || true exit 1 fi if grep -q 'io.delta' <<<"$DEPS_DEFAULT"; then @@ -154,17 +195,17 @@ green "OK: default Maven build has zero io.delta dependencies" # Per-Spark Delta version pinning: spark-4.1 -> delta-spark 4.1.x, spark-3.5 -> 3.x, spark-4.0 -> # 4.0.x (Delta 4.1 needs Spark 4.1 internals; 4.0 must stay on 4.0.x to avoid a runtime # NoSuchMethodError on ParserInterface.$init$). -V41="$(delta_spark_version -Pspark-4.1,contrib-delta)" +V41="$(delta_spark_version maven-contrib-spark-4.1 -Pspark-4.1,contrib-delta)" case "$V41" in 4.1.*) green "OK: -Pcontrib-delta + spark-4.1 correctly pulls delta-spark $V41" ;; *) red "FAIL: -Pcontrib-delta + spark-4.1 expected delta-spark 4.1.x, got '${V41:-}'"; exit 1 ;; esac -V35="$(delta_spark_version -Pspark-3.5,contrib-delta)" +V35="$(delta_spark_version maven-contrib-spark-3.5 -Pspark-3.5,contrib-delta)" case "$V35" in 3.*) green "OK: -Pcontrib-delta + spark-3.5 correctly pulls delta-spark $V35" ;; *) red "FAIL: -Pcontrib-delta + spark-3.5 expected delta-spark 3.x, got '${V35:-}'"; exit 1 ;; esac -V40="$(delta_spark_version -Pspark-4.0,contrib-delta)" +V40="$(delta_spark_version maven-contrib-spark-4.0 -Pspark-4.0,contrib-delta)" case "$V40" in 4.0.*) green "OK: -Pcontrib-delta + spark-4.0 correctly pulls delta-spark $V40" ;; *) red "FAIL: -Pcontrib-delta + spark-4.0 expected delta-spark 4.0.x, got '${V40:-}'"; exit 1 ;; @@ -181,7 +222,7 @@ cd "$ROOT" # resources. Without `clean`, this gate reports a leak that is really just a stale artifact, and # a developer who sees one false FAIL learns to ignore the gate. Build from scratch so what we # find in target/classes is exactly what THIS default build produced. -"$MVNW" -Pspark-4.1 -Djava.version=17 -Dmaven.compiler.source=17 -Dmaven.compiler.target=17 -Dmaven.gitcommitid.skip -pl spark -am clean test-compile -q -DskipTests=true >/dev/null 2>&1 +run_logged maven-test-compile "$MVNW" -Pspark-4.1 -Djava.version=17 -Dmaven.compiler.source=17 -Dmaven.compiler.target=17 -Dmaven.gitcommitid.skip -pl spark -am clean test-compile -q -DskipTests=true LEAK_CLASSES="$(find spark/target/classes -path '*comet/contrib*' -name '*.class' 2>/dev/null)" if [[ -n "$LEAK_CLASSES" ]]; then red "FAIL: default Maven build compiled contrib classes:" @@ -251,8 +292,9 @@ delta_syms() { nm "$1" 2>/dev/null | grep -ciE 'comet_contrib_delta|delta_kernel|deltadvfilter|deltasynthetic' || true } -cargo clean -p comet-contrib-delta -p datafusion-comet >/dev/null 2>&1 || true -cargo build -j 4 -p datafusion-comet >/dev/null 2>&1 +cargo clean -p comet-contrib-delta -p datafusion-comet >"$LOG_DIR/cargo-clean.stdout.log" \ + 2>"$LOG_DIR/cargo-clean.stderr.log" || true +run_logged cargo-build-default cargo build -j 4 -p datafusion-comet LIB_DEFAULT="$(comet_lib)" if [[ -z "$LIB_DEFAULT" ]]; then red "FAIL: default build produced no libcomet.{so,dylib} under $NATIVE_DIR/target/debug" @@ -270,7 +312,7 @@ if [[ "$EXT_SYMS" -ne 0 ]]; then fi green "OK: default libcomet has 0 Delta symbols (size=$SIZE_DEFAULT bytes)" -cargo build -j 4 -p datafusion-comet --features contrib-delta >/dev/null 2>&1 +run_logged cargo-build-contrib cargo build -j 4 -p datafusion-comet --features contrib-delta LIB_CONTRIB="$(comet_lib)" SIZE_CONTRIB="$(lib_size "$LIB_CONTRIB")" if [[ "$SIZE_CONTRIB" -le "$SIZE_DEFAULT" ]]; then