This is an automated email from the ASF dual-hosted git repository.
zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new 30548fe6df [VL] Add iceberg UT CI whitelist (#13323)
30548fe6df is described below
commit 30548fe6df1a1d781c047179108127a787d6f2b9
Author: inf / Hazmi <[email protected]>
AuthorDate: Thu Oct 15 16:16:51 2026 +0300
[VL] Add iceberg UT CI whitelist (#13323)
---
.github/workflows/util/iceberg-upstream/compare.py | 146 +++++++++----
.github/workflows/util/iceberg-upstream/control.py | 27 ++-
.../util/iceberg-upstream/flaky-tests.csv | 20 ++
.github/workflows/util/iceberg-upstream/flaky.py | 47 ++++
.../workflows/util/iceberg-upstream/summarize.py | 13 +-
docs/developers/velox-backend-CI.md | 22 +-
.../test/python/test_compare.py | 242 +++++++++++++++------
.../src-iceberg-upstream/test/python/test_flaky.py | 215 ++++++++++++++++++
.../test/python/test_summary.py | 2 +
9 files changed, 602 insertions(+), 132 deletions(-)
diff --git a/.github/workflows/util/iceberg-upstream/compare.py
b/.github/workflows/util/iceberg-upstream/compare.py
index 9fe24650ec..5ffbf182c8 100644
--- a/.github/workflows/util/iceberg-upstream/compare.py
+++ b/.github/workflows/util/iceberg-upstream/compare.py
@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Require each upstream Iceberg test to preserve or improve its base-commit
result."""
+"""Gate native-status regressions and new test failures; highlight other
changes."""
import argparse
import csv
@@ -23,6 +23,7 @@ import os
import re
from pathlib import Path
+from flaky import FLAKY_TESTS, is_flaky, load_flaky_tests
from summarize import REPORT_VERSION, native_data_proven, native_proven
PASSING = {
@@ -31,14 +32,6 @@ PASSING = {
"NO_ICEBERG_EXECUTION",
"PASSED_UNVERIFIED",
}
-ALLOWED = {
- "PASSED_NATIVE": {"PASSED_NATIVE"},
- "PASSED_FALLBACK": {"PASSED_FALLBACK", "PASSED_NATIVE"},
- "FAILED": PASSING | {"FAILED"},
- "NO_ICEBERG_EXECUTION": PASSING,
- "PASSED_UNVERIFIED": PASSING,
- "SKIPPED": PASSING | {"SKIPPED"},
-}
def parameters(row):
@@ -64,7 +57,9 @@ def parameters(row):
)
-def index(report, validate=True):
+def index(report, validate=True, flaky_tests=None):
+ if flaky_tests is None:
+ flaky_tests = load_flaky_tests()
if validate and report.get("version") != REPORT_VERSION:
raise ValueError(
"Incompatible coverage schema; a baseline from the current harness
is required"
@@ -74,19 +69,19 @@ def index(report, validate=True):
key = (row["class"], row["name"])
if key in tests:
raise ValueError(f"Duplicate test result: {key}")
- if validate and (row["status"] not in ALLOWED or
row.get("coverage_error")):
+ tests[key] = row
+ if not validate or is_flaky(row, flaky_tests):
+ continue
+ if row["status"] not in PASSING | {"FAILED", "SKIPPED"} or row.get(
+ "coverage_error"
+ ):
raise ValueError(f"Invalid execution coverage: {key}:
{row['status']}")
- if validate and row["status"] == "PASSED_NATIVE" and not
native_proven(row):
+ if row["status"] == "PASSED_NATIVE" and not native_proven(row):
raise ValueError(f"Unproven native coverage: {key}")
- if (
- validate
- and row["status"] == "FAILED"
- and row.get("failure_origin") != "GLUTEN"
- ):
+ if row["status"] == "FAILED" and row.get("failure_origin") != "GLUTEN":
raise ValueError(
f"Unverified failure attribution: {key}:
{row.get('failure_origin', 'MISSING')}"
)
- tests[key] = row
if not tests:
raise ValueError("Cannot gate an empty test report")
return tests
@@ -108,9 +103,13 @@ def proven_native_nodes(row):
}
-def compare(baseline, current, validate=True):
- before, after = index(baseline, validate), index(current, validate)
- regressions, improvements, changes = [], [], []
+def compare(baseline, current, validate=True, flaky_tests=None):
+ if flaky_tests is None:
+ flaky_tests = load_flaky_tests()
+ before = index(baseline, validate, flaky_tests)
+ after = index(current, validate, flaky_tests)
+ regressions, improvements, changes, highlights, ignored_changes = [], [],
[], [], []
+ ignored_tests = 0
for key in sorted(before.keys() | after.keys()):
old = before.get(key, {}).get("status", "NEW")
new = after.get(key, {}).get("status", "MISSING")
@@ -122,10 +121,9 @@ def compare(baseline, current, validate=True):
"after": new,
"base": previous,
"current": current_row,
+ "ignored": is_flaky(previous or current_row, flaky_tests),
}
- # Newly discovered upstream tests must pass or be disabled upstream.
Missing or
- # newly skipped tests cannot conceal a previous pass (or an unresolved
failure).
- allowed = PASSING | {"SKIPPED"} if old == "NEW" else ALLOWED.get(old,
set())
+ ignored_tests += change["ignored"]
mismatch = bool(
previous
and current_row
@@ -134,18 +132,29 @@ def compare(baseline, current, validate=True):
and parameters(previous) != parameters(current_row)
)
lost_native = proven_native_nodes(previous) -
proven_native_nodes(current_row)
+ if lost_native:
+ change["lost_native"] = sorted(lost_native)
if mismatch:
- change["reason"] = "PARAMETER_MISMATCH"
- regressions.append(change)
- elif new not in allowed:
+ change["parameter_mismatch"] = True
+ # Only a native test becoming non-native or a newly failed test gates.
+ # Node-level evidence changes remain visible even when the status is
unchanged.
+ regression = (old == "PASSED_NATIVE" and new != old) or (
+ new == "FAILED" and old != new
+ )
+ improvement = (
+ not regression
+ and not mismatch
+ and (
+ (new == "PASSED_NATIVE" and old != new)
+ or (old == "FAILED" and new in PASSING)
+ )
+ )
+ if regression:
change["reason"] = "STATUS_REGRESSION"
- regressions.append(change)
+ elif mismatch:
+ change["reason"] = "PARAMETER_MISMATCH"
elif lost_native:
- change["reason"] = "NATIVE_COVERAGE_REGRESSION"
- change["lost_native"] = sorted(lost_native)
- regressions.append(change)
- elif old != new and new != "SKIPPED":
- improvements.append(change)
+ change["reason"] = "NATIVE_COVERAGE_CHANGE"
# Metrics/timings vary between successful runs. Compare their
existence and node types,
# while retaining the actual values in each changed row for
verification.
evidence_changed = any(
@@ -170,10 +179,23 @@ def compare(baseline, current, validate=True):
"reason", "STATUS_CHANGE" if old != new else "EVIDENCE_CHANGE"
)
changes.append(change)
+ if change["ignored"]:
+ ignored_changes.append(change)
+ elif regression:
+ regressions.append(change)
+ elif improvement:
+ improvements.append(change)
+ if lost_native:
+ highlights.append(change)
+ else:
+ highlights.append(change)
return {
"regressions": regressions,
"improvements": improvements,
"changes": changes,
+ "highlights": highlights,
+ "ignored_changes": ignored_changes,
+ "ignored_tests": ignored_tests,
"base_tests": len(before),
"current_tests": len(after),
}
@@ -197,6 +219,9 @@ def write_changes(path, changes):
)
columns = [
"reason",
+ "ignored",
+ "lost_native",
+ "parameter_mismatch",
"class",
"test",
"before",
@@ -216,6 +241,9 @@ def write_changes(path, changes):
old, new = row["base"], row["current"]
values = [
row["reason"],
+ row["ignored"],
+ json.dumps(row.get("lost_native", [])),
+ row.get("parameter_mismatch", False),
row["class"],
row["name"],
row["before"],
@@ -239,6 +267,12 @@ def main():
parser.add_argument("baseline", type=Path)
parser.add_argument("current", type=Path)
parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument(
+ "--flaky-tests",
+ type=Path,
+ default=FLAKY_TESTS,
+ help="class,method CSV whitelist (defaults to the adjacent
flaky-tests.csv)",
+ )
parser.add_argument(
"--bootstrap",
action="store_true",
@@ -246,6 +280,11 @@ def main():
)
args = parser.parse_args()
baseline, current, errors = None, None, []
+ flaky_tests = set()
+ try:
+ flaky_tests = load_flaky_tests(args.flaky_tests)
+ except (ValueError, OSError, csv.Error) as error:
+ errors.append(f"Flaky test whitelist unavailable: {error}")
for label, path in (("Baseline", args.baseline), ("Current report",
args.current)):
if label == "Baseline" and args.bootstrap and not path.exists():
continue
@@ -269,25 +308,30 @@ def main():
"changes": [],
"regressions": [],
"improvements": [],
+ "highlights": [],
+ "ignored_changes": [],
+ "ignored_tests": 0,
"base_tests": len((baseline or {}).get("tests", [])),
"current_tests": len((current or {}).get("tests", [])),
}
try:
if errors:
if current is not None:
- index(current)
+ index(current, flaky_tests=flaky_tests)
elif baseline is None and args.bootstrap:
- index(current)
+ index(current, flaky_tests=flaky_tests)
result["baseline_bootstrap"] = True
else:
- result = compare(baseline, current)
+ result = compare(baseline, current, flaky_tests=flaky_tests)
result["comparison_performed"] = True
except (ValueError, KeyError, TypeError) as error:
# Still export diagnostic differences when complete reports exist, but
invalid
# evidence must fail the gate even if no statuses changed.
if baseline is not None and current is not None:
try:
- result = compare(baseline, current, validate=False)
+ result = compare(
+ baseline, current, validate=False, flaky_tests=flaky_tests
+ )
except (ValueError, KeyError, TypeError):
pass
result["comparison_performed"] = False
@@ -322,15 +366,29 @@ def main():
"Report validation failed; the CSV is diagnostic only: "
+ "; ".join(result["validation_errors"])
)
- for row in result["regressions"][:20]:
- detail = row["reason"]
- if row.get("lost_native"):
- detail += ": " + ", ".join(row["lost_native"])
+ if result["ignored_tests"]:
lines.append(
- f"- `{row['class']}#{row['name']}`: {row['before']} →
{row['after']} ({detail})"
+ f"{result['ignored_tests']} whitelisted test invocations excluded
from gating."
)
- if len(result["regressions"]) > 20:
- lines.append("See the comparison artifact for the remaining
regressions.")
+ for title, rows in (
+ ("Regressions", result["regressions"]),
+ ("Other changes (informational)", result["highlights"]),
+ ("Whitelisted changes (ignored)", result["ignored_changes"]),
+ ):
+ if not rows:
+ continue
+ lines.extend(["", f"### {title}: {len(rows)}", ""])
+ for row in rows[:20]:
+ detail = row["reason"]
+ if row.get("lost_native"):
+ detail += "; native nodes lost: " + ",
".join(row["lost_native"])
+ if row.get("parameter_mismatch"):
+ detail += "; parameters changed"
+ lines.append(
+ f"- `{row['class']}#{row['name']}`: {row['before']} →
{row['after']} ({detail})"
+ )
+ if len(rows) > 20:
+ lines.append("See the comparison artifact for the remaining
changes.")
text = "\n".join(lines) + "\n"
print(text)
if os.environ.get("GITHUB_STEP_SUMMARY"):
diff --git a/.github/workflows/util/iceberg-upstream/control.py
b/.github/workflows/util/iceberg-upstream/control.py
index 2298d82436..9c56d5c273 100644
--- a/.github/workflows/util/iceberg-upstream/control.py
+++ b/.github/workflows/util/iceberg-upstream/control.py
@@ -24,6 +24,7 @@ from pathlib import Path
import xml.etree.ElementTree as ET
from compare import PASSING, parameters
+from flaky import is_flaky, load_flaky_tests
from summarize import classify, write_reports
RUNTIME_FAILURE = re.compile(
@@ -32,22 +33,26 @@ RUNTIME_FAILURE = re.compile(
)
-def selection(results):
+def selection(results, flaky_tests=None):
+ if flaky_tests is None:
+ flaky_tests = load_flaky_tests()
methods = collections.defaultdict(set)
for row in results:
- if row["status"] == "FAILED":
+ if row["status"] == "FAILED" and not is_flaky(row, flaky_tests):
methods[row["class"]].add(row["name"].split("(")[0])
return ",".join(
name + "#" + "+".join(sorted(tests)) for name, tests in
sorted(methods.items())
)
-def attribute(results, controls):
+def attribute(results, controls, flaky_tests=None):
+ if flaky_tests is None:
+ flaky_tests = load_flaky_tests()
index = {(row["class"], row["name"]): row for row in controls}
if len(index) != len(controls):
raise ValueError("Duplicate vanilla controls")
for row in results:
- if row["status"] != "FAILED":
+ if row["status"] != "FAILED" or is_flaky(row, flaky_tests):
continue
control = index.get((row["class"], row["name"]))
if control is None:
@@ -79,13 +84,15 @@ def main():
results = json.loads((args.reports /
"execution-coverage.json").read_text())[
"tests"
]
+ flaky_tests = load_flaky_tests()
+ active = [row for row in results if not is_flaky(row, flaky_tests)]
if args.select is not None:
- args.select.write_text(selection(results))
+ args.select.write_text(selection(results, flaky_tests))
return 0
if args.vanilla is None:
parser.error("Specify --select or --vanilla")
controls = []
- if any(row["status"] == "FAILED" for row in results):
+ if any(row["status"] == "FAILED" for row in active):
for path in args.vanilla.glob("TEST-*.xml"):
props = {
p.get("name"): p.get("value")
@@ -97,10 +104,10 @@ def main():
)
controls = classify(args.vanilla)
write_reports(args.vanilla, controls, label="Vanilla Spark controls")
- results = attribute(results, controls)
+ results = attribute(results, controls, flaky_tests)
broken = [
row
- for row in results
+ for row in active
if row["status"] == "FAILED" and row.get("failure_origin") != "GLUTEN"
]
write_reports(args.reports, results, label="Gluten coverage after vanilla
controls")
@@ -108,9 +115,7 @@ def main():
print(
f"{len(broken)} failures could not be attributed reliably; see
failed.csv"
)
- return int(
- bool(broken) or any(row["status"] == "COVERAGE_ERROR" for row in
results)
- )
+ return int(bool(broken) or any(row["status"] == "COVERAGE_ERROR" for row
in active))
if __name__ == "__main__":
diff --git a/.github/workflows/util/iceberg-upstream/flaky-tests.csv
b/.github/workflows/util/iceberg-upstream/flaky-tests.csv
new file mode 100644
index 0000000000..7f5ec34e6d
--- /dev/null
+++ b/.github/workflows/util/iceberg-upstream/flaky-tests.csv
@@ -0,0 +1,20 @@
+# 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.
+#
+# All invocations of these methods run and are reported, but do not affect the
gate.
+# Use exact class and method names; omit method arguments and invocation
indices.
+class,method
+# Example
+#
org.apache.iceberg.spark.extensions.TestAlterTablePartitionFields,testDropPartitionAndSourceColumnLong
diff --git a/.github/workflows/util/iceberg-upstream/flaky.py
b/.github/workflows/util/iceberg-upstream/flaky.py
new file mode 100644
index 0000000000..8cf347c7fe
--- /dev/null
+++ b/.github/workflows/util/iceberg-upstream/flaky.py
@@ -0,0 +1,47 @@
+# 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.
+
+"""Methods whose results are reported but excluded from the Iceberg gate."""
+
+import csv
+from pathlib import Path
+
+FLAKY_TESTS = Path(__file__).with_name("flaky-tests.csv")
+
+
+def load_flaky_tests(path=FLAKY_TESTS):
+ tests = set()
+ with path.open(newline="") as source:
+ reader = csv.reader(
+ (line for line in source if not line.lstrip().startswith("#")),
strict=True
+ )
+ if next(reader, None) != ["class", "method"]:
+ raise ValueError(
+ f"Expected class,method header in flaky test whitelist: {path}"
+ )
+ for row in reader:
+ if not row:
+ continue
+ key = tuple(value.strip() for value in row)
+ if len(key) != 2 or not all(key):
+ raise ValueError(f"Invalid flaky test whitelist entry: {path}:
{row}")
+ if key in tests:
+ raise ValueError(f"Duplicate flaky test whitelist entry:
{path}: {row}")
+ tests.add(key)
+ return tests
+
+
+def is_flaky(row, tests):
+ return (row.get("class"), row.get("name", "").partition("(")[0]) in tests
diff --git a/.github/workflows/util/iceberg-upstream/summarize.py
b/.github/workflows/util/iceberg-upstream/summarize.py
index 82986427e9..4ddb441c53 100644
--- a/.github/workflows/util/iceberg-upstream/summarize.py
+++ b/.github/workflows/util/iceberg-upstream/summarize.py
@@ -24,6 +24,8 @@ from pathlib import Path
import sys
import xml.etree.ElementTree as ET
+from flaky import is_flaky, load_flaky_tests
+
STATUSES = (
"PASSED_NATIVE",
"PASSED_FALLBACK",
@@ -128,6 +130,8 @@ def classify(directory):
def write_reports(directory, results, label="Iceberg execution coverage"):
+ flaky_tests = load_flaky_tests()
+ results = [dict(row, ignored=is_flaky(row, flaky_tests)) for row in
results]
counts = collections.Counter(row["status"] for row in results)
summary = {status: counts[status] for status in STATUSES}
(directory / "execution-coverage.json").write_text(
@@ -144,6 +148,7 @@ def write_reports(directory, results, label="Iceberg
execution coverage"):
)
columns = (
"status",
+ "ignored",
"class",
"test",
"display_name",
@@ -184,6 +189,7 @@ def write_reports(directory, results, label="Iceberg
execution coverage"):
writer.writerow(
(
row["status"],
+ row["ignored"],
row["class"],
row["name"],
row.get("display_name", ""),
@@ -217,12 +223,17 @@ def write_reports(directory, results, label="Iceberg
execution coverage"):
)
table = f"### {label}\n\n| Iceberg execution result | Tests |\n| --- |
---: |\n"
table += "".join(f"| {status} | {count} |\n" for status, count in
summary.items())
+ ignored = sum(row["ignored"] for row in results)
+ if ignored:
+ table += f"\n{ignored} whitelisted test invocations are reported above
but excluded from gating.\n"
print(table)
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as output:
output.write(table)
# Control runs and the comparison gate handle upstream failures. Broken
observation fails here.
- return int(bool(counts["COVERAGE_ERROR"]))
+ return int(
+ any(row["status"] == "COVERAGE_ERROR" and not row["ignored"] for row
in results)
+ )
def main(directory):
diff --git a/docs/developers/velox-backend-CI.md
b/docs/developers/velox-backend-CI.md
index 0d67413d7f..c074ec45d2 100644
--- a/docs/developers/velox-backend-CI.md
+++ b/docs/developers/velox-backend-CI.md
@@ -92,22 +92,22 @@ final adaptive plans and subqueries.
| `SKIPPED` | Disabled upstream or aborted by an assumption. |
| `COVERAGE_ERROR` | Missing or invalid execution evidence; reporting fails. |
-Failing methods run again without Gluten. `control.py` attributes a failure to
Gluten only
-when vanilla Spark passes with matching parameters and settings. Missing,
skipped, failing
-or mismatched controls fail the gate. Timeouts, resource exhaustion and
broken-runtime
-failures remain unverified even if the control passes.
+Failing methods run again without Gluten unless whitelisted. `control.py`
attributes a failure
+to Gluten only when vanilla Spark passes with matching parameters and
settings. Missing,
+skipped, failing or mismatched controls fail the gate. Timeouts, resource
exhaustion and
+broken-runtime failures remain unverified even if the control passes.
Parameter/setup randomness is seeded.
`iceberg-upstream-gate (3.5)` applies these rules to each invocation:
-- Existing failures may stay failed or improve; new tests must pass or be
disabled upstream.
-- Fallback passes must remain fallback or improve to native; native passes
must remain native.
-- Executed tests cannot disappear or become skipped, and execution evidence
cannot be lost.
-- Mixed fallback/native passes must preserve every proven native node type,
even if their
- overall status is unchanged. Positive counter fluctuations alone are not
regressions.
-- Missing artifacts, duplicate results, incompatible report schemas,
mismatched parameters,
- observer errors and failed infrastructure checks fail the gate.
+- Native passes must remain native; newly failed invocations fail the gate.
+- Other changes are informational, including missing/skipped non-native tests,
base/head
+ parameter differences, and lost native node coverage without a native-status
regression.
+- Missing artifacts, duplicate results, incompatible report schemas, invalid
execution evidence,
+ unverified failure attribution and failed infrastructure checks fail the
gate.
+
+Tests listed by `class,method` in
[flaky-tests.csv](../../.github/workflows/util/iceberg-upstream/flaky-tests.csv)
still run and appear in reports, but do not affect the gate.
`iceberg-upstream-spark3.5-reports` contains base/head coverage JSON and CSVs,
head raw logs,
comparison validation details and a changes-only CSV. Separate CSVs show
native, fallback,
diff --git a/gluten-ut/src-iceberg-upstream/test/python/test_compare.py
b/gluten-ut/src-iceberg-upstream/test/python/test_compare.py
index 4b87024459..a3acbecb6b 100644
--- a/gluten-ut/src-iceberg-upstream/test/python/test_compare.py
+++ b/gluten-ut/src-iceberg-upstream/test/python/test_compare.py
@@ -18,6 +18,7 @@ import copy
import csv
import sys
import json
+import os
import subprocess
import tempfile
from pathlib import Path
@@ -87,49 +88,61 @@ class ComparisonTest(unittest.TestCase):
self.assertTrue(diagnostic["comparison_performed"])
self.assertEqual(1, len(diagnostic["regressions"]))
- def test_native_fallback_failure_transition_rules(self):
- allowed = {
- ("FAILED", "FAILED"),
- ("FAILED", "PASSED_FALLBACK"),
- ("FAILED", "PASSED_NATIVE"),
- ("PASSED_FALLBACK", "PASSED_FALLBACK"),
- ("PASSED_FALLBACK", "PASSED_NATIVE"),
- ("PASSED_NATIVE", "PASSED_NATIVE"),
- }
- for old in ("FAILED", "PASSED_FALLBACK", "PASSED_NATIVE"):
- for new in ("FAILED", "PASSED_FALLBACK", "PASSED_NATIVE"):
+ def test_only_native_status_loss_and_new_failures_gate(self):
+ statuses = sorted(comparison.PASSING | {"FAILED", "SKIPPED"})
+ for old in statuses:
+ for new in statuses:
with self.subTest(before=old, after=new):
result = comparison.compare(report(old), report(new))
- self.assertEqual(
- (old, new) not in allowed, bool(result["regressions"])
+ fails = (old == "PASSED_NATIVE" and new != old) or (
+ new == "FAILED" and old != new
)
+ self.assertEqual(fails, bool(result["regressions"]))
- def test_disabling_or_removing_tests_does_not_hide_regressions(self):
- for old in (
- "FAILED",
- "PASSED_FALLBACK",
- "PASSED_NATIVE",
- "NO_ICEBERG_EXECUTION",
- ):
+ def test_missing_non_native_tests_are_highlighted(self):
+ for old in sorted(comparison.PASSING | {"FAILED", "SKIPPED"}):
with self.subTest(before=old):
- self.assertTrue(
- comparison.compare(report(old),
report("SKIPPED"))["regressions"]
- )
result = comparison.compare(
- report(old), report("PASSED_NATIVE", "different")
+ report(old), report("PASSED_FALLBACK", "different")
)
- self.assertEqual("MISSING", result["regressions"][0]["after"])
+ self.assertEqual(old == "PASSED_NATIVE",
bool(result["regressions"]))
+ changes = [
+ row for row in result["changes"] if row["after"] ==
"MISSING"
+ ]
+ self.assertEqual(1, len(changes))
+ if old != "PASSED_NATIVE":
+ self.assertIn(changes[0], result["highlights"])
- def test_losing_execution_evidence_is_not_an_improvement(self):
- for old in ("PASSED_FALLBACK", "PASSED_NATIVE"):
- for new in ("NO_ICEBERG_EXECUTION", "PASSED_UNVERIFIED"):
- result = comparison.compare(report(old), report(new))
- self.assertTrue(result["regressions"])
+ def
test_passing_coverage_changes_are_informational_without_special_exceptions(
+ self,
+ ):
+ statuses = ("PASSED_FALLBACK", "PASSED_UNVERIFIED",
"NO_ICEBERG_EXECUTION")
+ for expected_exception in (False, True):
+ for old in statuses:
+ for new in statuses:
+ with self.subTest(
+ expected=expected_exception, before=old, after=new
+ ):
+ base, current = report(old), report(new)
+ for source in (base, current):
+ source["tests"][0].update(
+ body_started=True,
expected_exception=expected_exception
+ )
+ result = comparison.compare(base, current)
+ self.assertFalse(result["regressions"])
+ self.assertFalse(result["improvements"])
+ self.assertEqual(old != new,
bool(result["highlights"]))
+ if old != new:
+ self.assertEqual(
+ base["tests"][0],
result["highlights"][0]["base"]
+ )
+ self.assertEqual(
+ current["tests"][0],
result["highlights"][0]["current"]
+ )
- def test_mixed_execution_must_preserve_proven_native_coverage(self):
+ def test_mixed_execution_coverage_loss_is_highlighted(self):
base = report("PASSED_NATIVE")
- row = base["tests"][0]
- row.update(status="PASSED_FALLBACK", fallback=["AppendDataExec"])
+ base["tests"][0].update(status="PASSED_FALLBACK",
fallback=["AppendDataExec"])
for loss in (
"fallback",
"unverified",
@@ -141,8 +154,7 @@ class ComparisonTest(unittest.TestCase):
current = copy.deepcopy(base)
after = current["tests"][0]
if loss in ("fallback", "unverified"):
- after["native"] = []
- after["native_metrics"] = []
+ after.update(native=[], native_metrics=[])
after.setdefault(loss, []).append("IcebergScanTransformer")
elif loss == "metrics":
after["native_metrics"][0]["metrics"]["rawInputRows"] = 0
@@ -154,27 +166,26 @@ class ComparisonTest(unittest.TestCase):
after["expected_exception"] = True
with self.subTest(loss=loss):
result = comparison.compare(base, current)
- self.assertEqual(1, len(result["regressions"]))
- self.assertEqual([], result["improvements"])
- change = result["changes"][0]
- self.assertEqual("NATIVE_COVERAGE_REGRESSION",
change["reason"])
- self.assertEqual(["IcebergScanTransformer"],
change["lost_native"])
+ self.assertFalse(result["regressions"])
+ self.assertFalse(result["improvements"])
+ self.assertEqual(1, len(result["highlights"]))
+ self.assertEqual(
+ "NATIVE_COVERAGE_CHANGE", result["highlights"][0]["reason"]
+ )
+ self.assertEqual(
+ ["IcebergScanTransformer"],
result["highlights"][0]["lost_native"]
+ )
- def
test_native_node_types_cannot_disappear_even_when_status_improves(self):
+ def test_native_to_native_node_changes_are_highlighted(self):
base = report("PASSED_NATIVE")
- row = base["tests"][0]
- row["native"].append("IcebergWriteExec")
- row["native_metrics"].append(
+ base["tests"][0]["native"].append("IcebergWriteExec")
+ base["tests"][0]["native_metrics"].append(
{"node": "IcebergWriteExec", "metrics": {"numWrittenBytes": 10}}
)
- for status in ("PASSED_NATIVE", "PASSED_FALLBACK",
"PASSED_UNVERIFIED"):
- row["status"] = status
- with self.subTest(status=status):
- result = comparison.compare(base, report("PASSED_NATIVE"))
- self.assertEqual(
- ["IcebergWriteExec"],
result["regressions"][0]["lost_native"]
- )
- self.assertFalse(result["improvements"])
+ result = comparison.compare(base, report("PASSED_NATIVE"))
+ self.assertFalse(result["regressions"])
+ self.assertFalse(result["improvements"])
+ self.assertEqual(["IcebergWriteExec"],
result["highlights"][0]["lost_native"])
def
test_counter_variation_and_unproven_nodes_do_not_gate_mixed_execution(self):
base = report("PASSED_NATIVE")
@@ -186,19 +197,119 @@ class ComparisonTest(unittest.TestCase):
row["native"].append("UnprovenPlanNode")
self.assertFalse(comparison.compare(base, current)["regressions"])
- def test_new_tests_and_previously_skipped_tests_cannot_add_failures(self):
+ def test_whitelist_excludes_all_invocations_from_every_gate_outcome(self):
+ whitelist = {("org.apache.iceberg.TestScan", "read")}
+ statuses = sorted(comparison.PASSING | {"FAILED", "SKIPPED",
"COVERAGE_ERROR"})
+ for old in statuses:
+ for new in statuses:
+ with self.subTest(before=old, after=new):
+ base, current = report(old), report(new)
+ current["tests"][0].update(
+ parameters={"formatVersion": "3"},
+ failure_origin="CONTROL_MISSING",
+ )
+ result = comparison.compare(base, current,
flaky_tests=whitelist)
+ self.assertFalse(result["regressions"])
+ self.assertFalse(result["improvements"])
+ self.assertFalse(result["highlights"])
+ self.assertEqual(1, result["ignored_tests"])
+ self.assertTrue(result["changes"][0]["ignored"])
+ for name in ("read", "read()", "read()[12]", "read(int)[3]"):
+ base, current = report("PASSED_NATIVE", name), report("FAILED",
name)
+ current["tests"][0].pop("failure_origin")
+ result = comparison.compare(base, current, flaky_tests=whitelist)
+ self.assertFalse(result["regressions"])
+ self.assertEqual(1, len(result["ignored_changes"]))
+ removed = comparison.compare(
+ report("PASSED_NATIVE"),
+ report("PASSED_FALLBACK", "other"),
+ flaky_tests=whitelist,
+ )
+ self.assertFalse(removed["regressions"])
+ self.assertEqual("MISSING", removed["ignored_changes"][0]["after"])
+ for fields in (
+ {"class": "org.apache.iceberg.OtherTest"},
+ {"name": "readOther()[1]"},
+ ):
+ base, current = report("PASSED_NATIVE"), report("FAILED")
+ base["tests"][0].update(fields)
+ current["tests"][0].update(fields)
+ self.assertTrue(
+ comparison.compare(base, current,
flaky_tests=whitelist)["regressions"]
+ )
+
+ def test_whitelist_does_not_hide_invalid_report_structure(self):
+ whitelist = {("org.apache.iceberg.TestScan", "read")}
+ for invalid in (
+ {"version": 1, "tests": report("FAILED")["tests"]},
+ {"version": 2, "tests": []},
+ {"version": 2, "tests": report("FAILED")["tests"] * 2},
+ ):
+ with self.subTest(invalid=invalid), self.assertRaises(ValueError):
+ comparison.compare(invalid, report("FAILED"),
flaky_tests=whitelist)
+
+ def
test_csv_whitelist_and_informational_changes_are_visible_in_gate_summary(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ base, current = report("PASSED_NATIVE"), report("FAILED")
+ current["tests"][0].pop("failure_origin")
+ base["tests"].extend(report("PASSED_FALLBACK",
"anotherMethod")["tests"])
+ current["tests"].extend(
+ report("PASSED_UNVERIFIED", "anotherMethod")["tests"]
+ )
+ (root / "base.json").write_text(json.dumps(base))
+ (root / "head.json").write_text(json.dumps(current))
+ config = root / "flaky.csv"
+ config.write_text(
+ "# Known flaky
test\nclass,method\norg.apache.iceberg.TestScan,read\n"
+ )
+ command = [
+ sys.executable,
+ str(SCRIPT),
+ str(root / "base.json"),
+ str(root / "head.json"),
+ "--output",
+ str(root / "diff.json"),
+ "--flaky-tests",
+ str(config),
+ ]
+ summary = root / "summary.md"
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, GITHUB_STEP_SUMMARY=str(summary)),
+ )
+ self.assertEqual(0, result.returncode, result.stderr +
result.stdout)
+ diagnostic = json.loads((root / "diff.json").read_text())
+ self.assertFalse(diagnostic["regressions"])
+ self.assertFalse(diagnostic["improvements"])
+ self.assertEqual(1, len(diagnostic["ignored_changes"]))
+ self.assertEqual(1, len(diagnostic["highlights"]))
+ self.assertIn("Other changes (informational)", summary.read_text())
+ self.assertIn("PASSED_FALLBACK → PASSED_UNVERIFIED",
summary.read_text())
+ self.assertIn("Whitelisted changes (ignored)", summary.read_text())
+ self.assertIn("PASSED_NATIVE → FAILED", summary.read_text())
+ with (root / "diff.csv").open(newline="") as output:
+ rows = {row["test"]: row for row in csv.DictReader(output)}
+ self.assertEqual("True", rows["read()[1]"]["ignored"])
+ self.assertEqual("False", rows["anotherMethod"]["ignored"])
+ for contents in ("class,method,reason\n", None):
+ with self.subTest(contents=contents):
+ if contents is None:
+ config.unlink()
+ else:
+ config.write_text(contents)
+ result = subprocess.run(command, capture_output=True,
text=True)
+ self.assertEqual(1, result.returncode, result.stderr)
+ diagnostic = json.loads((root / "diff.json").read_text())
+ self.assertTrue(diagnostic["validation_errors"])
+
+ def test_new_tests_cannot_add_failures(self):
current = report("PASSED_NATIVE")
current["tests"].extend(report("FAILED", "new")["tests"])
result = comparison.compare(report("PASSED_NATIVE"), current)
self.assertEqual("NEW", result["regressions"][0]["before"])
- self.assertTrue(
- comparison.compare(report("SKIPPED"),
report("FAILED"))["regressions"]
- )
- self.assertFalse(
- comparison.compare(report("SKIPPED"), report("PASSED_NATIVE"))[
- "regressions"
- ]
- )
def test_empty_duplicate_and_broken_reports_fail_closed(self):
duplicate = report("PASSED_NATIVE")
@@ -214,7 +325,7 @@ class ComparisonTest(unittest.TestCase):
with self.assertRaises(ValueError):
comparison.compare(report("PASSED_NATIVE"), invalid)
- def test_parameters_must_match_except_fixture_ports(self):
+ def test_parameter_changes_are_highlighted_except_fixture_ports(self):
old, new = report("PASSED_NATIVE"), report("PASSED_NATIVE")
old["tests"][0]["parameters"] = {
"config": "uri=http://localhost:1234/",
@@ -228,10 +339,10 @@ class ComparisonTest(unittest.TestCase):
new["tests"][0]["parameters"]["vectorized"] = "false"
self.assertEqual(
"PARAMETER_MISMATCH",
- comparison.compare(old, new)["regressions"][0]["reason"],
+ comparison.compare(old, new)["highlights"][0]["reason"],
)
- def test_random_uuid_literal_is_data_but_other_arguments_must_match(self):
+ def
test_random_uuid_literals_are_ignored_but_other_arguments_are_highlighted(self):
old, new = report("PASSED_FALLBACK"), report("PASSED_FALLBACK")
old["tests"][0][
"display_name"
@@ -243,7 +354,8 @@ class ComparisonTest(unittest.TestCase):
new["tests"][0][
"display_name"
] = "[10] string, f41c0f33-5800-453f-b4d5-0d77ca0d1915"
- self.assertTrue(comparison.compare(old, new)["regressions"])
+ self.assertFalse(comparison.compare(old, new)["regressions"])
+ self.assertTrue(comparison.compare(old, new)["highlights"])
def test_gate_rejects_unproven_native_and_unattributed_failures(self):
invalid_native, invalid_failure = report("PASSED_NATIVE"),
report("FAILED")
diff --git a/gluten-ut/src-iceberg-upstream/test/python/test_flaky.py
b/gluten-ut/src-iceberg-upstream/test/python/test_flaky.py
new file mode 100644
index 0000000000..fa247d2e33
--- /dev/null
+++ b/gluten-ut/src-iceberg-upstream/test/python/test_flaky.py
@@ -0,0 +1,215 @@
+# 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 csv
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import xml.etree.ElementTree as ET
+
+SCRIPTS = (
+ Path(__file__).resolve().parents[4] /
".github/workflows/util/iceberg-upstream"
+)
+sys.path.insert(0, str(SCRIPTS))
+from compare import index
+from control import attribute, selection
+from flaky import is_flaky, load_flaky_tests
+
+
+class FlakyTest(unittest.TestCase):
+ def
test_two_column_whitelist_matches_exact_methods_across_invocations(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "flaky.csv"
+ path.write_text("# Confirmed flake\nclass,method\nexample.Test,
read\n\n")
+ tests = load_flaky_tests(path)
+ self.assertEqual({("example.Test", "read")}, tests)
+ for name in ("read", "read()", "read()[12]", "read(int)[1]"):
+ self.assertTrue(
+ is_flaky({"class": "example.Test", "name": name}, tests)
+ )
+ for row in (
+ {"class": "example.Other", "name": "read"},
+ {"class": "example.Test", "name": "reader()"},
+ ):
+ self.assertFalse(is_flaky(row, tests))
+
+ def test_invalid_whitelist_fails_configuration_validation(self):
+ header, row = "class,method\n", "example.Test,read\n"
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "flaky.csv"
+ for contents in (
+ "",
+ "method,class\n",
+ header + row + row,
+ header + "example.Test, \n",
+ header + "example.Test\n",
+ header + "example.Test,read,extra\n",
+ header + 'example.Test,"unclosed',
+ ):
+ with self.subTest(contents=contents):
+ path.write_text(contents)
+ with self.assertRaises((ValueError, csv.Error)):
+ load_flaky_tests(path)
+
+ def test_controls_are_only_required_for_unlisted_failures(self):
+ tests = {("example.Test", "flakyMethod")}
+ ignored = {
+ "class": "example.Test",
+ "name": "flakyMethod()[1]",
+ "status": "FAILED",
+ }
+ active = {
+ "class": "example.Test",
+ "name": "otherMethod()[1]",
+ "status": "FAILED",
+ }
+ self.assertEqual(
+ "example.Test#otherMethod", selection([ignored, active], tests)
+ )
+ attribute([ignored, active], [], tests)
+ self.assertNotIn("failure_origin", ignored)
+ self.assertEqual("CONTROL_MISSING", active["failure_origin"])
+
+ def
test_whitelisted_failures_survive_the_complete_reporting_pipeline(self):
+ class_name, method = "org.apache.iceberg.TestScan", "flakyMethod"
+ for status in ("FAILED", "COVERAGE_ERROR"):
+ with self.subTest(
+ status=status
+ ), tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ scripts = root / "harness"
+ scripts.mkdir()
+ for script in (
+ "compare.py",
+ "control.py",
+ "flaky.py",
+ "merge.py",
+ "summarize.py",
+ ):
+ shutil.copy2(SCRIPTS / script, scripts / script)
+ (scripts / "flaky-tests.csv").write_text(
+ f"class,method\n{class_name},{method}\n"
+ )
+ shard = root / "shard0"
+ shard.mkdir()
+ name = method + "()[2]"
+ ignored = {
+ "class": class_name,
+ "name": name,
+ "status": "PASSED_FALLBACK",
+ }
+ active = {
+ "class": class_name,
+ "name": "nonFlakyMethod",
+ "status": "PASSED_FALLBACK",
+ }
+ suite = ET.Element("testsuite")
+ case = ET.SubElement(suite, "testcase", classname=class_name,
name=name)
+ ET.SubElement(case, "failure", message="flaky assertion")
+ ET.SubElement(
+ suite, "testcase", classname=class_name,
name=active["name"]
+ )
+ ET.ElementTree(suite).write(shard / "TEST-queries.xml")
+ # Missing per-test observation must also remain non-gating for
this method.
+ observed = [active] + ([ignored] if status == "FAILED" else [])
+ (shard / "coverage-queries.jsonl").write_text(
+ "".join(json.dumps(row) + "\n" for row in observed)
+ )
+ (shard / "shard.json").write_text(json.dumps({"index": 0,
"count": 1}))
+
+ commands = [
+ ["summarize.py", str(shard)],
+ [
+ "control.py",
+ str(shard),
+ "--select",
+ str(shard / "selection.txt"),
+ ],
+ [
+ "control.py",
+ str(shard),
+ "--vanilla",
+ str(shard / "absent-vanilla"),
+ ],
+ ["merge.py", str(root), "--shards", "1"],
+ ]
+ for script, *arguments in commands:
+ result = subprocess.run(
+ [sys.executable, str(scripts / script), *arguments],
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(
+ 0, result.returncode, result.stderr + result.stdout
+ )
+ self.assertEqual("", (shard / "selection.txt").read_text())
+ report = json.loads((root /
"execution-coverage.json").read_text())
+ # Baseline record/restore uses the same validator.
+ indexed = index(report, flaky_tests={(class_name, method)})
+ outcome = indexed[class_name, name]
+ self.assertEqual(status, outcome["status"])
+ self.assertTrue(outcome["ignored"])
+ self.assertEqual("flaky assertion", outcome["failure"])
+ self.assertFalse(indexed[class_name,
active["name"]]["ignored"])
+ with (root / "failed.csv").open(newline="") as source:
+ rows = list(csv.DictReader(source))
+ self.assertEqual("True", rows[0]["ignored"])
+
+ base = dict(
+ ignored,
+ status="PASSED_NATIVE",
+ body_started=True,
+ native=["IcebergScanTransformer"],
+ native_metrics=[
+ {
+ "node": "IcebergScanTransformer",
+ "metrics": {"rawInputRows": 1},
+ }
+ ],
+ )
+ (root / "base.json").write_text(
+ json.dumps({"version": 2, "tests": [base, active]})
+ )
+ command = [
+ sys.executable,
+ str(scripts / "compare.py"),
+ str(root / "base.json"),
+ str(root / "execution-coverage.json"),
+ "--output",
+ str(root / "diff.json"),
+ ]
+ result = subprocess.run(command, capture_output=True,
text=True)
+ self.assertEqual(0, result.returncode, result.stderr +
result.stdout)
+ diff = json.loads((root / "diff.json").read_text())
+ self.assertFalse(diff["regressions"])
+ self.assertFalse(diff["improvements"])
+ self.assertEqual(status, diff["ignored_changes"][0]["after"])
+
+ # The identical failure in an unlisted method must still fail
validation.
+ outcome["name"] = "notWhitelisted"
+ (root /
"execution-coverage.json").write_text(json.dumps(report))
+ result = subprocess.run(command, capture_output=True,
text=True)
+ self.assertEqual(1, result.returncode, result.stderr)
+ self.assertTrue(
+ json.loads((root /
"diff.json").read_text())["validation_errors"]
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/gluten-ut/src-iceberg-upstream/test/python/test_summary.py
b/gluten-ut/src-iceberg-upstream/test/python/test_summary.py
index 12d454dae8..7440577bef 100644
--- a/gluten-ut/src-iceberg-upstream/test/python/test_summary.py
+++ b/gluten-ut/src-iceberg-upstream/test/python/test_summary.py
@@ -17,6 +17,7 @@ import importlib.util
import csv
import json
from pathlib import Path
+import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
@@ -25,6 +26,7 @@ SCRIPT = (
Path(__file__).resolve().parents[4]
/ ".github/workflows/util/iceberg-upstream/summarize.py"
)
+sys.path.insert(0, str(SCRIPT.parent))
SPEC = importlib.util.spec_from_file_location("summary", SCRIPT)
summary = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(summary)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]