This is an automated email from the ASF dual-hosted git repository.
sunchao 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 9b608cc509 ci: shard Iceberg Spark tests across four runners (#5459)
9b608cc509 is described below
commit 9b608cc50922bfeac83dde2199847d1c509967cb
Author: Chao Sun <[email protected]>
AuthorDate: Wed Sep 9 16:11:23 2026 -0700
ci: shard Iceberg Spark tests across four runners (#5459)
* ci: shard Iceberg Spark tests across four runners
* fix(ci): verify complete Iceberg shard inventories
---
.github/workflows/ci.yml | 3 +
.github/workflows/iceberg_spark_test_reusable.yml | 45 ++-
dev/ci/check-iceberg-shards.py | 313 +++++++++++++++++++++
dev/ci/compute-changes.py | 12 +
dev/ci/iceberg-test-shards.gradle | 110 ++++++++
dev/ci/test-iceberg-shards.py | 144 ++++++++++
.../contributor-guide/iceberg-spark-tests.md | 22 ++
7 files changed, 648 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 87a4ec53fd..a4484def25 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -91,6 +91,9 @@ jobs:
- name: Check pull request type labeling
run: node --test dev/ci/pr-type-label.test.mjs
+ - name: Check Iceberg shard inventory validation
+ run: python3 dev/ci/test-iceberg-shards.py
+
- name: Install actionlint
run: |
curl -sSfL
https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash
| bash
diff --git a/.github/workflows/iceberg_spark_test_reusable.yml
b/.github/workflows/iceberg_spark_test_reusable.yml
index 4b72cf47c2..c0ae5427e7 100644
--- a/.github/workflows/iceberg_spark_test_reusable.yml
+++ b/.github/workflows/iceberg_spark_test_reusable.yml
@@ -63,6 +63,9 @@ jobs:
build-native:
name: Build Native Library
runs-on: ubuntu-24.04
+ outputs:
+ shard-matrix: ${{ steps.shards.outputs.matrix }}
+ shard-count: ${{ steps.shards.outputs.count }}
container:
image: amd64/rust
steps:
@@ -74,6 +77,10 @@ jobs:
rust-version: ${{ env.RUST_VERSION }}
jdk-version: 17
+ - name: Define Iceberg test shards
+ id: shards
+ run: python3 dev/ci/check-iceberg-shards.py --github-output
"$GITHUB_OUTPUT"
+
- name: Restore Cargo cache
uses: actions/cache/restore@v6
with:
@@ -111,7 +118,10 @@ jobs:
iceberg-spark:
needs: build-native
- name: iceberg-spark/iceberg-${{ inputs.iceberg-full }}/spark-${{
inputs.spark-full }}/scala-${{ inputs.scala }}/java-${{ inputs.java }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.build-native.outputs.shard-matrix) }}
+ name: iceberg-spark/iceberg-${{ inputs.iceberg-full }}/spark-${{
inputs.spark-full }}/scala-${{ inputs.scala }}/java-${{ inputs.java
}}/shard-${{ matrix.shard }}
runs-on: ubuntu-24.04
container:
image: amd64/rust
@@ -142,7 +152,38 @@ jobs:
rm -rf /root/.m2/repository/org/apache/parquet # somehow parquet
cache requires cleanups
ENABLE_COMET=true ENABLE_COMET_ONHEAP=true ./gradlew
-DsparkVersions=${{ inputs.spark-short }} -DscalaVersion=${{ inputs.scala }}
-DflinkVersions= -DkafkaVersions= \
:iceberg-spark:iceberg-spark-${{ inputs.spark-short }}_${{
inputs.scala }}:test \
+ --init-script ../dev/ci/iceberg-test-shards.gradle \
+ -PcometShardTask=:iceberg-spark:iceberg-spark-${{
inputs.spark-short }}_${{ inputs.scala }}:test \
+ -PcometShardIndex=${{ matrix.shard }} -PcometShardCount=${{
needs.build-native.outputs.shard-count }} \
-Pquick=true -x javadoc
+ - name: Upload Iceberg shard inventory and test reports
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: iceberg-spark-${{ inputs.iceberg-full }}-spark-${{
inputs.spark-full }}-scala-${{ inputs.scala }}-jdk${{ inputs.java }}-shard-${{
matrix.shard }}-attempt-${{ github.run_attempt }}
+ path: |
+ apache-iceberg/**/build/comet-shards/*.json
+ apache-iceberg/**/build/test-results/test/*.xml
+ retention-days: 7
+
+ iceberg-spark-shard-coverage:
+ needs: iceberg-spark
+ if: ${{ !cancelled() }}
+ name: iceberg-spark-shard-coverage/iceberg-${{ inputs.iceberg-full
}}/spark-${{ inputs.spark-full }}/scala-${{ inputs.scala }}/java-${{
inputs.java }}
+ runs-on: ubuntu-slim
+ steps:
+ - uses: actions/checkout@v7
+ - name: Download Iceberg shard inventories
+ uses: actions/download-artifact@v8
+ with:
+ # Download all attempts: a failed-job rerun retains earlier
successful
+ # shards. The checker selects the latest inventory for each index.
+ pattern: iceberg-spark-${{ inputs.iceberg-full }}-spark-${{
inputs.spark-full }}-scala-${{ inputs.scala }}-jdk${{ inputs.java
}}-shard-*-attempt-*
+ path: iceberg-shard-reports
+ - name: Verify complete, disjoint Iceberg candidate coverage
+ run: |
+ python3 dev/ci/check-iceberg-shards.py --manifests
iceberg-shard-reports \
+ --task :iceberg-spark:iceberg-spark-${{ inputs.spark-short }}_${{
inputs.scala }}:test
iceberg-spark-extensions:
needs: build-native
@@ -206,6 +247,8 @@ jobs:
uses: ./.github/actions/setup-iceberg-builder
with:
iceberg-version: ${{ inputs.iceberg-full }}
+ - name: Verify test sharding preserves discovery
+ run: python3 dev/ci/check-iceberg-shards.py --gradle
"$PWD/apache-iceberg/gradlew"
- name: Run Iceberg Spark runtime tests
run: |
cd apache-iceberg
diff --git a/dev/ci/check-iceberg-shards.py b/dev/ci/check-iceberg-shards.py
new file mode 100644
index 0000000000..39de880a4c
--- /dev/null
+++ b/dev/ci/check-iceberg-shards.py
@@ -0,0 +1,313 @@
+# 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.
+
+"""Exercise the real Gradle/JUnit shard filter without building Spark or
Iceberg.
+
+Run with the Iceberg checkout's wrapper (or a Gradle executable):
+ python3 dev/ci/check-iceberg-shards.py --gradle
"$PWD/apache-iceberg/gradlew"
+
+The fixture downloads only JUnit. --junit-classpath accepts local JUnit jars
for
+offline checks. --work-dir retains the fixture, logs, and comparison results.
+--github-output exports the workflow matrix/count from one shared definition.
+--manifests verifies the real inventories downloaded from an Iceberg CI run.
+"""
+
+import argparse
+from collections import Counter
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import xml.etree.ElementTree as ET
+
+
+INIT_SCRIPT = Path(__file__).with_name("iceberg-test-shards.gradle")
+# The workflow matrix and Gradle's partition count must come from the same
value,
+# not strategy.job-total (which includes any other matrix dimensions).
+SHARD_COUNT = 4
+
+
+def verify_manifests(root, task):
+ """Check each latest shard attempt against its independently captured
baseline.
+
+ Rerunning only failed jobs leaves successful jobs' artifacts in an earlier
+ attempt. Keep those, but never count two attempts of the same shard twice.
+ Artifact download is scoped to one run and Iceberg/Spark/Scala/JDK tuple.
+ """
+ attempts = {}
+ for path in sorted(root.rglob("*.json")):
+ manifest = json.loads(path.read_text())
+ for key in ("shard", "count", "attempt"):
+ if type(manifest.get(key)) is not int or manifest[key] < 1:
+ raise ValueError(f"{path}: {key} must be a positive integer")
+ key = manifest["shard"], manifest["attempt"]
+ if key in attempts:
+ raise ValueError(f"Duplicate inventory for shard/attempt {key}:
{path}")
+ attempts[key] = manifest
+
+ expected = set(range(1, SHARD_COUNT + 1))
+ indices = {index for index, _ in attempts}
+ if indices != expected:
+ raise ValueError(f"Expected shard indices {sorted(expected)}, found
{sorted(indices)}")
+
+ baseline = None
+ combined = Counter()
+ for index in sorted(expected):
+ attempt = max(attempt for shard, attempt in attempts if shard == index)
+ manifest = attempts[index, attempt]
+ if manifest.get("task") != task or manifest["count"] != SHARD_COUNT:
+ raise ValueError(f"Shard {index}: mismatched task or shard count")
+ for field in ("candidates", "unshardedCandidates"):
+ values = manifest.get(field)
+ if not isinstance(values, list) or not all(isinstance(v, str) for
v in values):
+ raise ValueError(f"Shard {index}: {field} must be a list of
class paths")
+ if len(set(values)) != len(values):
+ raise ValueError(f"Shard {index}: duplicate class paths in
{field}")
+ inventory = set(manifest["unshardedCandidates"])
+ if not inventory:
+ raise ValueError(f"Shard {index}: empty unsharded candidate
inventory")
+ if baseline is not None and baseline != inventory:
+ raise ValueError(f"Shard {index}: unsharded inventories disagree")
+ baseline = inventory
+ combined.update(manifest["candidates"])
+
+ if set(combined) != baseline:
+ missing, extra = baseline - set(combined), set(combined) - baseline
+ raise ValueError(f"Shard coverage mismatch: missing={sorted(missing)},
extra={sorted(extra)}")
+ duplicates = sorted(name for name, count in combined.items() if count != 1)
+ if duplicates:
+ raise ValueError(f"Candidate classes selected by multiple shards:
{duplicates}")
+ print(f"Verified {SHARD_COUNT} shards and {len(baseline)} candidate
classes: "
+ "the selected inventories equal the unsharded inventory exactly
once.", flush=True)
+
+
+def write_workflow_matrix(output):
+ with output.open("a") as stream:
+ stream.write(f"matrix={json.dumps({'shard': list(range(1, SHARD_COUNT
+ 1))})}\n")
+ stream.write(f"count={SHARD_COUNT}\n")
+
+
+def groovy_string(value):
+ return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
+
+
+def create_fixture(root, junit_classpath):
+ (root / "settings.gradle").write_text("rootProject.name =
'comet-iceberg-shard-fixture'\n")
+ if junit_classpath:
+ jars = [str(Path(p).resolve()) for p in
junit_classpath.split(os.pathsep)]
+ if not all(Path(p).is_file() for p in jars):
+ raise ValueError("Every --junit-classpath entry must be an
existing jar")
+ dependencies = "testImplementation files(" + ",
".join(map(groovy_string, jars)) + ")"
+ else:
+ dependencies = """
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4'
+ """
+ (root / "build.gradle").write_text("""
+ import groovy.json.JsonOutput
+ plugins { id 'java' }
+ repositories { mavenCentral() }
+ dependencies { DEPENDENCIES }
+ test {
+ useJUnitPlatform { excludeTags 'excluded-tag' }
+ include '**/Test*.class'
+ exclude '**/TestExcludedByPattern.class', '**/TestOtherTask.class'
+ exclude { it.name == 'TestExcludedBySpec.class' }
+ systemProperty 'breakShardFixture',
project.findProperty('breakShardFixture') ?: 'false'
+ doFirst {
+ def candidates = new TreeSet<String>()
+ candidateClassFiles.visit { entry ->
+ if (!entry.directory && entry.name.endsWith('.class')) {
+ candidates.add(entry.relativePath.pathString)
+ }
+ }
+ file('build/candidates.json').text = JsonOutput.toJson(candidates)
+ }
+ }
+ tasks.register('otherTest', Test) {
+ testClassesDirs = sourceSets.test.output.classesDirs
+ classpath = sourceSets.test.runtimeClasspath
+ useJUnitPlatform()
+ include '**/TestOtherTask.class'
+ }
+ """.replace("DEPENDENCIES", dependencies))
+ source_dir = root / "src/test/java/fixture"
+ source_dir.mkdir(parents=True)
+ sources = {
+ "TestStructuredStreamingRead3": """
+ @org.junit.jupiter.params.ParameterizedTest
+ @org.junit.jupiter.params.provider.ValueSource(ints = {1, 2, 3})
+ void parameterized(int value) {
org.junit.jupiter.api.Assertions.assertTrue(value > 0); }
+ @org.junit.jupiter.api.Nested class Nested {
+ @org.junit.jupiter.api.Test void nested() {}
+ }
+ """,
+ "TestAlpha": "@org.junit.jupiter.api.Test void alpha() {}",
+ "TestBeta": "@org.junit.jupiter.api.Test void beta() {}",
+ "TestDelta": "@org.junit.jupiter.api.Test void delta() {}",
+ "TestNewlyAdded": "@org.junit.jupiter.api.Test void
automaticallyDiscovered() {}",
+ "TestInherited": "",
+ "TestDynamic": """
+ @org.junit.jupiter.api.TestFactory
java.util.stream.Stream<org.junit.jupiter.api.DynamicTest> generated() {
+ return java.util.stream.Stream.of("first", "second").map(name ->
+ org.junit.jupiter.api.DynamicTest.dynamicTest(name, () -> {}));
+ }
+ """,
+ "TestFailurePropagation": """
+ @org.junit.jupiter.api.Test void failurePropagates() {
+
org.junit.jupiter.api.Assertions.assertFalse(Boolean.getBoolean("breakShardFixture"));
+ }
+ """,
+ "TestOtherTask": "@org.junit.jupiter.api.Test void unaffectedTask()
{}",
+ "TestExcludedByPattern": "@org.junit.jupiter.api.Test void excluded()
{ throw new AssertionError(); }",
+ "TestExcludedBySpec": "@org.junit.jupiter.api.Test void excluded() {
throw new AssertionError(); }",
+ "NotIncluded": "@org.junit.jupiter.api.Test void excluded() { throw
new AssertionError(); }",
+ "TestExcludedByTag": """
+ @org.junit.jupiter.api.Tag("excluded-tag")
+ @org.junit.jupiter.api.Test void excluded() { throw new
AssertionError(); }
+ """,
+ }
+ for name, body in sources.items():
+ superclass = " extends FixtureBase" if name == "TestInherited" else ""
+ (source_dir / f"{name}.java").write_text(
+ f"package fixture;\npublic class {name}{superclass}
{{\n{body}\n}}\n")
+ (source_dir / "FixtureBase.java").write_text("""
+ package fixture;
+ abstract class FixtureBase {
+ @org.junit.jupiter.api.Test void inherited() {}
+ }
+ """)
+
+
+def read_cases(root, task):
+ cases = Counter()
+ for report in (root / "build/test-results" / task).glob("TEST-*.xml"):
+ for case in ET.parse(report).getroot().iter("testcase"):
+ state = "failed" if case.find("failure") is not None else "passed"
+ if case.find("skipped") is not None:
+ state = "skipped"
+ cases[case.attrib["classname"], case.attrib["name"], state] += 1
+ return cases
+
+
+def check(root, gradle, junit_classpath):
+ create_fixture(root, junit_classpath)
+ results = root / "results"
+ results.mkdir()
+ common = [gradle, "--project-dir", str(root), "--console=plain",
"--no-daemon",
+ "-Dorg.gradle.jvmargs=-Xmx256m", "--max-workers=2"]
+ if junit_classpath:
+ common.append("--offline")
+
+ def run(label, extra=(), task="test", expect_failure=False):
+ # Never let an UP-TO-DATE or NO-SOURCE task reuse another run's
reports.
+ reports = root / "build/test-results" / task
+ if reports.exists():
+ shutil.rmtree(reports)
+ if task == "test":
+ (root / "build/candidates.json").unlink(missing_ok=True)
+ completed = subprocess.run(common + [task] + list(extra), text=True,
+ stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
+ (results / f"{label}.log").write_text(completed.stdout)
+ if expect_failure:
+ if completed.returncode == 0:
+ raise AssertionError(f"{label}: expected Gradle to fail")
+ elif completed.returncode:
+ raise AssertionError(f"{label}
failed:\n{completed.stdout[-8000:]}")
+ print(f"{label}: {'expected failure' if expect_failure else
'passed'}", flush=True)
+ return read_cases(root, task)
+
+ def shard_args(index, count=SHARD_COUNT, task=":test"):
+ return ["--init-script", str(INIT_SCRIPT.resolve()),
f"-PcometShardTask={task}",
+ f"-PcometShardIndex={index}", f"-PcometShardCount={count}"]
+
+ baseline = run("baseline")
+ inventory = set(json.loads((root / "build/candidates.json").read_text()))
+ assert sum(baseline.values()) == 12, baseline
+ assert all(state == "passed" for _, _, state in baseline), baseline
+ assert any(cls == "fixture.TestNewlyAdded" for cls, _, _ in baseline)
+ assert any("$Nested" in cls for cls, _, _ in baseline)
+ assert all("Excluded" not in cls and "NotIncluded" not in cls for cls, _,
_ in baseline)
+
+ combined_cases = Counter()
+ combined_candidates = Counter()
+ failure_owner = None
+ for index in range(1, SHARD_COUNT + 1):
+ cases = run(f"shard-{index}", shard_args(index))
+ manifest = json.loads((root /
f"build/comet-shards/test-{index}.json").read_text())
+ candidates = json.loads((root / "build/candidates.json").read_text())
+ assert candidates == manifest["candidates"]
+ assert set(manifest["unshardedCandidates"]) == inventory
+ assert cases, f"empty fixture shard {index}"
+ combined_cases.update(cases)
+ combined_candidates.update(candidates)
+ if any(cls == "fixture.TestFailurePropagation" for cls, _, _ in cases):
+ failure_owner = index
+ (results / f"shard-{index}.json").write_text(json.dumps(manifest,
indent=2) + "\n")
+ assert set(combined_candidates) == inventory, (set(combined_candidates),
inventory)
+ assert set(combined_candidates.values()) == {1}, combined_candidates
+ assert combined_cases == baseline, (combined_cases, baseline)
+ verify_manifests(results, ":test")
+ assert run("single-shard", shard_args(1, count=1)) == baseline
+
+ other_baseline = run("other-baseline", task="otherTest")
+ assert sum(other_baseline.values()) == 1
+ assert run("other-unmodified", shard_args(1), task="otherTest") ==
other_baseline
+
+ failed = run("failure-propagation", shard_args(failure_owner) +
["-PbreakShardFixture=true"],
+ expect_failure=True)
+ assert any(cls == "fixture.TestFailurePropagation" and state == "failed"
+ for cls, _, state in failed), failed
+ run("invalid-index", shard_args(0), task="help", expect_failure=True)
+ run("unknown-task", shard_args(1, task=":missing"), task="help",
expect_failure=True)
+ print(f"Verified {len(inventory)} candidate classes and
{sum(baseline.values())} test cases: "
+ "the four shards equal the unsharded inventory exactly once.",
flush=True)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--gradle", default="gradle")
+ parser.add_argument("--junit-classpath")
+ parser.add_argument("--work-dir", type=Path)
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument("--github-output", type=Path)
+ mode.add_argument("--manifests", type=Path)
+ parser.add_argument("--task", help="Expected Gradle task path when
checking real manifests")
+ args = parser.parse_args()
+ if args.github_output:
+ write_workflow_matrix(args.github_output)
+ return
+ if args.manifests:
+ if not args.task:
+ parser.error("--manifests requires --task")
+ verify_manifests(args.manifests, args.task)
+ return
+ gradle = shutil.which(args.gradle)
+ if not gradle:
+ parser.error(f"Gradle executable not found: {args.gradle}")
+ if args.work_dir:
+ args.work_dir.mkdir(parents=True, exist_ok=False)
+ check(args.work_dir.resolve(), gradle, args.junit_classpath)
+ else:
+ with tempfile.TemporaryDirectory(prefix="comet-iceberg-shards-") as
tmp:
+ check(Path(tmp), gradle, args.junit_classpath)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/dev/ci/compute-changes.py b/dev/ci/compute-changes.py
index 9b7cd2f169..c385fb8412 100644
--- a/dev/ci/compute-changes.py
+++ b/dev/ci/compute-changes.py
@@ -193,6 +193,9 @@ FILTERS = {
".github/workflows/iceberg_spark_test_reusable.yml",
".github/actions/setup-builder/**",
".github/actions/setup-iceberg-builder/**",
+ "dev/ci/iceberg-test-shards.gradle",
+ "dev/ci/check-iceberg-shards.py",
+ "dev/ci/test-iceberg-shards.py",
],
"iceberg_1_9": [
"native/**/src/**",
@@ -210,6 +213,9 @@ FILTERS = {
".github/workflows/iceberg_spark_test_reusable.yml",
".github/actions/setup-builder/**",
".github/actions/setup-iceberg-builder/**",
+ "dev/ci/iceberg-test-shards.gradle",
+ "dev/ci/check-iceberg-shards.py",
+ "dev/ci/test-iceberg-shards.py",
],
"iceberg_1_10": [
"native/**/src/**",
@@ -227,6 +233,9 @@ FILTERS = {
".github/workflows/iceberg_spark_test_reusable.yml",
".github/actions/setup-builder/**",
".github/actions/setup-iceberg-builder/**",
+ "dev/ci/iceberg-test-shards.gradle",
+ "dev/ci/check-iceberg-shards.py",
+ "dev/ci/test-iceberg-shards.py",
],
"iceberg_1_11": [
"native/**/src/**",
@@ -244,6 +253,9 @@ FILTERS = {
".github/workflows/iceberg_spark_test_reusable.yml",
".github/actions/setup-builder/**",
".github/actions/setup-iceberg-builder/**",
+ "dev/ci/iceberg-test-shards.gradle",
+ "dev/ci/check-iceberg-shards.py",
+ "dev/ci/test-iceberg-shards.py",
],
}
diff --git a/dev/ci/iceberg-test-shards.gradle
b/dev/ci/iceberg-test-shards.gradle
new file mode 100644
index 0000000000..e8a39a073a
--- /dev/null
+++ b/dev/ci/iceberg-test-shards.gradle
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+import groovy.json.JsonOutput
+import org.gradle.api.file.FileTreeElement
+import org.gradle.api.tasks.testing.Test
+
+// An exclusion predicate partitions Gradle's existing candidates without
broadening
+// its includes, replacing its exclusions, or changing JUnit's
discovery/parameters.
+// All classes remain on the test classpath, including inherited test fixtures.
+class CometIcebergTestShard {
+ static int owner(String classFileName, int count) {
+ if (count == 1) {
+ return 1
+ }
+ // Keep JUnit @Nested classes with their enclosing class.
+ String outer = classFileName.substring(0, classFileName.length() -
'.class'.length())
+ .split('\\$', 2)[0]
+ // This family exists in all supported Iceberg versions. In the 1.11 run
used
+ // to size these shards it took ~19 of ~67 test minutes. The remaining
classes
+ // hash to ~18.5 / 16.4 / 13.2 minutes; new classes need no allowlist
update.
+ if (outer.startsWith('TestStructuredStreamingRead')) {
+ return 1
+ }
+ return 2 + Math.floorMod(outer.hashCode(), count - 1)
+ }
+}
+
+def properties = gradle.startParameter.projectProperties
+def required = ['cometShardTask', 'cometShardIndex', 'cometShardCount']
+if (required.any { !properties[it] }) {
+ throw new GradleException("Iceberg sharding requires ${required.join(', ')}")
+}
+int shardIndex
+int shardCount
+try {
+ shardIndex = Integer.parseInt(properties.cometShardIndex)
+ shardCount = Integer.parseInt(properties.cometShardCount)
+} catch (NumberFormatException e) {
+ throw new GradleException('Iceberg shard index/count must be integers', e)
+}
+if (shardCount < 1 || shardIndex < 1 || shardIndex > shardCount) {
+ throw new GradleException('Iceberg shard index must be between 1 and
cometShardCount')
+}
+
+gradle.projectsEvaluated {
+ def testTask = gradle.rootProject.tasks.findByPath(properties.cometShardTask)
+ if (!(testTask instanceof Test)) {
+ throw new GradleException("Not a Test task: ${properties.cometShardTask}")
+ }
+ testTask.inputs.property('cometShardIndex', shardIndex)
+ testTask.inputs.property('cometShardCount', shardCount)
+ int runAttempt = Integer.parseInt(System.getenv('GITHUB_RUN_ATTEMPT') ?: '1')
+ testTask.inputs.property('cometShardRunAttempt', runAttempt)
+
testTask.inputs.files(gradle.startParameter.initScripts).withPropertyName('cometShardScripts')
+ boolean applyShardFilter = true
+ testTask.exclude { FileTreeElement entry ->
+ applyShardFilter && !entry.directory && entry.name.endsWith('.class') &&
+ CometIcebergTestShard.owner(entry.name, shardCount) != shardIndex
+ }
+
+ // Keep a machine-readable inventory next to the normal JUnit reports. This
is
+ // Gradle's candidate set, not the number of tests that JUnit actually
executes.
+ def manifest = testTask.project.layout.buildDirectory
+ .file("comet-shards/${testTask.name}-${shardIndex}.json").get().asFile
+ testTask.outputs.file(manifest)
+ testTask.doFirst {
+ def inventory = {
+ def candidates = new TreeSet<String>()
+ testTask.candidateClassFiles.visit { entry ->
+ if (!entry.directory && entry.name.endsWith('.class')) {
+ candidates.add(entry.relativePath.pathString)
+ }
+ }
+ candidates
+ }
+ // Compilation has completed by this point. Disable only our predicate
while
+ // recording the baseline, preserving even upstream exclusion closures.
Then
+ // restore it before collecting the selected candidates and executing
tests.
+ def unshardedCandidates
+ applyShardFilter = false
+ try {
+ unshardedCandidates = inventory()
+ } finally {
+ applyShardFilter = true
+ }
+ def candidates = inventory()
+ manifest.parentFile.mkdirs()
+ manifest.text = JsonOutput.prettyPrint(JsonOutput.toJson([
+ task: testTask.path, shard: shardIndex, count: shardCount, attempt:
runAttempt,
+ candidates: candidates, unshardedCandidates: unshardedCandidates])) +
'\n'
+ testTask.logger.lifecycle("Iceberg shard ${shardIndex}/${shardCount}:
${candidates.size()} candidate classes")
+ }
+}
diff --git a/dev/ci/test-iceberg-shards.py b/dev/ci/test-iceberg-shards.py
new file mode 100644
index 0000000000..084facb622
--- /dev/null
+++ b/dev/ci/test-iceberg-shards.py
@@ -0,0 +1,144 @@
+#!/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.
+
+"""Fast regression tests for the real Iceberg inventory and workflow-matrix
guard."""
+
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+
+SPEC = importlib.util.spec_from_file_location(
+ "check_iceberg_shards",
Path(__file__).with_name("check-iceberg-shards.py"))
+CHECK = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(CHECK)
+
+
+class IcebergShardManifestTest(unittest.TestCase):
+ def setUp(self):
+ self.temp =
tempfile.TemporaryDirectory(prefix="comet-shard-manifest-test-")
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.candidates = [f"fixture/Test{index}.class"
+ for index in range(1, CHECK.SHARD_COUNT + 1)]
+ for index in range(1, CHECK.SHARD_COUNT + 1):
+ self.write(index)
+
+ def write(self, index, attempt=1, **overrides):
+ manifest = dict(task=":test", count=CHECK.SHARD_COUNT, shard=index,
attempt=attempt,
+ candidates=[self.candidates[index - 1]],
+ unshardedCandidates=self.candidates)
+ manifest.update(overrides)
+ path = self.root / f"shard-{index}-attempt-{attempt}.json"
+ path.write_text(json.dumps(manifest))
+ return path
+
+ def test_complete_inventory(self):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_matrix_and_partition_count_share_one_definition(self):
+ path = self.root / "github-output"
+ CHECK.write_workflow_matrix(path)
+ values = dict(line.split("=", 1) for line in
path.read_text().splitlines())
+ matrix = json.loads(values["matrix"])
+ indices = matrix["shard"]
+ self.assertEqual(indices, list(range(1, int(values["count"]) + 1)))
+ self.assertEqual(len(indices), len(set(indices)))
+ # A second dimension changes job-total but must not change shard count.
+ matrix["environment"] = ["first", "second"]
+ self.assertEqual(int(values["count"]), len(matrix["shard"]))
+
+ def test_missing_shard_fails(self):
+ (self.root / "shard-1-attempt-1.json").unlink()
+ with self.assertRaisesRegex(ValueError, "Expected shard indices"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_unexpected_shard_fails(self):
+ self.write(1, shard=CHECK.SHARD_COUNT + 1)
+ with self.assertRaisesRegex(ValueError, "Expected shard indices"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_overlapping_shards_fail(self):
+ self.write(2, candidates=self.candidates[:2])
+ with self.assertRaisesRegex(ValueError, "multiple shards"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_missing_candidate_fails(self):
+ self.write(1, candidates=[])
+ with self.assertRaisesRegex(ValueError, "coverage mismatch"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_extra_candidate_fails(self):
+ self.write(1, candidates=[self.candidates[0],
"fixture/Unexpected.class"])
+ with self.assertRaisesRegex(ValueError, "coverage mismatch"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_inconsistent_baselines_fail(self):
+ self.write(1, unshardedCandidates=self.candidates[:1])
+ with self.assertRaisesRegex(ValueError, "inventories disagree"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_wrong_count_or_task_fails(self):
+ for overrides in ({"count": CHECK.SHARD_COUNT * 2}, {"task":
":otherTest"}):
+ with self.subTest(overrides=overrides):
+ self.write(1, **overrides)
+ with self.assertRaisesRegex(ValueError, "mismatched task or
shard count"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_duplicate_candidate_in_one_manifest_fails(self):
+ self.write(1, candidates=[self.candidates[0]] * 2)
+ with self.assertRaisesRegex(ValueError, "duplicate class paths"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_duplicate_same_attempt_fails(self):
+ contents = (self.root / "shard-1-attempt-1.json").read_text()
+ (self.root / "duplicate.json").write_text(contents)
+ with self.assertRaisesRegex(ValueError, "Duplicate inventory"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_failed_job_rerun_uses_latest_attempt_per_shard(self):
+ # The other shards need not rerun when just shard 1 is retried.
+ self.write(1, candidates=[])
+ self.write(1, attempt=2)
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_newer_bad_inventory_cannot_be_hidden_by_old_attempt(self):
+ self.write(1, attempt=2, candidates=[])
+ with self.assertRaisesRegex(ValueError, "coverage mismatch"):
+ CHECK.verify_manifests(self.root, ":test")
+
+ def test_empty_inventory_directory_fails(self):
+ with self.assertRaisesRegex(ValueError, "Expected shard indices"):
+ CHECK.verify_manifests(self.root / "missing", ":test")
+
+ def test_malformed_metadata_fails(self):
+ for overrides in ({"attempt": 0}, {"shard": True}, {"count": "4"}):
+ with self.subTest(overrides=overrides):
+ path = self.write(1)
+ manifest = json.loads(path.read_text())
+ manifest.update(overrides)
+ path.write_text(json.dumps(manifest))
+ with self.assertRaisesRegex(ValueError, "positive integer"):
+ CHECK.verify_manifests(self.root, ":test")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/source/contributor-guide/iceberg-spark-tests.md
b/docs/source/contributor-guide/iceberg-spark-tests.md
index df6c168af6..4b0e827333 100644
--- a/docs/source/contributor-guide/iceberg-spark-tests.md
+++ b/docs/source/contributor-guide/iceberg-spark-tests.md
@@ -105,6 +105,28 @@ run against Spark 3.5.9 with Java 17; Iceberg 1.11.0 runs
against Spark 4.1.3 wi
(1.8, 1.9, 1.10) run only on pushes to main, or on a pull request labeled
`run-iceberg-tests`. All caller
workflows delegate to `iceberg_spark_test_reusable.yml`, which holds the build
and test job logic.
+The core Spark test target runs in four independent workers. The workflow
passes
+`dev/ci/iceberg-test-shards.gradle` as a Gradle init script: one worker runs
the long
+`TestStructuredStreamingRead` family, and the others hash the remaining class
names into three
+buckets. New tests are assigned automatically. Nested classes and all
parameterized cases stay
+with their enclosing class; Gradle's existing includes, exclusions, and JUnit
configuration are
+unchanged. The extensions and shaded-runtime targets remain unsharded.
+
+The matrix and partition count come from the same definition in
`dev/ci/check-iceberg-shards.py`;
+adding another matrix dimension does not change the partition count. Each
worker records its
+unsharded candidate set with only the Comet shard predicate disabled, then
restores the predicate
+before recording its selected set and executing tests. Both inventories and
the JUnit XML reports
+are uploaded. A dependent coverage job requires all shard indices, matching
unsharded inventories,
+and selected sets whose disjoint union equals that inventory. It downloads
only artifacts for the
+same Iceberg/Spark/Scala/JDK configuration in the current workflow run and
uses the latest available
+attempt per shard, so rerunning only failed jobs can reuse earlier successful
shards' inventories.
+
+These candidate inventories include classes that JUnit may not execute, so the
runtime job also
+runs `dev/ci/check-iceberg-shards.py`, a small Gradle/JUnit fixture that
checks the four shards'
+combined candidate classes and executed test cases equal an unsharded run
exactly once. It also
+checks nested, parameterized, inherited, and dynamically generated tests,
existing exclusions,
+and failure propagation. The fixture does not compile Spark or Iceberg.
+
Apply the `run-iceberg-tests` label to a pull request whenever it touches
reflection code
(`org.apache.comet.iceberg.IcebergReflection`) or other logic whose behavior
can differ across Iceberg
versions, since Iceberg 1.11 alone will not catch a regression that only
affects 1.8, 1.9, or 1.10.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]