diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07ce889dd5..cb6c9e5df03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,7 @@ jobs: iceberg_1_9: ${{ steps.compute.outputs.iceberg_1_9 }} iceberg_1_10: ${{ steps.compute.outputs.iceberg_1_10 }} iceberg_1_11: ${{ steps.compute.outputs.iceberg_1_11 }} + delta: ${{ steps.compute.outputs.delta }} steps: - uses: actions/checkout@v7 with: @@ -140,7 +141,7 @@ jobs: run: | set -euo pipefail if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - for key in build_linux build_macos benchmark docs spark_3_4 spark_3_5 spark_4_0 spark_4_1 iceberg_1_8 iceberg_1_9 iceberg_1_10 iceberg_1_11; do + for key in build_linux build_macos benchmark docs spark_3_4 spark_3_5 spark_4_0 spark_4_1 iceberg_1_8 iceberg_1_9 iceberg_1_10 iceberg_1_11 delta; do echo "${key}=true" >> "$GITHUB_OUTPUT" done exit 0 @@ -234,6 +235,18 @@ jobs: spark-full: '3.5.9' java: 17 + delta_contrib: + name: Delta Contrib Tests + needs: changes + permissions: + contents: read + if: | + needs.changes.outputs.delta == 'true' && + (github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request') + uses: ./.github/workflows/delta_contrib_test.yml + spark_4_0: name: Spark SQL Tests (Spark 4.0) needs: changes diff --git a/.github/workflows/delta_contrib_test.yml b/.github/workflows/delta_contrib_test.yml new file mode 100644 index 00000000000..586053afcea --- /dev/null +++ b/.github/workflows/delta_contrib_test.yml @@ -0,0 +1,165 @@ +# 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: Delta Contrib Tests + +# Reusable: invoked by ci.yml. Triggering, path filters, and concurrency +# live in the umbrella workflow. +on: + workflow_call: + +permissions: + contents: read + +env: + RUST_VERSION: stable + RUST_BACKTRACE: 1 + # Force GNU ld on Linux: rust-lld cannot resolve -ljvm against the Zulu JDK + # layout installed by setup-java (same rationale as pr_build_linux.yml). + RUSTFLAGS: "-Clink-arg=-fuse-ld=bfd" + +jobs: + + contrib-delta: + name: Delta contrib (Spark ${{ matrix.profile.spark }}) + runs-on: ubuntu-24.04 + container: + image: amd64/rust + strategy: + matrix: + profile: + - spark: "3.5" + java_version: "17" + - spark: "4.0" + java_version: "17" + - spark: "4.1" + java_version: "17" + # spark-4.2 is intentionally absent: the contrib profile is dormant + # until a Delta release supports Spark 4.2. + fail-fast: false + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust & Java toolchain + uses: ./.github/actions/setup-builder + with: + rust-version: ${{ env.RUST_VERSION }} + jdk-version: ${{ matrix.profile.java_version }} + + - name: Cache Maven dependencies + uses: actions/cache@v6 + with: + path: | + ~/.m2/repository + /root/.m2/repository + key: ${{ runner.os }}-java-maven-${{ hashFiles('**/pom.xml') }}-delta-${{ matrix.profile.spark }} + restore-keys: | + ${{ runner.os }}-java-maven- + + - name: Restore Cargo cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target + key: ${{ runner.os }}-cargo-ci-${{ hashFiles('native/**/Cargo.lock', 'native/**/Cargo.toml') }}-${{ hashFiles('native/**/*.rs') }} + restore-keys: | + ${{ runner.os }}-cargo-ci-${{ hashFiles('native/**/Cargo.lock', 'native/**/Cargo.toml') }}- + + - name: Build native library with the delta feature (CI profile) + run: | + cd native + cargo build --profile ci --features delta + env: + # Must match the flags spark_sql_test_reusable.yml builds with: + # cargo folds RUSTFLAGS into its fingerprints, so any divergence + # would make the shared cargo cache restore without ever hitting. + RUSTFLAGS: "-Ctarget-cpu=x86-64-v3 -Clink-arg=-fuse-ld=bfd" + + - name: Save Cargo cache + uses: actions/cache/save@v6 + if: github.ref == 'refs/heads/main' + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/target + key: ${{ runner.os }}-cargo-ci-${{ hashFiles('native/**/Cargo.lock', 'native/**/Cargo.toml') }}-${{ hashFiles('native/**/*.rs') }} + + - name: Stage native library at release path + run: | + # Maven's -Prelease profile (activated below) expects libcomet.so + # under native/target/release/; --profile ci builds it under + # native/target/ci/ instead (same as the other native-building + # workflows), so copy it into place. + mkdir -p native/target/release + cp native/target/ci/libcomet.so native/target/release/libcomet.so + + - name: Install Comet core jars + run: | + ./mvnw -B -q -Prelease -Pspark-${{ matrix.profile.spark }} install -pl common,spark -DskipTests -Dspotless.check.skip=true + + - name: Run Delta contrib test suites + run: | + SPARK_HOME=$(pwd) COMET_CONF_DIR=$(pwd)/conf ./mvnw -B -Prelease -Pspark-${{ matrix.profile.spark }},delta test -pl contrib/delta-spark + + # `delta` is in the default feature set, so no regular job builds without it; + # this keeps the feature-off build and its "built without the delta feature" + # error arm (planner.rs cfg(not(feature = "delta"))) from becoming dead code. + feature-off-build: + name: Feature-off native build + runs-on: ubuntu-24.04 + container: + image: amd64/rust + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust & Java toolchain + uses: ./.github/actions/setup-builder + with: + rust-version: ${{ env.RUST_VERSION }} + jdk-version: "17" + + - name: Test the delta-off error path + run: | + cd native + # The test binary links JNI; libjvm.so must be resolvable at load + # time (same as .github/actions/rust-test). + export LD_LIBRARY_PATH=${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH} + cargo test -p datafusion-comet --no-default-features --features hdfs-opendal delta_scan + + # The contrib's dev tooling (benchmark and regression-harness scripts) is + # Python; keep it import-clean across currently supported interpreters. + dev-scripts-python: + name: Delta dev scripts (Python ${{ matrix.python-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + fail-fast: false + steps: + - uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Byte-compile contrib dev scripts + run: | + python -m compileall -q contrib/delta-spark/dev diff --git a/contrib/delta-spark/README.md b/contrib/delta-spark/README.md new file mode 100644 index 00000000000..99d627252f5 --- /dev/null +++ b/contrib/delta-spark/README.md @@ -0,0 +1,60 @@ + + +# Comet Delta Lake Contrib (experimental) + +Native Delta Lake reads for Comet. Delta tables are scanned through Comet's +existing native Parquet reader, so they get row-group pruning, page-index +pruning, and filter pushdown, with deletion vectors applied inside the scan. + +Support is experimental and explicitly opt-in. Two things are required: + +1. This module's jar (`comet-contrib-delta-spark`) on the classpath, alongside + `delta-spark`. It is never bundled into `comet-spark`; without it, Comet + has no Delta surface at all. +2. `spark.comet.scan.delta.enabled=true`. The default is `false`, so the jar + alone does nothing. + +Unsupported tables and features fall back to Spark's reader. See the +[user guide](https://datafusion.apache.org/comet/user-guide/delta.html) +for configuration details. + +## Supported versions + +| Spark | Delta | Status | +| ----- | -------------- | --------------------------------------------- | +| 3.5 | 3.3.x | supported | +| 4.0 | 4.0.x | supported | +| 4.1 | 4.3.x | supported | +| 3.4 | delta-core 2.4 | not supported (older Delta, would need shims) | +| 4.2 | none released | inert until Delta ships a Spark 4.2 release | + +## Building and testing + +The module builds under the `delta` Maven profile: + +```shell +./mvnw -Pspark-3.5,delta install -pl contrib/delta-spark +``` + +Run the test suites the same way (`test` instead of `install`). CI runs them +on Spark 3.5, 4.0, and 4.1 via `.github/workflows/delta_contrib_test.yml`. + +`dev/` contains a benchmark script (`bench_delta_comet.py`) and a harness for +running Delta's own test suites against Comet (`run-delta-regression.sh`). diff --git a/contrib/delta-spark/dev/bench_delta_comet.py b/contrib/delta-spark/dev/bench_delta_comet.py new file mode 100644 index 00000000000..6416dda92c9 --- /dev/null +++ b/contrib/delta-spark/dev/bench_delta_comet.py @@ -0,0 +1,272 @@ +# 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. + +""" +Benchmark: page-level skipping on a DELTA table under three configurations. + + 1. stock — plain Spark 3.5.6 + delta-spark 3.3.2 + 2. comet — Comet enabled WITHOUT the Delta contrib (scan falls back to Spark) + 3. contrib — Comet + comet-contrib-delta (native Delta scan) + +Writes a 20M-row table sorted by `ts` (4 files, zstd, small pages) as Delta, +optionally deletes a slice via DVs, then runs a 5%-wide range predicate and +reports the fraction of the table materialized by the scan plus wall time. + +Usage: python bench_delta_comet.py [--dv] [--subquery] + mode: stock | comet | contrib (jars/extensions injected by the wrapper script) + --subquery: bound the range predicate with scalar subqueries over a one-row + thresholds Delta table instead of literals. Same rows selected; exercises + the execution-time resolve-and-push path (which stock Spark 3.5 lacks: + FileSourceStrategy strips subquery predicates from scan dataFilters). +""" + +import os +import sys +import time + +from pyspark.sql import SparkSession +from pyspark.sql import functions as F + +ROWS = 20_000_000 +FILES = 4 +PRED_LO, PRED_HI = 0.475, 0.525 # 5% slice in the middle +# DV delete ranges: one nested inside the predicate slice, one far outside it. +DV_DELETE_LO, DV_DELETE_HI = 0.48, 0.49 + + +def build_session(mode: str) -> SparkSession: + extensions = "io.delta.sql.DeltaSparkSessionExtension" + if mode in ("comet", "contrib"): + extensions += ",org.apache.comet.CometSparkSessionExtensions" + b = ( + SparkSession.builder.appName(f"delta-comet-bench-{mode}") + .config("spark.sql.extensions", extensions) + .config("spark.sql.adaptive.enabled", "false") + .config( + "spark.sql.catalog.spark_catalog", + "org.apache.spark.sql.delta.catalog.DeltaCatalog", + ) + .config("spark.driver.memory", "6g") + .config("spark.sql.shuffle.partitions", "8") + .config("spark.ui.enabled", "false") + .config("spark.hadoop.parquet.page.size", str(64 * 1024)) + .config("spark.hadoop.parquet.block.size", str(32 * 1024 * 1024)) + ) + if mode in ("comet", "contrib"): + b = ( + b.config("spark.comet.enabled", "true") + .config("spark.comet.exec.enabled", "true") + .config("spark.comet.exec.shuffle.enabled", "true") + .config( + "spark.shuffle.manager", + "org.apache.spark.sql.comet.execution.shuffle.CometShuffleManager", + ) + .config("spark.memory.offHeap.enabled", "true") + .config("spark.memory.offHeap.size", "4g") + .config("spark.comet.explainFallback.enabled", "true") + ) + if mode == "contrib": + b = b.config("spark.comet.scan.delta.enabled", "true") + return b.getOrCreate() + + +def write_table(spark: SparkSession, path: str, with_dv: bool) -> None: + df = ( + spark.range(ROWS) + .withColumn("ts", F.col("id")) + .withColumn("payload", F.sha1(F.col("id").cast("string"))) + .repartitionByRange(FILES, "ts") + .sortWithinPartitions("ts") + ) + ( + df.write.format("delta") + .option("compression", "zstd") + .mode("overwrite") + .save(path) + ) + if with_dv: + spark.sql( + f"ALTER TABLE delta.`{path}` SET TBLPROPERTIES " + "('delta.enableDeletionVectors' = 'true')" + ) + lo = int(ROWS * DV_DELETE_LO) + hi = int(ROWS * DV_DELETE_HI) + spark.sql(f"DELETE FROM delta.`{path}` WHERE ts >= {lo} AND ts < {hi}") + + +def scan_metrics(plan): + """Walk the executed plan and pull metrics from the leaf scan node(s). + + Safe to call right after collect(): the Dataset caches its QueryExecution, + per-task SQLMetric accumulator updates are merged on the driver before the + job completes, and AQE is disabled so the executed plan is final. + """ + from py4j.protocol import Py4JError, Py4JJavaError + + out = {} + + def walk(node): + try: + name = node.nodeName() + if "Scan" in name: + metrics = node.metrics() + it = metrics.keysIterator() + while it.hasNext(): + k = it.next() + out.setdefault((name, k), metrics.get(k).get().value()) + for i in range(node.children().length()): + walk(node.children().apply(i)) + # innerChildren covers plan-in-plan nodes; entries may not be + # SparkPlans, so failures here are ignored rather than fatal. + inner = node.innerChildren() + for i in range(inner.length()): + walk(inner.apply(i)) + except (Py4JError, Py4JJavaError): + pass + + walk(plan) + return out + + +def has_native_scan_with_column(plan, column: str) -> bool: + """True if the executed plan (including subquery inner plans) contains a + CometDeltaNativeScan whose output includes `column`. Programmatic version of + the test suite's `output.exists(_.name == col)` check — identifies the MAIN + table's scan by its distinctive column, since subquery mode adds trivial + thresholds-table scans that would fool any name-only or count-based check. + """ + from py4j.protocol import Py4JError, Py4JJavaError + + def walk(node) -> bool: + try: + if node.nodeName().startswith("CometDeltaNativeScan"): + attrs = node.output() + for i in range(attrs.length()): + if attrs.apply(i).name() == column: + return True + for i in range(node.children().length()): + if walk(node.children().apply(i)): + return True + inner = node.innerChildren() + for i in range(inner.length()): + if walk(inner.apply(i)): + return True + except (Py4JError, Py4JJavaError): + pass + return False + + return walk(plan) + + +def pred_bounds() -> tuple[int, int]: + """Single source of truth for the range bounds, so the literal and subquery + modes are guaranteed to select the same rows.""" + return int(ROWS * PRED_LO), int(ROWS * PRED_HI) + + +def write_thresholds(spark: SparkSession, thr_path: str) -> None: + lo, hi = pred_bounds() + spark.sql( + f"SELECT CAST({lo} AS BIGINT) AS lo, CAST({hi} AS BIGINT) AS hi" + ).write.format("delta").mode("overwrite").save(thr_path) + + +def run_query(spark: SparkSession, path: str, thr_path: str | None = None): + if thr_path is not None: + df = spark.sql( + f"SELECT count(*) AS n, sum(length(payload)) AS s FROM delta.`{path}` " + f"WHERE ts >= (SELECT lo FROM delta.`{thr_path}`) " + f"AND ts < (SELECT hi FROM delta.`{thr_path}`)" + ) + else: + lo, hi = pred_bounds() + df = ( + spark.read.format("delta") + .load(path) + .where((F.col("ts") >= lo) & (F.col("ts") < hi)) + .agg(F.count("*").alias("n"), F.sum(F.length("payload")).alias("s")) + ) + t0 = time.perf_counter() + row = df.collect()[0] + elapsed = time.perf_counter() - t0 + plan = df._jdf.queryExecution().executedPlan() + mets = scan_metrics(plan) + main_scan_native = has_native_scan_with_column(plan, "payload") + return row, elapsed, mets, plan.toString(), main_scan_native + + +def main(): + if len(sys.argv) < 3 or sys.argv[1] not in ("stock", "comet", "contrib"): + print(__doc__) + sys.exit(2) + mode, workdir = sys.argv[1], sys.argv[2] + with_dv = "--dv" in sys.argv + with_subquery = "--subquery" in sys.argv + path = f"{workdir}/delta_bench{'_dv' if with_dv else ''}" + thr_path = f"{workdir}/delta_bench_thr" if with_subquery else None + spark = build_session(mode) + spark.sparkContext.setLogLevel("WARN") + + if not os.path.exists(path + "/_delta_log"): + print(f"[bench] writing table to {path}") + write_table(spark, path, with_dv) + if thr_path is not None and not os.path.exists(thr_path + "/_delta_log"): + write_thresholds(spark, thr_path) + + try: + # warm-up then measured run + run_query(spark, path, thr_path) + row, elapsed, mets, plan_str, main_scan_native = run_query(spark, path, thr_path) + except BaseException: + spark.stop() + raise + + print(f"\n=== mode={mode} dv={with_dv} subquery={with_subquery} ===") + print(f"result: n={row['n']} sum={row['s']}") + print(f"wall_time_s: {elapsed:.3f}") + interesting = ( + "output_rows", + "numOutputRows", + "bytes_scanned", + "page_index_rows_pruned", + "page_index_rows_matched", + "row_groups_pruned_statistics", + "row_groups_matched_statistics", + "numFiles", + "filesSize", + ) + for (node, k), v in sorted(mets.items()): + if any(k == i for i in interesting): + print(f"metric: {node} :: {k} = {v}") + # rows materialized by the scan as fraction of table + scanned = [v for (n, k), v in mets.items() if k in ("output_rows", "numOutputRows")] + if scanned: + frac = max(scanned) / ROWS + print(f"scan_fraction: {frac:.4f}") + seen = {k for (_, k) in mets} + for key in ("output_rows", "numOutputRows"): + if key in seen: + break + else: + print("WARNING: no scan row metrics found; scan_fraction unavailable") + if mode == "contrib" and not main_scan_native: + print("WARNING: contrib mode but the main table's scan is not CometDeltaNativeScan!") + spark.stop() + + +if __name__ == "__main__": + main() diff --git a/contrib/delta-spark/dev/run-delta-regression.sh b/contrib/delta-spark/dev/run-delta-regression.sh new file mode 100755 index 00000000000..44cffb8d8ac --- /dev/null +++ b/contrib/delta-spark/dev/run-delta-regression.sh @@ -0,0 +1,176 @@ +#!/bin/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 Delta Lake's own Spark test suites against a Comet build with the +# native Delta scan enabled. Clones delta at $DELTA_VERSION into $WORKDIR, +# injects Comet into the test SparkSession (DeltaSQLCommandTest) and the +# test classpath (unmanagedJars), then runs the given testOnly selectors. +# +# Usage: +# COMET_JARS=/path/comet-spark.jar,/path/comet-contrib-delta.jar,/path/flatbuffers.jar \ +# ./run-delta-regression.sh 'org.apache.spark.sql.delta.DeletionVectorsSuite' [...] +# +# Env: +# DELTA_VERSION delta tag to test against (default 3.3.2) +# COMET_JARS comma-separated jars added to the test classpath (required) +# JAVA_HOME JDK for sbt (17 recommended) +set -euo pipefail + +DELTA_VERSION="${DELTA_VERSION:-3.3.2}" +WORKDIR="${1:?usage: run-delta-regression.sh [...suites]}" +shift +[ $# -ge 1 ] || { echo "no suites given" >&2; exit 2; } +: "${COMET_JARS:?COMET_JARS must list the comet jars}" + +# Resolve to an absolute path before the cd below: the log path is built from +# $WORKDIR after we're already inside the Delta checkout, so a relative +# argument would otherwise be re-anchored under $DELTA_DIR. +mkdir -p "$WORKDIR" +WORKDIR=$(cd "$WORKDIR" && pwd) + +# Canonicalize each entry to an absolute path before the cd below, for the same +# reason as the WORKDIR normalization above: the injected sbt `file(p)` resolves +# a relative COMET_EXTRA_JARS entry beneath $DELTA_DIR, not the caller's directory, +# once we've already changed into the Delta checkout. +IFS=',' read -ra _jars <<< "$COMET_JARS" +_jars_abs=() +for j in "${_jars[@]}"; do + [ -f "$j" ] || { echo "COMET_JARS entry not found: $j" >&2; exit 2; } + _jars_abs+=("$(cd "$(dirname "$j")" && pwd)/$(basename "$j")") +done +COMET_JARS=$(IFS=','; echo "${_jars_abs[*]}") + +DELTA_DIR="$WORKDIR/delta-$DELTA_VERSION" +if [ ! -d "$DELTA_DIR" ]; then + git clone --depth 1 --branch "v$DELTA_VERSION" https://github.com/delta-io/delta.git "$DELTA_DIR" +elif [ ! -d "$DELTA_DIR/.git" ]; then + echo "stale/partial checkout at $DELTA_DIR; remove it (rm -rf) and rerun" >&2 + exit 2 +fi +cd "$DELTA_DIR" + +# Add COMET_EXTRA_JARS to every project's test classpath, plus the JDK-17 +# module-access flags Spark needs (both for forked test JVMs and sbt's own JVM). +if ! grep -q "COMET_EXTRA_JARS" build.sbt; then + python3 - <<'EOF' +s = open('build.sbt').read() +marker = 'lazy val commonSettings = Seq(' +opens = [ + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.invoke=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.nio=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.util.concurrent=ALL-UNNAMED", + "--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.ref=ALL-UNNAMED", + "--add-opens=java.base/sun.nio.ch=ALL-UNNAMED", + "--add-opens=java.base/sun.nio.cs=ALL-UNNAMED", + "--add-opens=java.base/sun.security.action=ALL-UNNAMED", + "--add-opens=java.base/sun.util.calendar=ALL-UNNAMED", + "--add-exports=java.base/sun.nio.ch=ALL-UNNAMED", +] +opts = ", ".join('"%s"' % o for o in opens) +inject = ( + 'lazy val commonSettings = Seq(\n' + ' Test / unmanagedJars ++= sys.env.get("COMET_EXTRA_JARS").toSeq\n' + ' .flatMap(_.split(",")).map(p => Attributed.blank(file(p))),\n' + ' Test / fork := true,\n' + ' Test / javaOptions ++= Seq(%s),\n' % opts +) +assert marker in s, 'commonSettings marker not found' +open('build.sbt', 'w').write(s.replace(marker, inject, 1)) +EOF +fi + +# Inject Comet into the shared test SparkSession when COMET_EXTRA_JARS is set. +TEST_BASE=spark/src/test/scala/org/apache/spark/sql/delta/test/DeltaSQLCommandTest.scala +if ! grep -q "CometSparkSessionExtensions" "$TEST_BASE"; then + python3 - "$TEST_BASE" <<'EOF' +import sys +p = sys.argv[1] +s = open(p).read() +old = ''' override protected def sparkConf: SparkConf = { + super.sparkConf + .set(StaticSQLConf.SPARK_SESSION_EXTENSIONS.key, + classOf[DeltaSparkSessionExtension].getName) + .set(SQLConf.V2_SESSION_CATALOG_IMPLEMENTATION.key, + classOf[DeltaCatalog].getName) + }''' +new = ''' override protected def sparkConf: SparkConf = { + val conf = super.sparkConf + .set(StaticSQLConf.SPARK_SESSION_EXTENSIONS.key, + classOf[DeltaSparkSessionExtension].getName) + .set(SQLConf.V2_SESSION_CATALOG_IMPLEMENTATION.key, + classOf[DeltaCatalog].getName) + if (sys.env.contains("COMET_EXTRA_JARS")) { + conf + .set(StaticSQLConf.SPARK_SESSION_EXTENSIONS.key, + classOf[DeltaSparkSessionExtension].getName + + ",org.apache.comet.CometSparkSessionExtensions") + .set("spark.comet.enabled", "true") + .set("spark.comet.exec.enabled", "true") + .set("spark.comet.exec.shuffle.enabled", "true") + .set("spark.shuffle.manager", + "org.apache.spark.sql.comet.execution.shuffle.CometShuffleManager") + .set("spark.memory.offHeap.enabled", "true") + .set("spark.memory.offHeap.size", "2g") + .set("spark.comet.scan.delta.enabled", "true") + } else conf + }''' +assert old in s, 'sparkConf block not found' +open(p, 'w').write(s.replace(old, new)) +EOF +fi + +# ScanReportHelper is a test-only trait that counts scans by pattern-matching +# FileSourceScanExec in the executed plan. The Comet Delta scan replaces those +# nodes, so claimed scans would go uncounted ("0 did not equal 2" in +# MergeIntoSuiteBase's insert-only data-skipping test). Map the Comet node back +# to the FileSourceScanExec it was built from: originalPlan carries the same +# PreparedDeltaFileIndex, so the reported paths and skipping stats are identical. +SCAN_HELPER=spark/src/test/scala/org/apache/spark/sql/delta/test/ScanReportHelper.scala +if [ -f "$SCAN_HELPER" ] && ! grep -q "CometDeltaNativeScanExec" "$SCAN_HELPER"; then + python3 - "$SCAN_HELPER" <<'EOF' +import sys +p = sys.argv[1] +s = open(p).read() +old = " case fs: FileSourceScanExec => Seq(fs)\n" +new = (" case fs: FileSourceScanExec => Seq(fs)\n" + " case c: org.apache.spark.sql.comet.CometDeltaNativeScanExec =>\n" + " Seq(c.originalPlan)\n") +assert s.count(old) == 1, s.count(old) +open(p, 'w').write(s.replace(old, new)) +EOF +fi + +export COMET_EXTRA_JARS="$COMET_JARS" +export SPARK_LOCAL_IP=127.0.0.1 +export RUST_BACKTRACE=1 + +cmds=() +for sel in "$@"; do + cmds+=("spark/testOnly $sel") +done + +LOG="$WORKDIR/delta-regression-$(date +%Y%m%d-%H%M%S).log" +echo "==> logging to $LOG" +build/sbt "${cmds[@]}" 2>&1 | tee "$LOG" | grep -E "^\[info\] (Tests:|Suites:|All tests|.*\*\*\* FAILED| - )" | tail -80 diff --git a/contrib/delta-spark/pom.xml b/contrib/delta-spark/pom.xml new file mode 100644 index 00000000000..54a681474d0 --- /dev/null +++ b/contrib/delta-spark/pom.xml @@ -0,0 +1,210 @@ + + + + + 4.0.0 + + org.apache.datafusion + comet-parent-spark${spark.version.short}_${scala.binary.version} + 1.1.0-SNAPSHOT + ../../pom.xml + + + comet-contrib-delta-spark${spark.version.short}_${scala.binary.version} + comet-contrib-delta + + + + ${project.basedir}/../../native/target/debug + false + + + + + org.apache.datafusion + comet-spark-spark${spark.version.short}_${scala.binary.version} + ${project.version} + provided + + + io.delta + ${delta.artifact}_${scala.binary.version} + ${delta.version} + provided + + + + commons-logging + commons-logging + + + + + org.apache.spark + spark-sql_${scala.binary.version} + provided + + + + com.google.flatbuffers + flatbuffers-java + 25.2.10 + test + + + + org.apache.arrow + arrow-vector + ${arrow.version} + test + + + org.apache.arrow + arrow-memory-unsafe + ${arrow.version} + test + + + org.apache.arrow + arrow-c-data + ${arrow.version} + test + + + + org.apache.parquet + parquet-column + + + org.apache.parquet + parquet-hadoop + + + + org.apache.datafusion + comet-spark-spark${spark.version.short}_${scala.binary.version} + ${project.version} + test-jar + test + + + org.scalatest + scalatest_${scala.binary.version} + test + + + + org.testcontainers + minio + + + software.amazon.awssdk + s3 + + + + org.apache.spark + spark-hadoop-cloud_${scala.binary.version} + tests + + + + com.google.guava + guava + ${guava.version} + test + + + org.scalatestplus + junit-4-13_${scala.binary.version} + test + + + org.apache.spark + spark-sql_${scala.binary.version} + ${spark.version} + test-jar + test + + + org.apache.spark + spark-core_${scala.binary.version} + ${spark.version} + test-jar + test + + + + commons-logging + commons-logging + + + + + org.apache.spark + spark-catalyst_${scala.binary.version} + ${spark.version} + test-jar + test + + + + + + + net.alchim31.maven + scala-maven-plugin + + + org.scalatest + scalatest-maven-plugin + + + + + + + + release + + ${project.basedir}/../../native/target/release + + + + + diff --git a/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.CometConfigProvider b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.CometConfigProvider new file mode 100644 index 00000000000..6db01e4b245 --- /dev/null +++ b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.CometConfigProvider @@ -0,0 +1,17 @@ +# 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. +org.apache.comet.contrib.delta.DeltaSparkConfigProvider diff --git a/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.rules.CometScanContrib b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.rules.CometScanContrib new file mode 100644 index 00000000000..25a913e0cd0 --- /dev/null +++ b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.comet.rules.CometScanContrib @@ -0,0 +1,17 @@ +# 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. +org.apache.comet.contrib.delta.DeltaScanContrib diff --git a/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.spark.sql.comet.PlanDataInjector b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.spark.sql.comet.PlanDataInjector new file mode 100644 index 00000000000..c26629ec377 --- /dev/null +++ b/contrib/delta-spark/src/main/resources/META-INF/services/org.apache.spark.sql.comet.PlanDataInjector @@ -0,0 +1,17 @@ +# 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. +org.apache.spark.sql.comet.DeltaPlanDataInjector diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala new file mode 100644 index 00000000000..bdecc7b8643 --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala @@ -0,0 +1,557 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import scala.jdk.CollectionConverters._ + +import org.apache.hadoop.fs.Path +import org.apache.spark.internal.Logging +import org.apache.spark.sql.catalyst.expressions.Literal +import org.apache.spark.sql.comet.{CometScanExec, DeltaPlanDataInjector} +import org.apache.spark.sql.delta.DeltaParquetFileFormat +import org.apache.spark.sql.delta.RowIndexFilterType +import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor +import org.apache.spark.sql.execution.{FileSourceScanExec, ScalarSubquery => ExecScalarSubquery} +import org.apache.spark.sql.execution.datasources.{FilePartition, PartitionedFile} +import org.apache.spark.sql.types.{ByteType, LongType, MetadataBuilder, StructField, StructType} + +import org.apache.comet.objectstore.NativeConfig +import org.apache.comet.serde.OperatorOuterClass +import org.apache.comet.serde.OperatorOuterClass.Operator +import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType} +import org.apache.comet.serde.operator.{literalToProto, partition2Proto, schema2Proto, CometNativeScan} +import org.apache.comet.shims.ShimFileFormat + +/** + * Serde for the native Delta scan. Two shapes: + * - Plain reads reuse core's `NativeScanCommon` builder wholesale. + * - Deletion-vector reads: Delta's planner appends `__delta_internal_is_row_deleted` (tinyint) + * and Spark's row-index temp column (bigint) to the read schema and filters on is_row_deleted + * above the scan. The native reader applies the DV as a row selection, so both internal + * columns are emitted as per-file constants (0), the parquet read schema is stripped to the + * real data columns, and the DV descriptor ships per file for native to fetch and decode. + */ +object CometDeltaNativeScan + extends Logging + with org.apache.spark.sql.catalyst.expressions.PredicateHelper { + + val IsRowDeletedColumn: String = DeltaParquetFileFormat.IS_ROW_DELETED_COLUMN_NAME + val RowIndexColumn: String = ShimFileFormat.ROW_INDEX_TEMPORARY_COLUMN_NAME + + private[delta] val internalColumnNames: Set[String] = Set(IsRowDeletedColumn, RowIndexColumn) + + // Prefix for the internal columns' slots in the partition schema, mirroring core's + // _comet_metadata_ prefix rationale: DataFusion matches partition columns by name. + // [[allocateUniqueInternalFields]] additionally suffixes on collision with a real column. + private val deltaConstFieldPrefix = "_comet_delta_" + + def isDvShape(scanExec: FileSourceScanExec): Boolean = + scanExec.requiredSchema.exists(f => internalColumnNames.contains(f.name)) + + private def deltaFormat(scanExec: FileSourceScanExec): DeltaParquetFileFormat = + scanExec.relation.fileFormat.asInstanceOf[DeltaParquetFileFormat] + + private def columnMappingMode(scanExec: FileSourceScanExec): String = + deltaFormat(scanExec).metadata.columnMappingMode.name + + /** + * Under column mapping, parquet files store physical column names (stable UUIDs / ids), so the + * schemas passed to the native parquet reader must be physical. Positions and structure are + * preserved, so output binding and projection are unaffected. The scan's internal DV columns + * are not part of the table schema and must be stripped before calling this. + * + * `private[delta]` (not `private`): [[DeltaScanSupport.declineReason]]'s non-ASCII + * case-insensitive name gate reuses this exact conversion to compute the names native sees + * under column mapping, rather than re-deriving physical names with separate logic. + */ + private[delta] def toPhysical(scanExec: FileSourceScanExec, schema: StructType): StructType = { + val format = deltaFormat(scanExec) + if (format.metadata.columnMappingMode.name == "none") { + schema + } else { + // Name mode matches file columns by physical NAME. Strip the parquet.field.id metadata + // createPhysicalSchema also stamps: files written before the column-mapping upgrade have + // no field ids and would fail the reader's id expectations. + stripFieldIds(org.apache.spark.sql.delta.DeltaColumnMapping + .createPhysicalSchema(schema, format.metadata.schema, format.metadata.columnMappingMode)) + } + } + + private def stripFieldIds(schema: StructType): StructType = { + import org.apache.spark.sql.types._ + def stripType(dt: DataType): DataType = dt match { + case s: StructType => stripFieldIds(s) + case a: ArrayType => a.copy(elementType = stripType(a.elementType)) + case m: MapType => + m.copy(keyType = stripType(m.keyType), valueType = stripType(m.valueType)) + case other => other + } + StructType(schema.fields.map { f => + val metadata = new MetadataBuilder() + .withMetadata(f.metadata) + .remove("parquet.field.id") + // Sibling key Delta stamps on array/map fields under IcebergCompat/Uniform. + .remove("parquet.field.nested.ids") + .build() + f.copy(dataType = stripType(f.dataType), metadata = metadata) + }) + } + + /** + * Build the planning-time `DeltaScan` operator (common data only; file partitions are injected + * lazily at execution). Returns None when an output data type cannot be serialized or the plan + * shape is not one we can translate faithfully. `memo` is the same claim-memo instance + * [[DeltaScanSupport.declineReason]] populated on this claim; its `hadoopConf` and + * `dvDescriptors` are reused here rather than recomputed. + */ + def convert( + scanExec: FileSourceScanExec, + scanHelper: CometScanExec, + memo: DeltaScanSupport.DeltaClaimMemo): Option[Operator] = { + val relation = scanExec.relation + + val firstFileUri = scanHelper.selectedPartitions + .flatMap(_.files.headOption) + .headOption + .map(_.getPath.toUri) + + val hadoopConf = memo.hadoopConf + + val tableRootPath = relation.location.rootPaths.head + val tableRoot = tableRootPath.toString + + val commonOpt = if (!isDvShape(scanExec)) { + // Under column mapping (name mode) the parquet reader must see physical names; + // positions are preserved so output binding and projection stay untouched. + CometNativeScan.buildNativeScanCommon( + source = scanExec.simpleStringWithNodeId(), + output = scanExec.output, + requiredSchema = toPhysical(scanExec, scanExec.requiredSchema), + dataSchema = toPhysical(scanExec, relation.dataSchema), + partitionSchema = toPhysical(scanExec, relation.partitionSchema), + fileConstantMetadataColumns = scanExec.fileConstantMetadataColumns, + dataFilters = scanHelper.supportedDataFilters, + firstFileUri = firstFileUri, + hadoopConf = hadoopConf, + conf = scanExec.conf) + } else { + buildDvScanCommon(scanExec, scanHelper, firstFileUri, hadoopConf) + } + + commonOpt.map { commonBuilder => + // Already forced by declineReason on this claim; reused rather than deserialized again. + val dvDescriptors = memo.dvDescriptors + // Union object-store options over every authority a partition of this scan may need a + // store for, not just the first data file's scheme. + commonBuilder.putAllObjectStoreOptions( + mergedObjectStoreOptions( + hadoopConf, + storeUris(dvDescriptors, tableRootPath, firstFileUri)).asJava) + + val common = commonBuilder.build() + val deltaCommon = OperatorOuterClass.DeltaSparkScanCommon + .newBuilder() + .setTableRoot(tableRoot) + .setColumnMappingMode(columnMappingMode(scanExec)) + .setSourceKey(DeltaPlanDataInjector.sourceKey(tableRoot, common)) + .build() + val deltaScan = OperatorOuterClass.DeltaSparkScan + .newBuilder() + .setCommon(common) + .setDeltaCommon(deltaCommon) + Operator + .newBuilder() + .setPlanId(scanExec.id) + .setContribScan(DeltaSparkScanEnvelope.pack(deltaScan.build())) + .build() + } + } + + /** + * One representative store URI per distinct object-store authority this scan's partitions may + * need options for: the data-file authority (`firstFileUri`), the table root unconditionally + * (UUID-relative DV sidecars resolve against it), and every distinct on-disk DV authority from + * `descriptors` (inline DVs carry no external URI and are filtered out). Deduping by authority + * rather than full URI keeps this O(distinct authorities) instead of O(files), keeping the + * FIRST URI seen per authority so `firstFileUri`/the table root win over a same-authority DV + * path. + */ + private[delta] def storeUris( + descriptors: Seq[DeletionVectorDescriptor], + tableRootPath: Path, + firstFileUri: Option[java.net.URI]): Seq[java.net.URI] = { + val dvAuthorityUris = descriptors + .filter(_.storageType != DeletionVectorDescriptor.INLINE_DV_MARKER) + .map(_.absolutePath(tableRootPath).toUri) + val candidates = firstFileUri.toSeq ++ Seq(tableRootPath.toUri) ++ dvAuthorityUris + val byAuthority = scala.collection.mutable.LinkedHashMap.empty[String, java.net.URI] + candidates.foreach(uri => + byAuthority.getOrElseUpdate(DeltaScanSupport.uriAuthority(uri), uri)) + byAuthority.values.toSeq + } + + /** + * Unions `NativeConfig.extractObjectStoreOptions` over every `uris` authority. Safe to union + * rather than pick one: extracted keys are scheme-disjoint prefixes (`fs.s3a.*` vs + * `fs.azure.*`, ...), so options for different schemes never collide, and re-extracting the + * same scheme from two URIs is idempotent. + */ + private[delta] def mergedObjectStoreOptions( + hadoopConf: org.apache.hadoop.conf.Configuration, + uris: Seq[java.net.URI]): Map[String, String] = + uris.foldLeft(Map.empty[String, String]) { (merged, uri) => + merged ++ NativeConfig.extractObjectStoreOptions(hadoopConf, uri) + } + + /** + * Harvest subquery-bearing predicates for this scan from its covering FilterExec. Spark 3.x + * strips them from a scan's `dataFilters` at planning (`FileSourceStrategy` routes them to the + * post-scan filter only), while Spark 4.x keeps them in `dataFilters`; collecting them here at + * claim time gives the execution-time resolve-and-push path the same inputs on every version, + * and the dedup below keeps Spark 4.x from carrying duplicates. Reference containment alone + * does not prove pushing a predicate down is semantics-preserving, so `spineToScan` also + * requires every intervening operator to commute with the push (an intervening + * LIMIT/Sort/Aggregate/join etc. stops the walk and leaves the filter where Spark placed it: + * missed pruning only). + */ + def subqueryFiltersFromParent( + plan: org.apache.spark.sql.execution.SparkPlan, + scanExec: FileSourceScanExec): Seq[org.apache.spark.sql.catalyst.expressions.Expression] = { + import org.apache.spark.sql.catalyst.expressions.{PlanExpression, SubqueryExpression} + import org.apache.spark.sql.execution.{FilterExec, ProjectExec, SparkPlan} + + // Whether every node from `node` down to `scanExec` is one pushdown can safely cross: a + // deterministic ProjectExec is 1:1 on rows and a deterministic FilterExec only removes rows, + // so moving a predicate over the scan's output through either preserves semantics -- mirroring + // Spark's own PushPredicateThroughNonJoin/CollapseProject rules. A nondeterministic node (or + // anything else: LIMIT/TopN, Sort, Aggregate, Window, joins, ...) can change which rows survive + // to matter, so it stops the walk and the filter is left uncollected (missed pruning only). + def spineToScan(node: SparkPlan): Boolean = node match { + case n if n eq scanExec => true + case p: ProjectExec if p.projectList.forall(_.deterministic) => spineToScan(p.child) + case f: FilterExec if f.condition.deterministic => spineToScan(f.child) + case _ => false + } + + // Nearest FilterExec whose spine down to the scan is Project/Filter-only (the DV shape + // interposes such nodes between them, so do not require a direct parent-child edge). + val filtersAboveScan = plan.collect { + case f: FilterExec if spineToScan(f.child) => f + } + filtersAboveScan.lastOption + .map { f => + splitConjunctivePredicates(f.condition) + .filter(_.deterministic) + .filter(_.references.subsetOf(scanExec.outputSet)) + .filter(p => + SubqueryExpression.hasSubquery(p) || p.exists(_.isInstanceOf[PlanExpression[_]])) + .filterNot(p => scanExec.dataFilters.exists(_.semanticEquals(p))) + } + .getOrElse(Seq.empty) + } + + /** + * Resolve scalar-subquery data filters at execution time and serialize them for native + * pushdown, mirroring `CometNativeScanExec.serializedPartitionData`. `supportedDataFilters` + * excludes PlanExpressions at planning time (subquery results do not exist yet), so these + * bounds reach the native reader only through this path. Filters that fail to serialize are + * skipped: Spark keeps a covering FilterExec above the scan, so this is missed pruning only. + * + * Known core-parity limitation: when fused under a parent native operator, + * `ensureSubqueriesResolved` has already called `updateResult()` on these subqueries and this + * path calls it again (`ScalarSubquery.updateResult` re-executes unconditionally); benign here + * since the subquery's snapshot is pinned at analysis, but wasteful. Fix belongs in core. + */ + def resolvedSubqueryFilters( + dataFilters: Seq[org.apache.spark.sql.catalyst.expressions.Expression], + output: Seq[org.apache.spark.sql.catalyst.expressions.Attribute], + requiredSchema: StructType, + conf: org.apache.spark.sql.internal.SQLConf) + : Seq[org.apache.comet.serde.ExprOuterClass.Expr] = { + if (!conf.getConf(org.apache.spark.sql.internal.SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) { + return Seq.empty + } + val subqueryFilters = dataFilters.filter(_.exists(_.isInstanceOf[ExecScalarSubquery])) + if (subqueryFilters.isEmpty) { + return Seq.empty + } + // Same binding guard as the DV shape's plan-time filters: references limited to the + // data-column prefix of the output, where positions agree with the native read schema. + // Guard BEFORE updateResult so discarded filters never execute their subqueries. + val strippedLen = requiredSchema.count(f => !internalColumnNames.contains(f.name)) + val dataColIds = output.take(strippedLen).map(_.exprId).toSet + val pushableFilters = + subqueryFilters.filter(_.references.forall(r => dataColIds.contains(r.exprId))) + pushableFilters.foreach(_.foreach { + case s: ExecScalarSubquery => s.updateResult() + case _ => + }) + pushableFilters + .flatMap { filter => + // MergeScalarSubqueries can fuse several scalar subqueries into one struct-returning + // subquery accessed via GetStructField; fold that whole subtree to a literal (a bare + // GetStructField-over-Literal would not serialize). + val resolved = filter.transform { + case g @ org.apache.spark.sql.catalyst.expressions + .GetStructField(_: ExecScalarSubquery, _, _) => + Literal.create(g.eval(null), g.dataType) + case s: ExecScalarSubquery => + Literal.create(s.eval(null), s.dataType) + } + val proto = exprToProto(resolved, output) + if (proto.isEmpty) { + logWarning(s"Could not serialize resolved scalar subquery filter: $resolved") + } + proto + } + } + + /** + * Allocate the partition-schema slots for the DV shape's internal columns + * (`internalColumnNames`), with names collision-free against the physical data schema, the + * physical partition schema, and the constant-metadata slots already allocated for this scan + * (plus each other): DataFusion substitutes partition constants BY NAME, so an unprefixed, + * un-uniquified slot could collide with a real column and silently replace its data with the + * bookkeeping constant. `buildDvScanCommon` keys `internalIndexByName` by each field's ORIGINAL + * name from `requiredSchema`, so the renaming here only changes the proto's field name. + */ + private[delta] def allocateUniqueInternalFields( + requiredSchema: StructType, + physicalDataSchema: StructType, + physicalPartitionSchema: StructType, + constantMetadataFields: Seq[StructField]): Seq[StructField] = { + val reserved = scala.collection.mutable.LinkedHashSet[String]() + reserved ++= physicalDataSchema.fields.map(_.name) + reserved ++= physicalPartitionSchema.fields.map(_.name) + reserved ++= constantMetadataFields.map(_.name) + requiredSchema.fields.toSeq + .filter(f => internalColumnNames.contains(f.name)) + .map { f => + var name = s"$deltaConstFieldPrefix${f.name}" + while (reserved.contains(name)) { + name = name + "_" + } + reserved += name + StructField(name, f.dataType, f.nullable) + } + } + + /** + * DV shape common builder. Layout invariants (declined by DeltaScanSupport when violated): scan + * output = requiredSchema attrs (data columns, then the internal columns as a suffix) followed + * by partition and constant-metadata columns. The parquet read schema strips the internal + * columns; they are appended to the partition schema as per-file constants, so the projection + * vector routes them from the constants block. + */ + private def buildDvScanCommon( + scanExec: FileSourceScanExec, + scanHelper: CometScanExec, + firstFileUri: Option[java.net.URI], + hadoopConf: org.apache.hadoop.conf.Configuration) + : Option[OperatorOuterClass.NativeScanCommon.Builder] = { + val relation = scanExec.relation + val output = scanExec.output + val requiredSchema = scanExec.requiredSchema + + val commonBuilder = OperatorOuterClass.NativeScanCommon.newBuilder() + commonBuilder.setSource(scanExec.simpleStringWithNodeId()) + + val scanTypes = output.flatMap(attr => serializeDataType(attr.dataType)) + if (scanTypes.length != output.length) { + return None + } + commonBuilder.addAllFields(scanTypes.asJava) + + val strippedRequired = + StructType(requiredSchema.filterNot(f => internalColumnNames.contains(f.name))) + val strippedLen = strippedRequired.length + val requiredLen = requiredSchema.length + + // Keep only data filters that bind identically in the output and the native index space: + // references limited to the first strippedLen output attributes. Internal-column filters + // (is_row_deleted = 0) are trivially true after native DV application. + if (scanExec.conf.getConf( + org.apache.spark.sql.internal.SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) { + val dataColIds = output.take(strippedLen).map(_.exprId).toSet + val filterProtos = scanHelper.supportedDataFilters + .filter(_.references.forall(r => dataColIds.contains(r.exprId))) + .flatMap(f => exprToProto(f, output)) + commonBuilder.addAllDataFilters(filterProtos.asJava) + } + + // Real partition columns carry physical names in the proto, same as the data/required + // schemas: a retained physical data name can otherwise collide with a partition column's + // LOGICAL name after a rename history, and DataFusion's by-name partition rewrite would then + // replace the data projection with the partition constant. constantMetadataFields/ + // internalFields are synthetic slots, not table columns, so they are not physicalized. + val physicalDataSchema = toPhysical(scanExec, relation.dataSchema) + val physicalPartitionSchema = toPhysical(scanExec, relation.partitionSchema) + // Constant metadata and real partition columns follow the required schema in the output, + // exactly like the plain shape. Names are uniquified against the physical data/partition + // schemas for the same by-name-collision reason [[allocateUniqueInternalFields]] exists. + val constantMetadataFields = CometNativeScan.uniqueConstantMetadataFields( + scanExec.fileConstantMetadataColumns, + physicalDataSchema.fields.map(_.name).toSet ++ physicalPartitionSchema.fields + .map(_.name) + .toSet) + val internalFields = allocateUniqueInternalFields( + requiredSchema, + physicalDataSchema = physicalDataSchema, + physicalPartitionSchema = physicalPartitionSchema, + constantMetadataFields = constantMetadataFields) + val partitionSchemaFields = + physicalPartitionSchema.fields.toSeq ++ constantMetadataFields ++ internalFields + + // Protos carry physical names (column mapping); index math below stays logical. + val partitionSchemaProto = schema2Proto(partitionSchemaFields) + val requiredSchemaProto = schema2Proto(toPhysical(scanExec, strippedRequired)) + val dataSchemaProto = schema2Proto(physicalDataSchema) + + // Projection: data columns from the (stripped) read schema; internal columns from their + // constants slots at the END of the partition fields; the output tail (real partitions + + // constant metadata) positionally from the head of the partition fields. + val dataSchema = relation.dataSchema + val internalBase = dataSchema.length + partitionSchemaFields.length - internalFields.length + val internalIndexByName = requiredSchema.fields.toSeq + .filter(f => internalColumnNames.contains(f.name)) + .zipWithIndex + .map { case (f, i) => f.name -> (internalBase + i) } + .toMap + val projectionVector = output.zipWithIndex.map { case (attr, i) => + val idx = if (internalColumnNames.contains(attr.name)) { + internalIndexByName(attr.name) + } else if (i < requiredLen) { + dataSchema.fieldIndex(attr.name) + } else { + dataSchema.length + (i - requiredLen) + } + idx.toLong.asInstanceOf[java.lang.Long] + } + commonBuilder.addAllProjectionVector(projectionVector.asJava) + + commonBuilder.addAllDataSchema(dataSchemaProto.asJava) + commonBuilder.addAllRequiredSchema(requiredSchemaProto.asJava) + commonBuilder.addAllPartitionSchema(partitionSchemaProto.asJava) + + CometNativeScan.populateScanConfFlags( + commonBuilder, + strippedRequired, + firstFileUri, + hadoopConf, + scanExec.conf) + + Some(commonBuilder) + } + + /** Serialize one file partition into a DeltaSparkScan proto with per-file DV descriptors. */ + def serializePartition( + filePartition: FilePartition, + scanExec: FileSourceScanExec, + tableRoot: String): Array[Byte] = { + val relation = scanExec.relation + val sparkPartition = partition2Proto( + filePartition, + relation.partitionSchema, + scanExec.fileConstantMetadataColumns, + ShimFileFormat.fileConstantMetadataExtractors(relation.fileFormat)) + + val dvShape = isDvShape(scanExec) + + val deltaPartition = OperatorOuterClass.DeltaSparkFilePartition.newBuilder() + sparkPartition.getPartitionedFileList.asScala.zip(filePartition.files.toSeq).foreach { + case (fileProto, file) => + val fileBuilder = fileProto.toBuilder + if (dvShape) { + // Append the internal-constant values after the real partition/constant-metadata + // values, matching the order of the appended partition-schema fields. + scanExec.requiredSchema.fields + .filter(f => internalColumnNames.contains(f.name)) + .foreach { f => + val lit = f.dataType match { + case ByteType => Literal(0.toByte, ByteType) + case LongType => Literal(0L, LongType) + case other => + // Fixed internal invariant (observed Delta 3.3 types); fail loudly on + // drift rather than emit a plausible-looking constant. + throw new IllegalStateException( + s"Unexpected type $other for Delta internal column ${f.name}") + } + fileBuilder.addPartitionValues( + literalToProto(lit, s"delta internal constant ${f.name}")) + } + } + val dfb = OperatorOuterClass.DeltaSparkPartitionedFile + .newBuilder() + .setFile(fileBuilder.build()) + extractDvDescriptor(file, tableRoot).foreach(dfb.setDv) + deltaPartition.addPartitionedFile(dfb.build()) + } + + OperatorOuterClass.DeltaSparkScan + .newBuilder() + .setFilePartition(deltaPartition.build()) + .build() + .toByteArray + } + + /** + * Pull the DV descriptor Delta attached to this file (base64 under + * `row_index_filter_id_encoded`), resolving UUID-relative paths to absolute URLs and + * Z85-decoding inline bitmaps here on the JVM where delta-spark's codecs live. + */ + private def extractDvDescriptor( + file: PartitionedFile, + tableRoot: String): Option[OperatorOuterClass.DeltaSparkDvDescriptor] = { + val encoded = file.otherConstantMetadataColumnValues + .get(DeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED) + val filterType = file.otherConstantMetadataColumnValues + .get(DeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE) + encoded.map { enc => + filterType match { + case Some(RowIndexFilterType.IF_CONTAINED) | None => + case other => + // DeltaScanSupport declines CDF reads, the only source of inverted filters; + // reaching here means a gate was bypassed -- fail loudly rather than corrupt. + throw new IllegalStateException( + s"Native Delta scan cannot apply row index filter type $other") + } + val desc = DeletionVectorDescriptor.deserializeFromBase64(enc.asInstanceOf[String]) + val builder = OperatorOuterClass.DeltaSparkDvDescriptor + .newBuilder() + .setStorageType(desc.storageType) + .setSizeInBytes(desc.sizeInBytes) + .setCardinality(desc.cardinality) + if (desc.storageType == DeletionVectorDescriptor.INLINE_DV_MARKER) { + // Delegates to core, which owns the shaded/relocated dependency this field's setter + // is generated against, so this module's source never has to name that package. + CometNativeScan.setDvInlineData(builder, desc.inlineData) + } else { + // Same convention as data-file paths (SparkPath.urlEncoded): a raw Hadoop path + // with spaces or % characters would be mangled by the native URL parse. + builder.setAbsolutePath( + org.apache.spark.paths.SparkPath + .fromPath(desc.absolutePath(new Path(tableRoot))) + .urlEncoded) + desc.offset.foreach(builder.setOffset) + } + builder.build() + } + } +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanConf.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanConf.scala new file mode 100644 index 00000000000..f9e9dcf7692 --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanConf.scala @@ -0,0 +1,75 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import org.apache.comet.{ConfigBuilder, ConfigEntry} + +/** + * Configuration for the JVM-planned Delta Lake scan contrib. The support is experimental and + * explicitly opt-in: having the contrib jar on the classpath is not enough, the scan must also be + * enabled with `spark.comet.scan.delta.enabled`. + * + * This is the plain, user-facing flag for enabling native Delta scans, kept under the + * `spark.comet.scan.delta` namespace. The experimental Rust-kernel-backed scan path is a separate + * opt-in, defined by the kernel contrib's own `DeltaConf` under the + * `spark.comet.scan.deltaNative` namespace; the two jars define distinct keys and are not + * expected to coexist -- see the ownership contract in `CometScanContrib`. Entry construction + * self-registers with `CometConf.allConfs` via the `ConfigBuilder` machinery. + */ +object DeltaScanConf { + + // Matches the kernel contrib's category so both group onto the same generated-docs table. + private[delta] val CATEGORY = "delta" + + val COMET_DELTA_NATIVE_ENABLED: ConfigEntry[Boolean] = + ConfigBuilder("spark.comet.scan.delta.enabled") + .category(CATEGORY) + .doc( + "Whether to enable native Delta table scans. When enabled, DSv1 Delta table reads " + + "planned by delta-spark are executed through Comet's native Parquet scan, " + + "inheriting row-group pruning, page-index pruning, and filter pushdown, with " + + "deletion vectors applied inside the scan. Experimental: defaults to false, so " + + "adding the contrib jar does not by itself change how any query is read.") + .booleanConf + .createWithDefault(false) + + val COMET_DELTA_MAX_DELETED_ROWS_PER_FILE: ConfigEntry[Long] = + ConfigBuilder("spark.comet.scan.delta.dv.maxDeletedRowsPerFile") + .category(CATEGORY) + .doc( + "Upper bound on a single file's deletion-vector cardinality (deleted row count) the " + + "native Delta scan will claim. Applying a deletion vector expands it into per-row " + + "selectors that are retained in memory for the file's scan; this bound is a " + + "deliberately pessimistic planning-time proxy for that retained memory (deletion " + + "vector cardinality, not the exact selector count), so a large but contiguous " + + "deletion is declined the same as a large alternating one. Scans whose deletion " + + "vectors exceed this bound for any file fall back to Spark's reader.") + .longConf + .createWithDefault(1000000) + + /** + * Every entry defined here, in docs order. Referencing this forces object initialisation, which + * registers the entries -- see `CometConfigProvider`. + */ + def all: Seq[ConfigEntry[_]] = + Seq(COMET_DELTA_MAX_DELETED_ROWS_PER_FILE, COMET_DELTA_NATIVE_ENABLED) + + def scanEnabled: Boolean = COMET_DELTA_NATIVE_ENABLED.get() +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanContrib.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanContrib.scala new file mode 100644 index 00000000000..b17bd29f6ef --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanContrib.scala @@ -0,0 +1,104 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import org.apache.spark.internal.Logging +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.comet.CometDeltaNativeScanExec +import org.apache.spark.sql.execution.{FileSourceScanExec, SparkPlan} +import org.apache.spark.sql.execution.datasources.HadoopFsRelation + +import org.apache.comet.CometConf.COMET_EXEC_ENABLED +import org.apache.comet.CometSparkSessionExtensions.withFallbackReason +import org.apache.comet.rules.CometScanContrib + +/** + * Claims DSv1 Delta Lake scans for native execution, discovered by core's ServiceLoader (see + * `META-INF/services/org.apache.comet.rules.CometScanContrib`). Scans this contrib owns but + * cannot handle are claimed with a tagged fallback reason (per the `CometScanContrib` ownership + * contract); Spark's Delta reader then handles them. + * + * The produced `CometDeltaNativeScanExec` is fully converted at claim time, so this contrib does + * NOT use `CometContribScanMarker` (which exists for planning-time nodes that `CometExecRule` + * converts later; mixing it in here would convert the node a second time). + */ +class DeltaScanContrib extends CometScanContrib with Logging { + + override def tryTransformV1( + plan: SparkPlan, + session: SparkSession, + scanExec: FileSourceScanExec, + relation: HadoopFsRelation): Option[SparkPlan] = { + // Not a Delta scan: not ours; core handles it exactly as before. + if (!DeltaScanSupport.isDeltaScan(scanExec)) { + return None + } + + // Contrib scans are native-exec nodes, so like core's own nativeScan they require + // COMET_EXEC_ENABLED. Our old core-side hook gated all extensions centrally; the + // CometScanContrib call site does not, so the gate lives here. Silent None (no tag) + // preserves the old "never consulted" behavior and avoids double-tagging next to + // core's own exec-disabled fallback reason. + if (!COMET_EXEC_ENABLED.get()) { + return None + } + + if (!DeltaScanConf.scanEnabled) { + // Deliberate deviation from the "own but cannot handle => claim" contract: a + // user-disabled contrib must be fully inert (the jar alone changes nothing) and must + // not shadow another registered Delta contrib. Tag the opt-in hint for EXPLAIN, pass. + withFallbackReason( + scanExec, + "Native Delta scan not enabled: set " + + s"${DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key}=true to opt in") + return None + } + + // Built before declineReason (rather than only on claim) so the multi-object-store gate + // can inspect the scan's selected files without listing them twice; convert reuses this + // same helper on a claim. + val scanHelper = + CometDeltaNativeScanExec.planningHelper(scanExec, scanExec.partitionFilters) + // Populated by declineReason on the claimable path only, and reused by convert below so a + // claimed scan does not recompute the Hadoop conf or the DV descriptors a second time. + val claimMemo = new DeltaScanSupport.DeltaClaimMemo + DeltaScanSupport.declineReason(plan, scanExec, scanHelper, claimMemo) match { + case Some(reason) => + Some(withFallbackReason(scanExec, reason)) + case None => + CometDeltaNativeScan.convert(scanExec, scanHelper, claimMemo) match { + case Some(nativeOp) => + logDebug( + s"COMET-DELTA-CLAIM required=${scanExec.requiredSchema.map(_.name).mkString(",")} " + + s"output=${scanExec.output.map(_.name).mkString(",")} " + + s"dvShape=${CometDeltaNativeScan.isDvShape(scanExec)} " + + s"planRoot=${plan.getClass.getSimpleName}") + val subqueryDataFilters = + CometDeltaNativeScan.subqueryFiltersFromParent(plan, scanExec) + Some(CometDeltaNativeScanExec(nativeOp, scanExec, subqueryDataFilters)) + case None => + Some( + withFallbackReason( + scanExec, + "Native Delta scan does not support the scan's output data types")) + } + } + } +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanSupport.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanSupport.scala new file mode 100644 index 00000000000..087f219b0f8 --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanSupport.scala @@ -0,0 +1,1627 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import java.io.IOException +import java.net.URI +import java.util.Locale + +import scala.collection.mutable.{ListBuffer, Map => MutableMap} +import scala.jdk.CollectionConverters._ + +import org.apache.hadoop.conf.Configuration +import org.apache.hadoop.fs.Path +import org.apache.spark.sql.catalyst.expressions.{Alias, GenericInternalRow, InputFileBlockLength, InputFileBlockStart, InputFileName} +import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, GenericArrayData} +import org.apache.spark.sql.catalyst.util.ResolveDefaultColumns.getExistenceDefaultValues +import org.apache.spark.sql.comet.CometScanExec +import org.apache.spark.sql.delta.DeltaParquetFileFormat +import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor +import org.apache.spark.sql.execution.{FileSourceScanExec, ProjectExec, SparkPlan} +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType} + +import org.apache.comet.CometConf +import org.apache.comet.CometConf.COMET_LIBHDFS_SCHEMES +import org.apache.comet.parquet.CometParquetUtils +import org.apache.comet.rules.{CometScanRule, CometScanTypeChecker} +import org.apache.comet.serde.operator.CometNativeScan +import org.apache.comet.shims.ShimFileFormat + +/** + * Claim/decline gates for the native Delta scan. Correctness rule: when in doubt, decline, + * Spark's Delta reader handles the scan and results stay correct, just unaccelerated. + */ +object DeltaScanSupport { + + /** + * Reader features the native path understands; anything else on the protocol declines the + * table. `deletionVectors`/`columnMapping` are declined separately below for specific reasons. + */ + private val understoodReaderFeatures: Set[String] = + Set("columnMapping", "deletionVectors", "timestampNtz", "v2Checkpoint", "vacuumProtocolCheck") + + /** + * Is this exactly Delta's DSv1 parquet format? Compared by class name, not `classOf`: a + * `classOf` reference would raise `NoClassDefFoundError` and break every parquet scan when + * delta-spark is absent from the classpath. + */ + def isDeltaScan(scanExec: FileSourceScanExec): Boolean = + scanExec.relation.fileFormat.getClass.getName == + "org.apache.spark.sql.delta.DeltaParquetFileFormat" + + /** + * Claim-time artifacts [[declineReason]] already computes but [[CometDeltaNativeScan.convert]] + * also needs -- threaded through by reference (populated only on the claimable path, right + * before `declineReason` returns `None`) so a claimed scan does not pay to recompute either: + * the Hadoop conf ([[org.apache.spark.sql.internal.SessionState#newHadoopConfWithOptions]] is + * not cheap) and the deletion-vector descriptors (base64-decoded, non-trivial only for DV-shape + * scans). One instance is created per claim attempt in `DeltaScanContrib` and passed to both + * `declineReason` and `convert`. + */ + private[delta] final class DeltaClaimMemo { + var hadoopConf: Configuration = _ + var dvDescriptors: Seq[DeletionVectorDescriptor] = Seq.empty + } + + /** + * First reason this Delta scan cannot go native, or None when claimable (in which case `memo` + * is populated for [[CometDeltaNativeScan.convert]] to reuse). Only called when [[isDeltaScan]] + * is true. `scanHelper` is the [[CometScanExec]] built to drive `convert` on a claim, reused + * for the multi-store gate below. + */ + def declineReason( + plan: SparkPlan, + scanExec: FileSourceScanExec, + scanHelper: CometScanExec, + memo: DeltaClaimMemo): Option[String] = { + val format = scanExec.relation.fileFormat.asInstanceOf[DeltaParquetFileFormat] + val protocol = format.protocol + val metadata = format.metadata + // Name mode is supported via physical-name schemas; id mode needs the field-id path and + // stays declined until validated. Hoisted here since several gates below reuse it. + val cmMode = metadata.columnMappingMode.name + // Descriptor deserialization is expensive, so hoist it into a `lazy val`, forced at most + // once in this method; on the claimable path the result is handed to `convert` through + // `memo` below, so a claimed scan deserializes the descriptors exactly once end to end. + val tableRoot = scanExec.relation.location.rootPaths.head.toString + lazy val dvDescriptors: Seq[DeletionVectorDescriptor] = + selectedDvDescriptors(scanHelper, tableRoot) + + // Mirrors core's CometScanRule.isSchemaSupported so scan-time type gates (unsigned-small-int + // fallback, collation, shredded-variant-struct) apply identically here. Pure in-memory check, + // so it runs first, ahead of every I/O-bearing gate below. + val schemaFallbackReasons = new ListBuffer[String]() + val typeChecker = CometScanTypeChecker() + val requiredSchemaSupported = + typeChecker.isSchemaSupported(scanExec.requiredSchema, schemaFallbackReasons) + val partitionSchemaSupported = + typeChecker.isSchemaSupported(scanExec.relation.partitionSchema, schemaFallbackReasons) + if (!requiredSchemaSupported || !partitionSchemaSupported) { + return Some( + "Native Delta scan does not support the schema: " + schemaFallbackReasons.mkString(", ")) + } + + if (format.isCDCRead) { + return Some("Native Delta scan does not support Change Data Feed reads") + } + + // Delta's DML machinery (findTouchedFiles) disables reader optimizations and needs real + // row indexes from Spark's reader; claiming here would feed NULL indexes into DV construction. + if (!format.optimizationsEnabled) { + return Some("Native Delta scan does not support reads with reader optimizations disabled") + } + if (scanExec.requiredSchema.exists(_.name == DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME) || + scanExec.relation.dataSchema.exists( + _.name == DeltaParquetFileFormat.ROW_INDEX_COLUMN_NAME)) { + return Some("Native Delta scan does not support Delta's generated row-index column") + } + + if (cmMode != "none" && cmMode != "name") { + return Some(s"Native Delta scan does not support column mapping mode $cmMode") + } + // createPhysicalSchema wholesale-replaces field metadata, silently dropping EXISTS_DEFAULT. + if (cmMode == "name" && + getExistenceDefaultValues(scanExec.requiredSchema).exists(_ != null)) { + return Some( + "Native Delta scan does not support column defaults together with column mapping") + } + // createPhysicalSchema rewrites nested StructField names too, and the native builder emits the + // required schema verbatim as output, so name-sensitive expressions (e.g. to_json) would leak + // physical names. Decline until a rename adapter exists. + if (cmMode == "name" && + scanExec.requiredSchema.exists(f => containsNestedStruct(f.dataType))) { + return Some("Native Delta scan does not support column mapping with nested struct fields") + } + + val readerFeatures = protocol.readerFeatureNames + val unknownFeatures = readerFeatures -- understoodReaderFeatures + if (unknownFeatures.nonEmpty) { + return Some( + s"Native Delta scan does not support reader feature(s) ${unknownFeatures.mkString(", ")}") + } + + // Non-constant metadata columns are generated per-row by Spark's reader and unsupported, + // except Delta's DV bookkeeping columns, which the native path emits as constants. + val knownColNames = + scanExec.relation.dataSchema.map(_.name).toSet ++ + scanExec.relation.partitionSchema.map(_.name).toSet ++ + scanExec.fileConstantMetadataColumns.map(_.name).toSet ++ + CometDeltaNativeScan.internalColumnNames + val unknownOutput = scanExec.output.map(_.name).filterNot(knownColNames.contains) + if (unknownOutput.nonEmpty) { + return Some( + s"Native Delta scan does not support generated column(s) ${unknownOutput.mkString(", ")}") + } + + // Deletion-vector shape invariants (see CometDeltaNativeScan.buildDvScanCommon). + if (CometDeltaNativeScan.isDvShape(scanExec)) { + // A row-index column WITHOUT is_row_deleted is Delta DML bookkeeping (real row indexes), + // not a DV read; claiming it with a constant would corrupt the DVs being written. + val hasIsRowDeleted = + scanExec.requiredSchema.exists(_.name == CometDeltaNativeScan.IsRowDeletedColumn) + val hasRowIndex = + scanExec.requiredSchema.exists(_.name == CometDeltaNativeScan.RowIndexColumn) + if (hasRowIndex && !hasIsRowDeleted) { + return Some( + "Native Delta scan does not support row-index reads outside a deletion-vector scan") + } + // Internal columns must form a suffix of the read schema so data-column positions agree + // between Spark's output and the stripped native schema. + val names = scanExec.requiredSchema.fields.map(_.name) + val firstInternal = names.indexWhere(CometDeltaNativeScan.internalColumnNames.contains) + if (!names.drop(firstInternal).forall(CometDeltaNativeScan.internalColumnNames.contains)) { + return Some("Native Delta scan requires DV bookkeeping columns to trail the read schema") + } + // Native applies the DV itself and emits a dead constant for row-index, so the real value + // must be provably unused above the scan. + if (!rowIndexUnusedAbove(plan, scanExec)) { + return Some( + "Native Delta scan cannot supply _metadata.row_index values consumed by the query") + } + // The DV common builder does not serialize existence defaults yet. + if (getExistenceDefaultValues(scanExec.requiredSchema).exists(_ != null)) { + return Some( + "Native Delta scan does not support column defaults together with deletion vectors") + } + // Bounds native's memory for expanded DV row selectors (delta_dv.rs), pessimistically + // bounded by 2*cardinality + #row-groups; the conf below makes an over-pessimistic decline + // recoverable. + val maxDeletedRowsPerFile = DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.get() + val oversizedCardinalities = dvDescriptors + .map(_.cardinality) + .filter(_ > maxDeletedRowsPerFile) + if (oversizedCardinalities.nonEmpty) { + return Some( + "Native Delta scan does not support a deletion vector deleting " + + s"${oversizedCardinalities.max} rows in a single file, exceeding " + + s"${DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key}=$maxDeletedRowsPerFile") + } + } + + // input_file_name & friends read from a thread-local Spark's FileScanRDD sets; the native scan + // does not populate it, and Delta's DML find-touched-files scans use it (mirrors core's check + // in CometScanRule.nativeScan). + if (plan.exists(node => + node.expressions.exists(_.exists { + case _: InputFileName | _: InputFileBlockStart | _: InputFileBlockLength => true + case _ => false + }))) { + return Some( + "Native Delta scan is not compatible with input_file_name, " + + "input_file_block_start, or input_file_block_length") + } + + // Row-index metadata columns are generated per-row by Spark's reader (mirrors core); the DV + // shape's trailing row-index column is exempt since the gates above already proved it dead. + if (!CometDeltaNativeScan.isDvShape(scanExec) && + ShimFileFormat.findRowIndexColumnIndexInSchema(scanExec.requiredSchema) >= 0) { + return Some("Native Delta scan does not support row index generation") + } + + // Mirror core's vectorized-reader compatibility gate. + if (!SQLConf.get.getConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED) && + !CometConf.COMET_SCAN_ALLOW_DISABLED_PARQUET_VECTORIZED_READER.get()) { + return Some( + "Native Delta scan is incompatible with " + + s"${SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key}=false") + } + + // Decline ALL encrypted-parquet configurations (stricter than core): the exec node does not + // yet wire the decryption-key broadcast to executors. + val hadoopConf = scanExec.relation.sparkSession.sessionState + .newHadoopConfWithOptions(scanExec.relation.options) + // Populated now (rather than only at the very end) so it is available even though several + // early-return gates below still lie ahead: cheap to set, and every one of those gates + // declines the scan anyway, so `memo` is simply never read by `convert` in that case. + memo.hadoopConf = hadoopConf + if (CometParquetUtils.encryptionEnabled(hadoopConf)) { + return Some("Native Delta scan does not support encrypted parquet") + } + + // Nested-type column defaults cannot be serialized; a dropped default would misalign the + // value/index lists consumed positionally on the native side. Mirrors core's + // transformV1Scan gate. + val possibleDefaultValues = getExistenceDefaultValues(scanExec.requiredSchema) + if (possibleDefaultValues.exists(d => + d != null && (d.isInstanceOf[ArrayBasedMapData] || d + .isInstanceOf[GenericInternalRow] || d.isInstanceOf[GenericArrayData]))) { + return Some("Native Delta scan does not support default values for nested types") + } + + // Only claim scans whose root paths object_store (or the configured libhdfs schemes) can + // actually read (mirrors core's unsupportedFsSchemes gate). + val libhdfs = libhdfsSchemes + val unsupportedRootSchemes = + unsupportedSchemes(scanExec.relation.location.rootPaths.map(_.toUri), libhdfs) + if (unsupportedRootSchemes.nonEmpty) { + return Some( + "Native Delta scan does not support filesystem scheme(s) " + + s"${unsupportedRootSchemes.mkString(", ")}") + } + + // A shallow clone can span multiple object-store authorities, but the native builder resolves + // ObjectStoreUrl from only the FIRST selected file; force file listing and decline rather than + // risk reading a later file through the wrong handle. + val dataFileUris = + scanHelper.selectedPartitions.iterator.flatMap(_.files).map(_.getPath.toUri).toSeq + + // Both gates below need the DV absolute-path URIs; dvDescriptors is already memoized. + val dvUris = dvDescriptors + .filter(_.storageType != DeletionVectorDescriptor.INLINE_DV_MARKER) + .map(_.absolutePath(new Path(tableRoot)).toUri) + + // The root-path gate above only inspects the table root(s); selected files can resolve + // through a different scheme (e.g. `viewfs:`). Checked before the authority gates below, + // which presume every URI is natively resolvable. + val unsupportedSelected = unsupportedSelectedSchemeReason(dataFileUris ++ dvUris, libhdfs) + if (unsupportedSelected.isDefined) { + return unsupportedSelected + } + + // Checked before multiStoreReason, which presumes every URI resolves to a single store + // identity -- a userinfo-bearing authority provably does not (store keying drops userinfo). + val userInfoReason = userInfoBearingAuthorityReason(dataFileUris ++ dvUris) + if (userInfoReason.isDefined) { + return userInfoReason + } + + val multiStore = multiStoreReason(dataFileUris) + if (multiStore.isDefined) { + return multiStore + } + + // GCS's zero-I/O, conf-only credential-forwarding gate; ordered alongside the S3 credential + // gates below since all presume a single, well-formed store identity per URI. + val gcsAuthReason = gcsHadoopOnlyAuthReason(hadoopConf, dataFileUris ++ dvUris) + if (gcsAuthReason.isDefined) { + return gcsAuthReason + } + + // Zero-I/O, conf-only, like the GCS gate above: decline any bucket configured for an + // encryption algorithm outside the allowlist (SSE-C, CSE-KMS, CSE-CUSTOM, or unknown) before + // the credential-divergence gates below, which do not otherwise notice this table is readable + // through Hadoop only because Hadoop's request factory (SSE-C) or SDK-level decryption layer + // (CSE-*) does something native never learns about. + val encryptionReason = + unsupportedEncryptionAlgorithmReason(hadoopConf, dataFileUris ++ dvUris) + if (encryptionReason.isDefined) { + return encryptionReason + } + + // Shared across the two gates below: propagateBucketOptions is a full Configuration deep + // copy, and both gates would otherwise recompute it independently for the same bucket(s) + // (once here, then again per-key inside s3ConfigDivergenceReason). One cache, populated + // lazily per bucket on first use, makes it a single copy total per bucket across both gates. + val propagatedConfCache = MutableMap.empty[String, Configuration] + + // Zero-I/O in the common case, like the encryption gate above: native's S3 client has no + // HTTP proxy support at all (no fs.s3a.proxy.* key is read anywhere in s3.rs), so a bucket + // requiring a proxy for S3 egress must decline here rather than claim and then connect + // directly, bypassing whatever network-segmentation/firewall policy required the proxy. + val proxyReason = proxyGateReason(hadoopConf, dataFileUris ++ dvUris, propagatedConfCache) + if (proxyReason.isDefined) { + return proxyReason + } + + // Every fs.s3a.* option native's get_config (s3.rs) resolves must agree between what Hadoop + // itself would use and what native would read from the forwarded, substituted conf (covers + // long-form bucket credentials, JCEKS/credential-provider shadowing, and any other + // short-vs-effective divergence in one mechanism); reuses hadoopConf from the encryption gate + // above. + val s3Reason = + s3ConfigDivergenceReason(hadoopConf, dataFileUris ++ dvUris, propagatedConfCache) + if (s3Reason.isDefined) { + return s3Reason + } + + // A credential-provider class native's build_aws_credential_provider_metadata (s3.rs) does + // not recognize errors at scan EXECUTION time, after the scan was already claimed; decline + // eagerly instead. + val providerReason = providerClassGateReason(hadoopConf, dataFileUris ++ dvUris) + if (providerReason.isDefined) { + return providerReason + } + + // Reuse core's generic native-scan gates (ignoreCorruptFiles/ignoreMissingFiles, AQE DPP on + // Spark 3.4, exec enabled); tags its own fallback reasons. + if (!CometNativeScan.isSupported(scanExec)) { + return Some("Core native scan gates rejected the scan (see reasons above)") + } + + // Claimable: hand the already-forced descriptors to `convert` via `memo` so it does not + // deserialize them a second time. + memo.dvDescriptors = dvDescriptors + None + } + + /** + * Deletion-vector descriptors for every file this DV-shape scan selected, normalized to + * absolute on-disk paths. Returns `Seq.empty` for the plain shape. Shared by the DV cardinality + * gate and [[CometDeltaNativeScan.convert]]'s object-store option merge. + */ + private[delta] def selectedDvDescriptors( + scanHelper: CometScanExec, + tableRoot: String): Seq[DeletionVectorDescriptor] = { + if (!CometDeltaNativeScan.isDvShape(scanHelper.wrapped)) { + return Seq.empty + } + val tableRootPath = new Path(tableRoot) + scanHelper.selectedPartitions.iterator + .flatMap(_.files) + .flatMap { file => + file.metadata + .get(DeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED) + .map(enc => DeletionVectorDescriptor.deserializeFromBase64(enc.asInstanceOf[String])) + } + .map(_.copyWithAbsolutePath(tableRootPath)) + .toSeq + } + + /** + * The libhdfs scheme exemption set from [[org.apache.comet.CometConf.COMET_LIBHDFS_SCHEMES]], + * lowercased and defaulting to `Set("hdfs")` when unset. + */ + private[delta] def libhdfsSchemes: Set[String] = COMET_LIBHDFS_SCHEMES.get() match { + case Some(s) => + s.split(",").map(_.trim.toLowerCase(Locale.ROOT)).filter(_.nonEmpty).toSet + case None => Set("hdfs") + } + + /** + * The lowercased, deduplicated schemes among `uris` that neither `libhdfs` nor Comet's native + * object_store layer ([[CometScanRule.isNativelyReadableScheme]]) can read. A `null` scheme is + * tolerated, not flagged, since such a URI cannot come from a Hadoop-backed source. + */ + private[delta] def unsupportedSchemes(uris: Seq[URI], libhdfs: Set[String]): Set[String] = { + uris + .filter { uri => + val sch = uri.getScheme + sch != null && { + val sl = sch.toLowerCase(Locale.ROOT) + !libhdfs.contains(sl) && !CometScanRule.isNativelyReadableScheme(uri) + } + } + .map(_.getScheme.toLowerCase(Locale.ROOT)) + .toSet + } + + /** + * Decline reason when any of `uris` -- the scan's selected data-file and deletion-vector URIs + * -- use a scheme [[unsupportedSchemes]] flags, or `None` when every URI is natively readable + * (or libhdfs-exempt). + */ + private[delta] def unsupportedSelectedSchemeReason( + uris: Seq[URI], + libhdfs: Set[String]): Option[String] = { + val schemes = unsupportedSchemes(uris, libhdfs) + if (schemes.isEmpty) { + None + } else { + Some( + "Native Delta scan does not support selected data file or deletion vector filesystem " + + s"scheme(s) ${schemes.mkString(", ")}") + } + } + + /** + * Decline reason when `uris` span more than one object-store authority (scheme + lowercased raw + * authority, so e.g. `S3A://Bucket` and `s3a://bucket` collapse), or `None` when they share + * one. `file://` paths carry no authority, so local scans across many directories are + * unaffected. + */ + private[delta] def multiStoreReason(uris: Seq[URI]): Option[String] = { + val authorities = uris.map(uriAuthority).distinct + if (authorities.size > 1) { + Some( + "Native Delta scan does not support data files spanning multiple object stores " + + s"(found: ${authorities.sorted.mkString(", ")})") + } else { + None + } + } + + /** + * Normalizes `uri` to a lowercased `scheme://authority` string, keyed on the raw `getAuthority` + * rather than the parsed host/port/userinfo fields: `getHost` (and `getUserInfo`/`getPort`) + * return `null` for the whole authority when it fails RFC 3986 `reg-name` syntax (e.g. an + * underscore in a GCS bucket name, `gs://my_bucket`), which would silently collapse distinct + * buckets into one empty-host key. A `null` authority normalizes to the empty string. + */ + private[delta] def uriAuthority(uri: URI): String = { + val scheme = Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)).getOrElse("") + val authority = Option(uri.getAuthority).map(_.toLowerCase(Locale.ROOT)).getOrElse("") + s"$scheme://$authority" + } + + /** + * The raw userinfo component of `uri`'s authority, or empty when none. Splits at the LAST `@` + * rather than using `URI#getUserInfo`, which (like [[uriAuthority]]'s getters) returns `null` + * for the whole authority on an RFC 3986 `reg-name` violation. Never lowercased: userinfo is + * case-sensitive. + */ + private[delta] def uriUserInfo(uri: URI): String = { + val authority = Option(uri.getAuthority).getOrElse("") + val at = authority.lastIndexOf('@') + if (at >= 0) authority.substring(0, at) else "" + } + + /** + * Redacts `uri`'s authority to `scheme`, then `://`, then a literal `***` masking userinfo, + * then `@host[:port]`, for embedding in a decline reason. NEVER interpolate `uri.getAuthority` + * or [[uriUserInfo]] directly into a reason string: doing so would leak credentials embedded as + * URI userinfo into the SQL plan's explain output, fallback-reason logging, or the Spark UI. + */ + private[delta] def redactedAuthority(uri: URI): String = { + val scheme = Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)).getOrElse("") + val authority = Option(uri.getAuthority).getOrElse("") + val at = authority.lastIndexOf('@') + val hostPort = if (at >= 0) authority.substring(at + 1) else authority + s"$scheme://***@$hostPort" + } + + /** + * Decline reason when any of `uris` carries userinfo in its authority (e.g. the container in an + * abfss:// path), or `None` when none do. The native store cache, `ObjectStoreUrl`, and + * DataFusion registry all key on scheme/host/port only, dropping userinfo, so two authorities + * differing only in userinfo collide onto the same store handle. + */ + private[delta] def userInfoBearingAuthorityReason(uris: Seq[URI]): Option[String] = { + val offending = uris.filter(uri => uriUserInfo(uri).nonEmpty).map(redactedAuthority).distinct + if (offending.isEmpty) { + None + } else { + Some("Native Delta scan does not support object-store paths whose authority carries " + + "userinfo (e.g. the container in an abfss:// path): the native object-store cache, " + + "ObjectStoreUrl and DataFusion registry all key on scheme, host and port only, so two " + + "containers on one storage account share a single store handle " + + s"(found: ${offending.sorted.mkString(", ")})") + } + } + + /** + * String-literal Hadoop conf keys consulted below. `hadoop-aws` is NOT on this module's runtime + * classpath, so `org.apache.hadoop.fs.s3a.Constants` must never be referenced here (would raise + * `NoClassDefFoundError` for sessions with no S3 dependency). + */ + private val HadoopCredentialProviderPathKey = "hadoop.security.credential.provider.path" + private val S3aCredentialProviderPathKey = "fs.s3a.security.credential.provider.path" + + private def s3aBucketProviderPathKey(bucket: String): String = + s"fs.s3a.bucket.$bucket.security.credential.provider.path" + + /** + * The LONG form of [[s3aBucketProviderPathKey]]: `S3AUtils#lookupPassword` resolves per-bucket + * overrides through both a long key (`fs.s3a.bucket.B.`) and a short key; both + * must be covered here too. + */ + private def s3aBucketLongProviderPathKey(bucket: String): String = + s"fs.s3a.bucket.$bucket.fs.s3a.security.credential.provider.path" + + private def nonEmptyConf(hadoopConf: Configuration, key: String): Boolean = + Option(hadoopConf.get(key)).exists(_.nonEmpty) + + /** + * The lowercase-scheme-checked S3/S3A bucket name from `uri`'s authority, or `None` when + * `uri`'s scheme is not `s3`/`s3a`. Parses the raw authority manually rather than + * `URI#getHost`, avoiding the same RFC 3986 `reg-name` pitfall as [[uriAuthority]]. + */ + private def s3Bucket(uri: URI): Option[String] = { + val scheme = Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)) + if (scheme.contains("s3") || scheme.contains("s3a")) { + val authority = Option(uri.getAuthority).getOrElse("") + val at = authority.lastIndexOf('@') + val hostAndPort = if (at >= 0) authority.substring(at + 1) else authority + val colon = hostAndPort.lastIndexOf(':') + val host = if (colon >= 0) hostAndPort.substring(0, colon) else hostAndPort + if (host.isEmpty) None else Some(host) + } else { + None + } + } + + private def plainValue(hadoopConf: Configuration, key: String): Option[String] = + Option(hadoopConf.get(key)).filter(_.nonEmpty) + + /** + * The three keys where Hadoop's OWN resolver (`S3AUtils#lookupPassword`) is long-bucket-aware, + * verified via `javap` against `hadoop-aws` 3.3.4: `S3AUtils#lookupPassword(bucket, conf, + * baseKey)` builds `longBucketKey = "fs.s3a.bucket." + bucket + "." + baseKey` (note: the FULL + * `baseKey`, already `fs.s3a`-prefixed, appended after the bucket segment) and reads it via + * `getPassword` BEFORE the short-bucket key, keeping the long value whenever `getPassword` + * returns non-empty for it and only falling through to short-then-global otherwise. Kept as a + * named subset purely for documentation/discovery purposes (see + * [[hadoopLookupPasswordEffective]]'s doc); every key in [[AllS3ConfigKeys]] below is resolved + * through the SAME long-then-short-then-global function regardless of membership here -- see + * that function's doc for why extending long-bucket-awareness to keys where it does not + * strictly apply is safe. + */ + private val CredentialValueKeys: Seq[String] = + Seq("fs.s3a.access.key", "fs.s3a.secret.key", "fs.s3a.session.token") + + /** + * Every `fs.s3a.*` base key that governs whether a claimed native scan actually behaves like + * Hadoop's own reader would, checked by [[s3ConfigDivergenceReason]] via + * [[hadoopLookupPasswordEffective]] (see that function's doc for the resolution order and the + * deliberate over-decline rationale for the non-credential entries below). Two groups, kept + * merged into one list and one resolution path -- NOT two lists with two different resolution + * mechanisms, which is what let `fs.s3a.encryption.algorithm` silently fall through the + * short-then-global-only path while Hadoop's real resolver for it was long-bucket-aware (the + * SSE-C long-bucket-alias gap): the ORIGINAL [[CredentialValueKeys]] three, plus every OTHER + * per-bucket `fs.s3a.*` base key native's S3 client's `get_config` (s3.rs) resolves, verified + * directly against its call sites: `extract_s3_config_options` (endpoint.region, + * path.style.access, endpoint, requester.pays.enabled), `lookup_provider_class` (the + * Comet-specific credential-provider-class activation key), and + * `build_credential_provider`/`build_aws_credential_provider_metadata`/ + * `build_assume_role_credential_provider_metadata` (aws.credentials.provider, + * assumed.role.credentials.provider, assumed.role.arn, assumed.role.session.name). + * + * SYNC NOTE: this list's non-credential entries must stay a superset of native's + * `NATIVE_S3A_CONFIG_PROPERTIES` constant (`native/core/src/parquet/objectstore/s3.rs`, + * property suffixes without the `fs.s3a.` prefix) -- `DeltaScanContribSuite`'s + * discovery-harness test asserts this mechanically. Literal strings, not the + * [[AwsCredentialsProviderKey]] / [[AssumedRoleCredentialsProviderKey]] vals declared below, + * purely to avoid a forward reference inside this `object` body; kept textually identical to + * those two constants. + */ + private[delta] val AllS3ConfigKeys: Seq[String] = Seq( + "fs.s3a.access.key", + "fs.s3a.secret.key", + "fs.s3a.session.token", + "fs.s3a.aws.credentials.provider", + "fs.s3a.assumed.role.arn", + "fs.s3a.assumed.role.session.name", + "fs.s3a.assumed.role.credentials.provider", + "fs.s3a.endpoint", + "fs.s3a.endpoint.region", + "fs.s3a.path.style.access", + "fs.s3a.requester.pays.enabled", + "fs.s3a.comet.credential.provider.class") + + /** + * The short-bucket-then-global value resolved for `baseKey` under `bucket` from `hadoopConf`, + * skipping an empty value at either alias exactly like [[plainValue]]. NOT used by + * [[s3ConfigDivergenceReason]]/[[s3KeyDivergenceReason]] any more -- every key checked there + * resolves through [[hadoopLookupPasswordEffective]] uniformly (long-then-short-then-global), + * which is a strict superset of what this function reads. This function's one remaining caller + * is [[shortThenGlobalOrReason]], which reads provider-CLASS strings (from the ORIGINAL, + * unpropagated conf) for name-support validation in [[providerClassReason]]/ + * [[assumedRoleProviderClassReason]] -- by the time those run, [[s3ConfigDivergenceReason]] has + * already proven Hadoop's and native's effective values agree for the same key, so whichever of + * the two (equal) values this narrower read returns does not affect correctness there. NEVER + * used to compute native's own effective value -- see [[nativeShortThenGlobal]] for that. + */ + private def shortThenGlobal( + hadoopConf: Configuration, + bucket: String, + baseKey: String): Option[String] = { + val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.") + plainValue(hadoopConf, shortKey).orElse(plainValue(hadoopConf, baseKey)) + } + + /** + * The short-bucket-then-global value native's `get_config` (s3.rs) resolves for `baseKey` under + * `bucket` from the ORIGINAL, unpropagated `hadoopConf` -- `NativeConfig + * .extractObjectStoreOptions` forwards `Configuration#get`'s substituted value for every + * `fs.s3a.*` entry with no bucket-option propagation step of its own, so the original conf is + * the right input here. Unlike [[shortThenGlobal]]/[[plainValue]], this mirrors `get_config` + * faithfully: PRESENCE of the short-bucket key alone -- never its emptiness -- decides whether + * native falls back to the global key (`get_config` is a plain `HashMap::get`, which returns + * `Some` for a key explicitly set to `""`), so an explicitly empty or whitespace-only + * short-bucket value resolves to `Some("")` here and never falls through to global -- the + * OPPOSITE of Hadoop's own `getPassword`/`lookupPassword` semantics (see + * [[hadoopLookupPasswordEffective]]), which treat empty as absent and keep trying the next + * alias. The ONLY function used to compute native's effective value in + * [[s3KeyDivergenceReason]]. + * + * Deliberately does NOT apply `get_config_trimmed`'s `.trim()` here: [[s3KeyDivergenceReason]] + * trims both this value and Hadoop's effective value together, symmetrically, at the point they + * are compared, rather than one-sidedly here -- trimming only the native side would flag a + * spurious divergence for a value neither side's whitespace actually changes the behavior of + * once each side's own downstream parsing normalizes it (e.g. Hadoop's own multi-line + * `fs.s3a.aws.credentials.provider` default, which both Hadoop and native additionally trim per + * comma-separated entry after splitting), while a one-sided trim would make an + * otherwise-identical default value look diverged for every bucket, never claiming natively at + * all. + */ + private def nativeShortThenGlobal( + hadoopConf: Configuration, + bucket: String, + baseKey: String): Option[String] = { + val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.") + Option(hadoopConf.get(shortKey)).orElse(Option(hadoopConf.get(baseKey))) + } + + /** + * Faithful in-memory replica of `S3AUtils#propagateBucketOptions` (`hadoop-aws`), which + * `S3AFileSystem#initialize` calls FIRST, before any option or credential is read: + * `Configuration conf = propagateBucketOptions(originalConf, bucket); ...; setConf(conf);` -- + * every subsequent `conf.get`/`getPassword` call in that filesystem instance, including + * `${...}` variable substitution, resolves against this propagated view, not the original conf. + * `hadoop-aws` is not on this module's runtime classpath (see the string-literal-keys note + * above), so `S3AUtils#propagateBucketOptions` cannot be called directly; this reproduces its + * logic verbatim using only `hadoop-common`'s `Configuration`: + * {{{ + * public static Configuration propagateBucketOptions(Configuration source, String bucket) { + * final String bucketPrefix = FS_S3A_BUCKET_PREFIX + bucket + '.'; + * final Configuration dest = new Configuration(source); + * for (Map.Entry entry : source) { + * final String key = entry.getKey(); + * final String value = entry.getValue(); // the (unexpanded) value + * if (!key.startsWith(bucketPrefix) || bucketPrefix.equals(key)) continue; + * final String stripped = key.substring(bucketPrefix.length()); + * if (stripped.startsWith("bucket.") || "impl".equals(stripped)) { + * // ignored + * } else { + * final String generic = FS_S3A_PREFIX + stripped; + * dest.set(generic, value, ...); // overwrites any existing global value + * } + * } + * return dest; + * } + * }}} + * Note the LONG bucket form (`fs.s3a.bucket.B.fs.s3a.`) folds to an unread + * `fs.s3a.fs.s3a.` key here too, exactly like the real method -- `stripped` already starts + * with `fs.s3a.` in that case, so prepending `fs.s3a.` again produces a key nothing ever reads. + */ + private def propagateBucketOptions(hadoopConf: Configuration, bucket: String): Configuration = { + val bucketPrefix = s"fs.s3a.bucket.$bucket." + val dest = new Configuration(hadoopConf) + hadoopConf.iterator().asScala.foreach { entry => + val key = entry.getKey + if (key.startsWith(bucketPrefix) && key != bucketPrefix) { + val stripped = key.substring(bucketPrefix.length) + if (!stripped.startsWith("bucket.") && stripped != "impl") { + dest.set(s"fs.s3a.$stripped", entry.getValue) + } + } + } + dest + } + + /** + * Canonical and deprecated Hadoop S3A encryption-algorithm config keys, verified via `javap` + * against `hadoop-aws` 3.3.4's `org.apache.hadoop.fs.s3a.Constants`: `S3_ENCRYPTION_ALGORITHM = + * "fs.s3a.encryption.algorithm"` (canonical) and `SERVER_SIDE_ENCRYPTION_ALGORITHM = + * "fs.s3a.server-side-encryption-algorithm"` (DEPRECATED -- note the hyphen before "algorithm", + * unlike the corresponding `*.key` constants below, which both use a `.key` suffix). + * `hadoop-aws` is NOT on this module's runtime classpath, so these stay string literals, same + * rationale as [[HadoopCredentialProviderPathKey]]. + */ + private val S3EncryptionAlgorithmKey = "fs.s3a.encryption.algorithm" + private val DeprecatedS3EncryptionAlgorithmKey = "fs.s3a.server-side-encryption-algorithm" + + /** + * The exact strings `S3AEncryptionMethods#getMethod` accepts, verified via `javap`/CFR against + * `hadoop-aws` 3.3.4's `S3AEncryptionMethods` enum: `NONE("")`, `SSE_S3("AES256", serverSide = + * true, requiresSecret = false)`, `SSE_KMS("SSE-KMS", serverSide = true, requiresSecret = + * false)`, `SSE_C("SSE-C", serverSide = true, requiresSecret = true)`, `CSE_KMS("CSE-KMS", + * serverSide = false, requiresSecret = true)`, `CSE_CUSTOM("CSE-CUSTOM", serverSide = false, + * requiresSecret = true)`. `getMethod` parses case-insensitively + * (`values().find(_.getMethod.equalsIgnoreCase(algorithm))`), matched below the same way. + * + * ALLOWLIST, not a blocklist (replaces the former SSE-C-only blocklist): only the algorithms S3 + * decrypts transparently on GET/HEAD given read permission alone, with NO extra request header + * and NO client-side step, are safe for a native scan that forwards none of Hadoop's + * `fs.s3a.encryption.*`/`fs.s3a.server-side-encryption*` options -- + * - `AES256` (SSE_S3, `serverSide = true`): plain server-side encryption, transparent on GET. + * - `SSE-KMS` (SSE_KMS, `serverSide = true`): server-side, KMS-managed key, transparent on + * GET given KMS decrypt permission (no header). + * - `DSSE-KMS`: NOT present in this enum on `hadoop-aws` 3.3.4 (confirmed by the six values + * listed above) -- `S3AEncryptionMethods.getMethod("DSSE-KMS")` throws + * `IOException("Unknown encryption algorithm DSSE-KMS")` on this version, so + * `S3AUtils#buildEncryptionSecrets` (and therefore Hadoop's own reader) already fails + * before ever reading such a table under 3.3.4, meaning this string can never actually be + * the resolved value on the declared target version -- admitting it here is inert there. + * Included anyway, forward-compatible, for a newer `hadoop-aws` on the runtime classpath (a + * later Hadoop release; this module has no compile-time `hadoop-aws` dependency, see the + * string-literal-keys note above) where DSSE-KMS is a real, dual-layer, server-side + * algorithm decrypted transparently on GET the same way SSE-KMS is. Every other value + * declines: `SSE-C` (SSE_C is `serverSide = true` in Hadoop's own enum, but `requiresSecret + * \= true` -- S3 rejects a GET/HEAD for an SSE-C object outright (400 Bad Request) unless + * the customer key is resent as a request header on every call, so a native scan that never + * learns the key cannot succeed at all, where Hadoop's own reader -- whose request factory + * attaches the key -- would), `CSE-KMS`/`CSE-CUSTOM` (`serverSide = false`: client-side + * encryption decrypts object bytes locally in the SDK layer, which the native Parquet + * reader has no equivalent of -- it would read raw ciphertext), and any future/unknown + * value (a value `S3AEncryptionMethods.getMethod` itself would reject is certainly not one + * of the three confirmed-transparent algorithms above; declining is the only safe default + * for anything this gate cannot positively confirm). + */ + private val AllowedEncryptionAlgorithms: Set[String] = Set("AES256", "SSE-KMS", "DSSE-KMS") + + /** + * `bucket`'s effective encryption-algorithm key and value under `hadoopConf`, or `None` when + * neither the canonical nor deprecated key is set anywhere consulted. Mirrors + * `S3AUtils#buildEncryptionSecrets`'s real resolution order, verified via `javap`/CFR + * decompilation of `hadoop-aws` 3.3.4's `S3AUtils.class`: + * {{{ + * String algorithm = lookupBucketSecret(bucket, conf, "fs.s3a.encryption.algorithm"); + * if (algorithm == null) + * algorithm = lookupBucketSecret(bucket, conf, "fs.s3a.server-side-encryption-algorithm"); + * if (algorithm == null) + * algorithm = lookupPassword(null, conf, "fs.s3a.encryption.algorithm"); + * if (algorithm == null) + * algorithm = lookupPassword(null, conf, "fs.s3a.server-side-encryption-algorithm"); + * }}} + * i.e. bucket-tier (canonical, then deprecated), THEN global-tier (canonical, then deprecated) + * -- the two tiers are never interleaved key-by-key, so this must stay two explicit bucket-tier + * lookups followed by two explicit global-tier lookups, not a single + * [[hadoopLookupPasswordEffective]] call per key (which would let an unset canonical bucket key + * fall through straight to the canonical GLOBAL value ahead of a SET deprecated bucket key, the + * wrong answer). + * + * THE FIX for the SSE-C long-bucket-alias gap is entirely inside the bucket tier: + * `lookupBucketSecret` itself is long-then-short, decompiled from `hadoop-aws` 3.3.4's + * `S3AUtils.class`: + * {{{ + * // longBucketKey = fs.s3a.bucket.B.fs.s3a. + * String longBucketKey = String.format(BUCKET_PATTERN, bucket, baseKey); + * String initialVal = getPassword(conf, longBucketKey, null, null); + * // shortBucketKey = fs.s3a.bucket.B. + * String shortBucketKey = String.format(BUCKET_PATTERN, bucket, subkey); + * // keeps initialVal (the LONG value) if non-empty + * return getPassword(conf, shortBucketKey, initialVal, null); + * }}} + * i.e. the SAME long-bucket-key construction and long-wins-if-nonempty semantics as + * `S3AUtils#lookupPassword` (see [[CredentialValueKeys]]/[[hadoopLookupPasswordEffective]]) -- + * the encryption algorithm is NOT one of the keys that flows through + * `S3AUtils#propagateBucketOptions` (which folds an unrelated per-bucket LONG form into an + * unread key). An earlier version of this function modeled the bucket tier as SHORT-only, + * documented as "the LONG bucket form is genuinely never consulted for this key" -- that + * documentation was wrong (this decompilation supersedes it): a bucket configured only via + * `fs.s3a.bucket.B.fs.s3a.encryption.algorithm=SSE-C` bypassed the SSE-C gate entirely, because + * Hadoop's own reader DOES read that long form (and picks SSE-C), while this function reported + * `None` (nothing set) and the allowlist check below never even ran. + * + * The canonical-vs-deprecated distinction below is frequently moot in practice: `hadoop-aws`'s + * `S3AFileSystem.addDeprecatedKeys()` statically registers `fs.s3a.server-side-encryption-*` as + * `Configuration`-level deprecated aliases of `fs.s3a.encryption.*` (verified via `javap`), a + * registration that lives in a static field on Hadoop's `Configuration` class -- process-wide + * once `S3AFileSystem`'s class has loaded anywhere in the JVM, which a real scan has always + * already done by the time this gate runs, since reading the S3 table at all requires loading + * that class. Once active, `Configuration#get` resolves either literal key to the identical + * value transparently, making the two-key cascade below redundant (but harmless) for that case; + * it remains the operative path only when nothing else in the process has loaded + * `S3AFileSystem` yet. + * + * ALSO walks the Hadoop-credential-provider (JCEKS) path via [[resolveViaCredentialAliases]] + * for each of the four lookups below, matching `lookupBucketSecret`/`lookupPassword`'s real + * per-alias `getPassword` calls (quoted above) exactly: both are `getPassword`, not plain + * `Configuration#get`, so a bucket storing the algorithm name ONLY in a JCEKS keystore is + * exactly as real a Hadoop deployment shape for this key as it is for the credential keys + * [[hadoopLookupPasswordEffective]] already covers -- there is nothing algorithm-specific that + * makes JCEKS storage implausible here, so an earlier version of this function skipping it + * (documented at the time as "the algorithm NAME is not credential-sensitive data, so storing + * it in a keystore is not a realistic Hadoop deployment pattern") was an unjustified, narrower + * read than Hadoop's own resolver actually performs, under-declining a bucket whose algorithm + * is keystore-only. [[resolveViaCredentialAliases]]'s Arm B/C split still means this is zero + * extra I/O for the common case: keystore I/O only happens when a Hadoop credential-provider + * path is actually configured for the bucket, contained in that function's own try/catch. + * `bucketTier`/`globalTier` return `Left` (propagated straight through by [[orElseTier]]) when + * [[resolveViaCredentialAliases]] cannot safely verify a tier at all (an S3A-scoped provider + * path, or a corrupt/unreadable global keystore) -- correctly short-circuiting the whole + * cascade with a decline rather than silently falling through to a later tier that might look + * unset only because the true value was unverifiable. + */ + private def effectiveEncryptionAlgorithm( + hadoopConf: Configuration, + bucket: String): Either[String, Option[(String, String)]] = { + def bucketTier(baseKey: String): Either[String, Option[(String, String)]] = { + val longKey = s"fs.s3a.bucket.$bucket.$baseKey" + val shortKey = s"fs.s3a.bucket.$bucket." + baseKey.stripPrefix("fs.s3a.") + resolveViaCredentialAliases(hadoopConf, bucket, Seq(longKey, shortKey)) + .map(_.map(baseKey -> _)) + } + def globalTier(baseKey: String): Either[String, Option[(String, String)]] = + resolveViaCredentialAliases(hadoopConf, bucket, Seq(baseKey)).map(_.map(baseKey -> _)) + + // Short-circuits on Left (unverifiable tier) or Right(Some(_)) (resolved); only Right(None) + // (tier definitively unset) falls through to `next`, mirroring buildEncryptionSecrets's + // sequential `if (algorithm == null) algorithm = ...` cascade exactly. + def orElseTier( + current: Either[String, Option[(String, String)]], + next: => Either[String, Option[(String, String)]]) + : Either[String, Option[(String, String)]] = + current match { + case Left(reason) => Left(reason) + case Right(Some(value)) => Right(Some(value)) + case Right(None) => next + } + + orElseTier( + bucketTier(S3EncryptionAlgorithmKey), + orElseTier( + bucketTier(DeprecatedS3EncryptionAlgorithmKey), + orElseTier( + globalTier(S3EncryptionAlgorithmKey), + globalTier(DeprecatedS3EncryptionAlgorithmKey)))) + } + + private def unsupportedEncryptionAlgorithmDeclineReason( + bucket: String, + algorithmKey: String, + algorithm: String): String = + s"Native Delta scan does not support $algorithmKey=$algorithm for $bucket " + + "(the native S3 client only supports unencrypted objects and S3's transparent " + + "server-side algorithms -- AES256/SSE-S3, SSE-KMS, and DSSE-KMS decrypt on GET/HEAD given " + + "read permission alone, with no extra request header; SSE-C additionally requires the " + + "customer-provided key resent as a header on every GET/HEAD request, which the native S3 " + + "client's extract_s3_config_options never forwards, and CSE-KMS/CSE-CUSTOM decrypt object " + + "bytes client-side, a layer the native Parquet reader does not have -- any of these would " + + "fail outright or silently read ciphertext where Hadoop's own reader succeeds)" + + /** + * First reason any bucket among `uris` is configured for an encryption algorithm the native S3 + * client cannot safely read, or `None` when claimable. Allowlist-based (see + * [[AllowedEncryptionAlgorithms]]): only `AES256`/`SSE-KMS`/`DSSE-KMS` (and unset/empty) pass; + * every other resolved value -- `SSE-C`, `CSE-KMS`, `CSE-CUSTOM`, or any unrecognized future + * algorithm string -- declines. Deliberately NOT a blocklist keyed on `SSE-C` alone: an + * allowlist is safe by construction against a Hadoop release adding a new encryption method + * this gate has never heard of, where a blocklist would silently admit it. Never interpolates a + * resolved key value, only key names, the bucket, and the (non-secret) algorithm name. Declines + * on a `Left` from [[effectiveEncryptionAlgorithm]] too (an unverifiable credential-provider + * arm, e.g. an S3A-scoped provider path or a corrupt/unreadable global keystore) -- the + * algorithm cannot be ruled safe when it cannot be read at all. + */ + private[delta] def unsupportedEncryptionAlgorithmReason( + hadoopConf: Configuration, + uris: Seq[URI]): Option[String] = { + val buckets = uris.flatMap(s3Bucket).distinct + buckets.foldLeft(Option.empty[String]) { (declined, bucket) => + if (declined.isDefined) { + declined + } else { + effectiveEncryptionAlgorithm(hadoopConf, bucket) match { + case Left(reason) => Some(reason) + case Right(None) => None + case Right(Some((key, value))) => + if (!AllowedEncryptionAlgorithms.exists(_.equalsIgnoreCase(value))) { + Some(unsupportedEncryptionAlgorithmDeclineReason(bucket, key, value)) + } else { + None + } + } + } + } + } + + /** + * Canonical Hadoop S3A HTTP-proxy host config key, verified via CFR decompilation of + * `hadoop-aws` 3.3.4's `S3AUtils.class` (`initProxySupport`): + * {{{ + * String proxyHost = conf.getTrimmed("fs.s3a.proxy.host", ""); + * int proxyPort = conf.getInt("fs.s3a.proxy.port", -1); + * if (!proxyHost.isEmpty()) { + * ... + * String proxyUsername = + * S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.username", null, null); + * String proxyPassword = + * S3AUtils.lookupPassword(bucket, conf, "fs.s3a.proxy.password", null, null); + * ... + * } + * }}} + * `fs.s3a.proxy.host`/`fs.s3a.proxy.port` resolve via a PLAIN, non-bucket-scoped, non-JCEKS + * `Configuration#getTrimmed`/`getInt` call -- NOT `lookupPassword` -- against whatever conf + * `S3AFileSystem#initialize` already ran through `propagateBucketOptions` before + * `createAwsConf`/`initProxySupport` ever runs; only the SIBLING `fs.s3a.proxy.username`/ + * `fs.s3a.proxy.password` keys go through `lookupPassword` (bucket long/short/global, + * JCEKS-aware). So the host is bucket-aware only via `propagateBucketOptions`'s short-bucket- + * form folding, never the long-bucket form, and never a credential-provider read -- the SAME + * shape as `endpoint`/`path.style.access` (the "propagated option" family, see + * [[AllS3ConfigKeys]]'s doc), not the credential family. + * + * Resolved here through [[hadoopLookupPasswordEffective]] anyway (long-then-short-then-global, + * credential-provider-aware) for the identical reason [[AllS3ConfigKeys]]'s non-credential + * entries are: checking MORE than Hadoop's real, narrower resolution can only ever find a value + * Hadoop itself ignores, which can only turn an already-necessary decline into one found + * slightly more eagerly -- never a missed one. A missed decline is exactly the failure mode + * this gate exists to close (native has NO HTTP-proxy support of any kind -- no + * `fs.s3a.proxy.*` key is read anywhere in `s3.rs`), so over-approximating is the conservative + * direction here, unlike a value-EQUALITY comparator (e.g. [[s3KeyDivergenceReason]]) where + * over-approximating could manufacture a false divergence between two sides that would + * otherwise agree. + */ + private val S3ProxyHostKey = "fs.s3a.proxy.host" + + private def unsupportedProxyReason(bucket: String, key: String): String = + s"Native Delta scan does not support $key configured for $bucket (the native S3 client has " + + "no HTTP proxy support at all -- no fs.s3a.proxy.* key is read anywhere in its object " + + "store layer -- so a claimed scan would connect to S3 directly instead of routing through " + + "the configured proxy, either bypassing an egress/network-segmentation policy or simply " + + "failing to reach the endpoint)" + + /** + * First reason any bucket among `uris` has an HTTP proxy configured via [[S3ProxyHostKey]], or + * `None` when claimable. Ordered alongside [[unsupportedEncryptionAlgorithmReason]] among the + * other conf-only gates that are zero-I/O in the common case, ahead of + * [[s3ConfigDivergenceReason]]: [[hadoopLookupPasswordEffective]] only performs real keystore + * I/O, inside its own try/catch, when a Hadoop credential-provider path is actually configured + * for this bucket (see that function's doc for the Arm A/B/C dispatch). Never interpolates a + * resolved value: the proxy HOST is not secret, but naming it here would be a strange place to + * first surface it, and proxy CREDENTIALS (`fs.s3a.proxy.username`/`fs.s3a.proxy.password`, not + * read by this gate at all -- the whole point of gating on the host is that a non-empty host + * declines before any proxy credential would ever need to be forwarded) must never appear in a + * decline reason regardless. + */ + private[delta] def proxyGateReason( + hadoopConf: Configuration, + uris: Seq[URI], + propagatedConfCache: MutableMap[String, Configuration] = MutableMap.empty) + : Option[String] = { + val buckets = uris.flatMap(s3Bucket).distinct + buckets.foldLeft(Option.empty[String]) { (declined, bucket) => + if (declined.isDefined) { + declined + } else { + try { + val propagatedConf = + propagatedConfCache.getOrElseUpdate( + bucket, + propagateBucketOptions(hadoopConf, bucket)) + hadoopLookupPasswordEffective(propagatedConf, bucket, S3ProxyHostKey) match { + case Left(reason) => Some(reason) + case Right(Some(_)) => Some(unsupportedProxyReason(bucket, S3ProxyHostKey)) + case Right(None) => None + } + } catch { + case e @ (_: IOException | _: RuntimeException) => + Some(unverifiableValueReason(bucket, S3ProxyHostKey, e)) + } + } + } + } + + /** + * `baseKey`'s long, short, then global per-bucket aliases, in Hadoop's own resolution order. + */ + private def longThenShortThenGlobalAliases(bucket: String, baseKey: String): Seq[String] = { + val suffix = baseKey.stripPrefix("fs.s3a.") + Seq(s"fs.s3a.bucket.$bucket.fs.s3a.$suffix", s"fs.s3a.bucket.$bucket.$suffix", baseKey) + } + + private def s3aScopedProviderPathReason(bucket: String, providerPathKey: String): String = + "Native Delta scan cannot forward Hadoop credential-provider aliases for " + + s"$bucket ($providerPathKey configures an S3A-scoped Hadoop credential provider that " + + "Configuration#getPassword does not consult, so the native S3 client's credentials " + + "cannot be verified)" + + private def unverifiableCredentialProviderReason(bucket: String, error: Throwable): String = + "Native Delta scan cannot verify Hadoop credential-provider aliases for " + + s"$bucket (reading $HadoopCredentialProviderPathKey raised " + + s"${error.getClass.getName}), declining rather than risk missing credentials" + + /** + * The three-way Hadoop credential-provider-path precheck shared by every `getPassword`-based + * resolution below, factored out of what used to be [[hadoopLookupPasswordEffective]]'s own + * body so [[effectiveEncryptionAlgorithm]] can reuse it for its own bucket-tier-only and + * global-tier-only lookups (see that function's doc for why the algorithm needs its resolution + * split into separate tiers rather than one combined long-then-short-then-global list). This + * precheck is a property of the BUCKET alone, independent of which base key or alias list a + * caller goes on to resolve: Arm A -- an S3A-scoped or bucket-scoped Hadoop credential-provider + * path configures a provider `Configuration#getPassword` does not consult + * (`S3AUtils#lookupPassword`/`#lookupBucketSecret` build their own S3A-only provider list per + * bucket, which this plain-conf-reading gate cannot mirror) -- returns + * [[UnverifiableProvider]], NO keystore I/O. Arm B -- only the GLOBAL Hadoop provider path is + * set (which `getPassword` DOES consult) -- returns [[GlobalProviderOnly]]; the caller must + * wrap its own `getPassword` calls in try/catch, since real keystore I/O only happens once this + * arm is reached. Arm C -- no provider path is set anywhere -- returns [[NoProvider]], zero-I/O + * plain conf only. + */ + private sealed trait CredentialProviderArm + private case class UnverifiableProvider(offendingKey: String) extends CredentialProviderArm + private case object GlobalProviderOnly extends CredentialProviderArm + private case object NoProvider extends CredentialProviderArm + + private def credentialProviderArm( + hadoopConf: Configuration, + bucket: String): CredentialProviderArm = { + val bucketPathKey = s3aBucketProviderPathKey(bucket) + val bucketLongPathKey = s3aBucketLongProviderPathKey(bucket) + val s3aPathSet = nonEmptyConf(hadoopConf, S3aCredentialProviderPathKey) + val bucketPathSet = nonEmptyConf(hadoopConf, bucketPathKey) + val bucketLongPathSet = nonEmptyConf(hadoopConf, bucketLongPathKey) + if (s3aPathSet || bucketPathSet || bucketLongPathSet) { + val offendingKey = + if (s3aPathSet) S3aCredentialProviderPathKey + else if (bucketPathSet) bucketPathKey + else bucketLongPathKey + UnverifiableProvider(offendingKey) + } else if (nonEmptyConf(hadoopConf, HadoopCredentialProviderPathKey)) { + GlobalProviderOnly + } else { + NoProvider + } + } + + /** + * `aliases`' effective value under `hadoopConf`/`bucket`, tried in the given order and keeping + * the first non-empty result -- or `Left(reason)` when the value cannot be safely verified. + * Dispatches on [[credentialProviderArm]]: [[UnverifiableProvider]] declines outright (zero + * I/O); [[GlobalProviderOnly]] resolves every alias via `Configuration#getPassword` (real + * keystore I/O, contained in try/catch: a corrupt/unreadable store must decline this bucket, + * not abort planning for the whole session); [[NoProvider]] resolves every alias via plain + * [[plainValue]] reads (zero I/O). Generalizes what used to be + * [[hadoopLookupPasswordEffective]]'s entire body to an explicit, caller-supplied alias list -- + * [[hadoopLookupPasswordEffective]] is now the long-then-short-then-global special case of + * this; [[effectiveEncryptionAlgorithm]] is the bucket-tier-only (two aliases) and + * global-tier-only (one alias) special cases, matching + * `S3AUtils#lookupBucketSecret`/`#lookupPassword`'s real per-tier `getPassword` calls exactly + * instead of [[plainValue]]'s JCEKS-blind read that under-declined a keystore-only algorithm. + */ + private def resolveViaCredentialAliases( + hadoopConf: Configuration, + bucket: String, + aliases: Seq[String]): Either[String, Option[String]] = + credentialProviderArm(hadoopConf, bucket) match { + case UnverifiableProvider(offendingKey) => + Left(s3aScopedProviderPathReason(bucket, offendingKey)) + case GlobalProviderOnly => + try { + Right( + aliases.iterator + .map(alias => + Option(hadoopConf.getPassword(alias)).map(new String(_)).filter(_.nonEmpty)) + .collectFirst { case Some(v) => v }) + } catch { + case e @ (_: IOException | _: RuntimeException) => + Left(unverifiableCredentialProviderReason(bucket, e)) + } + case NoProvider => + Right(aliases.flatMap(plainValue(hadoopConf, _)).headOption) + } + + /** + * `bucket`'s effective value for `baseKey` in Hadoop's own `S3AUtils#lookupPassword` resolution + * order -- long bucket alias, then short bucket alias, then global, each tried through a Hadoop + * credential provider before falling back to plain conf (see [[resolveViaCredentialAliases]] + * for the Arm A/B/C dispatch this delegates to) -- or `Left(reason)` when the value cannot be + * safely verified. + * + * USED FOR EVERY KEY IN [[AllS3ConfigKeys]], not just [[CredentialValueKeys]] -- see that val's + * doc for why the structural bug behind the SSE-C long-bucket-alias gap was exactly this split + * (some keys resolved long-bucket-aware, others short-only) and why resolving every key through + * this SAME long-then-short-then-global function is fail-safe even for the keys (`endpoint`, + * `path.style.access`, `requester.pays.enabled`, `comet.credential.provider.class`) whose real + * Hadoop resolver is `S3AUtils#propagateBucketOptions` + plain `Configuration#get`, which never + * consults the long bucket form or a credential provider at all: checking the long alias (or a + * credential-provider path) for one of THOSE keys can only ever find a value real Hadoop itself + * ignores. That can make this function return a value differing from Hadoop's TRUE effective + * value only in the direction of an EXTRA divergence (a long-form/keystore value set + * differently from short/global for a key Hadoop never reads that way) -- which + * [[s3KeyDivergenceReason]] turns into an extra DECLINE, never a wrongly-claimed scan, since a + * value Hadoop never reads cannot be the reason a wrongly-claimed native scan would misbehave. + * A key added to [[AllS3ConfigKeys]] in the future inherits this safety automatically; no + * per-key classification decision (and no repeat of the SSE-C bug) is needed. + * + * `hadoopConf` must be a [[propagateBucketOptions]] result (the caller, + * [[s3KeyDivergenceReason]], always passes one) so that `${...}` references embedded in any + * alias resolve exactly like `S3AFileSystem#initialize`'s real propagate-then-resolve order. + */ + private def hadoopLookupPasswordEffective( + hadoopConf: Configuration, + bucket: String, + baseKey: String): Either[String, Option[String]] = + resolveViaCredentialAliases( + hadoopConf, + bucket, + longThenShortThenGlobalAliases(bucket, baseKey)) + + private def effectiveValueDivergenceReason(bucket: String, key: String): String = + s"Native Delta scan cannot forward $key for $bucket (Hadoop's effective value for this key " + + "differs from what the native S3 client resolves, so its credentials or configuration " + + "would differ from Hadoop's)" + + private def unverifiableValueReason(bucket: String, key: String, error: Throwable): String = + s"Native Delta scan cannot verify $key for $bucket (Configuration#get raised " + + s"${error.getClass.getName}), declining rather than risk forwarding a stale or " + + "diverging value" + + /** + * `None` when `baseKey`'s Hadoop-effective and native-effective values under `bucket` agree, or + * a decline reason naming `baseKey` and `bucket` (never a value) when they diverge or either + * side cannot be safely computed. + * + * Hadoop's effective value is computed against [[propagateBucketOptions]]'s result, mirroring + * `S3AFileSystem#initialize`'s actual order (propagate bucket options into the conf FIRST, only + * THEN read/substitute options against it), via Hadoop's `lookupPassword`-order read + * ([[hadoopLookupPasswordEffective]]) for EVERY key in [[AllS3ConfigKeys]] -- see that + * function's doc for why using the same long-then-short-then-global resolution uniformly, + * rather than a short-then-global-only read for the keys Hadoop's real resolver does not make + * long-bucket-aware, is a deliberate, fail-safe over-approximation. Native's effective value is + * always `nativeShortThenGlobal(hadoopConf, ...)` on the ORIGINAL, unpropagated conf, matching + * `NativeConfig.extractObjectStoreOptions`'s actual forwarding semantics (no propagation step) + * AND native's `get_config` presence-based (not emptiness-based) short-vs-global fallback -- + * see [[nativeShortThenGlobal]]. Both values are trimmed together, symmetrically, right before + * the equality check below (mirroring `get_config_trimmed`'s `.trim()`, which native applies + * regardless of which alias it read) rather than trimming [[nativeShortThenGlobal]]'s result on + * its own -- see [[nativeShortThenGlobal]]'s doc for why a one-sided trim there would flag a + * spurious divergence. Comparing against the propagated view (rather than the original conf, as + * an earlier version of this check did) matters because propagation can change what a `${...}` + * reference inside one bucket-scoped value resolves to: e.g. + * `fs.s3a.bucket.B.access.key=${fs.s3a.custom.ref}` with `fs.s3a.bucket.B.custom.ref=X` and + * global `fs.s3a.custom.ref=Y` propagates to `fs.s3a.custom.ref=X` (overwriting the global `Y`) + * before the access key's `${...}` reference is ever substituted, so Hadoop resolves `X` while + * a check against the unpropagated conf would (wrongly) also see `Y`, the same value native + * forwards -- masking a real divergence. Wrapped in try/catch: `Configuration#get` raises + * `IllegalStateException` once `${...}` substitution recurses past Hadoop's `MAX_SUBST` bound + * (e.g. a two-key mutual reference cycle); declining is safer than crashing planning or + * comparing a partially-substituted value. + */ + private def s3KeyDivergenceReason( + hadoopConf: Configuration, + propagatedConf: Configuration, + bucket: String, + baseKey: String): Option[String] = { + try { + val hadoopEffective: Either[String, Option[String]] = + hadoopLookupPasswordEffective(propagatedConf, bucket, baseKey) + hadoopEffective match { + case Left(reason) => Some(reason) + case Right(hadoopValue) => + val nativeValue = nativeShortThenGlobal(hadoopConf, bucket, baseKey) + if (hadoopValue.map(_.trim) != nativeValue.map(_.trim)) { + Some(effectiveValueDivergenceReason(bucket, baseKey)) + } else { + None + } + } + } catch { + case e @ (_: IOException | _: RuntimeException) => + Some(unverifiableValueReason(bucket, baseKey, e)) + } + } + + /** + * First reason any bucket among `uris` cannot faithfully forward every [[AllS3ConfigKeys]] + * option to native, or `None` when every key's Hadoop-effective and native-effective value + * agrees for every S3/S3A bucket referenced. Only `s3`/`s3a` authorities matter here (ABFS/WASB + * mooted by the userinfo gate, GCS handled by [[gcsHadoopOnlyAuthReason]]). One comparator + * replaces the former per-case gate family (long-form bucket credentials, JCEKS/provider + * shadowing, Hadoop `${...}` variable references): [[s3KeyDivergenceReason]] computes Hadoop's + * effective value against a per-bucket [[propagateBucketOptions]] replica (matching + * `S3AFileSystem#initialize`'s real propagate-then-resolve order), so a `${...}` reference that + * resolves identically under that propagated view and under native's unpropagated forwarding is + * no longer a divergence at all, while one that resolves differently (e.g. because propagation + * shadowed a referenced key with a per-bucket override) IS still caught. + * [[s3KeyDivergenceReason]] resolves EVERY key in [[AllS3ConfigKeys]] through the SAME + * [[hadoopLookupPasswordEffective]] function (long-then-short-then-global) rather than + * splitting credentials from "plain options" -- see [[AllS3ConfigKeys]]'s doc for why that + * split was the structural cause of the SSE-C long-bucket-alias bypass, and + * [[hadoopLookupPasswordEffective]]'s doc for why unifying on the more conservative resolution + * is safe even for keys Hadoop itself never makes long-bucket-aware. Never interpolates a + * resolved value, only key names. + */ + private[delta] def s3ConfigDivergenceReason( + hadoopConf: Configuration, + uris: Seq[URI], + propagatedConfCache: MutableMap[String, Configuration] = MutableMap.empty) + : Option[String] = { + val buckets = uris.flatMap(s3Bucket).distinct + buckets.foldLeft(Option.empty[String]) { (declined, bucket) => + if (declined.isDefined) { + declined + } else { + try { + val propagatedConf = + propagatedConfCache.getOrElseUpdate( + bucket, + propagateBucketOptions(hadoopConf, bucket)) + AllS3ConfigKeys.foldLeft(Option.empty[String]) { (keyDeclined, key) => + if (keyDeclined.isDefined) { + keyDeclined + } else { + s3KeyDivergenceReason(hadoopConf, propagatedConf, bucket, key) + } + } + } catch { + case e @ (_: IOException | _: RuntimeException) => + Some(unverifiableValueReason(bucket, AllS3ConfigKeys.head, e)) + } + } + } + } + + /** + * String-literal mirror of every credential-provider class name s3.rs's + * `build_aws_credential_provider_metadata` recognizes (Hadoop S3A plus AWS SDK v1/v2 names). + * `hadoop-aws` is NOT on this module's runtime classpath, so these stay string literals, never + * `classOf` references. + */ + private val SupportedCredentialProviderClasses: Set[String] = Set( + "org.apache.hadoop.fs.s3a.auth.IAMInstanceCredentialsProvider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider", + "org.apache.hadoop.fs.s3a.TemporaryAWSCredentialsProvider", + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider", + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider", + "software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider", + "com.amazonaws.auth.ContainerCredentialsProvider", + "com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper", + "software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider", + "com.amazonaws.auth.InstanceProfileCredentialsProvider", + "software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider", + "com.amazonaws.auth.EnvironmentVariableCredentialsProvider", + "software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider", + "com.amazonaws.auth.WebIdentityTokenCredentialsProvider", + "software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider", + "com.amazonaws.auth.profile.ProfileCredentialsProvider", + "software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider", + "com.amazonaws.auth.AnonymousAWSCredentials") + + private val AnonymousCredentialProviderClasses: Set[String] = Set( + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider", + "software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider", + "com.amazonaws.auth.AnonymousAWSCredentials") + + private val HadoopAssumedRoleProviderClass = + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider" + + private val AwsCredentialsProviderKey = "fs.s3a.aws.credentials.provider" + private val AssumedRoleCredentialsProviderKey = "fs.s3a.assumed.role.credentials.provider" + + /** Splits a comma-separated credential-provider-class list the same way s3.rs's parser does. */ + private def parseProviderClassNames(value: String): Seq[String] = + value.split(",").map(_.trim).filter(_.nonEmpty).toSeq + + private def unsupportedProviderReason(bucket: String, key: String, className: String): String = + s"Native Delta scan does not support the credential provider class $className " + + s"configured via $key for $bucket (the native S3 client only supports a fixed set of " + + "provider classes; an unsupported class would fail at scan execution time, after the " + + "scan was already claimed, rather than at planning time)" + + private def mixedAnonymousProviderReason(bucket: String, key: String): String = + s"Native Delta scan does not support $key for $bucket naming an anonymous credential " + + "provider together with any other provider (the native S3 client rejects this " + + "combination at scan execution time)" + + private def anonymousAssumedRoleProviderReason(bucket: String, key: String): String = + s"Native Delta scan does not support an anonymous credential provider in $key for " + + s"$bucket (the native S3 client does not allow an anonymous provider as the base " + + "credentials for an assumed-role chain)" + + private def unsupportedProviderNameReason( + bucket: String, + key: String, + names: Seq[String]): Option[String] = + names + .find(name => !SupportedCredentialProviderClasses.contains(name)) + .map(unsupportedProviderReason(bucket, key, _)) + + /** + * [[shortThenGlobal]] for `key` under `bucket`, or `Left(reason)` when `Configuration#get` + * itself raises: it recursively expands `${...}` references and throws `IllegalStateException` + * once expansion recurses past Hadoop's `MAX_SUBST` bound (e.g. a two-key mutual reference + * cycle between `fs.s3a.aws.credentials.provider` and + * `fs.s3a.assumed.role.credentials.provider` or any other conf entry). Every provider-CLASS + * read below goes through this wrapper rather than calling [[shortThenGlobal]] directly: unlike + * [[s3ConfigDivergenceReason]]'s `s3KeyDivergenceReason`, this chain has no outer try/catch of + * its own, and previously relied on `s3ConfigDivergenceReason` running first and covering the + * same keys to mask the exception + * -- an undocumented, bypassable coupling, since [[providerClassGateReason]] is itself a + * public, directly-callable entry point. Reuses [[unverifiableValueReason]]'s message shape + * (names the key and the exception class, never a value). + */ + private def shortThenGlobalOrReason( + hadoopConf: Configuration, + bucket: String, + key: String): Either[String, Option[String]] = + try { + Right(shortThenGlobal(hadoopConf, bucket, key)) + } catch { + case e @ (_: IOException | _: RuntimeException) => + Left(unverifiableValueReason(bucket, key, e)) + } + + /** + * Decline reason when `bucket`'s effective `assumed.role.credentials.provider` names an + * unsupported class, or an anonymous one (native rejects ANY anonymous entry here, not just a + * mix), or when reading it raises (see [[shortThenGlobalOrReason]]). Unset defaults to native's + * own always-supported fallback, so `None` is safe. + */ + private def assumedRoleProviderClassReason( + hadoopConf: Configuration, + bucket: String): Option[String] = { + shortThenGlobalOrReason(hadoopConf, bucket, AssumedRoleCredentialsProviderKey) match { + case Left(reason) => Some(reason) + case Right(None) => None + case Right(Some(value)) => + val names = parseProviderClassNames(value) + unsupportedProviderNameReason(bucket, AssumedRoleCredentialsProviderKey, names).orElse { + if (names.exists(AnonymousCredentialProviderClasses.contains)) { + Some(anonymousAssumedRoleProviderReason(bucket, AssumedRoleCredentialsProviderKey)) + } else { + None + } + } + } + } + + /** + * Decline reason when `bucket`'s effective `aws.credentials.provider` names an unrecognized + * class, mixes an anonymous provider with any other, a nested `AssumedRoleCredentialProvider` + * sub-chain has the same problem, or reading either key raises (see + * [[shortThenGlobalOrReason]]). Unset/empty falls back to native's default chain. + */ + private def providerClassReason(hadoopConf: Configuration, bucket: String): Option[String] = { + shortThenGlobalOrReason(hadoopConf, bucket, AwsCredentialsProviderKey) match { + case Left(reason) => Some(reason) + case Right(None) => None + case Right(Some(value)) => + val names = parseProviderClassNames(value) + unsupportedProviderNameReason(bucket, AwsCredentialsProviderKey, names) + .orElse { + if (names.length > 1 && names.exists(AnonymousCredentialProviderClasses.contains)) { + Some(mixedAnonymousProviderReason(bucket, AwsCredentialsProviderKey)) + } else { + None + } + } + .orElse { + if (names.contains(HadoopAssumedRoleProviderClass)) { + assumedRoleProviderClassReason(hadoopConf, bucket) + } else { + None + } + } + } + } + + /** + * First reason any bucket among `uris` names an unsupported credential-provider class, or + * `None` when every named class is supported (or the key is unset). The Hadoop `${...}` + * variable-reference precheck this used to run ahead of the class-support check is gone: once + * `NativeConfig` forwards `Configuration#get`'s substituted value for every entry (same as + * [[shortThenGlobal]] reads here), a `${...}` reference resolves identically for native and for + * this check, so it is no longer possible for one to see the literal and the other the expanded + * class name. + */ + private[delta] def providerClassGateReason( + hadoopConf: Configuration, + uris: Seq[URI]): Option[String] = { + val buckets = uris.flatMap(s3Bucket).distinct + buckets.foldLeft(Option.empty[String]) { (declined, bucket) => + if (declined.isDefined) declined else providerClassReason(hadoopConf, bucket) + } + } + + /** + * True when `key` names a GCS authentication option under either Hadoop conf namespace the + * `gcs-connector` reads (`fs.gs.*` or the legacy `google.cloud.*`) AND the key itself concerns + * authentication. The connector's own `HadoopCredentialConfiguration` builds each auth setting + * from a prefix crossed with a suffix (service-account keyfile/email/private-key, OAuth client + * id/secret, impersonation, workload identity, and so on), including reversed-word-order + * deprecated forms (`fs.gs.service.account.auth.keyfile`) alongside the modern ones + * (`fs.gs.auth.service.account.json.keyfile`) -- enumerating every current and future suffix as + * a fixed prefix list is a losing game the connector itself does not play; matching on + * "namespace + contains auth" tracks the connector's own auth-vs-non-auth boundary instead of + * chasing its naming history. `gcs-connector` is NOT on this module's runtime classpath by + * default, so referencing an actual GCS auth class would risk `NoClassDefFoundError`, same + * rationale as the S3A literals above. + */ + private def isGcsAuthKey(key: String): Boolean = + (key.startsWith("fs.gs.") || key.startsWith("google.cloud.")) && key.contains("auth") + + /** + * True when `uri`'s scheme is `gs` (case-insensitive) -- the ONLY scheme object_store's + * `ObjectStoreScheme::parse` (parquet_support.rs) routes to `GoogleCloudStorage`; `gcs` is not + * recognized there and is deliberately excluded. + */ + private def isGcsScheme(uri: URI): Boolean = + Option(uri.getScheme).exists(_.equalsIgnoreCase("gs")) + + /** + * The lowercase-scheme-checked GCS bucket name from `uri`'s authority (host, minus any userinfo + * or port), or `None` when `uri`'s scheme is not `gs`. Parses the raw authority manually, + * mirroring [[s3Bucket]]'s `URI#getHost`/RFC 3986 `reg-name` reasoning. + */ + private def gcsBucket(uri: URI): Option[String] = { + if (!isGcsScheme(uri)) { + None + } else { + val authority = Option(uri.getAuthority).getOrElse("") + val at = authority.lastIndexOf('@') + val hostAndPort = if (at >= 0) authority.substring(at + 1) else authority + val colon = hostAndPort.lastIndexOf(':') + val host = if (colon >= 0) hostAndPort.substring(0, colon) else hostAndPort + if (host.isEmpty) None else Some(host) + } + } + + /** + * The non-empty Hadoop conf keys set on `hadoopConf` for which [[isGcsAuthKey]] holds, full key + * names only -- NEVER their values, which are credential material and must never enter a + * decline reason. Iterates the conf map directly: no provider resolution, no I/O. + */ + private def gcsAuthKeys(hadoopConf: Configuration): Seq[String] = + hadoopConf + .iterator() + .asScala + .collect { + case entry + if isGcsAuthKey(entry.getKey) && entry.getValue != null && + entry.getValue.nonEmpty => + entry.getKey + } + .toSeq + .distinct + .sorted + + /** + * Decline reason when any of `uris` resolves to a `gs://` authority AND `hadoopConf` sets any + * key [[isGcsAuthKey]] flags, or `None` when claimable. Native forwards none of `fs.gs.*` (nor + * any of the legacy/deprecated `google.cloud.*` namespaces) to the object store, so a scan + * relying solely on Hadoop-side GCS credentials would claim here but then fail authentication + * natively. Application Default Credentials work identically in both engines and need no Hadoop + * conf key, so an ADC-only configuration still claims. Never interpolates a resolved value, + * only key names. + */ + private[delta] def gcsHadoopOnlyAuthReason( + hadoopConf: Configuration, + uris: Seq[URI]): Option[String] = { + val gcsUris = uris.filter(isGcsScheme) + if (gcsUris.isEmpty) { + return None + } + val authKeys = gcsAuthKeys(hadoopConf) + if (authKeys.isEmpty) { + return None + } + val buckets = gcsUris.flatMap(gcsBucket).distinct.sorted + Some( + "Native Delta scan does not support GCS authentication configured only via Hadoop conf " + + s"key(s) ${authKeys.mkString(", ")} for gs://${buckets.mkString(", gs://")} " + + "(the native GCS client does not forward fs.gs.* options; only Application Default " + + "Credentials -- environment or metadata-server -- are available natively)") + } + + /** + * True when `dataType` is, or structurally contains (through array elements or map keys/ + * values), a [[StructType]]. Only [[StructType]] fields carry Delta's physical, column-mapped + * names; array/map labels themselves are never column-mapped. + */ + private def containsNestedStruct(dataType: DataType): Boolean = dataType match { + case _: StructType => true + case ArrayType(elementType, _) => containsNestedStruct(elementType) + case MapType(keyType, valueType, _) => + containsNestedStruct(keyType) || containsNestedStruct(valueType) + case _ => false + } + + /** + * True when `node` is a positional-output union -- `UnionExec` or `CometUnionExec`. Both + * compute output positionally from the FIRST child's attributes, so a value carried only by a + * LATER branch needs an explicit positional walk below. Compared by class name (the + * [[isDeltaScan]] idiom) to avoid a compile-time dependency; an unmatched name is still safe, + * caught by the generic child-output safety net below. + */ + private def isPositionalUnion(node: SparkPlan): Boolean = { + val name = node.getClass.getSimpleName + name == "UnionExec" || name == "CometUnionExec" + } + + /** + * True when the scan's row-index column value is provably dead above the scan. The standard DV + * plan shape routes it only into a `named_struct(... row_index ...) AS _metadata` projection + * whose result the final projection discards; anything else (a query actually selecting + * `_metadata.row_index`, OR a write sink -- `DataWritingCommandExec`, `WriteFilesExec`, a DSv2 + * `V2TableWriteExec` -- persisting it) makes the value live and must decline. Conservative: any + * unrecognized consumption pattern returns false. + */ + private def rowIndexUnusedAbove(plan: SparkPlan, scanExec: FileSourceScanExec): Boolean = { + val rowIndexAttrs = scanExec.output + .filter(_.name == CometDeltaNativeScan.RowIndexColumn) + .map(_.exprId) + .toSet + if (rowIndexAttrs.isEmpty) { + return true + } + // Transitive taint analysis: everything derived from the row-index attribute within the + // visible plan, via Project aliases or positionally across a union. The plan may be an AQE + // stage fragment, so tainted values escaping to the fragment's own output must decline too. + var tainted = rowIndexAttrs + var changed = true + while (changed) { + changed = false + plan.foreach { + case p: ProjectExec => + p.projectList.foreach { + case a: Alias + if !tainted.contains(a.exprId) && + a.references.exists(r => tainted.contains(r.exprId)) => + tainted += a.exprId + changed = true + case _ => + } + case u if isPositionalUnion(u) => + // Output attributes carry the FIRST child's expression IDs, so a value tainted only in + // a LATER branch is otherwise invisible; walk it forward positionally instead. + // `children` can be re-parented by AQE after `output` is frozen, so an arity mismatch on + // ANY child (which would make a positional zip silently truncate) forces a decline. + if (u.children.exists(_.output.length != u.output.length)) { + return false + } + u.children.foreach { child => + child.output.zip(u.output).foreach { + case (from, to) if tainted.contains(from.exprId) && !tainted.contains(to.exprId) => + tainted += to.exprId + changed = true + case _ => + } + } + case _ => + } + } + val nonProjectConsumer = plan.exists { + case _: ProjectExec => false + case n if n ne scanExec => + n.expressions.exists(_.references.exists(r => tainted.contains(r.exprId))) + case _ => false + } + val escapes = plan.output.exists(a => tainted.contains(a.exprId)) + // Generic safety net for every OTHER node, of ANY arity (joins and other multi-child + // shapes, but also plain one-child nodes; positional unions and Project are exempt, already + // handled precisely above -- Project's own output legitimately omits a tainted attribute it + // dropped, which is not a leak). A tainted attribute a child contributes must either survive + // into the node's own output under the SAME expression ID or be consumed by one of the + // node's own expressions; otherwise decline. This catches two shapes: a multi-child node + // dropping the side carrying the tainted attribute (e.g. a LEFT SEMI/ANTI join), and a + // one-child WRITE SINK -- DataWritingCommandExec, WriteFilesExec, and the DSv2 + // AppendDataExec/OverwriteByExpressionExec/... family (V2TableWriteExec) -- that executes + // its child purely for the side effect of persisting its rows and so has an EMPTY output of + // its own. Such a sink neither preserves the tainted attribute (nothing survives into an + // empty output) nor references it in an expression, so without this check it looks like an + // inert pass-through even though the write persists whatever value the reader returned, + // including a DV scan's dead synthetic row-index constant. + val childOutputLeak = plan.exists { + case u if isPositionalUnion(u) => false + case _: ProjectExec => false + case n if n.children.nonEmpty => + n.children.exists { c => + c.output.exists { attr => + tainted.contains(attr.exprId) && + !n.output.exists(_.exprId == attr.exprId) && + !n.expressions.exists(_.references.exists(_.exprId == attr.exprId)) + } + } + case _ => false + } + !nonProjectConsumer && !escapes && !childOutputLeak + } +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkConfigProvider.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkConfigProvider.scala new file mode 100644 index 00000000000..c0a4f5e0fdb --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkConfigProvider.scala @@ -0,0 +1,34 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import org.apache.comet.{CometConfigProvider, ConfigEntry} + +/** + * Exposes this contrib's config entries to `GenerateDocs`. Note: with the current module layout + * (`contrib/delta-spark` depends on `comet-spark`) the doc build cannot see this provider; it + * exists to satisfy the contrib-conf contract and becomes active if the module is ever folded + * into the spark build like `contrib/delta` is. + */ +class DeltaSparkConfigProvider extends CometConfigProvider { + override def configs: Seq[ConfigEntry[_]] = DeltaScanConf.all + override def docPage: String = "delta.md" + override def docCategory: String = "delta" +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkScanEnvelope.scala b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkScanEnvelope.scala new file mode 100644 index 00000000000..5bfad727b2e --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaSparkScanEnvelope.scala @@ -0,0 +1,54 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import org.apache.comet.serde.OperatorOuterClass +import org.apache.comet.serde.OperatorOuterClass.Operator + +/** + * Packs and unpacks the JVM-planned `DeltaSparkScan` message in core's generic `ContribScan` + * envelope (`contrib_scan` on `Operator`). The native dispatcher routes by `type_url`, so this + * contrib's identifier is the only coupling between the JVM and native sides; core names no Delta + * type. + */ +object DeltaSparkScanEnvelope { + + /** + * Contrib-owned identifier for the message, mirrored by `DELTA_SPARK_SCAN_TYPE_NAME` in + * native's `delta_spark_scan.rs`. Distinct from the kernel path's + * `comet.contrib.delta.DeltaScan`. + */ + val TypeUrl = "type.googleapis.com/comet.contrib.delta_spark.DeltaSparkScan" + + def pack(scan: OperatorOuterClass.DeltaSparkScan): OperatorOuterClass.ContribScan = + OperatorOuterClass.ContribScan + .newBuilder() + .setTypeUrl(TypeUrl) + .setValue(scan.toByteString) + .build() + + /** Whether this operator carries this contrib's scan (and not some other contrib's). */ + def matches(op: Operator): Boolean = + op.hasContribScan && op.getContribScan.getTypeUrl == TypeUrl + + /** Callers must check `matches` first. */ + def unpack(op: Operator): OperatorOuterClass.DeltaSparkScan = + OperatorOuterClass.DeltaSparkScan.parseFrom(op.getContribScan.getValue) +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/CometDeltaNativeScanExec.scala b/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/CometDeltaNativeScanExec.scala new file mode 100644 index 00000000000..0d8f3c800bc --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/CometDeltaNativeScanExec.scala @@ -0,0 +1,306 @@ +/* + * 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. + */ + +package org.apache.spark.sql.comet + +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.catalyst.expressions._ +import org.apache.spark.sql.catalyst.plans.QueryPlan +import org.apache.spark.sql.catalyst.plans.physical.{Partitioning, UnknownPartitioning} +import org.apache.spark.sql.execution.{FileSourceScanExec, InSubqueryExec, ReusedSubqueryExec, ScalarSubquery, SparkPlan, SubqueryAdaptiveBroadcastExec} +import org.apache.spark.sql.execution.datasources.HadoopFsRelation +import org.apache.spark.sql.execution.metric.SQLMetric +import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.vectorized.ColumnarBatch + +import org.apache.comet.contrib.delta.DeltaSparkScanEnvelope +import org.apache.comet.serde.OperatorOuterClass +import org.apache.comet.serde.OperatorOuterClass.Operator + +/** + * Native scan node for Delta Lake tables (contrib). Delta's own planning (log replay, snapshot + * resolution, partition pruning) has already run inside delta-spark by the time this node is + * created from the DSv1 [[FileSourceScanExec]]; file listing and split planning are delegated to + * a [[CometScanExec]] helper, and data reads execute through Comet's native DataFusion parquet + * machinery, inheriting row-group and page-index pruning. + * + * DPP: `runtimeFilters` is a constructor field included in equality, so its rewrite (via + * [[CometScanWithPlanData]]) survives plan copies -- a transient field would be dropped by + * `TreeNode.makeCopy` on MERGE re-planning (the CometIcebergNativeScanExec lesson). + */ +case class CometDeltaNativeScanExec( + override val nativeOp: Operator, + override val output: Seq[Attribute], + requiredSchema: StructType, + runtimeFilters: Seq[Expression], + dataFilters: Seq[Expression], + @transient relation: HadoopFsRelation, + originalPlan: FileSourceScanExec, + override val serializedPlanOpt: SerializedPlan, + sourceKey: String) + extends CometLeafExec + with CometScanWithPlanData { + + override val nodeName: String = s"CometDeltaNativeScan $relation" + + // Derived from (originalPlan, runtimeFilters), never stored: any copy of this node + // automatically gets a helper consistent with ITS runtimeFilters, avoiding the #3510 class of + // bug where a stored helper field desyncs from rewritten filters. Costs one extra file listing + // per executed instance; correctness over the duplicate driver-side listing. + // + // Forcing invariant: this lazy val is forced by the `metrics` override below, and AQE's UI + // plan-walk calls `.metrics` on every node MID-PLANNING, including while a DPP subquery is + // still an adaptive placeholder or a partition filter holds an unresolved ScalarSubquery (see + // `hasUnevaluableSubqueryFilter` below). That's safe ONLY because constructing `scanHelper` is a + // cheap case-class build with no file listing, and core's `CometScanExec.metrics` touches only + // `wrapped.driverMetrics` (populated by Spark's own planning) plus a static metric-node + // constructor -- neither file listing nor subquery resolution. If core's `metrics` ever touches + // either, forcing `scanHelper` here would resurrect the AQE mid-planning crashes this invariant + // prevents. + @transient private lazy val scanHelper: CometScanExec = + CometDeltaNativeScanExec.planningHelper(originalPlan, runtimeFilters) + + // NOT lazy val: while a DPP subquery is still an adaptive placeholder, or a partition filter + // holds an unresolved scalar subquery, this returns a temporary value that must not be + // memoized -- after CometPlanAdaptiveDynamicPruningFilters rewrites the filters (DPP case) or + // AQE resolves the subquery (scalar case), later reads must see the real post-pruning + // partition count. + override def outputPartitioning: Partitioning = + if (hasUnevaluableSubqueryFilter) UnknownPartitioning(0) + else UnknownPartitioning(perPartitionData.length) + + // runtimeFilters IS scanHelper.partitionFilters element-for-element, so checking runtimeFilters + // here avoids constructing/forcing the derived scanHelper just to read partitioning. The + // InSubqueryExec placeholder shapes mirror + // CometPlanAdaptiveDynamicPruningFilters.extractSABData + hasWrappedSAB -- keep in sync. The + // ScalarSubquery case is probed rather than treated as permanently unevaluable: Spark exposes no + // public finished/updated flag on ExecSubqueryExpression, but `eval()` doubles as one -- it only + // reads the cached `result` behind a `require(updated, ...)` guard, while the subquery is + // actually run by `updateResult()` (invoked separately during prepare/AQE), never by `eval()`. + // Once resolved, outputPartitioning below reports the real perPartitionData.length instead of + // staying at zero -- a fused native parent's buildNativeContext requires that count to match. + private def hasUnevaluableSubqueryFilter: Boolean = + runtimeFilters.exists(_.exists { + // Match `e: InSubqueryExec` and dispatch on e.plan rather than unapplying InSubqueryExec + // directly: its unapply arity differs across Spark versions and this module ships no + // version shim. + case e: InSubqueryExec => isAdaptivePlaceholder(e.plan) + case s: ScalarSubquery => !isScalarSubqueryResolved(s) + case _ => false + }) + + // `eval()` never triggers the subquery's execution: on a resolved subquery it is a pure cached + // read of `result` (verified against bytecode: `Predef.require(updated(), ...)` then a plain + // field read), so this probe is safe to call repeatedly, including from AQE's mid-planning plan + // walks. Pre-resolution, the ONLY throw is `require`'s `IllegalArgumentException`; catch exactly + // that, since anything else escaping is a genuine bug we must not mask as unpartitioned. + private def isScalarSubqueryResolved(s: ScalarSubquery): Boolean = + try { + s.eval() + true + } catch { + case _: IllegalArgumentException => false + } + + private def isAdaptivePlaceholder(p: SparkPlan): Boolean = p match { + case ReusedSubqueryExec(inner) => isAdaptivePlaceholder(inner) + case _: CometSubqueryAdaptiveBroadcastExec => true + case _: SubqueryAdaptiveBroadcastExec => true + case _ => false + } + + override lazy val outputOrdering: Seq[SortOrder] = originalPlan.outputOrdering + + override def dynamicPruningFilters: Seq[Expression] = runtimeFilters + + override def withDynamicPruningFilters(filters: Seq[Expression]): SparkPlan = { + // A real copy: runtimeFilters is a constructor field included in equality, so the copy + // survives enclosing-block rebuilds, and the derived scanHelper picks up the rewritten + // filters automatically. + copy(runtimeFilters = filters) + } + + /** + * Lazy split-mode serialization, mirroring CometNativeScanExec: common data was serialized at + * planning; per-partition file lists serialize here, at execution time. + */ + @transient private lazy val serializedPartitionData + : (Array[Byte], Array[Array[Byte]], Array[Seq[String]]) = { + // Resolve the helper's DPP subqueries: it holds its own InSubqueryExec instances that + // Spark's expressions walk does not see (the helper is derived, not a child). + scanHelper.partitionFilters.foreach { + case DynamicPruningExpression(e: InSubqueryExec) if e.values().isEmpty => + e.updateResult() + case _ => + } + + val commonBytes = { + val deltaScan = DeltaSparkScanEnvelope.unpack(nativeOp) + // Scalar subqueries in dataFilters were unresolved at planning; resolve them now and + // append them as pushed filters, as CometNativeScanExec.serializedPartitionData does. + val subqueryFilters = org.apache.comet.contrib.delta.CometDeltaNativeScan + .resolvedSubqueryFilters(dataFilters, output, requiredSchema, conf) + val common = if (subqueryFilters.isEmpty) { + deltaScan.getCommon + } else { + val builder = deltaScan.getCommon.toBuilder + subqueryFilters.foreach(builder.addDataFilters) + builder.build() + } + OperatorOuterClass.DeltaSparkScan + .newBuilder() + .setCommon(common) + .setDeltaCommon(deltaScan.getDeltaCommon) + .build() + .toByteArray + } + + val filePartitions = scanHelper.getFilePartitions() + + val tableRoot = DeltaSparkScanEnvelope.unpack(nativeOp).getDeltaCommon.getTableRoot + val perPartitionBytes = filePartitions.map { filePartition => + org.apache.comet.contrib.delta.CometDeltaNativeScan + .serializePartition(filePartition, originalPlan, tableRoot) + }.toArray + + val perPartitionPaths = filePartitions.map(_.files.map(_.filePath.toString).toSeq).toArray + + (commonBytes, perPartitionBytes, perPartitionPaths) + } + + override def commonData: Array[Byte] = serializedPartitionData._1 + + override def perPartitionData: Array[Array[Byte]] = serializedPartitionData._2 + + def perPartitionFilePaths: Array[Seq[String]] = serializedPartitionData._3 + + override def doExecuteColumnar(): RDD[ColumnarBatch] = { + val nativeMetrics = CometMetricNode.fromCometPlan(this) + val serializedPlan = CometExec.serializeNativePlan(nativeOp) + + new CometExecRDD( + sparkContext, + Seq.empty, + Map(sourceKey -> commonData), + Map(sourceKey -> perPartitionData), + serializedPlan, + perPartitionData.length, + output.length, + nativeMetrics, + Seq.empty, + None, + Seq.empty, + perPartitionFilePaths = perPartitionFilePaths, + reportScanInputMetrics = true) + } + + override def doCanonicalize(): CometDeltaNativeScanExec = { + val canonOriginal = if (originalPlan != null) { + val stripped = originalPlan.copy(partitionFilters = + CometScanUtils.filterUnusedDynamicPruningExpressions(originalPlan.partitionFilters)) + stripped.doCanonicalize() + } else { + null + } + CometDeltaNativeScanExec( + nativeOp, + output.map(QueryPlan.normalizeExpressions(_, output)), + requiredSchema, + QueryPlan.normalizePredicates( + CometScanUtils.filterUnusedDynamicPruningExpressions(runtimeFilters), + output), + QueryPlan.normalizePredicates(dataFilters, output), + relation, + canonOriginal, + SerializedPlan(None), + "") + } + + override def stringArgs: Iterator[Any] = Iterator(output, runtimeFilters) + + override def equals(obj: Any): Boolean = obj match { + case other: CometDeltaNativeScanExec => + this.originalPlan == other.originalPlan && + this.serializedPlanOpt == other.serializedPlanOpt && + this.runtimeFilters == other.runtimeFilters && + this.dataFilters == other.dataFilters + case _ => false + } + + override def hashCode(): Int = + java.util.Objects.hash(originalPlan, serializedPlanOpt, runtimeFilters, dataFilters) + + private val driverMetricKeys = + Set( + "numFiles", + "filesSize", + "numPartitions", + "metadataTime", + "staticFilesNum", + "staticFilesSize", + "pruningTime") + + // Forces `scanHelper` (see its doc above for why that -- and reading `.metrics` off it -- is + // safe even when AQE calls `.metrics` mid-planning against an unresolved DPP/scalar subquery). + override lazy val metrics: Map[String, SQLMetric] = { + CometMetricNode.nativeScanMetrics(session.sparkContext) ++ + scanHelper.metrics.filter { case (k, _) => driverMetricKeys.contains(k) } + } +} + +object CometDeltaNativeScanExec { + + /** File-planning helper: reuses CometScanExec's listing/splitting/DPP machinery. */ + def planningHelper( + scanExec: FileSourceScanExec, + partitionFilters: Seq[Expression]): CometScanExec = + CometScanExec( + scanExec.relation, + scanExec.output, + scanExec.requiredSchema, + partitionFilters, + scanExec.optionalBucketSet, + scanExec.optionalNumCoalescedBuckets, + scanExec.dataFilters, + scanExec.tableIdentifier, + scanExec.disableBucketedScan, + scanExec) + + def apply( + nativeOp: Operator, + scanExec: FileSourceScanExec, + subqueryDataFilters: Seq[Expression] = Seq.empty): CometDeltaNativeScanExec = { + // subqueryDataFilters: subquery predicates harvested from the covering FilterExec at claim + // time (Spark 3.x keeps them out of scanExec.dataFilters; see + // CometDeltaNativeScan.subqueryFiltersFromParent). Carried in dataFilters so the + // execution-time resolve-and-push path sees them; correctness never depends on them. + val exec = CometDeltaNativeScanExec( + nativeOp, + scanExec.output, + scanExec.requiredSchema, + scanExec.partitionFilters, + scanExec.dataFilters ++ subqueryDataFilters, + scanExec.relation, + scanExec, + SerializedPlan(None), + DeltaSparkScanEnvelope.unpack(nativeOp).getDeltaCommon.getSourceKey) + scanExec.logicalLink.foreach(exec.setLogicalLink) + exec + } +} diff --git a/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/DeltaPlanDataInjector.scala b/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/DeltaPlanDataInjector.scala new file mode 100644 index 00000000000..b98f8c4afa4 --- /dev/null +++ b/contrib/delta-spark/src/main/scala/org/apache/spark/sql/comet/DeltaPlanDataInjector.scala @@ -0,0 +1,86 @@ +/* + * 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. + */ + +package org.apache.spark.sql.comet + +import scala.jdk.CollectionConverters._ + +import org.apache.comet.contrib.delta.DeltaSparkScanEnvelope +import org.apache.comet.serde.{OperatorOuterClass, QueryContextInterner} +import org.apache.comet.serde.OperatorOuterClass.Operator + +/** + * PlanDataInjector for the Delta contrib scan, discovered by core's ServiceLoader (see the + * `META-INF/services` resource). Lives in this package because [[PlanDataInjector]] is + * `private[comet]`. + */ +class DeltaPlanDataInjector extends PlanDataInjector { + + override val opStructCase: Operator.OpStructCase = Operator.OpStructCase.CONTRIB_SCAN + + override def canInject(op: Operator): Boolean = + DeltaSparkScanEnvelope.matches(op) && { + val scan = DeltaSparkScanEnvelope.unpack(op) + scan.hasCommon && !scan.hasFilePartition + } + + override def getKey(op: Operator): Option[String] = + Some(DeltaSparkScanEnvelope.unpack(op).getDeltaCommon.getSourceKey) + + override def inject( + op: Operator, + commonBytes: Array[Byte], + partitionBytes: Array[Byte]): Operator = { + // commonBytes is a DeltaSparkScan proto carrying common + delta_common (no file partition); + // partitionBytes is a DeltaSparkScan proto carrying only this partition's file list. + val common = OperatorOuterClass.DeltaSparkScan.parseFrom(commonBytes) + val partitionOnly = OperatorOuterClass.DeltaSparkScan.parseFrom(partitionBytes) + + val scanBuilder = OperatorOuterClass.DeltaSparkScan + .newBuilder() + .setCommon(common.getCommon) + .setDeltaCommon(common.getDeltaCommon) + .setFilePartition(partitionOnly.getFilePartition) + + op.toBuilder.setContribScan(DeltaSparkScanEnvelope.pack(scanBuilder.build())).build() + } +} + +object DeltaPlanDataInjector { + + /** + * The key under which a Delta scan's planning data is stored and looked up. Written into + * `DeltaSparkScanCommon.source_key` on the driver and read back by + * [[DeltaPlanDataInjector.getKey]] on the executor, so both sides agree by construction. + * Mirrors `NativeScanPlanDataInjector.sourceKey` (source string carries the plan node id, so + * two scans of the same table in one plan, self-join, MERGE, get distinct keys), plus the table + * root for extra safety across tables with identical projections. + */ + def sourceKey(tableRoot: String, common: OperatorOuterClass.NativeScanCommon): String = { + val dataFilters = common.getDataFiltersList.asScala + .map(QueryContextInterner.stripQueryContexts(_).toString) + val keyComponents = Seq( + tableRoot, + common.getRequiredSchemaList.toString, + dataFilters.mkString("[", ", ", "]"), + common.getProjectionVectorList.toString, + common.getFieldsList.toString) + s"delta_${common.getSource}_${keyComponents.mkString("|").hashCode}" + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaDmlReproSuite.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaDmlReproSuite.scala new file mode 100644 index 00000000000..8b5a48e8c2b --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaDmlReproSuite.scala @@ -0,0 +1,151 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import scala.collection.mutable.ListBuffer + +import org.apache.spark.sql.delta.DeltaLog +import org.apache.spark.sql.execution.{FileSourceScanExec, QueryExecution, SparkPlan} +import org.apache.spark.sql.util.QueryExecutionListener + +import org.apache.comet.ExtendedExplainInfo + +/** + * Repro for Delta's own DeletionVectorsSuite expectation: DELETE on a DV-enabled table must WRITE + * deletion vectors (not rewrite files) with Comet active. Mirrors "DELETE with DVs - on a table + * with no prior DVs". + */ +class CometDeltaDmlReproSuite extends CometDeltaTestBase { + + /** + * Every [[SparkPlan]] Delta's own internal DataFrame actions executed during `body`, captured + * via a [[QueryExecutionListener]] rather than the outer statement's own plan: Delta's DML + * commands (DELETE/UPDATE/MERGE) drive `findTouchedFiles` through separate internal + * `collect`/`count` actions on their own [[QueryExecution]]s, invisible to `df.queryExecution` + * on the outer SQL statement. + */ + private def capturePlansDuring(body: => Unit): Seq[SparkPlan] = { + val plans = ListBuffer.empty[SparkPlan] + val listener = new QueryExecutionListener { + override def onSuccess(funcName: String, qe: QueryExecution, durationNs: Long): Unit = { + plans += qe.executedPlan + } + override def onFailure( + funcName: String, + qe: QueryExecution, + exception: Exception): Unit = {} + } + spark.listenerManager.register(listener) + try { + body + } finally { + spark.listenerManager.unregister(listener) + } + plans.toSeq + } + + test( + "DELETE's internal deletion-vector-generating scan declines the row-index-outside-a-DV-" + + "scan reason (the read-side counterpart of the DV-write repro above)") { + withSQLConf("spark.databricks.delta.properties.defaults.enableDeletionVectors" -> "true") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 1000, 1, 4).write.format("delta").save(path) + + val capturedPlans = capturePlansDuring { + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0 AND id < 200") + } + + // Before writing a deletion vector, DELETE must first learn WHICH rows matched the + // predicate, so it reads each candidate file's `_metadata.row_index` directly (a bare + // row-index column, with no `is_row_deleted` alongside it -- unlike a normal DV-applying + // read, no existing DV is applied to this scan, since the very DV being computed does not + // exist yet). DeltaScanSupport.declineReason's hasRowIndex-without-hasIsRowDeleted gate + // exists precisely to keep this bookkeeping scan on Spark's reader: claiming it with a + // dead constant row-index would feed wrong (constant) row indexes into the DV this DELETE + // is trying to build. This must remain a plain Spark FileSourceScanExec here, never a + // CometDeltaNativeScanExec. + val declinedRowIndexScans = capturedPlans.flatMap { plan => + collectWithSubqueries(stripAQEPlan(plan)) { + case f: FileSourceScanExec + if DeltaScanSupport.isDeltaScan(f) && + f.requiredSchema.exists(_.name == CometDeltaNativeScan.RowIndexColumn) && + !f.requiredSchema.exists(_.name == CometDeltaNativeScan.IsRowDeletedColumn) => + f + } + } + assert( + declinedRowIndexScans.nonEmpty, + "expected to observe at least one internal row-index-only scan while DELETE " + + "computed which rows to mark in the new deletion vector") + + val reasons = + declinedRowIndexScans.flatMap(f => new ExtendedExplainInfo().getFallbackReasons(f)) + assert( + reasons.exists(_.contains("row-index reads outside a deletion-vector scan")), + "expected the internal row-index scan to carry the row-index-outside-a-DV-scan " + + s"decline reason, got: ${reasons.mkString(", ")}") + + val log = DeltaLog.forTable(spark, path) + val withDvs = log.update().allFiles.collect().count(_.deletionVector != null) + assert(withDvs > 0, s"expected at least one file to have a DV written, got $withDvs") + assert(spark.read.format("delta").load(path).count() == 900) + } + } + } + + test("DELETE writes DVs with useMetadataRowIndex=true (metadata row-index DML shape)") { + withSQLConf( + "spark.databricks.delta.properties.defaults.enableDeletionVectors" -> "true", + "spark.databricks.delta.deletionVectors.useMetadataRowIndex" -> "true") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 1000, 1, 500).write.format("delta").save(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0 AND id < 200") + + val log = DeltaLog.forTable(spark, path) + val withDvs = log.update().allFiles.collect().count(_.deletionVector != null) + assert(withDvs == 100, s"expected 100 files with DVs, got $withDvs") + assert(spark.read.format("delta").load(path).count() == 900) + } + } + } + + test("DELETE writes DVs rather than rewriting files") { + withSQLConf( + "spark.databricks.delta.properties.defaults.enableDeletionVectors" -> "true", + "spark.databricks.delta.delete.deletionVectors.persistent" -> "true") { + withTempDir { base => + // Mirror Delta's DeletionVectorsTestUtils: paths with spaces and a literal %2a. + val dir = new java.io.File(base, "s p a r k %2a") + val path = dir.getAbsolutePath + spark.range(0, 1000, 1, 500).write.format("delta").save(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0 AND id < 200") + + val log = DeltaLog.forTable(spark, path) + val files = log.update().allFiles.collect() + val withDvs = files.count(_.deletionVector != null) + assert(files.length == 500, s"expected 500 files, got ${files.length}") + assert(withDvs == 100, s"expected 100 files with DVs, got $withDvs") + assert(spark.read.format("delta").load(path).count() == 900) + } + } + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaNativeScanSuite.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaNativeScanSuite.scala new file mode 100644 index 00000000000..18b1fb1795c --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaNativeScanSuite.scala @@ -0,0 +1,2700 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import scala.collection.mutable +import scala.collection.mutable.ListBuffer +import scala.concurrent.duration.DurationInt + +import org.apache.spark.scheduler.{SparkListener, SparkListenerTaskEnd} +import org.apache.spark.sql.{DataFrame, Row} +import org.apache.spark.sql.catalyst.expressions.{AttributeReference, DynamicPruningExpression, NamedExpression, StructsToJson} +import org.apache.spark.sql.comet.CometDeltaNativeScanExec +import org.apache.spark.sql.execution.{FileSourceScanExec, QueryExecution, ScalarSubquery, SparkPlan, SubqueryExec} +import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec +import org.apache.spark.sql.functions.{col, lit, to_json} +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.types.{ByteType, LongType, StringType, StructField, StructType} +import org.apache.spark.sql.util.QueryExecutionListener + +import org.apache.comet.CometConf +import org.apache.comet.ExtendedExplainInfo +import org.apache.comet.serde.OperatorOuterClass +import org.apache.comet.serde.operator.CometNativeScan + +/** + * Differential suite: append-only Delta tables read through the native Delta scan must produce + * results identical to Spark's Delta reader, engage the native operator, and prune at row-group + * and page level. + */ +class CometDeltaNativeScanSuite extends CometDeltaTestBase { + + test("plain delta table reads natively with identical results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 1000) + .selectExpr("id", "id * 2 as v", "cast(id as string) as s") + .write + .format("delta") + .save(path) + + val df = spark.read.format("delta").load(path).filter(col("id") > 500) + checkDeltaNativeScanAnswer(df) + } + } + + test("projection and filter on delta table") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 1000) + .selectExpr("id", "id % 10 as bucket", "cast(id as double) as d") + .write + .format("delta") + .save(path) + + val df = spark.read + .format("delta") + .load(path) + .select("bucket", "d") + .filter(col("d") < 100.0) + checkDeltaNativeScanAnswer(df) + } + } + + test("partitioned delta table with partition filter") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 1000) + .selectExpr("id", "id % 7 as p") + .write + .format("delta") + .partitionBy("p") + .save(path) + + val df = spark.read.format("delta").load(path).filter(col("p") === 3) + checkDeltaNativeScanAnswer(df) + assert(df.count() > 0) + } + } + + test("multi-file delta table after several appends") { + withTempPath { dir => + val path = dir.getAbsolutePath + for (i <- 0 until 4) { + spark + .range(i * 100, (i + 1) * 100) + .selectExpr("id", "id * 3 as v") + .write + .format("delta") + .mode("append") + .save(path) + } + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 400) + } + } + + test("time travel VERSION AS OF reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + spark.range(100, 200).write.format("delta").mode("append").save(path) + + val v0 = spark.read.format("delta").option("versionAsOf", 0).load(path) + checkDeltaNativeScanAnswer(v0) + assert(v0.count() == 100) + } + } + + test("selective predicate prunes row groups and pages") { + withTempPath { dir => + val path = dir.getAbsolutePath + // Small row groups + page-level stats: sorted data so min/max stats are tight. The Delta + // writer ignores parquet.* DataFrameWriter options, so set them on the Hadoop conf. + val hadoopConf = spark.sparkContext.hadoopConfiguration + val oldBlockSize = hadoopConf.get("parquet.block.size") + val oldPageSize = hadoopConf.get("parquet.page.size") + hadoopConf.setInt("parquet.block.size", 256 * 1024) + hadoopConf.setInt("parquet.page.size", 16 * 1024) + try { + spark + .range(0, 500000) + .selectExpr("id", "id * 2 as v") + .sort("id") + .coalesce(1) + .write + .format("delta") + .save(path) + } finally { + if (oldBlockSize == null) hadoopConf.unset("parquet.block.size") + else hadoopConf.set("parquet.block.size", oldBlockSize) + if (oldPageSize == null) hadoopConf.unset("parquet.page.size") + else hadoopConf.set("parquet.page.size", oldPageSize) + } + + def query = spark.read + .format("delta") + .load(path) + .filter(col("id") >= 100 && col("id") < 200) + checkDeltaNativeScanAnswer(query) + + // checkSparkAnswer re-plans the query, so read metrics from a DataFrame we execute + // ourselves (collect() runs THIS Dataset's queryExecution; count() would plan a new one): + // its executed plan holds the metric objects native execution updated. + val df = query + assert(df.collect().length == 100) + val scans = deltaNativeScans(df) + assert(scans.size == 1) + val metrics = scans.head.metrics + val rowGroupsPruned = metrics.get("row_groups_pruned_statistics").map(_.value).getOrElse(0L) + val pagesPruned = metrics.get("page_index_rows_pruned").map(_.value).getOrElse(0L) + assert( + rowGroupsPruned > 0, + s"expected row-group pruning; metrics: ${metrics.map { case (k, v) => s"$k=${v.value}" }}") + assert( + pagesPruned > 0, + s"expected page-index pruning; metrics: ${metrics.map { case (k, v) => + s"$k=${v.value}" + }}") + } + } + + test("scalar subquery data filter is pushed down and prunes row groups and pages") { + withTempPath { dir => + val path = s"${dir.getAbsolutePath}/data" + val thresholds = s"${dir.getAbsolutePath}/thresholds" + // Same layout as the selective-predicate test: small row groups + tight page stats. + val hadoopConf = spark.sparkContext.hadoopConfiguration + val oldBlockSize = hadoopConf.get("parquet.block.size") + val oldPageSize = hadoopConf.get("parquet.page.size") + hadoopConf.setInt("parquet.block.size", 256 * 1024) + hadoopConf.setInt("parquet.page.size", 16 * 1024) + try { + spark + .range(0, 500000) + .selectExpr("id", "id * 2 as v") + .sort("id") + .coalesce(1) + .write + .format("delta") + .save(path) + } finally { + if (oldBlockSize == null) hadoopConf.unset("parquet.block.size") + else hadoopConf.set("parquet.block.size", oldBlockSize) + if (oldPageSize == null) hadoopConf.unset("parquet.page.size") + else hadoopConf.set("parquet.page.size", oldPageSize) + } + spark + .sql("SELECT CAST(100 AS BIGINT) AS lo, CAST(200 AS BIGINT) AS hi") + .write + .format("delta") + .save(thresholds) + + // Scalar subqueries are PlanExpressions: unresolved at planning, so the bounds can + // only reach the native reader via the execution-time resolve-and-append path. + def query = spark.sql( + s"SELECT * FROM delta.`$path` WHERE id >= (SELECT lo FROM delta.`$thresholds`) " + + s"AND id < (SELECT hi FROM delta.`$thresholds`)") + checkDeltaNativeScanAnswer(query) + + val df = query + assert(df.collect().length == 100) + // The thresholds table inside the subquery is also claimed natively; pick the + // main data-table scan by its output. + assertSubqueryFilterPushed(df, dataColumn = "v") + val scans = deltaNativeScans(df).filter(_.output.exists(_.name == "v")) + assert(scans.size == 1) + val metrics = scans.head.metrics + val rowGroupsPruned = metrics.get("row_groups_pruned_statistics").map(_.value).getOrElse(0L) + val pagesPruned = metrics.get("page_index_rows_pruned").map(_.value).getOrElse(0L) + assert( + rowGroupsPruned > 0, + s"expected row-group pruning from the resolved subquery bounds; metrics: ${metrics.map { + case (k, v) => s"$k=${v.value}" + }}") + assert( + pagesPruned > 0, + s"expected page-index pruning from the resolved subquery bounds; metrics: ${metrics.map { + case (k, v) => s"$k=${v.value}" + }}") + } + } + + test("deletion vectors: scalar subquery filter composes with DV application") { + withTempPath { dir => + val path = s"${dir.getAbsolutePath}/data" + val thresholds = s"${dir.getAbsolutePath}/thresholds" + createDvTable(path, rows = 10000) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + spark + .sql("SELECT CAST(5000 AS BIGINT) AS lo") + .write + .format("delta") + .save(thresholds) + + def query = + spark.sql(s"SELECT * FROM delta.`$path` WHERE id >= (SELECT lo FROM delta.`$thresholds`)") + checkDeltaNativeScanAnswer(query) + // Deleted rows must stay deleted with the pushed bound applied in-scan. + val df = query + val rows = df.collect() + assert(rows.length == 2500) + assert(rows.forall(r => r.getLong(0) % 2 == 1 && r.getLong(0) >= 5000)) + assertSubqueryFilterPushed(df, dataColumn = "v") + } + } + + test("column mapping: scalar subquery filter on a renamed column") { + withTempPath { dir => + val path = s"${dir.getAbsolutePath}/data" + val thresholds = s"${dir.getAbsolutePath}/thresholds" + spark.range(0, 1000).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN v TO w") + spark + .sql("SELECT CAST(900 AS BIGINT) AS lo") + .write + .format("delta") + .save(thresholds) + + // The pushed filter references the renamed column: it must bind against the + // physical read schema, not the logical name. + def query = + spark.sql(s"SELECT * FROM delta.`$path` WHERE w >= (SELECT lo FROM delta.`$thresholds`)") + checkDeltaNativeScanAnswer(query) + val df = query + assert(df.collect().length == 550) + assertSubqueryFilterPushed(df, dataColumn = "w") + } + } + + /** + * Assert the resolved scalar-subquery bound was actually appended to the native scan's + * execution-time common data (answers alone cannot show this: Spark's covering FilterExec would + * mask a silently-skipped pushdown). `df` must already have been executed. + */ + private def assertSubqueryFilterPushed(df: DataFrame, dataColumn: String): Unit = { + val scans = deltaNativeScans(df).collect { + case s: CometDeltaNativeScanExec if s.output.exists(_.name == dataColumn) => s + } + assert(scans.size == 1) + val scan = scans.head + val planTimeFilters = + DeltaSparkScanEnvelope.unpack(scan.nativeOp).getCommon.getDataFiltersCount + val executedFilters = OperatorOuterClass.DeltaSparkScan + .parseFrom(scan.commonData) + .getCommon + .getDataFiltersCount + assert( + executedFilters > planTimeFilters, + "expected resolved subquery filters appended at execution: " + + s"plan-time=$planTimeFilters executed=$executedFilters " + + s"dataFilters=${scan.dataFilters.mkString("; ")}") + } + + test("scalar subquery filter is NOT pushed below a limit") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 3).selectExpr("id").write.format("delta").save(path) + spark.read.format("delta").load(path).createOrReplaceTempView("t_limit_pushdown") + + val df = spark.sql( + "SELECT id FROM (SELECT id FROM t_limit_pushdown ORDER BY id LIMIT 1) q " + + "WHERE id > (SELECT max(id) FROM range(1))") + checkSparkAnswer(df) + assert(df.collect().isEmpty) + assertNoSubqueryFilterPushed(df) + } + } + + test("scalar subquery filter is NOT pushed across a nondeterministic projection") { + withSQLConf(CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key -> "true") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 5).coalesce(1).write.format("delta").save(path) + spark.read.format("delta").load(path).createOrReplaceTempView("t_monotonic_id") + + // A deterministic conjunct does not commute with a nondeterministic projection: the + // subquery bound must not be pushed into the scan below `seq`, or the surviving rows' + // monotonically_increasing_id() values change and the answer is wrong. + val df = spark.sql( + "SELECT id FROM (SELECT id, monotonically_increasing_id() AS seq " + + "FROM t_monotonic_id) q WHERE id > (SELECT max(id) FROM range(1)) AND seq = 1") + checkSparkAnswer(df) + assert(df.collect().toSeq == Seq(Row(1))) + assertNoSubqueryFilterPushed(df) + } + } + } + + /** + * Assert no scalar-subquery filter was harvested and pushed into the native scan's + * execution-time common data: the scan must sit below a non-commuting operator (e.g. LIMIT / + * TopN), so the covering FilterExec's predicate must stay above it rather than move into the + * scan. Also confirms the query still engaged the native Delta scan, i.e. this exercises the + * commutativity guard rather than a plan that fell back to Spark entirely. `df` must already + * have been executed. + */ + private def assertNoSubqueryFilterPushed(df: DataFrame): Unit = { + val scans = deltaNativeScans(df).collect { case s: CometDeltaNativeScanExec => s } + assert(scans.size == 1, s"expected exactly one native Delta scan; found ${scans.size}") + val scan = scans.head + val planTimeFilters = + DeltaSparkScanEnvelope.unpack(scan.nativeOp).getCommon.getDataFiltersCount + val executedFilters = OperatorOuterClass.DeltaSparkScan + .parseFrom(scan.commonData) + .getCommon + .getDataFiltersCount + assert( + executedFilters == planTimeFilters, + "expected no subquery filter pushed across the non-commuting operator between the " + + s"covering filter and the scan: plan-time=$planTimeFilters executed=$executedFilters " + + s"dataFilters=${scan.dataFilters.mkString("; ")}") + } + + test("aggregation over delta table") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 10000) + .selectExpr("id", "id % 13 as g", "id * 2 as v") + .write + .format("delta") + .save(path) + + val df = spark.read + .format("delta") + .load(path) + .groupBy("g") + .sum("v") + checkDeltaNativeScanAnswer(df) + } + } + + test("conf disables the native delta scan") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + withSQLConf(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key -> "false") { + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty) + } + } + } + + test("native delta scan is opt-in: disabled when the conf is not set") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + // The suite base enables the scan globally; drop the key entirely to + // observe the out-of-the-box default. + spark.conf.unset(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key) + try { + assert(!DeltaScanConf.scanEnabled) + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty) + } finally { + spark.conf.set(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key, "true") + } + } + } + + private def createDvTable(path: String, rows: Long = 1000): Unit = { + spark.range(0, rows).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + } + + /** + * Same shape as `createDvTable`, plus one extra TINYINT column (value 7) under `columnName`. + */ + private def createDvTableWithExtraColumn( + path: String, + columnName: String, + rows: Long = 1000): Unit = { + spark + .range(0, rows) + .selectExpr("id", s"cast(7 as tinyint) as `$columnName`") + .write + .format("delta") + .save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + } + + test("deletion vectors: DELETE-produced DVs read natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 500) + } + } + + test( + "deletion vectors: user column named like the synthetic internal-column slot keeps its " + + "own values") { + withTempPath { dir => + val path = dir.getAbsolutePath + val collidingName = "_comet_delta___delta_internal_is_row_deleted" + createDvTableWithExtraColumn(path, collidingName) + spark.sql(s"DELETE FROM delta.`$path` WHERE id = 0") + + val df = spark.read.format("delta").load(path).select("id", collidingName) + checkDeltaNativeScanAnswer(df) + val survivingValues = df.collect().map(_.getAs[Byte](collidingName)).distinct + assert( + survivingValues.sameElements(Array(7.toByte)), + "expected the user column's own value (7) to survive DV filtering, " + + s"got ${survivingValues.toSeq}") + } + } + + test("deletion vectors: normally named extra column alongside DVs reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTableWithExtraColumn(path, "tag") + spark.sql(s"DELETE FROM delta.`$path` WHERE id = 0") + + val df = spark.read.format("delta").load(path).select("id", "tag") + checkDeltaNativeScanAnswer(df) + val survivingValues = df.collect().map(_.getAs[Byte]("tag")).distinct + assert( + survivingValues.sameElements(Array(7.toByte)), + s"expected the extra column's value (7) to survive DV filtering, got " + + survivingValues.toSeq) + } + } + + test("deletion vectors: UPDATE-produced DVs read natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"UPDATE delta.`$path` SET v = -1 WHERE id < 100") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.filter(col("v") === -1).count() == 100) + assert(df.count() == 1000) + } + } + + test("deletion vectors: multiple DELETEs accumulate correctly") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 3 = 0") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + // odd ids not divisible by 3 + assert(df.count() == (0L until 1000L).count(i => i % 2 != 0 && i % 3 != 0)) + } + } + + test( + "deletion vectors: maxDeletedRowsPerFile budget declines an oversized DV and " + + "claims once raised") { + withTempPath { dir => + val path = dir.getAbsolutePath + // repartition(4) guarantees >= 2 physical files so the per-file cardinality gate has + // more than one file to inspect, mirroring design F3's multi-file test shape. + spark + .range(0, 1000) + .selectExpr("id", "id * 2 as v") + .repartition(4) + .write + .format("delta") + .save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + withSQLConf(DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key -> "1") { + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert( + deltaNativeScans(df).isEmpty, + "a budget of 1 deleted row per file must decline every DV-bearing file") + } + + withSQLConf(DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key -> "1000000") { + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + } + } + } + + test("deletion vectors: maxDeletedRowsPerFile decline reason names the conf key") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + withSQLConf(DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key -> "1") { + checkSparkAnswerAndFallbackReason( + spark.read.format("delta").load(path), + DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key) + } + } + } + + test("deletion vectors: fully-deleted region and selective predicate still prune pages") { + withTempPath { dir => + val path = dir.getAbsolutePath + val hadoopConf = spark.sparkContext.hadoopConfiguration + val oldBlockSize = hadoopConf.get("parquet.block.size") + val oldPageSize = hadoopConf.get("parquet.page.size") + hadoopConf.setInt("parquet.block.size", 256 * 1024) + hadoopConf.setInt("parquet.page.size", 16 * 1024) + try { + spark + .range(0, 500000) + .selectExpr("id", "id * 2 as v") + .sort("id") + .coalesce(1) + .write + .format("delta") + .save(path) + } finally { + if (oldBlockSize == null) hadoopConf.unset("parquet.block.size") + else hadoopConf.set("parquet.block.size", oldBlockSize) + if (oldPageSize == null) hadoopConf.unset("parquet.page.size") + else hadoopConf.set("parquet.page.size", oldPageSize) + } + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + // Delete a slice inside the predicate range and a large slice outside it. + spark.sql(s"DELETE FROM delta.`$path` WHERE id >= 150 AND id < 160") + spark.sql(s"DELETE FROM delta.`$path` WHERE id >= 300000") + + def query = spark.read + .format("delta") + .load(path) + .filter(col("id") >= 100 && col("id") < 200) + checkDeltaNativeScanAnswer(query) + + val df = query + assert(df.collect().length == 90) + val scans = deltaNativeScans(df) + assert(scans.size == 1) + val metrics = scans.head.metrics + val pagesPruned = metrics.get("page_index_rows_pruned").map(_.value).getOrElse(0L) + assert( + pagesPruned > 0, + s"expected page-index pruning to compose with DVs; metrics: ${metrics.map { case (k, v) => + s"$k=${v.value}" + }}") + } + } + + test("deletion vectors: aggregation over DV table") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path, rows = 10000) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 7 = 0") + + val df = spark.read.format("delta").load(path).groupBy(col("id") % 13).count() + checkDeltaNativeScanAnswer(df) + } + } + + test("deletion vectors: partitioned table reads natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 1000) + .selectExpr("id", "id % 5 as p", "id * 2 as v") + .write + .format("delta") + .partitionBy("p") + .save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 3 = 0") + + val df = spark.read.format("delta").load(path).filter(col("p") === 2) + checkDeltaNativeScanAnswer(df) + assert(df.count() == (0L until 1000L).count(i => i % 5 == 2 && i % 3 != 0)) + + val all = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(all) + assert(all.count() == (0L until 1000L).count(_ % 3 != 0)) + } + } + + test("deletion vectors: combined with constant metadata columns") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id < 250") + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", "v", "_metadata.file_name as fn") + checkSparkAnswer(df.selectExpr("id", "v", "length(fn) > 0")) + // Whether this claims or declines, results must match; if it claimed, verify the + // native node is present so the combination is actually exercised when supported. + val rows = df.collect() + assert(rows.length == 750) + assert(rows.forall(_.getString(2).nonEmpty)) + } + } + + test( + "deletion vectors: constant-metadata field names are deduplicated against the physical " + + "data and partition schemas") { + // End-to-end coverage is not possible here: selecting any `_metadata.*` field in the DV + // shape always declines today for an unrelated, pre-existing reason -- Spark reuses the + // scan's own row-index bookkeeping attribute as `_metadata.row_index`'s source, and + // `DeltaScanSupport.rowIndexUnusedAbove` conservatively treats extracting ANY `_metadata` + // field as making that attribute live (see "combined with constant metadata columns" + // above, which hedges its assertions for the same reason). That decline fires before + // `buildDvScanCommon` ever runs, regardless of collision, so it cannot exercise the fix. + // Test the builder's dedup logic directly instead, the same way `storeUris` and + // `mergedObjectStoreOptions` are unit-tested without a live scan. + val physicalDataSchema = + StructType(Seq(StructField("_comet_metadata_file_path", ByteType))) + val physicalPartitionSchema = + StructType(Seq(StructField("_comet_metadata_file_size", LongType))) + val fileConstantMetadataColumns = Seq( + AttributeReference("file_path", StringType, nullable = false)(), + AttributeReference("file_size", LongType, nullable = false)()) + + val constantMetadataFields = CometNativeScan.uniqueConstantMetadataFields( + fileConstantMetadataColumns, + physicalDataSchema.fields.map(_.name).toSet ++ physicalPartitionSchema.fields + .map(_.name) + .toSet) + assert( + constantMetadataFields.map(_.name) == Seq( + "_comet_metadata_file_path_", + "_comet_metadata_file_size_"), + "expected both constant-metadata names to be uniquified on collision, got " + + s"${constantMetadataFields.map(_.name)}") + + // The DV builder must feed these already-unique names into allocateUniqueInternalFields's + // reserved set so the internal-column suffix chain stays consistent with them. + val requiredSchema = StructType( + Seq( + StructField("id", LongType), + StructField(CometDeltaNativeScan.IsRowDeletedColumn, ByteType), + StructField(CometDeltaNativeScan.RowIndexColumn, LongType))) + val internalFields = CometDeltaNativeScan.allocateUniqueInternalFields( + requiredSchema, + physicalDataSchema, + physicalPartitionSchema, + constantMetadataFields) + + val allNames = physicalDataSchema.fields.map(_.name) ++ + physicalPartitionSchema.fields.map(_.name) ++ + constantMetadataFields.map(_.name) ++ + internalFields.map(_.name) + assert(allNames.distinct.length == allNames.length, s"expected all names distinct: $allNames") + } + + test( + "non-DV shape: user column named like the synthetic constant-metadata slot keeps its " + + "own values") { + withTempPath { dir => + val path = dir.getAbsolutePath + val collidingName = "_comet_metadata_file_path" + spark + .range(0, 100) + .selectExpr("id", s"cast(7 as tinyint) as `$collidingName`") + .write + .format("delta") + .save(path) + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", s"`$collidingName`", "_metadata.file_path as fp") + checkDeltaNativeScanAnswer(df) + val rows = df.collect() + val survivingValues = rows.map(_.getAs[Byte](collidingName)).distinct + assert( + survivingValues.sameElements(Array(7.toByte)), + "expected the user column's own value (7) to survive the constant-metadata " + + s"collision, got ${survivingValues.toSeq}") + assert( + rows.forall(_.getString(2).nonEmpty), + "expected _metadata.file_path to still report a real path") + } + } + + test("deletion vectors: special characters in table path") { + withTempDir { base => + val dir = new java.io.File(base, "s p a r k %dv% test") + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 500) + } + } + + test("deletion vectors: decline when row_index is consumed via multi-hop aliases") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", "_metadata.row_index as ri") + .selectExpr("id", "ri + 1 as ri2") + .filter(col("ri2") > 10) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty, "derived row_index consumption must decline") + } + } + + test("deletion vectors: decline when row_index feeds a non-Project operator") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read + .format("delta") + .load(path) + .groupBy(col("_metadata.row_index") % 7) + .count() + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty, "aggregate over row_index must decline") + } + } + + test("deletion vectors: decline when _metadata.row_index is referenced above the scan") { + withTempPath { dir => + val path = dir.getAbsolutePath + createDvTable(path) + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", "_metadata.row_index as ri") + checkSparkAnswer(df) + assert( + deltaNativeScans(df).isEmpty, + "plans consuming a real row_index must fall back to Spark") + } + } + + /** + * Every [[SparkPlan]] executed during `body`, captured via a [[QueryExecutionListener]] rather + * than a returned `DataFrame`'s own plan: a `DataFrameWriter` action such as `.write.parquet` + * has no result `Dataset` to call `.queryExecution` on, so the write's physical plan -- the one + * `DeltaScanSupport.declineReason` actually saw -- is only observable this way. + */ + private def capturePlansDuring(body: => Unit): Seq[SparkPlan] = { + val plans = ListBuffer.empty[SparkPlan] + val listener = new QueryExecutionListener { + override def onSuccess(funcName: String, qe: QueryExecution, durationNs: Long): Unit = { + plans += qe.executedPlan + } + override def onFailure( + funcName: String, + qe: QueryExecution, + exception: Exception): Unit = {} + } + spark.listenerManager.register(listener) + try { + body + } finally { + spark.listenerManager.unregister(listener) + } + plans.toSeq + } + + test( + "deletion vectors: a write sink persisting _metadata.row_index declines the native scan " + + "and saves the real row indexes") { + withTempPath { srcDir => + withTempPath { dstDir => + val src = srcDir.getAbsolutePath + val dst = dstDir.getAbsolutePath + spark + .range(32) + .coalesce(1) + .write + .format("delta") + .option("delta.enableDeletionVectors", "true") + .save(src) + spark.sql(s"DELETE FROM delta.`$src` WHERE id IN (1, 7, 13)").collect() + + val capturedPlans = capturePlansDuring { + spark.read + .format("delta") + .load(src) + .selectExpr("id", "_metadata.row_index AS ri") + .write + .parquet(dst) + } + + // The write persists whatever the reader returns for `ri`, so the native DV scan must + // not be claimed here: claiming it would let the reader's dead synthetic row-index + // constant (correct only because the value is normally proven unused) get persisted as + // if it were the real row index. + val nativeScans = capturedPlans.flatMap(p => collectByName(p, "CometDeltaNativeScanExec")) + assert( + nativeScans.isEmpty, + "expected the write to decline the native Delta scan for a persisted row_index") + + // Documents which write-sink shape this test actually covers: the liveness gate in + // `DeltaScanSupport.rowIndexUnusedAbove` (the `childOutputLeak` check) declines a DV + // scan under ANY one-child, empty-output write sink structurally, including the DSv2 + // `V2TableWriteExec` family -- but a DV-enabled Delta read cannot be composed with a + // genuine DSv2 `AppendData` write in this delta-spark/Spark combination (see the + // dsv2-infeasibility test below), so `.write.parquet` here is the only write-sink shape + // this liveness gate is exercised against end-to-end. + assert( + capturedPlans.map(stripAQEPlan).forall(!_.isInstanceOf[V2TableWriteExec]), + s"expected a V1 write command, not a DSv2 write, got: $capturedPlans") + + val declinedScans = capturedPlans.flatMap { plan => + collectWithSubqueries(stripAQEPlan(plan)) { + case f: FileSourceScanExec if DeltaScanSupport.isDeltaScan(f) => f + } + } + val reasons = declinedScans.flatMap(f => new ExtendedExplainInfo().getFallbackReasons(f)) + assert( + reasons.exists(_.contains("row_index values consumed by the query")), + "expected the row-index-consumed-by-the-query decline reason, got: " + + reasons.mkString(", ")) + + val readBack = spark.read.parquet(dst) + checkSparkAnswer(readBack) + val rows = readBack.collect() + assert(rows.length == 29, s"expected 29 surviving rows, got ${rows.length}") + val id31 = rows.find(_.getLong(0) == 31) + assert(id31.isDefined, "expected id=31 to survive the DELETE") + assert( + id31.get.getLong(1) == 31, + "expected the persisted row_index for id=31 to be 31, got " + + s"${id31.get.getLong(1)} -- a wrongly-claimed native scan would have written a " + + "synthetic zero instead") + val sumRi = rows.map(_.getLong(1)).sum + assert( + sumRi == 475, + s"expected sum(row_index) == 475 (sum(0..31) - (1 + 7 + 13) = 496 - 21), got " + + s"$sumRi -- a wrongly-claimed native scan would have summed to 0") + } + } + } + + /** + * The write-sink liveness gate above (`rowIndexUnusedAbove`'s `childOutputLeak` check in + * `DeltaScanSupport`) covers a DSv2 write sink STRUCTURALLY -- any one-child node with an empty + * output that doesn't re-expose a tainted attribute, which is exactly the shape + * `AppendDataExec`/`OverwriteByExpressionExec`/the rest of the `V2TableWriteExec` family take + * -- but the test above only ever exercises the V1 `.write.parquet` command path. + * + * Reaching a genuine DSv2 `AppendDataExec` in this Spark 3.5 setup is itself achievable: a + * table created via the session catalog with `USING parquet` still plans as a V1 + * `InsertIntoHadoopFsRelationCommand` (built-in file-based sources stay on + * `spark.sql.sources.useV1SourceList` by default), but `InMemoryTableCatalog` (from + * `spark-catalyst`'s test-jar, already a test dependency of this module, registered ad hoc + * under a throwaway name exactly as Spark's own DataSourceV2 test suites do) forces a genuine + * V2 write. + * + * What is NOT achievable in this delta-spark 3.3.2 / Spark 3.5.9 combination: composing that + * DSv2 `AppendData` write with a deletion-vector-enabled Delta table as its SOURCE. Both + * `df.writeTo(target).append()` (gluing an already-analyzed `DataFrame` into a fresh V2 + * command) AND a single `INSERT INTO target SELECT ... FROM delta.\`path\`` statement + * (resolving the read and the V2 write in one analysis pass) hit the identical failure: + * delta-spark's own `PreprocessTableWithDVs` rule requires the source relation's + * `TahoeFileIndex` to be a "pinned" `TahoeLogFileIndex` + * (`ScanWithDeletionVectors$.dvEnabledScanFor`, `PreprocessTableWithDVs.scala:78`), which does + * not hold when that relation sits under a DSv2 `AppendData` command's analysis -- confirmed + * unrelated to catalog choice or DataFrame-vs-SQL construction. This is a delta-spark + * limitation on how a DV read may be composed, not a Comet regression, so this test pins it + * down as an expected, named failure rather than silently having no DSv2 coverage at all: the + * write-sink liveness gate's DSv2 coverage for a DV row-index source remains V1-only (see the + * test above), which this test documents by construction. + */ + test( + "deletion vectors: a genuine DSv2 AppendData write cannot compose with a DV-enabled Delta " + + "source in this Spark/Delta combination (delta-spark's own pinned-snapshot requirement, " + + "not a Comet regression) -- documents why DSv2 write-sink coverage stays V1-only above") { + val catalogName = "cometDeltaRowIndexV2Cat" + withSQLConf( + s"spark.sql.catalog.$catalogName" -> + "org.apache.spark.sql.connector.catalog.InMemoryTableCatalog") { + withTempPath { srcDir => + val src = srcDir.getAbsolutePath + spark + .range(32) + .coalesce(1) + .write + .format("delta") + .option("delta.enableDeletionVectors", "true") + .save(src) + spark.sql(s"DELETE FROM delta.`$src` WHERE id IN (1, 7, 13)").collect() + + val targetTable = s"$catalogName.ns.row_index_sink" + spark.sql(s"CREATE TABLE $targetTable (id BIGINT, ri BIGINT) USING foo") + + val ex = intercept[IllegalArgumentException] { + spark.sql( + s"INSERT INTO $targetTable SELECT id, _metadata.row_index AS ri FROM delta.`$src`") + } + assert( + ex.getMessage.contains("non-pinned"), + "expected delta-spark's pinned-TahoeLogFileIndex requirement to be the failure " + + s"(if this now succeeds, DSv2 coverage for the DV row-index write-sink scenario " + + s"may finally be achievable and this test should be replaced with a real one): " + + ex.getMessage) + } + } + } + + /** + * Single-file (ids 0-4) deletion-vector table with one id deleted, used by the UnionExec + * row-index liveness tests below: UnionExec's output takes its expression IDs positionally from + * its FIRST child, so a live `_metadata.row_index` alias in a later branch is invisible to a + * taint analysis that only follows `ProjectExec` aliases. A small fixed fixture keeps the + * expected surviving row_index values easy to hand-verify. + */ + private def createSmallDvTable(path: String, deleteId: Long): Unit = { + spark.range(0, 5).selectExpr("id").coalesce(1).write.format("delta").save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"DELETE FROM delta.`$path` WHERE id = $deleteId") + } + + test( + "deletion vectors: row_index live through UNION ALL declines both branches " + + "with correct SUM") { + withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { + withTempPath { dir1 => + withTempPath { dir2 => + val t1 = dir1.getAbsolutePath + val t2 = dir2.getAbsolutePath + // t1: ids 0,1,3,4 survive (id 2 deleted); t2: ids 0,1,2,4 survive (id 3 deleted). + createSmallDvTable(t1, deleteId = 2) + createSmallDvTable(t2, deleteId = 3) + + def query: DataFrame = { + val left = + spark.read.format("delta").load(t1).selectExpr("id", "_metadata.row_index as ri") + val right = + spark.read.format("delta").load(t2).selectExpr("id", "_metadata.row_index as ri") + left.union(right) + } + + checkSparkAnswer(query) + val df = query + val rows = df.collect() + assert(rows.length == 8, s"expected 8 surviving rows, got ${rows.length}") + assert( + deltaNativeScans(df).isEmpty, + "row_index live via a union's positional output remap must decline both branches") + // row_index equals id for every surviving row in this single-file, insertion-ordered + // fixture, so summing the real (uncorrupted) row indexes is equivalent to summing ids: + // t1 (0+1+3+4=8) + t2 (0+1+2+4=7) = 15. A wrongly-claimed branch would instead + // contribute a constant 0 per row, which this exact total rules out. + val sum = rows.map(_.getLong(1)).sum + assert(sum == 15L, s"expected SUM(ri) == 15, got $sum") + } + } + } + } + + test( + "deletion vectors: row_index live only in the second UNION ALL branch declines " + + "only that branch") { + withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { + withTempPath { dir1 => + withTempPath { dir2 => + val t1 = dir1.getAbsolutePath + val t2 = dir2.getAbsolutePath + createSmallDvTable(t1, deleteId = 2) + createSmallDvTable(t2, deleteId = 3) + + def query: DataFrame = { + // Branch 1's "ri" is a constant, never derived from its own row_index; branch 2's + // "ri" is the real _metadata.row_index. UnionExec's output reuses branch 1's + // expression ID for the "ri" column, so only branch 2's scan should decline. + val left = + spark.read.format("delta").load(t1).selectExpr("id", "CAST(-1 AS BIGINT) as ri") + val right = + spark.read.format("delta").load(t2).selectExpr("id", "_metadata.row_index as ri") + left.union(right) + } + + checkSparkAnswer(query) + val df = query + val rows = df.collect() + assert(rows.length == 8, s"expected 8 surviving rows, got ${rows.length}") + val fromT1 = rows.filter(_.getLong(1) == -1L) + val fromT2 = rows.filter(_.getLong(1) != -1L) + assert(fromT1.length == 4, s"expected 4 rows from t1, got ${fromT1.length}") + assert(fromT2.length == 4, s"expected 4 rows from t2, got ${fromT2.length}") + // Real row_index equals id in this fixture; a wrongly-claimed branch 2 would instead + // report a constant 0 for every row, which this per-row check rules out. + assert( + fromT2.forall(r => r.getLong(0) == r.getLong(1)), + s"expected t2's ri to equal id, got: ${fromT2.mkString(", ")}") + val scans = deltaNativeScans(df) + assert( + scans.size == 1, + s"expected exactly branch 1 (t1) to claim natively, got ${scans.size} native scans") + } + } + } + } + + test( + "deletion vectors: SUM(row_index) over UNION ALL declines both branches with correct total") { + withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { + withTempPath { dir1 => + withTempPath { dir2 => + val t1 = dir1.getAbsolutePath + val t2 = dir2.getAbsolutePath + createSmallDvTable(t1, deleteId = 2) + createSmallDvTable(t2, deleteId = 3) + + def query: DataFrame = { + val left = + spark.read.format("delta").load(t1).selectExpr("id", "_metadata.row_index as ri") + val right = + spark.read.format("delta").load(t2).selectExpr("id", "_metadata.row_index as ri") + left.union(right).selectExpr("sum(ri) as total") + } + + checkSparkAnswer(query) + val total = query.collect()(0).getLong(0) + assert(total == 15L, s"expected SUM(ri) == 15, got $total") + assert( + deltaNativeScans(query).isEmpty, + "row_index live via an aggregate over a union must decline both branches") + } + } + } + } + + test( + "deletion vectors: UNION ALL without _metadata still claims both branches natively " + + "(anti-regression)") { + withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { + withTempPath { dir1 => + withTempPath { dir2 => + val t1 = dir1.getAbsolutePath + val t2 = dir2.getAbsolutePath + createSmallDvTable(t1, deleteId = 2) + createSmallDvTable(t2, deleteId = 3) + + def query: DataFrame = { + val left = spark.read.format("delta").load(t1).selectExpr("id") + val right = spark.read.format("delta").load(t2).selectExpr("id") + left.union(right) + } + + checkSparkAnswer(query) + val df = query + val ids = df.collect().map(_.getLong(0)).sorted + assert( + ids.sameElements(Array(0L, 0L, 1L, 1L, 2L, 3L, 4L, 4L)), + s"unexpected surviving ids: ${ids.mkString(", ")}") + val scans = deltaNativeScans(df) + assert( + scans.size == 2, + "a DV union without _metadata must still claim both branches natively " + + s"(the row-index column is dead in both), got ${scans.size} native scans") + } + } + } + } + + test("deletion vectors: inner join between two DV tables claims both scans natively") { + // Positive coverage for the generic multi-child safety net (DeltaScanSupport.scala's + // multiChildLeak check): a plain join carries no row-index taint at all, so the safety net + // must not mistake a join's normal attribute passthrough for a leak and fall both sides back + // to Spark. + withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") { + withTempPath { dir1 => + withTempPath { dir2 => + val t1 = dir1.getAbsolutePath + val t2 = dir2.getAbsolutePath + // t1 survives ids {0,1,3,4} (id 2 deleted); t2 survives ids {0,1,2,4} (id 3 deleted). + createSmallDvTable(t1, deleteId = 2) + createSmallDvTable(t2, deleteId = 3) + + def query: DataFrame = { + val left = spark.read.format("delta").load(t1).withColumnRenamed("id", "lid") + val right = spark.read.format("delta").load(t2).withColumnRenamed("id", "rid") + left.join(right, col("lid") === col("rid")) + } + + checkSparkAnswer(query) + val df = query + val rows = df.collect() + val ids = rows.map(_.getLong(0)).sorted + // Only ids surviving in BOTH tables' deletion vectors should match. + assert( + ids.sameElements(Array(0L, 1L, 4L)), + s"expected join to match surviving ids {0,1,4}, got: ${ids.mkString(", ")}") + assert( + rows.forall(r => r.getLong(0) == r.getLong(1)), + "join key mismatch in result rows") + val scans = deltaNativeScans(df) + assert( + scans.size == 2, + "a DV-backed join with no row-index consumption must claim both sides natively, " + + s"got ${scans.size} native scans") + } + } + } + } + + private def enableColumnMapping(path: String): Unit = + spark.sql(s"""ALTER TABLE delta.`$path` SET TBLPROPERTIES ( + | 'delta.minReaderVersion' = '2', + | 'delta.minWriterVersion' = '5', + | 'delta.columnMapping.mode' = 'name')""".stripMargin) + + test("column mapping: renamed column reads natively across old and new files") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN v TO w") + // Files written after the rename carry the same physical name. + spark + .range(100, 200) + .selectExpr("id", "id * 2 as w") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path).filter(col("w") > 100) + checkDeltaNativeScanAnswer(df) + assert(spark.read.format("delta").load(path).count() == 200) + } + } + + test("column mapping: dropped and re-added column name reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` DROP COLUMN v") + spark.sql(s"ALTER TABLE delta.`$path` ADD COLUMN v LONG") + spark + .range(100, 200) + .selectExpr("id", "id * 3 as v") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + // Old files must yield NULL for the re-added v (different physical column). + assert(df.filter(col("id") < 100).filter(col("v").isNotNull).count() == 0) + assert(df.filter(col("id") >= 100).filter(col("v").isNull).count() == 0) + } + } + + test("column mapping: partitioned table reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 500) + .selectExpr("id", "id % 5 as p") + .write + .format("delta") + .partitionBy("p") + .save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN p TO part") + + val df = spark.read.format("delta").load(path).filter(col("part") === 3) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 100) + } + } + + test( + "column mapping: rename history colliding logical partition name with physical data " + + "name reads correctly") { + withTempPath { dir => + val path = dir.getAbsolutePath + // a->b then p->a leaves the LOGICAL name "a" bound to the partition column while a + // DIFFERENT physical data column (originally "a", now logically "b") retains physical + // name "a". Passing the partition schema's logical names to the native side collides + // with that retained physical data name and lets DataFusion's name-based partition + // rewrite replace the data projection with the partition constant. + spark.sql(s"CREATE TABLE delta.`$path` (a BIGINT, p BIGINT) USING delta PARTITIONED BY (p)") + enableColumnMapping(path) + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 100), (2, 100)") + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN a TO b") + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN p TO a") + + val df = spark.sql(s"SELECT b, a FROM delta.`$path`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().map(r => (r.getLong(0), r.getLong(1))).sorted + assert( + rows.sameElements(Array((1L, 100L), (2L, 100L))), + s"expected (1,100),(2,100) but got ${rows.mkString(", ")}") + } + } + + test( + "column mapping: rename history colliding partition name reads correctly with " + + "deletion vectors") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (a BIGINT, p BIGINT) USING delta PARTITIONED BY (p)") + enableColumnMapping(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 100), (2, 100)") + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN a TO b") + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN p TO a") + spark.sql(s"DELETE FROM delta.`$path` WHERE b = 1") + + val df = spark.sql(s"SELECT b, a FROM delta.`$path`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().map(r => (r.getLong(0), r.getLong(1))).sorted + assert( + rows.sameElements(Array((2L, 100L))), + s"expected (2,100) but got ${rows.mkString(", ")}") + } + } + + test("column mapping: renamed partition column without collision reads correctly (control)") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (a BIGINT, p BIGINT) USING delta PARTITIONED BY (p)") + enableColumnMapping(path) + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 100), (2, 100)") + // Rename ONLY the partition column, to a name that collides with nothing: no physical + // data column is named "q", so this must not be affected by the collision above. + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN p TO q") + + val df = spark.sql(s"SELECT a, q FROM delta.`$path`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().map(r => (r.getLong(0), r.getLong(1))).sorted + assert( + rows.sameElements(Array((1L, 100L), (2L, 100L))), + s"expected (1,100),(2,100) but got ${rows.mkString(", ")}") + } + } + + test("column mapping: combined with deletion vectors") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 1000).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + enableColumnMapping(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN v TO w") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 4 = 0") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 750) + } + } + + test("column mapping: to_json on a nested struct matches Spark's field names") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 10) + .selectExpr("id", "named_struct('a', id) as s") + .write + .format("delta") + .save(path) + enableColumnMapping(path) + // Renaming the NESTED field (not the outer column) is what diverges the physical name + // ("a", preserved on rename) from the logical name ("b") for a struct field below the + // top level — the shape that leaks physical names into to_json's output. + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN s.a TO b") + + withSQLConf(CometConf.getExprAllowIncompatConfigKey(classOf[StructsToJson]) -> "true") { + val df = spark.read.format("delta").load(path).select(to_json(col("s"))) + checkSparkAnswer(df) + assert( + deltaNativeScans(df).isEmpty, + "column mapping with nested struct fields must fall back to Spark") + } + } + } + + test("decline: column mapping with nested struct columns falls back to Spark") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 200) + .selectExpr( + "id", + "named_struct('a', id, 'b', cast(id as string)) as st", + "array(id, id * 2) as arr", + "map(cast(id as string), id) as mp") + .write + .format("delta") + .save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN st TO st2") + spark + .range(200, 300) + .selectExpr( + "id", + "named_struct('a', id, 'b', cast(id as string)) as st2", + "array(id, id * 2) as arr", + "map(cast(id as string), id) as mp") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path).selectExpr("id", "st2.a", "arr", "mp") + checkSparkAnswer(df) + assert(df.count() == 300) + assert( + deltaNativeScans(df).isEmpty, + "column mapping with nested struct fields must fall back to Spark") + } + } + + test("decline: column mapping with structs nested in arrays and maps falls back to Spark") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 50) + .selectExpr( + "id", + "array(named_struct('a', id, 'b', cast(id as string))) as arrOfStruct", + "map(cast(id as string), named_struct('a', id)) as mapOfStruct") + .write + .format("delta") + .save(path) + enableColumnMapping(path) + + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert( + deltaNativeScans(df).isEmpty, + "column mapping with structs nested in arrays/maps must fall back to Spark") + } + } + + test("column mapping: top-level scalars and array-of-primitives still claim natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 200) + .selectExpr("id", "cast(id as string) as v", "array(id, id * 2) as arr") + .write + .format("delta") + .save(path) + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` RENAME COLUMN v TO w") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 200) + } + } + + test("decline: column mapping id mode falls back to Spark with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"""CREATE TABLE delta.`$path` (id LONG, v LONG) USING delta + |TBLPROPERTIES ('delta.columnMapping.mode' = 'id')""".stripMargin) + spark + .range(0, 100) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty, "id-mode column mapping must decline") + } + } + + test("delete without deletion vectors rewrites files and still reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 1000).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + // DVs are off by default, so DELETE rewrites files; result is still a plain table. + spark.sql(s"DELETE FROM delta.`$path` WHERE id < 100") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 900) + } + } + + test("dynamic partition pruning via broadcast join prunes delta partitions") { + withSQLConf( + SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true", + SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "true", + SQLConf.SHUFFLE_PARTITIONS.key -> "20", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "100m") { + withTempPath { factDir => + withTempPath { dimDir => + val factPath = factDir.getAbsolutePath + val dimPath = dimDir.getAbsolutePath + spark + .range(0, 2000) + .selectExpr("id", "id % 10 as p") + .write + .format("delta") + .partitionBy("p") + .save(factPath) + // Unfiltered on disk: the selective predicate below is a query-time filter, which + // is what gives Spark's DynamicPartitionPruning rule a subquery to inject in the + // first place. Filtering before the write (the old shape of this test) leaves no + // predicate in the query for DPP to see, so the assertions below never fired. + spark + .range(0, 10) + .selectExpr("id as key", "id % 10 as dp") + .write + .format("delta") + .save(dimPath) + + def query = { + val fact = spark.read.format("delta").load(factPath) + val dim = spark.read.format("delta").load(dimPath) + // only partitions 0 and 1 survive the join + fact.join(dim, fact("p") === dim("dp")).filter(dim("key") < 2) + } + + checkSparkAnswer(query) + + val df = query + val rows = df.collect() + assert(rows.length == 400) // 2 partitions x 200 rows + val scans = deltaNativeScans(df) + assert( + scans.nonEmpty, + s"expected native delta scans:\n${df.queryExecution.executedPlan}") + + val deltaScans = scans.collect { case s: CometDeltaNativeScanExec => s } + assert( + deltaScans.exists(_.runtimeFilters.exists(_.isInstanceOf[DynamicPruningExpression])), + "expected a DynamicPruningExpression in a CometDeltaNativeScanExec's " + + s"runtimeFilters:\n${df.queryExecution.executedPlan}") + + // The fact-side scan must have read fewer files than the table holds (DPP pruning). + val factScan = scans.maxBy(_.metrics.get("staticFilesNum").map(_.value).getOrElse(0L)) + val staticFiles = factScan.metrics.get("staticFilesNum").map(_.value).getOrElse(0L) + val readFiles = factScan.metrics.get("numFiles").map(_.value).getOrElse(0L) + assert(staticFiles > 0, "expected the staticFilesNum metric to be populated") + assert( + readFiles < staticFiles, + s"expected DPP pruning: read $readFiles of $staticFiles files") + } + } + } + } + + test("union all with DPP join and coalescible shuffle survives AQE partitioning checks") { + // The crash shape: a DPP join in one UNION ALL branch and a coalescible shuffle (the + // GROUP BY) in the other. Spark's AQE plan validation walks every operator's + // outputPartitioning, including the DPP branch's scan, before + // CometPlanAdaptiveDynamicPruningFilters has rewritten the placeholder subquery -- this + // is the ordering that reproduced the crash. + withSQLConf( + SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true", + SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "true", + SQLConf.SHUFFLE_PARTITIONS.key -> "20", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "100m") { + withTempPath { factDir => + withTempPath { dimDir => + withTempPath { otherDir => + val factPath = factDir.getAbsolutePath + val dimPath = dimDir.getAbsolutePath + val otherPath = otherDir.getAbsolutePath + + spark + .range(0, 2000) + .selectExpr("id", "id % 10 as p") + .write + .format("delta") + .partitionBy("p") + .save(factPath) + spark + .range(0, 10) + .selectExpr("id as key", "id % 10 as dp", "id as sel") + .write + .format("delta") + .save(dimPath) + spark + .range(0, 500) + .selectExpr("id", "id % 10 as p") + .write + .format("delta") + .partitionBy("p") + .save(otherPath) + + spark.read.format("delta").load(factPath).createOrReplaceTempView("r43Fact") + spark.read.format("delta").load(dimPath).createOrReplaceTempView("r43Dim") + spark.read.format("delta").load(otherPath).createOrReplaceTempView("r43Other") + + def query = + spark.sql(""" + |SELECT f.p, f.id FROM r43Fact f JOIN r43Dim d ON f.p = d.dp WHERE d.sel < 2 + |UNION ALL + |SELECT p, CAST(count(*) AS LONG) AS id FROM r43Other GROUP BY p + |""".stripMargin) + + try { + checkSparkAnswer(query) + } catch { + case e: Throwable => + if (e.getMessage != null && + e.getMessage.contains("does not support the execute() code path")) { + throw new AssertionError( + "AQE inspected outputPartitioning on an unresolved adaptive DPP " + + "placeholder -- this is the crash this test guards against", + e) + } + throw e + } + + val df = query + df.collect() + // Best-effort: this UNION ALL shape need not always route through the native + // Delta scan, but if it does, it must have survived AQE's partitioning checks + // above without throwing. Observed to vary run-to-run on this build (Spark 3.5.9 + // / Delta 3.3.2), so this is logged rather than asserted -- answer correctness is + // already verified by checkSparkAnswer above. + val scans = deltaNativeScans(df) + if (scans.isEmpty) { + logInfo( + "union all with DPP join and coalescible shuffle: no CometDeltaNativeScanExec " + + "claimed this query on this build; answer correctness already verified above") + } else { + logInfo( + s"union all with DPP join and coalescible shuffle: ${scans.length} " + + "CometDeltaNativeScanExec node(s) claimed this query; answer correctness " + + "already verified above") + } + } + } + } + } + } + + test("scalar subquery in a partition filter does not force partitioning during AQE checks") { + // Crash shape: a scalar subquery used directly as + // a partition filter, e.g. `p = (SELECT max(p) FROM dim ...)`, references only the + // partition column, so it lands in runtimeFilters rather than dataFilters. + // ValidateRequirements walks outputPartitioning for every operator, including this scan, + // before the subquery has executed -- forcing perPartitionData at that point evaluates the + // still-unresolved ScalarSubquery and throws "has not finished". + withSQLConf( + SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true", + SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "true", + SQLConf.SHUFFLE_PARTITIONS.key -> "20", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "100m") { + withTempPath { factDir => + withTempPath { dimDir => + withTempPath { otherDir => + val factPath = factDir.getAbsolutePath + val dimPath = dimDir.getAbsolutePath + val otherPath = otherDir.getAbsolutePath + + spark + .range(0, 2000) + .selectExpr("id", "id % 10 as p") + .write + .format("delta") + .partitionBy("p") + .save(factPath) + spark + .range(0, 10) + .selectExpr("id as p", "case when id in (0, 3) then 'yes' else 'no' end as country") + .write + .format("parquet") + .save(dimPath) + spark + .range(0, 500) + .selectExpr("id", "id % 10 as p") + .write + .format("parquet") + .save(otherPath) + + spark.read.format("delta").load(factPath).createOrReplaceTempView("r45Fact") + spark.read.format("parquet").load(dimPath).createOrReplaceTempView("r45Dim") + spark.read.format("parquet").load(otherPath).createOrReplaceTempView("r45Other") + + def query = + spark.sql(""" + |SELECT id, p FROM r45Fact + |WHERE p = (SELECT max(p) FROM r45Dim WHERE country = 'yes') + |UNION ALL + |SELECT cast(count(*) AS int) AS id, p FROM r45Other GROUP BY p + |""".stripMargin) + + try { + checkSparkAnswer(query) + } catch { + case e: Throwable => + if (e.getMessage != null && e.getMessage.contains("has not finished")) { + throw new AssertionError( + "AQE ValidateRequirements forced outputPartitioning to evaluate an " + + "unresolved scalar partition-filter subquery -- this is the crash this " + + "test guards against", + e) + } + throw e + } + + val df = query + df.collect() + // Best-effort, mirroring the DPP union-all test above: this shape need not always + // route through the native Delta scan, but if it does, it must have survived AQE's + // partitioning checks above without throwing. Answer correctness is already + // verified by checkSparkAnswer above. + val scans = deltaNativeScans(df) + if (scans.isEmpty) { + logInfo( + "scalar subquery partition filter: no CometDeltaNativeScanExec claimed this " + + "query on this build; answer correctness already verified above") + } else { + logInfo( + s"scalar subquery partition filter: ${scans.length} CometDeltaNativeScanExec " + + "node(s) claimed this query; answer correctness already verified above") + } + } + } + } + } + } + + test( + "aggregate over a scalar-subquery partition filter executes under a fused native " + + "parent") { + // Crash shape: a scalar subquery used as a partition filter (`p = (SELECT max(p) ...)`) + // lands in runtimeFilters. Once execution resolves it, a native aggregate sitting + // directly on top of the scan (no intervening exchange) reads the scan's + // outputPartitioning to size its own execution context; that getter must report the + // real post-pruning partition count, not a value stuck from before resolution. + withTempPath { factDir => + withTempPath { thresholdsDir => + val factPath = factDir.getAbsolutePath + val thresholdsPath = thresholdsDir.getAbsolutePath + + spark + .range(0, 2000) + .selectExpr("id", "id % 10 as p") + .write + .format("delta") + .partitionBy("p") + .save(factPath) + + spark + .sql("SELECT CAST(7 AS BIGINT) AS p") + .write + .format("delta") + .save(thresholdsPath) + + def query = + spark.sql( + s"SELECT sum(id) AS total FROM delta.`$factPath` " + + s"WHERE p = (SELECT max(p) FROM delta.`$thresholdsPath`)") + + checkSparkAnswer(query) + + val df = query + try { + df.collect() + } catch { + case e: Throwable => + if (e.getMessage != null && e.getMessage.contains("All per-partition arrays")) { + throw new AssertionError( + "a fused native aggregate above the scan read a stale zero " + + "outputPartitioning after the scalar-subquery partition filter had " + + "already resolved", + e) + } + throw e + } + + val scans = deltaNativeScans(df) + assert( + scans.nonEmpty, + s"expected CometDeltaNativeScanExec in plan:\n${df.queryExecution.executedPlan}") + assert( + collectByName(df.queryExecution.executedPlan, "CometHashAggregateExec").nonEmpty, + "expected a fused native aggregate parent above the scan in plan:\n" + + s"${df.queryExecution.executedPlan}") + } + } + } + + test( + "metrics evaluates without throwing when runtimeFilters holds a ScalarSubquery " + + "placeholder (pins the invariant documented on CometDeltaNativeScanExec.scanHelper: " + + "AQE's UI plan-walk calls .metrics on every node mid-planning, sometimes before a " + + "DPP/scalar-subquery filter has resolved, and this must never throw)") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 50).write.format("delta").save(path) + + val scan = deltaNativeScans(spark.read.format("delta").load(path)).collect { + case s: CometDeltaNativeScanExec => s + }.head + + // A real execution.ScalarSubquery instance (the exec-time class CometDeltaNativeScanExec + // itself matches against in hasUnevaluableSubqueryFilter), wrapping a never-executed + // SubqueryExec -- deliberately never run, so this is unresolved exactly as it would be + // when AQE's mid-planning walk reaches this node ahead of subquery execution. + val innerPlan = spark.range(1).selectExpr("id AS c").queryExecution.executedPlan + val unresolvedScalarSubquery = + ScalarSubquery( + SubqueryExec("metrics-guard-subquery", innerPlan), + NamedExpression.newExprId) + + val scanWithSubquery = scan.copy(runtimeFilters = Seq(unresolvedScalarSubquery)) + val metrics = scanWithSubquery.metrics + assert( + metrics.nonEmpty, + "expected CometDeltaNativeScanExec.metrics to populate the native scan metric node " + + "even with an unresolved ScalarSubquery in runtimeFilters") + } + } + + test("input_file_name falls back to Spark with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id").write.format("delta").save(path) + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", "input_file_name() as f") + checkSparkAnswer(df.selectExpr("id", "length(f) > 0")) + assert(deltaNativeScans(df).isEmpty, "input_file_name must decline") + } + } + + test("self-join of the same delta table keeps scans distinct") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id", "id % 5 as k").write.format("delta").save(path) + + def query = { + val left = spark.read.format("delta").load(path).filter(col("id") < 50) + val right = spark.read.format("delta").load(path).filter(col("id") >= 50) + left.as("l").join(right.as("r"), col("l.k") === col("r.k")) + } + checkSparkAnswer(query) + + val df = query + df.collect() + assert(deltaNativeScans(df).size == 2) + } + } + + test("schema evolution: added column yields nulls for old files, natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id").write.format("delta").save(path) + spark.sql(s"ALTER TABLE delta.`$path` ADD COLUMN v LONG") + spark + .range(100, 200) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.filter(col("id") < 100).filter(col("v").isNotNull).count() == 0) + assert(df.filter(col("id") >= 100).filter(col("v").isNull).count() == 0) + } + } + + test("schema evolution: column default (Delta two-step) reads correctly") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id").write.format("delta").save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES " + + "('delta.feature.allowColumnDefaults' = 'supported')") + // Delta only allows defaults via add-then-set (applies to FUTURE inserts; old files + // read as NULL -- unlike Spark's existence defaults). + spark.sql(s"ALTER TABLE delta.`$path` ADD COLUMN v LONG") + spark.sql(s"ALTER TABLE delta.`$path` ALTER COLUMN v SET DEFAULT 42") + spark.sql(s"INSERT INTO delta.`$path` (id) VALUES (100), (101)") + + val df = spark.read.format("delta").load(path) + // Whether claimed or declined, results must match Spark exactly. + checkSparkAnswer(df) + assert(df.count() == 102) + assert(df.filter(col("v") === 42).count() == 2) + assert(df.filter(col("id") < 100).filter(col("v").isNotNull).count() == 0) + } + } + + test("legacy INT96 timestamps read natively with correct values") { + withTempPath { dir => + val path = dir.getAbsolutePath + withSQLConf("spark.sql.parquet.outputTimestampType" -> "INT96") { + spark + .range(0, 100) + .selectExpr("id", "timestamp_seconds(1600000000 + id * 3600) as ts") + .write + .format("delta") + .save(path) + } + val df = spark.read.format("delta").load(path).filter(col("id") < 50) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 50) + } + } + + test("decline: type widening feature falls back with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + // Delta 3.3's widening preview supports byte/short -> int. + spark.sql(s"""CREATE TABLE delta.`$path` (id SMALLINT) USING delta + |TBLPROPERTIES ('delta.enableTypeWidening' = 'true')""".stripMargin) + spark + .range(0, 100) + .selectExpr("cast(id as smallint) as id") + .write + .format("delta") + .mode("append") + .save(path) + spark.sql(s"ALTER TABLE delta.`$path` ALTER COLUMN id TYPE INT") + spark + .range(100, 200) + .selectExpr("cast(id as int) as id") + .write + .format("delta") + .mode("append") + .save(path) + + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(df.count() == 200) + } + } + + test( + "decline: SMALLINT column falls back with correct results when unsigned-small-int " + + "safety check is enabled") { + // Regression: the Delta claim path must + // run the same CometScanTypeChecker core's own scan does, so the default-on + // COMET_PARQUET_UNSIGNED_SMALL_INT_CHECK safety fallback still applies to a native Delta + // scan. Without it, an out-of-range/malformed UINT_8 payload stored under a ShortType + // column could be claimed and silently decoded with the wrong values. + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (id INT, s SMALLINT) USING delta") + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 10), (2, 20), (3, 30)") + + // CometTestBase flips this conf off by default so the rest of the suite can exercise + // ShortType columns against Comet's native scan; put it back to its real production + // default so this gate actually declines (mirrors the same pattern in + // DeltaScanContribSuite for the vectorized-reader conf). + withSQLConf(CometConf.COMET_PARQUET_UNSIGNED_SMALL_INT_CHECK.key -> "true") { + val df = spark.read.format("delta").load(path) + checkSparkAnswerAndFallbackReason( + df, + CometConf.COMET_PARQUET_UNSIGNED_SMALL_INT_CHECK.key) + assert(deltaNativeScans(df).isEmpty) + } + } + } + + test("claims SMALLINT column natively when unsigned-small-int safety check is disabled") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (id INT, s SMALLINT) USING delta") + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 10), (2, 20), (3, 30)") + + withSQLConf(CometConf.COMET_PARQUET_UNSIGNED_SMALL_INT_CHECK.key -> "false") { + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + } + } + } + + test("checkpointed delta log reads natively") { + withTempPath { dir => + val path = dir.getAbsolutePath + // Force a checkpoint by exceeding the default interval via many commits. + spark.sql(s"""CREATE TABLE delta.`$path` (id LONG, v LONG) USING delta + |TBLPROPERTIES ('delta.checkpointInterval' = '3')""".stripMargin) + for (i <- 0 until 5) { + spark + .range(i * 10, (i + 1) * 10) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(path) + } + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 50) + } + } + + test( + "decline: shallow clone with a supported local root but viewfs-scheme selected files " + + "falls back to Spark") { + // The shape this decline guards against: a Delta shallow clone whose table ROOT is a natively + // supported scheme (here, local `file:`) but whose SELECTED data files still resolve + // through the shallow clone's ORIGINAL, natively-unsupported location (here, `viewfs:`, + // mounted transparently onto the local filesystem so the on-disk bytes are real and the + // query's results are actually checkable). The rootPaths-only gate this task extends cannot + // see this: it only ever inspects the clone's own (supported) root. + val cluster = "cometDeltaViewfsGate" + // Hadoop's mounttable is plain Configuration, not SQLConf: mutate the session's shared + // hadoopConfiguration directly (mirroring withSQLConf's set-then-restore shape) rather than + // withSQLConf, which only round-trips actual SQLConf entries. + val hadoopConf = spark.sparkContext.hadoopConfiguration + val linkFallbackKey = s"fs.viewfs.mounttable.$cluster.linkFallback" + val priorLinkFallback = Option(hadoopConf.get(linkFallbackKey)) + hadoopConf.set(linkFallbackKey, "file:///") + try { + withTempPath { sourceDir => + withTempPath { cloneDir => + val sourcePath = sourceDir.getAbsolutePath + val clonePath = cloneDir.getAbsolutePath + val sourceViewfsPath = s"viewfs://$cluster$sourcePath" + + spark + .range(0, 10) + .write + .format("delta") + .save(sourceViewfsPath) + spark.sql(s"CREATE TABLE delta.`$clonePath` SHALLOW CLONE delta.`$sourceViewfsPath`") + // Append local (file:) data on top of the clone's inherited viewfs-scheme files: the + // scan's selected data files now span both an unsupported scheme (viewfs) AND multiple + // object-store authorities (file: carries none, viewfs://cometDeltaViewfsGate carries + // one), the same shape DeltaScanContribSuite's + // "unsupportedSelectedSchemeReason declines a mixed file:+viewfs selection" unit test + // pins directly against declineReason's gate ordering (DeltaScanSupport.scala): the + // scheme gate runs before multiStoreReason, so the fallback reason below must still + // name viewfs, never "spans multiple object stores". This confirms that ordering + // end to end through declineReason, not merely at the unit level. + spark.range(10, 20).write.format("delta").mode("append").save(clonePath) + + val df = spark.read.format("delta").load(clonePath) + assert( + deltaNativeScans(df).isEmpty, + s"Expected no native Delta scan for a viewfs-selected-file clone:\n" + + s"${df.queryExecution.executedPlan}") + checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan does not support selected data file or deletion vector " + + "filesystem scheme(s) viewfs") + } + } + } finally { + priorLinkFallback match { + case Some(v) => hadoopConf.set(linkFallbackKey, v) + case None => hadoopConf.unset(linkFallbackKey) + } + } + } + + test("change data feed read never engages the native Delta scan, with correct results") { + // A batch readChangeFeed() query never reaches DeltaScanSupport.declineReason's own + // isCDCRead check at all: CDCReader wraps its answer in a DeltaCDFRelation whose buildScan + // executes its internal (possibly DeltaParquetFileFormat-backed) plan via queryExecution's + // RDD lineage directly, so the physical plan Spark and Comet's extensions ultimately see for + // this query is a single, opaque RowDataSourceScanExec, never a FileSourceScanExec + // DeltaScanSupport.isDeltaScan could recognize. This still pins the outcome that matters: + // Change Data Feed reads are never claimed by the native Delta scan and stay correct. + withTempPath { dir => + val path = dir.getAbsolutePath + // Change Data Feed must be enabled from the table's first version: CDC reads validate + // that change data was actually recorded for every version in the requested range. + spark.sql(s"""CREATE TABLE delta.`$path` (id LONG, v LONG) USING delta + |TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true')""".stripMargin) + spark.sql(s"INSERT INTO delta.`$path` SELECT id, id * 2 FROM range(0, 100)") + spark.sql(s"UPDATE delta.`$path` SET v = -1 WHERE id < 10") + + val df = spark.read + .format("delta") + .option("readChangeFeed", "true") + .option("startingVersion", 0) + .load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty) + assert(df.count() > 0) + } + } + + test("reader features: TIMESTAMP_NTZ column claims natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (id LONG, ts TIMESTAMP_NTZ) USING delta") + spark.sql( + s"INSERT INTO delta.`$path` VALUES " + + "(1, CAST('2021-01-01 00:00:00' AS TIMESTAMP_NTZ)), " + + "(2, CAST('2022-06-15 12:30:00' AS TIMESTAMP_NTZ))") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 2) + } + } + + test("reader features: v2Checkpoint table claims natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"""CREATE TABLE delta.`$path` (id LONG, v LONG) USING delta + |TBLPROPERTIES ( + | 'delta.checkpointPolicy' = 'v2', + | 'delta.checkpointInterval' = '3')""".stripMargin) + for (i <- 0 until 5) { + spark + .range(i * 10, (i + 1) * 10) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(path) + } + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 50) + } + } + + test( + "reader features: an unsupported reader feature (type widening) declines with the " + + "reader feature(s) reason") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"""CREATE TABLE delta.`$path` (id SMALLINT) USING delta + |TBLPROPERTIES ('delta.enableTypeWidening' = 'true')""".stripMargin) + spark + .range(0, 100) + .selectExpr("cast(id as smallint) as id") + .write + .format("delta") + .mode("append") + .save(path) + spark.sql(s"ALTER TABLE delta.`$path` ALTER COLUMN id TYPE INT") + + val df = spark.read.format("delta").load(path) + checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan does not support reader feature(s) typeWidening") + assert(deltaNativeScans(df).isEmpty) + } + } + + test("_metadata.row_index declines before any deletion vector exists on a DV-enabled table") { + // _metadata.row_index only resolves on a Delta table once deletion-vector support is on + // the protocol (it errors as an unknown field otherwise); once it resolves, Delta always + // routes the read through the DV-application shape (a row-index column with no + // is_row_deleted alongside it), even with zero deletion vectors written yet. This pins that + // the hasRowIndex-without-hasIsRowDeleted gate declines this shape regardless of whether a + // DV has ever actually been written for the file. + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id").write.format("delta").save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + + val df = spark.read + .format("delta") + .load(path) + .selectExpr("id", "_metadata.row_index as ri") + checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan does not support row-index reads outside a deletion-vector scan") + assert(deltaNativeScans(df).isEmpty) + assert(df.count() == 100) + } + } + + test( + "decline: parquet.crypto.factory.class configured declines conservatively even without " + + "actual encryption") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + + val hadoopConf = spark.sparkContext.hadoopConfiguration + val key = "parquet.crypto.factory.class" + val prior = Option(hadoopConf.get(key)) + // A real, resolvable factory that explicitly allows plaintext files: the table itself is + // NOT encrypted, so this exercises Comet's stricter, conservative "decline ALL + // encrypted-parquet configurations" gate without breaking Spark's own read. + hadoopConf.set(key, "org.apache.parquet.crypto.keytools.PropertiesDrivenCryptoFactory") + try { + val df = spark.read.format("delta").load(path) + checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan does not support encrypted parquet") + assert(deltaNativeScans(df).isEmpty) + assert(df.count() == 100) + } finally { + prior match { + case Some(v) => hadoopConf.set(key, v) + case None => hadoopConf.unset(key) + } + } + } + } + + test( + "deletion vectors: a data predicate deleting every row of one file still claims " + + "natively with correct results") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 40) + .selectExpr("id", "id % 2 as p", "id * 2 as v") + .repartition(2, col("p")) + .write + .format("delta") + .partitionBy("p") + .save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + // A data-column predicate (not purely a partition predicate) forces Delta through the + // row-level deletion-vector path rather than a metadata-only partition drop, even though + // every row in partition 1's file happens to match. + spark.sql(s"DELETE FROM delta.`$path` WHERE p = 1 AND v >= 0") + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 20) + assert(df.filter(col("p") === 1).count() == 0) + } + } + + test( + "conf interactions: ANSI, case sensitivity, and disabled DPP leave claim/decline " + + "outcomes unchanged") { + withTempPath { claimDir => + withTempPath { declineDir => + val claimPath = claimDir.getAbsolutePath + val declinePath = declineDir.getAbsolutePath + spark.range(0, 200).selectExpr("id", "id * 2 as v").write.format("delta").save(claimPath) + spark.sql(s"""CREATE TABLE delta.`$declinePath` (id LONG, v LONG) USING delta + |TBLPROPERTIES ('delta.columnMapping.mode' = 'id')""".stripMargin) + spark + .range(0, 200) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(declinePath) + + val confVariants = Seq( + SQLConf.ANSI_ENABLED.key -> "true", + SQLConf.CASE_SENSITIVE.key -> "true", + SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "false") + + confVariants.foreach { case (key, value) => + withSQLConf(key -> value) { + val claimDf = spark.read.format("delta").load(claimPath) + checkDeltaNativeScanAnswer(claimDf) + + val declineDf = spark.read.format("delta").load(declinePath) + checkSparkAnswer(declineDf) + assert( + deltaNativeScans(declineDf).isEmpty, + s"expected id-mode column mapping to still decline under $key=$value") + } + } + } + } + } + + test( + "deletion vectors: maxDeletedRowsPerFile boundary claims when cardinality exactly " + + "equals the limit (gate declines only when the limit is exceeded)") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 1000) + .selectExpr("id", "id * 2 as v") + .coalesce(1) + .write + .format("delta") + .save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + withSQLConf(DeltaScanConf.COMET_DELTA_MAX_DELETED_ROWS_PER_FILE.key -> "500") { + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 500) + } + } + } + + // Both tests below pin caseSensitive=true purely to exercise the exact-match (non-folding) + // path for a non-ASCII column name. Native's case-insensitive name matching reproduces the + // planning JVM's `toLowerCase(Locale.ROOT)` from shipped case tables (see + // `names_equal_ignore_case_java` in schema_adapter.rs and JvmCaseTables.scala), so + // caseSensitive=false would also read these correctly -- there is no decline gate involved + // here to route around. + test("unicode column names round-trip natively with correct results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.sql(s"CREATE TABLE delta.`$path` (id LONG, `名前` STRING) USING delta") + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 'たろう'), (2, 'はなこ')") + + val df = spark.sql(s"SELECT id, `名前` FROM delta.`$path` ORDER BY id") + checkDeltaNativeScanAnswer(df) + val rows = df.collect() + assert(rows.map(_.getString(1)).sameElements(Array("たろう", "はなこ"))) + } + } + } + + test("unicode and space-containing column names round-trip natively under column mapping") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") { + withTempPath { dir => + val path = dir.getAbsolutePath + // A space is one of Parquet's disallowed schema-name characters, so the space-containing + // column can only be added AFTER column mapping (physical names) is already active -- + // creating it inline at CREATE TABLE time fails before column mapping ever takes effect. + spark.sql(s"CREATE TABLE delta.`$path` (id LONG, `名前` STRING) USING delta") + enableColumnMapping(path) + spark.sql(s"ALTER TABLE delta.`$path` ADD COLUMN `a b` LONG") + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 'たろう', 10), (2, 'はなこ', 20)") + + val df = spark.sql(s"SELECT id, `名前`, `a b` FROM delta.`$path` ORDER BY id") + checkDeltaNativeScanAnswer(df) + val rows = df.collect() + assert(rows.map(_.getString(1)).sameElements(Array("たろう", "はなこ"))) + assert(rows.map(_.getLong(2)).sameElements(Array(10L, 20L))) + } + } + } + + /** Fallback reason strings for every declined Delta scan node in `df`'s (executed) plan. */ + private def deltaDeclineReasons(df: DataFrame): Seq[String] = + collectWithSubqueries(stripAQEPlan(df.queryExecution.executedPlan)) { + case f: FileSourceScanExec if DeltaScanSupport.isDeltaScan(f) => f + }.flatMap(f => new ExtendedExplainInfo().getFallbackReasons(f)) + + test( + "a non-ASCII case-insensitive column name claims the native Delta scan with correct " + + "results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_unicode_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // Two plain parquet files whose footers differ only in the case of a non-ASCII letter + // (an ordinary CONVERT-eligible layout: no column mapping, no defaults, no DVs). + // Native's name matcher reproduces this JVM's `toLowerCase(Locale.ROOT)` from + // shipped case tables, which folds 'É'/'é' together just like Spark does. + spark.range(1, 2).select(col("id"), lit(71).as("É")).coalesce(1).write.parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("é")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `É` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`É`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.map(_.getInt(1)).sameElements(Array(71, 72))) + } + } + } + } + + test( + "an ASCII case-insensitive column name still claims the native Delta scan with correct " + + "results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_ascii_case_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // Same shape as above, but the differing-case letter is plain ASCII, which native's + // name matcher (`names_equal_ignore_case_java` in schema_adapter.rs) always matched + // correctly, ASCII being the easy case. + spark.range(1, 2).select(col("id"), lit(71).as("E")).coalesce(1).write.parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("e")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `E` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`E`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.map(_.getInt(1)).sameElements(Array(71, 72))) + } + } + } + } + + test( + "a non-ASCII partition column name still claims the native Delta scan with correct " + + "results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + // Partition values are injected into the output as constants by exact name match, never + // matched against a file's footer schema, so a non-ASCII partition name (data names stay + // plain ASCII here) never goes through native's case-insensitive DATA-column name + // matching (`names_equal_ignore_case_java` in schema_adapter.rs) at all. + spark + .range(0, 20) + .selectExpr("id", "cast(id % 4 as long) as `名前`") + .write + .format("delta") + .partitionBy("名前") + .save(path) + + val df = spark.read.format("delta").load(path).filter(col("名前") === 2) + checkDeltaNativeScanAnswer(df) + assert(df.count() > 0) + } + } + } + + test( + "a non-ASCII physical column name still claims the native Delta scan under column " + + "mapping with case-insensitive reads") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + // The column pre-exists the column-mapping upgrade, so Delta assigns its physical name + // as its current (non-ASCII) name verbatim -- exactly what a converted-then-upgraded + // table keeps. Logical and physical names are identical here, so this was always safe; + // it now also claims natively rather than being caught by a blanket non-ASCII gate. + spark.sql(s"CREATE TABLE delta.`$path` (id LONG, `É` STRING) USING delta") + enableColumnMapping(path) + spark.sql(s"INSERT INTO delta.`$path` VALUES (1, 'a'), (2, 'b')") + + val df = spark.sql(s"SELECT id, `É` FROM delta.`$path` ORDER BY id") + checkDeltaNativeScanAnswer(df) + val rows = df.collect() + assert(rows.map(_.getString(1)).sameElements(Array("a", "b"))) + } + } + } + + test( + "a Kelvin sign physical column name in one file of an otherwise-ASCII CONVERTed table " + + "claims the native Delta scan with correct results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_kelvin_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // An ordinary CONVERT-eligible layout (no column mapping, no defaults, no DVs) + // where the table is declared with a plain ASCII "K" column, but one of its + // underlying Parquet files happens to have been written with a physical column + // literally named U+212A (KELVIN SIGN) -- not decomposable to ASCII by naive + // folding, but a case variant of ASCII 'k'/'K' under Java's `Character` mappings + // (and thus under Spark's `caseSensitive=false` resolution). Nothing on the JVM + // side can see this: the divergent name lives only in the second file's footer. + spark.range(1, 2).select(col("id"), lit(71).as("K")).coalesce(1).write.parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("K")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `K` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`K`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.map(_.getInt(1)).sameElements(Array(71, 72))) + assert( + df.filter(col("K").isNotNull).count() == 2, + "the Kelvin-sign-named file's row must not be nulled out by native") + } + } + } + } + + test( + "a capital-sigma physical column name matches a final-sigma table column with correct " + + "results") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_sigma_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // Java's `String.toLowerCase(Locale.ROOT)` lowers "A1Σ" to "a1ς" (FINAL + // sigma): its Final_Cased context scan runs on word boundaries, and the digit keeps + // "A1Σ" a single word, so the trailing sigma takes the final form. Spark's + // footer matching therefore folds physical "A1Σ" onto a requested "a1ς", + // and the value in that file must be read, not nulled. Nothing on the JVM side can + // see this: the divergent name lives only in the second file's footer. + spark + .range(1, 2) + .select(col("id"), lit(71).as("a1ς")) + .coalesce(1) + .write + .parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("A1Σ")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `a1ς` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`a1ς`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.map(_.getInt(1)).sameElements(Array(71, 72))) + assert( + df.filter(col("a1ς").isNotNull).count() == 2, + "the capital-sigma-named file's row must not be nulled out by native") + } + } + } + } + + test("a capital-sigma physical column name is missing for a non-final-sigma table column") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_sigma_miss_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // The inverse of the test above: "A1Σ" lowers to "a1ς", NOT "a1σ" + // (non-final sigma), so Spark's footer lookup treats a requested "a1σ" as + // MISSING in the capital-sigma file and substitutes NULL. Reading a value there + // (as a naive codepoint-wise fold would) surfaces a row Spark considers absent + // and breaks IS NOT NULL filters. + spark + .range(1, 2) + .select(col("id"), lit(71).as("a1σ")) + .coalesce(1) + .write + .parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("A1Σ")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `a1σ` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`a1σ`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.length == 2) + assert(rows(0).getInt(1) == 71) + assert( + rows(1).isNullAt(1), + "the capital-sigma file's column lowers to final sigma, so a non-final-sigma " + + "requested column must read as missing (NULL) there") + } + } + } + } + + test("a Unicode-version-drift physical column name folds per the running JDK") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + withTempPath { dir => + val path = dir.getAbsolutePath + val table = "comet_drift_" + java.util.UUID.randomUUID().toString.replace("-", "") + withTable(table) { + // U+A7C0 (LATIN CAPITAL LETTER OLD POLISH O) gained its lowercase pairing U+A7C1 + // in Unicode 14, after JDK 17's Unicode snapshot: JDK 17 lowers it to itself + // (no match against a U+A7C1 column), while JDK 21+ lowers it to U+A7C1 (match). + // The expectation is derived from the RUNNING JDK's own toLowerCase, so this test + // is correct on any JDK -- exactly the property the native matcher must mirror, + // since it consumes case tables generated by this same JVM at plan time. + val physicalFolds = + "Ꟁ".toLowerCase(java.util.Locale.ROOT) == "ꟁ" + + spark + .range(1, 2) + .select(col("id"), lit(71).as("ꟁ")) + .coalesce(1) + .write + .parquet(path) + spark + .range(2, 3) + .select(col("id"), lit(72).as("Ꟁ")) + .coalesce(1) + .write + .mode("append") + .parquet(path) + + spark.sql(s"CREATE TABLE $table (id BIGINT, `ꟁ` INT) USING PARQUET LOCATION '$path'") + spark.sql(s"CONVERT TO DELTA $table NO STATISTICS") + + val df = spark.read.format("delta").load(path).selectExpr("id", "`ꟁ`") + checkDeltaNativeScanAnswer(df) + val rows = df.collect().sortBy(_.getLong(0)) + assert(rows.length == 2) + assert(rows(0).getInt(1) == 71) + if (physicalFolds) { + assert( + !rows(1).isNullAt(1) && rows(1).getInt(1) == 72, + "this JDK folds U+A7C0 onto U+A7C1, so the value must be read") + } else { + assert( + rows(1).isNullAt(1), + "this JDK does not fold U+A7C0 onto U+A7C1, so the column must be missing") + } + } + } + } + } + + /** + * Runs `action` under a [[SparkListener]] that captures every `onTaskEnd` input-metrics + * reading, then waits (via `eventually`, since this suite lives outside the `org.apache.spark` + * package and cannot reach the package-private `SparkContext.listenerBus.waitUntilEmpty`) for + * the aggregated recordsRead to reach at least `minRecords` -- the listener bus delivers + * `onTaskEnd` asynchronously, so `action` returning is not enough to guarantee every event has + * already been processed. `minRecords` is a floor rather than an exact target because Delta's + * own transaction-log state reconstruction runs a small auxiliary job reading the commit JSON, + * which legitimately contributes a few extra input records alongside the actual data scan. + * Returns the aggregated (recordsRead, bytesRead) once stable. + */ + private def collectTaskInputMetrics(minRecords: Long)(action: => Unit): (Long, Long) = { + val inputRecords = mutable.ArrayBuffer.empty[Long] + val inputBytes = mutable.ArrayBuffer.empty[Long] + val listener = new SparkListener { + override def onTaskEnd(taskEnd: SparkListenerTaskEnd): Unit = { + val im = taskEnd.taskMetrics.inputMetrics + inputRecords.synchronized { inputRecords += im.recordsRead } + inputBytes.synchronized { inputBytes += im.bytesRead } + } + } + spark.sparkContext.addSparkListener(listener) + try { + action + eventually(timeout(30.seconds), interval(200.milliseconds)) { + val recordsRead = inputRecords.synchronized(inputRecords.sum) + assert( + recordsRead >= minRecords, + s"expected task input recordsRead to reach at least $minRecords, currently $recordsRead") + } + (inputRecords.synchronized(inputRecords.sum), inputBytes.synchronized(inputBytes.sum)) + } finally { + spark.sparkContext.removeSparkListener(listener) + } + } + + test("standalone uncached delta read reports task-level input metrics") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 10000) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .save(path) + + val df = spark.read.format("delta").load(path) + var collected = 0L + val (recordsRead, bytesRead) = collectTaskInputMetrics(10000L) { + collected = df.collect().length.toLong + } + + assert(collected == 10000L) + assert( + deltaNativeScans(df).nonEmpty, + s"expected a native Delta scan:\n${df.queryExecution.executedPlan}") + assert( + recordsRead >= 10000L, + s"expected task input recordsRead to cover the row count, got $recordsRead") + assert(bytesRead > 0L, s"expected task input bytesRead > 0, got $bytesRead") + } + } + + test("fused aggregate over a delta scan reports task-level input metrics") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark + .range(0, 10000) + .selectExpr("id", "id % 13 as g", "id * 2 as v") + .write + .format("delta") + .save(path) + + val df = spark.read.format("delta").load(path).groupBy("g").sum("v") + val (recordsRead, bytesRead) = collectTaskInputMetrics(10000L) { + df.collect() + } + + assert( + deltaNativeScans(df).nonEmpty, + s"expected the native Delta scan fused into the aggregate:\n${df.queryExecution.executedPlan}") + assert( + recordsRead >= 10000L, + s"expected task input recordsRead to cover the scanned row count, got $recordsRead") + assert(bytesRead > 0L, s"expected task input bytesRead > 0, got $bytesRead") + } + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaS3Suite.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaS3Suite.scala new file mode 100644 index 00000000000..a97544219b7 --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaS3Suite.scala @@ -0,0 +1,275 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import scala.util.{Failure, Success, Try} + +import org.testcontainers.DockerClientFactory + +import org.apache.hadoop.fs.Path +import org.apache.spark.internal.Logging +import org.apache.spark.sql.delta.DeltaLog +import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor + +import org.apache.comet.CometS3TestBase + +/** + * MinIO-backed integration coverage for multi-bucket Delta shapes: a real two-bucket shallow + * clone, which a single-bucket `withTempPath` table can never produce, because it is + * `DeltaTable`'s CLONE machinery -- not test fixturing -- that leaves some `AddFile` entries + * pointing at the source table's absolute location while new files land under the clone's own + * root. + * + * Manual/opt-in, same as [[org.apache.comet.parquet.ParquetReadFromS3Suite]] in the spark module + * -- but gated differently out of necessity. That suite is invisible to every PR workflow simply + * because `.github/workflows/pr_build_linux.yml` / `pr_build_macos.yml` enumerate test classes by + * name and never name it (`dev/ci/check-suites.py` exempts it via `ignore_list` instead of + * requiring it be listed). The contrib module has no such allowlist: `delta_contrib_test.yml` + * runs `mvn ... test -pl contrib/delta-spark`, which discovers and runs every suite on the + * module's test classpath, and `check-suites.py` does not enforce anything under `contrib/` at + * all (see its `path.parts[0] == "contrib"` skip), so there is no file to omit this suite from. + * Every test therefore starts with `assume(dockerAvailable, ...)`: when no Docker daemon is + * reachable, ScalaTest reports the test CANCELED rather than failed or run, which + * `scalatest-maven-plugin` does not treat as a build failure -- the practical equivalent of + * `ParquetReadFromS3Suite`'s blanket omission, reached by a runtime check instead of never being + * named. `beforeAll` mirrors this: it probes Docker BEFORE calling `CometS3TestBase#beforeAll`, + * because that trait's `sparkConf` dereferences `minioContainer` unconditionally, and starting + * the Spark session (let alone a container) is exactly what a Docker-less run must not do. + */ +class CometDeltaS3Suite extends CometDeltaTestBase with CometS3TestBase with Logging { + + override protected val testBucketName = "comet-delta-a" + + /** + * The clone's destination bucket: distinct from [[testBucketName]] on purpose -- these tests + * exist to put a table's data (or its deletion vectors) across two object-store authorities. + */ + private val cloneBucketName = "comet-delta-b" + + /** + * A bucket touched by no other test in this suite: the native S3 object-store cache + * (`object_store_cache` in parquet_support.rs) is process-wide and keyed per bucket, so reusing + * [[testBucketName]] for the `${...}` forwarding test below risks silently passing against a + * store handle another test already warmed with plain credentials, rather than actually forcing + * a fresh credential derivation through the substituted `${...}` value. + */ + private val reviewRefBucketName = "comet-delta-review-ref" + + private var dockerAvailable = false + + override def beforeAll(): Unit = { + dockerAvailable = DockerClientFactory.instance().isDockerAvailable + if (dockerAvailable) { + // Fail soft: this suite runs unconditionally in CI (no allowlist to omit it from, see the + // class doc above), and testcontainers networking inside a CI job container is unverified + // -- MinIO is a sibling container there, so `getS3URL` may resolve to an address that is + // wrong from inside the job container. If startup or bucket creation blows up, log the + // resolved URL (the signal needed to diagnose a first bad CI run), flip `dockerAvailable` + // back off so every test cancels via `assume` instead of aborting the whole suite, and + // best-effort stop whatever container did come up. + Try { + super.beforeAll() // CometS3TestBase starts MinIO, then CometTestBase starts the session. + createBucketIfNotExists(cloneBucketName) + createBucketIfNotExists(reviewRefBucketName) + } match { + case Success(_) => + logInfo(s"CometDeltaS3Suite: MinIO reachable at ${minioContainer.getS3URL}") + case Failure(e) => + val resolvedUrl = Try(minioContainer.getS3URL).getOrElse("") + logWarning( + s"CometDeltaS3Suite: MinIO setup failed (resolved S3 URL: $resolvedUrl); " + + "skipping all tests in this suite", + e) + dockerAvailable = false + // Tear down here, synchronously: super.beforeAll() may have partially succeeded + // (e.g. the Spark session started but createBucketIfNotExists(cloneBucketName) + // failed), and this suite's own afterAll() below is gated on `dockerAvailable`, + // which is now false -- the framework-invoked afterAll() will no-op and never get a + // chance to stop anything. super.afterAll() stops both the Spark session + // (CometTestBase#afterAll, tolerates a session that never started) and MinIO + // (CometS3TestBase#afterAll, tolerates a container that never started), so this is + // safe to call unconditionally here regardless of how far beforeAll got. + Try(super.afterAll()) + } + } + } + + override def afterAll(): Unit = { + if (dockerAvailable) { + super.afterAll() + } + } + + // CometTestBase#afterEach unconditionally touches `spark` (cache-clearing, open-stream + // assertions); with no session ever created in a Docker-less run, that NPEs and aborts the + // whole suite -- turning a clean per-test cancellation into a module-wide build failure. + override def afterEach(): Unit = { + if (dockerAvailable) { + super.afterEach() + } + } + + private def tablePath(bucket: String, relPath: String): String = s"s3a://$bucket/$relPath" + + test("shallow clone across buckets + append declines with the multi-store reason") { + assume(dockerAvailable, "Docker is not available; skipping MinIO-backed Delta test") + + val sourcePath = tablePath(testBucketName, "clone-append/source") + val clonePath = tablePath(cloneBucketName, "clone-append/clone") + + spark.range(0, 100).selectExpr("id", "id * 2 as v").write.format("delta").save(sourcePath) + spark.sql(s"CREATE TABLE delta.`$clonePath` SHALLOW CLONE delta.`$sourcePath`") + // The clone's own transaction log still references the SOURCE's physical files (bucket A) + // for every row carried over by the clone. This append writes NEW physical files under the + // clone's own root (bucket B): the clone's data files now span two object-store + // authorities -- exactly the shape the multi-store decline gate exists for, since the shared + // native scan builder resolves the whole scan's ObjectStoreUrl from the first selected file + // only. + spark + .range(100, 150) + .selectExpr("id", "id * 2 as v") + .write + .format("delta") + .mode("append") + .save(clonePath) + + val df = spark.read.format("delta").load(clonePath) + checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan does not support data files spanning multiple object stores") + } + + test( + "clone across buckets + DELETE on the clone reads correct rows natively " + + "(cold cross-bucket deletion-vector store)") { + assume(dockerAvailable, "Docker is not available; skipping MinIO-backed Delta test") + + val sourcePath = tablePath(testBucketName, "clone-delete/source") + val clonePath = tablePath(cloneBucketName, "clone-delete/clone") + + spark.range(0, 1000).selectExpr("id", "id * 2 as v").write.format("delta").save(sourcePath) + spark.sql( + s"ALTER TABLE delta.`$sourcePath` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"CREATE TABLE delta.`$clonePath` SHALLOW CLONE delta.`$sourcePath`") + // DELETE against a deletion-vector table does not rewrite the target file; it attaches a + // deletion-vector sidecar to the existing `AddFile` action instead. The sidecar is written + // under the CLONE's own root (bucket B), while the `AddFile` it decorates still points at + // the SOURCE's absolute, un-copied physical file (bucket A) -- shallow clone never + // relocates data it did not modify. That is the cold cross-bucket deletion-vector-store bug + // shape: attaching the access plan nested a `Handle::block_on` call that built the + // (previously untouched, so cold) bucket-B object store for the sidecar from inside an + // already-running Tokio runtime, which panics. + // + // It is also, deliberately, NOT the shape the multi-store decline gate catches: that gate + // inspects only DATA-file authorities (`scanHelper.selectedPartitions...map(_.getPath)`), + // and every data file this scan selects is still on bucket A -- only the deletion vector's + // own authority is bucket B. That distinction is worth stating here because it is + // the one property that makes this test exercise the cold cross-bucket deletion-vector-store + // path instead of re-proving the multi-store decline gate. + spark.sql(s"DELETE FROM delta.`$clonePath` WHERE id % 2 = 0") + + // Assert the cross-bucket shape STRUCTURALLY, not just end-to-end via the read below: if + // Delta's shallow-clone or DELETE-on-a-DV-table semantics ever change (DELETE starts + // rewriting the file instead of writing a DV, or the DV sidecar starts landing next to the + // data it decorates instead of under the clone's own root), the test must fail loudly right + // here -- otherwise it would silently degrade into a same-bucket read that never exercises + // the cold cross-bucket deletion-vector-store code path at all, while + // `checkDeltaNativeScanAnswer` below would still pass. + val log = DeltaLog.forTable(spark, clonePath) + val cloneTableRootPath = new Path(clonePath) + val files = log.update().allFiles.collect() + + // At least one data file must still resolve into the SOURCE bucket: shallow clone never + // copies files it did not modify. + val dataAuthorities = files.map(_.absolutePath(log).toUri.getHost).distinct + assert( + dataAuthorities.contains(testBucketName), + "expected at least one data file to still resolve into the SOURCE bucket " + + s"($testBucketName, carried over unmodified by the shallow clone); resolved data-file " + + s"authorities: ${dataAuthorities.mkString(", ")}") + + // At least one deletion-vector descriptor must resolve into the CLONE's own bucket. + // Resolution mirrors DeltaScanSupport.selectedDvDescriptors (copyWithAbsolutePath against + // the table root) followed by CometDeltaNativeScan.storeUris's own absolutePath call -- + // the exact path production code takes from AddFile to an object-store authority. Inline + // or canonically-empty descriptors are excluded first (`cardinality == 0` is the + // EMPTY-descriptor characterization: no rows deleted, so no on-disk sidecar exists): + // DeletionVectorDescriptor#absolutePath's isOnDisk precondition + // throws for inline ones, and neither carries a resolvable external authority. + val dvAuthorities = files + .flatMap(f => Option(f.deletionVector)) + .filter(dv => + dv.storageType != DeletionVectorDescriptor.INLINE_DV_MARKER && dv.cardinality > 0) + .map( + _.copyWithAbsolutePath(cloneTableRootPath).absolutePath(cloneTableRootPath).toUri.getHost) + .distinct + assert( + dvAuthorities.contains(cloneBucketName), + "expected at least one deletion-vector sidecar to resolve into the CLONE's own bucket " + + s"($cloneBucketName); resolved deletion-vector authorities: " + + s"${dvAuthorities.mkString(", ")}") + + val df = spark.read.format("delta").load(clonePath) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 500) + } + + test( + "S3 credentials configured via a Hadoop ${...} variable reference (fs.s3a.access.key = " + + "${review.access}, fs.s3a.secret.key = ${review.secret}) claim natively and read " + + "correct rows against a real MinIO bucket") { + assume(dockerAvailable, "Docker is not available; skipping MinIO-backed Delta test") + + // Mutate the session's shared hadoopConfiguration directly (mirroring the set-then-restore + // shape CometDeltaNativeScanSuite's viewfs gate test uses for the same reason: these are + // plain Hadoop Configuration entries, not SQLConf, so withSQLConf cannot round-trip them). + // Aliasing the real MinIO credentials behind review.access/review.secret and pointing + // fs.s3a.access.key/fs.s3a.secret.key at them via ${...} reproduces exactly the shape + // PART 1 fixed: Configuration#get expands the reference to the real credential, and + // NativeConfig.extractObjectStoreOptions must forward that EXPANDED value, not the literal + // "${review.access}" string, or the native S3 client would authenticate with garbage. + val hadoopConf = spark.sparkContext.hadoopConfiguration + val priorAccessKey = Option(hadoopConf.get("fs.s3a.access.key")) + val priorSecretKey = Option(hadoopConf.get("fs.s3a.secret.key")) + hadoopConf.set("review.access", userName) + hadoopConf.set("review.secret", password) + hadoopConf.set("fs.s3a.access.key", "${review.access}") + hadoopConf.set("fs.s3a.secret.key", "${review.secret}") + try { + val path = tablePath(reviewRefBucketName, "review-ref-table") + spark.range(0, 200).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + + val df = spark.read.format("delta").load(path) + checkDeltaNativeScanAnswer(df) + assert(df.count() == 200) + } finally { + hadoopConf.unset("review.access") + hadoopConf.unset("review.secret") + priorAccessKey match { + case Some(v) => hadoopConf.set("fs.s3a.access.key", v) + case None => hadoopConf.unset("fs.s3a.access.key") + } + priorSecretKey match { + case Some(v) => hadoopConf.set("fs.s3a.secret.key", v) + case None => hadoopConf.unset("fs.s3a.secret.key") + } + } + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaTestBase.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaTestBase.scala new file mode 100644 index 00000000000..50fbcc23fe2 --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/CometDeltaTestBase.scala @@ -0,0 +1,57 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import org.apache.spark.SparkConf +import org.apache.spark.sql.{CometTestBase, DataFrame} +import org.apache.spark.sql.execution.SparkPlan +import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper + +/** + * Base for Delta contrib suites: CometTestBase plus the Delta Lake session extension and catalog. + */ +abstract class CometDeltaTestBase extends CometTestBase with AdaptiveSparkPlanHelper { + + override protected def sparkConf: SparkConf = { + val conf = super.sparkConf + conf.set("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension") + conf.set("spark.sql.catalog.spark_catalog", "org.apache.spark.sql.delta.catalog.DeltaCatalog") + conf.set(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key, "true") + conf + } + + /** Collect nodes of the given simple class name anywhere in the (AQE-stripped) plan. */ + protected def collectByName(plan: SparkPlan, simpleName: String): Seq[SparkPlan] = + collectWithSubqueries(stripAQEPlan(plan)) { + case op if op.getClass.getSimpleName == simpleName => op + } + + protected def deltaNativeScans(df: DataFrame): Seq[SparkPlan] = + collectByName(df.queryExecution.executedPlan, "CometDeltaNativeScanExec") + + /** Assert the query ran through the native Delta scan AND matches the comet-off answer. */ + protected def checkDeltaNativeScanAnswer(df: DataFrame): Unit = { + checkSparkAnswer(df) + // Re-materialize the plan after execution so AQE has finalized stages. + assert( + deltaNativeScans(df).nonEmpty, + s"Expected CometDeltaNativeScanExec in plan:\n${df.queryExecution.executedPlan}") + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/DeltaScanContribSuite.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/DeltaScanContribSuite.scala new file mode 100644 index 00000000000..bc7e2a5317f --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/DeltaScanContribSuite.scala @@ -0,0 +1,2110 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import java.io.File +import java.net.URI +import java.nio.file.Files +import java.util.{Locale, UUID} + +import org.apache.hadoop.conf.Configuration +import org.apache.hadoop.fs.Path +import org.apache.hadoop.fs.s3a.S3AUtils +import org.apache.hadoop.security.alias.CredentialProviderFactory +import org.apache.spark.sql.delta.actions.DeletionVectorDescriptor + +import org.apache.comet.{CometConf, ExtendedExplainInfo} +import org.apache.comet.rules.CometScanRule + +/** + * Guards the claim-path behaviors that used to be enforced by core's now-deleted extension-SPI + * suite: the contrib is never active when Comet exec or Comet scan is disabled, and the claim + * hook runs before core's metadata-column guard. + */ +class DeltaScanContribSuite extends CometDeltaTestBase { + + test("contrib is inert when comet exec is disabled") { + // The COMET_EXEC_ENABLED gate moved from core's (deleted) extension call site into + // DeltaScanContrib.tryTransformV1; this pins it there. + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + withSQLConf(CometConf.COMET_EXEC_ENABLED.key -> "false") { + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty) + } + } + } + + test("contrib is inert when comet native scan is disabled") { + // COMET_NATIVE_SCAN_ENABLED is checked in CometScanRule.transformScan before any V1 + // handling, so it short-circuits the CometScanContrib hook too. + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + withSQLConf(CometConf.COMET_NATIVE_SCAN_ENABLED.key -> "false") { + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).isEmpty) + } + } + } + + test("claim runs before core's metadata-column guard") { + // A DV read's plan carries generated metadata columns that core's generic V1 guard + // would decline; the scan still goes native because CometScanContrib.tryTransformV1 + // is consulted first (CometScanRule.transformV1Scan hook order). + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 1000).selectExpr("id", "id * 2 as v").write.format("delta").save(path) + spark.sql( + s"ALTER TABLE delta.`$path` SET TBLPROPERTIES ('delta.enableDeletionVectors' = 'true')") + spark.sql(s"DELETE FROM delta.`$path` WHERE id % 2 = 0") + + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).nonEmpty) + } + } + + test("declined scan carries the contrib's fallback reason, not core's generic one") { + // Disabling Spark's vectorized Parquet reader is a scan the contrib recognizes + // (DeltaScanSupport.isDeltaScan) but explicitly declines (DeltaScanSupport.declineReason, + // mirroring core's own vectorized-reader gate). Per the CometScanContrib ownership + // contract the contrib still claims it (tagging its own fallback reason), so core's + // generic V1 gate -- and its "Unsupported file format" message -- never runs on it. + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + withSQLConf( + "spark.sql.parquet.enableVectorizedReader" -> "false", + // CometTestBase flips this to "true" so the rest of the suite can exercise the + // vectorized-off path against Comet's native scan; put it back to its real default so + // this gate actually declines. + CometConf.COMET_SCAN_ALLOW_DISABLED_PARQUET_VECTORIZED_READER.key -> "false") { + val df = spark.read.format("delta").load(path) + + val (_, cometPlan) = checkSparkAnswerAndFallbackReason( + df, + "Native Delta scan is incompatible with " + + "spark.sql.parquet.enableVectorizedReader=false") + + val reasons = new ExtendedExplainInfo().getFallbackReasons(cometPlan) + assert( + !reasons.exists(_.contains("Unsupported file format")), + s"Did not expect core's generic fallback reason among: $reasons") + } + } + } + + test( + "vectorized reader disabled still claims natively when the safety conf allows it " + + "(claim-direction control for the decline above)") { + withTempPath { dir => + val path = dir.getAbsolutePath + spark.range(0, 100).write.format("delta").save(path) + + withSQLConf( + "spark.sql.parquet.enableVectorizedReader" -> "false", + CometConf.COMET_SCAN_ALLOW_DISABLED_PARQUET_VECTORIZED_READER.key -> "true") { + val df = spark.read.format("delta").load(path) + checkSparkAnswer(df) + assert(deltaNativeScans(df).nonEmpty) + } + } + } + + test( + "unsupportedSchemes declines an all-viewfs root-path selection (the same helper " + + "declineReason applies to scanExec.relation.location.rootPaths, ahead of the " + + "selected-file gate)") { + val viewfsUri = new URI("viewfs://cluster/table") + // Precondition, mirroring the selected-file scheme tests below: guards against a fail-open + // native build vacuously passing this test. + assert(!CometScanRule.isNativelyReadableScheme(viewfsUri)) + + val schemes = DeltaScanSupport.unsupportedSchemes(Seq(viewfsUri), Set("hdfs")) + assert(schemes == Set("viewfs")) + } + + test("unsupportedSchemes passes an all-file: root-path selection (no regression)") { + assert( + DeltaScanSupport + .unsupportedSchemes(Seq(new URI("file:///tmp/table")), Set("hdfs")) + .isEmpty) + } + + test( + "unsupportedSchemes passes a root-path scheme configured as a libhdfs exemption " + + "(exemption honored for the root-path call site too)") { + val viewfsUri = new URI("viewfs://cluster/table") + assert(!CometScanRule.isNativelyReadableScheme(viewfsUri)) + + assert(DeltaScanSupport.unsupportedSchemes(Seq(viewfsUri), Set("viewfs")).isEmpty) + } + + test("multiStoreReason declines data files spanning multiple object-store authorities") { + // Same bucket, different keys: one authority, claimable. + assert( + DeltaScanSupport + .multiStoreReason( + Seq(new URI("s3a://bucket/a/part-0.parquet"), new URI("s3a://bucket/b/part-1.parquet"))) + .isEmpty) + + // Distinct buckets: two authorities, must decline (this is the shallow-clone-across- + // buckets-plus-append shape the shared native scan builder cannot route correctly, since + // it resolves the whole scan's ObjectStoreUrl from the first file only). + val reason = DeltaScanSupport.multiStoreReason( + Seq(new URI("s3a://bucket-a/part-0.parquet"), new URI("s3a://bucket-b/part-1.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("multiple object stores")) + assert(reason.get.contains("bucket-a")) + assert(reason.get.contains("bucket-b")) + + // file:// paths never carry an authority (host/port are always empty), so local scans + // across distinct directories are unaffected. + assert( + DeltaScanSupport + .multiStoreReason( + Seq(new URI("file:///tmp/a/part-0.parquet"), new URI("file:///tmp/b/part-1.parquet"))) + .isEmpty) + } + + test( + "multiStoreReason declines cross-container abfss shallow clones (userinfo normalization)") { + // Same storage account, different containers: URI#getHost drops the userinfo entirely, so + // keying the authority on host alone would collapse containerA and containerB into one + // authority and silently claim a cross-container shallow clone. getAuthority (used by + // uriAuthority) keeps the userinfo, so this must decline. + val reason = DeltaScanSupport.multiStoreReason( + Seq( + new URI("abfss://containerA@account.dfs.core.windows.net/a/part-0.parquet"), + new URI("abfss://containerB@account.dfs.core.windows.net/b/part-1.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("multiple object stores")) + + // Same container: one authority, so multiStoreReason itself still passes this shape + // unchanged (this gate was never touched by the userinfo work). But every abfss:// URI here + // carries userinfo (the container) in its authority, so declineReason's earlier-firing + // userInfoBearingAuthorityReason gate now declines this input before multiStoreReason ever + // runs on it -- pinned directly here since multiStoreReason alone can no longer observe the + // difference between this shape and a truly userinfo-free single-authority scan. + val sameContainer = Seq( + new URI("abfss://container@account.dfs.core.windows.net/a/part-0.parquet"), + new URI("abfss://container@account.dfs.core.windows.net/b/part-1.parquet")) + assert(DeltaScanSupport.multiStoreReason(sameContainer).isEmpty) + assert(DeltaScanSupport.userInfoBearingAuthorityReason(sameContainer).isDefined) + } + + test( + "multiStoreReason declines distinct underscore-bearing GCS buckets " + + "(URI#getHost null-collapse)") { + // `gs://my_bucket` has an underscore reg-name, which URI#getHost cannot parse -- it returns + // null for the WHOLE authority, not just an empty host. Keying uriAuthority on getHost alone + // would make every underscore-bearing bucket normalize to the same "null host" authority + // regardless of which bucket it actually is, so two distinct underscore buckets would + // wrongly collapse into one authority and never decline -- even though the native side + // parses `gs://my_bucket` and `gs://other_bucket` as genuinely different authorities and + // would hard-error on them. getAuthority (used by uriAuthority) returns the raw authority + // text regardless of RFC 3986 conformance, so this must decline instead. + val reason = DeltaScanSupport.multiStoreReason( + Seq( + new URI("gs://my_bucket/a/part-0.parquet"), + new URI("gs://other_bucket/b/part-1.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("multiple object stores")) + + // Same underscore-bearing bucket: one authority, claimable on both the JVM gate and the + // native check (native side asserted in delta_spark_scan.rs's + // same_underscore_host_bucket_files_pass). + assert( + DeltaScanSupport + .multiStoreReason( + Seq( + new URI("gs://my_bucket/a/part-0.parquet"), + new URI("gs://my_bucket/b/part-1.parquet"))) + .isEmpty) + } + + test( + "userInfoBearingAuthorityReason declines a single userinfo-bearing abfss authority " + + "(the behavior change: one container alone is no longer claimable)") { + val reason = DeltaScanSupport.userInfoBearingAuthorityReason( + Seq(new URI("abfss://container@account.dfs.core.windows.net/a/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("userinfo")) + } + + test( + "userInfoBearingAuthorityReason declines two containers on one storage account " + + "(cross-container deletion-vector authority on a single storage account)") { + val reason = DeltaScanSupport.userInfoBearingAuthorityReason( + Seq( + new URI("abfss://source@account.dfs.core.windows.net/a/part-0.parquet"), + new URI("abfss://clone@account.dfs.core.windows.net/_delta_log/dv/deletion_vector.bin"))) + assert(reason.isDefined) + } + + test( + "userInfoBearingAuthorityReason passes s3a data-file and deletion-vector paths " + + "(no regression for the MinIO live suites)") { + // Same bucket: userinfo-free authority, unaffected. + assert( + DeltaScanSupport + .userInfoBearingAuthorityReason( + Seq( + new URI("s3a://bucket/a/part-0.parquet"), + new URI("s3a://bucket/_delta_log/dv/deletion_vector.bin"))) + .isEmpty) + + // Distinct buckets, still no userinfo on either: this gate only inspects userinfo, so it is + // unaffected by multiStoreReason's separate authority-count decline (ported from the deleted + // storeIdentityCollisionReason suite's "passes distinct s3a buckets" case). + assert( + DeltaScanSupport + .userInfoBearingAuthorityReason( + Seq( + new URI("s3a://bucket-a/part-0.parquet"), + new URI("s3a://bucket-b/deletion_vector.bin"))) + .isEmpty) + } + + test("userInfoBearingAuthorityReason passes file:// paths (no authority at all)") { + assert( + DeltaScanSupport + .userInfoBearingAuthorityReason( + Seq(new URI("file:///tmp/a/part-0.parquet"), new URI("file:///tmp/b/part-1.parquet"))) + .isEmpty) + } + + test( + "userInfoBearingAuthorityReason: underscore-bearing GCS bucket passes without userinfo, " + + "declines with it (raw-authority parsing, not URI#getHost)") { + // `gs://my_bucket` has an underscore reg-name that URI#getHost cannot parse (returns null + // for the whole authority); no userinfo either way, so this must pass. + assert( + DeltaScanSupport + .userInfoBearingAuthorityReason(Seq(new URI("gs://my_bucket/a/part-0.parquet"))) + .isEmpty) + + // Same underscore-bearing bucket, now with userinfo: uriUserInfo's raw last-`@` split still + // finds it even though URI#getHost/getUserInfo would return null for this authority. + val reason = DeltaScanSupport.userInfoBearingAuthorityReason( + Seq(new URI("gs://u1@my_bucket/a/part-0.parquet"))) + assert(reason.isDefined) + } + + test("userInfoBearingAuthorityReason passes an hdfs authority with no userinfo") { + assert( + DeltaScanSupport + .userInfoBearingAuthorityReason(Seq(new URI("hdfs://nn:8020/table/part-0.parquet"))) + .isEmpty) + } + + test( + "userInfoBearingAuthorityReason redacts userinfo out of the decline reason (never leaks " + + "embedded credentials)") { + val reason = DeltaScanSupport.userInfoBearingAuthorityReason( + Seq(new URI("s3a://AKIAEXAMPLE:secr3t@bucket/a/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("bucket")) + assert(!reason.get.contains("secr3t")) + assert(!reason.get.contains("AKIAEXAMPLE")) + } + + test( + "unsupportedSelectedSchemeReason declines an all-viewfs selection, naming the scheme and " + + "the selected-file/DV wording") { + val viewfsUri = new URI("viewfs://cluster/table/part-0.parquet") + // Precondition: guards against a fail-open native build vacuously passing this test -- + // isNativelyReadableScheme falls back to TRUE when the native library can't be consulted + // (see its doc), which would make viewfs look natively readable and this test pass for the + // wrong reason regardless of whether the new gate is even wired up correctly. + assert(!CometScanRule.isNativelyReadableScheme(viewfsUri)) + + val reason = DeltaScanSupport.unsupportedSelectedSchemeReason( + Seq(viewfsUri, new URI("viewfs://cluster/table/part-1.parquet")), + Set("hdfs")) + assert(reason.isDefined) + assert(reason.get.contains("viewfs")) + assert(reason.get.contains("data file or deletion vector")) + } + + test( + "unsupportedSelectedSchemeReason declines a mixed file:+viewfs selection with the scheme " + + "reason (pins its ordering ahead of the authority gates)") { + // A supported-scheme file alongside an unsupported-scheme one: this shape ALSO spans + // multiple object-store authorities (multiStoreReason below would decline it too), but + // declineReason places the scheme gate first, so callers must see the scheme reason here, + // not whatever the authority gates would have said about this same input. + val fileUri = new URI("file:///tmp/table/part-0.parquet") + val viewfsUri = new URI("viewfs://cluster/table/part-1.parquet") + assert(!CometScanRule.isNativelyReadableScheme(viewfsUri)) + + val reason = + DeltaScanSupport.unsupportedSelectedSchemeReason(Seq(fileUri, viewfsUri), Set("hdfs")) + assert(reason.isDefined) + assert(reason.get.contains("viewfs")) + // Confirms this input really would ALSO trip multiStoreReason, so the assertion above is + // meaningfully pinning which reason wins under declineReason's ordering, not merely proving + // the scheme gate fires in isolation. + assert(DeltaScanSupport.multiStoreReason(Seq(fileUri, viewfsUri)).isDefined) + } + + test( + "unsupportedSelectedSchemeReason declines a viewfs deletion-vector absolute path even when " + + "every data file is file:// (proves dvUris is part of the gated URI set)") { + val dvUri = new URI("viewfs://cluster/table/_delta_log/dv/deletion_vector.bin") + assert(!CometScanRule.isNativelyReadableScheme(dvUri)) + + val dataFileUris = Seq(new URI("file:///tmp/table/part-0.parquet")) + val reason = + DeltaScanSupport.unsupportedSelectedSchemeReason(dataFileUris :+ dvUri, Set("hdfs")) + assert(reason.isDefined) + assert(reason.get.contains("viewfs")) + } + + test( + "unsupportedSelectedSchemeReason passes all-file: and all-s3a: selections (no regression " + + "for the MinIO live suites)") { + assert( + DeltaScanSupport + .unsupportedSelectedSchemeReason( + Seq( + new URI("file:///tmp/a/part-0.parquet"), + new URI("file:///tmp/b/deletion_vector.bin")), + Set("hdfs")) + .isEmpty) + assert( + DeltaScanSupport + .unsupportedSelectedSchemeReason( + Seq( + new URI("s3a://bucket/a/part-0.parquet"), + new URI("s3a://bucket/_delta_log/dv/deletion_vector.bin")), + Set("hdfs")) + .isEmpty) + } + + test( + "unsupportedSelectedSchemeReason passes an all-viewfs selection when viewfs is configured " + + "as a libhdfs scheme (exemption honored on the new call site)") { + val viewfsUri = new URI("viewfs://cluster/table/part-0.parquet") + assert(!CometScanRule.isNativelyReadableScheme(viewfsUri)) + + assert( + DeltaScanSupport.unsupportedSelectedSchemeReason(Seq(viewfsUri), Set("viewfs")).isEmpty) + } + + test("mergedObjectStoreOptions unions options across every authority without leaking schemes") { + // The merge must reach a DV sidecar living on a different provider than the data files + // (e.g. S3 data + ABFS deletion vector), and must never hand an unrelated provider's + // credentials to a scan that never referenced it. + val hadoopConf = new org.apache.hadoop.conf.Configuration(false) + hadoopConf.set("fs.s3a.access.key", "s3-access-key") + hadoopConf.set("fs.s3a.secret.key", "s3-secret-key") + hadoopConf.set("fs.azure.account.key.acct.dfs.core.windows.net", "azure-account-key") + + val s3Uri = new URI("s3a://bucket/data.parquet") + val abfssUri = new URI("abfss://container@acct.dfs.core.windows.net/dv.bin") + + val merged = + CometDeltaNativeScan.mergedObjectStoreOptions(hadoopConf, Seq(s3Uri, abfssUri)) + assert(merged.get("fs.s3a.access.key").contains("s3-access-key")) + assert(merged.get("fs.s3a.secret.key").contains("s3-secret-key")) + assert( + merged + .get("fs.azure.account.key.acct.dfs.core.windows.net") + .contains("azure-account-key")) + + // s3-only input must not leak the azure credentials into the merged map. + val s3Only = CometDeltaNativeScan.mergedObjectStoreOptions(hadoopConf, Seq(s3Uri)) + assert(s3Only.get("fs.s3a.access.key").contains("s3-access-key")) + assert(!s3Only.keys.exists(_.startsWith("fs.azure."))) + } + + test( + "storeUris dedups by authority: one representative URI per (scheme, authority), even " + + "when DV files live at distinct paths on the same authority") { + // No Spark session involved, and deliberately NOT a file:// scan: a local-path test can't + // exercise a DV sidecar on a foreign authority (extractObjectStoreOptions returns an empty + // map for file://), which is exactly the shape that requires unioning object-store options + // across every authority. Hand-build descriptors via Delta's own factory methods instead of + // going through a real scan/claim. + val tableRootPath = new Path("s3a://bucket-root/table") + val firstFileUri = Some(new URI("s3a://bucket-root/table/part-0.parquet")) + + // Path-based ('p') DV on a different authority than the data files / table root. + val foreignDv = DeletionVectorDescriptor + .onDiskWithAbsolutePath("abfss://acct.dfs.core.windows.net/dv1.bin", 40, 4) + // A SECOND, distinct path on the SAME foreign authority as `foreignDv` -- the shape that + // motivates per-authority dedup: before dedup, N deletion-vector files on one external store + // yielded ~N distinct URIs here (each independently walked by mergedObjectStoreOptions); now + // they collapse to a single representative. + val sameAuthoritySecondDv = DeletionVectorDescriptor + .onDiskWithAbsolutePath("abfss://acct.dfs.core.windows.net/dv2.bin", 40, 4) + // UUID-relative ('u') DV: resolves under the table root's authority (s3a/bucket-root), which + // `firstFileUri` already represents -- must not add a second entry for that authority. + val relativeDv = DeletionVectorDescriptor.onDiskWithRelativePath(UUID.randomUUID(), "", 40, 4) + // Inline ('i') DV: no external URI at all; must not be resolved (would throw -- inline + // descriptors fail `absolutePath`'s `isOnDisk` precondition) and must contribute nothing. + val inlineDv = DeletionVectorDescriptor.inlineInLog(Array[Byte](1, 2, 3), 1) + + val uris = CometDeltaNativeScan.storeUris( + Seq(foreignDv, sameAuthoritySecondDv, relativeDv, inlineDv), + tableRootPath, + firstFileUri) + + // Exactly one representative per authority: s3a/bucket-root (firstFileUri wins -- it is + // first in candidate order, ahead of the table root and the relative DV's resolution) and + // abfss/acct.dfs.core.windows.net (foreignDv wins over sameAuthoritySecondDv, the first DV + // seen on that authority). + assert( + uris == Seq(firstFileUri.get, new URI("abfss://acct.dfs.core.windows.net/dv1.bin")), + s"expected exactly one representative URI per authority, got: $uris") + } + + test("storeUris always includes firstFileUri and the table root even with no DV descriptors") { + val tableRootPath = new Path("file:///tmp/table") + val firstFileUri = Some(new URI("file:///tmp/table/part-0.parquet")) + + // firstFileUri and tableRootPath share the same (empty) file:// authority, so the table root + // is deduped away in favor of firstFileUri, which is first in candidate order. + assert( + CometDeltaNativeScan.storeUris(Seq.empty, tableRootPath, firstFileUri) == + Seq(firstFileUri.get)) + + // No first file (e.g. an empty selected-partitions edge case): table root alone, no crash. + assert( + CometDeltaNativeScan.storeUris(Seq.empty, tableRootPath, None) == + Seq(tableRootPath.toUri)) + } + + test("user guide documents the native Delta scan config verbatim") { + // Guards against the config's `.doc` drifting out of sync with the hand-written user-guide + // page (the generated table only covers `docs/source/user-guide/latest`, so there is no + // build-time check tying the two together). + val docsPath = DeltaScanContribSuite.findRepoFile("docs/source/user-guide/latest/delta.md") + docsPath match { + case None => + cancel( + "Could not locate docs/source/user-guide/latest/delta.md from this checkout; " + + "skipping the docs drift guard.") + case Some(file) => + val contents = scala.io.Source.fromFile(file, "UTF-8").mkString + assert( + contents.contains(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key), + s"Expected ${file.getAbsolutePath} to mention " + + s"${DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.key}") + assert( + contents.contains(DeltaScanConf.COMET_DELTA_NATIVE_ENABLED.doc), + s"Expected ${file.getAbsolutePath} to contain the config's doc string verbatim") + } + } + + /** + * Builds a real JCEKS keystore backing `hadoop.security.credential.provider.path`, seeded with + * `entries`, and hands `test` a fresh [[Configuration]] already pointed at it (path only -- + * `entries` are NOT mirrored into the plain conf; callers add plain values themselves when a + * case needs them). Uses `CredentialProviderFactory` directly (the real API `Configuration# + * getPassword` reads through), not a hand-rolled keystore, so these tests exercise the actual + * Hadoop credential-provider resolution path rather than a stand-in for it. The store password + * defaults to `"none"` when neither `HADOOP_CREDSTORE_PASSWORD` nor a password file is set in + * the test environment, which is the JCEKS provider's own documented default -- nothing extra + * to configure here. + */ + private def withJceks(entries: Map[String, String])(test: Configuration => Unit): Unit = { + val storeFile = File.createTempFile("comet-delta-creds", ".jceks") + // JavaKeyStoreProvider creates the backing file itself on first flush; a pre-existing empty + // file (createTempFile always creates one) makes it treat the store as an existing, empty + // keystore instead -- harmless either way for JCEKS, but deleting it first keeps this fixture + // honest about what it is actually exercising (provider-created, not merely provider-opened). + storeFile.delete() + val providerPath = "jceks://file" + storeFile.getAbsolutePath + try { + val buildConf = new Configuration(false) + buildConf.set(CredentialProviderFactory.CREDENTIAL_PROVIDER_PATH, providerPath) + val provider = CredentialProviderFactory.getProviders(buildConf).get(0) + entries.foreach { case (alias, value) => + provider.createCredentialEntry(alias, value.toCharArray) + } + provider.flush() + + val testConf = new Configuration(false) + testConf.set(CredentialProviderFactory.CREDENTIAL_PROVIDER_PATH, providerPath) + test(testConf) + } finally { + storeFile.delete() + } + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on a Hadoop service-account " + + "keyfile, naming the key but never the value, and matches the scheme case-insensitively") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("GS://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.service.account.json.keyfile")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("/secret/path/svc-key.json")) + } + + test( + "gcsHadoopOnlyAuthReason passes a gs URI when no fs.gs.auth.* key is set " + + "(Application Default Credentials work in both engines)") { + val conf = new Configuration(false) + assert( + DeltaScanSupport + .gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "gcsHadoopOnlyAuthReason does not fire for s3a/file URIs even when fs.gs.auth.* is set " + + "(scheme-scoped)") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + assert( + DeltaScanSupport + .gcsHadoopOnlyAuthReason( + conf, + Seq( + new URI("s3a://mybucket/part-0.parquet"), + new URI("file:///tmp/table/part-0.parquet"))) + .isEmpty) + } + + test( + "gcsHadoopOnlyAuthReason declines when local data files are mixed with an absolute gs " + + "deletion-vector sidecar backed only by a Hadoop keyfile") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + val reason = DeltaScanSupport.gcsHadoopOnlyAuthReason( + conf, + Seq( + new URI("file:///tmp/table/part-0.parquet"), + new URI("gs://mybucket/_delta_log/deletion_vector_abc123.bin"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.service.account.json.keyfile")) + assert(reason.get.contains("mybucket")) + } + + test( + "gcsHadoopOnlyAuthReason's decline reason names every offending fs.gs.auth.* key but never " + + "any of their configured values") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + conf.set("fs.gs.auth.client.id", "super-secret-client-id-xyz") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.service.account.json.keyfile")) + assert(reason.get.contains("fs.gs.auth.client.id")) + assert(!reason.get.contains("/secret/path/svc-key.json")) + assert(!reason.get.contains("super-secret-client-id-xyz")) + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on the legacy " + + "google.cloud.auth.* connector prefix, naming the key but never the value") { + val conf = new Configuration(false) + conf.set("google.cloud.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("google.cloud.auth.service.account.json.keyfile")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("/secret/path/svc-key.json")) + } + + test( + "gcsHadoopOnlyAuthReason does not fire for s3a/file URIs even when google.cloud.auth.* is " + + "set (scheme-scoped)") { + val conf = new Configuration(false) + conf.set("google.cloud.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + assert( + DeltaScanSupport + .gcsHadoopOnlyAuthReason( + conf, + Seq( + new URI("s3a://mybucket/part-0.parquet"), + new URI("file:///tmp/table/part-0.parquet"))) + .isEmpty) + } + + test( + "gcsHadoopOnlyAuthReason's decline reason names offending keys under both fs.gs.auth. and " + + "google.cloud.auth. but never any of their configured values") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.client.id", "super-secret-client-id-xyz") + conf.set("google.cloud.auth.service.account.json.keyfile", "/secret/path/svc-key.json") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.client.id")) + assert(reason.get.contains("google.cloud.auth.service.account.json.keyfile")) + assert(!reason.get.contains("super-secret-client-id-xyz")) + assert(!reason.get.contains("/secret/path/svc-key.json")) + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on the deprecated " + + "fs.gs.service.account.auth.keyfile key (reversed word order vs the modern " + + "fs.gs.auth.service.account.* prefix), naming the key but never the value") { + val conf = new Configuration(false) + conf.set("fs.gs.service.account.auth.keyfile", "/secret/path/svc-key.p12") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.service.account.auth.keyfile")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("/secret/path/svc-key.p12")) + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on the deprecated " + + "fs.gs.service.account.auth.email key, naming the key but never the value") { + val conf = new Configuration(false) + conf.set("fs.gs.service.account.auth.email", "svc@example-project.iam.gserviceaccount.com") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.service.account.auth.email")) + assert(!reason.get.contains("svc@example-project.iam.gserviceaccount.com")) + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on the deprecated " + + "google.cloud.service.account.auth.keyfile key, naming the key but never the value") { + val conf = new Configuration(false) + conf.set("google.cloud.service.account.auth.keyfile", "/secret/path/svc-key.p12") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("google.cloud.service.account.auth.keyfile")) + assert(!reason.get.contains("/secret/path/svc-key.p12")) + } + + test( + "gcsHadoopOnlyAuthReason declines a gs data file relying only on the deprecated " + + "google.cloud.service.account.auth.email key, naming the key but never the value") { + val conf = new Configuration(false) + conf.set( + "google.cloud.service.account.auth.email", + "svc@example-project.iam.gserviceaccount.com") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("google.cloud.service.account.auth.email")) + assert(!reason.get.contains("svc@example-project.iam.gserviceaccount.com")) + } + + test( + "gcsHadoopOnlyAuthReason does not fire for s3a/file URIs even when the deprecated " + + "fs.gs.service.account.auth.* prefix is set (scheme-scoped)") { + val conf = new Configuration(false) + conf.set("fs.gs.service.account.auth.keyfile", "/secret/path/svc-key.p12") + assert( + DeltaScanSupport + .gcsHadoopOnlyAuthReason( + conf, + Seq( + new URI("s3a://mybucket/part-0.parquet"), + new URI("file:///tmp/table/part-0.parquet"))) + .isEmpty) + } + + test( + "gcsHadoopOnlyAuthReason declines on fs.gs.auth.type, a suffix no fixed prefix list ever " + + "enumerated (predicate-based matching instead of a prefix table)") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.type", "SERVICE_ACCOUNT_JSON_KEYFILE") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.type")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("SERVICE_ACCOUNT_JSON_KEYFILE")) + } + + test( + "gcsHadoopOnlyAuthReason declines on fs.gs.auth.client.id, naming the key but never the " + + "value") { + val conf = new Configuration(false) + conf.set("fs.gs.auth.client.id", "super-secret-client-id-xyz") + val reason = + DeltaScanSupport.gcsHadoopOnlyAuthReason(conf, Seq(new URI("gs://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.gs.auth.client.id")) + assert(!reason.get.contains("super-secret-client-id-xyz")) + } + + test( + "s3ConfigDivergenceReason declines when access/secret keys exist only in a JCEKS " + + "keystore, naming the base key and bucket but never the secret") { + withJceks(Map("fs.s3a.access.key" -> "AKIAEXAMPLE", "fs.s3a.secret.key" -> "s3cr3tValue")) { + conf => + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIAEXAMPLE")) + assert(!reason.get.contains("s3cr3tValue")) + } + } + + test( + "s3ConfigDivergenceReason passes when only plain keys are set and no provider path is " + + "configured (zero-I/O precheck exit)") { + val conf = new Configuration(false) + conf.set("fs.s3a.access.key", "AKIAPLAIN") + conf.set("fs.s3a.secret.key", "plainSecret") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when the provider path is set and the plain keys match " + + "the keystore (plain keys consistent with the credential provider)") { + withJceks(Map("fs.s3a.access.key" -> "AKIAMATCH", "fs.s3a.secret.key" -> "matchingSecret")) { + conf => + conf.set("fs.s3a.access.key", "AKIAMATCH") + conf.set("fs.s3a.secret.key", "matchingSecret") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + } + + test( + "s3ConfigDivergenceReason declines when the keystore value differs from a shadowed plain " + + "value") { + withJceks(Map("fs.s3a.access.key" -> "AKIAKEYSTORE")) { conf => + conf.set("fs.s3a.access.key", "AKIADIFFERENTPLAIN") + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(!reason.get.contains("AKIAKEYSTORE")) + } + } + + test( + "s3ConfigDivergenceReason declines on an S3A-scoped provider path immediately, without " + + "touching a nonexistent keystore (Arm A proves no keystore I/O)") { + val tempDir = Files.createTempDirectory("comet-delta-no-keystore") + try { + val conf = new Configuration(false) + val nonexistentPath = "jceks://file" + tempDir + "/does-not-exist.jceks" + conf.set("fs.s3a.security.credential.provider.path", nonexistentPath) + // No exception from a missing file is the point of this test: Arm A declines on the + // presence of the S3A-scoped path key alone, never reading it. + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.security.credential.provider.path")) + } finally { + Files.delete(tempDir) + } + } + + test( + "s3ConfigDivergenceReason passes file:// URIs regardless of any provider path " + + "(S3-only scope)") { + val conf = new Configuration(false) + conf.set("hadoop.security.credential.provider.path", "jceks://file/nonexistent.jceks") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("file:///tmp/table/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason declines via a per-bucket credential alias " + + "(fs.s3a.bucket.mybucket.access.key)") { + withJceks(Map("fs.s3a.bucket.mybucket.access.key" -> "AKIABUCKETSCOPED")) { conf => + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIABUCKETSCOPED")) + } + } + + test( + "s3ConfigDivergenceReason declines via a long-form per-bucket credential alias " + + "(fs.s3a.bucket.mybucket.fs.s3a.access.key), a Hadoop S3AUtils.lookupPassword alias " + + "the short-form check alone misses") { + withJceks( + Map( + "fs.s3a.bucket.mybucket.fs.s3a.access.key" -> "AKIALONGFORM", + "fs.s3a.bucket.mybucket.fs.s3a.secret.key" -> "longFormSecret")) { conf => + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIALONGFORM")) + assert(!reason.get.contains("longFormSecret")) + } + } + + test( + "s3ConfigDivergenceReason declines via a long-form per-bucket credential alias even when " + + "different plain global keys are also configured (Hadoop would resolve the long-form " + + "keystore value first; native reads only the differing plain globals)") { + withJceks( + Map( + "fs.s3a.bucket.mybucket.fs.s3a.access.key" -> "AKIALONGFORM", + "fs.s3a.bucket.mybucket.fs.s3a.secret.key" -> "longFormSecret")) { conf => + conf.set("fs.s3a.access.key", "AKIADIFFERENTGLOBAL") + conf.set("fs.s3a.secret.key", "differentGlobalSecret") + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIALONGFORM")) + assert(!reason.get.contains("longFormSecret")) + assert(!reason.get.contains("AKIADIFFERENTGLOBAL")) + assert(!reason.get.contains("differentGlobalSecret")) + } + } + + test( + "s3ConfigDivergenceReason declines on a long-form per-bucket provider path immediately, " + + "without touching a nonexistent keystore (Arm A proves no keystore I/O)") { + val tempDir = Files.createTempDirectory("comet-delta-no-keystore-long-bucket") + try { + val conf = new Configuration(false) + val nonexistentPath = "jceks://file" + tempDir + "/does-not-exist.jceks" + conf.set("fs.s3a.bucket.mybucket.fs.s3a.security.credential.provider.path", nonexistentPath) + // No exception from a missing file is the point of this test: Arm A declines on the + // presence of the long-form bucket-scoped path key alone, never reading it. + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert( + reason.get.contains("fs.s3a.bucket.mybucket.fs.s3a.security.credential.provider.path")) + } finally { + Files.delete(tempDir) + } + } + + test( + "s3ConfigDivergenceReason passes when only plain global keys are set and no provider " + + "path is configured, including the long-form bucket provider path (control: unaffected " + + "by the new long-form aliases)") { + val conf = new Configuration(false) + conf.set("fs.s3a.access.key", "AKIAPLAIN") + conf.set("fs.s3a.secret.key", "plainSecret") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason declines without throwing when the keystore is " + + "corrupt/unreadable (global arm try/catch containment)") { + val corruptFile = File.createTempFile("comet-delta-corrupt-creds", ".jceks") + try { + Files.write(corruptFile.toPath, Array[Byte](1, 2, 3, 4, 5, 6, 7, 8)) + val conf = new Configuration(false) + conf.set( + "hadoop.security.credential.provider.path", + "jceks://file" + corruptFile.getAbsolutePath) + // Must not throw: a corrupt/unreadable keystore must decline this bucket, not escape and + // abort planning for the whole session. + val reason = DeltaScanSupport.s3ConfigDivergenceReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + } finally { + corruptFile.delete() + } + } + + test( + "s3ConfigDivergenceReason declines when a plain long-form bucket credential key is set " + + "with nothing else (Hadoop resolves it, native's short-then-global lookup never sees " + + "it), naming the base key and bucket but never a credential value") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "AKIALONGPLAIN") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.secret.key", "longPlainSecret") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIALONGPLAIN")) + assert(!reason.get.contains("longPlainSecret")) + } + + test("s3ConfigDivergenceReason declines when a long-form bucket credential holds a Hadoop " + + "${...} reference that DOES resolve, with nothing else set (substitution alone does not " + + "erase the long-form divergence: native's short-then-global read never consults the long " + + "form regardless of what it expands to), naming the base key and bucket but never a value") { + val conf = new Configuration(false) + conf.set("review.longFormAccess", "AKIALONGRESOLVED") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "${review.longFormAccess}") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIALONGRESOLVED")) + assert(!reason.get.contains("${review.longFormAccess}")) + } + + test( + "s3ConfigDivergenceReason declines when a plain long-form bucket credential diverges " + + "from a different plain global value (Hadoop would use the long-form bucket value; " + + "native would use the differing global)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "AKIALONGPLAIN") + conf.set("fs.s3a.access.key", "AKIADIFFERENTGLOBALPLAIN") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("AKIALONGPLAIN")) + assert(!reason.get.contains("AKIADIFFERENTGLOBALPLAIN")) + } + + test( + "s3ConfigDivergenceReason declines when the plain long-form and short-form bucket " + + "credential keys are set to DIFFERENT values (Hadoop's SimpleAWSCredentialsProvider " + + "resolves the long pair; native resolves the short pair, so they diverge), naming the " + + "base key and bucket but never a credential value") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "long-ak") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.secret.key", "long-sk") + conf.set("fs.s3a.bucket.mybucket.access.key", "short-ak") + conf.set("fs.s3a.bucket.mybucket.secret.key", "short-sk") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("long-ak")) + assert(!reason.get.contains("short-ak")) + } + + test( + "control: S3AUtils#propagateBucketOptions folds a long-form bucket option into the " + + "unread key fs.s3a.fs.s3a.endpoint, proving Hadoop itself ignores the long form for " + + "general (non-credential) per-bucket options -- unlike lookupPassword for credentials, " + + "no Comet gate exists (or is needed) for this case") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.endpoint", "long-form.example.com") + conf.set("fs.s3a.endpoint", "global.example.com") + + // Real Hadoop code, not a Comet stand-in: S3AFileSystem#initialize assigns exactly this + // result to the `conf` it reads ENDPOINT/PATH_STYLE_ACCESS/etc. from. + val propagated = S3AUtils.propagateBucketOptions(conf, "mybucket") + assert(propagated.get("fs.s3a.endpoint") == "global.example.com") + assert(propagated.get("fs.s3a.fs.s3a.endpoint") == "long-form.example.com") + } + + test( + "s3ConfigDivergenceReason declines when a bucket-scoped credential references another " + + "bucket-scoped key that Hadoop's real propagate-then-resolve order shadows the global " + + "value with (Hadoop resolves the bucket-scoped referent; native, which never propagates " + + "bucket options, still resolves the global one), naming the base key and bucket but " + + "never a credential value") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.access.key", "${fs.s3a.custom.ref}") + conf.set("fs.s3a.bucket.mybucket.custom.ref", "bucket-scoped-value") + conf.set("fs.s3a.custom.ref", "global-value") + + // Real Hadoop code, not a Comet stand-in: this is exactly what S3AFileSystem#initialize + // assigns to the `conf` it later reads fs.s3a.access.key from -- the bucket-scoped + // fs.s3a.bucket.mybucket.custom.ref overwrites the global fs.s3a.custom.ref BEFORE the + // ${...} reference in the propagated fs.s3a.bucket.mybucket.access.key is ever substituted. + val propagated = S3AUtils.propagateBucketOptions(conf, "mybucket") + assert(propagated.get("fs.s3a.custom.ref") == "bucket-scoped-value") + assert(propagated.get("fs.s3a.bucket.mybucket.access.key") == "bucket-scoped-value") + + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("bucket-scoped-value")) + assert(!reason.get.contains("global-value")) + } + + test( + "s3ConfigDivergenceReason passes when a bucket-scoped credential references another " + + "bucket-scoped key whose propagated value happens to equal the global value (no actual " + + "divergence, despite the same shadowing mechanism as the declining case above)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.access.key", "${fs.s3a.custom.ref}") + conf.set("fs.s3a.bucket.mybucket.custom.ref", "same-value") + conf.set("fs.s3a.custom.ref", "same-value") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason declines, without any keystore I/O, when a bucket-scoped " + + "long-form credential-provider-path key (Arm A) is itself set via a ${...} reference to " + + "another bucket-scoped key that Hadoop's real propagate-then-resolve order shadows the " + + "global value with -- naming only the provider path key and bucket, never either " + + "resolved path") { + // Uses the LONG form (fs.s3a.bucket.B.fs.s3a.security.credential.provider.path), not the + // short form, deliberately: propagateBucketOptions folds ANY fs.s3a.bucket.B. key into + // a global fs.s3a. key. For the short form, is + // "security.credential.provider.path", so it propagates into the GLOBAL S3A-scoped provider + // path key itself (fs.s3a.security.credential.provider.path) -- correctly triggering the + // OTHER Arm A branch instead, since real Hadoop would see the same thing. The long form's + // is "fs.s3a.security.credential.provider.path", which propagates into the inert, + // double-prefixed fs.s3a.fs.s3a.security.credential.provider.path key instead, isolating + // the long-form bucket-scoped branch this test targets. + val conf = new Configuration(false) + conf.set( + "fs.s3a.bucket.mybucket.fs.s3a.security.credential.provider.path", + "${fs.s3a.custom.ref}") + conf.set( + "fs.s3a.bucket.mybucket.custom.ref", + "jceks://file/does-not-exist-bucket-scoped.jceks") + conf.set("fs.s3a.custom.ref", "jceks://file/does-not-exist-global.jceks") + + // Real Hadoop code, not a Comet stand-in: this is exactly what S3AFileSystem#initialize + // assigns to the `conf` it later reads the bucket-scoped provider path from -- the + // bucket-scoped fs.s3a.bucket.mybucket.custom.ref overwrites the global fs.s3a.custom.ref + // BEFORE the ${...} reference in the propagated provider path key is ever substituted. + val propagated = S3AUtils.propagateBucketOptions(conf, "mybucket") + assert( + propagated.get("fs.s3a.custom.ref") == "jceks://file/does-not-exist-bucket-scoped.jceks") + assert( + propagated.get("fs.s3a.bucket.mybucket.fs.s3a.security.credential.provider.path") == + "jceks://file/does-not-exist-bucket-scoped.jceks") + // Confirms the long form's propagated target is the inert double-prefixed key, NOT the + // global S3A-scoped provider path key -- i.e. this test genuinely isolates the long-form + // bucket-scoped branch rather than accidentally exercising the global-S3A-path branch. + assert(propagated.get("fs.s3a.security.credential.provider.path") == null) + + // Neither referenced path exists on disk -- if this gate mistakenly tried to open either + // as a keystore instead of declining on the key's mere presence (Arm A), it would throw + // rather than return a reason, which this test would catch. + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.bucket.mybucket.fs.s3a.security.credential.provider.path")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("does-not-exist-bucket-scoped")) + assert(!reason.get.contains("does-not-exist-global")) + } + + test( + "s3ConfigDivergenceReason declines when the long-form and global bucket credential keys " + + "share the same value but the short-form bucket keys are set to EMPTY strings (Hadoop's " + + "SimpleAWSCredentialsProvider resolves the long pair via lookupPassword's skip-empty " + + "semantics; native's get_config_trimmed resolves the short pair's mere PRESENCE, landing " + + "on empty credentials instead), naming the base key and bucket but never a credential " + + "value") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.secret.key", "shared-sk") + conf.set("fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.secret.key", "shared-sk") + conf.set("fs.s3a.bucket.mybucket.access.key", "") + conf.set("fs.s3a.bucket.mybucket.secret.key", "") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("shared-ak")) + } + + test("s3ConfigDivergenceReason declines when the short-form bucket credential keys hold only " + + "whitespace: native's get_config_trimmed still resolves the key's mere PRESENCE before " + + "trimming its value, so a whitespace-only short-form key diverges from Hadoop's long-form " + + "resolution exactly like an outright empty one") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.secret.key", "shared-sk") + conf.set("fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.secret.key", "shared-sk") + conf.set("fs.s3a.bucket.mybucket.access.key", " ") + conf.set("fs.s3a.bucket.mybucket.secret.key", " ") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.access.key")) + assert(reason.get.contains("mybucket")) + } + + test( + "control: s3ConfigDivergenceReason passes when the short-form bucket credential keys are " + + "absent rather than empty, so Hadoop's long-form resolution and native's short-then-global " + + "resolution both land on the same shared pair") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.bucket.mybucket.fs.s3a.secret.key", "shared-sk") + conf.set("fs.s3a.access.key", "shared-ak") + conf.set("fs.s3a.secret.key", "shared-sk") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "control: s3ConfigDivergenceReason passes when a global-only value (no bucket override at " + + "all, so Hadoop's and native's effective values are the exact same conf entry) carries " + + "incidental leading/trailing whitespace, such as Hadoop's own multi-line " + + "fs.s3a.aws.credentials.provider default -- trimming must apply symmetrically to both " + + "sides of the comparison, or an untouched default value would diverge from itself and " + + "decline every S3 scan") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "\n org.apache.hadoop.fs.s3a.TemporaryAWSCredentialsProvider,\n " + + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider\n ") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + // ----------------------------------------------------------------------------------------- + // providerClassGateReason: declines an unsupported credential-provider class (or + // invalid combination) before the scan is claimed, rather than letting it fail during + // execution in s3.rs's build_aws_credential_provider_metadata. + // ----------------------------------------------------------------------------------------- + + private val nativeSupportedProviderClasses = Seq( + "org.apache.hadoop.fs.s3a.auth.IAMInstanceCredentialsProvider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider", + "org.apache.hadoop.fs.s3a.TemporaryAWSCredentialsProvider", + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider", + "software.amazon.awssdk.auth.credentials.ContainerCredentialsProvider", + "com.amazonaws.auth.ContainerCredentialsProvider", + "com.amazonaws.auth.EC2ContainerCredentialsProviderWrapper", + "software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider", + "com.amazonaws.auth.InstanceProfileCredentialsProvider", + "software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider", + "com.amazonaws.auth.EnvironmentVariableCredentialsProvider", + "software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider", + "com.amazonaws.auth.WebIdentityTokenCredentialsProvider", + "software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider", + "com.amazonaws.auth.profile.ProfileCredentialsProvider", + "software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider", + "com.amazonaws.auth.AnonymousAWSCredentials") + + test( + "providerClassGateReason passes when aws.credentials.provider is unset (native's " + + "default AWS SDK provider chain)") { + val conf = new Configuration(false) + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test("providerClassGateReason passes for every credential provider class s3.rs supports") { + nativeSupportedProviderClasses.foreach { className => + val conf = new Configuration(false) + conf.set("fs.s3a.aws.credentials.provider", className) + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isEmpty, s"Expected $className to be claimable, but got: $reason") + } + } + + test( + "providerClassGateReason declines an unsupported credential provider class, naming the " + + "class and the bucket") { + val conf = new Configuration(false) + conf.set("fs.s3a.aws.credentials.provider", "com.example.CustomCredentialsProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("com.example.CustomCredentialsProvider")) + assert(reason.get.contains("mybucket")) + } + + test( + "providerClassGateReason declines via the per-bucket short form, honoring bucket-scoped " + + "override (mirrors get_config's short-then-global resolution)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set( + "fs.s3a.bucket.mybucket.aws.credentials.provider", + "com.example.CustomCredentialsProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("com.example.CustomCredentialsProvider")) + } + + test( + "providerClassGateReason passes a comma-separated list of entirely supported provider " + + "classes (native chains them via build_chained_aws_credential_provider_metadata)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider, " + + "software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason declines a comma-separated list containing one unsupported " + + "class, naming only the unsupported one") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider,com.example.Bogus") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("com.example.Bogus")) + assert(!reason.get.contains("SimpleAWSCredentialsProvider")) + } + + test( + "providerClassGateReason declines an anonymous provider mixed with another provider " + + "(native's build_credential_provider rejects this combination at execution time)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider," + + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("anonymous")) + } + + test( + "providerClassGateReason passes a solo anonymous provider (native returns None -- an " + + "unsigned client -- rather than erroring; only a MIX with other providers is rejected)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason passes AssumedRoleCredentialProvider with an unset " + + "assumed.role.credentials.provider (native defaults to its own always-supported " + + "[Simple, EnvironmentVariable] fallback)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason declines AssumedRoleCredentialProvider whose " + + "assumed.role.credentials.provider names an unsupported base provider class") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider") + conf.set("fs.s3a.assumed.role.credentials.provider", "com.example.BogusBaseProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("com.example.BogusBaseProvider")) + assert(reason.get.contains("fs.s3a.assumed.role.credentials.provider")) + } + + test( + "providerClassGateReason declines AssumedRoleCredentialProvider whose " + + "assumed.role.credentials.provider names an anonymous base provider (native rejects ANY " + + "anonymous entry here, not just a mix)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider") + conf.set( + "fs.s3a.assumed.role.credentials.provider", + "org.apache.hadoop.fs.s3a.AnonymousAWSCredentialsProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("anonymous")) + } + + test( + "providerClassGateReason ignores assumed.role.credentials.provider when " + + "AssumedRoleCredentialProvider is not itself in play (dead config on the native side)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set("fs.s3a.assumed.role.credentials.provider", "com.example.BogusBaseProvider") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason passes when the global aws.credentials.provider key holds a " + + "Hadoop variable reference that Configuration#get expands to a supported class " + + "(post-substitution, native's plain-conf extraction sees the same expanded class name " + + "the class-support check does, so no divergence exists to decline)") { + val conf = new Configuration(false) + conf.set("review.provider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set("fs.s3a.aws.credentials.provider", "${review.provider}") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason passes when a bucket-scoped short-form " + + "aws.credentials.provider override holds a variable reference that expands to a " + + "supported class, even though the global key is a different supported literal") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set("review.bucketProvider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set("fs.s3a.bucket.mybucket.aws.credentials.provider", "${review.bucketProvider}") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason passes when the assumed-role base-provider key holds a " + + "variable reference that expands to a supported base class") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.auth.AssumedRoleCredentialProvider") + conf.set("review.baseProvider", "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + conf.set("fs.s3a.assumed.role.credentials.provider", "${review.baseProvider}") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "providerClassGateReason passes literal provider classes with no variable references " + + "(unaffected by variable expansion, still runs the class-support gate)") { + val conf = new Configuration(false) + conf.set( + "fs.s3a.aws.credentials.provider", + "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider") + assert( + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + + val badConf = new Configuration(false) + badConf.set("fs.s3a.aws.credentials.provider", "com.example.CustomCredentialsProvider") + val reason = + DeltaScanSupport + .providerClassGateReason(badConf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("com.example.CustomCredentialsProvider")) + } + + test( + "providerClassGateReason declines rather than throws when a two-key mutual Hadoop " + + "variable-reference cycle involves fs.s3a.aws.credentials.provider, called DIRECTLY " + + "(not routed through s3ConfigDivergenceReason, which masks this for the same keys when " + + "checked first -- this pins the gate's OWN containment, not that coupling)") { + val conf = new Configuration(false) + conf.set("fs.s3a.aws.credentials.provider", "${fs.s3a.assumed.role.credentials.provider}") + conf.set("fs.s3a.assumed.role.credentials.provider", "${fs.s3a.aws.credentials.provider}") + val reason = + DeltaScanSupport + .providerClassGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.aws.credentials.provider")) + assert(reason.get.contains("IllegalStateException")) + assert(!reason.get.contains("${fs.s3a.assumed.role.credentials.provider}")) + assert(!reason.get.contains("${fs.s3a.aws.credentials.provider}")) + } + + test( + "s3ConfigDivergenceReason passes when both the plain long-form and short-form bucket " + + "credential keys are set to the EQUAL value (Hadoop's long-first resolution and " + + "native's short-then-global resolution agree)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "AKIASAMEBOTH") + conf.set("fs.s3a.bucket.mybucket.access.key", "AKIASAMEBOTH") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when the plain long-form bucket credential value equals " + + "the plain global value (both sides resolve to the same value)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.access.key", "AKIASAME") + conf.set("fs.s3a.access.key", "AKIASAME") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes with only a plain short-form bucket credential key set " + + "(control: unaffected by the long-form plain-value check)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.access.key", "AKIASHORTONLY") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when a credential key holds a Hadoop variable reference " + + "that Configuration#get expands to a literal (post-substitution, native's plain-conf " + + "extraction forwards the SAME expanded value this comparator reads, so both sides agree)") { + val conf = new Configuration(false) + conf.set("review.access", "AKIAEXAMPLE") + conf.set("fs.s3a.access.key", "${review.access}") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when credential keys hold literal values with no " + + "variable references") { + val conf = new Configuration(false) + conf.set("fs.s3a.access.key", "AKIALITERAL") + conf.set("fs.s3a.secret.key", "literalSecretValue") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when a credential key references an undefined variable " + + "(Hadoop leaves the literal unresolved, so native and Hadoop see the identical value)") { + val conf = new Configuration(false) + conf.set("fs.s3a.access.key", "${undefined.var}") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason passes when a bucket-scoped short-form credential alias holds " + + "a variable reference that expands identically for both sides (alias-set coverage " + + "beyond the plain global key)") { + val conf = new Configuration(false) + conf.set("review.secret", "topSecretValue") + conf.set("fs.s3a.bucket.mybucket.secret.key", "${review.secret}") + assert( + DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "s3ConfigDivergenceReason does not throw for a credential key that is its own Hadoop " + + "variable reference (Configuration#get's substitution loop converges immediately -- " + + "the raw and expanded literals are already equal -- so this is the same safe shape as " + + "an undefined variable, not a MAX_SUBST failure)") { + val conf = new Configuration(false) + conf.set("fs.s3a.secret.key", "realSecretValue") + conf.set("fs.s3a.access.key", "${fs.s3a.access.key}") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert( + reason.isEmpty, + s"expected no decline (and no exception) for a literal " + + s"self-reference, since it resolves to the same unexpanded text on both sides: $reason") + } + + test( + "s3ConfigDivergenceReason declines rather than throws when two credential keys form a " + + "mutual Hadoop variable-reference cycle (Configuration#get raises IllegalStateException " + + "once ${...} substitution recurses past Hadoop's MAX_SUBST bound)") { + val conf = new Configuration(false) + conf.set("fs.s3a.access.key", "${fs.s3a.secret.key}") + conf.set("fs.s3a.secret.key", "${fs.s3a.access.key}") + val reason = DeltaScanSupport + .s3ConfigDivergenceReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("IllegalStateException")) + assert(!reason.get.contains("realSecretValue")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines a bucket configured with global SSE-C, " + + "naming the algorithm key and the algorithm but never the customer-provided key value") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "SSE-C") + conf.set("fs.s3a.encryption.key", "c3VwZXItc2VjcmV0LWN1c3RvbWVyLWtleQ==") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.encryption.algorithm")) + assert(reason.get.contains("SSE-C")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("c3VwZXItc2VjcmV0LWN1c3RvbWVyLWtleQ==")) + } + + test( + "unsupportedEncryptionAlgorithmReason matches the SSE-C algorithm value " + + "case-insensitively, mirroring S3AEncryptionMethods#getMethod's equalsIgnoreCase " + + "parsing") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "sse-c") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("mybucket")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines a bucket configured with the deprecated " + + "fs.s3a.server-side-encryption-algorithm spelling of SSE-C, naming the algorithm key " + + "actually consulted but never the customer-provided key value") { + val conf = new Configuration(false) + conf.set("fs.s3a.server-side-encryption-algorithm", "SSE-C") + conf.set("fs.s3a.server-side-encryption.key", "c3VwZXItc2VjcmV0LWN1c3RvbWVyLWtleQ==") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + // Names EITHER spelling, never both/neither: hadoop-aws's S3AFileSystem statically registers + // this exact pair as a Configuration-level deprecated alias (verified via javap -- + // S3AFileSystem.addDeprecatedKeys() calls Configuration.addDeprecations, a field static on + // Hadoop's Configuration class, process-wide once S3AFileSystem's class has loaded anywhere + // in this JVM -- which a real Spark job has always done by the time it evaluates this gate, + // since reading the S3 table at all requires that class). Once registered, + // Configuration#get resolves either literal key to the same value transparently, so which + // name THIS gate happens to read the value under depends on whether that static + // registration already ran elsewhere in the test JVM, not on anything this test controls. + assert( + reason.get.contains("fs.s3a.server-side-encryption-algorithm") || + reason.get.contains("fs.s3a.encryption.algorithm")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("c3VwZXItc2VjcmV0LWN1c3RvbWVyLWtleQ==")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines only the bucket whose per-bucket " + + "SHORT-form key sets SSE-C, leaving an unrelated bucket unaffected") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.secure-bucket.encryption.algorithm", "SSE-C") + val declined = DeltaScanSupport.unsupportedEncryptionAlgorithmReason( + conf, + Seq(new URI("s3a://secure-bucket/part-0.parquet"))) + assert(declined.isDefined) + assert(declined.get.contains("secure-bucket")) + + assert( + DeltaScanSupport + .unsupportedEncryptionAlgorithmReason( + conf, + Seq(new URI("s3a://other-bucket/part-0.parquet"))) + .isEmpty) + } + + test( + "unsupportedEncryptionAlgorithmReason DOES fire for SSE-C set only via the LONG " + + "per-bucket form: S3AUtils#lookupBucketSecret is long-then-short, " + + "decompiled from hadoop-aws 3.3.4's S3AUtils.class -- unlike a plain propagated option, " + + "the encryption algorithm's bucket tier DOES consult fs.s3a.bucket.B.fs.s3a.encryption." + + "algorithm, and Hadoop's own reader picks SSE-C from it, so this must decline exactly " + + "like the short-form case above") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.secure-bucket.fs.s3a.encryption.algorithm", "SSE-C") + val reason = DeltaScanSupport.unsupportedEncryptionAlgorithmReason( + conf, + Seq(new URI("s3a://secure-bucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("secure-bucket")) + assert(reason.get.contains("SSE-C")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines CSE-KMS (client-side encryption): the " + + "native Parquet reader has no client-side decryption layer, so it would read raw " + + "ciphertext where Hadoop's own reader, which decrypts client-side via the SDK, succeeds") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "CSE-KMS") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.encryption.algorithm")) + assert(reason.get.contains("CSE-KMS")) + assert(reason.get.contains("mybucket")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines CSE-CUSTOM (client-side encryption) the " + + "same way as CSE-KMS") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "CSE-CUSTOM") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("CSE-CUSTOM")) + } + + test( + "unsupportedEncryptionAlgorithmReason declines an unrecognized future algorithm string " + + "(allowlist semantics: anything not positively confirmed transparent declines, rather " + + "than a blocklist that would silently admit a new Hadoop encryption method)") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "SOME-FUTURE-ALGORITHM") + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("SOME-FUTURE-ALGORITHM")) + } + + test( + "unsupportedEncryptionAlgorithmReason passes for AES256, SSE-KMS, DSSE-KMS, and for no " + + "encryption configured at all (S3 decrypts these server-side algorithms transparently " + + "on GET/HEAD given read permission alone; only SSE-C requires a client-sent key, and " + + "only CSE-* requires client-side decryption)") { + for (algorithm <- Seq("AES256", "SSE-KMS", "DSSE-KMS")) { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", algorithm) + conf.set("fs.s3a.encryption.key", "arn:aws:kms:us-east-1:123456789012:key/abc-123") + assert( + DeltaScanSupport + .unsupportedEncryptionAlgorithmReason( + conf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty, + s"expected $algorithm to be allowlisted") + } + + val unsetConf = new Configuration(false) + assert( + DeltaScanSupport + .unsupportedEncryptionAlgorithmReason( + unsetConf, + Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "unsupportedEncryptionAlgorithmReason declines when the algorithm is stored ONLY in a " + + "JCEKS keystore as SSE-C, naming the algorithm key and value but never any keystore " + + "material (buildEncryptionSecrets resolves the algorithm via getPassword, which this " + + "gate now mirrors instead of the JCEKS-blind plain-conf read that used to under-decline " + + "this case)") { + withJceks(Map("fs.s3a.encryption.algorithm" -> "SSE-C")) { conf => + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.encryption.algorithm")) + assert(reason.get.contains("SSE-C")) + assert(reason.get.contains("mybucket")) + } + } + + test( + "unsupportedEncryptionAlgorithmReason passes when the algorithm is stored ONLY in a JCEKS " + + "keystore as AES256 (allowlisted even through the keystore-aware resolution path)") { + withJceks(Map("fs.s3a.encryption.algorithm" -> "AES256")) { conf => + assert(DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + } + + test( + "unsupportedEncryptionAlgorithmReason declines without throwing when the keystore backing " + + "the algorithm is corrupt/unreadable (global arm try/catch containment, same pattern as " + + "s3ConfigDivergenceReason's corrupt-keystore test)") { + val corruptFile = File.createTempFile("comet-delta-corrupt-encryption-creds", ".jceks") + try { + Files.write(corruptFile.toPath, Array[Byte](1, 2, 3, 4, 5, 6, 7, 8)) + val conf = new Configuration(false) + conf.set( + "hadoop.security.credential.provider.path", + "jceks://file" + corruptFile.getAbsolutePath) + // Must not throw: a corrupt/unreadable keystore must decline this bucket, not escape and + // abort planning for the whole session. + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + } finally { + corruptFile.delete() + } + } + + test( + "unsupportedEncryptionAlgorithmReason declines on an S3A-scoped provider path immediately " + + "when resolving the algorithm, without touching a nonexistent keystore (Arm A proves no " + + "keystore I/O), even though no algorithm key is set in plain conf") { + val tempDir = Files.createTempDirectory("comet-delta-encryption-no-keystore") + try { + val conf = new Configuration(false) + val nonexistentPath = "jceks://file" + tempDir + "/does-not-exist.jceks" + conf.set("fs.s3a.security.credential.provider.path", nonexistentPath) + val reason = DeltaScanSupport + .unsupportedEncryptionAlgorithmReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.security.credential.provider.path")) + } finally { + Files.delete(tempDir) + } + } + + test( + "unsupportedEncryptionAlgorithmReason does not fire for non-S3 URIs even when SSE-C is " + + "configured globally (scheme-scoped, no S3 bucket to derive from a file:// or gs:// " + + "URI)") { + val conf = new Configuration(false) + conf.set("fs.s3a.encryption.algorithm", "SSE-C") + conf.set("fs.s3a.encryption.key", "c3VwZXItc2VjcmV0LWN1c3RvbWVyLWtleQ==") + assert( + DeltaScanSupport + .unsupportedEncryptionAlgorithmReason( + conf, + Seq( + new URI("file:///tmp/table/part-0.parquet"), + new URI("gs://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "proxyGateReason declines a bucket configured with a global fs.s3a.proxy.host, naming the " + + "key and bucket but never any proxy credential") { + val conf = new Configuration(false) + conf.set("fs.s3a.proxy.host", "proxy.internal.example.com") + conf.set("fs.s3a.proxy.port", "8080") + conf.set("fs.s3a.proxy.username", "proxyuser") + conf.set("fs.s3a.proxy.password", "proxySecretValue") + val reason = + DeltaScanSupport.proxyGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.proxy.host")) + assert(reason.get.contains("mybucket")) + assert(!reason.get.contains("proxyuser")) + assert(!reason.get.contains("proxySecretValue")) + assert(!reason.get.contains("proxy.internal.example.com")) + } + + test( + "proxyGateReason declines via a short-form per-bucket fs.s3a.proxy.host " + + "(fs.s3a.bucket.mybucket.proxy.host)") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.proxy.host", "proxy.internal.example.com") + val reason = + DeltaScanSupport.proxyGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.proxy.host")) + assert(reason.get.contains("mybucket")) + } + + test( + "proxyGateReason declines via a long-form per-bucket fs.s3a.proxy.host " + + "(fs.s3a.bucket.mybucket.fs.s3a.proxy.host), matching hadoopLookupPasswordEffective's " + + "long-then-short-then-global cascade reused for this key") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.mybucket.fs.s3a.proxy.host", "proxy.internal.example.com") + val reason = + DeltaScanSupport.proxyGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.proxy.host")) + assert(reason.get.contains("mybucket")) + } + + test( + "proxyGateReason passes when no fs.s3a.proxy.host is configured anywhere (zero-I/O, no " + + "provider path set)") { + val conf = new Configuration(false) + assert( + DeltaScanSupport + .proxyGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "proxyGateReason declines only the bucket whose proxy host is actually configured, " + + "leaving an unrelated bucket unaffected") { + val conf = new Configuration(false) + conf.set("fs.s3a.bucket.proxied-bucket.proxy.host", "proxy.internal.example.com") + val declined = + DeltaScanSupport.proxyGateReason(conf, Seq(new URI("s3a://proxied-bucket/part-0.parquet"))) + assert(declined.isDefined) + assert(declined.get.contains("proxied-bucket")) + + assert( + DeltaScanSupport + .proxyGateReason(conf, Seq(new URI("s3a://other-bucket/part-0.parquet"))) + .isEmpty) + } + + test( + "proxyGateReason does not fire for non-S3 URIs even when fs.s3a.proxy.host is configured " + + "globally (scheme-scoped, no S3 bucket to derive from a file:// or gs:// URI)") { + val conf = new Configuration(false) + conf.set("fs.s3a.proxy.host", "proxy.internal.example.com") + assert( + DeltaScanSupport + .proxyGateReason( + conf, + Seq( + new URI("file:///tmp/table/part-0.parquet"), + new URI("gs://mybucket/part-0.parquet"))) + .isEmpty) + } + + test( + "proxyGateReason declines on an S3A-scoped provider path immediately, without touching a " + + "nonexistent keystore (Arm A proves no keystore I/O), even though no fs.s3a.proxy.host " + + "is set in plain conf -- the host's true value cannot be ruled empty without consulting " + + "the provider this gate cannot safely mirror") { + val tempDir = Files.createTempDirectory("comet-delta-proxy-no-keystore") + try { + val conf = new Configuration(false) + val nonexistentPath = "jceks://file" + tempDir + "/does-not-exist.jceks" + conf.set("fs.s3a.security.credential.provider.path", nonexistentPath) + val reason = + DeltaScanSupport.proxyGateReason(conf, Seq(new URI("s3a://mybucket/part-0.parquet"))) + assert(reason.isDefined) + assert(reason.get.contains("fs.s3a.security.credential.provider.path")) + } finally { + Files.delete(tempDir) + } + } + + // --------------------------------------------------------------------------------------- + // Discovery harness: mechanically bounds the "which fs.s3a.* keys does this comparator need + // to know about" model, rather than relying on someone noticing the next one by hand (which + // is exactly how the SSE-C long-bucket-alias gap went unnoticed). Two + // independent checks: + // (a) DeltaScanSupport.AllS3ConfigKeys must be a superset of native's OWN checked-in list + // of every fs.s3a.* property it reads (native/core/src/parquet/objectstore/s3.rs's + // NATIVE_S3A_CONFIG_PROPERTIES, itself mechanically verified against that file's call + // sites by a Rust unit test -- see that constant's doc). + // (b) Every fs.s3a.* key Hadoop's own Constants class declares that looks credential- or + // encryption-shaped (name contains key/secret/token/password/encryption) must be either + // covered by AllS3ConfigKeys or explicitly, individually documented as exempt -- a loud + // failure naming the key the moment Hadoop grows a new one nobody has classified yet. + // --------------------------------------------------------------------------------------- + + test( + "discovery harness: AllS3ConfigKeys is a superset of native's checked-in " + + "NATIVE_S3A_CONFIG_PROPERTIES list (native/core/src/parquet/objectstore/s3.rs)") { + val rustPath = + DeltaScanContribSuite.findRepoFile("native/core/src/parquet/objectstore/s3.rs") + rustPath match { + case None => + cancel( + "Could not locate native/core/src/parquet/objectstore/s3.rs from this checkout; " + + "skipping the native-key-list superset guard.") + case Some(file) => + val contents = scala.io.Source.fromFile(file, "UTF-8").mkString + val marker = "NATIVE_S3A_CONFIG_PROPERTIES: &[&str] = &[" + val start = contents.indexOf(marker) + assert( + start >= 0, + s"Expected ${file.getAbsolutePath} to declare NATIVE_S3A_CONFIG_PROPERTIES -- has " + + "the constant been renamed or removed?") + val end = contents.indexOf("];", start) + assert(end > start, "Expected a `];`-terminated array literal after the marker") + val arrayBody = contents.substring(start + marker.length, end) + val nativeProperties = + "\"([^\"]*)\"".r.findAllMatchIn(arrayBody).map(_.group(1)).toSet + assert( + nativeProperties.nonEmpty, + "Parsed zero property names out of NATIVE_S3A_CONFIG_PROPERTIES -- the parser above " + + "is likely out of sync with the constant's declaration syntax") + + val nativeKeys = nativeProperties.map(p => s"fs.s3a.$p") + val comparatorKeys = DeltaScanSupport.AllS3ConfigKeys.toSet + val uncovered = nativeKeys.diff(comparatorKeys) + assert( + uncovered.isEmpty, + "Native reads fs.s3a.* key(s) that DeltaScanSupport.AllS3ConfigKeys does not compare, " + + s"so a Hadoop-vs-native divergence on any of them would go undetected: " + + s"${uncovered.toSeq.sorted.mkString(", ")} -- add the missing key(s) to " + + "AllS3ConfigKeys") + } + } + + test( + "discovery harness: every credential/encryption-shaped fs.s3a.* key Hadoop's Constants " + + "class declares is either compared by AllS3ConfigKeys or individually documented as " + + "exempt") { + val constantsClassName = "org.apache.hadoop.fs.s3a.Constants" + val constantsClass = + try { + Some(Class.forName(constantsClassName)) + } catch { + case _: ClassNotFoundException => None + } + constantsClass match { + case None => + cancel( + s"$constantsClassName is not on the test classpath (expected via the " + + "spark-hadoop-cloud test dependency); skipping the sensitive-key coverage guard.") + case Some(cls) => + val allS3aKeys = cls.getFields + .filter { f => + f.getType == classOf[String] && + java.lang.reflect.Modifier.isStatic(f.getModifiers) + } + .flatMap { f => + f.get(null) match { + case s: String if s.startsWith("fs.s3a.") => Some(s) + case _ => None + } + } + .toSet + assert( + allS3aKeys.size > 20, + s"Expected many fs.s3a.* keys via reflection on $constantsClassName, found only " + + s"${allS3aKeys.size} -- has the class's field layout changed in a way this " + + "reflection no longer handles?") + + val sensitiveNameFragments = + Seq("key", "secret", "token", "password", "encryption") + val sensitiveKeys = allS3aKeys.filter { key => + val lower = key.toLowerCase(Locale.ROOT) + sensitiveNameFragments.exists(lower.contains) + } + + val comparatorKeys = DeltaScanSupport.AllS3ConfigKeys.toSet + // Individually justified, one at a time -- NOT a blanket "everything encryption-shaped + // is exempt" carve-out, which would have hidden the SSE-C long-bucket-alias gap just as easily as + // never checking at all. + val documentedExempt: Map[String, String] = Map( + "fs.s3a.encryption.algorithm" -> + ("handled by the dedicated unsupportedEncryptionAlgorithmReason/" + + "effectiveEncryptionAlgorithm allowlist gate, not the generic comparator (needs " + + "its own canonical/deprecated resolution cascade, not a flat single-key compare)"), + "fs.s3a.server-side-encryption-algorithm" -> + "deprecated alias of fs.s3a.encryption.algorithm, same dedicated gate", + "fs.s3a.encryption.key" -> + ("key MATERIAL for the algorithm above; never read for comparison at all -- the " + + "allowlist gate declines on the ALGORITHM alone, so the key's value cannot " + + "change the outcome, and never appears in a decline reason (see " + + "effectiveEncryptionAlgorithm's doc)"), + "fs.s3a.server-side-encryption.key" -> + "deprecated alias of fs.s3a.encryption.key, same reasoning", + "fs.s3a.proxy.password" -> + ("covered via the dedicated fs.s3a.proxy.host gate (proxyGateReason/" + + "unsupportedProxyReason), not the generic comparator: the password (and the " + + "sibling fs.s3a.proxy.username, not sensitive-shaped so never reaches this map) " + + "only matters once a proxy is actually in effect, and any bucket with a " + + "non-empty effective fs.s3a.proxy.host now declines outright, before any " + + "credential comparison would even run -- so the deployment shape this key used " + + "to be a KNOWN GAP for (a Hadoop deployment requiring a proxy for S3 egress " + + "being silently claimed and connected to directly) can no longer reach this key " + + "at all; the password's VALUE itself is still never read or forwarded to native, " + + "same as before"), + "fs.s3a.failinject.inconsistency.key.substring" -> + ("hadoop-aws test-only S3 fault-injection knob (InconsistentAmazonS3Client " + + "family), not a credential; matches the sensitive-name heuristic only " + + "incidentally via \"key.substring\"")) + + val unclassified = sensitiveKeys + .diff(comparatorKeys) + .diff(documentedExempt.keySet) + assert( + unclassified.isEmpty, + "Hadoop's Constants class declares credential/encryption-shaped fs.s3a.* key(s) " + + "this discovery harness has never classified (neither compared by " + + s"AllS3ConfigKeys nor documented as exempt above): ${unclassified.toSeq.sorted + .mkString(", ")} -- decide whether the key needs a gate, then either add it to " + + "AllS3ConfigKeys or add a justified entry to `documentedExempt` in this test") + } + } + +} + +object DeltaScanContribSuite { + + /** + * Walks up from a candidate root (the `comet.repo.root` system property when set, otherwise + * `user.dir`) looking for `relativePath`. Handles both a repo-root working directory and a + * module-root working directory (e.g. `contrib/delta-spark`) without hardcoding either. + * + * Package-visible (not `private`) so other suites in this package needing a repo-relative file + * (e.g. [[JvmLowercaseParitySuite]]) can share it instead of duplicating it. + */ + private[delta] def findRepoFile(relativePath: String): Option[File] = { + val startDir = Option(System.getProperty("comet.repo.root")) + .map(new File(_)) + .getOrElse(new File(System.getProperty("user.dir"))) + Iterator + .iterate(Option(startDir))(_.flatMap(d => Option(d.getParentFile))) + .takeWhile(_.isDefined) + .map(_.get) + .map(new File(_, relativePath)) + .find(_.isFile) + } +} diff --git a/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/JvmLowercaseParitySuite.scala b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/JvmLowercaseParitySuite.scala new file mode 100644 index 00000000000..0ed34e25759 --- /dev/null +++ b/contrib/delta-spark/src/test/scala/org/apache/comet/contrib/delta/JvmLowercaseParitySuite.scala @@ -0,0 +1,343 @@ +/* + * 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. + */ + +package org.apache.comet.contrib.delta + +import java.util.Locale + +import org.scalatest.funsuite.AnyFunSuite + +import org.apache.comet.serde.operator.JvmCaseTables + +/** + * Self-validating proof that `JvmCaseTables.mirrorLowercase` -- a line-for-line mirror of the + * native `JvmCaseTables::lowercase` in `native/core/src/parquet/schema_adapter.rs`, run over the + * tables `JvmCaseTables` generates from the RUNNING JVM -- reproduces this JVM's + * `String.toLowerCase(Locale.ROOT)`. Because both the tables and the expectations come from the + * same running JVM, this suite passes on ANY supported JDK by construction. + * + * Needs no `SparkSession`, so it extends `AnyFunSuite` directly rather than `CometDeltaTestBase`. + */ +class JvmLowercaseParitySuite extends AnyFunSuite { + + private def hex(s: String): String = + s.codePoints().toArray.map(cp => f"U+$cp%04X").mkString(" ") + + private def assertMirrors(input: String): Option[String] = { + val expected = input.toLowerCase(Locale.ROOT) + val actual = JvmCaseTables.mirrorLowercase(input) + if (expected == actual) None + else Some(s"[${hex(input)}] jvm=[${hex(expected)}] mirror=[${hex(actual)}]") + } + + test("generated tables are well-formed for the wire") { + val t = JvmCaseTables.generated + assert(t.lowerCps.length == t.lowerRepls.length, "lowercase arrays must stay index-aligned") + assert(t.lowerCps.nonEmpty, "every Unicode version maps at least ASCII A-Z") + // Strictly increasing codepoints (generation sweeps in order; native builds a map). + t.lowerCps.sliding(2).foreach { + case Array(a, b) => assert(a < b, f"lowercase codepoints out of order at U+$a%04X") + case _ => + } + t.lowerRepls.foreach(repl => assert(repl.nonEmpty, "empty lowercase replacement")) + // Class ranges: (start, end, class) triples, sorted, disjoint, valid classes. + assert(t.classRanges.length % 3 == 0, "class ranges must be (start, end, class) triples") + var prevEnd = -1 + t.classRanges.grouped(3).foreach { case Array(start, end, cls) => + assert(start <= end && start > prevEnd, f"overlapping/unsorted range at U+$start%04X") + assert(cls >= 1 && cls <= 15, s"class $cls outside the wire contract") + assert(end <= 0x10ffff) + prevEnd = end + } + } + + test("running JVM's toLowerCase(Locale.ROOT) is reproduced for every isolated codepoint") { + // Proves the lowercase TABLE is exactly this JVM's non-identity set (nothing extra, + // nothing missing); full 1,114,112-codepoint sweep, well under a second. + val mismatches = (0 to 0x10ffff).view + .filterNot(cp => cp >= 0xd800 && cp <= 0xdfff) // surrogate halves: not codepoints + .flatMap(cp => assertMirrors(new String(Character.toChars(cp)))) + .take(20) + .toList + assert( + mismatches.isEmpty, + "the running JDK's toLowerCase(Locale.ROOT) diverges from the generated tables -- " + + s"the JvmCaseTables generation is wrong for this JDK:\n${mismatches.mkString("\n")}") + } + + test("running JVM's contextual sigma lowering is reproduced across the full codepoint sweep") { + // Eight sigma contexts per codepoint X, exercising both scan directions of the ported + // condition. Zero-mismatch calibrated on JDK 17, 21, and 25. Sequential (not `.par`): Scala + // 2.13 moved parallel collections out of the standard library, breaking the spark-4.x + // profiles; the sequential sweep already finishes in a few seconds. + val mismatches = (0 to 0x10ffff) + .filter(cp => !(cp >= 0xd800 && cp <= 0xdfff)) + .flatMap { cp => + val x = new String(Character.toChars(cp)) + Seq( + s"A${x}Σ", // backward scan, cased letter beyond X + s"AΣ$x", // forward scan, X terminal + s"AΣ${x}B", // forward scan, cased letter beyond X + s"A1${x}Σ", // backward scan, digit beyond X + s"A${x}1Σ", // backward scan, digit between X and sigma + s"${x}Σ", // X at string start + s"${x}1Σ", // X at string start, digit before sigma + s"A ${x}Σ" // X after a word boundary (space) + ).flatMap(assertMirrors) + } + .seq + .take(20) + .toList + assert( + mismatches.isEmpty, + "the running JDK's contextual sigma lowering diverges from the mirrored native " + + s"algorithm:\n${mismatches.mkString("\n")}") + } + + test("sigma corpus matches the running JVM exactly") { + // Human-readable corpus pinning the interesting shapes by name. + val corpus = Seq( + "ΑΣ", // capital alpha + sigma: final + "A1Σ", // digit keeps the word open: final + "A_Σ", // underscore is mid-word: final + "A-Σ", // dash is mid-word: final + "A.Σ", // period is mid-word: final + "A,Σ", // comma is mid-num only: non-final + "A..Σ", // two mid-word marks in a row break the word: non-final + "A1.Σ", // mid-word mark needs letters on both sides: non-final + "A-1Σ", // dash before a digit breaks the word: non-final + "A'Σ", // apostrophe is mid-word: final + "AΣB", // cased letter after: non-final + "Σ", // isolated: non-final + "ΣA", // nothing cased before: non-final + "ΣΣ", // sigma before sigma is cased: "σς" + "AΣ", // simplest final + "ABΣ", + "A Σ", // space breaks the word: non-final + "AΣ B", + "A1Σ, B", + "İΣ", // dotted capital I expands to two chars and is cased: final + "AΣİ", // cased letter after: non-final + "ΟΔΥΣΣΕΥΣ", // ΟΔΥΣΣΕΥΣ: only the last is final + "ÁΣ", // precomposed accented letter: final + "AΣ́", // combining acute after sigma is transparent: final + "1Σ", // digit alone is not cased: non-final + "Σ1", + "A1Σ1", // trailing digit stays in the word: non-final... but nothing cased after + "x'Σ", + "A‍Σ", // zero-width joiner (Cf) is transparent: final + "A­Σ", // soft hyphen is mid-word: final + "3.Σ", // mid mark between digit and sigma: non-final + "AアΣ", // katakana forms its own word: non-final + "一Σ", // kanji forms its own word: non-final + "A]Σ", + "A[Σ", + "Σ.", + "AΣ.", + "AΣ-", + "AΣ_b", // mid-word joins a cased letter after: non-final + "AΣ_", + "AΣ'b", + "AΣ.b", + "AΣ1b", + "AΣ1.", + "a1ς", // already-lowercase controls + "a1σ", + "𐐀Σ", // leading Deseret capital: joined at string start, final + "A𐐀Σ", // supplementary closes the preceding word: non-final + "AΣ𐐀", // cased supplementary visible forward: non-final + "𠀀Σ", // supplementary Han: not cased, non-final + "AΣ𠀀", // and invisible forward: final + "A।" + "1Σ", // danda chains into a number: final + "AΣ।1B", // danda then number then cased letter: non-final + "AΣ।B", // danda then letter: word closed, final + "ͅΣ", // base-less cased mark: non-final + "A ͅΣ", // cased mark attached to a space: non-final + "1ͅΣ", // cased mark attached to a digit: final + "A-ͅΣ", // cased mark attached to a dash: non-final + "AΣͅ", // cased mark forward: non-final + "A゙Σ", // combining kana voicing mark is transparent: final + "KelvinΣ", // Kelvin sign is cased: final + "col_a1Σ", // realistic column names + "COL_A1Σ", + "sales_2024.q1Σ", + "AΣ-\u200Db", // sigma, dash, ZWJ, letter: the format filter bridges to the cased 'b' + "AΣ-\u200D\u200Db", // and any number of pure-format riders + "AΣ-\u0301b", // an Mn mark after the dash blocks the bridge: final + "AΣ-\u200D\u0301b", // an Mn anywhere in the rider chain blocks it too + "AΣ\u200D-b", // format rider trailing the sigma bridges onward + "A-\u200DΣ", // and backward: "A-Σ" behaves exactly like "A-Σ" + "\u200D\uD801\uDC00Σ", // leading format defeats the supplementary text-start join + "A\uD804\uDF74\u0345Σ", // anchored supplementary mark carries the cased mark + "\uD804\uDF74\u0345Σ", // unanchored at text start: non-final + "A\uD804\uDF74\u0345-Σ", // and across a mid-letter bridge when letter-anchored + "A\uD835\uDFD3\u0345,1Σ", // supplementary digit carries the mark across mid-num + "A\uD835\uDFD3\u0345-Σ", // but never across mid-letter + "\uD801\uDC00।1Σ" + ) // cased supplementary at text start chains through the danda + val mismatches = corpus.flatMap(assertMirrors) + assert( + mismatches.isEmpty, + s"corpus diverges from the running JDK:\n${mismatches.mkString("\n")}") + } + + // One or more representatives per word-class `JvmCaseTables.classify` assigns, plus plain + // word-boundary characters, shared by the pair sweep and the fuzz test below: cased/letterish/ + // digit/mid-word/mid-num/supplementary/danda/mark-cased codepoints, including the Unicode-14 + // scripts whose version-gated assignment first exposed the JDK 17 vs 21 divergence (U+A7C0, + // U+1C89). A path-dependent DFA quirk that only shows up across multiple adjacent specials + // would appear as a mismatch between two of these class shapes and the real JVM. + private val classRepresentativeCodepoints: Array[Int] = Array(0x0041, 0x0061, 0x0391, 0x03c3, + 0x0130, 0x0131, 0x212a, 0xa7c0, 0x1c89, 0x2160, 0x2170, 0x0301, 0x3099, 0x0903, 0x20dd, + 0x0031, 0x0660, 0x00b2, 0x002d, 0x005f, 0x00ad, 0x2027, 0x002c, 0x066b, 0x002e, 0x0022, + 0x0027, 0x10570, 0x10400, 0x0964, 0x0965, 0x0345, 0x0020, 0x30a2, 0x4e00, 0x20000, 0x0021, + 0x00a0, 0x200d, 0x11374, 0x1d7ce, 0xe0049, 0x99992) + + // Exactly one representative per shipped word-break class, plus two plain boundary + // characters, for the ordered-triple sweep: every 3-deep class sequence between the cased + // anchor and the sigma is exercised in both scan directions. + private val perClassRepresentatives: Array[Int] = Array(0x0041, // ClassALetterCased + 0x05d0, // ClassALetter (Hebrew alef: letter-base, not cased) + 0x0031, // ClassNumeric + 0x2160, // ClassNumericCased + 0x002d, // ClassMidLetter + 0x002c, // ClassMidNum + 0x002e, // ClassMidNumLet + 0x0301, // ClassExtend + 0x0345, // ClassExtendCased + 0x200d, // ClassFormat + 0x0964, // ClassDanda + 0x10400, // ClassSuppCased + 0x20000, // ClassSuppLetter + 0x11374, // ClassSuppMn + 0x1d7ce, // ClassSuppNum + 0x0020, // boundary (space) + 0x0021 // boundary (symbol) + ) + + test("ordered pairs of class-representative codepoints around a sigma match the running JVM") { + val r = classRepresentativeCodepoints.map(cp => new String(Character.toChars(cp))) + val templates: Array[(String, String) => String] = Array( + (x, y) => s"A$x${y}Σ", + (x, y) => s"AΣ$x$y", + (x, y) => s"A${x}Σ$y", + (x, y) => s"$x${y}Σ", + (x, y) => s"Σ$x$y", + (x, y) => s"A${x}1${y}Σ") + val mismatches = scala.collection.mutable.ListBuffer.empty[String] + var total = 0 + var i = 0 + while (i < r.length && mismatches.size < 20) { + var j = 0 + while (j < r.length && mismatches.size < 20) { + templates.foreach { tmpl => + total += 1 + assertMirrors(tmpl(r(i), r(j))).foreach(mismatches += _) + } + j += 1 + } + i += 1 + } + info( + s"pair sweep: ${r.length} representatives x ${r.length} x ${templates.length} " + + s"templates = $total strings tested") + assert(total <= 100000, s"pair sweep exceeded the string-count budget: $total") + assert( + mismatches.isEmpty, + s"multi-special ordered-pair sweep diverges from the running JDK " + + s"($total strings tested):\n${mismatches.mkString("\n")}") + } + + test("ordered triples of per-class codepoints around a sigma match the running JVM") { + // Multi-rider chains: every ordered TRIPLE of class representatives in four templates + // (trailing the sigma, preceding it, and sandwiched between the sigma and a cased + // letter), plus every ordered pair sandwiched the same way. This is the shape family + // where the pre-UAX#29 hand-rolled rider logic diverged from Java's BreakIterator + // (e.g. sigma, '-', U+200D, letter). + val r = perClassRepresentatives.map(cp => new String(Character.toChars(cp))) + val mismatches = scala.collection.mutable.ListBuffer.empty[String] + var total = 0 + var i = 0 + while (i < r.length && mismatches.size < 20) { + var j = 0 + while (j < r.length && mismatches.size < 20) { + total += 1 + assertMirrors(s"AΣ${r(i)}${r(j)}B").foreach(mismatches += _) + var k = 0 + while (k < r.length && mismatches.size < 20) { + total += 3 + assertMirrors(s"AΣ${r(i)}${r(j)}${r(k)}").foreach(mismatches += _) + assertMirrors(s"A${r(i)}${r(j)}${r(k)}Σ").foreach(mismatches += _) + assertMirrors(s"AΣ${r(i)}${r(j)}${r(k)}B").foreach(mismatches += _) + k += 1 + } + j += 1 + } + i += 1 + } + info( + s"triple sweep: ${r.length} per-class representatives, 3 triple templates + 1 pair " + + s"template = $total strings tested") + assert(total <= 500000, s"triple sweep exceeded the string-count budget: $total") + assert( + mismatches.isEmpty, + s"multi-rider ordered-triple sweep diverges from the running JDK " + + s"($total strings tested):\n${mismatches.mkString("\n")}") + } + + test("seeded fuzz: multi-special strings around a sigma match the running JVM") { + // Fixed seed for determinism. Draws codepoints predominantly from the class-representative + // pool above and otherwise uniformly from the full codepoint space, with exactly one sigma + // placed at a random position in every generated string. + val seed = 0x516d41ceL + val rnd = new scala.util.Random(seed) + val pool = classRepresentativeCodepoints + def randomCodepoint(): Int = { + if (rnd.nextBoolean()) { + pool(rnd.nextInt(pool.length)) + } else { + var cp = 0 + do { + cp = rnd.nextInt(0x110000) + } while (cp >= 0xd800 && cp <= 0xdfff) + cp + } + } + val fuzzCount = 40000 + val mismatches = scala.collection.mutable.ListBuffer.empty[String] + var n = 0 + while (n < fuzzCount && mismatches.size < 20) { + val len = 3 + rnd.nextInt(6) // 3..8 codepoints + val sigmaAt = rnd.nextInt(len) + val sb = new java.lang.StringBuilder() + var k = 0 + while (k < len) { + sb.appendCodePoint(if (k == sigmaAt) 0x03a3 else randomCodepoint()) + k += 1 + } + assertMirrors(sb.toString).foreach(mismatches += _) + n += 1 + } + info(s"seeded fuzz (seed=0x${seed.toHexString}): $n strings tested") + assert( + mismatches.isEmpty, + s"seeded multi-special fuzz diverges from the running JDK " + + s"($n strings, seed=0x${seed.toHexString}):\n${mismatches.mkString("\n")}") + } +} diff --git a/dev/ci/check-suites.py b/dev/ci/check-suites.py index b7369d17076..760ed8e2292 100644 --- a/dev/ci/check-suites.py +++ b/dev/ci/check-suites.py @@ -46,6 +46,10 @@ def file_to_class_name(path: Path) -> str | None: root = Path(".") for path in root.rglob("*Suite.scala"): + # contrib suites run via their own module-level workflows + # (e.g. delta_contrib_test.yml), not the main PR build matrix + if path.parts[0] == "contrib": + continue class_name = file_to_class_name(path) if class_name: if "Shim" in class_name: diff --git a/dev/ci/compute-changes.py b/dev/ci/compute-changes.py index 9b7cd2f1691..aa0adcf9654 100644 --- a/dev/ci/compute-changes.py +++ b/dev/ci/compute-changes.py @@ -245,6 +245,23 @@ ".github/actions/setup-builder/**", ".github/actions/setup-iceberg-builder/**", ], + "delta": [ + "contrib/delta/**", + "contrib/delta-spark/**", + "native/**/src/**", + "native/**/Cargo.toml", + "native/Cargo.lock", + "common/src/main/**", + "common/pom.xml", + "spark/src/main/**", + "!spark/src/main/scala/org/apache/comet/GenerateDocs.scala", + "spark/pom.xml", + "pom.xml", + "rust-toolchain.toml", + ".github/workflows/ci.yml", + ".github/workflows/delta_contrib_test.yml", + ".github/actions/setup-builder/**", + ], } diff --git a/docs/source/user-guide/latest/delta.md b/docs/source/user-guide/latest/delta.md new file mode 100644 index 00000000000..6e85ca5bd26 --- /dev/null +++ b/docs/source/user-guide/latest/delta.md @@ -0,0 +1,47 @@ + + +# Delta Lake (experimental) + +Comet can execute DSv1 Delta Lake table scans natively. Reads planned by +delta-spark run through Comet's native Parquet scan, inheriting row-group +pruning, page-index pruning, and filter pushdown, with deletion vectors +applied inside the scan. + +Support is experimental and explicitly opt-in. Two things are required: + +1. The `comet-contrib-delta-spark` contrib jar on the classpath, alongside + `delta-spark`. It is never bundled into `comet-spark`. +2. `spark.comet.scan.delta.enabled=true`. The default is `false`, so + the jar alone does nothing. + +Unsupported tables and features fall back to Spark's reader. See the +[contrib module README](https://github.com/apache/datafusion-comet/blob/main/contrib/delta-spark/README.md) +for the supported Spark/Delta version matrix and build instructions. + +## Configuration + + + +| Config | Description | Default Value | +|--------|-------------|---------------| +| `spark.comet.scan.delta.dv.maxDeletedRowsPerFile` | Upper bound on a single file's deletion-vector cardinality (deleted row count) the native Delta scan will claim. Applying a deletion vector expands it into per-row selectors that are retained in memory for the file's scan; this bound is a deliberately pessimistic planning-time proxy for that retained memory (deletion vector cardinality, not the exact selector count), so a large but contiguous deletion is declined the same as a large alternating one. Scans whose deletion vectors exceed this bound for any file fall back to Spark's reader. | 1000000 | +| `spark.comet.scan.delta.enabled` | Whether to enable native Delta table scans. When enabled, DSv1 Delta table reads planned by delta-spark are executed through Comet's native Parquet scan, inheriting row-group pruning, page-index pruning, and filter pushdown, with deletion vectors applied inside the scan. Experimental: defaults to false, so adding the contrib jar does not by itself change how any query is read. | false | + + diff --git a/docs/source/user-guide/latest/index.rst b/docs/source/user-guide/latest/index.rst index 815e12289c7..cecb3c2e469 100644 --- a/docs/source/user-guide/latest/index.rst +++ b/docs/source/user-guide/latest/index.rst @@ -81,6 +81,7 @@ to read more. :caption: Integrations :hidden: + Delta Lake Iceberg Guide Iceberg Writes S3 Credential Providers diff --git a/native/Cargo.lock b/native/Cargo.lock index e835cdced30..4b313b57403 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1952,6 +1952,7 @@ dependencies = [ "aws-credential-types", "bytes", "comet-contrib-delta", + "crc32fast", "criterion", "datafusion", "datafusion-comet-common", @@ -1989,6 +1990,7 @@ dependencies = [ "rand 0.10.2", "reqsign-core", "reqwest 0.12.28", + "roaring", "serde_json", "tempfile", "tikv-jemalloc-ctl", diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml index 8dc8d73273f..af0b4a5c917 100644 --- a/native/core/Cargo.toml +++ b/native/core/Cargo.toml @@ -35,6 +35,9 @@ include = [ publish = false [dependencies] +# Delta deletion-vector decoding (feature = "delta") +roaring = { version = "0.11", optional = true } +crc32fast = { version = "1.5", optional = true } arrow = { workspace = true } bytes = { workspace = true } parquet = { workspace = true, default-features = false, features = ["experimental", "arrow", "snap", "lz4", "zstd", "flate2-zlib-rs"] } @@ -98,12 +101,19 @@ datafusion-functions-nested = { version = "54.1.0" } [features] backtrace = ["datafusion/backtrace"] -default = ["hdfs-opendal"] +default = ["hdfs-opendal", "delta"] hdfs-opendal = ["opendal", "object_store_opendal", "hdfs-sys"] jemalloc = ["tikv-jemallocator", "tikv-jemalloc-ctl"] -# Delta Lake integration. When enabled, links the `comet-contrib-delta` crate -# into `libcomet` and activates the `OpStruct::DeltaScan` dispatcher arm. -# Default builds carry zero Delta surface. +# Native Delta Lake scan support for the JVM-planned path (contrib/delta-spark). +# In the default set: inert at runtime unless the contrib jar is on the +# classpath (ServiceLoader) AND spark.comet.scan.delta.enabled is set, so it +# cannot affect non-Delta scans. Opt out with --no-default-features for slim +# builds; the planner arm then returns a clear "built without the delta +# feature" error. +delta = ["dep:roaring", "dep:crc32fast"] +# Delta Lake integration via delta-kernel-rs. When enabled, links the +# `comet-contrib-delta` crate into `libcomet` and activates the contrib scan +# dispatcher arm. Default builds carry zero kernel surface. contrib-delta = ["dep:comet-contrib-delta"] # exclude optional packages from cargo machete verifications diff --git a/native/core/src/execution/delta_dv.rs b/native/core/src/execution/delta_dv.rs new file mode 100644 index 00000000000..76418675d19 --- /dev/null +++ b/native/core/src/execution/delta_dv.rs @@ -0,0 +1,2096 @@ +// 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. + +//! Delta Lake deletion-vector decoding and translation into DataFusion +//! [`ParquetAccessPlan`]s (feature = "delta"). +//! +//! Wire formats implemented here (from delta-spark's `DeletionVectorStore` / +//! `RoaringBitmapArray`, v3.3.2): +//! - On-disk DV file: 1 version byte at the start of the file; at +//! `descriptor.offset`: `[i32 BE size][data: size bytes][i32 BE CRC32(data)]`. +//! - `data`: `[i32 LE magic]` then either +//! - magic 1681511376 ("native"): `[i32 LE count]`, then per bitmap +//! `[i32 LE size][standard 32-bit RoaringBitmap]`, keys implicit (index); +//! - magic 1681511377 ("portable", the spec's 64-bit extension): `[i64 LE +//! count]`, then per bitmap `[i32 LE key][standard 32-bit RoaringBitmap]` +//! with keys ascending -- exactly [`RoaringTreemap`]'s serialized form. + +use std::mem::size_of; +use std::sync::Arc; + +use datafusion::datasource::listing::PartitionedFile; +use datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata; +use datafusion::datasource::physical_plan::parquet::{ParquetAccessPlan, RowGroupAccess}; +use datafusion::execution::memory_pool::{MemoryConsumer, MemoryReservation}; +use datafusion::execution::runtime_env::RuntimeEnv; +use futures::{StreamExt, TryStreamExt}; +use object_store::path::Path; +use object_store::{ObjectStore, ObjectStoreExt}; +use parquet::arrow::arrow_reader::{RowSelection, RowSelector}; +use parquet::file::metadata::{PageIndexPolicy, ParquetMetaData}; +use roaring::{RoaringBitmap, RoaringTreemap}; + +use crate::execution::operators::ExecutionError; +use crate::execution::operators::ExecutionError::GeneralError; +use datafusion_comet_proto::spark_operator::DeltaSparkDvDescriptor; + +const NATIVE_MAGIC: i32 = 1681511376; +const PORTABLE_MAGIC: i32 = 1681511377; + +/// Unframe a DV blob read from `descriptor.offset` of a DV file: +/// `[i32 BE size][data][i32 BE crc]`. Verifies both the size against the +/// descriptor's `size_in_bytes` and the CRC32 checksum. +pub fn unframe_dv_blob(blob: &[u8], expected_size: usize) -> Result<&[u8], ExecutionError> { + if blob.len() < 8 { + return Err(GeneralError(format!( + "Deletion vector blob too short: {} bytes", + blob.len() + ))); + } + let size = i32::from_be_bytes(blob[0..4].try_into().unwrap()); + if size < 0 || size as usize != expected_size { + return Err(GeneralError(format!( + "Deletion vector size mismatch: file says {size}, descriptor says {expected_size}" + ))); + } + let end = 4 + size as usize; + if blob.len() < end + 4 { + return Err(GeneralError(format!( + "Deletion vector blob truncated: need {} bytes, have {}", + end + 4, + blob.len() + ))); + } + let data = &blob[4..end]; + let expected_crc = i32::from_be_bytes(blob[end..end + 4].try_into().unwrap()); + let actual_crc = crc32fast::hash(data) as i32; + if expected_crc != actual_crc { + return Err(GeneralError( + "Deletion vector checksum mismatch".to_string(), + )); + } + Ok(data) +} + +/// Deserialize the magic-prefixed RoaringBitmapArray into a 64-bit treemap of +/// deleted row indexes. +pub fn deserialize_dv_bitmap(data: &[u8]) -> Result { + if data.len() < 4 { + return Err(GeneralError( + "Deletion vector bitmap too short for magic number".to_string(), + )); + } + let magic = i32::from_le_bytes(data[0..4].try_into().unwrap()); + let rest = &data[4..]; + match magic { + PORTABLE_MAGIC => RoaringTreemap::deserialize_from(rest) + .map_err(|e| GeneralError(format!("Invalid portable deletion vector bitmap: {e}"))), + NATIVE_MAGIC => { + if rest.len() < 4 { + return Err(GeneralError( + "Native deletion vector bitmap missing count".to_string(), + )); + } + let count = i32::from_le_bytes(rest[0..4].try_into().unwrap()); + if count < 0 { + return Err(GeneralError(format!( + "Invalid RoaringBitmapArray length ({count} < 0)" + ))); + } + let mut pos = 4usize; + let mut treemap = RoaringTreemap::new(); + for key in 0..count as u64 { + if rest.len() < pos + 4 { + return Err(GeneralError( + "Native deletion vector bitmap truncated".to_string(), + )); + } + let size = i32::from_le_bytes(rest[pos..pos + 4].try_into().unwrap()); + pos += 4; + if size < 0 || rest.len() < pos + size as usize { + return Err(GeneralError( + "Native deletion vector bitmap truncated".to_string(), + )); + } + let bitmap = RoaringBitmap::deserialize_from(&rest[pos..pos + size as usize]) + .map_err(|e| { + GeneralError(format!("Invalid deletion vector sub-bitmap: {e}")) + })?; + pos += size as usize; + for value in bitmap { + treemap.insert((key << 32) | value as u64); + } + } + Ok(treemap) + } + other => Err(GeneralError(format!( + "Unexpected RoaringBitmapArray magic number {other}" + ))), + } +} + +/// Translate deleted row indexes into a [`ParquetAccessPlan`]: fully-deleted +/// row groups become `Skip`, untouched groups stay `Scan`, and partially +/// deleted groups get a `RowSelection` selecting the complement of the deleted +/// rows. Page-index pruning later INTERSECTS with these selections, so DV +/// skips and page skips compose. +pub fn build_access_plan( + row_group_row_counts: &[i64], + deleted: &RoaringTreemap, +) -> Result { + let mut plan = ParquetAccessPlan::new_all(row_group_row_counts.len()); + // Single sweep over the (sorted) deleted row indexes, bucketing by row group. + let mut deleted_iter = deleted.iter().peekable(); + let mut group_start = 0u64; + for (idx, &num_rows) in row_group_row_counts.iter().enumerate() { + // A corrupt footer can report a negative row count. `num_rows as u64` would otherwise + // wrap it into a huge positive value, silently corrupting every row-group boundary + // computed from `group_start`/`group_end` below (and therefore which deleted row indexes + // land in which row group) instead of failing loudly. + if num_rows < 0 { + return Err(GeneralError(format!( + "Parquet footer reports a negative row count ({num_rows}) for row group {idx}" + ))); + } + let num_rows = num_rows as u64; + let group_end = group_start + num_rows; + let mut selectors: Vec = Vec::new(); + let mut cursor = group_start; + let mut deleted_in_group = 0u64; + while let Some(&row) = deleted_iter.peek() { + if row >= group_end { + break; + } + deleted_iter.next(); + deleted_in_group += 1; + if row > cursor { + selectors.push(RowSelector::select((row - cursor) as usize)); + } + // Merge runs of consecutive deleted rows into one skip. + match selectors.last_mut() { + Some(last) if last.skip => last.row_count += 1, + _ => selectors.push(RowSelector::skip(1)), + } + cursor = row + 1; + } + if deleted_in_group == num_rows && num_rows > 0 { + plan.skip(idx); + } else if deleted_in_group > 0 { + if group_end > cursor { + selectors.push(RowSelector::select((group_end - cursor) as usize)); + } + plan.scan_selection(idx, RowSelection::from(selectors)); + } + group_start = group_end; + } + // A deleted index beyond the file's total row count means the DV does not + // belong to this file (stale or corrupted metadata); silently dropping it + // would under-apply deletions. + if let Some(&row) = deleted_iter.peek() { + return Err(GeneralError(format!( + "Deletion vector marks row {row} but the file only has {group_start} rows" + ))); + } + Ok(plan) +} + +/// Verify a decoded deletion vector's row count matches the descriptor's +/// declared `cardinality`, mirroring Delta's JVM reader +/// (`StoredBitmap.validateCardinality`). The CRC and framing checks catch +/// corruption but not a stale, otherwise well-formed bitmap whose row count +/// no longer matches the descriptor -- that would silently under- or +/// over-delete rows. +fn validate_cardinality( + file_path: &str, + expected: i64, + deleted: &RoaringTreemap, +) -> Result<(), ExecutionError> { + let actual = deleted.len(); + if actual != expected as u64 { + return Err(GeneralError(format!( + "Deletion vector for {file_path} has cardinality mismatch: descriptor says {expected}, decoded bitmap has {actual} deleted rows" + ))); + } + Ok(()) +} + +/// One data file plus everything needed to apply its deletion vector. The +/// file's size comes from `file.object_meta.size` (built by the planner from +/// the proto's `file_size`). +/// +/// `data_store` and `dv_store` are resolved by the caller *before* entering +/// the async `attach_access_plans` runtime (see its doc comment): building an +/// object store is sync I/O that, for a cold S3 authority, internally issues +/// its own `Handle::block_on` calls, which panics if nested inside another +/// `block_on`. Resolving up front means this module never constructs a +/// store itself. +pub struct DvScanFile { + pub file: PartitionedFile, + /// Full URL of the data file (proto `file_path`). + pub file_path: String, + pub dv: Option, + /// Object store for `file_path`, pre-resolved by the caller. Only read + /// when `dv` is `Some` (files without a deletion vector never open their + /// footer here), but every file carries one so the struct's shape + /// doesn't depend on whether a deletion vector is present. + pub data_store: Arc, + /// Store and within-store path for an on-disk deletion vector's absolute + /// path, pre-resolved by the caller. `None` when the file has no + /// deletion vector or the deletion vector is stored inline. + pub dv_store: Option<(Arc, Path)>, +} + +/// Execution-memory-pool reservation covering one file's expanded DV row selectors across +/// their *entire* lifetime attached to a scan -- from `build_access_plan`'s construction +/// through DataFusion 54.1's reader normalizing the attached [`ParquetAccessPlan`] +/// (`create_initial_plan`'s deep clone plus `into_overall_row_selection`'s combined +/// `RowSelection`; see [`reader_peak_bytes`]) -- attached to the file's [`PartitionedFile`] +/// extensions alongside its [`ParquetAccessPlan`]. The reservation's lifetime is tied to the +/// `PartitionedFile` it is attached to, so it is released back to the pool exactly when the +/// plan is dropped (query completion or an early-terminated scan), never held open longer. +/// Newtype-wrapped so it occupies its own slot in the multi-slot, type-keyed `extensions` map +/// (`datafusion_common::extensions::Extensions`) alongside the plan, rather than a bare +/// `MemoryReservation` colliding with one some other extension might attach. +pub struct DvAccessPlanReservation(pub MemoryReservation); + +/// Total number of [`RowSelector`]s materialized across `plan`'s per-row-group +/// selections (`RowGroupAccess::Selection`); `Scan`/`Skip` row groups +/// contribute none. An alternating deleted/retained bitmap produces one +/// non-coalescing selector per row (see [`reader_peak_bytes`]'s doc comment +/// for the worst-case accounting), so this count -- not the deletion +/// vector's cardinality -- is the thing that must be bounded and reserved +/// against the execution memory pool. +fn total_selectors(plan: &ParquetAccessPlan) -> usize { + plan.inner() + .iter() + .map(|access| match access { + RowGroupAccess::Selection(selection) => selection.iter().count(), + _ => 0, + }) + .sum() +} + +/// Multiplier bounding the peak allocation live *during construction* of one +/// file's [`RowSelection`]s, relative to the conservative selector-count +/// bound `S = 2 * cardinality + num_row_groups` (one non-coalescing selector +/// per deleted row in the worst-case alternating pattern, doubled, plus up to +/// one extra boundary selector per row group). Split `S` into `r`, the +/// selectors already retained from row groups `build_access_plan` has +/// finished, and `c`, the selectors accumulated so far in the current row +/// group's source `Vec`; `r` and `c` partition the selectors counted toward +/// `S`, so `r + c <= S` always. While the current group is being built, the +/// `Vec`'s doubling growth strategy can leave its backing allocation at up to +/// `2 * c` (the next power-of-two capacity above `c`). Once the group +/// finishes, `RowSelection::from(Vec)` (parquet's `FromIterator` impl, +/// `with_capacity` + copy) builds a second, separate `Vec` of size `c` from +/// that source while the source is still alive, so at the moment the copy +/// begins, the retained selectors, the current group's doubled source `Vec`, +/// and the copy are all live simultaneously: `r + 2c + c = r + 3c`. Since +/// `r >= 0`, `r + 3c <= 3r + 3c = 3(r + c) <= 3S`. 3x covers that peak. +const CONSTRUCTION_PEAK_FACTOR: usize = 3; + +/// Upper bound on how much larger a `Vec`'s backing allocation can be than its element count +/// after being built by repeated pushes: `std`'s doubling growth strategy never leaves a `Vec` +/// of `n` elements with a backing allocation larger than the next power of two above `n`, which +/// is at most `2 * n` for any `n >= 1`. +const VEC_GROWTH_CAPACITY_FACTOR: usize = 2; + +/// `RawVec`'s minimum non-zero capacity for element sizes `<= 1024` bytes ([`RowSelector`] is +/// 16 bytes on 64-bit platforms: a `usize` row count plus a padded `bool`). Applied once per +/// row group (or per contiguous run of row groups) a fresh `from_fn`/`FlatMap`-driven `Vec` +/// gets built for (see [`reader_peak_bytes`]), so even a group or run whose true selector count +/// is tiny still pays this floor. +const MIN_VEC_CAPACITY_SELECTORS: usize = 4; + +/// Conservative upper bound, in bytes, on the peak allocation live while DataFusion 54.1's +/// reader normalizes one file's attached [`ParquetAccessPlan`] -- the allocation this module's +/// steady-state reservation must cover, not merely the plan's own retained selector bytes. +/// THREE allocations can be live simultaneously by the time `into_overall_row_selection` +/// returns, not two -- the clone is only exact when page-index pruning never touches it: +/// +/// 1. **Attached original** (`selectors`, exact): `create_initial_plan` deep-clones the +/// attached plan while the original remains reachable from the file's `extensions` until +/// the scan consumes it. The ORIGINAL's own selector `Vec`s are exact -- a coalesced +/// [`RowSelection`] built via `RowSelection::from(Vec)` (what +/// `build_access_plan` uses) has no excess capacity, because that conversion is a plain +/// `with_capacity(len)` copy, not a `size_hint`-blind fold. +/// 2. **The clone, possibly capacity-inflated** (`<= VEC_GROWTH_CAPACITY_FACTOR * selectors + +/// MIN_VEC_CAPACITY_SELECTORS * num_row_groups`): if page-index pruning fires +/// (`PagePruningAccessPlanFilter`; `access_plan.rs`'s `scan_selection` on a row group that +/// already carries a `RowGroupAccess::Selection` calls `existing.intersection(&page_derived)` +/// -- `RowSelection::intersection` -> `intersect_row_selections`), it replaces the CLONE's +/// per-row-group selection with that intersection's output. `intersect_row_selections` is +/// ANOTHER `from_fn` generator with `size_hint() == (0, None)`, so each intersected row +/// group's backing `Vec` starts at `with_capacity(0)` and doubles as it grows, independent +/// of whatever capacity the pre-intersection selection had. This inflated clone is still +/// live when `into_overall_row_selection` later moves its buffer. Term 1's exactness +/// guarantee holds for the ORIGINAL always, and for the clone only when page-index pruning +/// never fires against it -- once it does, the clone must be charged at the SAME +/// growth-capped bound as a fresh combined-selection `Vec` (term 3), summed once per row +/// group rather than once per run, since each row group's `Selection` is intersected +/// independently. +/// 3. **Per-run combined-selection allocation** (`<= VEC_GROWTH_CAPACITY_FACTOR * (selectors + +/// num_row_groups) + MIN_VEC_CAPACITY_SELECTORS * num_row_groups`): `into_overall_row_selection` +/// collects each contiguous run of row groups' selectors into a *new* `RowSelection` via a +/// `FlatMap` whose `size_hint().0 == 0`, so that run's `Vec` starts at `with_capacity(0)` +/// and doubles as it grows -- capping its backing allocation at +/// `max(MIN_VEC_CAPACITY_SELECTORS, next_power_of_two(len))`, which is at most +/// `MIN_VEC_CAPACITY_SELECTORS + VEC_GROWTH_CAPACITY_FACTOR * len` for a run of `len` +/// selectors. `len` is at most that run's share of `selectors` plus one boundary selector +/// per `RowGroupAccess::Scan` row group in the run (`Scan` always contributes exactly one +/// `RowSelector::select(num_rows)`; see `access_plan.rs`'s `into_overall_row_selection`). +/// Summing across at most `num_row_groups` runs (each spans >= 1 row group) bounds the total +/// at `VEC_GROWTH_CAPACITY_FACTOR * selectors + (MIN_VEC_CAPACITY_SELECTORS + +/// VEC_GROWTH_CAPACITY_FACTOR) * num_row_groups`. +/// +/// Summing all three terms and converting to bytes: `((1 + 2 * VEC_GROWTH_CAPACITY_FACTOR) * +/// selectors + (2 * MIN_VEC_CAPACITY_SELECTORS + VEC_GROWTH_CAPACITY_FACTOR) * num_row_groups) +/// * size_of::()` -- with the constants above, `(5 * selectors + 10 * +/// num_row_groups) * size_of::()`. Checked against two measured worst cases: +/// +/// - No page-index pruning (the original P2 report; term 2 stays exact): one 2,000,000-row +/// group, 1,000,000 alternating deletions, `selectors = 2,000,000`. Measured allocator peak +/// 97,554,457 B; the byte-for-byte accounting for the attached original plus the (here, +/// exact) clone plus the inflated combined selection explains 97,554,432 B of that, a 25 B +/// residue we did not attribute. This bound gives 160,000,160 B -- much looser here because +/// it must also cover the next case, where the clone is NOT exact. +/// - Page-index pruning fires against the clone: one 1,048,577-row group, `selectors = +/// 1,048,577`. Measured peak 83,886,096 B; this bound gives 83,886,320 B (a 224 B, <1% +/// margin -- deliberately tight, since this is the case that drives the bound). +/// +/// Uses checked arithmetic throughout: a selector or row-group count large enough to overflow +/// `usize` indicates a corrupted or malicious input, reported as a clean error rather than +/// panicking. +fn reader_peak_bytes(selectors: usize, num_row_groups: usize) -> Result { + let overflow = || { + GeneralError(format!( + "Deletion vector reader-peak bound overflowed for {selectors} selectors and \ + {num_row_groups} row groups" + )) + }; + // Term 1: the attached original -- exact, untouched by page-index pruning (only the clone + // is ever intersected; see the doc comment above). + let attached_term = selectors; + // Term 2: the clone, bounded as if page-index pruning DID fire against every row group + // (safe even when it doesn't: term 2's bound is always >= `selectors`, so it never + // undershoots the exact case either). + let clone_growth = selectors + .checked_mul(VEC_GROWTH_CAPACITY_FACTOR) + .ok_or_else(overflow)?; + let clone_floor = num_row_groups + .checked_mul(MIN_VEC_CAPACITY_SELECTORS) + .ok_or_else(overflow)?; + let clone_term = clone_growth.checked_add(clone_floor).ok_or_else(overflow)?; + // Term 3: into_overall_row_selection's per-run combined-selection allocation. + let combined_growth = selectors + .checked_mul(VEC_GROWTH_CAPACITY_FACTOR) + .ok_or_else(overflow)?; + let combined_floor = num_row_groups + .checked_mul(MIN_VEC_CAPACITY_SELECTORS + VEC_GROWTH_CAPACITY_FACTOR) + .ok_or_else(overflow)?; + let combined_term = combined_growth + .checked_add(combined_floor) + .ok_or_else(overflow)?; + + let selector_bound = attached_term + .checked_add(clone_term) + .and_then(|sum| sum.checked_add(combined_term)) + .ok_or_else(overflow)?; + selector_bound + .checked_mul(size_of::()) + .ok_or_else(overflow) +} + +/// Upper bound, in [`RowSelector`]s, on how many extra selectors the parquet reader's +/// page-index pruning can add on top of the deletion vector's own selection when normalizing +/// one file, from that file's already-fetched [`ParquetMetaData`]. +/// +/// `intersect_row_selections` (parquet's `selection.rs`), which combines a page-pruning +/// selection with the deletion vector's selection, is a `from_fn` generator whose +/// `size_hint()` is `(0, None)`: for inputs of length `a` and `b`, its output can have up to +/// `a + b` selectors -- longer than either input. Bounding the page-pruning side of that sum +/// requires knowing how many selectors a page-index-derived selection could produce: at most +/// two per data page (one skip, one select, in the worst case of alternating page-level +/// pruning decisions), summed over every column of every row group. +/// +/// Returns `0` when `metadata` carries no offset index (`metadata.offset_index()` is `None`). +/// This is provably safe, not merely a convenient default: page-index pruning cannot produce a +/// page-level selection without the offset index to locate pages by, so there are no +/// page-pruning selectors to bound. The offset index is fetched with +/// `PageIndexPolicy::Optional` from the same `FileMetadataCache` entry the scan's reader later +/// reopens (see [`attach_access_plan`]'s footer-fetch comment), so this function observes +/// exactly what the reader will see. +/// +/// Uses checked arithmetic throughout for the same reason as [`admission_bound_bytes`]. +fn page_selection_bound_selectors(metadata: &ParquetMetaData) -> Result { + let Some(offset_index) = metadata.offset_index() else { + return Ok(0); + }; + let overflow = || { + GeneralError( + "Deletion vector page-selection bound overflowed while summing offset-index page \ + locations" + .to_string(), + ) + }; + let mut total_page_locations = 0usize; + for row_group in offset_index { + for column in row_group { + total_page_locations = total_page_locations + .checked_add(column.page_locations().len()) + .ok_or_else(overflow)?; + } + } + total_page_locations.checked_mul(2).ok_or_else(overflow) +} + +/// Execution-memory-pool admission bound, in bytes, for one file's deletion-vector access +/// plan -- reserved *before* calling `build_access_plan` (see [`attach_access_plan`]'s +/// pre-reserve call site) to cover the larger of two peaks live at different points in the +/// plan's lifetime. In practice the reader-normalization peak below dominates the construction +/// peak unconditionally for any non-trivial input (`reader_peak_bytes(S, G) = (5S + 10G) * +/// size_of::()` always exceeds `CONSTRUCTION_PEAK_FACTOR * S * +/// size_of::() = 3S * size_of::()` once `S >= 1`, since the `5S` term +/// alone already exceeds `3S`); the construction term is retained as a documented floor rather +/// than dropped, since it is cheap to compute and keeps this bound correct even if the reader's +/// growth factors ever shrink below construction's. +/// +/// - **Construction peak** (`CONSTRUCTION_PEAK_FACTOR * S`, see that constant's doc comment): +/// live while `build_access_plan` builds the plan's `RowSelection`s. Construction's +/// transient allocations fully unwind before `build_access_plan` returns, so this peak never +/// overlaps the reader-normalization peak below. +/// - **Reader-normalization peak** (`reader_peak_bytes(S + page_bound_selectors, +/// num_row_groups)`, see that function): live later, once DataFusion's reader normalizes the +/// attached plan. `S = 2 * cardinality + num_row_groups` is the same conservative bound on +/// the plan's final retained selector count used for the construction peak -- it provably +/// bounds `R = total_selectors(&plan)` (`R <= S`, from `build_access_plan`'s +/// one-non-coalescing-selector-per-deleted-row worst case plus one boundary selector per row +/// group), so `S + page_bound_selectors` bounds `R` after page-index inflation the same way +/// `S` bounds `R` before it. +/// +/// These two peaks never overlap in time, so `max` -- not `sum` -- is the correct combinator: +/// reserving their sum would over-reserve for no safety benefit. +/// +/// Deliberately not clamped by the file's total row count here, unlike the reader-peak target +/// `attach_access_plan` resizes down to after construction (see that call site): `S`'s +/// `+ num_row_groups` boundary term is a worst-case padding margin that can legitimately exceed +/// the total row count for a small, heavily-deleted file, and admission sizing has no actual +/// retained-selector count yet to clamp against -- only after construction, once `R` is known, +/// is clamping to the total row count both meaningful and strictly tighter. Leaving this bound +/// unclamped only ever makes admission more conservative, never less safe. +/// +/// Uses checked arithmetic throughout: a cardinality, row-group count, or page bound large +/// enough to overflow `usize` while computing this bound indicates a corrupted or malicious +/// descriptor, reported as a clean error rather than panicking. +fn admission_bound_bytes( + cardinality: i64, + num_row_groups: usize, + page_bound_selectors: usize, +) -> Result { + let overflow = || { + GeneralError(format!( + "Deletion vector admission bound overflowed for cardinality {cardinality}, \ + {num_row_groups} row groups, and page bound {page_bound_selectors} selectors" + )) + }; + let cardinality_usize = usize::try_from(cardinality).map_err(|_| overflow())?; + // S: the conservative bound on the plan's final *retained* selector count (what + // `total_selectors(&plan)` cannot exceed) -- unchanged from the pre-existing + // construction-only bound this function replaces. + let s = cardinality_usize + .checked_mul(2) + .and_then(|doubled| doubled.checked_add(num_row_groups)) + .ok_or_else(overflow)?; + + let construction_bytes = s + .checked_mul(size_of::()) + .and_then(|bytes| bytes.checked_mul(CONSTRUCTION_PEAK_FACTOR)) + .ok_or_else(overflow)?; + + let s_plus_page = s.checked_add(page_bound_selectors).ok_or_else(overflow)?; + let reader_bytes = reader_peak_bytes(s_plus_page, num_row_groups)?; + + Ok(construction_bytes.max(reader_bytes)) +} + +/// Upper bound on concurrent DV-blob and footer fetches per partition. Both +/// are small ranged reads, so a modest fan-out hides object-store latency +/// without flooding the store client. +const DV_FETCH_CONCURRENCY: usize = 8; + +/// Called via `block_on` at plan-creation time on the executor task: DV blobs +/// are small ranged reads and footers are needed to learn row-group +/// boundaries. Files are fetched concurrently (bounded by +/// [`DV_FETCH_CONCURRENCY`]) with input order preserved. Footer fetches go +/// through the scan's shared FileMetadataCache, so the scan's subsequent open +/// of the same file is served from cache. That reuse relies on each input +/// [`PartitionedFile`] being returned as-is (only `with_extension` applied), +/// never rebuilt: the cache entry is keyed by this exact `object_meta` and the +/// scan later looks it up through the same struct. +/// +/// Deliberately takes no object-store options map and imports no +/// store-construction helper: every [`DvScanFile`] arrives with its stores +/// already resolved by the caller (see its doc comment), so this async path +/// structurally cannot build an object store -- only `runtime_env` is still +/// threaded through, for the shared `FileMetadataCache` and (per file) the +/// execution `MemoryPool` each expanded access plan's row selectors are +/// reserved against -- see [`DvAccessPlanReservation`]. +pub async fn attach_access_plans( + runtime_env: Arc, + files: Vec, +) -> Result, ExecutionError> { + futures::stream::iter(files) + .map(|scan_file| attach_access_plan(Arc::clone(&runtime_env), scan_file)) + .buffered(DV_FETCH_CONCURRENCY) + .try_collect() + .await +} + +/// Resolve one file's deletion vector into an attached [`ParquetAccessPlan`]; +/// files without a DV pass through untouched. +async fn attach_access_plan( + runtime_env: Arc, + scan_file: DvScanFile, +) -> Result { + let DvScanFile { + file, + file_path, + dv, + data_store, + dv_store, + } = scan_file; + let dv = match dv { + Some(dv) => dv, + None => return Ok(file), + }; + // Delta's canonical `DeletionVectorDescriptor.EMPTY`: inline storage, empty + // payload, size 0, cardinality 0. Spark's reader returns all rows for it; + // decoding would fail (the empty payload is too short for a magic + // number), so pass the file through unchanged before attempting to read it. + if dv.cardinality == 0 && dv.size_in_bytes == 0 { + return Ok(file); + } + if dv.size_in_bytes < 0 { + return Err(GeneralError(format!( + "Deletion vector for {file_path} has negative size {}", + dv.size_in_bytes + ))); + } + if dv.cardinality < 0 { + return Err(GeneralError(format!( + "Deletion vector for {file_path} has negative cardinality {}", + dv.cardinality + ))); + } + + let data: Vec = if let Some(inline) = dv.inline_data { + inline + } else if let Some(dv_path) = &dv.absolute_path { + let offset = dv + .offset + .ok_or_else(|| GeneralError("On-disk deletion vector missing offset".into()))?; + if offset < 0 { + return Err(GeneralError(format!( + "Deletion vector for {file_path} has negative offset {offset}" + ))); + } + let offset = offset as u64; + // [i32 BE size][data: size_in_bytes][i32 BE crc] + let framed_len = 4 + dv.size_in_bytes as u64 + 4; + let (store, dv_store_path) = dv_store.ok_or_else(|| { + GeneralError(format!( + "Deletion vector for {file_path} has an absolute path but no pre-resolved object store" + )) + })?; + let blob = store + .get_range(&dv_store_path, offset..offset + framed_len) + .await + .map_err(|e| GeneralError(format!("Failed to read deletion vector {dv_path}: {e}")))?; + unframe_dv_blob(&blob, dv.size_in_bytes as usize)?.to_vec() + } else { + return Err(GeneralError( + "Deletion vector descriptor has neither inline data nor a path".into(), + )); + }; + let deleted = deserialize_dv_bitmap(&data) + .map_err(|e| GeneralError(format!("Invalid deletion vector for {file_path}: {e}")))?; + validate_cardinality(&file_path, dv.cardinality, &deleted)?; + + // Row-group boundaries come from the data file's footer, fetched through the scan's + // shared FileMetadataCache with the page index loaded eagerly and the scan's metadata + // size hint (mirroring EagerPageIndexReaderFactory): the one fetch here also serves the + // subsequent data-file open, so DV files pay no extra footer round-trip. Keyed by + // `file.object_meta`, the exact ObjectMeta the scan's reader factory will look up. + let metadata_cache = runtime_env.cache_manager.get_file_metadata_cache(); + let metadata = DFParquetMetadata::new(data_store.as_ref(), &file.object_meta) + .with_file_metadata_cache(Some(metadata_cache)) + .with_page_index_policy(Some(PageIndexPolicy::Optional)) + .with_metadata_size_hint(Some(crate::parquet::parquet_exec::METADATA_SIZE_HINT)) + .fetch_metadata() + .await + .map_err(|e| GeneralError(format!("Failed to read parquet footer of {file_path}: {e}")))?; + let row_counts: Vec = metadata + .row_groups() + .iter() + .map(|rg| rg.num_rows()) + .collect(); + + // Pre-reserve the admission bound *before* calling build_access_plan: this bound covers + // both construction's own transient peak AND the larger peak DataFusion's reader hits + // later while normalizing the attached plan (`create_initial_plan`'s deep clone plus + // `into_overall_row_selection`'s combined RowSelection) -- see admission_bound_bytes and + // reader_peak_bytes. Reserving first means a rejection happens before any large `Vec` is + // allocated, not after -- see reader_peak_bytes's doc comment for the measured worst + // cases. The error message names this as a construction-phase rejection (contains + // "construct"), textually distinct from the steady-state message below, so callers/logs + // can tell which phase failed. + let page_bound_selectors = page_selection_bound_selectors(&metadata)?; + let admission_bytes = + admission_bound_bytes(dv.cardinality, row_counts.len(), page_bound_selectors)?; + let reservation = + MemoryConsumer::new("DeltaDeletionVectorAccessPlan").register(&runtime_env.memory_pool); + reservation.try_grow(admission_bytes).map_err(|e| { + GeneralError(format!( + "Deletion vector access plan for {file_path} needs up to {admission_bytes} \ + bytes to construct, exceeding the execution memory pool: {e}" + )) + })?; + + let plan = build_access_plan(&row_counts, &deleted) + .map_err(|e| GeneralError(format!("Invalid deletion vector for {file_path}: {e}")))?; + + // Shrink the reservation to the reader-lifecycle steady state now that construction's + // transient peak has passed: the peak DataFusion's reader hits later while normalizing + // this file's attached plan (see reader_peak_bytes), not merely the plan's own retained + // selector bytes. `Rp_bound` bounds the selector count the reader will see after + // page-index pruning inflates the deletion vector's own selection: this plan's actual + // retained selector count (`R = total_selectors(&plan)`) plus `page_bound_selectors`, + // clamped to the file's total row count -- a RowSelection can never carry more than one + // selector per row, so `total_rows` independently bounds the reader's true selector count + // regardless of how loose `R + page_bound_selectors` is. + // + // NEVER-GROWS PROOF (this call always shrinks -- never fails): `R <= S` (established by + // `build_access_plan`'s worst case, the same invariant `admission_bound_bytes` relies on + // for its own `S`), so `Rp_bound = min(R + page_bound_selectors, total_rows) <= + // R + page_bound_selectors <= S + page_bound_selectors` -- the exact quantity + // `admission_bound_bytes` fed into `reader_peak_bytes` when computing the reservation + // already made above. `reader_peak_bytes` is monotone non-decreasing in its first + // argument (all three terms of its sum scale with `selectors`, `num_row_groups`, or + // both), so + // `reader_peak_bytes(Rp_bound, num_row_groups) <= + // reader_peak_bytes(S + page_bound_selectors, num_row_groups) <= admission_bytes`. + // `try_resize` is still used (rather than the infallible `resize`) so a violation of that + // invariant surfaces as a clean error instead of an internal panic. + let selector_count = total_selectors(&plan); + let total_rows: usize = row_counts + .iter() + .try_fold(0usize, |sum, &n| { + usize::try_from(n).ok().and_then(|n| sum.checked_add(n)) + }) + .ok_or_else(|| { + GeneralError(format!( + "Deletion vector total row count negative or overflowed usize for {file_path}" + )) + })?; + let reader_selector_bound = selector_count + .checked_add(page_bound_selectors) + .ok_or_else(|| { + GeneralError(format!( + "Deletion vector reader-peak bound overflowed for {file_path} while adding the \ + page-index inflation term" + )) + })? + .min(total_rows); + let retained_bytes_bound = reader_peak_bytes(reader_selector_bound, row_counts.len())?; + reservation.try_resize(retained_bytes_bound).map_err(|e| { + GeneralError(format!( + "Deletion vector access plan for {file_path} retains {selector_count} row \ + selectors, needing up to {retained_bytes_bound} bytes at the reader's \ + normalization peak, exceeding the execution memory pool: {e}" + )) + })?; + + // Keyed by concrete type: the parquet opener looks up + // `extensions.get::()`, so the plan must be stored + // as ParquetAccessPlan itself, NOT wrapped in an Arc (which would key + // it as Arc and silently skip DV application). The + // reservation occupies its own slot (`DvAccessPlanReservation`, keyed + // separately by its own concrete type) alongside it -- `extensions` is + // a multi-slot, type-keyed map (`datafusion_common::extensions`), not a + // single-slot table, so the two coexist without conflict and are + // dropped together. + Ok(file + .with_extension(plan) + .with_extension(DvAccessPlanReservation(reservation))) +} + +#[cfg(test)] +mod tests { + use super::*; + use datafusion::arrow::datatypes::Schema; + use datafusion::arrow::record_batch::RecordBatch; + use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool}; + use datafusion::execution::runtime_env::RuntimeEnvBuilder; + use parquet::arrow::ArrowWriter; + use parquet::file::metadata::ParquetMetaDataReader; + use parquet::file::properties::WriterProperties; + + /// Mirror the pre-resolution `plan_delta_spark_scan` does before entering + /// `attach_access_plans`: resolve `url`'s object store and within-store + /// path via the same helper the production code path uses, outside any + /// async runtime, exactly as `DvScanFile` requires. + fn resolve_store(runtime_env: &Arc, url: &str) -> (Arc, Path) { + use crate::parquet::parquet_support::prepare_object_store_with_configs; + let (store_url, path) = prepare_object_store_with_configs( + Arc::clone(runtime_env), + url.to_string(), + &std::collections::HashMap::new(), + ) + .unwrap(); + let store = runtime_env.object_store(&store_url).unwrap(); + (store, path) + } + + /// Build a one-column (`id: Int64`), `num_rows`-row batch (values `0..num_rows`), shared by + /// every parquet-writing helper below. + fn sequential_int64_batch(num_rows: i64) -> (Arc, RecordBatch) { + use datafusion::arrow::array::Int64Array; + use datafusion::arrow::datatypes::{DataType, Field}; + let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)])); + let batch = RecordBatch::try_new( + Arc::clone(&schema), + vec![Arc::new(Int64Array::from_iter_values(0..num_rows))], + ) + .unwrap(); + (schema, batch) + } + + /// Write a one-column parquet file with rows 0..num_rows using explicit `props`; returns + /// its size. + fn write_parquet_with_properties( + path: &std::path::Path, + num_rows: i64, + props: WriterProperties, + ) -> i64 { + let (schema, batch) = sequential_int64_batch(num_rows); + let out = std::fs::File::create(path).unwrap(); + let mut writer = ArrowWriter::try_new(out, schema, Some(props)).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + std::fs::metadata(path).unwrap().len() as i64 + } + + /// Write a one-column parquet file with rows 0..num_rows; returns its size. + fn write_parquet(path: &std::path::Path, num_rows: i64) -> i64 { + write_parquet_with_properties(path, num_rows, WriterProperties::default()) + } + + /// Write a two-row-group parquet file (`2 * rows_per_group` total rows, split evenly via + /// an explicit `max_row_group_size`); returns its size. Used by tests exercising + /// `into_overall_row_selection`'s per-`Scan`-group boundary-selector term. + fn write_two_row_groups(path: &std::path::Path, rows_per_group: i64) -> i64 { + write_parquet_with_properties( + path, + rows_per_group * 2, + WriterProperties::builder() + .set_max_row_group_row_count(Some(rows_per_group as usize)) + .build(), + ) + } + + /// Read `path`'s full [`ParquetMetaData`], including the page index, exactly as this + /// module's own footer fetch does (`PageIndexPolicy::Optional`) -- synchronously, for test + /// setup that needs the real metadata before entering `attach_access_plans`' async path. + fn read_metadata_with_page_index(path: &std::path::Path) -> ParquetMetaData { + let file = std::fs::File::open(path).unwrap(); + ParquetMetaDataReader::new() + .with_page_index_policy(PageIndexPolicy::Optional) + .parse_and_finish(&file) + .unwrap() + } + + /// End-to-end over local files: inline and on-disk DVs resolve to attached + /// access plans, non-DV files pass through untouched, and the output keeps + /// the input's file order (which concurrent fetching must preserve). + #[tokio::test] + async fn attach_access_plans_resolves_dvs_and_preserves_order() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + let inline_deleted: RoaringTreemap = [0u64].into_iter().collect(); + let inline_data = portable_bytes(&inline_deleted); + + // On-disk DV file: 1 version byte, then the framed blob at offset 1. + let ondisk_deleted: RoaringTreemap = [1u64].into_iter().collect(); + let ondisk_data = portable_bytes(&ondisk_deleted); + let dv_file = dir.join("dv.bin"); + let mut dv_bytes = vec![1u8]; + dv_bytes.extend(frame(&ondisk_data)); + std::fs::write(&dv_file, &dv_bytes).unwrap(); + + let dv_for = |name: &str| match name { + "f0" => Some(DeltaSparkDvDescriptor { + storage_type: "i".to_string(), + absolute_path: None, + inline_data: Some(inline_data.clone()), + offset: None, + size_in_bytes: inline_data.len() as i32, + cardinality: 1, + }), + "f2" => Some(DeltaSparkDvDescriptor { + storage_type: "p".to_string(), + absolute_path: Some(format!("file://{}", dv_file.display())), + inline_data: None, + offset: Some(1), + size_in_bytes: ondisk_data.len() as i32, + cardinality: 1, + }), + // Delta's `DeletionVectorDescriptor.EMPTY`: inline storage, empty + // payload, size 0, cardinality 0. Must pass through unchanged + // without attempting to decode the (empty) payload. + "f4" => Some(DeltaSparkDvDescriptor { + storage_type: "i".to_string(), + absolute_path: None, + inline_data: Some(vec![]), + offset: None, + size_in_bytes: 0, + cardinality: 0, + }), + _ => None, + }; + + let runtime_env = Arc::new(RuntimeEnv::default()); + let names = ["f0", "f1", "f2", "f3", "f4"]; + let files: Vec = names + .iter() + .map(|name| { + let path = dir.join(format!("{name}.parquet")); + let size = write_parquet(&path, 10); + let file_path = format!("file://{}", path.display()); + let (data_store, _) = resolve_store(&runtime_env, &file_path); + let dv = dv_for(name); + let dv_store = dv + .as_ref() + .and_then(|d| d.absolute_path.as_deref()) + .map(|dv_path| resolve_store(&runtime_env, dv_path)); + DvScanFile { + file: PartitionedFile::new(path.display().to_string(), size as u64), + file_path, + dv, + data_store, + dv_store, + } + }) + .collect(); + + let out = attach_access_plans(Arc::clone(&runtime_env), files) + .await + .unwrap(); + + assert_eq!(out.len(), names.len()); + for (file, name) in out.iter().zip(names) { + assert!( + file.object_meta + .location + .as_ref() + .ends_with(&format!("{name}.parquet")), + "output order broken: expected {name}, got {}", + file.object_meta.location + ); + let plan = file.extensions.get::(); + match name { + "f0" | "f2" => { + let plan = plan.unwrap_or_else(|| panic!("{name} should carry an access plan")); + let skipped_row = if name == "f0" { 1 } else { 2 }; + match &plan.inner()[0] { + RowGroupAccess::Selection(sel) => { + let selectors: Vec = sel.clone().into(); + let expected = if skipped_row == 1 { + vec![RowSelector::skip(1), RowSelector::select(9)] + } else { + vec![ + RowSelector::select(1), + RowSelector::skip(1), + RowSelector::select(8), + ] + }; + assert_eq!(selectors, expected, "{name}"); + } + other => panic!("{name}: expected selection, got {other:?}"), + } + } + _ => assert!(plan.is_none(), "{name} should have no access plan"), + } + } + + // Footer reads must go through the shared FileMetadataCache so the scan's + // subsequent open of the same file is served from cache instead of paying a + // second footer round-trip. Files without a DV read no footer at all. + let cache = runtime_env.cache_manager.get_file_metadata_cache(); + for (file, name) in out.iter().zip(names) { + let cached = cache.get(&file.object_meta.location); + match name { + "f0" | "f2" => assert!( + cached.is_some(), + "{name}: DV footer read should populate the shared metadata cache" + ), + _ => assert!( + cached.is_none(), + "{name}: no-DV file should not have fetched a footer" + ), + } + } + } + + /// Serialize a treemap in Delta's portable RoaringBitmapArray format + /// (magic + RoaringTreemap wire form). + fn portable_bytes(deleted: &RoaringTreemap) -> Vec { + let mut data = PORTABLE_MAGIC.to_le_bytes().to_vec(); + deleted.serialize_into(&mut data).unwrap(); + data + } + + /// Serialize values in Delta's "native" RoaringBitmapArray format. + fn native_bytes(values: &[u64]) -> Vec { + use std::collections::BTreeMap; + let mut by_key: BTreeMap = BTreeMap::new(); + for v in values { + by_key + .entry((v >> 32) as u32) + .or_default() + .insert(*v as u32); + } + let max_key = by_key.keys().max().copied().unwrap_or(0); + let mut data = NATIVE_MAGIC.to_le_bytes().to_vec(); + data.extend(((max_key + 1) as i32).to_le_bytes()); + for key in 0..=max_key { + let bitmap = by_key.remove(&key).unwrap_or_default(); + let mut bytes = Vec::new(); + bitmap.serialize_into(&mut bytes).unwrap(); + data.extend((bytes.len() as i32).to_le_bytes()); + data.extend(bytes); + } + data + } + + fn frame(data: &[u8]) -> Vec { + let mut blob = (data.len() as i32).to_be_bytes().to_vec(); + blob.extend_from_slice(data); + blob.extend((crc32fast::hash(data) as i32).to_be_bytes()); + blob + } + + #[test] + fn portable_roundtrip_through_framing() { + let deleted: RoaringTreemap = [1u64, 5, 6, 7, 1000, (3u64 << 32) + 42] + .into_iter() + .collect(); + let blob = frame(&portable_bytes(&deleted)); + let data = unframe_dv_blob(&blob, blob.len() - 8).unwrap(); + let decoded = deserialize_dv_bitmap(data).unwrap(); + assert_eq!(decoded, deleted); + } + + #[test] + fn native_format_decodes() { + let values = [0u64, 2, 3, 100, (1u64 << 32) + 7]; + let decoded = deserialize_dv_bitmap(&native_bytes(&values)).unwrap(); + let expected: RoaringTreemap = values.into_iter().collect(); + assert_eq!(decoded, expected); + } + + #[test] + fn framing_rejects_bad_size_and_crc() { + let deleted: RoaringTreemap = [1u64, 2].into_iter().collect(); + let blob = frame(&portable_bytes(&deleted)); + let err = unframe_dv_blob(&blob, 3).unwrap_err(); + assert!(format!("{err}").contains("size mismatch")); + + let mut corrupted = blob.clone(); + let mid = corrupted.len() / 2; + corrupted[mid] ^= 0xFF; + let err = unframe_dv_blob(&corrupted, blob.len() - 8).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("checksum") || msg.contains("size mismatch"), + "unexpected: {msg}" + ); + } + + #[test] + fn cardinality_mismatch_is_rejected() { + let deleted: RoaringTreemap = [1u64].into_iter().collect(); + let bytes = portable_bytes(&deleted); + let decoded = deserialize_dv_bitmap(&bytes).unwrap(); + + let err = validate_cardinality("f.parquet", 2, &decoded).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("cardinality"), "unexpected: {msg}"); + + validate_cardinality("f.parquet", 1, &decoded).unwrap(); + } + + #[test] + fn access_plan_scan_skip_and_selection() { + // Three row groups of 10 rows: group 0 untouched, group 1 fully + // deleted, group 2 rows 21..24 deleted (local 1..4). + let deleted: RoaringTreemap = (10u64..20).chain(21u64..24).collect(); + let plan = build_access_plan(&[10, 10, 10], &deleted).unwrap(); + assert_eq!(&plan.inner()[0], &RowGroupAccess::Scan); + assert_eq!(&plan.inner()[1], &RowGroupAccess::Skip); + match &plan.inner()[2] { + RowGroupAccess::Selection(sel) => { + let selectors: Vec = sel.clone().into(); + assert_eq!( + selectors, + vec![ + RowSelector::select(1), + RowSelector::skip(3), + RowSelector::select(6) + ] + ); + } + other => panic!("expected selection, got {other:?}"), + } + } + + #[test] + fn access_plan_rejects_out_of_range_rows() { + let deleted: RoaringTreemap = [5u64, 25].into_iter().collect(); + let err = build_access_plan(&[10, 10], &deleted).unwrap_err(); + assert!(format!("{err}").contains("only has 20 rows")); + } + + #[test] + fn access_plan_rejects_negative_row_count_reported_by_a_corrupt_footer() { + // A corrupt footer can report a negative row count for a row group. Round-trip through + // the real parquet-crate RowGroupMetaData builder (`into_builder`, reusing a real row + // group's own column metadata rather than a bare negative literal) to prove the guard + // fires on the exact shape a corrupt footer would produce, not just an arbitrary i64. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("f.parquet"); + write_parquet(&path, 10); + let metadata = read_metadata_with_page_index(&path); + let corrupted = metadata + .row_group(0) + .clone() + .into_builder() + .set_num_rows(-5) + .build() + .unwrap(); + let row_counts = vec![corrupted.num_rows()]; + + let deleted = RoaringTreemap::new(); + let err = build_access_plan(&row_counts, &deleted).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("-5"), "expected the negative value: {msg}"); + assert!( + msg.contains("row group 0"), + "expected the row group index: {msg}" + ); + } + + #[test] + fn access_plan_selects_complement_row_count() { + // Random-ish pattern in one 100-row group: every 7th row deleted. + let deleted: RoaringTreemap = (0u64..100).filter(|i| i % 7 == 0).collect(); + let plan = build_access_plan(&[100], &deleted).unwrap(); + match &plan.inner()[0] { + RowGroupAccess::Selection(sel) => { + let selected: usize = sel.iter().filter(|s| !s.skip).map(|s| s.row_count).sum(); + let skipped: usize = sel.iter().filter(|s| s.skip).map(|s| s.row_count).sum(); + assert_eq!(selected + skipped, 100); + assert_eq!(skipped, deleted.len() as usize); + } + other => panic!("expected selection, got {other:?}"), + } + } + + /// The confirmed worst case: deleting every even row leaves + /// no adjacent skips or selects to merge, so `build_access_plan` emits + /// one non-coalescing `RowSelector` per row of the group. + fn alternating_deleted(num_rows: u64) -> RoaringTreemap { + (0..num_rows).step_by(2).collect() + } + + #[test] + fn total_selectors_counts_one_per_row_for_alternating_bitmap() { + let deleted = alternating_deleted(1024); + let plan = build_access_plan(&[1024], &deleted).unwrap(); + assert_eq!(total_selectors(&plan), 1024); + } + + #[test] + fn total_selectors_ignores_scan_and_skip_row_groups() { + // Group 0 untouched (Scan), group 1 fully deleted (Skip): neither + // carries a RowSelection, so both must contribute zero selectors. + let deleted: RoaringTreemap = (10u64..20).collect(); + let plan = build_access_plan(&[10, 10], &deleted).unwrap(); + assert_eq!(total_selectors(&plan), 0); + } + + /// Writes one file's on-disk parquet data for a full-file, alternating-bitmap deletion + /// vector, returning its path, byte size, and deleted-row bitmap so callers needing the + /// file's on-disk metadata (to size a memory pool exactly, or to replay the real reader + /// path) can inspect it before building a [`DvScanFile`] from it. + fn write_alternating_parquet( + dir: &std::path::Path, + num_rows: i64, + ) -> (std::path::PathBuf, i64, RoaringTreemap) { + let deleted = alternating_deleted(num_rows as u64); + let path = dir.join("alternating.parquet"); + let size = write_parquet(&path, num_rows); + (path, size, deleted) + } + + /// Builds a [`DvScanFile`] with an inline deletion vector for an already-written parquet + /// file at `path`. + fn dv_scan_file_for_alternating( + runtime_env: &Arc, + path: &std::path::Path, + size: i64, + deleted: &RoaringTreemap, + ) -> DvScanFile { + let inline_data = portable_bytes(deleted); + let file_path = format!("file://{}", path.display()); + let (data_store, _) = resolve_store(runtime_env, &file_path); + DvScanFile { + file: PartitionedFile::new(path.display().to_string(), size as u64), + file_path, + dv: Some(DeltaSparkDvDescriptor { + storage_type: "i".to_string(), + absolute_path: None, + inline_data: Some(inline_data.clone()), + offset: None, + size_in_bytes: inline_data.len() as i32, + cardinality: deleted.len() as i64, + }), + data_store, + dv_store: None, + } + } + + /// Builds one file's [`DvScanFile`] carrying an inline, alternating-bitmap + /// deletion vector over `num_rows` -- enough retained selectors to make + /// the reservation's byte count non-trivial without needing an on-disk DV + /// file. Used by the memory-accounting tests below. + fn alternating_dv_scan_file( + runtime_env: &Arc, + dir: &std::path::Path, + num_rows: i64, + ) -> DvScanFile { + let (path, size, deleted) = write_alternating_parquet(dir, num_rows); + dv_scan_file_for_alternating(runtime_env, &path, size, &deleted) + } + + /// A pool too small for even one `RowSelector` must reject the file's + /// access plan with a clean, file-naming error instead of the caller + /// materializing the selectors unbounded and risking an executor OOM. + #[tokio::test] + async fn attach_access_plans_rejects_oversized_dv_against_tiny_pool() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(1)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let scan_file = alternating_dv_scan_file(&runtime_env, tmp.path(), 1024); + + let err = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("alternating.parquet"), + "error should name the file: {msg}" + ); + assert!( + msg.contains("Resources exhausted") || msg.contains("exceeding"), + "error should surface pool exhaustion: {msg}" + ); + assert!( + msg.to_lowercase().contains("construct"), + "a pool too small even for the construction-phase bound should fail with a \ + construction-phase message: {msg}" + ); + assert_eq!( + pool.reserved(), + 0, + "a rejected reservation must not leak bytes into the pool" + ); + } + + /// A pool with room for the plan succeeds, reserves exactly the reader-lifecycle peak + /// bound (`reader_peak_bytes`, never a hardcoded constant) once construction's transient + /// peak has passed, attaches the reservation alongside the access plan, and releases it + /// back to the pool when the returned files are dropped. + #[tokio::test] + async fn attach_access_plans_reserves_and_releases_selector_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(1_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let scan_file = alternating_dv_scan_file(&runtime_env, tmp.path(), 1024); + // A full-file alternating bitmap retains exactly one selector per row (1024), which + // equals the file's total row count -- so the reader-peak clamp collapses to exactly + // this file's retained selector count regardless of its real page-index bound. + let expected_bytes = reader_peak_bytes(1024, 1).unwrap(); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap(); + assert_eq!(out.len(), 1); + assert_eq!( + pool.reserved(), + expected_bytes, + "plan bytes should be reserved against the pool" + ); + + let reservation = out[0] + .extensions + .get::() + .expect("reservation extension should be attached alongside the access plan"); + assert_eq!(reservation.0.size(), expected_bytes); + + drop(out); + assert_eq!( + pool.reserved(), + 0, + "dropping the files should release the reservation back to the pool" + ); + } + + /// Multi-file variant of `attach_access_plans_reserves_and_releases_selector_bytes`: two + /// files with distinct alternating deletion vectors (different row counts, so distinct + /// selector byte counts) must have their reservations summed in the pool while the returned + /// files are alive, and released in full once every returned file is dropped. + #[tokio::test] + async fn attach_access_plans_reserves_and_releases_selector_bytes_for_multiple_files() { + let tmp_a = tempfile::tempdir().unwrap(); + let tmp_b = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(10_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let scan_file_a = alternating_dv_scan_file(&runtime_env, tmp_a.path(), 1024); + let scan_file_b = alternating_dv_scan_file(&runtime_env, tmp_b.path(), 512); + // Per-file sum: each full-file alternating bitmap's reader-peak bound is independent of + // the other file's row count (unlike a naive shared-factor formula would suggest). + let expected_bytes = + reader_peak_bytes(1024, 1).unwrap() + reader_peak_bytes(512, 1).unwrap(); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file_a, scan_file_b]) + .await + .unwrap(); + assert_eq!(out.len(), 2); + assert_eq!( + pool.reserved(), + expected_bytes, + "reserved bytes should be the SUM of both files' selector bytes while the files \ + are alive" + ); + + drop(out); + assert_eq!( + pool.reserved(), + 0, + "dropping the files should release every file's reservation back to the pool" + ); + } + + /// A pool sized to fit only the larger of two files' selector bytes must reject the whole + /// batch -- regardless of which file's reservation attempt happens to run first under + /// `buffered`'s bounded concurrency -- and must not leave an earlier, transiently successful + /// file's reservation stranded in the pool once the batch's error propagates: `try_collect` + /// drops the whole in-flight `Vec` (including any already-resolved file's + /// attached `DvAccessPlanReservation`) as soon as any one file errors. + #[tokio::test] + async fn attach_access_plans_rejects_multi_file_batch_without_leaking_earlier_reservation() { + let tmp_a = tempfile::tempdir().unwrap(); + let tmp_b = tempfile::tempdir().unwrap(); + + // Write file A up front (rather than via `alternating_dv_scan_file`) so its on-disk + // metadata -- and thus its exact page-selection bound -- is available here, before the + // pool exists, to size `pool_capacity` using the exact same admission bound the + // production code computes. + let (path_a, size_a, deleted_a) = write_alternating_parquet(tmp_a.path(), 1024); + let metadata_a = read_metadata_with_page_index(&path_a); + let page_bound_a = page_selection_bound_selectors(&metadata_a).unwrap(); + + // Sized to exactly fit the larger file's (1024 rows, cardinality 512) admission bound + // alone -- derived, never hardcoded, so it tracks CONSTRUCTION_PEAK_FACTOR, + // reader_peak_bytes, and size_of::() across changes. Whichever of the two + // files reserves first (the FIRST reservation each file makes) fits alone, but the + // combined requirement (both files' admission bounds together) never does, so the + // batch fails no matter the scheduling order under `buffered`'s bounded concurrency. + let pool_capacity = admission_bound_bytes(512, 1, page_bound_a).unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(pool_capacity)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let scan_file_a = dv_scan_file_for_alternating(&runtime_env, &path_a, size_a, &deleted_a); + let scan_file_b = alternating_dv_scan_file(&runtime_env, tmp_b.path(), 512); + + let err = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file_a, scan_file_b]) + .await + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Resources exhausted") || msg.contains("exceeding"), + "error should surface pool exhaustion: {msg}" + ); + assert_eq!( + pool.reserved(), + 0, + "a rejected multi-file batch must not leak bytes from any file's reservation, \ + including one that transiently succeeded before the batch as a whole failed" + ); + } + + /// A pool sized to fit only the STEADY-STATE reservation (`reader_peak_bytes` at this + /// file's actual retained selector count) but not the larger admission bound must still be + /// rejected: the pre-reserve step runs before `build_access_plan`, so undersizing only for + /// steady state is not enough to admit a file whose transient admission-phase peak the pool + /// cannot actually hold. The error must be textually distinguishable from a steady-state + /// rejection (contains "construct"). + #[tokio::test] + async fn construction_bound_rejects_before_building_the_plan() { + let num_rows = 1024i64; + let cardinality = 512i64; // alternating_deleted(1024).len() + let num_row_groups = 1usize; + + let tmp = tempfile::tempdir().unwrap(); + let (path, size, deleted) = write_alternating_parquet(tmp.path(), num_rows); + let metadata = read_metadata_with_page_index(&path); + let page_bound = page_selection_bound_selectors(&metadata).unwrap(); + + // A full-file alternating bitmap's actual retained selector count equals its total row + // count, so its reader-peak-clamped steady state is exactly reader_peak_bytes(num_rows, + // 1). This is strictly smaller than the admission bound below: S = 2 * cardinality + + // num_row_groups (1025) is strictly larger than num_rows == R (1024) for this file + // (S's one-selector row-group boundary padding), and the file's real page bound `P` + // (from its default-written offset index, `page_bound` above) further inflates the + // admission side via `S + P` -- so the true gap is + // `reader_peak_bytes(S + page_bound, 1) - reader_peak_bytes(num_rows, 1) == + // 5 * (S + page_bound - num_rows) * size_of::() == + // 5 * (1 + page_bound) * size_of::()`, not merely the 1-selector S/R + // difference alone. Deliberately near-tight, and NOT hardcoded to a specific byte + // count: `page_bound` is measured from the real file, not assumed to be zero. + let steady_state_bytes = reader_peak_bytes(num_rows as usize, num_row_groups).unwrap(); + let admission_bytes = + admission_bound_bytes(cardinality, num_row_groups, page_bound).unwrap(); + assert!( + steady_state_bytes < admission_bytes, + "test setup invariant: steady state ({steady_state_bytes}) must be smaller than the \ + admission bound ({admission_bytes}) for this rejection to be meaningful" + ); + + let pool: Arc = Arc::new(GreedyMemoryPool::new(steady_state_bytes)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let scan_file = dv_scan_file_for_alternating(&runtime_env, &path, size, &deleted); + + let err = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.to_lowercase().contains("construct"), + "rejection at the pre-reserve step should carry a construction-phase message: {msg}" + ); + assert_eq!( + pool.reserved(), + 0, + "a rejected construction-phase reservation must not leak bytes into the pool" + ); + } + + /// Directly verifies the reader-peak invariant end to end: after `attach_access_plans` + /// completes, the attached reservation's steady-state size must equal `reader_peak_bytes` + /// evaluated at this file's actual retained selector count and row-group count -- + /// computed independently here via `build_access_plan`/`total_selectors`, never hardcoded + /// -- not the larger admission bound that was reserved up front. + #[tokio::test] + async fn steady_state_reservation_covers_reader_peak() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(10_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let num_rows = 300i64; + let deleted = alternating_deleted(num_rows as u64); + let scan_file = alternating_dv_scan_file(&runtime_env, tmp.path(), num_rows); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap(); + + let plan = build_access_plan(&[num_rows], &deleted).unwrap(); + let expected_bytes = reader_peak_bytes(total_selectors(&plan), 1).unwrap(); + + let reservation = out[0] + .extensions + .get::() + .expect("reservation extension should be attached alongside the access plan"); + assert_eq!(reservation.0.size(), expected_bytes); + assert_eq!(pool.reserved(), expected_bytes); + } + + #[test] + fn admission_bound_bytes_derives_from_cardinality_and_row_groups() { + let sel = size_of::(); + + // Zero cardinality, zero page bound: the reader term dominates in both cases below + // (reader_peak_bytes(S, G) = (5S + 10G) * sel always exceeds CONSTRUCTION_PEAK_FACTOR + // * S * sel = 3S * sel for S >= 1, since 5S alone already exceeds 3S). + assert_eq!( + admission_bound_bytes(0, 1, 0).unwrap(), + (CONSTRUCTION_PEAK_FACTOR * sel).max(reader_peak_bytes(1, 1).unwrap()) + ); + // Many row groups, still zero cardinality. + assert_eq!( + admission_bound_bytes(0, 1_000, 0).unwrap(), + (CONSTRUCTION_PEAK_FACTOR * 1_000 * sel).max(reader_peak_bytes(1_000, 1_000).unwrap()) + ); + // Typical case: cardinality dominates over a single row group, with a non-zero page + // bound feeding only the reader-normalization term. + let cardinality = 512usize; + let num_row_groups = 1usize; + let page_bound = 7usize; + let s = 2 * cardinality + num_row_groups; + assert_eq!( + admission_bound_bytes(cardinality as i64, num_row_groups, page_bound).unwrap(), + (CONSTRUCTION_PEAK_FACTOR * s * sel) + .max(reader_peak_bytes(s + page_bound, num_row_groups).unwrap()) + ); + + // Overflow anywhere in the derivation must produce a clean GeneralError, never a panic. + let err = admission_bound_bytes(0, usize::MAX, 0).unwrap_err(); + assert!(matches!(err, GeneralError(_)), "unexpected error: {err:?}"); + } + + /// Replays DataFusion 54.1's REAL reader-normalization path (not a reimplementation of + /// it): clones the attached plan exactly as `create_initial_plan` does, calls the actual, + /// public `ParquetAccessPlan::into_overall_row_selection` DataFusion will call from + /// `build_stream`, and recovers the resulting `RowSelection`'s TRUE backing `Vec` capacity + /// (not its length) -- the same quantity `reader_peak_bytes` bounds. Exercising the real + /// dependency rather than a model of it means this test keeps working (or fails loudly) + /// across future `datafusion`/`parquet` upgrades that change either crate's growth + /// strategy. + /// + /// This test (and `..._with_a_scan_row_group` below) covers the NO-page-index-pruning + /// path only: neither ever calls `scan_selection` on the clone, so `retained_selectors` + /// (from `total_selectors`, i.e. length, not capacity) is exact for BOTH the attached + /// original and the clone here -- see `reader_path_peak_fits_the_reservation_with_page_pruning` + /// for the case where the clone's own capacity can exceed its length. Also note the + /// assertion below is purely arithmetic: `attached_plan`, `cloned_plan`, and `combined` + /// are not necessarily all simultaneously resident in this process's memory at one program + /// point (Rust may reuse `cloned_plan`'s allocation once `into_overall_row_selection` + /// consumes it, before `combined` is bound) -- this test checks that the byte counts the + /// real dependency reports add up within the reservation, not that three buffers are + /// observed live at once via a profiler. + #[tokio::test] + async fn reader_path_peak_fits_the_reservation() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(10_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let (path, size, deleted) = write_alternating_parquet(tmp.path(), 1024); + let scan_file = dv_scan_file_for_alternating(&runtime_env, &path, size, &deleted); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap(); + let attached_plan = out[0] + .extensions + .get::() + .expect("attach_access_plans should have attached a plan") + .clone(); + let reservation = out[0] + .extensions + .get::() + .expect("reservation extension should be attached alongside the access plan"); + + let retained_selectors = total_selectors(&attached_plan); + + // Mirror create_initial_plan's deep clone: the original (still reachable via + // `out[0]`'s extensions) and the clone are live at once, exactly like the real reader. + let cloned_plan = attached_plan.clone(); + let metadata = read_metadata_with_page_index(&path); + let combined = cloned_plan + .into_overall_row_selection(metadata.row_groups()) + .unwrap() + .expect("a fully-alternating file should produce a combined RowSelection"); + // `From for Vec` moves the RowSelection's backing Vec, so + // this preserves its TRUE allocated capacity -- not merely its length. + let combined_selectors: Vec = combined.into(); + let combined_capacity = combined_selectors.capacity(); + + let peak_bytes = (retained_selectors + retained_selectors + combined_capacity) + * size_of::(); + assert!( + peak_bytes <= reservation.0.size(), + "the real DataFusion/parquet reader path's peak ({peak_bytes} bytes: \ + {retained_selectors} retained selectors x 2 live plan copies + \ + {combined_capacity} combined-selection Vec capacity) must fit the reservation \ + ({} bytes)", + reservation.0.size() + ); + } + + /// Same replay as `reader_path_peak_fits_the_reservation`, but with a two-row-group file + /// where only the first group has any deletions -- the second stays `RowGroupAccess::Scan` + /// (no `RowSelection`), exercising `into_overall_row_selection`'s one-`select`-per- + /// `Scan`-group term that a naive `k * total_selectors` bound would miss entirely. Like + /// that test, this one never calls `scan_selection` on the clone, so it exercises the + /// NO-page-index-pruning path only (clone length == clone capacity here); see the doc + /// comment there for why `retained_selectors` is exact in this test and why the assertion + /// below is arithmetic rather than a live-memory observation. + #[tokio::test] + async fn reader_path_peak_fits_the_reservation_with_a_scan_row_group() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(10_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + + // Two 500-row groups: only the first has any deletions, so the second stays a `Scan` + // row group in the resulting ParquetAccessPlan. + let rows_per_group = 500i64; + let deleted: RoaringTreemap = alternating_deleted(rows_per_group as u64); + let path = tmp.path().join("two_groups.parquet"); + let size = write_two_row_groups(&path, rows_per_group); + let scan_file = dv_scan_file_for_alternating(&runtime_env, &path, size, &deleted); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap(); + let attached_plan = out[0] + .extensions + .get::() + .expect("attach_access_plans should have attached a plan") + .clone(); + assert_eq!( + &attached_plan.inner()[1], + &RowGroupAccess::Scan, + "the second, untouched row group must stay Scan" + ); + let reservation = out[0] + .extensions + .get::() + .expect("reservation extension should be attached alongside the access plan"); + + let retained_selectors = total_selectors(&attached_plan); + let cloned_plan = attached_plan.clone(); + let metadata = read_metadata_with_page_index(&path); + let combined = cloned_plan + .into_overall_row_selection(metadata.row_groups()) + .unwrap() + .expect("a plan with a Selection row group should produce a combined RowSelection"); + let combined_selectors: Vec = combined.into(); + let combined_capacity = combined_selectors.capacity(); + + let peak_bytes = (retained_selectors + retained_selectors + combined_capacity) + * size_of::(); + assert!( + peak_bytes <= reservation.0.size(), + "the real reader path's peak with a Scan row group present ({peak_bytes} bytes) \ + must fit the reservation ({} bytes)", + reservation.0.size() + ); + } + + /// Replays the page-index-pruning path that drives peak memory the highest: clones + /// the attached plan (mirroring `create_initial_plan`), then intersects the clone's + /// row-group `Selection` with a synthetic, all-selecting page `RowSelection` via + /// `ParquetAccessPlan::scan_selection` -- the EXACT call `access_plan.rs`'s row-group + /// intersection makes when `PagePruningAccessPlanFilter` fires + /// (`existing_selection.intersection(&page_derived)` -> `RowSelection::intersection` -> + /// `intersect_row_selections`, ANOTHER `from_fn` generator with `size_hint() == (0, + /// None)`). The synthetic selection selects every row of the row group (a no-op filter -- + /// it changes nothing about which rows are scanned), included ONLY to drive the clone + /// through the SAME capacity-inflating intersection path real page pruning takes, so the + /// recovered capacity reflects the real dependency's growth strategy, not a model of it. + /// `num_rows` is chosen just above a power of two (at test scale, `1,048,577` rows) so the + /// intersection's `next_power_of_two` capacity jump is real and visible, not accidentally + /// exact. + /// + /// Recovers BOTH the intersected clone's TRUE capacity and the subsequent combined + /// selection's TRUE capacity (each via `into_inner()` / pattern-matching by value and + /// `Into>`, never `.clone()` -- cloning a `RowSelection` resets capacity + /// to length, since `Vec::clone` allocates exactly `with_capacity(len)`), and asserts + /// `attached_len + clone_capacity + combined_capacity` fits the reservation. Unlike + /// `reader_path_peak_fits_the_reservation`, this test does NOT model the clone as exact -- + /// it is the one that would have caught the original under-count. + #[tokio::test] + async fn reader_path_peak_fits_the_reservation_with_page_pruning() { + let tmp = tempfile::tempdir().unwrap(); + let pool: Arc = Arc::new(GreedyMemoryPool::new(100_000_000)); + let runtime_env = Arc::new( + RuntimeEnvBuilder::new() + .with_memory_pool(Arc::clone(&pool)) + .build() + .unwrap(), + ); + let num_rows = 1025i64; // 2^10 + 1: next_power_of_two(1025) == 2048, a real jump. + let (path, size, deleted) = write_alternating_parquet(tmp.path(), num_rows); + let scan_file = dv_scan_file_for_alternating(&runtime_env, &path, size, &deleted); + + let out = attach_access_plans(Arc::clone(&runtime_env), vec![scan_file]) + .await + .unwrap(); + let attached_plan = out[0] + .extensions + .get::() + .expect("attach_access_plans should have attached a plan") + .clone(); + let reservation = out[0] + .extensions + .get::() + .expect("reservation extension should be attached alongside the access plan"); + let attached_len = total_selectors(&attached_plan); + + let all_select = RowSelection::from(vec![RowSelector::select(num_rows as usize)]); + + // Mirror create_initial_plan's clone, then simulate PagePruningAccessPlanFilter firing + // against it. + let mut clone_for_capacity = attached_plan.clone(); + clone_for_capacity.scan_selection(0, all_select.clone()); + // Recover the intersected clone's TRUE capacity: `into_inner()` moves the + // `Vec` out without cloning, and pattern-matching by value on the + // result moves the `RowSelection` out the same way -- neither step clones it. + let clone_selection = match clone_for_capacity.into_inner().into_iter().next().unwrap() { + RowGroupAccess::Selection(sel) => sel, + other => panic!( + "expected row group 0 to carry a Selection after scan_selection, got {other:?}" + ), + }; + let clone_selectors: Vec = clone_selection.into(); + let clone_capacity = clone_selectors.capacity(); + assert!( + clone_capacity > attached_len, + "test setup invariant: the intersection must actually inflate the clone's capacity \ + past its length ({attached_len}) for this test to exercise the fix -- got \ + {clone_capacity}" + ); + + // A second, independently-reconstructed intersected clone (identical content, so the + // SAME deterministic capacity) feeds into_overall_row_selection, mirroring how the + // real reader calls it on the plan AFTER page pruning has already mutated it in place. + let mut clone_for_combining = attached_plan.clone(); + clone_for_combining.scan_selection(0, all_select); + let metadata = read_metadata_with_page_index(&path); + let combined = clone_for_combining + .into_overall_row_selection(metadata.row_groups()) + .unwrap() + .expect("a plan with a Selection row group should produce a combined RowSelection"); + let combined_selectors: Vec = combined.into(); + let combined_capacity = combined_selectors.capacity(); + + let peak_bytes = + (attached_len + clone_capacity + combined_capacity) * size_of::(); + assert!( + peak_bytes <= reservation.0.size(), + "the real reader path's peak WITH page-index pruning firing against the clone \ + ({peak_bytes} bytes: {attached_len} attached selectors + {clone_capacity} \ + intersected-clone Vec capacity + {combined_capacity} combined-selection Vec \ + capacity) must fit the reservation ({} bytes)", + reservation.0.size() + ); + } + + /// Property check over a grid of `(cardinality, num_row_groups, page_bound)` combinations, + /// each checked at several `R <= S`: the reader-lifecycle steady-state bound can never + /// exceed the admission bound reserved up front -- the resize at the end of + /// `attach_access_plan` must never need to GROW the reservation, only shrink it. + #[test] + fn resize_never_grows() { + for cardinality in [0i64, 1, 5, 100, 1_000, 10_000] { + for num_row_groups in [1usize, 2, 5, 100] { + for page_bound in [0usize, 1, 3, 50] { + let s = 2 * cardinality as usize + num_row_groups; + let admission = + admission_bound_bytes(cardinality, num_row_groups, page_bound).unwrap(); + // Sample the real invariant `R <= S` at both extremes and the midpoint -- + // reader_peak_bytes is monotone in its first argument, so checking a few + // representative points is sufficient to catch a regression. + for &r in &[0usize, s / 2, s] { + let rp_bound = r + page_bound; + let reader_bytes = reader_peak_bytes(rp_bound, num_row_groups).unwrap(); + assert!( + reader_bytes <= admission, + "reader_peak_bytes({rp_bound}, {num_row_groups}) = {reader_bytes} \ + must not exceed admission_bound_bytes({cardinality}, \ + {num_row_groups}, {page_bound}) = {admission} for R={r} <= S={s}" + ); + } + } + } + } + } + + /// `page_selection_bound_selectors` must return exactly `0` when the file's metadata + /// carries no offset index (the `unwrap_or(0)` this module's doc comment claims is + /// provably safe, not merely a convenient default), and the shared + /// `PageIndexPolicy::Optional` fetch used throughout this module must actually populate the + /// offset index when the file has one -- otherwise every other test in this file exercising + /// `page_selection_bound_selectors` indirectly would be silently testing against `0` + /// instead of a real page-index bound. + #[test] + fn page_selection_bound_selectors_reflects_offset_index_presence() { + let tmp = tempfile::tempdir().unwrap(); + + // A file written with the offset index explicitly disabled: no page locations to bound. + let no_index_path = tmp.path().join("no_page_index.parquet"); + write_parquet_with_properties( + &no_index_path, + 1024, + WriterProperties::builder() + .set_offset_index_disabled(true) + .build(), + ); + let metadata_without_index = read_metadata_with_page_index(&no_index_path); + assert!( + metadata_without_index.offset_index().is_none(), + "test setup invariant: this file must have no offset index" + ); + assert_eq!( + page_selection_bound_selectors(&metadata_without_index).unwrap(), + 0 + ); + + // A file written with default properties: the offset index is written by default, and + // the PageIndexPolicy::Optional fetch this module uses must actually populate it. + let indexed_path = tmp.path().join("with_page_index.parquet"); + write_parquet(&indexed_path, 1024); + let metadata_with_index = read_metadata_with_page_index(&indexed_path); + assert!( + metadata_with_index.offset_index().is_some(), + "a default-written file should carry an offset index -- if this fails, the \ + Optional page-index fetch policy stopped populating it, and \ + page_selection_bound_selectors would be silently under-bounding" + ); + assert!( + page_selection_bound_selectors(&metadata_with_index).unwrap() > 0, + "a file with pages and an offset index should have a positive page-selection bound" + ); + } + + // ----------------------------------------------------------------------------------------- + // Malformed-input hardening matrix: every way a deletion-vector blob can be corrupted + // (truncation, CRC, magic, length lies, cardinality lies, and general bit-flip fuzzing) must + // yield a clean `Err`, NEVER a panic and never a silently wrong answer. + // ----------------------------------------------------------------------------------------- + + /// Runs `f` under `catch_unwind`, failing the test with `context` if it panics. Every + /// malformed-input case below routes through this so a panic surfaces as an attributable test + /// failure instead of aborting the whole test binary silently at whichever case triggered it. + fn assert_no_panic(context: &str, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T { + match std::panic::catch_unwind(f) { + Ok(result) => result, + Err(_) => panic!("panicked while decoding malformed input: {context}"), + } + } + + /// A valid on-disk-framed blob (`[i32 BE size][data][i32 BE crc]`) plus its unframed `data` + /// payload (the portable-format `[i32 LE magic][RoaringTreemap bytes]`, the same bytes an + /// inline DV descriptor would carry directly), shared by every malformed-input case below so + /// each corruption starts from one known-good baseline. + fn valid_dv_fixture() -> (Vec, Vec) { + let deleted: RoaringTreemap = [1u64, 5, 6, 7, 1000, (3u64 << 32) + 42] + .into_iter() + .collect(); + let data = portable_bytes(&deleted); + let blob = frame(&data); + (blob, data) + } + + /// (1) Truncating a valid on-disk-framed blob at EVERY byte length from 0 to `len - 1` must + /// be rejected cleanly by `unframe_dv_blob`, never panic -- covers every truncation point in + /// one deterministic sweep rather than a few hand-picked lengths. + #[test] + fn unframe_rejects_every_truncation_length() { + let (blob, data) = valid_dv_fixture(); + let expected_size = data.len(); + for len in 0..blob.len() { + let truncated = &blob[..len]; + let result = assert_no_panic(&format!("on-disk blob truncated to {len} bytes"), || { + unframe_dv_blob(truncated, expected_size) + }); + assert!( + result.is_err(), + "truncating the on-disk blob to {len}/{} bytes should be rejected", + blob.len() + ); + } + } + + /// (1, inline-DV path) `attach_access_plan` feeds an inline descriptor's `inline_data` + /// straight to `deserialize_dv_bitmap`, skipping `unframe_dv_blob` entirely -- it carries no + /// `[size][data][crc]` framing, just `[i32 LE magic]...`. Every truncation length of that + /// unframed payload must also be handled cleanly: either a clean `Err`, or -- if a truncated + /// prefix happens to still parse -- a well-formed treemap that `build_access_plan` can + /// consume without panicking. Never a panic in either step. + #[test] + fn deserialize_rejects_every_truncation_length_of_inline_payload() { + let (_blob, data) = valid_dv_fixture(); + for len in 0..data.len() { + let truncated = &data[..len]; + let context = format!("inline payload truncated to {len} bytes"); + let result = assert_no_panic(&context, || deserialize_dv_bitmap(truncated)); + if let Ok(treemap) = result { + let max_row = treemap + .max() + .and_then(|m| m.checked_add(1)) + .unwrap_or(u64::MAX); + assert_no_panic(&format!("{context}: build_access_plan on survivor"), || { + let _ = build_access_plan(&[max_row as i64], &treemap); + }); + } + } + } + + /// (2) Flipping each byte of the CRC field individually must be rejected as a checksum + /// mismatch. XORing with `0xFF` guarantees the flipped byte differs from its original value + /// at that position, so every flip actually corrupts the checksum -- it can never coincide + /// with the real value by construction. + #[test] + fn unframe_rejects_every_crc_byte_flip() { + let (blob, data) = valid_dv_fixture(); + let expected_size = data.len(); + let crc_start = blob.len() - 4; + for i in crc_start..blob.len() { + let mut corrupted = blob.clone(); + corrupted[i] ^= 0xFF; + let context = format!("CRC byte {i} flipped"); + let result = assert_no_panic(&context, || unframe_dv_blob(&corrupted, expected_size)); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("checksum"), + "{context} should be reported as a checksum mismatch: {err}" + ); + } + } + + /// (3) A magic number that matches neither known format must be rejected by name -- checked + /// against both obviously-wrong values and the bitwise complement of each real magic (which, + /// by construction, can never accidentally equal either real magic). + #[test] + fn deserialize_rejects_corrupted_magic() { + let (_blob, data) = valid_dv_fixture(); + let payload = &data[4..]; // magic-stripped body, reused under every corrupted magic + for bad_magic in [0i32, 1, -1, i32::MAX, !PORTABLE_MAGIC, !NATIVE_MAGIC] { + assert_ne!(bad_magic, PORTABLE_MAGIC); + assert_ne!(bad_magic, NATIVE_MAGIC); + let mut corrupted = bad_magic.to_le_bytes().to_vec(); + corrupted.extend_from_slice(payload); + let context = format!("magic corrupted to {bad_magic}"); + let result = assert_no_panic(&context, || deserialize_dv_bitmap(&corrupted)); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("magic"), + "{context}: unexpected error: {err}" + ); + } + } + + /// (4a) A declared size larger than the buffer actually holds must be rejected as truncated + /// -- not read out of bounds, not panic -- even when the descriptor's `expected_size` agrees + /// with the (lied-about) declared size, so it is the truncation check, not the size-mismatch + /// check, that has to catch it. + #[test] + fn unframe_rejects_size_field_larger_than_buffer() { + let (_blob, data) = valid_dv_fixture(); + let lie = data.len() + 1_000_000; // declares far more data than the buffer holds + let mut lied_blob = (lie as i32).to_be_bytes().to_vec(); + lied_blob.extend_from_slice(&data); + lied_blob.extend((crc32fast::hash(&data) as i32).to_be_bytes()); + let result = assert_no_panic("size field lies larger than the buffer", || { + unframe_dv_blob(&lied_blob, lie) + }); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("truncated"), + "unexpected error: {err}" + ); + } + + /// (4b) A declared size smaller than the data actually written shifts which bytes get hashed + /// as the CRC input, so it must surface as a checksum mismatch -- never a panic, never a + /// successful decode of a differently-sliced payload. + #[test] + fn unframe_rejects_size_field_smaller_than_actual_data() { + let (_blob, data) = valid_dv_fixture(); + let lie = data.len() - 4; // declares less data than was actually written + let mut lied_blob = (lie as i32).to_be_bytes().to_vec(); + lied_blob.extend_from_slice(&data); + lied_blob.extend((crc32fast::hash(&data) as i32).to_be_bytes()); + let result = assert_no_panic("size field lies smaller than actual data", || { + unframe_dv_blob(&lied_blob, lie) + }); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("checksum"), + "unexpected error: {err}" + ); + } + + /// (5) `validate_cardinality` must reject absurd cardinality claims in BOTH directions -- far + /// too high (a stale descriptor claiming millions of deletions for a handful of actual bits) + /// and far too low (zero or negative expected against many actual bits) -- and must never + /// panic, including when a negative `expected` (an `i64`) is cast to the `u64` comparison + /// `deleted.len()` uses. + #[test] + fn validate_cardinality_rejects_absurd_mismatches_in_both_directions() { + let deleted: RoaringTreemap = (0u64..1000).collect(); // 1000 actual deletions + + for (context, expected) in [ + ("claimed far too high", i64::MAX), + ("claimed far too low (zero)", 0i64), + ("claimed negative", -1i64), + ] { + let result = assert_no_panic(context, || { + validate_cardinality("f.parquet", expected, &deleted) + }); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("cardinality"), + "{context}: unexpected error: {err}" + ); + } + + // Reverse imbalance: an empty bitmap against a huge claimed cardinality. + let empty = RoaringTreemap::new(); + let result = assert_no_panic("empty bitmap vs huge claimed cardinality", || { + validate_cardinality("f.parquet", 1_000_000_000, &empty) + }); + let err = result.unwrap_err(); + assert!( + format!("{err}").contains("cardinality"), + "unexpected error: {err}" + ); + } + + /// (6) Single-bit-flip fuzz sweep over one valid on-disk-framed blob: for every bit position, + /// flip it and run the FULL decode pipeline (`unframe_dv_blob` then `deserialize_dv_bitmap`). + /// Every outcome must be either a clean `Err` or a successfully-decoded, well-formed treemap + /// that `build_access_plan` can consume without panicking -- NEVER a panic in either step. + /// Bounded to one pass over one blob's bits, so runtime stays well under 5s. + #[test] + fn single_bit_flip_sweep_never_panics() { + let (blob, data) = valid_dv_fixture(); + let expected_size = data.len(); + let mut checked = 0usize; + + for byte_idx in 0..blob.len() { + for bit in 0u8..8 { + let mut corrupted = blob.clone(); + corrupted[byte_idx] ^= 1 << bit; + let context = format!("byte {byte_idx} bit {bit} flipped"); + checked += 1; + + let unframed = assert_no_panic(&context, || { + unframe_dv_blob(&corrupted, expected_size).map(|d| d.to_vec()) + }); + let Ok(unframed_data) = unframed else { + continue; + }; + + let decoded = assert_no_panic(&context, || deserialize_dv_bitmap(&unframed_data)); + if let Ok(treemap) = decoded { + // A "VALID selection": consuming the decoded treemap downstream must not + // panic either, whatever its contents happen to be. `checked_add` avoids an + // overflow panic (rather than a clean Err) if corruption produced a max value + // of u64::MAX. + let max_row = treemap + .max() + .and_then(|m| m.checked_add(1)) + .unwrap_or(u64::MAX); + assert_no_panic(&format!("{context}: build_access_plan"), || { + let _ = build_access_plan(&[max_row as i64], &treemap); + }); + } + } + } + assert_eq!( + checked, + blob.len() * 8, + "every single-bit flip must have been exercised" + ); + } + + /// (6, inline-DV path) Same single-bit-flip sweep as above, but over the shorter, unframed + /// inline payload (`deserialize_dv_bitmap` only, no `unframe_dv_blob`) -- the exact bytes an + /// inline `DeltaSparkDvDescriptor.inline_data` carries. Bounded to one pass over one + /// (shorter) payload's bits. + #[test] + fn inline_payload_single_bit_flip_sweep_never_panics() { + let (_blob, data) = valid_dv_fixture(); + let mut checked = 0usize; + + for byte_idx in 0..data.len() { + for bit in 0u8..8 { + let mut corrupted = data.clone(); + corrupted[byte_idx] ^= 1 << bit; + let context = format!("inline byte {byte_idx} bit {bit} flipped"); + checked += 1; + + let decoded = assert_no_panic(&context, || deserialize_dv_bitmap(&corrupted)); + if let Ok(treemap) = decoded { + let max_row = treemap + .max() + .and_then(|m| m.checked_add(1)) + .unwrap_or(u64::MAX); + assert_no_panic(&format!("{context}: build_access_plan"), || { + let _ = build_access_plan(&[max_row as i64], &treemap); + }); + } + } + } + assert_eq!( + checked, + data.len() * 8, + "every single-bit flip of the inline payload must have been exercised" + ); + } +} diff --git a/native/core/src/execution/mod.rs b/native/core/src/execution/mod.rs index 55da2c733aa..cacc92b48f1 100644 --- a/native/core/src/execution/mod.rs +++ b/native/core/src/execution/mod.rs @@ -17,6 +17,8 @@ //! PoC of vectorization execution through JNI to Rust. pub mod columnar_to_row; +#[cfg(feature = "delta")] +pub mod delta_dv; pub mod expressions; pub mod jni_api; pub(crate) mod merge_as_partial; diff --git a/native/core/src/execution/planner.rs b/native/core/src/execution/planner.rs index 42e9333da08..0a829124963 100644 --- a/native/core/src/execution/planner.rs +++ b/native/core/src/execution/planner.rs @@ -29,6 +29,9 @@ pub mod operator_registry; // and calls into that crate. #[cfg(feature = "contrib-delta")] mod delta_scan; +// JVM-planned Delta sibling of the kernel handler above; see delta_spark_scan.rs. +#[cfg(feature = "delta")] +mod delta_spark_scan; use crate::execution::operators::init_csv_datasource_exec; use crate::execution::operators::AlignedArrowStreamReader; @@ -104,6 +107,7 @@ use datafusion::common::{ JoinType as DFJoinType, NullEquality, ScalarValue, }; use datafusion::datasource::listing::PartitionedFile; +use datafusion::datasource::object_store::ObjectStoreUrl; use datafusion::logical_expr::type_coercion::functions::fields_with_udf; use datafusion::logical_expr::type_coercion::other::get_coerce_type_for_case_expression; use datafusion::logical_expr::{ @@ -115,6 +119,7 @@ use datafusion::physical_expr::window::WindowExpr; use datafusion::physical_expr::LexOrdering; use crate::parquet::parquet_exec::init_datasource_exec; +use crate::parquet::schema_adapter::JvmCaseTables; use arrow::array::{ new_empty_array, Array, ArrayRef, BinaryBuilder, BooleanArray, Date32Array, Decimal128Array, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, ListArray, @@ -444,6 +449,173 @@ impl PhysicalPlanner { self.partition } + /// Build the native parquet `DataSourceExec` shared by the parquet-backed scan arms + /// (NativeScan and, behind the `delta` feature, DeltaScan): schema conversion, data-filter + /// binding, object-store setup, file-group construction, and `init_datasource_exec`. + /// Arm-specific concerns (file-list decoding, deletion-vector handling) stay in the arms. + fn build_parquet_scan_plan( + &self, + plan_id: u32, + common: &spark_operator::NativeScanCommon, + object_store_url: ObjectStoreUrl, + files: Vec, + ) -> Result, ExecutionError> { + let data_schema = convert_spark_types_to_arrow_schema(common.data_schema.as_slice()); + let required_schema: SchemaRef = + convert_spark_types_to_arrow_schema(common.required_schema.as_slice()); + let partition_schema: SchemaRef = + convert_spark_types_to_arrow_schema(common.partition_schema.as_slice()); + let projection_vector: Vec = common + .projection_vector + .iter() + .map(|offset| *offset as usize) + .collect(); + + // Check if this partition has any files (bucketed scan with bucket pruning may have + // empty partitions; a fully-pruned Delta partition likewise). + if files.is_empty() { + let empty_exec = Arc::new(EmptyExec::new(required_schema)); + return Ok(Arc::new(SparkPlan::new(plan_id, empty_exec, vec![]))); + } + + // data_filters may reference partition columns and constant metadata columns + // (e.g. `_metadata.file_size`), which the Parquet reader appends after + // required_schema's columns once partition_values are projected into the + // batch. Bind against the combined schema so `Bound` indices resolve + // correctly -- Scala's `exprToProto(filter, scan.output)` + // (CometNativeScan.scala) numbers columns against that same ordering. + let data_filters: Result>, ExecutionError> = + if common.data_filters.is_empty() { + Ok(vec![]) + } else { + let filter_schema: SchemaRef = Arc::new(Schema::new( + required_schema + .fields() + .iter() + .chain(partition_schema.fields().iter()) + .cloned() + .collect::>(), + )); + common + .data_filters + .iter() + .map(|expr| self.create_expr(expr, Arc::clone(&filter_schema))) + .collect() + }; + + let default_values = self.parse_default_values(common, &required_schema)?; + + let file_groups: Vec> = vec![files]; + + // Case tables shipped by the planning JVM (populated only when case_sensitive is + // false): they let native's case-insensitive footer matching reproduce that JVM's + // `toLowerCase(Locale.ROOT)` exactly. Absent tables on a case-insensitive scan (an + // old plan) degrade to a `str::to_lowercase` fallback in `java_lowercase`. + let jvm_case_tables = if !common.case_sensitive && !common.jvm_lower_cp.is_empty() { + Some(Arc::new(JvmCaseTables::from_proto( + &common.jvm_lower_cp, + &common.jvm_lower_repl, + &common.jvm_sigma_class_ranges, + ))) + } else { + None + }; + + let scan = init_datasource_exec( + required_schema, + Some(data_schema), + Some(partition_schema), + object_store_url, + file_groups, + Some(projection_vector), + Some(data_filters?), + default_values, + common.session_timezone.as_str(), + common.case_sensitive, + jvm_case_tables, + common.return_null_struct_if_all_fields_missing, + common.allow_type_promotion, + common.allow_timestamp_ltz_to_ntz, + self.session_ctx(), + common.encryption_enabled, + common.use_field_id, + common.ignore_missing_field_id, + )?; + Ok(Arc::new(SparkPlan::new(plan_id, scan, vec![]))) + } + + /// Register the scan's object store and convert its proto file list into DataFusion + /// [`PartitionedFile`]. Shared by the NativeScan and DeltaScan arms; empty partitions + /// yield an empty file list (handled by `build_parquet_scan_plan`). + fn prepare_scan_store_and_files( + &self, + common: &spark_operator::NativeScanCommon, + partition_files: &SparkFilePartition, + ) -> Result<(ObjectStoreUrl, Vec), ExecutionError> { + let one_file = match partition_files.partitioned_file.first() { + Some(f) => f.file_path.clone(), + None => { + // Empty partition: no store to resolve; the URL is unused because the + // file group is empty and build_parquet_scan_plan returns EmptyExec. + return Ok((ObjectStoreUrl::local_filesystem(), vec![])); + } + }; + let object_store_options: HashMap = common + .object_store_options + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let (object_store_url, _) = prepare_object_store_with_configs( + self.session_ctx.runtime_env(), + one_file, + &object_store_options, + )?; + let files = self.get_partitioned_files(partition_files)?; + Ok((object_store_url, files)) + } + + /// Parse a scan's serialized default values (for columns missing in older files) into the + /// map consumed by the SchemaMapper. Shared by the NativeScan and DeltaScan arms. + fn parse_default_values( + &self, + common: &spark_operator::NativeScanCommon, + required_schema: &SchemaRef, + ) -> Result>, ExecutionError> { + if common.default_values.is_empty() { + return Ok(None); + } + // We have default values. Extract the two lists (same length) of values and + // indexes in the schema, and then create a HashMap to use in the SchemaMapper. + let default_values: Result, DataFusionError> = common + .default_values + .iter() + .map(|expr| { + let literal = self.create_expr(expr, Arc::clone(required_schema))?; + let df_literal = literal.downcast_ref::().ok_or_else(|| { + GeneralError("Expected literal of default value.".to_string()) + })?; + Ok(df_literal.value().clone()) + }) + .collect(); + let default_values = default_values?; + let default_values_indexes: Vec = common + .default_values_indexes + .iter() + .map(|offset| *offset as usize) + .collect(); + Ok(Some( + default_values_indexes + .into_iter() + .zip(default_values) + .map(|(idx, scalar_value)| { + let field = required_schema.field(idx); + let column = Column::new(field.name().as_str(), idx); + (column, scalar_value) + }) + .collect(), + )) + } + /// get DataFusion PartitionedFiles from a Spark FilePartition fn get_partitioned_files( &self, @@ -1581,143 +1753,20 @@ impl PhysicalPlanner { .as_ref() .ok_or_else(|| GeneralError("NativeScan missing common data".into()))?; - let data_schema = - convert_spark_types_to_arrow_schema(common.data_schema.as_slice()); - let required_schema: SchemaRef = - convert_spark_types_to_arrow_schema(common.required_schema.as_slice()); - let partition_schema: SchemaRef = - convert_spark_types_to_arrow_schema(common.partition_schema.as_slice()); - let projection_vector: Vec = common - .projection_vector - .iter() - .map(|offset| *offset as usize) - .collect(); - let partition_files = scan .file_partition .as_ref() .ok_or_else(|| GeneralError("NativeScan missing file_partition".into()))?; - // Check if this partition has any files (bucketed scan with bucket pruning may have empty partitions) - if partition_files.partitioned_file.is_empty() { - let empty_exec = Arc::new(EmptyExec::new(required_schema)); - return Ok(( - vec![], - vec![], - Arc::new(SparkPlan::new(spark_plan.plan_id, empty_exec, vec![])), - )); - } - - // data_filters may reference partition columns and constant metadata columns - // (e.g. `_metadata.file_size`), which the Parquet reader appends after - // required_schema's columns once partition_values are projected into the - // batch. Bind against the combined schema so `Bound` indices resolve - // correctly -- Scala's `exprToProto(filter, scan.output)` - // (CometNativeScan.scala) numbers columns against that same ordering. - let data_filters: Result>, ExecutionError> = - if common.data_filters.is_empty() { - Ok(vec![]) - } else { - let filter_schema: SchemaRef = Arc::new(Schema::new( - required_schema - .fields() - .iter() - .chain(partition_schema.fields().iter()) - .cloned() - .collect::>(), - )); - common - .data_filters - .iter() - .map(|expr| self.create_expr(expr, Arc::clone(&filter_schema))) - .collect() - }; - - let default_values: Option> = if !common - .default_values - .is_empty() - { - // We have default values. Extract the two lists (same length) of values and - // indexes in the schema, and then create a HashMap to use in the SchemaMapper. - let default_values: Result, DataFusionError> = common - .default_values - .iter() - .map(|expr| { - let literal = self.create_expr(expr, Arc::clone(&required_schema))?; - let df_literal = - literal.downcast_ref::().ok_or_else(|| { - GeneralError("Expected literal of default value.".to_string()) - })?; - Ok(df_literal.value().clone()) - }) - .collect(); - let default_values = default_values?; - let default_values_indexes: Vec = common - .default_values_indexes - .iter() - .map(|offset| *offset as usize) - .collect(); - Some( - default_values_indexes - .into_iter() - .zip(default_values) - .map(|(idx, scalar_value)| { - let field = required_schema.field(idx); - let column = Column::new(field.name().as_str(), idx); - (column, scalar_value) - }) - .collect(), - ) - } else { - None - }; - - // Get one file from this partition (we know it's not empty due to early return above) - let one_file = partition_files - .partitioned_file - .first() - .map(|f| f.file_path.clone()) - .expect("partition should have files after empty check"); - - let object_store_options: HashMap = common - .object_store_options - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - let (object_store_url, _) = prepare_object_store_with_configs( - self.session_ctx.runtime_env(), - one_file, - &object_store_options, - )?; - - // Get files for this partition - let files = self.get_partitioned_files(partition_files)?; - let file_groups: Vec> = vec![files]; - - let scan = init_datasource_exec( - required_schema, - Some(data_schema), - Some(partition_schema), + let (object_store_url, files) = + self.prepare_scan_store_and_files(common, partition_files)?; + let scan = self.build_parquet_scan_plan( + spark_plan.plan_id, + common, object_store_url, - file_groups, - Some(projection_vector), - Some(data_filters?), - default_values, - common.session_timezone.as_str(), - common.case_sensitive, - common.return_null_struct_if_all_fields_missing, - common.allow_type_promotion, - common.allow_timestamp_ltz_to_ntz, - self.session_ctx(), - common.encryption_enabled, - common.use_field_id, - common.ignore_missing_field_id, + files, )?; - Ok(( - vec![], - vec![], - Arc::new(SparkPlan::new(spark_plan.plan_id, scan, vec![])), - )) + Ok((vec![], vec![], scan)) } OpStruct::CsvScan(scan) => { let data_schema = convert_spark_types_to_arrow_schema(scan.data_schema.as_slice()); @@ -1851,10 +1900,18 @@ impl PhysicalPlanner { if let Some(result) = delta_scan::try_plan_contrib_scan(self, spark_plan, contrib) { return result; } + #[cfg(feature = "delta")] + if let Some(result) = + delta_spark_scan::try_plan_contrib_scan(self, spark_plan, contrib) + { + return result; + } Err(GeneralError(format!( "Received a contrib_scan operator (type_url: {}) but core was built without a \ contrib that handles it. Rebuild with the matching contrib feature -- e.g. \ - `-Pcontrib-delta` (Maven) + `--features contrib-delta` (Cargo) for Delta Lake.", + `-Pcontrib-delta` (Maven) + `--features contrib-delta` (Cargo) for the \ + kernel-planned Delta path, or `-Pdelta` + `--features delta` for the \ + JVM-planned Delta path.", contrib.type_url ))) } @@ -4998,6 +5055,21 @@ mod tests { } } + /// Pack a `DeltaSparkScan` into the generic `ContribScan` envelope exactly as the + /// contrib jar does on the JVM side. + fn delta_spark_envelope(scan: spark_operator::DeltaSparkScan) -> Operator { + use prost::Message; + Operator { + plan_id: 0, + sql_text_pool: vec![], + children: vec![], + op_struct: Some(OpStruct::ContribScan(spark_operator::ContribScan { + type_url: "type.googleapis.com/comet.contrib.delta_spark.DeltaSparkScan".into(), + value: scan.encode_to_vec(), + })), + } + } + #[test] fn shuffle_partition_writer_legacy_paths_remain_supported() { let writer = spark_operator::ShuffleWriter { @@ -5189,6 +5261,88 @@ mod tests { ); } + #[test] + fn delta_scan_errors_without_delta_feature() { + let op = delta_spark_envelope(spark_operator::DeltaSparkScan { + common: None, + delta_common: None, + file_partition: None, + }); + let planner = PhysicalPlanner::default(); + let err = planner.create_plan(&op, &mut vec![], 1).unwrap_err(); + let msg = format!("{err}"); + #[cfg(not(feature = "delta"))] + assert!( + msg.contains("built without a contrib that handles it"), + "expected mismatched-build error, got: {msg}" + ); + #[cfg(feature = "delta")] + assert!( + msg.contains("missing common data"), + "expected missing-common-data error for an empty DeltaSparkScan, got: {msg}" + ); + } + + #[cfg(feature = "delta")] + fn delta_scan_op(files: Vec) -> Operator { + delta_spark_envelope(spark_operator::DeltaSparkScan { + common: Some(Default::default()), + delta_common: None, + file_partition: Some(spark_operator::DeltaSparkFilePartition { + partitioned_file: files, + }), + }) + } + + #[cfg(feature = "delta")] + #[test] + fn delta_scan_rejects_dv_without_source() { + let op = delta_scan_op(vec![spark_operator::DeltaSparkPartitionedFile { + file: Some(spark_operator::SparkPartitionedFile { + file_path: "file:///tmp/f.parquet".into(), + start: 0, + length: 0, + file_size: 0, + partition_values: vec![], + }), + dv: Some(spark_operator::DeltaSparkDvDescriptor { + storage_type: "u".into(), + absolute_path: None, + inline_data: None, + offset: Some(1), + size_in_bytes: 1, + cardinality: 1, + }), + // (file_path carries a scheme because store resolution now precedes + // the DV handling) + }]); + let err = PhysicalPlanner::default() + .create_plan(&op, &mut vec![], 1) + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("neither inline data nor a path"), + "expected malformed-descriptor error, got: {msg}" + ); + } + + #[cfg(feature = "delta")] + #[test] + fn delta_scan_rejects_missing_inner_file() { + let op = delta_scan_op(vec![spark_operator::DeltaSparkPartitionedFile { + file: None, + dv: None, + }]); + let err = PhysicalPlanner::default() + .create_plan(&op, &mut vec![], 1) + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("missing inner file"), + "expected missing-inner-file error, got: {msg}" + ); + } + #[test] fn shuffle_partition_writer_rejects_rss_with_legacy_index_path() { let writer = spark_operator::ShuffleWriter { diff --git a/native/core/src/execution/planner/delta_spark_scan.rs b/native/core/src/execution/planner/delta_spark_scan.rs new file mode 100644 index 00000000000..023bc3e4912 --- /dev/null +++ b/native/core/src/execution/planner/delta_spark_scan.rs @@ -0,0 +1,764 @@ +// 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. + +//! JVM-planned Delta handler for the generic `OpStruct::ContribScan` dispatcher, feature-gated +//! behind `delta`. +//! +//! delta-spark has already done log replay, snapshot resolution, and partition pruning by the +//! time the scan reaches Comet, so the envelope carries a concrete file list (plus deletion +//! vector descriptors) and the read path reuses the exact same shared parquet scan builder as +//! `NativeScan` -- inheriting row-group stats pruning, page-index pruning, and filter pushdown. +//! Sibling of the kernel-planned handler in `delta_scan.rs`; the two claim different +//! `type_url`s within the same `ContribScan` envelope. + +use std::collections::HashMap; +use std::sync::Arc; + +use datafusion::execution::object_store::ObjectStoreUrl; +use object_store::path::Path; +use object_store::ObjectStore; +use url::Url; + +use datafusion_comet_proto::spark_operator::{ + ContribScan, DeltaSparkScan, Operator, SparkFilePartition, SparkPartitionedFile, +}; +use prost::Message; + +use crate::execution::operators::ExecutionError; +use crate::execution::operators::ExecutionError::GeneralError; +use crate::execution::planner::PhysicalPlanner; +use crate::execution::planner::PlanCreationResult; +use crate::parquet::parquet_support::{ + hash_object_store_configs, object_store_url_key, prepare_object_store_with_config_hash, +}; + +/// Type name the JVM-planned Delta contrib claims within the `ContribScan` envelope. The +/// contrib jar packs a `DeltaSparkScan` with a `type_url` of +/// `type.googleapis.com/comet.contrib.delta_spark.DeltaSparkScan`; dispatch keys on the +/// contrib-owned suffix, same convention as the kernel path's `delta_scan.rs`. +const DELTA_SPARK_SCAN_TYPE_NAME: &str = "comet.contrib.delta_spark.DeltaSparkScan"; + +/// Contrib entry point for the `OpStruct::ContribScan` dispatcher. Returns `Some(result)` when +/// the envelope carries a JVM-planned Delta scan, or `None` when the `type_url` belongs to some +/// other contrib. +pub(crate) fn try_plan_contrib_scan( + planner: &PhysicalPlanner, + spark_plan: &Operator, + contrib: &ContribScan, +) -> Option { + if !contrib.type_url.ends_with(DELTA_SPARK_SCAN_TYPE_NAME) { + return None; + } + Some( + DeltaSparkScan::decode(contrib.value.as_slice()) + .map_err(|e| { + GeneralError(format!( + "Failed to decode DeltaSparkScan from contrib_scan: {e}" + )) + }) + .and_then(|scan| plan_delta_spark_scan(planner, spark_plan, &scan)), + ) +} + +fn plan_delta_spark_scan( + planner: &PhysicalPlanner, + spark_plan: &Operator, + scan: &DeltaSparkScan, +) -> PlanCreationResult { + // Delta data files are plain parquet; the read path deliberately reuses + // the same shared parquet scan builder as NativeScan so Delta inherits + // row-group stats pruning, page-index pruning, and filter pushdown. Only + // the file list arrives in Delta-specific form. Note delta_common's + // column_mapping_mode is informational in M1: the actual field-id + // matching switch is common.use_field_id, same as the Iceberg path. + let common = scan + .common + .as_ref() + .ok_or_else(|| GeneralError("DeltaSparkScan missing common data".into()))?; + + let delta_partition = scan + .file_partition + .as_ref() + .ok_or_else(|| GeneralError("DeltaSparkScan missing file_partition".into()))?; + + let spark_partition = SparkFilePartition { + partitioned_file: delta_partition + .partitioned_file + .iter() + .map(|f| { + f.file.clone().ok_or_else(|| { + GeneralError("DeltaSparkPartitionedFile missing inner file".into()) + }) + }) + .collect::, _>>()?, + }; + + // Defense-in-depth against a stale or bypassed JVM gate: DeltaScanSupport.declineReason + // (multiStoreReason) already declines data files spanning multiple object-store authorities + // at planning time, but prepare_scan_store_and_files below resolves this whole partition's + // ObjectStoreUrl from the FIRST file only and then strips every other file down to its bare + // object-store path -- a file that actually lives under a different authority would + // silently read through the first file's store handle. Checked here rather than inside + // prepare_scan_store_and_files itself, which is shared with plain NativeScan and out of + // scope for this Delta-specific invariant. + check_same_object_store_authority(&spark_partition.partitioned_file)?; + + let (object_store_url, mut files) = + planner.prepare_scan_store_and_files(common, &spark_partition)?; + + // Translate deletion vectors into per-file ParquetAccessPlans so deleted + // rows are skipped inside the reader (composing, by intersection, with + // page-index pruning). Fetching the bitmaps and footers is async I/O; + // create_plan runs on the JNI task thread outside the tokio context, so + // block_on here is safe and keeps the scan a plain DataSourceExec. + if delta_partition + .partitioned_file + .iter() + .any(|f| f.dv.is_some()) + { + let object_store_options: HashMap = common + .object_store_options + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let runtime_env = planner.session_ctx.runtime_env(); + // `object_store_options` is the same map for every file this partition resolves a store + // for, so its hash is loop-invariant too: computed once here rather than once per file + // inside `prepare_object_store_with_configs`. + let object_store_config_hash = hash_object_store_configs(&object_store_options); + + // Resolve every object store this partition's files touch -- the data + // files' shared authority (check_same_object_store_authority above + // has already verified every data file in this partition resolves to + // the same authority, so prepare_scan_store_and_files's + // first-file-only resolution is safe here) plus any on-disk deletion + // vector's authority, which may legitimately differ from the data + // files' and carries its own resolved store -- before entering the + // async DV runtime below. This MUST happen here, on the JNI thread + // outside the tokio runtime: + // constructing a cold S3 store issues its own internal + // Handle::block_on calls (credential-provider / bucket-region + // resolution), which panics when nested inside the + // get_runtime().block_on(...) a few lines down. See + // delta_dv::attach_access_plans's doc comment for the invariant this + // maintains -- the async path never builds a store. + let mut resolved_stores: HashMap> = HashMap::new(); + // Tracks, per resolved ObjectStoreUrl, the userinfo (and raw URL, for the error + // message) of the first URL that resolved to it. This closure is the ONE place in this + // scan that sees both data-file and deletion-vector URLs together, so the + // store-identity collision check (see check_store_identity's doc comment) lives here + // rather than as an extension of check_same_object_store_authority above, which sees + // only data files and would hard-error the legitimate cross-bucket DV shape. + let mut store_identities: HashMap = HashMap::new(); + let mut resolve_store = + |url: String| -> Result<(Path, Arc), ExecutionError> { + let parsed_url = Url::parse(&url).map_err(|e| { + GeneralError(format!( + "Error parsing URL {}: {e}", + redacted_url_display(&url) + )) + })?; + let user_info = url_user_info(&parsed_url); + + // Cheap, I/O-free cache key: no config hashing, no global object-store-cache + // lock, no runtime_env registration. Checked against the LOCAL `resolved_stores` + // map below before ever paying for the expensive resolution path -- most files + // in a partition share the same authority as an already-resolved file. + let (url_key, _is_hdfs_scheme) = + object_store_url_key(&parsed_url, &object_store_options); + let store_url = ObjectStoreUrl::parse(url_key)?; + check_store_identity(&store_url, &user_info, &url, &mut store_identities)?; + if let Some(store) = resolved_stores.get(&store_url) { + let path = Path::from_url_path(parsed_url.path()) + .map_err(|e| GeneralError(e.to_string()))?; + return Ok((path, Arc::clone(store))); + } + + // Local miss: fall through to the expensive resolution (global cache lock, + // possible store creation, runtime_env registration). `object_store_config_hash` + // was already computed once above, outside this closure. + let (store_url, path) = prepare_object_store_with_config_hash( + Arc::clone(&runtime_env), + url.clone(), + &object_store_options, + object_store_config_hash, + )?; + let store = runtime_env.object_store(&store_url)?; + resolved_stores.insert(store_url, Arc::clone(&store)); + Ok((path, store)) + }; + + // get_partitioned_files maps 1:1 over the proto file list, so the three sources are + // expected to be index-aligned. `.zip()` truncates silently on a length mismatch instead + // of erroring, so check_zip_lengths asserts the invariant up front rather than trusting + // it implicitly -- a future change to any one of the three builders that drops or adds an + // element would otherwise corrupt file-to-DV pairing without either side noticing. + check_zip_lengths( + files.len(), + spark_partition.partitioned_file.len(), + delta_partition.partitioned_file.len(), + )?; + let mut dv_files: Vec = + Vec::with_capacity(files.len()); + for ((file, spark_file), delta_file) in files + .into_iter() + .zip(spark_partition.partitioned_file.iter()) + .zip(delta_partition.partitioned_file.iter()) + { + let (_, data_store) = resolve_store(spark_file.file_path.clone())?; + let dv_store = match delta_file + .dv + .as_ref() + .and_then(|dv| dv.absolute_path.clone()) + { + Some(dv_path) => { + let (path, store) = resolve_store(dv_path)?; + Some((store, path)) + } + None => None, + }; + dv_files.push(crate::execution::delta_dv::DvScanFile { + file, + file_path: spark_file.file_path.clone(), + dv: delta_file.dv.clone(), + data_store, + dv_store, + }); + } + + files = crate::execution::jni_api::get_runtime().block_on( + crate::execution::delta_dv::attach_access_plans(runtime_env, dv_files), + )?; + } + + let scan = + planner.build_parquet_scan_plan(spark_plan.plan_id, common, object_store_url, files)?; + Ok((vec![], vec![], scan)) +} + +/// (scheme, username, host, port), all normalized so equality means "same object-store +/// authority". Scheme and host are lowercased; username (the URI's userinfo -- e.g. the container +/// in `abfss://container@account/...`) is compared verbatim, since object-store identifiers built +/// from it may be case-sensitive and it is safer to draw more authority distinctions than fewer; +/// port is compared as `Option` so an explicit port never collapses into an absent one. +/// Mirrors `DeltaScanSupport.uriAuthority`'s normalization on the JVM side, which folds scheme, +/// userinfo, host, and port into one lowercased `getAuthority`-derived key -- both sides must +/// treat two URIs as the same authority in exactly the same cases so the JVM-side gate +/// (`multiStoreReason`, which declines) always fires before this native check (which errors) ever +/// would. +type ObjectStoreAuthority = (String, String, String, Option); + +/// Errors unless every file in `files` shares the first file's [`ObjectStoreAuthority`]. The +/// `url` crate does NOT lowercase the host for opaque (non-"special") schemes like +/// `s3a`/`abfss`/`hdfs`, so comparing `url[BeforeHost..AfterPort]` verbatim would treat two +/// spellings of the same bucket (`s3a://Bucket-A/..` vs `s3a://bucket-a/..`) as different +/// authorities and hard-error instead of gracefully declining. See the call site's comment for +/// why this defensive check exists alongside the JVM-side gate. +fn check_same_object_store_authority(files: &[SparkPartitionedFile]) -> Result<(), ExecutionError> { + let mut first: Option<(ObjectStoreAuthority, &str)> = None; + for file in files { + let url = Url::parse(&file.file_path).map_err(|e| { + GeneralError(format!( + "Error parsing URL {}: {e}", + redacted_url_display(&file.file_path) + )) + })?; + let authority: ObjectStoreAuthority = ( + url.scheme().to_ascii_lowercase(), + url.username().to_string(), + url.host_str().unwrap_or("").to_ascii_lowercase(), + url.port(), + ); + match &first { + None => first = Some((authority, file.file_path.as_str())), + Some((first_authority, first_path)) if *first_authority != authority => { + return Err(GeneralError(format!( + "Native Delta scan does not support data files spanning multiple object \ + stores (found {} and {})", + redacted_url_display(first_path), + redacted_url_display(&file.file_path) + ))); + } + Some(_) => {} + } + } + Ok(()) +} + +/// Errors unless `files_len`, `spark_files_len`, and `delta_files_len` all agree. Called before +/// the three-way `.zip()` over the object-store-resolved files, the JVM-planned +/// `SparkPartitionedFile`s, and the Delta-specific per-file deletion-vector descriptors that +/// builds `dv_files` -- `Iterator::zip` stops at the shortest sequence with no error, so any +/// future change to one of the three independently-built sources that adds or drops an element +/// would otherwise silently mis-pair a data file with the wrong (or a missing) deletion vector +/// instead of failing loudly. +fn check_zip_lengths( + files_len: usize, + spark_files_len: usize, + delta_files_len: usize, +) -> Result<(), ExecutionError> { + if files_len == spark_files_len && spark_files_len == delta_files_len { + return Ok(()); + } + Err(GeneralError(format!( + "Native Delta scan found mismatched file-list lengths while attaching deletion vectors \ + (resolved files: {files_len}, planned files: {spark_files_len}, deletion-vector \ + descriptors: {delta_files_len}); refusing to zip index-aligned sequences of unequal \ + length" + ))) +} + +/// The userinfo component of `url`'s authority (e.g. the container in +/// `abfss://container@account.dfs.core.windows.net/...`), or the empty string when the URL +/// carries none. Never lowercased, mirroring `check_same_object_store_authority`'s own use of +/// `url.username()` above: userinfo is the ONE component `parquet_support.rs`'s `url_key` drops +/// before it becomes the [`ObjectStoreUrl`] two URLs are resolved and cached under, so it must +/// be compared verbatim, not normalized, to detect a real store-identity collision. Mirrors +/// `DeltaScanSupport.uriUserInfo` on the JVM side. +fn url_user_info(url: &Url) -> String { + url.username().to_string() +} + +/// A display form of `url` safe to embed in an error message: userinfo (e.g. the access/secret +/// key pair embedded as `s3a://AKIA...:secret@bucket/...`, or a Delta shallow-clone container +/// name) is replaced with a literal `***`, mirroring `DeltaScanSupport.redactedAuthority` on the +/// JVM side (`scheme://***@host[:port]`). Scheme and host/port are kept verbatim (not +/// lowercased) and the path is kept in full -- userinfo is the only secret-bearing component, +/// and dropping the path would make the two defense-in-depth checks that call this ([` +/// check_same_object_store_authority`] and [`check_store_identity`]) unable to name which file +/// triggered the error. +/// +/// `url` need not be a valid [`Url`] -- every call site formats a `GeneralError` from a URL that +/// may originate from a foreign/bypassed proto producer, including ones a credential-bearing URL +/// can produce by FAILING to parse in the first place (e.g. `s3a://AKIA:secret@bucket:notaport/x` +/// is `Url::parse`-rejected as `InvalidPort`, but still carries userinfo), so this must be total +/// (never panic) AND must still redact on the parse-failure path -- it is exactly the credentials +/// that make a URL unusual enough to fail parsing that most need to never reach a log line. +/// The fallback below is purely textual: it looks for a `://` scheme delimiter and, within the +/// authority segment that follows (up to the next `/`, mirroring where a real URL's authority +/// ends), replaces everything up to and including the LAST `@` with `***@` -- same last-`@` split +/// as the successfully-parsed path and `DeltaScanSupport.redactedAuthority` on the JVM side. A +/// string with no `://` is treated as having no authority at all and its whole text is searched +/// for a trailing userinfo-shaped `...@host` prefix the same way. A string with neither shape +/// (no `@` anywhere before its authority ends) has no evident secret to redact and is returned +/// unchanged. +fn redacted_url_display(url: &str) -> String { + if let Ok(parsed) = Url::parse(url) { + if parsed.username().is_empty() && parsed.password().is_none() { + return url.to_string(); + } + let host_port = match (parsed.host_str(), parsed.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + (None, _) => String::new(), + }; + let mut redacted = format!("{}://***@{host_port}{}", parsed.scheme(), parsed.path()); + if let Some(query) = parsed.query() { + redacted.push('?'); + redacted.push_str(query); + } + return redacted; + } + + let (scheme_prefix, rest) = match url.find("://") { + Some(scheme_end) => (&url[..scheme_end + 3], &url[scheme_end + 3..]), + None => ("", url), + }; + let authority_len = rest.find('/').unwrap_or(rest.len()); + match rest[..authority_len].rfind('@') { + Some(at) => format!("{scheme_prefix}***@{}", &rest[at + 1..]), + None => url.to_string(), + } +} + +/// Errors when `store_url` was already resolved earlier in this scan under a DIFFERENT +/// `user_info` than the one now being resolved for `url`; otherwise records `(user_info, url)` +/// for `store_url` in `seen` (first resolution wins the recorded userinfo) and returns `Ok`. +/// +/// This is the free-standing half of the residual cross-container DV check, called from inside +/// the `resolve_store` closure above -- the ONE place in this scan that sees both data-file AND +/// deletion-vector URLs. `store_url` is exactly the key `prepare_object_store_with_configs` +/// resolves the object store, `ObjectStoreUrl`, and DataFusion's registry under (its +/// `url_key = scheme://{BeforeHost..AfterPort}`, dropping userinfo entirely -- see +/// `parquet_support.rs`), so two URLs agreeing on `store_url` but disagreeing on `user_info` are +/// exactly the URLs the native side would otherwise silently collapse onto one store handle. +/// That is the shape a Delta shallow clone across containers on a single storage account +/// produces: data stays in `source`, a later DELETE writes its deletion vector into `clone`, +/// and `abfss://source@account/...` / `abfss://clone@account/...` share a host (so the SAME +/// `store_url`) while their userinfo (the container) differs. +/// +/// Deliberately NOT folded into `check_same_object_store_authority` above: that check only ever +/// sees DATA files and hard-errors on ANY authority mismatch, which would incorrectly reject the +/// legitimate cross-bucket DV shape (data in one S3 bucket, its DV in another) -- distinct hosts +/// mean distinct `store_url`s, so this check never even treats them as collision candidates; see +/// `dv_in_different_bucket_is_allowed` below. +fn check_store_identity( + store_url: &ObjectStoreUrl, + user_info: &str, + url: &str, + seen: &mut HashMap, +) -> Result<(), ExecutionError> { + match seen.get(store_url) { + Some((seen_user_info, seen_url)) if seen_user_info != user_info => { + Err(GeneralError(format!( + "Native Delta scan does not support data files and deletion vectors whose \ + stores collide under the native store-identity key (found {} and {})", + redacted_url_display(seen_url), + redacted_url_display(url) + ))) + } + Some(_) => Ok(()), + None => { + seen.insert(store_url.clone(), (user_info.to_string(), url.to_string())); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn partitioned_file(path: &str) -> SparkPartitionedFile { + SparkPartitionedFile { + file_path: path.to_string(), + start: 0, + length: 0, + file_size: 0, + partition_values: vec![], + } + } + + #[test] + fn same_authority_files_pass() { + let files = vec![ + partitioned_file("s3a://bucket/a/part-0.parquet"), + partitioned_file("s3a://bucket/b/part-1.parquet"), + ]; + assert!(check_same_object_store_authority(&files).is_ok()); + } + + #[test] + fn same_authority_files_pass_regardless_of_host_case() { + // The `url` crate does not lowercase hosts for opaque (non-"special") schemes like + // s3a, so this must be normalized explicitly rather than relying on Url's own + // formatting -- otherwise the same physical bucket recorded with mixed casing would + // pass the JVM gate (which does lowercase) but hard-error here instead. + let files = vec![ + partitioned_file("s3a://Bucket-A/x.parquet"), + partitioned_file("s3a://bucket-a/y.parquet"), + ]; + assert!(check_same_object_store_authority(&files).is_ok()); + } + + #[test] + fn mixed_authority_files_error_names_both() { + let files = vec![ + partitioned_file("s3a://bucket-a/part-0.parquet"), + partitioned_file("s3a://bucket-b/part-1.parquet"), + ]; + let err = check_same_object_store_authority(&files).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("bucket-a"), + "expected message to name bucket-a: {msg}" + ); + assert!( + msg.contains("bucket-b"), + "expected message to name bucket-b: {msg}" + ); + assert!( + msg.contains("multiple object stores"), + "expected message to explain the failure: {msg}" + ); + } + + #[test] + fn cross_container_abfss_files_error() { + // Same storage account, different containers: the userinfo (container) must be part of + // the authority key, or `abfss://containerA@account/..` and + // `abfss://containerB@account/..` would collapse into the same authority (same host, + // same scheme) and this defense-in-depth check would silently let a cross-container scan + // through instead of erroring. + let files = vec![ + partitioned_file("abfss://containerA@account.dfs.core.windows.net/a/part-0.parquet"), + partitioned_file("abfss://containerB@account.dfs.core.windows.net/b/part-1.parquet"), + ]; + let err = check_same_object_store_authority(&files).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("multiple object stores"), + "expected message to explain the failure: {msg}" + ); + } + + #[test] + fn same_container_abfss_files_pass() { + let files = vec![ + partitioned_file("abfss://container@account.dfs.core.windows.net/a/part-0.parquet"), + partitioned_file("abfss://container@account.dfs.core.windows.net/b/part-1.parquet"), + ]; + assert!(check_same_object_store_authority(&files).is_ok()); + } + + #[test] + fn distinct_underscore_host_buckets_error() { + // `gs://my_bucket/..` has an underscore reg-name; the `url` crate (unlike Java's `URI`) + // parses it as an opaque host without failing the whole authority, so this check must + // still tell two distinct underscore-bearing buckets apart. + let files = vec![ + partitioned_file("gs://my_bucket/a/part-0.parquet"), + partitioned_file("gs://other_bucket/b/part-1.parquet"), + ]; + let err = check_same_object_store_authority(&files).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("multiple object stores"), + "expected message to explain the failure: {msg}" + ); + } + + #[test] + fn same_underscore_host_bucket_files_pass() { + let files = vec![ + partitioned_file("gs://my_bucket/a/part-0.parquet"), + partitioned_file("gs://my_bucket/b/part-1.parquet"), + ]; + assert!(check_same_object_store_authority(&files).is_ok()); + } + + #[test] + fn local_paths_pass_regardless_of_directory() { + let files = vec![ + partitioned_file("file:///tmp/a/part-0.parquet"), + partitioned_file("file:///tmp/b/part-1.parquet"), + ]; + assert!(check_same_object_store_authority(&files).is_ok()); + } + + /// Builds the same `(ObjectStoreUrl, userinfo)` pair `resolve_store` computes for a URL, + /// without touching any object-store backend: the key mirrors `parquet_support.rs`'s + /// `url_key = scheme://{BeforeHost..AfterPort}` exactly, so these fixtures collide (or + /// don't) under [`check_store_identity`] the same way the real closure's calls would. + fn store_url_and_user_info(url_str: &str) -> (ObjectStoreUrl, String) { + let parsed = Url::parse(url_str).unwrap(); + let key = format!( + "{}://{}", + parsed.scheme(), + &parsed[url::Position::BeforeHost..url::Position::AfterPort], + ); + (ObjectStoreUrl::parse(key).unwrap(), url_user_info(&parsed)) + } + + #[test] + fn dv_in_different_container_same_account_errors() { + // Same storage account (same host -> same ObjectStoreUrl), different containers + // (different userinfo): the shape a Delta shallow clone across containers produces + // when data stays in `source` but a later DELETE writes its DV into `clone`. Both + // authorities collapse onto one native store identity, so this must decline. + let mut seen = HashMap::new(); + let data = "abfss://source@account.dfs.core.windows.net/a/part-0.parquet"; + let dv = "abfss://clone@account.dfs.core.windows.net/_delta_log/deletion_vector_x.bin"; + let (data_store_url, data_user_info) = store_url_and_user_info(data); + check_store_identity(&data_store_url, &data_user_info, data, &mut seen).unwrap(); + let (dv_store_url, dv_user_info) = store_url_and_user_info(dv); + let err = check_store_identity(&dv_store_url, &dv_user_info, dv, &mut seen).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("store-identity"), + "expected message to reference the store-identity collision: {msg}" + ); + // The container names ARE the userinfo here, so the message must redact them rather + // than name the raw URLs -- see redacted_url_display. + assert!( + !msg.contains("source@") && !msg.contains("clone@"), + "expected message to redact the container userinfo: {msg}" + ); + assert!( + msg.contains("***@account.dfs.core.windows.net"), + "expected message to show a redacted authority: {msg}" + ); + assert!( + msg.contains("a/part-0.parquet") && msg.contains("deletion_vector_x.bin"), + "expected message to still name the differing paths: {msg}" + ); + } + + #[test] + fn dv_in_different_bucket_is_allowed() { + // Guards the legitimate MinIO/S3 shape: data in one bucket, its DV in another. + // Distinct hosts mean distinct ObjectStoreUrls, so these must never even look like a + // collision to check_store_identity -- this is exactly the shape + // check_same_object_store_authority alone would be too strict to allow if the + // collision check were folded into it instead of resolve_store. + let mut seen = HashMap::new(); + let data = "s3://comet-delta-a/part-0.parquet"; + let dv = "s3://comet-delta-b/_delta_log/deletion_vector_x.bin"; + let (data_store_url, data_user_info) = store_url_and_user_info(data); + check_store_identity(&data_store_url, &data_user_info, data, &mut seen).unwrap(); + let (dv_store_url, dv_user_info) = store_url_and_user_info(dv); + assert!(check_store_identity(&dv_store_url, &dv_user_info, dv, &mut seen).is_ok()); + } + + #[test] + fn dv_in_same_container_passes() { + let mut seen = HashMap::new(); + let data = "abfss://container@account.dfs.core.windows.net/a/part-0.parquet"; + let dv = "abfss://container@account.dfs.core.windows.net/_delta_log/deletion_vector_x.bin"; + let (data_store_url, data_user_info) = store_url_and_user_info(data); + check_store_identity(&data_store_url, &data_user_info, data, &mut seen).unwrap(); + let (dv_store_url, dv_user_info) = store_url_and_user_info(dv); + assert!(check_store_identity(&dv_store_url, &dv_user_info, dv, &mut seen).is_ok()); + } + + #[test] + fn dv_with_local_paths_passes() { + let mut seen = HashMap::new(); + let data = "file:///tmp/a/part-0.parquet"; + let dv = "file:///tmp/_delta_log/deletion_vector_x.bin"; + let (data_store_url, data_user_info) = store_url_and_user_info(data); + check_store_identity(&data_store_url, &data_user_info, data, &mut seen).unwrap(); + let (dv_store_url, dv_user_info) = store_url_and_user_info(dv); + assert!(check_store_identity(&dv_store_url, &dv_user_info, dv, &mut seen).is_ok()); + } + + #[test] + fn redacted_url_display_leaves_plain_url_unchanged() { + let url = "s3a://bucket/a/part-0.parquet"; + assert_eq!(redacted_url_display(url), url); + } + + #[test] + fn redacted_url_display_redacts_userinfo() { + let url = "s3a://AKIAEXAMPLE:supersecret@bucket/a/part-0.parquet"; + let redacted = redacted_url_display(url); + assert!( + !redacted.contains("AKIAEXAMPLE") && !redacted.contains("supersecret"), + "expected credentials to be redacted: {redacted}" + ); + assert!( + redacted.contains("bucket"), + "expected host to remain visible: {redacted}" + ); + assert_eq!(redacted, "s3a://***@bucket/a/part-0.parquet"); + } + + #[test] + fn redacted_url_display_redacts_multi_at_password_fully() { + // The '@' inside the password must not be mistaken for the userinfo/host delimiter -- + // the LAST '@' in the authority is the real delimiter, same as the JVM's + // `redactedAuthority` split. + let url = "s3a://user:p@ss@bucket/k"; + let redacted = redacted_url_display(url); + assert!( + !redacted.contains("user") && !redacted.contains("p@ss"), + "expected the entire userinfo, including the embedded '@', to be redacted: {redacted}" + ); + assert_eq!(redacted, "s3a://***@bucket/k"); + } + + #[test] + fn redacted_url_display_is_total_for_non_url_input() { + // Not a valid URL and has no authority-like userinfo prefix before its first '/' -- + // must return unchanged rather than panic. + let input = "not a url at all"; + assert_eq!(redacted_url_display(input), input); + + // Not a valid URL (no scheme, so `Url::parse` rejects it as relative), but does have a + // userinfo-shaped prefix before its first '/' -- must still redact it rather than leak + // it verbatim. + let input = "secret@host/path"; + let redacted = redacted_url_display(input); + assert!( + !redacted.contains("secret"), + "expected the userinfo-shaped prefix to be redacted: {redacted}" + ); + assert_eq!(redacted, "***@host/path"); + } + + #[test] + fn redacted_url_display_redacts_credentials_from_a_scheme_prefixed_url_that_fails_to_parse() { + // Invalid port -- `url::Url::parse` rejects this outright (InvalidPort), so this never + // reaches the successfully-parsed branch above; it must still be caught by the fallback, + // which must recognize the `scheme://` prefix so it doesn't stop at the FIRST '/' in + // that prefix (a bug that would leave userinfo un-redacted for exactly this shape). + let url = "s3a://AKIA:secret@bucket:notaport/path"; + assert!(Url::parse(url).is_err(), "fixture must fail to parse"); + let redacted = redacted_url_display(url); + assert!( + !redacted.contains("AKIA") && !redacted.contains("secret"), + "expected credentials to be redacted: {redacted}" + ); + assert_eq!(redacted, "s3a://***@bucket:notaport/path"); + } + + #[test] + fn zip_lengths_agreeing_pass() { + assert!(check_zip_lengths(3, 3, 3).is_ok()); + assert!(check_zip_lengths(0, 0, 0).is_ok()); + } + + #[test] + fn zip_lengths_mismatch_names_all_three_lengths() { + // Every producer of these three sequences (get_partitioned_files, the + // spark_partition.partitioned_file map, and the raw delta_partition.partitioned_file + // list) currently guarantees 1:1 length agreement on every success path -- this can't be + // reached today through the public ContribScan entry point without a code change + // upstream of this check. It's exercised directly here as defense-in-depth against a + // future regression in one of those producers. + let err = check_zip_lengths(2, 3, 3).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("resolved files: 2"), "message was: {msg}"); + assert!(msg.contains("planned files: 3"), "message was: {msg}"); + assert!( + msg.contains("deletion-vector descriptors: 3"), + "message was: {msg}" + ); + } + + #[test] + fn zip_lengths_mismatch_on_delta_files_only() { + let err = check_zip_lengths(4, 4, 5).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("resolved files: 4"), "message was: {msg}"); + assert!(msg.contains("planned files: 4"), "message was: {msg}"); + assert!( + msg.contains("deletion-vector descriptors: 5"), + "message was: {msg}" + ); + } + + #[test] + fn parse_error_on_credential_bearing_url_redacts_the_error_message() { + // Regression: a credential-bearing URL that FAILS `Url::parse` (bad port here) must + // still produce an error whose message omits the secret -- this exercises the actual + // `check_same_object_store_authority` error path, not just the helper in isolation. + let files = vec![partitioned_file( + "s3a://AKIA:supersecret@bucket:notaport/part-0.parquet", + )]; + let err = check_same_object_store_authority(&files).unwrap_err(); + let msg = err.to_string(); + assert!( + !msg.contains("AKIA") && !msg.contains("supersecret"), + "expected the parse-error message to redact credentials: {msg}" + ); + assert!( + msg.contains("***@bucket"), + "expected the parse-error message to still name the redacted host: {msg}" + ); + } +} diff --git a/native/core/src/parquet/mod.rs b/native/core/src/parquet/mod.rs index cfa03220c10..b373d558ba4 100644 --- a/native/core/src/parquet/mod.rs +++ b/native/core/src/parquet/mod.rs @@ -25,306 +25,3 @@ pub mod util; mod cast_column; pub(crate) mod objectstore; - -use std::collections::HashMap; -use std::task::Poll; -use std::{boxed::Box, sync::Arc}; - -use crate::errors::{try_unwrap_or_throw, CometError}; - -/// JNI exposed methods -use jni::{ - objects::{Global, JClass}, - sys::{jboolean, jint, jlong}, - Env, EnvUnowned, -}; - -use crate::execution::jni_api::get_runtime; -use crate::execution::metrics::utils::update_comet_metric; -use crate::execution::operators::ExecutionError; -use crate::execution::planner::PhysicalPlanner; -use crate::execution::serde; -use crate::execution::spark_plan::SparkPlan; -use crate::execution::utils::SparkArrowConvert; -use crate::jvm_bridge::JVMClasses; -use crate::parquet::encryption_support::{CometEncryptionFactory, ENCRYPTION_FACTORY_ID}; -use crate::parquet::parquet_exec::init_datasource_exec; -use crate::parquet::parquet_support::prepare_object_store_with_configs; -use arrow::array::{Array, RecordBatch}; -use datafusion::datasource::listing::PartitionedFile; -use datafusion::execution::SendableRecordBatchStream; -use datafusion::physical_plan::ExecutionPlan; -use datafusion::prelude::{SessionConfig, SessionContext}; -use futures::{poll, StreamExt}; -use jni::objects::{JByteArray, JLongArray, JMap, JObject, JString, ReleaseMode}; -use jni::sys::JNI_FALSE; -use object_store::path::Path; -use util::jni::deserialize_schema; - -// TODO: (ARROW NATIVE) remove this if not needed. -enum ParquetReaderState { - Init, - Reading, - Complete, -} -/// Parquet read context maintained across multiple JNI calls. -struct BatchContext { - native_plan: Arc, - metrics_node: Arc>>, - batch_stream: Option, - current_batch: Option, - reader_state: ParquetReaderState, -} - -#[inline] -fn get_batch_context<'a>(handle: jlong) -> Result<&'a mut BatchContext, CometError> { - unsafe { - (handle as *mut BatchContext) - .as_mut() - .ok_or_else(|| CometError::NullPointer("null batch context handle".to_string())) - } -} - -fn get_file_groups_single_file( - path: &Path, - file_size: u64, - starts: &mut [i64], - lengths: &mut [i64], -) -> Vec> { - assert!(!starts.is_empty() && starts.len() == lengths.len()); - let mut groups: Vec = Vec::with_capacity(starts.len()); - for (i, &start) in starts.iter().enumerate() { - let mut partitioned_file = PartitionedFile::new_with_range( - String::new(), // Dummy file path. We will override this with our path so that url encoding does not occur - file_size, - start, - start + lengths[i], - ); - partitioned_file.object_meta.location = (*path).clone(); - groups.push(partitioned_file); - } - vec![groups] -} - -pub fn get_object_store_options( - env: &mut Env, - map_object: JObject, -) -> Result, CometError> { - let map = env.cast_local::(map_object)?; - // Convert to a HashMap - let mut collected_map = HashMap::new(); - map.iter(env).and_then(|mut iter| { - while let Some(entry) = iter.next(env)? { - let key = entry.key(env)?; - let value = entry.value(env)?; - let key = unsafe { JString::from_raw(env, key.into_raw()) }; - let value = unsafe { JString::from_raw(env, value.into_raw()) }; - let key_string = key.try_to_string(env)?; - let value_string = value.try_to_string(env)?; - collected_map.insert(key_string, value_string); - } - Ok(()) - })?; - - Ok(collected_map) -} - -/// # Safety -/// This function is inherently unsafe since it deals with raw pointers passed from JNI. -#[no_mangle] -pub unsafe extern "system" fn Java_org_apache_comet_parquet_Native_initRecordBatchReader( - e: EnvUnowned, - _jclass: JClass, - file_path: JString, - file_size: jlong, - starts: JLongArray, - lengths: JLongArray, - filter: JByteArray, - required_schema: JByteArray, - data_schema: JByteArray, - session_timezone: JString, - batch_size: jint, - case_sensitive: jboolean, - return_null_struct_if_all_fields_missing: jboolean, - object_store_options: JObject, - key_unwrapper_obj: JObject, - metrics_node: JObject, -) -> jlong { - try_unwrap_or_throw(&e, |env| unsafe { - JVMClasses::init(env); - let session_config = SessionConfig::new().with_batch_size(batch_size as usize); - let planner = - PhysicalPlanner::new(Arc::new(SessionContext::new_with_config(session_config)), 0); - let session_ctx = planner.session_ctx(); - - let path: String = file_path.try_to_string(env).unwrap(); - - let object_store_config = get_object_store_options(env, object_store_options)?; - let (object_store_url, object_store_path) = prepare_object_store_with_configs( - session_ctx.runtime_env(), - path.clone(), - &object_store_config, - )?; - - let required_schema_buffer = env.convert_byte_array(&required_schema)?; - let required_schema = Arc::new(deserialize_schema(&required_schema_buffer)?); - - let data_schema_buffer = env.convert_byte_array(&data_schema)?; - let data_schema = Arc::new(deserialize_schema(&data_schema_buffer)?); - - let data_filters = if !filter.is_null() { - let filter_buffer = env.convert_byte_array(&filter)?; - let filter_expr = serde::deserialize_expr(filter_buffer.as_slice())?; - Some(vec![ - planner.create_expr(&filter_expr, Arc::clone(&data_schema))? - ]) - } else { - None - }; - let starts = starts.get_elements(env, ReleaseMode::NoCopyBack)?; - let starts = core::slice::from_raw_parts_mut(starts.as_ptr(), starts.len()); - - let lengths = lengths.get_elements(env, ReleaseMode::NoCopyBack)?; - let lengths = core::slice::from_raw_parts_mut(lengths.as_ptr(), lengths.len()); - - let file_groups = - get_file_groups_single_file(&object_store_path, file_size as u64, starts, lengths); - - let session_timezone: String = session_timezone.try_to_string(env).unwrap(); - - // Handle key unwrapper for encrypted files - let encryption_enabled = if !key_unwrapper_obj.is_null() { - let encryption_factory = CometEncryptionFactory { - key_unwrapper: Arc::new(jni_new_global_ref!(env, key_unwrapper_obj)?), - }; - session_ctx - .runtime_env() - .register_parquet_encryption_factory( - ENCRYPTION_FACTORY_ID, - Arc::new(encryption_factory), - ); - true - } else { - false - }; - - let scan = init_datasource_exec( - required_schema, - Some(data_schema), - None, - object_store_url, - file_groups, - None, - data_filters, - None, - session_timezone.as_str(), - case_sensitive != JNI_FALSE, - return_null_struct_if_all_fields_missing != JNI_FALSE, - true, // allow_type_promotion: JVM side already validated via TypeUtil.checkParquetType - true, // allow_timestamp_ltz_to_ntz: JVM side already validated via TypeUtil.checkParquetType - session_ctx, - encryption_enabled, - // The iceberg-compat path resolves IDs in the JVM via NativeBatchReader, - // so the native side does not need to do field-ID matching here. - false, - false, - )?; - - let partition_index: usize = 0; - let batch_stream = scan.execute(partition_index, session_ctx.task_ctx())?; - - let ctx = BatchContext { - native_plan: Arc::new(SparkPlan::new(0, scan, vec![])), - metrics_node: Arc::new(jni_new_global_ref!(env, metrics_node)?), - batch_stream: Some(batch_stream), - current_batch: None, - reader_state: ParquetReaderState::Init, - }; - let res = Box::new(ctx); - - Ok(Box::into_raw(res) as i64) - }) -} - -#[no_mangle] -pub extern "system" fn Java_org_apache_comet_parquet_Native_readNextRecordBatch( - e: EnvUnowned, - _jclass: JClass, - handle: jlong, -) -> jint { - try_unwrap_or_throw(&e, |env| { - let context = get_batch_context(handle)?; - let mut rows_read: i32 = 0; - let batch_stream = context.batch_stream.as_mut().unwrap(); - let runtime = get_runtime(); - - loop { - let next_item = batch_stream.next(); - let poll_batch: Poll>> = - runtime.block_on(async { poll!(next_item) }); - - match poll_batch { - Poll::Ready(Some(batch)) => { - let batch = batch?; - rows_read = batch.num_rows() as i32; - context.current_batch = Some(batch); - context.reader_state = ParquetReaderState::Reading; - break; - } - Poll::Ready(None) => { - // EOF - - update_comet_metric(env, context.metrics_node.as_obj(), &context.native_plan)?; - - context.current_batch = None; - context.reader_state = ParquetReaderState::Complete; - break; - } - Poll::Pending => { - // TODO: (ARROW NATIVE): Just keeping polling?? - // Ideally we want to yield to avoid consuming CPU while blocked on IO ?? - continue; - } - } - } - Ok(rows_read) - }) -} - -#[no_mangle] -pub extern "system" fn Java_org_apache_comet_parquet_Native_currentColumnBatch( - e: EnvUnowned, - _jclass: JClass, - handle: jlong, - column_idx: jint, - array_addr: jlong, - schema_addr: jlong, -) { - try_unwrap_or_throw(&e, |_env| { - let context = get_batch_context(handle)?; - let batch_reader = context - .current_batch - .as_mut() - .ok_or_else(|| CometError::Execution { - source: ExecutionError::GeneralError("There is no more data to read".to_string()), - }); - let data = batch_reader?.column(column_idx as usize).into_data(); - data.move_to_spark(array_addr, schema_addr) - .map_err(|e| e.into()) - }) -} - -#[no_mangle] -pub extern "system" fn Java_org_apache_comet_parquet_Native_closeRecordBatchReader( - env: EnvUnowned, - _jclass: JClass, - handle: jlong, -) { - try_unwrap_or_throw(&env, |_| { - unsafe { - let ctx = get_batch_context(handle)?; - let _ = Box::from_raw(ctx); - }; - Ok(()) - }) -} diff --git a/native/core/src/parquet/objectstore/s3.rs b/native/core/src/parquet/objectstore/s3.rs index f9c5f7a8855..5cca01438ce 100644 --- a/native/core/src/parquet/objectstore/s3.rs +++ b/native/core/src/parquet/objectstore/s3.rs @@ -317,6 +317,41 @@ pub(super) fn get_config_trimmed<'a>( get_config(configs, bucket, property).map(|s| s.trim()) } +/// Every `fs.s3a.*` property suffix (without the `fs.s3a.` prefix) this module resolves via +/// [`get_config`]/[`get_config_trimmed`], i.e. every Hadoop S3A config key native's S3 client +/// actually reads. Kept as an explicit, checked-in constant -- rather than only living implicitly +/// as scattered string literals at call sites -- so it can be asserted against two things: (1) the +/// `native_s3a_config_properties_matches_call_sites` test below, which mechanically re-derives the +/// same set from this file's own source text and fails loudly if a call site is added/removed/ +/// retyped without updating this list; and (2) `DeltaScanSupport.scala`'s `AllS3ConfigKeys` in the +/// `contrib/delta-spark` module, which the discovery-harness tests in `DeltaScanContribSuite` +/// assert is a superset of this exact list. +/// +/// SYNC NOTE: keep this list and `AllS3ConfigKeys` +/// (`contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/DeltaScanSupport.scala`) +/// in sync manually -- Scala cannot reference this Rust constant directly, so +/// `DeltaScanContribSuite`'s discovery-harness test carries its own hand-copied duplicate of +/// these same literal values (with a sync-note pointing back here) and asserts `AllS3ConfigKeys` +/// is a superset of it. Adding a `get_config`/`get_config_trimmed` call site here for a new +/// property MUST add the corresponding `fs.s3a.` entry on BOTH sides, or one of the two +/// discovery-harness tests will fail. `#[cfg(test)]`-only: nothing in the production build reads +/// this constant, only the mechanical self-check test below. +#[cfg(test)] +pub(super) const NATIVE_S3A_CONFIG_PROPERTIES: &[&str] = &[ + "endpoint.region", + "path.style.access", + "endpoint", + "requester.pays.enabled", + "comet.credential.provider.class", + "aws.credentials.provider", + "access.key", + "secret.key", + "session.token", + "assumed.role.credentials.provider", + "assumed.role.arn", + "assumed.role.session.name", +]; + /// Activation key (without `fs.s3a.` prefix) naming the vendor `CometS3CredentialProvider` FQCN. /// Per-bucket override is honored via [`get_config_trimmed`]. const PROVIDER_CLASS_PROPERTY: &str = "comet.credential.provider.class"; @@ -867,10 +902,97 @@ impl CredentialProviderMetadata { #[cfg(test)] mod tests { + use std::collections::BTreeSet; use std::sync::atomic::{AtomicI32, Ordering}; use super::*; + /// Discovery-harness test (see `NATIVE_S3A_CONFIG_PROPERTIES`'s doc): mechanically re-derives + /// the set of `fs.s3a.*` property suffixes this file actually resolves by scanning this + /// file's OWN source text (via `include_str!`) for every `get_config(configs, bucket, ...)`/ + /// `get_config_trimmed(configs, bucket, ...)` call site, resolving an identifier argument + /// (e.g. `PROVIDER_CLASS_PROPERTY`) through its own `const NAME: &str = "..."` definition, and + /// asserts the result is EXACTLY `NATIVE_S3A_CONFIG_PROPERTIES`. This fails loudly the moment + /// a call site is added, removed, or its literal changes without updating that constant -- + /// which is exactly the class of bug (a config key silently added to one side of the + /// Scala/Rust boundary but not the other) that let a Hadoop-side resolution rule diverge + /// unnoticed in the round-15 SSE-C finding. + /// + /// The `configs, property` call inside `get_config_trimmed`'s own body (a passthrough of its + /// own `property` parameter, not a call site naming a fixed config key) is deliberately + /// excluded by name. + #[test] + fn native_s3a_config_properties_matches_call_sites() { + let full_source = include_str!("s3.rs"); + // Scan only the non-test portion of this file: the test module below (this very test) + // necessarily contains the pattern strings themselves as strings, which would otherwise + // make the scan match itself and capture garbage. + let test_mod_start = full_source + .find("#[cfg(test)]\nmod tests {") + .expect("this file must contain a `#[cfg(test)] mod tests {` block"); + let source = &full_source[..test_mod_start]; + let mut found: BTreeSet = BTreeSet::new(); + + for pattern in [ + "get_config_trimmed(configs, bucket, ", + "get_config(configs, bucket, ", + ] { + let mut search_start = 0usize; + while let Some(rel_idx) = source[search_start..].find(pattern) { + let start = search_start + rel_idx + pattern.len(); + let end = start + + source[start..] + .find(')') + .expect("unterminated get_config(_trimmed) call in source scan"); + let arg = source[start..end].trim(); + search_start = end + 1; + + if arg == "property" { + // get_config_trimmed's own passthrough of its `property` parameter -- not a + // call site naming a fixed config key. + continue; + } + + let literal = if let Some(stripped) = arg.strip_prefix('"') { + stripped + .strip_suffix('"') + .unwrap_or_else(|| panic!("malformed string literal argument: {arg}")) + .to_string() + } else { + // Identifier argument (e.g. PROVIDER_CLASS_PROPERTY): resolve via its own + // `const NAME: &str = "value";` definition elsewhere in this file. + let const_decl = format!("const {arg}: &str = \""); + let decl_start = source.find(&const_decl).unwrap_or_else(|| { + panic!( + "no `const {arg}: &str = \"...\";` definition found for identifier \ + argument passed to get_config/get_config_trimmed -- update this \ + test's resolution logic or the source" + ) + }) + const_decl.len(); + let decl_end = source[decl_start..] + .find('"') + .expect("unterminated const string literal") + + decl_start; + source[decl_start..decl_end].to_string() + }; + found.insert(literal); + } + } + + let expected: BTreeSet = NATIVE_S3A_CONFIG_PROPERTIES + .iter() + .map(|s| s.to_string()) + .collect(); + + assert_eq!( + found, expected, + "NATIVE_S3A_CONFIG_PROPERTIES must exactly match every property name passed to \ + get_config/get_config_trimmed in this file -- update the constant (and keep \ + DeltaScanSupport.scala's AllS3ConfigKeys in sync, see that constant's SYNC NOTE) \ + when a call site changes" + ); + } + /// Test configuration builder for easier setup Hadoop configurations #[derive(Debug, Default)] struct TestConfigBuilder { diff --git a/native/core/src/parquet/parquet_exec.rs b/native/core/src/parquet/parquet_exec.rs index 1308ce97fca..ee118eebde5 100644 --- a/native/core/src/parquet/parquet_exec.rs +++ b/native/core/src/parquet/parquet_exec.rs @@ -19,7 +19,9 @@ use crate::execution::operators::ExecutionError; use crate::parquet::eager_page_index_reader_factory::EagerPageIndexReaderFactory; use crate::parquet::encryption_support::{CometEncryptionConfig, ENCRYPTION_FACTORY_ID}; use crate::parquet::parquet_support::SparkParquetOptions; -use crate::parquet::schema_adapter::SparkPhysicalExprAdapterFactory; +use crate::parquet::schema_adapter::{ + names_equal_ignore_case_java, JvmCaseTables, SparkPhysicalExprAdapterFactory, +}; use arrow::datatypes::{Field, SchemaRef}; use datafusion::config::{ParquetOptions, TableParquetOptions}; use datafusion::datasource::listing::PartitionedFile; @@ -38,6 +40,11 @@ use datafusion_datasource::TableSchema; use std::collections::HashMap; use std::sync::Arc; +/// Footer/page-index prefetch size for metadata reads, same as DataFusion's default. Shared +/// with the Delta DV path so its cache-populating footer fetch issues the identical read the +/// scan would. +pub(crate) const METADATA_SIZE_HINT: usize = 512 * 1024; + /// Initializes a DataSourceExec plan with a ParquetSource for Comet's native Parquet scan. /// /// `required_schema`: Schema to be projected by the scan. @@ -68,6 +75,7 @@ pub(crate) fn init_datasource_exec( default_values: Option>, session_timezone: &str, case_sensitive: bool, + jvm_case_tables: Option>, return_null_struct_if_all_fields_missing: bool, allow_type_promotion: bool, allow_timestamp_ltz_to_ntz: bool, @@ -93,6 +101,7 @@ pub(crate) fn init_datasource_exec( ); spark_parquet_options.use_field_id = use_field_id; spark_parquet_options.ignore_missing_field_id = ignore_missing_field_id; + spark_parquet_options.jvm_case_tables = jvm_case_tables; // Determine the schema and projection to use for ParquetSource. // When data_schema is provided, use it as the base schema so DataFusion knows the full @@ -111,7 +120,11 @@ pub(crate) fn init_datasource_exec( if case_sensitive { data_field.name() == req_field.name() } else { - data_field.name().to_lowercase() == req_field.name().to_lowercase() + names_equal_ignore_case_java( + data_field.name(), + req_field.name(), + spark_parquet_options.jvm_case_tables.as_deref(), + ) } }) }) @@ -138,7 +151,7 @@ pub(crate) fn init_datasource_exec( let mut parquet_source = ParquetSource::new(table_schema) .with_table_parquet_options(table_parquet_options) - .with_metadata_size_hint(512 * 1024); // Same as DataFusion's default + .with_metadata_size_hint(METADATA_SIZE_HINT); if encryption_enabled { parquet_source = parquet_source.with_encryption_factory( @@ -396,6 +409,7 @@ mod tests { None, "UTC", true, + None, false, false, false, diff --git a/native/core/src/parquet/parquet_support.rs b/native/core/src/parquet/parquet_support.rs index 5b22afa2609..a0e022c4015 100644 --- a/native/core/src/parquet/parquet_support.rs +++ b/native/core/src/parquet/parquet_support.rs @@ -16,6 +16,7 @@ // under the License. use crate::execution::operators::ExecutionError; +use crate::parquet::schema_adapter::{java_lowercase, JvmCaseTables}; use arrow::array::{FixedSizeBinaryArray, ListArray, MapArray, StringArray}; use arrow::buffer::NullBuffer; use arrow::compute::can_cast_types; @@ -34,6 +35,7 @@ use datafusion::error::DataFusionError; use datafusion::execution::object_store::ObjectStoreUrl; use datafusion::execution::runtime_env::RuntimeEnv; use datafusion::physical_plan::ColumnarValue; +use datafusion_comet_common::SparkError; use datafusion_comet_spark_expr::EvalMode; use log::debug; use object_store::path::Path; @@ -99,6 +101,12 @@ pub struct SparkParquetOptions { /// (Spark 3.x, SPARK-36182). Mirrors Comet's per-Spark-version constant /// in ShimCometConf. pub allow_timestamp_ltz_to_ntz: bool, + /// Case tables generated by the planning JVM (`NativeScanCommon.jvm_*` fields), present + /// whenever `case_sensitive` is false on the plan-based scan paths. They let every + /// case-insensitive name comparison reproduce that JVM's `toLowerCase(Locale.ROOT)` + /// exactly; when absent, matching falls back to `str::to_lowercase` (see + /// `java_lowercase` in `schema_adapter.rs`). + pub jvm_case_tables: Option>, } impl SparkParquetOptions { @@ -115,6 +123,7 @@ impl SparkParquetOptions { ignore_missing_field_id: false, allow_type_promotion: false, allow_timestamp_ltz_to_ntz: false, + jvm_case_tables: None, } } @@ -131,6 +140,7 @@ impl SparkParquetOptions { ignore_missing_field_id: false, allow_type_promotion: false, allow_timestamp_ltz_to_ntz: false, + jvm_case_tables: None, } } } @@ -262,11 +272,14 @@ fn parquet_convert_struct_to_struct( let should_match_by_id = parquet_options.use_field_id && to_fields.iter().any(|f| field_id(f).is_some()); - let from_id_to_index: HashMap = if should_match_by_id { - let mut map = HashMap::new(); + // ID lookups keep EVERY index sharing an ID: Spark's `matchIdField` raises + // `foundDuplicateFieldInFieldIdLookupModeError` when a requested ID resolves to + // more than one physical field, and only when that ID is actually requested. + let from_id_to_indices: HashMap> = if should_match_by_id { + let mut map: HashMap> = HashMap::new(); for (i, field) in from_fields.iter().enumerate() { if let Some(id) = field_id(field) { - map.entry(id).or_insert(i); + map.entry(id).or_default().push(i); } } map @@ -274,18 +287,69 @@ fn parquet_convert_struct_to_struct( HashMap::new() }; - let normalize_name = |name: &str| -> String { + // Name-based lookup. Case-sensitive mode uses an exact-match HashMap; on a + // (pathological) duplicate exact name the last field wins, matching Spark's + // `caseSensitiveParquetFieldMap` built with `.toMap`. Case-insensitive mode + // mirrors Spark's actual Parquet footer matching (see + // `names_equal_ignore_case_java`), which groups physical field names by + // full-string `toLowerCase(Locale.ROOT)`; like Spark's + // `caseInsensitiveParquetFieldMap` groupBy, every colliding index is kept and + // `foundDuplicateFieldInCaseInsensitiveModeError` fires only when a REQUESTED + // field's lookup turns out ambiguous -- unrequested collisions read fine. + let exact_name_to_index_map: Option> = if parquet_options.case_sensitive { - name.to_string() + let mut map = HashMap::new(); + for (i, field) in from_fields.iter().enumerate() { + map.insert(field.name().as_str(), i); + } + Some(map) + } else { + None + }; + let case_insensitive_map: Option>> = + if parquet_options.case_sensitive { + None } else { - name.to_lowercase() + let mut map: HashMap> = + HashMap::with_capacity(from_fields.len()); + for (i, field) in from_fields.iter().enumerate() { + map.entry(java_lowercase( + field.name(), + parquet_options.jvm_case_tables.as_deref(), + )) + .or_default() + .push(i); + } + Some(map) + }; + let from_index_by_name = |name: &str| -> DataFusionResult> { + if let Some(map) = &exact_name_to_index_map { + return Ok(map.get(name).copied()); + } + let map = case_insensitive_map + .as_ref() + .expect("case_insensitive_map is set whenever case_sensitive is false"); + match map.get(&java_lowercase( + name, + parquet_options.jvm_case_tables.as_deref(), + )) { + None => Ok(None), + Some(indices) if indices.len() == 1 => Ok(Some(indices[0])), + Some(indices) => { + let matched = indices + .iter() + .map(|i| from_fields[*i].name().as_str()) + .collect::>() + .join(", "); + Err(DataFusionError::External(Box::new( + SparkError::DuplicateFieldCaseInsensitive { + required_field_name: name.to_string(), + matched_fields: format!("[{}]", matched), + }, + ))) + } } }; - let mut field_name_to_index_map = HashMap::new(); - for (i, field) in from_fields.iter().enumerate() { - field_name_to_index_map.insert(normalize_name(field.name()), i); - } - assert_eq!(field_name_to_index_map.len(), from_fields.len()); let mut field_overlap = false; let mut cast_fields: Vec = Vec::with_capacity(to_fields.len()); @@ -293,10 +357,24 @@ fn parquet_convert_struct_to_struct( let from_index = match (should_match_by_id, field_id(to_field)) { // Spark treats a missing ID match as a missing column rather than // falling back to name match. - (true, Some(id)) => from_id_to_index.get(&id).copied(), - _ => field_name_to_index_map - .get(&normalize_name(to_field.name())) - .copied(), + (true, Some(id)) => match from_id_to_indices.get(&id) { + None => None, + Some(indices) if indices.len() == 1 => Some(indices[0]), + Some(indices) => { + let matched = indices + .iter() + .map(|i| from_fields[*i].name().as_str()) + .collect::>() + .join(", "); + return Err(DataFusionError::External(Box::new( + SparkError::DuplicateFieldByFieldId { + required_id: id, + matched_fields: matched, + }, + ))); + } + }, + _ => from_index_by_name(to_field.name())?, }; if let Some(from_index) = from_index { @@ -475,10 +553,11 @@ type ObjectStoreCache = RwLock>>; /// /// ## Why static / process lifetime? /// -/// Comet's JNI architecture calls `initRecordBatchReader` once per Parquet file, and each -/// call constructs a fresh `RuntimeEnv`. There is therefore no executor-scoped Rust object -/// with a lifetime longer than a single file read that could own this cache. The executor -/// process itself is the natural scope for HTTP connection-pool reuse, so process lifetime +/// Comet's JNI architecture builds a fresh `SessionContext`/`RuntimeEnv` per native plan +/// (`Java_org_apache_comet_Native_createPlan`, once per Spark task). There is therefore no +/// executor-scoped Rust object with a lifetime longer than a single task's plan that could +/// own this cache. The executor process itself is the natural scope for HTTP +/// connection-pool reuse, so process lifetime /// (i.e. `static`) is the appropriate choice here. In the standard Spark-on-Kubernetes /// deployment model each executor process is dedicated to a single Spark application, so /// process lifetime and application lifetime are equivalent; the cache is reclaimed when @@ -508,7 +587,7 @@ fn object_store_cache() -> &'static ObjectStoreCache { } /// Compute a hash of the object store configuration for cache keying. -fn hash_object_store_configs(configs: &HashMap) -> u64 { +pub(crate) fn hash_object_store_configs(configs: &HashMap) -> u64 { let mut hasher = DefaultHasher::new(); let mut keys: Vec<&String> = configs.keys().collect(); keys.sort(); @@ -519,30 +598,61 @@ fn hash_object_store_configs(configs: &HashMap) -> u64 { hasher.finish() } +/// The `scheme://host:port` cache-key string [`prepare_object_store_with_configs`] resolves and +/// registers object stores under, plus the "is this an HDFS-scheme URL" classification the +/// `s3a` -> `s3` remap below depends on. Pure and I/O-free (no config hashing, no cache lock, no +/// store creation/registration): a caller that keeps its OWN local `ObjectStoreUrl`-keyed cache +/// (e.g. `delta_spark_scan.rs`'s `resolve_store`, which resolves a store per FILE but only needs +/// one per distinct authority) can compute this cheap key first and consult its local cache +/// before ever calling into the expensive resolution path below. +pub(crate) fn object_store_url_key( + url: &Url, + object_store_configs: &HashMap, +) -> (String, bool) { + let is_hdfs_scheme = is_hdfs_scheme(url, object_store_configs); + let scheme = if !is_hdfs_scheme && url.scheme() == "s3a" { + "s3" + } else { + url.scheme() + }; + let url_key = format!( + "{}://{}", + scheme, + &url[url::Position::BeforeHost..url::Position::AfterPort], + ); + (url_key, is_hdfs_scheme) +} + /// Parses the url, registers the object store with configurations, and returns a tuple of the object store url /// and object store path pub(crate) fn prepare_object_store_with_configs( runtime_env: Arc, url: String, object_store_configs: &HashMap, +) -> Result<(ObjectStoreUrl, Path), ExecutionError> { + let config_hash = hash_object_store_configs(object_store_configs); + prepare_object_store_with_config_hash(runtime_env, url, object_store_configs, config_hash) +} + +/// Same as [`prepare_object_store_with_configs`], but takes an already-computed +/// [`hash_object_store_configs`] result instead of hashing `object_store_configs` again. `configs` +/// is loop-invariant across every file resolved for one scan/writer, so a caller that already +/// hashed it once (e.g. once per partition, rather than once per file) should call this directly. +pub(crate) fn prepare_object_store_with_config_hash( + runtime_env: Arc, + url: String, + object_store_configs: &HashMap, + config_hash: u64, ) -> Result<(ObjectStoreUrl, Path), ExecutionError> { let mut url = Url::parse(url.as_str()) .map_err(|e| ExecutionError::GeneralError(format!("Error parsing URL {url}: {e}")))?; - let is_hdfs_scheme = is_hdfs_scheme(&url, object_store_configs); - let mut scheme = url.scheme(); - if !is_hdfs_scheme && scheme == "s3a" { - scheme = "s3"; + let (url_key, is_hdfs_scheme) = object_store_url_key(&url, object_store_configs); + if !is_hdfs_scheme && url.scheme() == "s3a" { url.set_scheme("s3").map_err(|_| { ExecutionError::GeneralError("Could not convert scheme from s3a to s3".to_string()) })?; } - let url_key = format!( - "{}://{}", - scheme, - &url[url::Position::BeforeHost..url::Position::AfterPort], - ); - let config_hash = hash_object_store_configs(object_store_configs); let cache_key = (url_key.clone(), config_hash); // Check the cache first to reuse existing object store instances. @@ -564,9 +674,9 @@ pub(crate) fn prepare_object_store_with_configs( debug!("Creating new object store for {url_key}"); let (store, path): (Box, Path) = if is_hdfs_scheme { create_hdfs_object_store(&url) - } else if scheme == "s3" { + } else if url.scheme() == "s3" { objectstore::s3::create_store(&url, object_store_configs, Duration::from_secs(300)) - } else if is_azure_scheme(scheme) { + } else if is_azure_scheme(url.scheme()) { objectstore::azure::create_store(&url, object_store_configs) } else { parse_url(&url) @@ -656,4 +766,179 @@ mod tests { } } } + + #[test] + fn struct_cast_does_not_match_capital_i_with_dot_above_to_ascii_i() { + use crate::parquet::parquet_support::{parquet_convert_array, SparkParquetOptions}; + use arrow::array::{Array, ArrayRef, Int32Array, StructArray}; + use arrow::datatypes::{DataType, Field, Fields}; + use datafusion_comet_spark_expr::EvalMode; + use std::sync::Arc; + + // Physical (file) struct field named plain ASCII "I"; logical field named 'İ' + // (U+0130, LATIN CAPITAL LETTER I WITH DOT ABOVE). Both Java's + // `toLowerCase(Locale.ROOT)` and Rust's `str::to_lowercase` fold these to DIFFERENT + // strings ("i" vs "i" + COMBINING DOT ABOVE), matching Spark's actual + // `toLowerCase`-keyed Parquet footer field matching -- so the logical field should NOT + // resolve to the physical column and must come back null rather than 42. + let from_field = Field::new("I", DataType::Int32, true); + let from_array: ArrayRef = Arc::new(Int32Array::from(vec![Some(42)])); + let from_struct = StructArray::new( + Fields::from(vec![from_field.clone()]), + vec![from_array], + None, + ); + + let to_field = Field::new("\u{130}", DataType::Int32, true); + let to_type = DataType::Struct(Fields::from(vec![to_field])); + + let mut parquet_options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + parquet_options.case_sensitive = false; + + let result = parquet_convert_array(Arc::new(from_struct), &to_type, &parquet_options) + .expect("struct cast should succeed"); + let result_struct = result.as_any().downcast_ref::().unwrap(); + let result_col = result_struct + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert!( + result_col.is_null(0), + "expected physical field \"I\" to NOT match logical field \"\\u{{130}}\" and come \ + back null, per Spark's toLowerCase-keyed field matching" + ); + } + + #[cfg(test)] + mod struct_field_matching { + use crate::parquet::parquet_support::{parquet_convert_array, SparkParquetOptions}; + use arrow::array::{Array, ArrayRef, Int32Array, StructArray}; + use arrow::datatypes::{DataType, Field, Fields}; + use datafusion_comet_spark_expr::EvalMode; + use parquet::arrow::PARQUET_FIELD_ID_META_KEY; + use std::collections::HashMap; + use std::sync::Arc; + + fn field_with_id(name: &str, id: i32) -> Field { + Field::new(name, DataType::Int32, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + id.to_string(), + )])) + } + + fn struct_of(fields: Vec, values: Vec) -> ArrayRef { + let arrays: Vec = values + .into_iter() + .map(|v| Arc::new(Int32Array::from(vec![Some(v)])) as ArrayRef) + .collect(); + Arc::new(StructArray::new(Fields::from(fields), arrays, None)) + } + + /// Two physical struct fields share field ID 1 and the logical struct requests that + /// ID: Spark's `matchIdField` raises `foundDuplicateFieldInFieldIdLookupModeError` + /// (`_LEGACY_ERROR_TEMP_2094`) rather than silently reading the first match. + #[test] + fn requested_duplicate_field_id_errors() { + let from = struct_of( + vec![field_with_id("x", 1), field_with_id("y", 1)], + vec![42, 43], + ); + let to_type = DataType::Struct(Fields::from(vec![field_with_id("f", 1)])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.use_field_id = true; + + let err = parquet_convert_array(from, &to_type, &opts).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("_LEGACY_ERROR_TEMP_2094") && msg.contains("id=1"), + "unexpected error: {msg}" + ); + } + + /// The physical struct holds `B` and `b` (a case-insensitive collision) plus `c`, + /// but only `c` is requested. Spark's `matchCaseInsensitiveField` errors solely when + /// the REQUESTED field's `toLowerCase(Locale.ROOT)` lookup is ambiguous, so this + /// read must succeed and return `c`'s value. + #[test] + fn unrequested_case_insensitive_duplicate_reads_fine() { + let from = struct_of( + vec![ + Field::new("B", DataType::Int32, true), + Field::new("b", DataType::Int32, true), + Field::new("c", DataType::Int32, true), + ], + vec![1, 2, 3], + ); + let to_type = + DataType::Struct(Fields::from(vec![Field::new("c", DataType::Int32, true)])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = false; + + let result = parquet_convert_array(from, &to_type, &opts).unwrap(); + let result_struct = result.as_any().downcast_ref::().unwrap(); + let col = result_struct + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(col.value(0), 3); + } + + /// Requesting `b` against physical `B`/`b` in case-insensitive mode is ambiguous: + /// Spark raises `foundDuplicateFieldInCaseInsensitiveModeError` + /// (`_LEGACY_ERROR_TEMP_2093`). + #[test] + fn requested_case_insensitive_duplicate_errors() { + let from = struct_of( + vec![ + Field::new("B", DataType::Int32, true), + Field::new("b", DataType::Int32, true), + ], + vec![1, 2], + ); + let to_type = + DataType::Struct(Fields::from(vec![Field::new("b", DataType::Int32, true)])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = false; + + let err = parquet_convert_array(from, &to_type, &opts).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("_LEGACY_ERROR_TEMP_2093") && msg.contains('b'), + "unexpected error: {msg}" + ); + } + + /// Two physical struct fields carry the IDENTICAL name in case-sensitive mode. + /// Spark's `caseSensitiveParquetFieldMap` is built with `.toMap`, where the later + /// entry wins silently; the exact-name map here must do the same rather than fail. + #[test] + fn duplicate_exact_names_resolve_to_the_last_field() { + let from = struct_of( + vec![ + Field::new("d", DataType::Int32, true), + Field::new("d", DataType::Int32, true), + ], + vec![1, 2], + ); + let to_type = + DataType::Struct(Fields::from(vec![Field::new("d", DataType::Int32, true)])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = true; + + let result = parquet_convert_array(from, &to_type, &opts).unwrap(); + let result_struct = result.as_any().downcast_ref::().unwrap(); + let col = result_struct + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(col.value(0), 2); + } + } } diff --git a/native/core/src/parquet/schema_adapter.rs b/native/core/src/parquet/schema_adapter.rs index c6586b4681e..0721a8475b1 100644 --- a/native/core/src/parquet/schema_adapter.rs +++ b/native/core/src/parquet/schema_adapter.rs @@ -33,6 +33,7 @@ use datafusion_physical_expr_adapter::{ PhysicalExprAdapterFactory, }; use parquet::arrow::PARQUET_FIELD_ID_META_KEY; +use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::fmt::{self, Display}; use std::hash::{Hash, Hasher}; @@ -76,6 +77,583 @@ fn schema_has_field_ids(schema: &SchemaRef) -> bool { schema.fields().iter().any(|f| parse_field_id(f).is_some()) } +// --------------------------------------------------------------------------------------------- +// JVM-shipped case tables: reproduce the PLANNING JVM's `String.toLowerCase(Locale.ROOT)`, +// which Spark's Parquet footer field matching is built on. +// +// The data arrives on `NativeScanCommon` (populated by `JvmCaseTables.scala` when +// case_sensitive = false), generated from the very JVM that plans the query, so the native +// matcher is correct BY CONSTRUCTION for whatever JDK runs Spark. The one contextual mapping +// (`Locale.ROOT` has exactly one: Greek capital sigma U+03A3) cannot be a per-codepoint table +// entry, so its condition is ported as an algorithm over a shipped per-codepoint +// classification -- see `JvmCaseTables::lowercase`. +// --------------------------------------------------------------------------------------------- + +// Sigma-scan classes: the wire contract shared with `JvmCaseTables.scala`. The classes are +// the UAX#29-style word-break classes (ALetter, Numeric, MidLetter, MidNum, MidNumLet, +// Extend, Format) as the PLANNING JVM's legacy break iterator actually realizes them -- +// probed per codepoint from `BreakIterator.isBoundary` on the JVM side -- plus classes for +// its pre-UAX#29 extensions (the danda and the supplementary-plane behaviors of its UTF-16 +// DFA), with cased variants split out via the JDK's `isCased`. Any codepoint outside every +// shipped range -- and any class value this build does not know -- is a word boundary: the +// sigma context scan stops there, which is also the safe reading for class values added by a +// NEWER JVM-side generator. +const CLASS_ALETTER_CASED: u8 = 1; +const CLASS_ALETTER: u8 = 2; +const CLASS_NUMERIC: u8 = 3; +const CLASS_MID_LETTER: u8 = 4; +const CLASS_MID_NUM: u8 = 5; +const CLASS_MID_NUM_LET: u8 = 6; +/// Cased supplementary char: attaches to the preceding word and closes it (and forms a word +/// of its own at raw text start). +const CLASS_SUPP_CASED: u8 = 7; +/// U+0964/U+0965: word-terminal, chains only into digits. +const CLASS_DANDA: u8 = 8; +/// U+0345, the one cased combining mark: cased only when its run is attached to a word. +const CLASS_EXTEND_CASED: u8 = 9; +/// Cased digit-base (Nl Roman numerals): joins like CLASS_ALETTER_CASED when reached +/// directly, but bridges only mid-num punctuation, never mid-letter. +const CLASS_NUMERIC_CASED: u8 = 10; +/// Non-cased Mn/Me marks: riders that attach only to genuine letter/digit bases. +const CLASS_EXTEND: u8 = 11; +/// Word-forming non-cased supplementary letter: a genuine letter-base that closes the word +/// immediately after itself. +const CLASS_SUPP_LETTER: u8 = 12; +/// Cf format characters: fully transparent (WB4-style) -- deleted from the sequence before +/// the scans run, so a pure-format rider chain bridges mid punctuation ("AΣ-b" is one +/// word exactly like "AΣ-b"). +const CLASS_FORMAT: u8 = 13; +/// Supplementary chars that attach to the preceding word but never form one themselves +/// (supplementary combining marks, tag characters): a cased mark riding on one belongs to +/// the sigma's word only when the run hangs off a real base (`supp_mn_anchor`). +const CLASS_SUPP_MN: u8 = 14; +/// Word-forming supplementary digit: like CLASS_SUPP_LETTER except a riding cased mark +/// carries only across mid-num (digit-context) punctuation, never mid-letter. +const CLASS_SUPP_NUM: u8 = 15; +/// Not on the wire: the absence of a class. +const CLASS_BOUNDARY: u8 = 0; + +const CAPITAL_SIGMA: char = '\u{03A3}'; +const SMALL_SIGMA: char = '\u{03C3}'; +const SMALL_FINAL_SIGMA: char = '\u{03C2}'; + +/// What a supplementary-mark run ultimately hangs off (see `supp_mn_anchor`). +#[derive(PartialEq, Eq, Clone, Copy)] +enum SuppMnAnchor { + None, + Letter, + Digit, +} + +/// The planning JVM's case data, parsed once per scan from `NativeScanCommon` and attached to +/// [`SparkParquetOptions`]. Two tables: +/// +/// - `lower`: every codepoint the JVM lowercases non-identically, with its full (possibly +/// multi-char, e.g. U+0130 -> "i" + U+0307) replacement; codepoints absent here lowercase +/// to themselves; +/// - `class_ranges`: sorted, disjoint `(start, end, class)` codepoint ranges holding the +/// word-break classification the JVM probed from its own `BreakIterator`. +/// +/// `lowercase` applies Java's algorithm over that data: per codepoint, U+03A3 takes its +/// contextual final/non-final form via the ported `isFinalCased` condition -- word-boundary +/// based (the JDK's legacy break-iterator word rules), NOT the Unicode-standard Final_Sigma +/// case-ignorable skip, so e.g. "A1Σ" lowers to "a1ς" -- and every other codepoint takes its +/// table replacement. `JvmCaseTables.mirrorLowercase` on the Scala side is the line-for-line +/// mirror of this function over the same generated data; the JVM-side parity suite proves the +/// pair equal to the running JDK's `String.toLowerCase(Locale.ROOT)` across the full codepoint +/// space (calibrated to zero mismatches on JDK 17, 21, and 25). +#[derive(Debug)] +pub struct JvmCaseTables { + /// Non-identity lowercase mappings: codepoint -> full replacement string. + lower: HashMap, + /// Sorted, disjoint (start, end, class) inclusive codepoint ranges for the sigma scan. + class_ranges: Vec<(u32, u32, u8)>, + /// Precomputed content hash so `SparkParquetOptions`'s derived `Hash` stays cheap. + fingerprint: u64, +} + +impl PartialEq for JvmCaseTables { + fn eq(&self, other: &Self) -> bool { + self.fingerprint == other.fingerprint + && self.class_ranges == other.class_ranges + && self.lower == other.lower + } +} + +impl Eq for JvmCaseTables {} + +impl Hash for JvmCaseTables { + fn hash(&self, state: &mut H) { + // Equal contents always produce the equal (deterministically computed) fingerprint, + // so hashing only the fingerprint is consistent with `PartialEq`. + state.write_u64(self.fingerprint); + } +} + +fn is_letter_base(cls: u8) -> bool { + cls == CLASS_ALETTER_CASED + || cls == CLASS_ALETTER + || cls == CLASS_SUPP_CASED + || cls == CLASS_SUPP_LETTER +} + +fn is_digit_base(cls: u8) -> bool { + cls == CLASS_NUMERIC || cls == CLASS_NUMERIC_CASED +} + +impl JvmCaseTables { + /// Parse the proto representation: `lower_cp`/`lower_repl` are index-aligned, and + /// `class_ranges` holds (start, end, class) triples. Malformed input (length mismatch, + /// trailing partial triple, out-of-range codepoints) is dropped entry-by-entry rather + /// than rejected: every dropped entry degrades one codepoint to identity/boundary + /// behavior instead of failing the scan. + pub fn from_proto(lower_cp: &[u32], lower_repl: &[String], class_ranges: &[u32]) -> Self { + let mut lower = HashMap::with_capacity(lower_cp.len()); + for (cp, repl) in lower_cp.iter().zip(lower_repl.iter()) { + if let Some(c) = char::from_u32(*cp) { + lower.insert(c, repl.clone()); + } + } + let ranges: Vec<(u32, u32, u8)> = class_ranges + .as_chunks::<3>() + .0 + .iter() + .filter(|t| t[0] <= t[1] && t[1] <= 0x10FFFF && u8::try_from(t[2]).is_ok()) + .map(|t| (t[0], t[1], t[2] as u8)) + .collect(); + + let mut hasher = DefaultHasher::new(); + for (start, end, class) in &ranges { + hasher.write_u32(*start); + hasher.write_u32(*end); + hasher.write_u8(*class); + } + let mut lower_sorted: Vec<(&char, &String)> = lower.iter().collect(); + lower_sorted.sort_by_key(|(c, _)| **c); + for (c, repl) in lower_sorted { + hasher.write_u32(*c as u32); + hasher.write(repl.as_bytes()); + } + + Self { + lower, + class_ranges: ranges, + fingerprint: hasher.finish(), + } + } + + /// Sigma-scan class of `c`; `CLASS_BOUNDARY` when no shipped range covers it. + fn class_of(&self, c: char) -> u8 { + let cp = c as u32; + let mut lo = 0usize; + let mut hi = self.class_ranges.len(); + while lo < hi { + let mid = (lo + hi) / 2; + let (start, end, class) = self.class_ranges[mid]; + if cp < start { + hi = mid; + } else if cp > end { + lo = mid + 1; + } else { + return class; + } + } + CLASS_BOUNDARY + } + + /// First position at or beyond `start` (stepping by `step`, i.e. -1 backward / +1 + /// forward) whose class is not CLASS_EXTEND. Returns `None` if the scan runs off the + /// array without finding one. + fn skip_extends(&self, cps: &[char], start: isize, step: isize) -> Option { + let mut k = start; + while k >= 0 && (k as usize) < cps.len() { + if self.class_of(cps[k as usize]) != CLASS_EXTEND { + return Some(k as usize); + } + k += step; + } + None + } + + /// As [`Self::skip_extends`], but also skips CLASS_EXTEND_CASED, reporting whether one + /// was walked: a cased mark (U+0345) crossed while looking for a base is itself cased + /// whenever the landing validates the run. + fn skip_extends_tracking_cased( + &self, + cps: &[char], + start: isize, + step: isize, + ) -> (Option, bool) { + let mut k = start; + let mut saw_cased = false; + while k >= 0 && (k as usize) < cps.len() { + let cls = self.class_of(cps[k as usize]); + if cls != CLASS_EXTEND && cls != CLASS_EXTEND_CASED { + return (Some(k as usize), saw_cased); + } + if cls == CLASS_EXTEND_CASED { + saw_cased = true; + } + k += step; + } + (None, saw_cased) + } + + /// What the supplementary-mark run at `k` (CLASS_SUPP_MN) ultimately hangs off, walking + /// down through further marks and supplementary chars: a letter-flavored base, a + /// digit-flavored base, or nothing word-forming. A cased mark riding the run belongs to + /// the sigma's word only per this anchor. + fn supp_mn_anchor(&self, cps: &[char], k: usize) -> SuppMnAnchor { + let mut m = k as isize - 1; + while m >= 0 { + let cls = self.class_of(cps[m as usize]); + if cls != CLASS_SUPP_MN && cls != CLASS_EXTEND && cls != CLASS_EXTEND_CASED { + break; + } + m -= 1; + } + if m < 0 { + return SuppMnAnchor::None; + } + match self.class_of(cps[m as usize]) { + CLASS_ALETTER_CASED | CLASS_ALETTER | CLASS_SUPP_CASED | CLASS_SUPP_LETTER => { + SuppMnAnchor::Letter + } + CLASS_NUMERIC | CLASS_NUMERIC_CASED | CLASS_SUPP_NUM => SuppMnAnchor::Digit, + _ => SuppMnAnchor::None, + } + } + + /// Backward half of the ported `isFinalCased`: is there a cased letter before position + /// `i` within the sigma's word? Runs over the FORMAT-FILTERED sequence; `leading_format` + /// says whether format chars were filtered off the raw text start. + fn scan_back_finds_cased(&self, cps: &[char], i: usize, leading_format: bool) -> bool { + let mut last_letter = true; // the sigma itself is a letter + let mut j = i as isize - 1; + while j >= 0 { + match self.class_of(cps[j as usize]) { + CLASS_ALETTER_CASED | CLASS_NUMERIC_CASED => return true, + CLASS_ALETTER => { + last_letter = true; + j -= 1; + } + CLASS_NUMERIC => { + last_letter = false; + j -= 1; + } + CLASS_EXTEND => { + // Non-cased marks attach only to a real base below them; anything else + // (mid punctuation, danda, boundary, text start) leaves the run + // unattached. + let Some(k) = self.skip_extends(cps, j, -1) else { + return false; + }; + let b = self.class_of(cps[k]); + let is_continuer = b == CLASS_ALETTER_CASED + || b == CLASS_NUMERIC_CASED + || b == CLASS_NUMERIC + || b == CLASS_EXTEND_CASED + || b == CLASS_ALETTER + || b == CLASS_SUPP_CASED + || b == CLASS_SUPP_LETTER + || b == CLASS_SUPP_NUM; + if !is_continuer { + return false; + } + j = k as isize; + } + CLASS_SUPP_CASED => { + // Closes the preceding word, so the scan stops -- except at RAW text + // start (no filtered-out leading format chars), where the DFA keeps it + // joined to what follows. + return j == 0 && !leading_format; + } + CLASS_SUPP_LETTER | CLASS_SUPP_MN | CLASS_SUPP_NUM => { + // Attach/close and never themselves cased; nothing beyond is reachable. + return false; + } + CLASS_EXTEND_CASED => { + // Cased combining mark (U+0345): cased when its run hangs off a base -- + // a BMP letter/digit, a word-forming supplementary char (which closes a + // word right below the mark, merging the mark into the sigma's + // segment), or an ANCHORED supplementary mark. + let (Some(k), _) = self.skip_extends_tracking_cased(cps, j - 1, -1) else { + return false; + }; + let b = self.class_of(cps[k]); + if b == CLASS_ALETTER_CASED + || b == CLASS_NUMERIC + || b == CLASS_NUMERIC_CASED + || b == CLASS_ALETTER + || b == CLASS_SUPP_CASED + || b == CLASS_SUPP_LETTER + || b == CLASS_SUPP_NUM + { + return true; + } + if b == CLASS_SUPP_MN { + return self.supp_mn_anchor(cps, k) != SuppMnAnchor::None; + } + return false; + } + CLASS_DANDA => { + // Backward across a danda: the word part before it must end in letters + // (grammar: letters, optional danda, then number+word chains) -- or + // carry a riding cased mark on a word-forming base, or be a cased + // supplementary char at text start -- and the danda itself chains only + // into digits after it. + if last_letter { + return false; + } + let (Some(k), saw_cased_mark) = + self.skip_extends_tracking_cased(cps, j - 1, -1) + else { + return false; + }; + let b = self.class_of(cps[k]); + if b == CLASS_ALETTER_CASED { + return true; + } + if b == CLASS_SUPP_CASED { + return saw_cased_mark || (k == 0 && !leading_format); + } + if b == CLASS_SUPP_LETTER { + return saw_cased_mark; + } + if b == CLASS_SUPP_MN { + return saw_cased_mark + && self.supp_mn_anchor(cps, k) == SuppMnAnchor::Letter; + } + if b != CLASS_ALETTER { + return false; + } + if saw_cased_mark { + return true; + } + last_letter = true; + j = k as isize; + } + cls @ (CLASS_MID_LETTER | CLASS_MID_NUM | CLASS_MID_NUM_LET) => { + // `` / `` require a genuine + // letter/digit base before the punctuation; scanning backward + // legitimately walks marks-then-base (marks trail their base). A cased + // mark walked over rides whatever the punctuation hangs off, including + // a context-matching anchored supplementary mark or supplementary + // digit. + let mw_ok = cls == CLASS_MID_LETTER || cls == CLASS_MID_NUM_LET; + let mn_ok = cls == CLASS_MID_NUM || cls == CLASS_MID_NUM_LET; + let (Some(real_pos), saw_cased_mark) = + self.skip_extends_tracking_cased(cps, j - 1, -1) + else { + return false; + }; + let b = self.class_of(cps[real_pos]); + if last_letter + && mw_ok + && saw_cased_mark + && b == CLASS_SUPP_MN + && self.supp_mn_anchor(cps, real_pos) == SuppMnAnchor::Letter + { + return true; + } + if !last_letter + && mn_ok + && saw_cased_mark + && (b == CLASS_SUPP_NUM + || (b == CLASS_SUPP_MN + && self.supp_mn_anchor(cps, real_pos) == SuppMnAnchor::Digit)) + { + return true; + } + let bridge_valid = (last_letter && mw_ok && is_letter_base(b)) + || (!last_letter && mn_ok && is_digit_base(b)); + if !bridge_valid { + return false; + } + if saw_cased_mark { + return true; + } + j = real_pos as isize; + } + _ => return false, + } + } + false + } + + /// Forward half of the ported `isFinalCased`: is there a cased letter after position `i` + /// within the sigma's word? Runs over the FORMAT-FILTERED sequence. + fn scan_fwd_finds_cased(&self, cps: &[char], i: usize) -> bool { + let mut last_letter = true; + let mut j = i + 1; + while j < cps.len() { + match self.class_of(cps[j]) { + CLASS_ALETTER_CASED | CLASS_NUMERIC_CASED => return true, + CLASS_ALETTER => { + last_letter = true; + j += 1; + } + CLASS_NUMERIC => { + last_letter = false; + j += 1; + } + CLASS_EXTEND => { + // A mark run trailing the anchor is properly attached in text order, so + // the run stays open past it, including onto mid punctuation on its far + // side. + let Some(k) = self.skip_extends(cps, j as isize, 1) else { + return false; + }; + let b = self.class_of(cps[k]); + let is_continuer = b == CLASS_ALETTER_CASED + || b == CLASS_NUMERIC_CASED + || b == CLASS_NUMERIC + || b == CLASS_EXTEND_CASED + || b == CLASS_ALETTER + || b == CLASS_SUPP_CASED + || b == CLASS_SUPP_LETTER + || b == CLASS_SUPP_MN + || b == CLASS_SUPP_NUM + || b == CLASS_DANDA + || b == CLASS_MID_LETTER + || b == CLASS_MID_NUM + || b == CLASS_MID_NUM_LET; + if !is_continuer { + return false; + } + j = k; + } + CLASS_SUPP_CASED | CLASS_EXTEND_CASED => { + // Attaches to the current word, so the scan sees it (cased). + return true; + } + CLASS_SUPP_LETTER | CLASS_SUPP_MN | CLASS_SUPP_NUM => { + // Attach to the current word and close it; never themselves cased, and + // nothing beyond is reachable. + return false; + } + // The danda attaches only to a word part that ends in letters (reached + // after digits the word is already closed) and continues only into a digit + // -- unless that digit is itself cased (a Roman numeral), which resolves + // the scan immediately. + CLASS_DANDA if !last_letter => return false, + CLASS_DANDA + if j + 1 < cps.len() && self.class_of(cps[j + 1]) == CLASS_NUMERIC_CASED => + { + return true; + } + CLASS_DANDA if j + 1 < cps.len() && self.class_of(cps[j + 1]) == CLASS_NUMERIC => { + last_letter = false; + j += 2; + } + cls @ (CLASS_MID_LETTER | CLASS_MID_NUM | CLASS_MID_NUM_LET) => { + // `` / `` require a genuine + // letter/digit base IMMEDIATELY after the punctuation -- unlike the + // backward scan, marks here are never skipped past: a mark directly + // after the punctuation is attached to the punctuation, not a base, so + // it blocks the bridge. (Format chars are already filtered out, which + // is what lets "AΣ-b" bridge exactly like "AΣ-b".) + let mw_ok = cls == CLASS_MID_LETTER || cls == CLASS_MID_NUM_LET; + let mn_ok = cls == CLASS_MID_NUM || cls == CLASS_MID_NUM_LET; + if j + 1 >= cps.len() { + return false; + } + let b = self.class_of(cps[j + 1]); + if (last_letter && mw_ok && is_letter_base(b)) + || (!last_letter && mn_ok && is_digit_base(b)) + { + j += 1; + } else { + return false; + } + } + _ => return false, + } + } + false + } + + /// Lowercase `s` the way the planning JVM's `String.toLowerCase(Locale.ROOT)` does. + pub fn lowercase(&self, s: &str) -> String { + let raw: Vec = s.chars().collect(); + // Built lazily on the first sigma: the format-filtered sequence the scans run over + // (WB4-style: the legacy break iterator's `` class loops on every DFA + // state), the raw->filtered index map, and whether format chars led the raw text. + let mut filtered: Option<(Vec, Vec, bool)> = None; + let mut out = String::with_capacity(s.len()); + for (i, &c) in raw.iter().enumerate() { + if c == CAPITAL_SIGMA { + // The condition consults the ORIGINAL neighbors, exactly as the JDK scans + // `src`, not the partially-lowered output. The final/non-final target chars + // are Unicode-stable (pinned in `ConditionalSpecialCasing`'s entry table). + let (f, idx, leading_format) = filtered.get_or_insert_with(|| { + let mut f = Vec::with_capacity(raw.len()); + let mut idx = vec![0usize; raw.len()]; + for (k, &rc) in raw.iter().enumerate() { + idx[k] = f.len(); + if self.class_of(rc) != CLASS_FORMAT { + f.push(rc); + } + } + let leading_format = self.class_of(raw[0]) == CLASS_FORMAT; + (f, idx, leading_format) + }); + let fi = idx[i]; + let is_final = self.scan_back_finds_cased(f, fi, *leading_format) + && !self.scan_fwd_finds_cased(f, fi); + out.push(if is_final { + SMALL_FINAL_SIGMA + } else { + SMALL_SIGMA + }); + } else if let Some(repl) = self.lower.get(&c) { + out.push_str(repl); + } else { + out.push(c); + } + } + out + } +} + +/// Lowercase `s` for case-insensitive field matching. With tables (populated whenever +/// `case_sensitive = false`, shared by the core Parquet scan and the Delta contrib scan) this +/// reproduces the planning JVM's `String.toLowerCase(Locale.ROOT)` exactly. +/// +/// Without tables, fall back to Rust's `str::to_lowercase`, which agrees with Java on all +/// simple mappings and differs only where the two Unicode snapshots or the sigma context +/// diverge. This is a real, live path, not just a defensive default: the Iceberg native scan +/// (`SparkPhysicalExprAdapterFactory::new(_, None)`) defaults `case_sensitive = false` and +/// always reaches this fallback for its schema name remap, alongside +/// `parquet_convert_struct_to_struct`'s general struct-cast matching and Rust-only unit tests +/// that construct `SparkParquetOptions` directly. +pub(crate) fn java_lowercase(s: &str, tables: Option<&JvmCaseTables>) -> String { + match tables { + Some(t) => t.lowercase(s), + None => { + log::debug!( + "case-insensitive name matching without JVM case tables; \ + falling back to str::to_lowercase for {s:?}" + ); + s.to_lowercase() + } + } +} + +/// Case-insensitive name equality mirroring Spark's actual Parquet footer field matching, +/// which groups/looks up physical column names by full-string `toLowerCase(Locale.ROOT)` +/// (see `ParquetReadSupport.clipParquetGroupFields`'s `caseInsensitiveParquetFieldMap`, and +/// `ParquetSchemaConverter.normalizeFieldName` for the vectorized `ParquetColumn` path -- both +/// key on `name.toLowerCase(Locale.ROOT)`, not a per-character `String.equalsIgnoreCase` +/// comparison). Two names are equal here iff their [`java_lowercase`] forms are equal. +pub(crate) fn names_equal_ignore_case_java( + a: &str, + b: &str, + tables: Option<&JvmCaseTables>, +) -> bool { + java_lowercase(a, tables) == java_lowercase(b, tables) +} + /// Remap physical schema field names to match logical schema field names. Mirrors Spark's /// `clipParquetGroupFields`: prefer ID match for any logical field that carries a /// `PARQUET:field_id`, fall back to case-insensitive name match otherwise. @@ -88,6 +666,7 @@ fn remap_physical_schema( logical_schema: &SchemaRef, physical_schema: &SchemaRef, case_sensitive: bool, + case_tables: Option<&JvmCaseTables>, use_field_id: bool, ignore_missing_field_id: bool, ) -> DataFusionResult<(SchemaRef, HashMap)> { @@ -143,29 +722,43 @@ fn remap_physical_schema( HashMap::new() }; - // Names of ID-bearing logical fields whose ID is not present in the file. Any physical - // field that shares one of these names must be renamed to something the - // `DefaultPhysicalExprAdapter` cannot name-match, otherwise the read would silently fall - // through to a name match. Spark's `matchIdField` solves the same problem with + // Names of ID-bearing logical fields. Spark's `matchIdField` resolves these strictly by + // ID and never falls back to a name match, so a physical field that carries such a name + // WITHOUT being the ID match (its ID is absent, different, or the logical ID matched a + // different physical field) must be renamed to something the `DefaultPhysicalExprAdapter` + // cannot name-match; otherwise the read would silently resolve the wrong column instead + // of null-filling. Spark's `matchIdField` solves the same problem with // `generateFakeColumnName` (see `ParquetReadSupport.scala`). - let unmatched_id_logical_names: std::collections::HashSet = if should_match_by_id { + let id_logical_names: std::collections::HashSet<&str> = if should_match_by_id { logical_schema .fields() .iter() - .filter_map(|lf| { - parse_field_id(lf).and_then(|id| { - if id_to_phys_names.contains_key(&id) { - None - } else { - Some(lf.name().clone()) - } - }) - }) + .filter(|lf| parse_field_id(lf).is_some()) + .map(|lf| lf.name().as_str()) .collect() } else { std::collections::HashSet::new() }; + + // Fake names must never collide with a real column from either schema: a physical column + // legitimately named like the fake pattern would otherwise become indistinguishable from + // the shield's output and could steal an exact-name match. Spark gets the same guarantee + // from the random UUID in `generateFakeColumnName`; here the counter is bumped past any + // reserved name instead so the result stays deterministic. + let reserved_names: std::collections::HashSet<&str> = logical_schema + .fields() + .iter() + .chain(physical_schema.fields().iter()) + .map(|f| f.name().as_str()) + .collect(); let mut fake_counter: usize = 0; + let mut next_fake_name = move || loop { + fake_counter += 1; + let candidate = format!("__comet_unmatched_field_id_{}", fake_counter); + if !reserved_names.contains(candidate.as_str()) { + return candidate; + } + }; let mut name_map: HashMap = HashMap::new(); let remapped_fields: Vec = physical_schema @@ -192,27 +785,20 @@ fn remap_physical_schema( } } - // Block accidental name match for ID-bearing logical fields whose ID is missing - // from the file. Mirrors Spark's `generateFakeColumnName` in `matchIdField`. - if should_match_by_id - && unmatched_id_logical_names - .iter() - .any(|name| name.eq_ignore_ascii_case(field.name())) - { - fake_counter += 1; - let fake_name = format!("__comet_unmatched_field_id_{}", fake_counter); - return Arc::new( - Field::new(fake_name, field.data_type().clone(), field.is_nullable()) - .with_metadata(field.metadata().clone()), - ); - } - - // Name match. Spark's `matchIdField` does not fall through to a name match for - // ID-bearing logical fields, so skip those when the schema is ID-bearing. + // Name match. Spark resolves every non-ID-bearing logical field by name -- + // `matchCaseSensitiveField` / `matchCaseInsensitiveField` in + // `clipParquetGroupFields` -- even when field-ID matching is on, and that + // resolution takes the physical field regardless of any ID-bearing logical field + // with a similar name (`matchIdField` only ever fakes the REQUESTED field's name, + // never the physical column's). Only ID-bearing logical fields skip the name + // fallback when the schema is ID-bearing. Case-sensitive mode needs no rename + // here (the downstream adapter's exact-name lookup already hits); the + // case-insensitive lookup rewrites the physical name, and a successful match + // claims the field before the shield below can hide it. if !case_sensitive { let logical_field = logical_schema.fields().iter().find(|lf| { let lf_has_id = should_match_by_id && parse_field_id(lf).is_some(); - !lf_has_id && lf.name().eq_ignore_ascii_case(field.name()) + !lf_has_id && names_equal_ignore_case_java(lf.name(), field.name(), case_tables) }); if let Some(logical_field) = logical_field { if logical_field.name() != field.name() { @@ -226,6 +812,35 @@ fn remap_physical_schema( .with_metadata(field.metadata().clone()), ); } + return Arc::clone(field); + } + } + + // Shield: any remaining physical field whose name would hit an ID-bearing + // logical field downstream gets a fake name (Spark's `generateFakeColumnName` + // equivalent). ID-bearing logical fields resolve strictly by ID, so a name hit + // on one would read the wrong column instead of null-filling it or leaving it to + // its real ID match. The collision test mirrors the matcher that would otherwise + // hit: exact names in case-sensitive mode (Spark's `matchCaseSensitiveField` / + // the row converter's exact `catalystFieldIdxByName`), the planning JVM's + // lowercase fold otherwise. + if should_match_by_id { + let collides = if case_sensitive { + id_logical_names.contains(field.name().as_str()) + } else { + id_logical_names + .iter() + .any(|name| names_equal_ignore_case_java(name, field.name(), case_tables)) + }; + if collides { + return Arc::new( + Field::new( + next_fake_name(), + field.data_type().clone(), + field.is_nullable(), + ) + .with_metadata(field.metadata().clone()), + ); } } @@ -334,11 +949,15 @@ fn reject_on_non_empty_expr( /// Check if a specific column name has duplicate matches in the physical schema /// (case-insensitive). Returns the error info if so. -fn check_column_duplicate(col_name: &str, physical_schema: &SchemaRef) -> Option<(String, String)> { +fn check_column_duplicate( + col_name: &str, + physical_schema: &SchemaRef, + case_tables: Option<&JvmCaseTables>, +) -> Option<(String, String)> { let matches: Vec<&str> = physical_schema .fields() .iter() - .filter(|pf| pf.name().eq_ignore_ascii_case(col_name)) + .filter(|pf| names_equal_ignore_case_java(pf.name(), col_name, case_tables)) .map(|pf| pf.name().as_str()) .collect(); if matches.len() > 1 { @@ -374,6 +993,7 @@ impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory { &logical_file_schema, &physical_file_schema, self.parquet_options.case_sensitive, + self.parquet_options.jvm_case_tables.as_deref(), self.parquet_options.use_field_id, self.parquet_options.ignore_missing_field_id, )?; @@ -450,12 +1070,31 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter { // a field with multiple case-insensitive matches in the physical schema. // Only the columns actually referenced trigger the error (not the whole schema). if let Some(orig_physical) = &self.original_physical_schema { + // ID-bearing logical fields resolve strictly by field ID (`matchIdField`) and + // never reach Spark's case-insensitive name lookup, so its duplicate-name error + // (`foundDuplicateFieldInCaseInsensitiveModeError`) never fires for them; exempt + // them here the same way. + let match_by_id = self.parquet_options.use_field_id + && schema_has_field_ids(&self.logical_file_schema); // Walk the expression tree to find Column references let mut duplicate_err: Option = None; let _ = Arc::::clone(&expr).transform(|e| { if let Some(col) = e.downcast_ref::() { - if let Some((req, matched)) = check_column_duplicate(col.name(), orig_physical) - { + let id_routed = match_by_id + && self + .logical_file_schema + .field_with_name(col.name()) + .ok() + .and_then(parse_field_id) + .is_some(); + if id_routed { + return Ok(Transformed::no(e)); + } + if let Some((req, matched)) = check_column_duplicate( + col.name(), + orig_physical, + self.parquet_options.jvm_case_tables.as_deref(), + ) { duplicate_err = Some(DataFusionError::External(Box::new( SparkError::DuplicateFieldCaseInsensitive { required_field_name: req, @@ -548,10 +1187,13 @@ impl SparkPhysicalExprAdapter { .iter() .find(|f| f.name() == col_name) } else { - self.logical_file_schema - .fields() - .iter() - .find(|f| f.name().eq_ignore_ascii_case(col_name)) + self.logical_file_schema.fields().iter().find(|f| { + names_equal_ignore_case_java( + f.name(), + col_name, + self.parquet_options.jvm_case_tables.as_deref(), + ) + }) }; let physical_field = if self.parquet_options.case_sensitive { self.physical_file_schema @@ -559,10 +1201,13 @@ impl SparkPhysicalExprAdapter { .iter() .find(|f| f.name() == col_name) } else { - self.physical_file_schema - .fields() - .iter() - .find(|f| f.name().eq_ignore_ascii_case(col_name)) + self.physical_file_schema.fields().iter().find(|f| { + names_equal_ignore_case_java( + f.name(), + col_name, + self.parquet_options.jvm_case_tables.as_deref(), + ) + }) }; // Remap the column index to the physical file schema so @@ -571,10 +1216,13 @@ impl SparkPhysicalExprAdapter { let physical_index = if self.parquet_options.case_sensitive { self.physical_file_schema.index_of(col_name).ok() } else { - self.physical_file_schema - .fields() - .iter() - .position(|f| f.name().eq_ignore_ascii_case(col_name)) + self.physical_file_schema.fields().iter().position(|f| { + names_equal_ignore_case_java( + f.name(), + col_name, + self.parquet_options.jvm_case_tables.as_deref(), + ) + }) }; if let (Some(logical_field), Some(physical_field), Some(phys_idx)) = @@ -951,11 +1599,13 @@ impl SparkPhysicalExprAdapter { let is_missing = if self.parquet_options.case_sensitive { self.physical_file_schema.field_with_name(col_name).is_err() } else { - !self - .physical_file_schema - .fields() - .iter() - .any(|f| f.name().eq_ignore_ascii_case(col_name)) + !self.physical_file_schema.fields().iter().any(|f| { + names_equal_ignore_case_java( + f.name(), + col_name, + self.parquet_options.jvm_case_tables.as_deref(), + ) + }) }; if !is_missing { @@ -1102,10 +1752,16 @@ impl PhysicalExpr for RejectOnNonEmpty { #[cfg(test)] mod test { use crate::parquet::parquet_support::SparkParquetOptions; - use crate::parquet::schema_adapter::SparkPhysicalExprAdapterFactory; + use crate::parquet::schema_adapter::{ + java_lowercase, names_equal_ignore_case_java, remap_physical_schema, JvmCaseTables, + SparkPhysicalExprAdapterFactory, CLASS_ALETTER_CASED, CLASS_DANDA, CLASS_EXTEND, + CLASS_EXTEND_CASED, CLASS_FORMAT, CLASS_MID_LETTER, CLASS_MID_NUM, CLASS_MID_NUM_LET, + CLASS_NUMERIC, CLASS_NUMERIC_CASED, CLASS_SUPP_CASED, CLASS_SUPP_LETTER, CLASS_SUPP_MN, + CLASS_SUPP_NUM, + }; use arrow::array::UInt32Array; use arrow::array::{ - BinaryArray, Date32Array, Decimal128Array, Float32Array, Float64Array, Int32Array, + Array, BinaryArray, Date32Array, Decimal128Array, Float32Array, Float64Array, Int32Array, Int64Array, StringArray, TimestampMicrosecondArray, }; use arrow::datatypes::SchemaRef; @@ -1122,7 +1778,8 @@ mod test { use datafusion_comet_spark_expr::EvalMode; use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory; use futures::StreamExt; - use parquet::arrow::ArrowWriter; + use parquet::arrow::{ArrowWriter, PARQUET_FIELD_ID_META_KEY}; + use std::collections::HashMap; use std::fs::File; use std::sync::Arc; @@ -1775,4 +2432,868 @@ mod test { "Expected duplicate field error, got: {err_msg}" ); } + + /// Build a nullable Int64 field carrying a Parquet field ID. + fn field_with_id(name: &str, id: i32) -> Field { + Field::new(name, DataType::Int64, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + id.to_string(), + )])) + } + + /// Write a Parquet file from `file_schema`/`columns`, then scan it with + /// `required_schema` through the Spark expression adapter and return the first batch. + async fn scan_with_adapter( + file_schema: SchemaRef, + columns: Vec>, + required_schema: SchemaRef, + spark_parquet_options: SparkParquetOptions, + ) -> Result { + let batch = RecordBatch::try_new(Arc::clone(&file_schema), columns).unwrap(); + + let filename = get_temp_filename(); + let filename = filename.as_path().as_os_str().to_str().unwrap().to_string(); + let file = File::create(&filename).unwrap(); + let mut writer = ArrowWriter::try_new(file, file_schema, None).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + + let expr_adapter_factory: Arc = Arc::new( + SparkPhysicalExprAdapterFactory::new(spark_parquet_options, None), + ); + + let object_store_url = ObjectStoreUrl::local_filesystem(); + let parquet_source = ParquetSource::new(required_schema); + let files = FileGroup::new(vec![PartitionedFile::from_path(filename).unwrap()]); + let file_scan_config = + FileScanConfigBuilder::new(object_store_url, Arc::new(parquet_source)) + .with_file_groups(vec![files]) + .with_expr_adapter(Some(expr_adapter_factory)) + .build(); + + let parquet_exec = DataSourceExec::new(Arc::new(file_scan_config)); + let mut stream = parquet_exec + .execute(0, Arc::new(TaskContext::default())) + .unwrap(); + stream.next().await.unwrap() + } + + /// File: one column `κ` (U+03BA) with field ID 2 holding 7. Required: `Κ` (U+039A, + /// field ID 1) and ID-less `κ`; case-sensitive, field-ID reading on. Spark routes `Κ` + /// through `matchIdField` (no ID 1 in the file -> null-filled behind a faked REQUESTED + /// name) and resolves `κ` by exact name through `matchCaseSensitiveField`, reading the + /// real column: the result is (NULL, 7), never (NULL, NULL). + #[tokio::test] + async fn parquet_field_id_miss_null_fills_but_exact_name_sibling_still_reads() { + let file_schema = Arc::new(Schema::new(vec![field_with_id("\u{3BA}", 2)])); + let col = Arc::new(Int64Array::from(vec![7])) as Arc; + let required_schema = Arc::new(Schema::new(vec![ + field_with_id("\u{39A}", 1), + Field::new("\u{3BA}", DataType::Int64, true), + ])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = true; + opts.use_field_id = true; + + let batch = scan_with_adapter(file_schema, vec![col], required_schema, opts) + .await + .unwrap(); + assert_eq!(batch.num_rows(), 1); + let capital = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(capital.is_null(0)); + let small = batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(!small.is_null(0)); + assert_eq!(small.value(0), 7); + } + + /// Case-insensitive variant of the Kappa scenario. Spark's `matchCaseInsensitiveField` + /// resolves the ID-less requested `κ` through the `toLowerCase(Locale.ROOT)`-keyed map + /// of the file's fields, which holds the physical `κ`; the unmatched-ID requested `Κ` + /// is null-filled and never blocks that lookup. Same (NULL, 7) result as the + /// case-sensitive read. + #[tokio::test] + async fn parquet_field_id_miss_case_insensitive_sibling_still_reads() { + let file_schema = Arc::new(Schema::new(vec![field_with_id("\u{3BA}", 2)])); + let col = Arc::new(Int64Array::from(vec![7])) as Arc; + let required_schema = Arc::new(Schema::new(vec![ + field_with_id("\u{39A}", 1), + Field::new("\u{3BA}", DataType::Int64, true), + ])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = false; + opts.use_field_id = true; + + let batch = scan_with_adapter(file_schema, vec![col], required_schema, opts) + .await + .unwrap(); + assert_eq!(batch.num_rows(), 1); + let capital = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(capital.is_null(0)); + let small = batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(!small.is_null(0)); + assert_eq!(small.value(0), 7); + } + + /// File: a stray ID-less column literally named `A` = [10, 20] FIRST, then `a` with + /// field ID 1 = [1, 2]. Required: `A` with field ID 1, case-insensitive, field-ID + /// reading on. Spark's `matchIdField` resolves requested `A` to physical `a` by ID; the + /// stray `A` is never requested, and no case-insensitive duplicate error fires because + /// ID-routed requested fields never enter the name lookup. Expect [1, 2] -- neither the + /// stray column's data nor a spurious duplicate-field error. + #[tokio::test] + async fn parquet_field_id_match_beats_stray_column_with_requested_name() { + let file_schema = Arc::new(Schema::new(vec![ + Field::new("A", DataType::Int64, true), + field_with_id("a", 1), + ])); + let stray = Arc::new(Int64Array::from(vec![10, 20])) as Arc; + let matched = Arc::new(Int64Array::from(vec![1, 2])) as Arc; + let required_schema = Arc::new(Schema::new(vec![field_with_id("A", 1)])); + + let mut opts = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + opts.case_sensitive = false; + opts.use_field_id = true; + + let batch = scan_with_adapter(file_schema, vec![stray, matched], required_schema, opts) + .await + .unwrap(); + assert_eq!(batch.num_rows(), 2); + let values = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(values.value(0), 1); + assert_eq!(values.value(1), 2); + } + + // -- JvmCaseTables mechanics: the native algorithm over a miniature, JDK-17-sourced + // table. These pin the NATIVE half; the runtime consumes tables generated by the live + // planning JVM (JvmCaseTables.scala), proven equal to `String.toLowerCase(Locale.ROOT)` + // by the JVM-side parity suite (JvmLowercaseParitySuite). -- + + /// Builds a miniature `JvmCaseTables` from literal data recorded from a real JDK 17 + /// (zulu 17.0.18) `String.toLowerCase(Locale.ROOT)` run, covering exactly the codepoints + /// these tests touch. Expected strings in the tests below are JDK-17-sourced the same way. + fn jdk17_test_tables() -> JvmCaseTables { + let mut lower_cp: Vec = Vec::new(); + let mut lower_repl: Vec = Vec::new(); + for cp in 0x41u32..=0x5A { + lower_cp.push(cp); + lower_repl.push(char::from_u32(cp + 0x20).unwrap().to_string()); + } + let mut pair = |cp: u32, repl: &str| { + lower_cp.push(cp); + lower_repl.push(repl.to_string()); + }; + pair(0xC9, "\u{E9}"); // É -> é + pair(0x130, "i\u{307}"); // İ -> "i" + COMBINING DOT ABOVE (multi-char expansion) + pair(0x391, "\u{3B1}"); // Α -> α + pair(0x392, "\u{3B2}"); // Β -> β + pair(0x394, "\u{3B4}"); // Δ -> δ + pair(0x395, "\u{3B5}"); // Ε -> ε + pair(0x39F, "\u{3BF}"); // Ο -> ο + pair(0x3A3, "\u{3C3}"); // Σ -> σ (the isolated form; contexts handled by the scan) + pair(0x3A5, "\u{3C5}"); // Υ -> υ + pair(0x212A, "k"); // KELVIN SIGN -> ASCII k + pair(0x10400, "\u{10428}"); // DESERET CAPITAL LONG I -> small + pair(0x2160, "\u{2170}"); // ROMAN NUMERAL ONE -> small roman numeral one + + #[rustfmt::skip] + let class_ranges: Vec = vec![ + 0x22, 0x22, CLASS_MID_NUM_LET as u32, // '"' + 0x27, 0x27, CLASS_MID_NUM_LET as u32, // '\'' + 0x2C, 0x2C, CLASS_MID_NUM as u32, // ',' + 0x2D, 0x2D, CLASS_MID_LETTER as u32, // '-' + 0x2E, 0x2E, CLASS_MID_NUM_LET as u32, // '.' + 0x30, 0x39, CLASS_NUMERIC as u32, // 0-9 + 0x41, 0x5A, CLASS_ALETTER_CASED as u32, // A-Z + 0x5F, 0x5F, CLASS_MID_LETTER as u32, // '_' + 0x61, 0x7A, CLASS_ALETTER_CASED as u32, // a-z + 0xC9, 0xC9, CLASS_ALETTER_CASED as u32, // É + 0xDF, 0xDF, CLASS_ALETTER_CASED as u32, // ß + 0xE9, 0xE9, CLASS_ALETTER_CASED as u32, // é + 0x130, 0x131, CLASS_ALETTER_CASED as u32, // İ, ı + 0x301, 0x301, CLASS_EXTEND as u32, // COMBINING ACUTE (Mn, non-cased) + 0x307, 0x307, CLASS_EXTEND as u32, // COMBINING DOT ABOVE (Mn, non-cased) + 0x345, 0x345, CLASS_EXTEND_CASED as u32, // COMBINING GREEK YPOGEGRAMMENI + 0x391, 0x3A9, CLASS_ALETTER_CASED as u32, // Greek capitals + 0x3B1, 0x3C9, CLASS_ALETTER_CASED as u32, // Greek smalls (incl. σ, ς) + 0x64E, 0x64E, CLASS_EXTEND as u32, // ARABIC FATHA (Mn, non-cased) + 0x964, 0x964, CLASS_DANDA as u32, // DEVANAGARI DANDA + 0x200D, 0x200D, CLASS_FORMAT as u32, // ZERO WIDTH JOINER (Cf) + 0x212A, 0x212A, CLASS_ALETTER_CASED as u32, // KELVIN SIGN + 0x2160, 0x2170, CLASS_NUMERIC_CASED as u32, // Roman numeral one, upper and lower + 0x10400, 0x10400, CLASS_SUPP_CASED as u32, // DESERET CAPITAL LONG I + 0x11374, 0x11374, CLASS_SUPP_MN as u32, // COMBINING GRANTHA LETTER A (Mn, supp) + 0x1D7D3, 0x1D7D3, CLASS_SUPP_NUM as u32, // MATHEMATICAL BOLD DIGIT FIVE (Nd, supp) + 0x20000, 0x20000, CLASS_SUPP_LETTER as u32, // CJK UNIFIED IDEOGRAPH-20000 (Lo) + ]; + JvmCaseTables::from_proto(&lower_cp, &lower_repl, &class_ranges) + } + + #[test] + fn kelvin_sign_matches_ascii_k_and_capital_k() { + // U+212A KELVIN SIGN lowercases (Locale.ROOT) to ASCII 'k'. Rust's + // `eq_ignore_ascii_case` never looks past the ASCII range, so it would (wrongly) say + // these differ. + let t = jdk17_test_tables(); + assert!(names_equal_ignore_case_java("\u{212A}", "k", Some(&t))); + assert!(names_equal_ignore_case_java("\u{212A}", "K", Some(&t))); + assert!(names_equal_ignore_case_java("k", "\u{212A}", Some(&t))); + } + + #[test] + fn e_acute_case_pair_matches() { + // 'É' (U+00C9) / 'é' (U+00E9): a Latin-1 case pair outside the ASCII range. + let t = jdk17_test_tables(); + assert!(names_equal_ignore_case_java("\u{c9}", "\u{e9}", Some(&t))); + assert!(names_equal_ignore_case_java( + "R\u{c9}SUM\u{c9}", + "r\u{e9}sum\u{e9}", + Some(&t) + )); + } + + #[test] + fn greek_capital_sigma_matches_regular_lowercase_sigma() { + // A standalone capital sigma has no preceding cased letter, so the contextual "final + // sigma" rule does not apply and the unconditional mapping to σ (U+03C3) is used. + let t = jdk17_test_tables(); + assert!(names_equal_ignore_case_java("\u{3a3}", "\u{3c3}", Some(&t))); + } + + #[test] + fn greek_final_sigma_does_not_match_regular_lowercase_sigma() { + // 'ς' (U+03C2, final lowercase sigma) is already lowercase and maps to itself; it does + // NOT unify with 'σ' (U+03C3, regular lowercase sigma). + let t = jdk17_test_tables(); + assert!(!names_equal_ignore_case_java( + "\u{3c2}", + "\u{3c3}", + Some(&t) + )); + } + + #[test] + fn sharp_s_does_not_match_ss() { + // 'ß' (U+00DF) is already lowercase and maps to itself under `toLowerCase`, so it + // never unifies with "ss" -- unlike `str::to_uppercase`'s full folding to "SS". + let t = jdk17_test_tables(); + assert!(!names_equal_ignore_case_java("\u{df}", "ss", Some(&t))); + assert!(!names_equal_ignore_case_java("\u{df}", "SS", Some(&t))); + } + + #[test] + fn capital_i_with_dot_above_does_not_match_ascii_i() { + // 'İ' (U+0130) lowercases (Locale.ROOT) to the TWO-char string "i" + COMBINING DOT + // ABOVE, so it does NOT match ASCII 'I'/'i' under toLowerCase-keyed matching. + let t = jdk17_test_tables(); + assert!(!names_equal_ignore_case_java("\u{130}", "I", Some(&t))); + assert!(!names_equal_ignore_case_java("\u{130}", "i", Some(&t))); + } + + #[test] + fn dotless_i_does_not_match_ascii_i_or_capital_i() { + // 'ı' (U+0131) is already lowercase and maps to itself, so it does NOT unify with + // ASCII 'I' (which lowercases to 'i') or 'i' -- unlike Java's `Character`-level + // `equalsIgnoreCase`, which is not what Spark's footer matching uses. + let t = jdk17_test_tables(); + assert!(!names_equal_ignore_case_java("\u{131}", "I", Some(&t))); + assert!(!names_equal_ignore_case_java("\u{131}", "i", Some(&t))); + } + + #[test] + fn ascii_pairs_still_match() { + let t = jdk17_test_tables(); + assert!(names_equal_ignore_case_java("Foo", "foo", Some(&t))); + assert!(names_equal_ignore_case_java("BAR", "bar", Some(&t))); + assert!(!names_equal_ignore_case_java("foo", "bar", Some(&t))); + } + + #[test] + fn differing_lengths_never_match() { + let t = jdk17_test_tables(); + assert!(!names_equal_ignore_case_java("ab", "a", Some(&t))); + assert!(!names_equal_ignore_case_java("", "a", Some(&t))); + } + + #[test] + fn digit_keeps_final_sigma_context_open() { + // JDK-17-sourced: "A1Σ".toLowerCase(Locale.ROOT) == "a1ς" (FINAL sigma). The JDK's + // Final_Cased condition runs on word boundaries, and a digit keeps "A1Σ" one word, so + // the trailing sigma takes the final form -- unlike the Unicode-standard Final_Sigma + // (and unlike `str::to_lowercase`), where the digit is not case-ignorable and blocks + // the context, giving "a1σ". + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A1\u{3A3}"), "a1\u{3C2}"); + assert_ne!("A1\u{3A3}".to_lowercase(), "a1\u{3C2}"); + assert!(names_equal_ignore_case_java( + "A1\u{3A3}", + "a1\u{3C2}", + Some(&t) + )); + assert!(!names_equal_ignore_case_java( + "A1\u{3A3}", + "a1\u{3C3}", + Some(&t) + )); + } + + #[test] + fn word_boundaries_and_following_cased_letters_block_final_sigma() { + let t = jdk17_test_tables(); + // JDK-17-sourced expected values: + assert_eq!(t.lowercase("A \u{3A3}"), "a \u{3C3}"); // space breaks the word + assert_eq!(t.lowercase("A\u{3A3}B"), "a\u{3C3}b"); // cased letter follows + assert_eq!(t.lowercase("\u{3A3}"), "\u{3C3}"); // isolated + assert_eq!(t.lowercase("\u{3A3}A"), "\u{3C3}a"); // nothing cased before + } + + #[test] + fn greek_word_with_trailing_capital_sigma_lowercases_with_final_sigma() { + // JDK-17-sourced: a transliteration of "Odysseus" in all-capital Greek, chosen for two + // medial sigmas plus a word-final one: both medial Σ fold to plain σ and only the + // word-final Σ folds to ς. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("\u{39F}\u{394}\u{3A5}\u{3A3}\u{3A3}\u{395}\u{3A5}\u{3A3}"), + "\u{3BF}\u{3B4}\u{3C5}\u{3C3}\u{3C3}\u{3B5}\u{3C5}\u{3C2}" + ); + assert!(names_equal_ignore_case_java( + "\u{39F}\u{394}\u{3A5}\u{3A3}\u{3A3}\u{395}\u{3A5}\u{3A3}", + "\u{3BF}\u{3B4}\u{3C5}\u{3C3}\u{3C3}\u{3B5}\u{3C5}\u{3C2}", + Some(&t) + )); + } + + #[test] + fn greek_word_with_medial_sigma_lowercases_with_regular_sigma() { + // JDK-17-sourced: cased letter, sigma, cased letter -- the sigma is medial, so it folds + // to plain σ, and a name spelled with ς instead must NOT match. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("\u{391}\u{3A3}\u{392}"), + "\u{3B1}\u{3C3}\u{3B2}" + ); + assert!(!names_equal_ignore_case_java( + "\u{391}\u{3A3}\u{392}", + "\u{3B1}\u{3C2}\u{3B2}", + Some(&t) + )); + } + + #[test] + fn mid_punctuation_joins_words_per_the_jdk_rules() { + // JDK-17-sourced expected values. Mid-word punctuation (underscore, dash, period, + // apostrophe) joins letter..letter and keeps the sigma context open; it does NOT join + // against digits, two in a row break, and mid-num-only punctuation (comma) never joins + // letters. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A_\u{3A3}"), "a_\u{3C2}"); + assert_eq!(t.lowercase("A-\u{3A3}"), "a-\u{3C2}"); + assert_eq!(t.lowercase("A.\u{3A3}"), "a.\u{3C2}"); + assert_eq!(t.lowercase("A,\u{3A3}"), "a,\u{3C3}"); + assert_eq!(t.lowercase("A..\u{3A3}"), "a..\u{3C3}"); + assert_eq!(t.lowercase("A1.\u{3A3}"), "a1.\u{3C3}"); + assert_eq!(t.lowercase("A-1\u{3A3}"), "a-1\u{3C3}"); + assert_eq!(t.lowercase("A\u{3A3}_b"), "a\u{3C3}_b"); // joins to a cased letter after + } + + #[test] + fn cased_digit_is_cased_but_joins_words_like_a_digit_not_a_letter() { + // U+2160 ROMAN NUMERAL ONE: cased (the JDK's hardcoded Other_Uppercase list) but + // digit-typed (Nl), so it satisfies the scan's "found a cased letter" check when + // reached directly, yet -- unlike an ordinary cased letter -- does NOT let mid-word + // punctuation (only letter..letter) bridge past it; only mid-num punctuation + // (digit..digit) does. This is the exact class-sequence gap the multi-special pair + // sweep in `JvmLowercaseParitySuite` found: folding cased digits into the plain + // cased-letter class let mid-word marks wrongly bridge past them. + let t = jdk17_test_tables(); + // Directly adjacent to the sigma: cased, so the scan finds it either direction. + assert_eq!(t.lowercase("\u{2160}\u{3A3}"), "\u{2170}\u{3C2}"); + assert_eq!(t.lowercase("A\u{3A3}\u{2160}"), "a\u{3C3}\u{2170}"); + // A mid-word mark ('-') does NOT bridge into a cased digit: non-final. (The cased + // digit itself is not directly adjacent to sigma in either case, so the scan must + // cross the dash to reach it -- and fails to, since mid-word only bridges + // letter..letter.) + assert_eq!(t.lowercase("\u{2160}-\u{3A3}"), "\u{2170}-\u{3C3}"); + assert_eq!(t.lowercase("A-\u{2160}-\u{3A3}"), "a-\u{2170}-\u{3C3}"); + // A mid-num mark (',') DOES bridge digit..digit into a cased digit: the '1' adjacent + // to sigma establishes the digit-run state, then ',' bridges back to the cased digit. + assert_eq!(t.lowercase("\u{2160},1\u{3A3}"), "\u{2170},1\u{3C2}"); + } + + #[test] + fn combining_marks_are_transparent_to_the_sigma_scan() { + // JDK-17-sourced: Mn marks ride along with their base ("Á" decomposed, an Arabic + // fatha), and the İ expansion's own combining mark does not break adjacency. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A\u{301}\u{3A3}"), "a\u{301}\u{3C2}"); + assert_eq!(t.lowercase("A\u{64E}\u{3A3}"), "a\u{64E}\u{3C2}"); + assert_eq!(t.lowercase("\u{130}\u{3A3}"), "i\u{307}\u{3C2}"); + } + + #[test] + fn cased_combining_mark_counts_only_when_attached_to_a_word() { + // JDK-17-sourced: U+0345 COMBINING GREEK YPOGEGRAMMENI is the one CASED combining + // mark. Attached to a letter or digit it satisfies the "preceded by cased" condition; + // base-less at string start it does not. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A\u{345}\u{3A3}"), "a\u{345}\u{3C2}"); + assert_eq!(t.lowercase("1\u{345}\u{3A3}"), "1\u{345}\u{3C2}"); + assert_eq!(t.lowercase("\u{345}\u{3A3}"), "\u{345}\u{3C3}"); + } + + #[test] + fn supplementary_cased_letter_closes_the_preceding_word() { + // JDK-17-sourced: the legacy break iterator attaches a supplementary character to the + // preceding word and closes it, so U+10400 blocks the backward scan (mid-string) but + // satisfies it at string start, and always satisfies the forward scan. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A\u{10400}\u{3A3}"), "a\u{10428}\u{3C3}"); + assert_eq!(t.lowercase("\u{10400}\u{3A3}"), "\u{10428}\u{3C2}"); + assert_eq!(t.lowercase("A\u{3A3}\u{10400}"), "a\u{3C3}\u{10428}"); + } + + #[test] + fn danda_chains_only_into_numbers() { + // JDK-17-sourced: the danda terminates a word; the segment continues past it only + // into digits. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A\u{964}1\u{3A3}"), "a\u{964}1\u{3C2}"); + assert_eq!(t.lowercase("A\u{964}\u{3A3}"), "a\u{964}\u{3C3}"); + } + + #[test] + fn cf_format_chars_are_fully_transparent_but_mn_marks_are_not() { + // Real-JDK-verified: `=[:Cf:]` loops on every state of the legacy DFA, so + // format characters are deleted from the sequence before segmentation -- a ZWJ + // anywhere in a mid-punctuation bridge leaves the bridge intact ("A-Σ" and + // "AΣ-b" behave exactly like "A-Σ" / "AΣ-b") -- while an Mn mark in the same + // position blocks it (the mark is orphaned: its text-order predecessor is the + // punctuation, not a letter-base). + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A-\u{200D}\u{3A3}"), "a-\u{200D}\u{3C2}"); + assert_eq!(t.lowercase("A-\u{301}\u{3A3}"), "a-\u{301}\u{3C3}"); + // The forward mid-letter bridge crosses a ZWJ to the cased letter beyond, so the + // sigma is NOT final -- the exact residual shape the format filter fixes. + assert_eq!(t.lowercase("A\u{3A3}-\u{200D}b"), "a\u{3C3}-\u{200D}b"); + assert_eq!( + t.lowercase("A\u{3A3}-\u{200D}\u{200D}b"), + "a\u{3C3}-\u{200D}\u{200D}b" + ); + // An Mn mark anywhere in the same rider chain blocks that bridge. + assert_eq!( + t.lowercase("A\u{3A3}-\u{200D}\u{301}b"), + "a\u{3C2}-\u{200D}\u{301}b" + ); + // Riders trailing the sigma itself bridge onward regardless of Cf vs Mn. + assert_eq!(t.lowercase("A\u{3A3}\u{200D}-B"), "a\u{3C3}\u{200D}-b"); + assert_eq!(t.lowercase("A\u{3A3}\u{301}-B"), "a\u{3C3}\u{301}-b"); + } + + #[test] + fn leading_format_chars_defeat_the_supplementary_text_start_join() { + // Real-JDK-verified: a cased supplementary char forms a word at RAW text start + // ("𐐀Σ" is final), but a leading format char occupies the DFA's initial state, so + // the same shape behind a ZWJ is non-final. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("\u{200D}\u{10400}\u{3A3}"), + "\u{200D}\u{10428}\u{3C3}" + ); + } + + #[test] + fn supplementary_mark_anchors_a_riding_cased_mark_only_off_a_real_base() { + // Real-JDK-verified: a supplementary combining mark (U+11374) attaches to the + // preceding word but never forms one. A U+0345 riding on it counts as cased exactly + // when the run hangs off a real base -- and for a mid-letter bridge, only a + // letter-flavored one. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("A\u{11374}\u{345}\u{3A3}"), + "a\u{11374}\u{345}\u{3C2}" + ); + assert_eq!( + t.lowercase("\u{11374}\u{345}\u{3A3}"), + "\u{11374}\u{345}\u{3C3}" + ); + assert_eq!( + t.lowercase("A\u{11374}\u{345}-\u{3A3}"), + "a\u{11374}\u{345}-\u{3C2}" + ); + assert_eq!( + t.lowercase("\u{2160}\u{11374}\u{345}-\u{3A3}"), + "\u{2170}\u{11374}\u{345}-\u{3C3}" + ); + } + + #[test] + fn supplementary_digit_carries_a_riding_cased_mark_only_in_digit_context() { + // Real-JDK-verified: a word-forming supplementary digit (U+1D7D3) backs a riding + // U+0345 against a bare sigma and across mid-num punctuation in digit context, but + // never across mid-letter punctuation. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("A\u{1D7D3}\u{345}\u{3A3}"), + "a\u{1D7D3}\u{345}\u{3C2}" + ); + assert_eq!( + t.lowercase("A\u{1D7D3}\u{345},1\u{3A3}"), + "a\u{1D7D3}\u{345},1\u{3C2}" + ); + assert_eq!( + t.lowercase("A\u{1D7D3}\u{345}-\u{3A3}"), + "a\u{1D7D3}\u{345}-\u{3C3}" + ); + } + + #[test] + fn assigned_noncased_supplementary_letter_backs_a_following_cased_mark() { + // Real-JDK-verified: an assigned non-cased supplementary letter (Lo, e.g. CJK + // Extension B) is still a genuine letter-base -- unlike a plain word boundary, it can + // back a following CLASS_EXTEND_CASED (U+0345), which is cased once its run is + // attached, even though the base itself never counts as cased directly. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("\u{20000}\u{345}\u{3A3}"), + "\u{20000}\u{345}\u{3C2}" + ); + assert_eq!(t.lowercase("\u{20000}\u{3A3}"), "\u{20000}\u{3C3}"); + } + + #[test] + fn backward_mid_word_bridge_credits_a_cased_mark_riding_a_noncased_base() { + // Real-JDK-verified: scanning backward through a mid-word connector to find its base + // legitimately walks riders-then-base (marks trail their base in text order). A + // CLASS_EXTEND_CASED (U+0345) found along that walk is cased once the bridge + // validates, regardless of whether the ultimate base underneath it is itself cased. + let t = jdk17_test_tables(); + assert_eq!( + t.lowercase("\u{20000}\u{345}_\u{3A3}"), + "\u{20000}\u{345}_\u{3C2}" + ); + } + + #[test] + fn forward_mid_word_bridge_rejects_an_orphaned_mark_after_the_punctuation() { + // Real-JDK-verified: unlike the backward-scan bridge, a mark found IMMEDIATELY after + // mid-word punctuation (before any real base) is orphaned -- its text-order + // predecessor is the punctuation, not a letter -- so the forward bridge must reject + // it rather than skipping past it to a real letter beyond. + let t = jdk17_test_tables(); + assert_eq!(t.lowercase("A\u{3A3}-\u{301}B"), "a\u{3C2}-\u{301}b"); + } + + #[test] + fn absent_tables_fall_back_to_rust_lowercase() { + // Without shipped tables -- non-scan `SparkParquetOptions` consumers (e.g. general + // struct-to-struct type conversion), Rust-only unit tests of the matching logic that + // skip the JVM proto round trip, or a defensively malformed plan -- matching falls + // back to `str::to_lowercase`: correct for all simple mappings (Kelvin sign, ASCII) + // and knowingly divergent from the JVM only where the Unicode snapshots or the sigma + // word-context differ. + assert_eq!(java_lowercase("A1\u{3A3}", None), "a1\u{3C3}"); + assert!(names_equal_ignore_case_java("\u{212A}", "k", None)); + assert!(names_equal_ignore_case_java("Foo", "foo", None)); + assert!(!names_equal_ignore_case_java( + "A1\u{3A3}", + "a1\u{3C2}", + None + )); + } + + #[test] + fn unknown_class_values_read_as_word_boundaries() { + // A newer JVM-side generator may ship class values this build does not know; they must + // degrade to the safe reading (word boundary), never crash or misclassify. + let t = JvmCaseTables::from_proto( + &[0x41], + &["a".to_string()], + &[0x41, 0x5A, 99, 0x3B1, 0x3C9, CLASS_ALETTER_CASED as u32], + ); + // 'A' (class 99 -> boundary) does not open the sigma context... + assert_eq!(t.lowercase("A\u{3A3}"), "a\u{3C3}"); + // ...while a known cased class still does. + assert_eq!(t.lowercase("\u{3B1}\u{3A3}"), "\u{3B1}\u{3C2}"); + } + + #[test] + fn malformed_proto_degrades_entry_by_entry() { + // Misaligned lowercase arrays: extra codepoints without replacements are dropped. + let t = JvmCaseTables::from_proto(&[0x41, 0x42], &["a".to_string()], &[]); + assert_eq!(t.lowercase("AB"), "aB"); + // A trailing partial triple and an inverted range are dropped; the valid triple works. + let t = JvmCaseTables::from_proto( + &[], + &[], + &[ + 0x61, + 0x7A, + CLASS_ALETTER_CASED as u32, + 0x5A, + 0x41, // inverted -- dropped + CLASS_ALETTER_CASED as u32, + 0x30, // trailing partial triple -- dropped + ], + ); + assert_eq!(t.lowercase("a\u{3A3}"), "a\u{3C2}"); + assert_eq!(t.lowercase("A\u{3A3}"), "A\u{3C3}"); + } + + #[test] + fn equal_tables_hash_and_compare_equal() { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let a = jdk17_test_tables(); + let b = jdk17_test_tables(); + assert_eq!(a, b); + let hash = |t: &JvmCaseTables| { + let mut h = DefaultHasher::new(); + t.hash(&mut h); + h.finish() + }; + assert_eq!(hash(&a), hash(&b)); + let c = JvmCaseTables::from_proto(&[0x41], &["a".to_string()], &[]); + assert_ne!(a, c); + } + + // -- remap_physical_schema: end-to-end wiring of the Java-parity matcher into the schema + // remap that Spark's scan relies on. -- + + #[test] + fn remap_case_sensitive_does_not_fold_kelvin_sign_to_ascii_k() { + let logical = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, true)])); + let physical = Arc::new(Schema::new(vec![Field::new( + "\u{212A}", + DataType::Int64, + true, + )])); + + let (remapped, name_map) = remap_physical_schema( + &logical, + &physical, + /* case_sensitive */ true, + Some(&jdk17_test_tables()), + false, + false, + ) + .unwrap(); + + // No case-insensitive fallback in case-sensitive mode: the physical field name is left + // untouched and no remap entry is recorded. + assert_eq!(remapped.field(0).name(), "\u{212A}"); + assert!(name_map.is_empty()); + } + + #[test] + fn remap_case_insensitive_folds_kelvin_sign_to_ascii_k() { + let logical = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, true)])); + let physical = Arc::new(Schema::new(vec![Field::new( + "\u{212A}", + DataType::Int64, + true, + )])); + + let (remapped, name_map) = remap_physical_schema( + &logical, + &physical, + /* case_sensitive */ false, + Some(&jdk17_test_tables()), + false, + false, + ) + .unwrap(); + + // The physical field is renamed to the logical name so the default expr adapter's + // exact-name lookup hits, and the reverse map records the original physical name. + assert_eq!(remapped.field(0).name(), "k"); + assert_eq!(name_map.get("k").map(String::as_str), Some("\u{212A}")); + } + + #[test] + fn remap_field_id_shield_is_exact_in_case_sensitive_mode() { + // Logical `k` carries field ID 5, so Spark's `matchIdField` resolves it strictly by + // ID; a physical field named exactly `k` with no matching ID must be shielded from + // the downstream exact-name lookup. Physical `K`, however, is a DIFFERENT name under + // case-sensitive matching (Spark's `matchCaseSensitiveField` keys on the exact + // string), so it must stay untouched: nothing can name-match it, and hiding it would + // wrongly null a legitimate exact-name lookup elsewhere. JVM case tables are only + // shipped when `case_sensitive = false`, so `tables: None` is the live configuration. + let logical = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, true) + .with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "5".to_string(), + )]))])); + let physical = Arc::new(Schema::new(vec![ + Field::new("k", DataType::Int64, true), + Field::new("K", DataType::Int64, true), + ])); + + let (remapped, name_map) = remap_physical_schema( + &logical, &physical, /* case_sensitive */ true, /* case_tables */ None, + /* use_field_id */ true, /* ignore_missing_field_id */ true, + ) + .unwrap(); + + assert_ne!(remapped.field(0).name(), "k"); + assert_ne!(remapped.field(0).name(), "K"); + assert_eq!(remapped.field(1).name(), "K"); + assert!(name_map.is_empty()); + } + + #[test] + fn remap_case_sensitive_keeps_exact_name_for_id_less_logical_field() { + // Greek capital Kappa (U+039A) carries field ID 1; lowercase kappa (U+03BA) carries + // no ID. The file holds only `κ` with field ID 2. Spark null-fills `Κ` (ID 1 absent, + // `matchIdField` -> fake REQUESTED name) but resolves the ID-less `κ` by exact name + // through `matchCaseSensitiveField`, reading the real column. The physical `κ` must + // therefore survive the remap untouched -- `Κ` and `κ` only collide under a case + // fold, which case-sensitive matching must not apply. + let logical = Arc::new(Schema::new(vec![ + Field::new("\u{39A}", DataType::Int64, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "1".to_string(), + )])), + Field::new("\u{3BA}", DataType::Int64, true), + ])); + let physical = Arc::new(Schema::new(vec![Field::new( + "\u{3BA}", + DataType::Int64, + true, + ) + .with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "2".to_string(), + )]))])); + + let (remapped, name_map) = remap_physical_schema( + &logical, &physical, /* case_sensitive */ true, /* case_tables */ None, + /* use_field_id */ true, /* ignore_missing_field_id */ false, + ) + .unwrap(); + + assert_eq!(remapped.field(0).name(), "\u{3BA}"); + assert!(name_map.is_empty()); + } + + #[test] + fn remap_case_insensitive_claim_beats_unmatched_id_shield() { + // Case-insensitive variant of the Kappa scenario. Spark's `matchCaseInsensitiveField` + // resolves the ID-less requested `κ` through the `toLowerCase(Locale.ROOT)`-keyed + // physical field map, which contains the file's `κ` -- the unmatched-ID requested `Κ` + // only gets its own REQUESTED name faked and never blocks that lookup. So the + // physical `κ` must be claimed by the name match (and kept), not hidden by the + // shield, even though `Κ` and `κ` are equal under the case fold. + let logical = Arc::new(Schema::new(vec![ + Field::new("\u{39A}", DataType::Int64, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "1".to_string(), + )])), + Field::new("\u{3BA}", DataType::Int64, true), + ])); + let physical = Arc::new(Schema::new(vec![Field::new( + "\u{3BA}", + DataType::Int64, + true, + ) + .with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "2".to_string(), + )]))])); + + let (remapped, name_map) = remap_physical_schema( + &logical, &physical, /* case_sensitive */ false, /* case_tables */ None, + /* use_field_id */ true, /* ignore_missing_field_id */ false, + ) + .unwrap(); + + assert_eq!(remapped.field(0).name(), "\u{3BA}"); + assert!(name_map.is_empty()); + } + + #[test] + fn remap_shields_stray_physical_field_named_like_id_matched_logical_field() { + // The file holds a stray ID-less `A` FIRST and the real ID match `a` (ID 1) second. + // Spark reads requested `A` (ID 1) from physical `a` via `matchIdField`; the stray + // `A` is never requested. After the remap renames `a` -> `A`, the stray physical `A` + // must not remain as a second exact-name candidate ahead of it, or the downstream + // adapter's name lookup would resolve `A` to the wrong column's data. + let logical = Arc::new(Schema::new(vec![Field::new("A", DataType::Int64, true) + .with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "1".to_string(), + )]))])); + let physical = Arc::new(Schema::new(vec![ + Field::new("A", DataType::Int64, true), + Field::new("a", DataType::Int64, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "1".to_string(), + )])), + ])); + + let (remapped, name_map) = remap_physical_schema( + &logical, &physical, /* case_sensitive */ true, /* case_tables */ None, + /* use_field_id */ true, /* ignore_missing_field_id */ false, + ) + .unwrap(); + + assert_ne!(remapped.field(0).name(), "A"); + assert_ne!(remapped.field(0).name(), "a"); + assert_eq!(remapped.field(1).name(), "A"); + assert_eq!(name_map.get("A").map(String::as_str), Some("a")); + } + + #[test] + fn remap_fake_names_never_collide_with_real_columns() { + // A real column may legitimately be named like the fake-name pattern. Spark's + // `generateFakeColumnName` embeds a random UUID, so its fakes can never shadow a + // real column; the deterministic counter here must skip past reserved names to give + // the same guarantee, otherwise the shielded field would duplicate the real + // `__comet_unmatched_field_id_1` and could steal its exact-name match. + let logical = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int64, true).with_metadata(HashMap::from([( + PARQUET_FIELD_ID_META_KEY.to_string(), + "1".to_string(), + )])), + Field::new("__comet_unmatched_field_id_1", DataType::Int64, true), + ])); + let physical = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int64, true), + Field::new("__comet_unmatched_field_id_1", DataType::Int64, true), + ])); + + let (remapped, name_map) = remap_physical_schema( + &logical, &physical, /* case_sensitive */ true, /* case_tables */ None, + /* use_field_id */ true, /* ignore_missing_field_id */ true, + ) + .unwrap(); + + // Physical `a` collides with the ID-bearing logical `a` (its ID is absent from the + // file) and gets shielded -- but not with the taken fake name. + assert_eq!(remapped.field(0).name(), "__comet_unmatched_field_id_2"); + // The real column matching the fake pattern is untouched and still name-matchable. + assert_eq!(remapped.field(1).name(), "__comet_unmatched_field_id_1"); + assert!(name_map.is_empty()); + } } diff --git a/native/proto/src/proto/operator.proto b/native/proto/src/proto/operator.proto index acac87d53ea..9fe2100b415 100644 --- a/native/proto/src/proto/operator.proto +++ b/native/proto/src/proto/operator.proto @@ -77,6 +77,8 @@ message Operator { // zero runtime cost. ContribScan contrib_scan = 200; } + + reserved "delta_scan"; } // A `google.protobuf.Any`-shaped envelope for out-of-tree contrib scan messages. Hand-rolled @@ -175,6 +177,25 @@ message NativeScanCommon { // SchemaColumnConvertNotSupportedException (Spark 3.x, SPARK-36182). Set // from Comet's per-Spark-version constant in ShimCometConf. bool allow_timestamp_ltz_to_ntz = 18; + + // Case tables generated by the PLANNING JVM (JvmCaseTables.scala), populated + // only when case_sensitive = false. They let the native side reproduce that + // JVM's `String.toLowerCase(Locale.ROOT)` -- Spark's Parquet footer + // field-matching key -- exactly, whatever Unicode version the JVM bundles. + // + // jvm_lower_cp / jvm_lower_repl are index-aligned: codepoint N in + // jvm_lower_cp lowercases to the (possibly multi-char) string at + // jvm_lower_repl[N]; codepoints absent from the list lowercase to + // themselves. jvm_sigma_class_ranges holds (start, end, class) triples of + // inclusive codepoint ranges (sorted, disjoint) classifying every codepoint + // for the Greek final-sigma context scan; the class values are the wire + // contract shared by JvmCaseTables.scala and schema_adapter.rs (absent => + // word-boundary). Serialized size is ~15 KB total (measured: 14,435 bytes + // on JDK 17, 14,915 bytes on JDK 21; packed varints), sent once per scan at + // planning time. + repeated uint32 jvm_lower_cp = 19; + repeated string jvm_lower_repl = 20; + repeated uint32 jvm_sigma_class_ranges = 21; } message NativeScan { @@ -185,6 +206,66 @@ message NativeScan { SparkFilePartition file_partition = 2; } +// Delta-table-wide data shared by all partitions (sent once at planning). +// Produced by the contrib Delta module; the native handler is compiled only +// when the `delta` Cargo feature is enabled. +message DeltaSparkScanCommon { + // Table root URL, used to resolve relative deletion-vector paths. + string table_root = 1; + // Column mapping mode: "none", "name", or "id". + string column_mapping_mode = 2; + // Key for split-mode plan-data injection. Derived from (table root, snapshot + // version, scan hash) so two scans of the same table in one plan (self-join, + // MERGE) don't collide -- same lesson as IcebergScan's + // (metadata_location, scan_hash_code) key. + string source_key = 3; +} + +// Descriptor for a Delta deletion vector, derived from the Delta protocol's +// DeletionVectorDescriptor. The JVM side (which has delta-spark on the +// classpath) resolves UUID-relative paths to absolute URLs and Z85-decodes +// inline bitmaps, so the native side needs neither codec. Executors fetch +// on-disk bitmaps with a single ranged object-store read; only this small +// descriptor crosses JNI. +message DeltaSparkDvDescriptor { + // Original storage form, for diagnostics: "u" (UUID-relative), "i" + // (inline), "p" (absolute path). + string storage_type = 1; + // Absolute URL of the DV file (on-disk forms). At descriptor.offset the + // file holds [i32 BE size][bitmap data][i32 BE CRC32-of-data]. + optional string absolute_path = 2; + // The bitmap data (magic + RoaringBitmapArray), already unframed and + // Z85-decoded (inline form). + optional bytes inline_data = 3; + // Byte offset of the size-prefixed bitmap within the DV file. + optional int32 offset = 4; + // Length of the bitmap data (excluding the size/CRC framing). + int32 size_in_bytes = 5; + // Number of deleted rows encoded in the bitmap. + int64 cardinality = 6; +} + +// A data file plus its optional deletion vector. +message DeltaSparkPartitionedFile { + SparkPartitionedFile file = 1; + optional DeltaSparkDvDescriptor dv = 2; +} + +// Single partition's Delta file list (injected at execution time). +// Field name matches SparkFilePartition.partitioned_file for consistency. +message DeltaSparkFilePartition { + repeated DeltaSparkPartitionedFile partitioned_file = 1; +} + +message DeltaSparkScan { + // Reuses the parquet scan's common data (schemas, filters, projections, + // object-store options, reader flags) -- the Delta read path delegates to + // the same native parquet machinery as NativeScan. + NativeScanCommon common = 1; + DeltaSparkScanCommon delta_common = 2; + DeltaSparkFilePartition file_partition = 3; +} + message CsvScan { repeated SparkStructField data_schema = 1; repeated SparkStructField partition_schema = 2; diff --git a/pom.xml b/pom.xml index 11494665755..c7d9a6c7f9f 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,11 @@ under the License. 33.2.1-jre 1.21.4 2.31.51 + + delta-spark + 4.3.1 ${project.basedir}/../native/target/debug darwin x86_64 @@ -672,6 +677,11 @@ under the License. spark-3.x spark-3.4 spark-none + + delta-core + 2.4.0 11 ${java.version} ${java.version} @@ -691,6 +701,7 @@ under the License. spark-3.x spark-3.5 spark-none + 3.3.2 11 ${java.version} ${java.version} @@ -710,6 +721,7 @@ under the License. spark-4.x spark-4.0 spark-none + 4.0.1 17 ${java.version} ${java.version} @@ -733,6 +745,9 @@ under the License. spark-4.x spark-4.1+ spark-4.1 + + 4.3.1 17 ${java.version} ${java.version} @@ -753,6 +768,10 @@ under the License. spark-4.x spark-4.1+ spark-4.2 + + 4.3.1 17 ${java.version} @@ -760,6 +779,16 @@ under the License. + + + delta + + contrib/delta-spark + + + scala-2.12 @@ -1273,6 +1302,21 @@ under the License. org.apache.datasketches.memory.internal.ResourceImpl + + org.apache.datafusion + comet-common-spark${spark.version.short}_${scala.binary.version} + + + org.apache.comet.* + + true true diff --git a/spark/pom.xml b/spark/pom.xml index a257415dd3f..0374cf7dad9 100644 --- a/spark/pom.xml +++ b/spark/pom.xml @@ -550,6 +550,19 @@ under the License. org.scalatest scalatest-maven-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + org.apache.maven.plugins maven-shade-plugin diff --git a/spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala b/spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala index 6c2436ac39a..c3e3815be1c 100644 --- a/spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala +++ b/spark/src/main/scala/org/apache/comet/objectstore/NativeConfig.scala @@ -75,13 +75,38 @@ object NativeConfig { // Extract all configurations that match the object store prefixes hadoopConf.iterator().asScala.foreach { entry => val key = entry.getKey - val value = entry.getValue // Check if key starts with any of the prefixes for this scheme if (prefixes.get.exists(prefix => key.startsWith(prefix))) { - options(key) = value + options(key) = substitutedValue(hadoopConf, key, entry.getValue) } } options.toMap } + + /** + * The value Hadoop's own consumers observe for `key`. `Configuration#get` expands any `${...}` + * variable reference in the stored value against other conf entries and system properties, + * while `Configuration.Entry#getValue` (what `iterator()` surfaces) is the raw, unexpanded + * literal. Forwarding the raw literal here would diverge from every Hadoop-side consumer + * whenever a value contains such a reference. Substitution is bounded (Hadoop caps recursion at + * `MAX_SUBST`, currently 20 passes) and purely in-memory, so resolving it here is cheap and has + * no side effects. + * + * Falls back to `rawValue` when `get` returns `null` (deprecated-key aliasing can do this even + * though `key` came from the conf's own iterator) or when it raises `IllegalStateException` (a + * substitution cycle that never converges) -- either way, forwarding the raw literal preserves + * the extraction's prior behavior for that entry rather than aborting the whole object store's + * option extraction. + */ + private def substitutedValue( + hadoopConf: Configuration, + key: String, + rawValue: String): String = { + try { + Option(hadoopConf.get(key)).getOrElse(rawValue) + } catch { + case _: IllegalStateException => rawValue + } + } } diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanContrib.scala b/spark/src/main/scala/org/apache/comet/rules/CometScanContrib.scala index 5bff024d00b..5a11e2dcc39 100644 --- a/spark/src/main/scala/org/apache/comet/rules/CometScanContrib.scala +++ b/spark/src/main/scala/org/apache/comet/rules/CometScanContrib.scala @@ -120,8 +120,15 @@ object CometScanContrib extends Logging { * speculative and can fail for reasons entirely outside the query -- an unreachable object * store, a metadata format newer than the contrib understands, a version-skewed reflective * lookup -- and none of those should turn a runnable query into a failed one. Logging (rather - * than swallowing silently) keeps an unexpectedly-declining contrib diagnosable. `NonFatal` - * deliberately lets `LinkageError`/`OOM`-class failures through. + * than swallowing silently) keeps an unexpectedly-declining contrib diagnosable. + * + * `NonFatal` does not match `LinkageError` (`NoSuchMethodError`, `NoClassDefFoundError`, ...), + * so it is caught separately and contained the same way: a contrib jar built against internals + * Comet has since moved or removed is a classpath/version skew, not a JVM-corrupting failure, + * and must not fail a query Spark could otherwise run. Genuinely fatal conditions -- + * `OutOfMemoryError` and the like -- are neither `NonFatal` nor `LinkageError` and always + * propagate; this is a narrow, deliberate widening for one specific `Error` subtype, not a + * blanket `catch (Throwable)`. */ private def firstClaim(hook: CometScanContrib => Option[SparkPlan]): Option[SparkPlan] = firstClaimFrom(contribs)(hook) @@ -147,6 +154,21 @@ object CometScanContrib extends Logging { "declining it and continuing with Comet's built-in handling", e) None + case e: LinkageError => + // A version-skewed contrib jar (compiled against a Comet internal that has since + // moved, been renamed, or been removed) surfaces as NoSuchMethodError, + // NoClassDefFoundError, or a sibling LinkageError -- a classpath mismatch, not a + // query-specific failure, and not the JVM corruption OutOfMemoryError/StackOverflowError + // signal. Contained the same way a NonFatal decline is: logged and treated as "this + // contrib does not claim this scan" so a stale contrib jar cannot fail a query Spark + // could otherwise run. + logWarning( + s"Contrib scan handler ${contrib.getClass.getName} failed with " + + s"${e.getClass.getName}, indicating it was built against a different version of " + + "Comet's internals than is on the classpath now; declining it and continuing with " + + "Comet's built-in handling", + e) + None } // Short-circuit before reading the config: a default build registers nothing, and this is on diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala index a524da3af92..4cc9b1cee72 100644 --- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala +++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala @@ -1031,7 +1031,8 @@ object CometScanRule extends Logging { * early-fallback optimization, and a build without a working native library can't run Comet's * native scan anyway, so declining here would only over-restrict. */ - private[rules] def isNativelyReadableScheme(uri: URI): Boolean = { + // private[comet] (not [rules]) so contrib scan extensions can apply the same gate. + private[comet] def isNativelyReadableScheme(uri: URI): Boolean = { val scheme = uri.getScheme if (scheme == null) return true schemeSupportCache diff --git a/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala b/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala index e395ac6d9d3..10d68475983 100644 --- a/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala +++ b/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala @@ -19,11 +19,14 @@ package org.apache.comet.serde.operator +import java.net.URI + import scala.collection.mutable.ListBuffer import scala.jdk.CollectionConverters._ +import org.apache.hadoop.conf.Configuration import org.apache.spark.internal.Logging -import org.apache.spark.sql.catalyst.expressions.{Expression, Literal} +import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference, Expression, Literal} import org.apache.spark.sql.catalyst.util.ResolveDefaultColumns.getExistenceDefaultValues import org.apache.spark.sql.comet.{CometNativeExec, CometNativeScanExec, CometScanExec} import org.apache.spark.sql.execution.{FileSourceScanExec, InSubqueryExec, SubqueryAdaptiveBroadcastExec} @@ -49,7 +52,29 @@ object CometNativeScan extends CometOperatorSerde[CometScanExec] with CometTypeS // DataFusion's table_partition_cols literal substitution matches by name, so a bare name // like "file_size" could collide with a real column of the same name. Prefix to avoid it. - private val constantMetadataFieldPrefix = "_comet_metadata_" + private[comet] val constantMetadataFieldPrefix = "_comet_metadata_" + + /** + * Build synthetic constant-metadata field names, uniquified against `reservedNames` (physical + * data and partition schema names): DataFusion substitutes partition constants BY NAME, so a + * colliding user/partition column would otherwise silently receive the constant metadata value + * instead of its own. Binding is purely positional (`partition2Proto` keys off the ORIGINAL + * attribute name), so renaming here is always safe. + */ + private[comet] def uniqueConstantMetadataFields( + fileConstantMetadataColumns: Seq[AttributeReference], + reservedNames: Set[String]): Seq[StructField] = { + val reserved = scala.collection.mutable.LinkedHashSet[String]() + reserved ++= reservedNames + fileConstantMetadataColumns.map { attr => + var name = s"$constantMetadataFieldPrefix${attr.name}" + while (reserved.contains(name)) { + name = name + "_" + } + reserved += name + StructField(name, attr.dataType, attr.nullable) + } + } private def containsVariantType(dataType: DataType): Boolean = dataType match { case dt if isVariantType(dt) => true @@ -125,166 +150,233 @@ object CometNativeScan extends CometOperatorSerde[CometScanExec] with CometTypeS scan: CometScanExec, builder: Operator.Builder, childOp: OperatorOuterClass.Operator*): Option[OperatorOuterClass.Operator] = { - val nativeScanBuilder = OperatorOuterClass.NativeScan.newBuilder() + // Extract object store options from first file (S3 configs apply to all files in scan). + // Use selectedPartitions (static) instead of getFilePartitions() because at planning time + // DPP subqueries haven't been resolved yet. Object store options don't depend on DPP. + val firstFileUri = scan.selectedPartitions + .flatMap(_.files.headOption) + .headOption + .map(_.getPath.toUri) + + // Collect S3/cloud storage configurations + val hadoopConf = scan.relation.sparkSession.sessionState + .newHadoopConfWithOptions(scan.relation.options) + + buildNativeScanCommon( + source = scan.simpleStringWithNodeId(), + output = scan.output, + requiredSchema = scan.requiredSchema, + dataSchema = scan.relation.dataSchema, + partitionSchema = scan.relation.partitionSchema, + fileConstantMetadataColumns = scan.wrapped.fileConstantMetadataColumns, + dataFilters = scan.supportedDataFilters, + firstFileUri = firstFileUri, + hadoopConf = hadoopConf, + conf = scan.conf) match { + case Some(commonBuilder) => + // Sink operators don't have children + builder.clearChildren() + val nativeScanBuilder = OperatorOuterClass.NativeScan.newBuilder() + // Set common data in NativeScan (file_partition will be populated at execution time) + nativeScanBuilder.setCommon(commonBuilder.build()) + Some(builder.setNativeScan(nativeScanBuilder).build()) + case None => + // There are unsupported scan type + withFallbackReason( + scan, + s"unsupported Comet operator: ${scan.nodeName}, due to unsupported data types above") + None + } + } + + /** + * Build the `NativeScanCommon` proto shared by the core parquet scan and contrib scans that + * delegate to the same native parquet machinery (e.g. a Delta scan contrib, which passes + * physical-name schemas under column mapping). Returns `None` when an output data type cannot + * be serialized; the caller is responsible for tagging a fallback reason. + * + * Visibility note: `private[comet]` means a contrib caller must live under an + * `org.apache.comet.*` package (the same constraint `PlanDataInjector` implementers have). + */ + private[comet] def buildNativeScanCommon( + source: String, + output: Seq[Attribute], + requiredSchema: StructType, + dataSchema: StructType, + partitionSchema: StructType, + fileConstantMetadataColumns: Seq[AttributeReference], + dataFilters: Seq[Expression], + firstFileUri: Option[URI], + hadoopConf: Configuration, + conf: SQLConf): Option[OperatorOuterClass.NativeScanCommon.Builder] = { val commonBuilder = OperatorOuterClass.NativeScanCommon.newBuilder() // Set source in common (used as part of injection key) - commonBuilder.setSource(scan.simpleStringWithNodeId()) + commonBuilder.setSource(source) - val scanTypes = scan.output.flatten { attr => + val scanTypes = output.flatten { attr => serializeDataType(attr.dataType) } - if (scanTypes.length == scan.output.length) { - commonBuilder.addAllFields(scanTypes.asJava) - - // Sink operators don't have children - builder.clearChildren() - - if (scan.conf.getConf(SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) { - val dataFilters = new ListBuffer[Expr]() - for (filter <- scan.supportedDataFilters) { - exprToProto(filter, scan.output) match { - case Some(proto) => dataFilters += proto - case _ => - logWarning(s"Unsupported data filter $filter") - } + if (scanTypes.length != output.length) { + // There are unsupported scan types + return None + } + commonBuilder.addAllFields(scanTypes.asJava) + + if (conf.getConf(SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED)) { + val filterProtos = new ListBuffer[Expr]() + for (filter <- dataFilters) { + exprToProto(filter, output) match { + case Some(proto) => filterProtos += proto + case _ => + logWarning(s"Unsupported data filter $filter") } - commonBuilder.addAllDataFilters(dataFilters.asJava) - } - - val possibleDefaultValues = getExistenceDefaultValues(scan.requiredSchema) - if (possibleDefaultValues.exists(_ != null)) { - // Our schema has default values. Serialize two lists, one with the default values - // and another with the indexes in the schema so the native side can map missing - // columns to these default values. - val (defaultValues, indexes) = possibleDefaultValues.iterator.zipWithIndex - .filter { case (expr, _) => expr != null } - .map { case (expr, index) => - // ResolveDefaultColumnsUtil.getExistenceDefaultValues has evaluated these - // expressions and they should now just be literals. - (Literal(expr), index.toLong.asInstanceOf[java.lang.Long]) - } - .toList - .unzip - commonBuilder.addAllDefaultValues( - defaultValues.flatMap(exprToProto(_, scan.output)).asJava) - commonBuilder.addAllDefaultValuesIndexes(indexes.asJava) } + commonBuilder.addAllDataFilters(filterProtos.asJava) + } - // Extract object store options from first file (S3 configs apply to all files in scan). - // Use selectedPartitions (static) instead of getFilePartitions() because at planning time - // DPP subqueries haven't been resolved yet. Object store options don't depend on DPP. - val firstFileUri = scan.selectedPartitions - .flatMap(_.files.headOption) - .headOption - .map(_.getPath.toUri) - - // Constant metadata columns (file_path, file_name, file_size, file_block_start, - // file_block_length, file_modification_time) are known before opening the file and - // constant for every row read from it, exactly like partition columns. Spark places - // them immediately after partition columns in `scan.output` - // (FileSourceStrategy.scala: readDataColumns ++ generatedMetadataColumns ++ - // partitionColumns ++ constantMetadataColumns), so appending them after the real - // partition schema here keeps the two in lockstep. - val constantMetadataFields = scan.wrapped.fileConstantMetadataColumns.map(attr => - StructField(s"$constantMetadataFieldPrefix${attr.name}", attr.dataType, attr.nullable)) - val partitionSchemaFields = scan.relation.partitionSchema.fields.toSeq ++ - constantMetadataFields - val partitionSchema = schema2Proto(partitionSchemaFields) - val requiredSchema = schema2Proto(scan.requiredSchema) - - // Spark's required schema can prune a Variant column, including a Variant nested under an - // unrequested struct. The complete relation schema still contains that unsupported type, - // and serializing it would throw even though the native reader never needs those bytes. - // Keep ordinary fields unchanged and replace a requested Variant-bearing root with its - // already-validated, pruned required field. A requested actual Variant never reaches this - // point because CometScanRule keeps those scans on Spark. - val nativeDataSchema = StructType(scan.relation.dataSchema.fields.flatMap { field => - if (containsVariantType(field.dataType)) { - scan.requiredSchema.fields.find(requiredField => - scan.conf.resolver(requiredField.name, field.name)) - } else { - Some(field) - } - }) - val dataSchema = schema2Proto(nativeDataSchema) - - val dataSchemaIndexes = scan.requiredSchema.map(field => { - nativeDataSchema.fieldIndex(field.name) - }) - val partitionSchemaIndexes = nativeDataSchema.fields.length until - (nativeDataSchema.length + partitionSchemaFields.length) - - val projectionVector = (dataSchemaIndexes ++ partitionSchemaIndexes).map(idx => - idx.toLong.asInstanceOf[java.lang.Long]) - - commonBuilder.addAllProjectionVector(projectionVector.asJava) - - // In `CometScanRule`, we ensure partitionSchema (including constant metadata columns) - // is supported. - assert(partitionSchema.length == partitionSchemaFields.length) - - commonBuilder.addAllDataSchema(dataSchema.asJava) - commonBuilder.addAllRequiredSchema(requiredSchema.asJava) - commonBuilder.addAllPartitionSchema(partitionSchema.asJava) - commonBuilder.setSessionTimezone(scan.conf.getConfString("spark.sql.session.timeZone")) - commonBuilder.setCaseSensitive(scan.conf.getConf[Boolean](SQLConf.CASE_SENSITIVE)) - - // SPARK-53535 (Spark 4.1+): when reading a struct whose requested fields are all - // missing in the Parquet file, the new default preserves the parent struct's - // nullness from the file (so non-null parents materialize as a struct of all-null - // fields). Pre-4.1 Spark hardcodes the legacy behavior (whole struct null), which - // matches the Comet default we use as fallback. - val returnNullStructConfKey = - "spark.sql.legacy.parquet.returnNullStructIfAllFieldsMissing" - val returnNullStructDefault = if (isSpark41Plus) "false" else "true" - commonBuilder.setReturnNullStructIfAllFieldsMissing( - scan.conf.getConfString(returnNullStructConfKey, returnNullStructDefault).toBoolean) - - // Field-ID matching: only ask the native side to do extra work when the conf is on AND - // the requested schema actually carries IDs. Spark's ParquetReadSupport applies the same - // gate before invoking matchIdField. - val useFieldId = - scan.conf.getConf(SQLConf.PARQUET_FIELD_ID_READ_ENABLED) && - ParquetUtils.hasFieldIds(scan.requiredSchema) - commonBuilder.setUseFieldId(useFieldId) - commonBuilder.setIgnoreMissingFieldId( - scan.conf.getConf(SQLConf.IGNORE_MISSING_PARQUET_FIELD_ID)) - - commonBuilder.setAllowTypePromotion(CometConf.COMET_SCHEMA_EVOLUTION_ENABLED) - commonBuilder.setAllowTimestampLtzToNtz(CometConf.COMET_ALLOW_TIMESTAMP_LTZ_AS_NTZ) - - // Collect S3/cloud storage configurations - val hadoopConf = scan.relation.sparkSession.sessionState - .newHadoopConfWithOptions(scan.relation.options) - - commonBuilder.setEncryptionEnabled(CometParquetUtils.encryptionEnabled(hadoopConf)) - - firstFileUri.foreach { uri => - val objectStoreOptions = - NativeConfig.extractObjectStoreOptions(hadoopConf, uri) - objectStoreOptions.foreach { case (key, value) => - commonBuilder.putObjectStoreOptions(key, value) + val possibleDefaultValues = getExistenceDefaultValues(requiredSchema) + if (possibleDefaultValues.exists(_ != null)) { + // Our schema has default values. Serialize two lists, one with the default values + // and another with the indexes in the schema so the native side can map missing + // columns to these default values. + val (defaultValues, indexes) = possibleDefaultValues.iterator.zipWithIndex + .filter { case (expr, _) => expr != null } + .map { case (expr, index) => + // ResolveDefaultColumnsUtil.getExistenceDefaultValues has evaluated these + // expressions and they should now just be literals. + (Literal(expr), index.toLong.asInstanceOf[java.lang.Long]) } + .toList + .unzip + commonBuilder.addAllDefaultValues(defaultValues.flatMap(exprToProto(_, output)).asJava) + commonBuilder.addAllDefaultValuesIndexes(indexes.asJava) + } + + // Constant metadata columns (file_path, file_name, file_size, file_block_start, + // file_block_length, file_modification_time) are known before opening the file and + // constant for every row read from it, exactly like partition columns. Spark places + // them immediately after partition columns in the scan output + // (FileSourceStrategy.scala: readDataColumns ++ generatedMetadataColumns ++ + // partitionColumns ++ constantMetadataColumns), so appending them after the real + // partition schema here keeps the two in lockstep. + val constantMetadataFields = uniqueConstantMetadataFields( + fileConstantMetadataColumns, + dataSchema.fields.map(_.name).toSet ++ partitionSchema.fields.map(_.name).toSet) + val partitionSchemaFields = partitionSchema.fields.toSeq ++ constantMetadataFields + val partitionSchemaProto = schema2Proto(partitionSchemaFields) + val requiredSchemaProto = schema2Proto(requiredSchema) + + // Spark's required schema can prune a Variant column, including a Variant nested under an + // unrequested struct. The complete relation schema still contains that unsupported type, + // and serializing it would throw even though the native reader never needs those bytes. + // Keep ordinary fields unchanged and replace a requested Variant-bearing root with its + // already-validated, pruned required field. A requested actual Variant never reaches this + // point because CometScanRule keeps those scans on Spark. + val nativeDataSchema = StructType(dataSchema.fields.flatMap { field => + if (containsVariantType(field.dataType)) { + requiredSchema.fields.find(requiredField => conf.resolver(requiredField.name, field.name)) + } else { + Some(field) } + }) + val dataSchemaProto = schema2Proto(nativeDataSchema) - // Set common data in NativeScan (file_partition will be populated at execution time) - nativeScanBuilder.setCommon(commonBuilder.build()) + val dataSchemaIndexes = requiredSchema.map(field => { + nativeDataSchema.fieldIndex(field.name) + }) + val partitionSchemaIndexes = nativeDataSchema.fields.length until + (nativeDataSchema.length + partitionSchemaFields.length) - Some(builder.setNativeScan(nativeScanBuilder).build()) + val projectionVector = (dataSchemaIndexes ++ partitionSchemaIndexes).map(idx => + idx.toLong.asInstanceOf[java.lang.Long]) - } else { - // There are unsupported scan type - withFallbackReason( - scan, - s"unsupported Comet operator: ${scan.nodeName}, due to unsupported data types above") - None + commonBuilder.addAllProjectionVector(projectionVector.asJava) + + // In `CometScanRule`, we ensure partitionSchema (including constant metadata columns) + // is supported. + assert(partitionSchemaProto.length == partitionSchemaFields.length) + + commonBuilder.addAllDataSchema(dataSchemaProto.asJava) + commonBuilder.addAllRequiredSchema(requiredSchemaProto.asJava) + commonBuilder.addAllPartitionSchema(partitionSchemaProto.asJava) + + populateScanConfFlags(commonBuilder, requiredSchema, firstFileUri, hadoopConf, conf) + + Some(commonBuilder) + } + + /** + * Populate the configuration-derived flags of a `NativeScanCommon`: session timezone, case + * sensitivity, struct-nullness legacy flag, field-ID matching, type promotion, encryption, and + * object-store options. Shared with contrib scans that assemble their own schemas/projection + * (e.g. the Delta contrib's deletion-vector shape) so new flags added here reach them without + * drift. + */ + private[comet] def populateScanConfFlags( + commonBuilder: OperatorOuterClass.NativeScanCommon.Builder, + requiredSchema: StructType, + firstFileUri: Option[URI], + hadoopConf: Configuration, + conf: SQLConf): Unit = { + commonBuilder.setSessionTimezone(conf.getConfString("spark.sql.session.timeZone")) + val caseSensitive = conf.getConf[Boolean](SQLConf.CASE_SENSITIVE) + commonBuilder.setCaseSensitive(caseSensitive) + if (!caseSensitive) { + // Ship THIS JVM's case data so native's case-insensitive footer matching reproduces + // this JVM's `toLowerCase(Locale.ROOT)` exactly, whatever Unicode version it bundles. + JvmCaseTables.populate(commonBuilder) } + // SPARK-53535 (Spark 4.1+): when reading a struct whose requested fields are all + // missing in the Parquet file, the new default preserves the parent struct's + // nullness from the file (so non-null parents materialize as a struct of all-null + // fields). Pre-4.1 Spark hardcodes the legacy behavior (whole struct null), which + // matches the Comet default we use as fallback. + val returnNullStructConfKey = + "spark.sql.legacy.parquet.returnNullStructIfAllFieldsMissing" + val returnNullStructDefault = if (isSpark41Plus) "false" else "true" + commonBuilder.setReturnNullStructIfAllFieldsMissing( + conf.getConfString(returnNullStructConfKey, returnNullStructDefault).toBoolean) + + // Field-ID matching: only ask the native side to do extra work when the conf is on AND + // the requested schema actually carries IDs. Spark's ParquetReadSupport applies the same + // gate before invoking matchIdField. + val useFieldId = + conf.getConf(SQLConf.PARQUET_FIELD_ID_READ_ENABLED) && + ParquetUtils.hasFieldIds(requiredSchema) + commonBuilder.setUseFieldId(useFieldId) + commonBuilder.setIgnoreMissingFieldId(conf.getConf(SQLConf.IGNORE_MISSING_PARQUET_FIELD_ID)) + + commonBuilder.setAllowTypePromotion(CometConf.COMET_SCHEMA_EVOLUTION_ENABLED) + commonBuilder.setAllowTimestampLtzToNtz(CometConf.COMET_ALLOW_TIMESTAMP_LTZ_AS_NTZ) + + commonBuilder.setEncryptionEnabled(CometParquetUtils.encryptionEnabled(hadoopConf)) + + firstFileUri.foreach { uri => + val objectStoreOptions = + NativeConfig.extractObjectStoreOptions(hadoopConf, uri) + objectStoreOptions.foreach { case (key, value) => + commonBuilder.putObjectStoreOptions(key, value) + } + } } override def createExec(nativeOp: Operator, op: CometScanExec): CometNativeExec = { CometNativeScanExec(nativeOp, op.wrapped, op.session, op) } + + /** + * Sets the `inline_data` bytes field on a `DeltaSparkDvDescriptor` builder. The shade plugin + * relocates `com.google.protobuf.ByteString` when packaged, rewriting bytecode descriptors but + * not a Scala method's own pickled signature, so a helper returning `ByteString` directly would + * disagree with the packaged jar's Java-generated `setInlineData(ByteString)`. Keeping the + * protobuf type out of this method's signature sidesteps that, letting out-of-tree modules + * (e.g. Delta contrib) call this whether compiled against unshaded or shaded classes. + */ + def setDvInlineData( + builder: OperatorOuterClass.DeltaSparkDvDescriptor.Builder, + bytes: Array[Byte]): OperatorOuterClass.DeltaSparkDvDescriptor.Builder = + builder.setInlineData(com.google.protobuf.ByteString.copyFrom(bytes)) } diff --git a/spark/src/main/scala/org/apache/comet/serde/operator/JvmCaseTables.scala b/spark/src/main/scala/org/apache/comet/serde/operator/JvmCaseTables.scala new file mode 100644 index 00000000000..b45bb7edffb --- /dev/null +++ b/spark/src/main/scala/org/apache/comet/serde/operator/JvmCaseTables.scala @@ -0,0 +1,539 @@ +/* + * 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. + */ + +package org.apache.comet.serde.operator + +import java.text.BreakIterator +import java.util.Locale + +import org.apache.comet.serde.OperatorOuterClass + +/** + * Generates, from the RUNNING JVM, the case tables the native Parquet scan needs to reproduce + * this JVM's `String.toLowerCase(Locale.ROOT)`, which Spark's Parquet footer field matching is + * built on. Shipping the running JVM's own data (rather than pinning a Unicode snapshot) keeps + * the native matcher correct by construction for whatever JDK executes the query. + * + * Two data sets are generated lazily, once per JVM (cached in [[generated]], ~15KB): + * + * - a lowercase table: every codepoint whose single-codepoint string lowercases + * non-identically, with its full (possibly multi-char, e.g. U+0130 -> "i" + U+0307) + * replacement; + * - a word-break classification of every codepoint for the Greek capital sigma's contextual + * (final/non-final) lowering, `java.lang.ConditionalSpecialCasing`'s only locale-independent + * conditional mapping. + * + * Java's final-sigma rule is WORD-BOUNDARY based (`BreakIterator.getWordInstance`), NOT the + * Unicode-standard Final_Sigma "case-ignorable" skip: `isFinalCased` asks whether a cased + * character precedes the sigma within its word with none following inside the word. The + * word-break classes are the UAX#29-style classes (ALetter, Numeric, MidLetter, MidNum, + * MidNumLet, Extend, Format) as the RUNNING JDK's legacy break iterator actually realizes them, + * plus classes for its pre-UAX#29 extensions (the danda, and the supplementary-plane behaviors of + * its UTF-16 DFA). Rather than pinning any character-property model, [[classify]] PROBES every + * codepoint through `BreakIterator.isBoundary` in discriminating templates, so the shipped + * classes track the executing JDK's break data exactly; the JDK's `isCased` predicate then splits + * cased variants. Classes ship as (start, end, class) range triples. + * + * [[mirrorLowercase]] is a line-for-line mirror of the native algorithm + * (`JvmCaseTables::lowercase` in schema_adapter.rs) over these same generated tables -- any + * change to one side must be mirrored in the other. `JvmLowercaseParitySuite` proves + * `mirrorLowercase == String.toLowerCase(Locale.ROOT)` for the running JDK across the full + * codepoint space, which then transfers to native. Calibrated to zero mismatches on JDK 17, 21, + * and 25 against full-codepoint context sweeps, multi-special triple sweeps, and million-string + * fuzzing. + */ +private[comet] object JvmCaseTables { + + // Wire contract shared with `JvmCaseTables` in native/core/src/parquet/schema_adapter.rs: + // lowerCps/lowerRepls are index-aligned; classRanges is a flat (start, end, class) triple + // list, sorted and disjoint, class ids 1-15 (0 = unshipped word boundary). + private[comet] val ClassALetterCased = 1 // word-joining BMP letter-base, cased + private[comet] val ClassALetter = 2 // word-joining BMP letter-base, not cased + private[comet] val ClassNumeric = 3 // BMP digit-base: joins words, bridges only mid-num + private[comet] val ClassMidLetter = 4 // joins only letter..letter ('-', '_', U+00AD, ...) + private[comet] val ClassMidNum = 5 // joins only digit..digit (',', U+066B) + private[comet] val ClassMidNumLet = 6 // both of the above ('"', '\'', '.') + private[comet] val ClassSuppCased = 7 // cased supplementary: attaches and closes the word + private[comet] val ClassDanda = 8 // U+0964/U+0965: word-terminal, chains only into digits + private[comet] val ClassExtendCased = 9 // U+0345: cased only when attached to a word + private[comet] val ClassNumericCased = 10 // cased digit-base (Nl Roman numerals) + private[comet] val ClassExtend = 11 // Mn/Me marks: attach only to letter/digit bases + private[comet] val ClassSuppLetter = 12 // word-forming non-cased supplementary letter + private[comet] val ClassFormat = 13 // Cf format chars: fully transparent (WB4-style) + // Supplementary chars that attach to the preceding word but never form one themselves + // (supplementary combining marks, tag characters): a cased mark riding on one belongs to + // the sigma's word only when the run hangs off a real base ([[suppMnAnchor]]). + private[comet] val ClassSuppMn = 14 + // Word-forming supplementary digit: like ClassSuppLetter except a riding cased mark + // carries only across mid-num (digit-context) punctuation, never mid-letter. + private[comet] val ClassSuppNum = 15 + + /** The generated tables: aligned lowercase arrays plus (start, end, class) range triples. */ + private[comet] final case class Tables( + lowerCps: Array[Int], + lowerRepls: Array[String], + classRanges: Array[Int]) + + /** + * Pinned from `ConditionalSpecialCasing.isCased`: Lu/Ll/Lt by `getType` plus the JDK's + * hardcoded Other_Uppercase/Other_Lowercase ranges (byte-identical in JDK 17 and 21). + */ + private def isCasedJdk(cp: Int): Boolean = { + val t = Character.getType(cp) + if (t == Character.LOWERCASE_LETTER || t == Character.UPPERCASE_LETTER || + t == Character.TITLECASE_LETTER) { + true + } else { + (cp >= 0x02b0 && cp <= 0x02b8) || (cp >= 0x02c0 && cp <= 0x02c1) || + (cp >= 0x02e0 && cp <= 0x02e4) || cp == 0x0345 || cp == 0x037a || + (cp >= 0x1d2c && cp <= 0x1d61) || (cp >= 0x2160 && cp <= 0x217f) || + (cp >= 0x24b6 && cp <= 0x24e9) + } + } + + private def isBoundary(bi: BreakIterator, s: String, pos: Int): Boolean = { + bi.setText(s) + bi.isBoundary(pos) + } + + /** + * One word-break class per codepoint, derived EMPIRICALLY from the running JDK's own + * `BreakIterator.getWordInstance(Locale.ROOT)` by probing `isBoundary` in discriminating + * templates ('a'/'b' anchor letters, '1'/'2' digits, '-' mid-letter, ',' mid-num, U+0345 the + * cased mark, U+03A3 the sigma). Everything without a joining fingerprint (spaces, symbols, + * kana/kanji -- which form their own segments a sigma can never share -- unassigned codepoints, + * surrogates) is a word boundary (class 0, never shipped). + */ + private[comet] def classify(bi: BreakIterator, cp: Int): Int = { + if (cp >= 0xd800 && cp <= 0xdfff) { + return 0 // surrogate halves: unreachable from well-formed UTF-8 column names + } + val x = new String(Character.toChars(cp)) + if (cp > 0xffff) { + // Supplementary: the legacy UTF-16 DFA attaches every non-isolated supplementary char + // to the preceding word and closes the word after it. Discriminate: attach-close + // ("aXb"), word-forming at text start ("Xa"), whether a riding mark starts a fresh + // segment ("X" + U+0345 + sigma), and letter- vs digit-flavored mid bridging. + if (!isBoundary(bi, "a" + x + "b", 1)) { + if (!isBoundary(bi, x + "a", 2)) { + if (isCasedJdk(cp)) return ClassSuppCased + val casedMark = 0x0345.toChar.toString + val sigma = 0x03a3.toChar.toString + val grab = x + casedMark + sigma + if (isBoundary(bi, grab, grab.length - 1)) return ClassSuppMn + val mid = "A" + x + casedMark + "-" + sigma + if (isBoundary(bi, mid, mid.length - 1)) ClassSuppNum else ClassSuppLetter + } else { + ClassSuppMn + } + } else { + 0 + } + } else { + val axb = "a" + x + "b" + val j1 = !isBoundary(bi, axb, 1) + val j2 = !isBoundary(bi, axb, 2) + if (j1 && j2) { + val axxb = "a" + x + x + "b" + val dbl = !isBoundary(bi, axxb, 1) && !isBoundary(bi, axxb, 2) && + !isBoundary(bi, axxb, 3) + val n12 = "1" + x + "2" + val num = !isBoundary(bi, n12, 1) && !isBoundary(bi, n12, 2) + if (dbl) { + // Full joiner: letter/digit-base, attached mark, or transparent format char, + // separated by which mid punctuation it bridges. + val bridgeL = !isBoundary(bi, "a-" + x + "c", 1) + val bridgeN = !isBoundary(bi, "1," + x + "2", 1) + if (bridgeL && bridgeN) ClassFormat + else if (bridgeL) { if (isCasedJdk(cp)) ClassALetterCased else ClassALetter } + else if (bridgeN) { if (isCasedJdk(cp)) ClassNumericCased else ClassNumeric } + else if (isCasedJdk(cp)) ClassExtendCased + else ClassExtend + } else { + // Joins a single letter..letter gap only: mid punctuation. + if (num) ClassMidNumLet else ClassMidLetter + } + } else if (j1 && !j2) { + // Attaches to the preceding word and closes it; the danda also chains into digits. + val ax1 = "a" + x + "1" + if (!isBoundary(bi, ax1, 1) && !isBoundary(bi, ax1, 2)) ClassDanda else 0 + } else { + val n12 = "1" + x + "2" + if (!isBoundary(bi, n12, 1) && !isBoundary(bi, n12, 2)) ClassMidNum else 0 + } + } + } + + /** Generated once per JVM; both the proto population and the parity suite read this. */ + private[comet] lazy val generated: Tables = { + val bi = BreakIterator.getWordInstance(Locale.ROOT) + val cps = new java.util.ArrayList[Integer]() + val repls = new java.util.ArrayList[String]() + val ranges = new java.util.ArrayList[Integer]() + var rangeStart = -1 + var rangeClass = 0 + var cp = 0 + while (cp <= 0x10ffff) { + if (cp < 0xd800 || cp > 0xdfff) { + val s = new String(Character.toChars(cp)) + val low = s.toLowerCase(Locale.ROOT) + if (low != s) { + cps.add(cp) + repls.add(low) + } + } + val cls = classify(bi, cp) + if (cls != rangeClass) { + if (rangeClass != 0) { + ranges.add(rangeStart) + ranges.add(cp - 1) + ranges.add(rangeClass) + } + rangeStart = cp + rangeClass = cls + } + cp += 1 + } + if (rangeClass != 0) { + ranges.add(rangeStart) + ranges.add(0x10ffff) + ranges.add(rangeClass) + } + Tables( + lowerCps = cps.toArray(new Array[Integer](0)).map(_.intValue()), + lowerRepls = repls.toArray(new Array[String](0)), + classRanges = ranges.toArray(new Array[Integer](0)).map(_.intValue())) + } + + // Boxed views cached once so per-scan proto population is a bulk addAll, not a re-box. + private lazy val lowerCpsBoxed: java.util.List[Integer] = { + val list = new java.util.ArrayList[Integer](generated.lowerCps.length) + generated.lowerCps.foreach(list.add(_)) + java.util.Collections.unmodifiableList(list) + } + private lazy val lowerReplsBoxed: java.util.List[String] = + java.util.Collections.unmodifiableList(java.util.Arrays.asList(generated.lowerRepls: _*)) + private lazy val classRangesBoxed: java.util.List[Integer] = { + val list = new java.util.ArrayList[Integer](generated.classRanges.length) + generated.classRanges.foreach(list.add(_)) + java.util.Collections.unmodifiableList(list) + } + + /** Attach the running JVM's case tables to a scan's common proto (case-insensitive only). */ + private[comet] def populate(builder: OperatorOuterClass.NativeScanCommon.Builder): Unit = { + builder.addAllJvmLowerCp(lowerCpsBoxed) + builder.addAllJvmLowerRepl(lowerReplsBoxed) + builder.addAllJvmSigmaClassRanges(classRangesBoxed) + } + + // --------------------------------------------------------------------------------------- + // Mirror of the native algorithm (schema_adapter.rs `JvmCaseTables::lowercase`). Test-facing: + // the parity suite proves this equals `String.toLowerCase(Locale.ROOT)` on the running JDK. + // + // The scans run over a FORMAT-FILTERED codepoint sequence (WB4-style: the legacy break + // iterator's `` class loops on every DFA state, so Cf characters are deleted before + // segmentation -- this is what lets a pure-format rider chain bridge mid punctuation, e.g. + // "A-b" is one word exactly like "A-b"). + // --------------------------------------------------------------------------------------- + + /** Class of `cp` per the generated (start, end, class) triples; 0 = word boundary. */ + private[comet] def sigmaClassOf(cp: Int): Int = { + val r = generated.classRanges + var lo = 0 + var hi = r.length / 3 - 1 + while (lo <= hi) { + val mid = (lo + hi) >>> 1 + if (cp < r(3 * mid)) hi = mid - 1 + else if (cp > r(3 * mid + 1)) lo = mid + 1 + else return r(3 * mid + 2) + } + 0 + } + + private def isLetterBase(cls: Int): Boolean = + cls == ClassALetterCased || cls == ClassALetter || cls == ClassSuppCased || + cls == ClassSuppLetter + + private def isDigitBase(cls: Int): Boolean = + cls == ClassNumeric || cls == ClassNumericCased + + /** First position at/beyond `start` (step -1/+1) that isn't ClassExtend; -1 off the array. */ + private def skipExtends(cps: Array[Int], start: Int, step: Int): Int = { + var k = start + while (k >= 0 && k < cps.length && sigmaClassOf(cps(k)) == ClassExtend) k += step + if (k < 0 || k >= cps.length) -1 else k + } + + /** + * As [[skipExtends]] but also skips ClassExtendCased, reporting whether one was walked: a cased + * mark (U+0345) crossed while looking for a base is itself cased whenever the landing validates + * the run. + */ + private def skipExtendsTrackingCased(cps: Array[Int], start: Int, step: Int): (Int, Boolean) = { + var k = start + var sawCased = false + while (k >= 0 && k < cps.length && { + val c = sigmaClassOf(cps(k)) + c == ClassExtend || c == ClassExtendCased + }) { + if (sigmaClassOf(cps(k)) == ClassExtendCased) sawCased = true + k += step + } + (if (k < 0 || k >= cps.length) -1 else k, sawCased) + } + + private val AnchorNone = 0 + private val AnchorLetter = 1 + private val AnchorDigit = 2 + + /** + * What the supplementary-mark run at `k` (ClassSuppMn) ultimately hangs off, walking down + * through further marks and supplementary chars: a letter-flavored base, a digit-flavored base, + * or nothing word-forming. A cased mark riding the run belongs to the sigma's word only per + * this anchor. + */ + private def suppMnAnchor(cps: Array[Int], k: Int): Int = { + var m = k - 1 + while (m >= 0 && { + val c = sigmaClassOf(cps(m)) + c == ClassSuppMn || c == ClassExtend || c == ClassExtendCased + }) { + m -= 1 + } + if (m < 0) return AnchorNone + val a = sigmaClassOf(cps(m)) + if (a == ClassALetterCased || a == ClassALetter || a == ClassSuppCased || + a == ClassSuppLetter) { + AnchorLetter + } else if (a == ClassNumeric || a == ClassNumericCased || a == ClassSuppNum) { + AnchorDigit + } else { + AnchorNone + } + } + + private def scanBackFindsCased(cps: Array[Int], i: Int, leadingFormat: Boolean): Boolean = { + var lastLetter = true // the sigma itself is a letter + var j = i - 1 + while (j >= 0) { + sigmaClassOf(cps(j)) match { + case ClassALetterCased | ClassNumericCased => return true + case ClassALetter => lastLetter = true; j -= 1 + case ClassNumeric => lastLetter = false; j -= 1 + case ClassExtend => + // Non-cased marks attach only to a real base below them; anything else (mid + // punctuation, danda, boundary, text start) leaves the run unattached. + val k = skipExtends(cps, j, -1) + if (k < 0) return false + val b = sigmaClassOf(cps(k)) + val isContinuer = b == ClassALetterCased || b == ClassNumericCased || + b == ClassNumeric || b == ClassExtendCased || b == ClassALetter || + b == ClassSuppCased || b == ClassSuppLetter || b == ClassSuppNum + if (!isContinuer) return false + j = k + case ClassSuppCased => + // Closes the preceding word, so the scan stops -- except at RAW text start (no + // filtered-out leading format chars), where the DFA keeps it joined to what + // follows. + return j == 0 && !leadingFormat + case ClassSuppLetter | ClassSuppMn | ClassSuppNum => + // Attach/close and never themselves cased; nothing beyond is reachable. + return false + case ClassExtendCased => + // Cased combining mark (U+0345): cased when its run hangs off a base -- a BMP + // letter/digit, a word-forming supplementary char (which closes a word right + // below the mark, merging the mark into the sigma's segment), or an ANCHORED + // supplementary mark. + val (k, _) = skipExtendsTrackingCased(cps, j - 1, -1) + if (k < 0) return false + val b = sigmaClassOf(cps(k)) + if (b == ClassALetterCased || b == ClassNumeric || b == ClassNumericCased || + b == ClassALetter || b == ClassSuppCased || b == ClassSuppLetter || + b == ClassSuppNum) { + return true + } + if (b == ClassSuppMn) return suppMnAnchor(cps, k) != AnchorNone + return false + case ClassDanda => + // Backward across a danda: the word part before it must end in letters (grammar: + // letters, optional danda, then number+word chains) -- or carry a riding cased + // mark on a word-forming base, or be a cased supplementary char at text start -- + // and the danda itself chains only into digits after it. + if (lastLetter) return false + val (k, sawCasedMark) = skipExtendsTrackingCased(cps, j - 1, -1) + if (k < 0) return false + val b = sigmaClassOf(cps(k)) + if (b == ClassALetterCased) return true + if (b == ClassSuppCased) return sawCasedMark || (k == 0 && !leadingFormat) + if (b == ClassSuppLetter) return sawCasedMark + if (b == ClassSuppMn) { + return sawCasedMark && suppMnAnchor(cps, k) == AnchorLetter + } + if (b != ClassALetter) return false + if (sawCasedMark) return true + lastLetter = true + j = k + case cls @ (ClassMidLetter | ClassMidNum | ClassMidNumLet) => + // `` / `` require a genuine letter/digit base + // before the punctuation; scanning backward legitimately walks marks-then-base + // (marks trail their base). A cased mark walked over rides whatever the + // punctuation hangs off, including a context-matching anchored supplementary + // mark or supplementary digit. + val mwOk = cls == ClassMidLetter || cls == ClassMidNumLet + val mnOk = cls == ClassMidNum || cls == ClassMidNumLet + val (realPos, sawCasedMark) = skipExtendsTrackingCased(cps, j - 1, -1) + if (realPos < 0) return false + val b = sigmaClassOf(cps(realPos)) + if (lastLetter && mwOk && sawCasedMark && b == ClassSuppMn && + suppMnAnchor(cps, realPos) == AnchorLetter) { + return true + } + if (!lastLetter && mnOk && sawCasedMark && + (b == ClassSuppNum || + (b == ClassSuppMn && suppMnAnchor(cps, realPos) == AnchorDigit))) { + return true + } + val bridgeValid = (lastLetter && mwOk && isLetterBase(b)) || + (!lastLetter && mnOk && isDigitBase(b)) + if (!bridgeValid) return false + if (sawCasedMark) return true + j = realPos + case _ => return false + } + } + false + } + + private def scanFwdFindsCased(cps: Array[Int], i: Int): Boolean = { + var lastLetter = true + var j = i + 1 + while (j < cps.length) { + sigmaClassOf(cps(j)) match { + case ClassALetterCased | ClassNumericCased => return true + case ClassALetter => lastLetter = true; j += 1 + case ClassNumeric => lastLetter = false; j += 1 + case ClassExtend => + // A mark run trailing the anchor is properly attached in text order, so the run + // stays open past it, including onto mid punctuation on its far side. + val k = skipExtends(cps, j, 1) + if (k < 0) return false + val b = sigmaClassOf(cps(k)) + val isContinuer = b == ClassALetterCased || b == ClassNumericCased || + b == ClassNumeric || b == ClassExtendCased || b == ClassALetter || + b == ClassSuppCased || b == ClassSuppLetter || b == ClassSuppMn || + b == ClassSuppNum || b == ClassDanda || b == ClassMidLetter || + b == ClassMidNum || b == ClassMidNumLet + if (!isContinuer) return false + j = k + case ClassSuppCased | ClassExtendCased => + // Attaches to the current word, so the scan sees it (cased). + return true + case ClassSuppLetter | ClassSuppMn | ClassSuppNum => + // Attach to the current word and close it; never themselves cased, and nothing + // beyond is reachable. + return false + case ClassDanda => + // The danda attaches only to a word part that ends in letters (reached after + // digits the word is already closed) and continues only into a digit -- unless + // that digit is itself cased (a Roman numeral), which resolves the scan. + if (!lastLetter) return false + if (j + 1 < cps.length && sigmaClassOf(cps(j + 1)) == ClassNumericCased) { + return true + } else if (j + 1 < cps.length && sigmaClassOf(cps(j + 1)) == ClassNumeric) { + lastLetter = false + j += 2 + } else { + return false + } + case cls @ (ClassMidLetter | ClassMidNum | ClassMidNumLet) => + // `` / `` require a genuine letter/digit base + // IMMEDIATELY after the punctuation -- unlike the backward scan, marks here are + // never skipped past: a mark directly after the punctuation is attached to the + // punctuation, not a base, so it blocks the bridge. (Format chars are already + // filtered out, which is what lets "A-b" bridge exactly like + // "A-b".) + val mwOk = cls == ClassMidLetter || cls == ClassMidNumLet + val mnOk = cls == ClassMidNum || cls == ClassMidNumLet + if (j + 1 >= cps.length) return false + val b = sigmaClassOf(cps(j + 1)) + if ((lastLetter && mwOk && isLetterBase(b)) || + (!lastLetter && mnOk && isDigitBase(b))) { + j += 1 + } else { + return false + } + case _ => return false + } + } + false + } + + private lazy val lowerMap: java.util.HashMap[Integer, String] = { + val m = new java.util.HashMap[Integer, String](generated.lowerCps.length * 2) + var i = 0 + while (i < generated.lowerCps.length) { + m.put(generated.lowerCps(i), generated.lowerRepls(i)) + i += 1 + } + m + } + + /** + * Lowercase `s` exactly as the native matcher will, from the generated tables: per codepoint, + * U+03A3 takes its contextual final/non-final form via the ported `isFinalCased` scan over the + * format-filtered sequence; every other codepoint takes its table replacement (or itself). + */ + private[comet] def mirrorLowercase(s: String): String = { + val raw = s.codePoints().toArray + var filtered: Array[Int] = null + var filteredIdx: Array[Int] = null + val sb = new java.lang.StringBuilder(s.length) + var i = 0 + while (i < raw.length) { + val cp = raw(i) + if (cp == 0x03a3) { + if (filtered == null) { + val buf = new Array[Int](raw.length) + filteredIdx = new Array[Int](raw.length) + var n = 0 + var k = 0 + while (k < raw.length) { + filteredIdx(k) = n + if (sigmaClassOf(raw(k)) != ClassFormat) { + buf(n) = raw(k) + n += 1 + } + k += 1 + } + filtered = java.util.Arrays.copyOf(buf, n) + } + val fi = filteredIdx(i) + val leadingFormat = sigmaClassOf(raw(0)) == ClassFormat + val isFinal = scanBackFindsCased(filtered, fi, leadingFormat) && + !scanFwdFindsCased(filtered, fi) + sb.append((if (isFinal) 0x03c2 else 0x03c3).toChar) + } else { + val repl = lowerMap.get(cp) + if (repl != null) sb.append(repl) else sb.appendCodePoint(cp) + } + i += 1 + } + sb.toString + } +} diff --git a/spark/src/main/scala/org/apache/comet/serde/operator/package.scala b/spark/src/main/scala/org/apache/comet/serde/operator/package.scala index cf6e3fabe8d..bee7f61a128 100644 --- a/spark/src/main/scala/org/apache/comet/serde/operator/package.scala +++ b/spark/src/main/scala/org/apache/comet/serde/operator/package.scala @@ -106,7 +106,9 @@ package object operator { // In `CometScanRule`, we have already checked that all partition and metadata column values // are supported. So, we can safely use `get` here. - private def literalToProto(literal: Literal, description: String): ExprOuterClass.Expr = { + private[comet] def literalToProto( + literal: Literal, + description: String): ExprOuterClass.Expr = { val valueProto = exprToProto(literal, Seq.empty) assert(valueProto.isDefined, s"Unsupported $description") valueProto.get diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/CometExecRDD.scala b/spark/src/main/scala/org/apache/spark/sql/comet/CometExecRDD.scala index d9e0bf3a4c9..d334e432fb0 100644 --- a/spark/src/main/scala/org/apache/spark/sql/comet/CometExecRDD.scala +++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometExecRDD.scala @@ -67,7 +67,14 @@ private[spark] class CometExecRDD( broadcastedHadoopConfForEncryption: Option[Broadcast[SerializableConfiguration]] = None, encryptedFilePaths: Seq[String] = Seq.empty, shuffleScanIndices: Set[Int] = Set.empty, - @transient perPartitionFilePaths: Array[Seq[String]] = Array.empty) + @transient perPartitionFilePaths: Array[Seq[String]] = Array.empty, + // Set by leaf scans (e.g. `CometNativeScanExec`, the Delta contrib's + // `CometDeltaNativeScanExec`) that build this RDD directly, bypassing + // `CometNativeExec.executeColumnarWithContext`'s own `ctx.hasScanInput` check. Centralizing + // the registration here means every bare-RDD leaf-scan `doExecuteColumnar` override gets the + // same task-input-metrics reporting by passing this flag instead of hand-writing an + // anonymous `compute` override -- future contrib scans inherit it for free. + reportScanInputMetrics: Boolean = false) extends RDD[ColumnarBatch](sc, inputRDDs.map(rdd => new OneToOneDependency(rdd))) { // Determine partition count: from inputs if available, otherwise from parameter @@ -144,6 +151,10 @@ private[spark] class CometExecRDD( } } + if (reportScanInputMetrics) { + Option(context).foreach(nativeMetrics.reportScanInputMetrics) + } + it } @@ -225,7 +236,8 @@ object CometExecRDD { broadcastedHadoopConfForEncryption: Option[Broadcast[SerializableConfiguration]] = None, encryptedFilePaths: Seq[String] = Seq.empty, shuffleScanIndices: Set[Int] = Set.empty, - perPartitionFilePaths: Array[Seq[String]] = Array.empty): CometExecRDD = { + perPartitionFilePaths: Array[Seq[String]] = Array.empty, + reportScanInputMetrics: Boolean = false): CometExecRDD = { // scalastyle:on new CometExecRDD( @@ -241,6 +253,7 @@ object CometExecRDD { broadcastedHadoopConfForEncryption, encryptedFilePaths, shuffleScanIndices, - perPartitionFilePaths) + perPartitionFilePaths, + reportScanInputMetrics) } } diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala index fbebc420046..8c0bf5692cc 100644 --- a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala +++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala @@ -19,7 +19,6 @@ package org.apache.spark.sql.comet -import org.apache.spark.{Partition, TaskContext} import org.apache.spark.rdd.RDD import org.apache.spark.sql.SparkSession import org.apache.spark.sql.catalyst._ @@ -274,16 +273,8 @@ case class CometNativeScanExec( Seq.empty, broadcastedHadoopConfForEncryption, encryptedFilePaths, - perPartitionFilePaths = perPartitionFilePaths) { - override def compute(split: Partition, context: TaskContext): Iterator[ColumnarBatch] = { - val res = super.compute(split, context) - - // Report scan input metrics after the iterator is fully consumed. - Option(context).foreach(nativeMetrics.reportScanInputMetrics) - - res - } - } + perPartitionFilePaths = perPartitionFilePaths, + reportScanInputMetrics = true) } override def doCanonicalize(): CometNativeScanExec = { diff --git a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala index 3700e97642b..515088b6021 100644 --- a/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala +++ b/spark/src/main/scala/org/apache/spark/sql/comet/operators.scala @@ -25,7 +25,6 @@ import scala.collection.mutable import scala.collection.mutable.ArrayBuffer import scala.jdk.CollectionConverters._ -import org.apache.spark.{Partition, TaskContext} import org.apache.spark.broadcast.Broadcast import org.apache.spark.internal.Logging import org.apache.spark.rdd.RDD @@ -624,15 +623,8 @@ abstract class CometNativeExec extends CometExec { ctx.subqueries, ctx.broadcastedHadoopConfForEncryption, ctx.encryptedFilePaths, - ctx.shuffleScanIndices) { - override def compute(split: Partition, context: TaskContext): Iterator[ColumnarBatch] = { - val res = super.compute(split, context) - if (ctx.hasScanInput) { - Option(context).foreach(nativeMetrics.reportScanInputMetrics) - } - res - } - } + ctx.shuffleScanIndices, + reportScanInputMetrics = ctx.hasScanInput) } /** @@ -825,7 +817,18 @@ abstract class CometNativeExec extends CometExec { commonByKey = commonByKey, perPartitionByKey = perPartitionByKey, shuffleScanIndices = shuffleScanIndices, - hasScanInput = sparkPlans.exists(_.isInstanceOf[CometNativeScanExec])) + // Widened from the single concrete `CometNativeScanExec` type to the same + // `CometLeafExec with CometScanWithPlanData` shape `findAllPlanData` (above) and + // `foreachUntilCometInput` already use to recognise contrib leaf scans (e.g. the Delta + // contrib's `CometDeltaNativeScanExec`) generically. `hasScanInput` gates both this + // context's own `reportScanInputMetrics` registration and `CometNativeShuffleWriter`'s + // (which consumes the same `NativeExecContext`), so without this widening a contrib scan + // fused into a larger native subtree -- or embedded in a native-shuffle writer plan -- + // never gets its SQL scan metrics copied into the Spark task's input counters. + hasScanInput = sparkPlans.exists { + case _: CometLeafExec with CometScanWithPlanData => true + case _ => false + }) } /** diff --git a/spark/src/test/scala/org/apache/comet/CometS3TestBase.scala b/spark/src/test/scala/org/apache/comet/CometS3TestBase.scala index f8cc9f4e429..1c4225d03b3 100644 --- a/spark/src/test/scala/org/apache/comet/CometS3TestBase.scala +++ b/spark/src/test/scala/org/apache/comet/CometS3TestBase.scala @@ -32,6 +32,7 @@ import org.apache.spark.sql.CometTestBase import org.apache.comet.CometSparkSessionExtensions.isSpark42Plus import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider} +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.{CreateBucketRequest, HeadBucketRequest} @@ -66,6 +67,9 @@ trait CometS3TestBase extends CometTestBase { conf.set("spark.hadoop.fs.s3a.secret.key", password) conf.set("spark.hadoop.fs.s3a.endpoint", minioContainer.getS3URL) conf.set("spark.hadoop.fs.s3a.path.style.access", "true") + // Pin the region explicitly rather than relying on Hadoop-version-dependent region + // resolution; MinIO ignores the value. Native maps this the same way (see s3.rs). + conf.set("spark.hadoop.fs.s3a.endpoint.region", "us-east-1") } // Spark 4.2 has no published Iceberg spark-runtime yet; the build reuses the 4.0 runtime, whose @@ -99,6 +103,7 @@ trait CometS3TestBase extends CometTestBase { .builder() .endpointOverride(URI.create(minioContainer.getS3URL)) .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .region(Region.US_EAST_1) .forcePathStyle(true) .build() try { diff --git a/spark/src/test/scala/org/apache/comet/objectstore/NativeConfigSuite.scala b/spark/src/test/scala/org/apache/comet/objectstore/NativeConfigSuite.scala index b49b958f588..c69e6ec1e1f 100644 --- a/spark/src/test/scala/org/apache/comet/objectstore/NativeConfigSuite.scala +++ b/spark/src/test/scala/org/apache/comet/objectstore/NativeConfigSuite.scala @@ -108,4 +108,33 @@ class NativeConfigSuite extends AnyFunSuite with Matchers { s"oauth provider type should be forwarded for $path") } } + + test("extractObjectStoreOptions - forwards the substituted value of a ${...} reference") { + // Hadoop's own consumers read values through Configuration#get, which expands a ${...} + // reference against another conf entry. Forwarding the raw, unexpanded literal here would + // give native a different credential than every Hadoop-side consumer sees. + val hadoopConf = new Configuration() + hadoopConf.set("my.custom.access.key", "expanded-access-key") + hadoopConf.set("fs.s3a.access.key", "${my.custom.access.key}") + + val options = + NativeConfig.extractObjectStoreOptions(hadoopConf, new URI("s3a://test-bucket/test-object")) + assert(options("fs.s3a.access.key") == "expanded-access-key") + } + + test( + "extractObjectStoreOptions - a cyclic ${...} reference falls back to the raw value " + + "instead of throwing") { + // Configuration#get raises IllegalStateException once ${...} expansion recurses past + // Hadoop's MAX_SUBST bound; a two-key mutual cycle triggers this on every call. Extraction + // must still return a full options map rather than aborting for the whole object store. + val hadoopConf = new Configuration() + hadoopConf.set("fs.s3a.access.key", "${fs.s3a.secret.key}") + hadoopConf.set("fs.s3a.secret.key", "${fs.s3a.access.key}") + + val options = + NativeConfig.extractObjectStoreOptions(hadoopConf, new URI("s3a://test-bucket/test-object")) + assert(options("fs.s3a.access.key") == "${fs.s3a.secret.key}") + assert(options("fs.s3a.secret.key") == "${fs.s3a.access.key}") + } } diff --git a/spark/src/test/scala/org/apache/comet/rules/CometScanContribSuite.scala b/spark/src/test/scala/org/apache/comet/rules/CometScanContribSuite.scala index c0f3c0a6ce4..fb3476d0fd5 100644 --- a/spark/src/test/scala/org/apache/comet/rules/CometScanContribSuite.scala +++ b/spark/src/test/scala/org/apache/comet/rules/CometScanContribSuite.scala @@ -25,10 +25,14 @@ import java.nio.charset.StandardCharsets import java.nio.file.Files import java.util.ServiceLoader +import scala.collection.mutable.ArrayBuffer import scala.jdk.CollectionConverters._ import org.scalatest.funsuite.AnyFunSuite +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.core.LogEvent +import org.apache.logging.log4j.core.appender.AbstractAppender import org.apache.spark.rdd.RDD import org.apache.spark.sql.SparkSession import org.apache.spark.sql.catalyst.InternalRow @@ -244,10 +248,63 @@ class CometScanContribSuite extends AnyFunSuite { } test("a fatal error from a contrib is not swallowed") { - // NonFatal deliberately lets LinkageError/OOM-class failures through: those signal a broken - // JVM or a mis-built jar, not a scan this contrib cannot plan. + // OutOfMemoryError is neither NonFatal nor a LinkageError: it signals real JVM-level + // exhaustion, not a version-skewed contrib jar, and must always propagate uncontained. val contribs = Seq(new FatalScanContrib) - intercept[LinkageError](offerV1(contribs)) + intercept[OutOfMemoryError](offerV1(contribs)) + } + + test( + "a LinkageError from a contrib is contained, logged by name, and the next contrib still " + + "gets a look") { + // A version-skewed contrib jar (compiled against a Comet internal that has since moved or + // been removed) throws NoSuchMethodError/NoClassDefFoundError -- a LinkageError, which + // NonFatal does not match. It must be contained the same way a NonFatal decline is: logged, + // treated as "does not claim this scan", and the next contrib still consulted. + val contribs = Seq(new VersionSkewedScanContrib, new ClaimingScanContrib) + val events = withCapturedLogEvents(classOf[CometScanContrib].getName) { + assert(offerV1(contribs).contains(ContribStubs.ClaimedByV1)) + assert(offerV2(contribs).contains(ContribStubs.ClaimedByV2)) + } + val messages = events.map(_.getMessage.getFormattedMessage) + assert( + messages.count(m => + m.contains(classOf[VersionSkewedScanContrib].getName) && + m.contains(classOf[NoSuchMethodError].getName)) == 2, + "expected one warning per hook naming both the contrib class and the LinkageError " + + s"subtype, got: $messages") + } + + test("a LinkageError with nothing behind it declines rather than failing the query") { + val contribs = Seq(new VersionSkewedScanContrib) + assert(offerV1(contribs).isEmpty, "the scan must fall through to Comet's built-in handling") + assert(offerV2(contribs).isEmpty) + } + + /** + * Attaches a minimal Log4j2 appender directly to the logger named `loggerName` for the duration + * of `f`, returning every event it captured. `CometScanContrib`'s `logWarning` calls go through + * Spark's `Logging` trait to a logger named after the emitting class, so this lets a test + * assert a specific warning was actually emitted -- not merely that the surrounding code path + * didn't throw. Restores the logger's prior appenders/level afterward so this cannot leak into + * other tests in the same JVM. + */ + private def withCapturedLogEvents(loggerName: String)(f: => Unit): Seq[LogEvent] = { + val logger = + LogManager.getLogger(loggerName).asInstanceOf[org.apache.logging.log4j.core.Logger] + val appender = new CapturingAppender(s"CometScanContribSuite-${System.nanoTime()}") + appender.start() + val originalLevel = logger.getLevel + logger.addAppender(appender) + logger.setLevel(org.apache.logging.log4j.Level.WARN) + try { + f + appender.events.toSeq + } finally { + logger.removeAppender(appender) + logger.setLevel(originalLevel) + appender.stop() + } } /** @@ -354,12 +411,44 @@ class ThrowingScanContrib extends CometScanContrib { throw new IllegalStateException("contrib blew up while planning a V2 scan") } -/** Fails in a way that must NOT be caught. */ +/** Fails in a way that must NOT be caught: neither `NonFatal` nor a `LinkageError`. */ class FatalScanContrib extends CometScanContrib { override def tryTransformV1( plan: SparkPlan, session: SparkSession, scanExec: FileSourceScanExec, relation: HadoopFsRelation): Option[SparkPlan] = - throw new NoClassDefFoundError("mis-built contrib jar") + throw new OutOfMemoryError("simulated JVM-level exhaustion, not a version-skewed contrib jar") +} + +/** + * Simulates a contrib jar built against a Comet internal (a method signature, a class) that has + * since moved, been renamed, or been removed -- the exact failure mode a stale `--jars` contrib + * hits against a newer Comet on the driver's classpath. Must be contained the same way a + * `NonFatal` decline is, unlike [[FatalScanContrib]]'s genuinely fatal error. + */ +class VersionSkewedScanContrib extends CometScanContrib { + override def tryTransformV1( + plan: SparkPlan, + session: SparkSession, + scanExec: FileSourceScanExec, + relation: HadoopFsRelation): Option[SparkPlan] = + throw new NoSuchMethodError( + "org.apache.comet.rules.CometScanContribSuite$InternalApi.movedMethod()V") + + override def tryTransformV2(scanExec: BatchScanExec): Option[SparkPlan] = + throw new NoSuchMethodError( + "org.apache.comet.rules.CometScanContribSuite$InternalApi.movedMethod()V") +} + +/** + * Minimal Log4j2 appender that records every event it receives, verbatim, for + * [[CometScanContribSuite.withCapturedLogEvents]] to inspect after the fact. + */ +private class CapturingAppender(name: String) extends AbstractAppender(name, null, null, false) { + val events: ArrayBuffer[LogEvent] = ArrayBuffer.empty + + override def append(event: LogEvent): Unit = events.synchronized { + events += event.toImmutable + } }