sunchao commented on code in PR #5365:
URL: https://github.com/apache/datafusion-comet/pull/5365#discussion_r3818688990


##########
native/core/src/execution/planner/delta_spark_scan.rs:
##########
@@ -0,0 +1,145 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! 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 datafusion_comet_proto::spark_operator::{
+    ContribScan, DeltaSparkScan, Operator, SparkFilePartition,
+};
+use prost::Message;
+
+use crate::execution::operators::ExecutionError::GeneralError;
+use crate::execution::planner::PhysicalPlanner;
+use crate::execution::planner::PlanCreationResult;
+
+/// 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<PlanCreationResult> {
+    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::<Result<Vec<_>, _>>()?,
+    };
+
+    let (object_store_url, mut files) =
+        planner.prepare_scan_store_and_files(common, &spark_partition)?;

Review Comment:
   **[P1] Preserve the object-store authority of every Delta data file**
   
   Could we decline mixed-store scans before claiming them, or preserve each 
file's actual store identity? `prepare_scan_store_and_files` chooses the first 
file's store, while `get_partitioned_files` reduces every URL to its path. A 
shallow clone from bucket A into bucket B followed by an append is a valid 
Delta table containing absolute source files and new target files. The 
size-based file packing can put both in one task, which then requests B's key 
from A. That normally produces `NoSuchKey`, but it can read the wrong data if 
the same key exists in A. This assumption was already present in the generic 
helper, but the new Delta reader makes it reachable through ordinary 
shallow-clone behavior. Please cover a cross-bucket clone plus append and 
preserve Spark's file order if the scan is split internally.



##########
contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala:
##########
@@ -0,0 +1,450 @@
+/*
+ * 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.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 
surviving rows are
+ *     by construction not deleted: 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 the native side 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.
+  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 all positional 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 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. createPhysicalSchema 
also stamps
+      // parquet.field.id metadata, but files written before the 
column-mapping upgrade have
+      // no field ids and would fail the reader's id expectations, strip the 
ids so the
+      // reader stays purely name-based (id mode, when enabled, will keep 
them).
+      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.
+   */
+  def convert(scanExec: FileSourceScanExec, scanHelper: CometScanExec): 
Option[Operator] = {
+    val relation = scanExec.relation
+
+    val firstFileUri = scanHelper.selectedPartitions
+      .flatMap(_.files.headOption)
+      .headOption
+      .map(_.getPath.toUri)
+
+    val hadoopConf = relation.sparkSession.sessionState
+      .newHadoopConfWithOptions(relation.options)
+
+    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),

Review Comment:
   **[P2] Restore logical nested names in the scan output**
   
   Could we separate the physical read schema from the logical output schema 
and restore nested field names before parent expressions run? `toPhysical` 
rewrites nested names as well as top-level names. The shared native builder 
returns that physical schema directly, and the native `ToJson` implementation 
takes JSON keys from the actual Arrow struct fields. For a name-mapped Delta 
column `s STRUCT<a: BIGINT>`, enabling 
`spark.comet.expression.StructsToJson.allowIncompatible=true` makes `SELECT 
to_json(s)` reach this path. Spark returns `{"a":7}`, but the native expression 
receives the physical `col-...` name. A stock Delta 4 probe confirms the 
logical and physical schemas differ this way. Please cover a mapped nested 
struct with a native expression that observes field names, rather than only 
positional field access.



##########
native/core/src/execution/delta_dv.rs:
##########
@@ -0,0 +1,587 @@
+// 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::collections::HashMap;
+use std::sync::Arc;
+
+use datafusion::datasource::listing::PartitionedFile;
+use 
datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata;
+use datafusion::datasource::physical_plan::parquet::ParquetAccessPlan;
+use datafusion::execution::runtime_env::RuntimeEnv;
+use futures::{StreamExt, TryStreamExt};
+use object_store::ObjectStoreExt;
+use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
+use parquet::file::metadata::PageIndexPolicy;
+use roaring::{RoaringBitmap, RoaringTreemap};
+
+use crate::execution::operators::ExecutionError;
+use crate::execution::operators::ExecutionError::GeneralError;
+use crate::parquet::parquet_support::prepare_object_store_with_configs;
+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<RoaringTreemap, 
ExecutionError> {
+    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<ParquetAccessPlan, ExecutionError> {
+    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() {
+        let num_rows = num_rows as u64;
+        let group_end = group_start + num_rows;
+        let mut selectors: Vec<RowSelector> = 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)
+}
+
+/// 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`).
+pub struct DvScanFile {
+    pub file: PartitionedFile,
+    /// Full URL of the data file (proto `file_path`).
+    pub file_path: String,
+    pub dv: Option<DeltaSparkDvDescriptor>,
+}
+
+/// 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.
+pub async fn attach_access_plans(
+    runtime_env: Arc<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    files: Vec<DvScanFile>,
+) -> Result<Vec<PartitionedFile>, ExecutionError> {
+    futures::stream::iter(files)
+        .map(|scan_file| {
+            attach_access_plan(Arc::clone(&runtime_env), object_store_options, 
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<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    scan_file: DvScanFile,
+) -> Result<PartitionedFile, ExecutionError> {
+    let DvScanFile {
+        file,
+        file_path,
+        dv,
+    } = scan_file;
+    let dv = match dv {
+        Some(dv) => dv,
+        None => 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
+        )));
+    }
+
+    let data: Vec<u8> = 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 (dv_url, dv_store_path) = prepare_object_store_with_configs(
+            Arc::clone(&runtime_env),
+            dv_path.clone(),
+            object_store_options,
+        )?;
+        let store = runtime_env.object_store(&dv_url)?;
+        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}")))?;

Review Comment:
   **[P2] Validate the decoded DV cardinality**
   
   Could we compare `deleted.len()` with `dv.cardinality` before applying the 
bitmap? The descriptor carries the expected count, but the native reader never 
checks it. A valid-CRC bitmap containing only row 1, paired with a descriptor 
declaring two deletions, is accepted by the current decoder and access-plan 
builder and selects 9 of 10 rows. Delta's JVM reader rejects this mismatch in 
[`StoredBitmap.validateCardinality`](https://github.com/delta-io/delta/blob/v4.0.0/spark/src/main/scala/org/apache/spark/sql/delta/deletionvectors/StoredBitmap.scala#L70-L96).
 CRC and row-range validation do not catch a stale but otherwise well-formed 
bitmap. Please add a mismatched-cardinality case alongside the existing 
corruption checks.



##########
contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala:
##########
@@ -0,0 +1,450 @@
+/*
+ * 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.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 
surviving rows are
+ *     by construction not deleted: 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 the native side 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.
+  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 all positional 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 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. createPhysicalSchema 
also stamps
+      // parquet.field.id metadata, but files written before the 
column-mapping upgrade have
+      // no field ids and would fail the reader's id expectations, strip the 
ids so the
+      // reader stays purely name-based (id mode, when enabled, will keep 
them).
+      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.
+   */
+  def convert(scanExec: FileSourceScanExec, scanHelper: CometScanExec): 
Option[Operator] = {
+    val relation = scanExec.relation
+
+    val firstFileUri = scanHelper.selectedPartitions
+      .flatMap(_.files.headOption)
+      .headOption
+      .map(_.getPath.toUri)
+
+    val hadoopConf = relation.sparkSession.sessionState
+      .newHadoopConfWithOptions(relation.options)
+
+    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 = relation.partitionSchema,
+        fileConstantMetadataColumns = scanExec.fileConstantMetadataColumns,
+        dataFilters = scanHelper.supportedDataFilters,
+        firstFileUri = firstFileUri,
+        hadoopConf = hadoopConf,
+        conf = scanExec.conf)
+    } else {
+      buildDvScanCommon(scanExec, scanHelper, firstFileUri, hadoopConf)
+    }
+
+    commonOpt.map { commonBuilder =>
+      val common = commonBuilder.build()
+      val tableRoot = relation.location.rootPaths.head.toString
+      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()
+    }
+  }
+
+  /**
+   * 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 Spark
+   * version; the dedup keeps Spark 4.x from carrying duplicates.
+   */
+  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}
+    // Nearest FilterExec above the scan (the DV shape interposes nodes 
between them, so do
+    // not require a direct parent-child edge). Safety comes from the 
reference guard, not the
+    // plan walk: only conjuncts expressed directly over the scan's own output 
attributes
+    // survive, so filters from other branches or over aliased projections 
contribute nothing.
+    val filtersAboveScan = plan.collect {
+      case f: org.apache.spark.sql.execution.FilterExec if f.find(_ eq 
scanExec).isDefined => f
+    }
+    filtersAboveScan.lastOption
+      .map { f =>
+        splitConjunctivePredicates(f.condition)
+          .filter(_.references.subsetOf(scanExec.outputSet))

Review Comment:
   **[P1] Keep scalar-subquery pushdown above non-commuting operators**
   
   Could we restrict this walk to plan shapes where moving the predicate into 
the scan is safe? The reference check does not protect against `LIMIT` or TopN. 
With a Delta table `t` containing IDs 0, 1, and 2, `SELECT id FROM (SELECT id 
FROM t ORDER BY id LIMIT 1) q WHERE id > (SELECT max(id) FROM range(1))` must 
return no rows. A stock Spark 4.0.3 / Delta 4.0.0 probe produces `Filter -> 
TakeOrderedAndProject -> FileSourceScanExec`, with empty scan `dataFilters`, 
and this guard accepts the outer predicate. Moving `id > 0` below TopN returns 
row 1 instead. The execution-time path then installs that predicate in the 
native Parquet scan, so enabling row-filter pushdown can change the result. 
Please keep the covering filter and only harvest across operators for which 
pushdown is valid. This query would make a useful regression test.



##########
native/core/src/execution/delta_dv.rs:
##########
@@ -0,0 +1,587 @@
+// 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::collections::HashMap;
+use std::sync::Arc;
+
+use datafusion::datasource::listing::PartitionedFile;
+use 
datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata;
+use datafusion::datasource::physical_plan::parquet::ParquetAccessPlan;
+use datafusion::execution::runtime_env::RuntimeEnv;
+use futures::{StreamExt, TryStreamExt};
+use object_store::ObjectStoreExt;
+use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
+use parquet::file::metadata::PageIndexPolicy;
+use roaring::{RoaringBitmap, RoaringTreemap};
+
+use crate::execution::operators::ExecutionError;
+use crate::execution::operators::ExecutionError::GeneralError;
+use crate::parquet::parquet_support::prepare_object_store_with_configs;
+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<RoaringTreemap, 
ExecutionError> {
+    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<ParquetAccessPlan, ExecutionError> {
+    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() {
+        let num_rows = num_rows as u64;
+        let group_end = group_start + num_rows;
+        let mut selectors: Vec<RowSelector> = 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)
+}
+
+/// 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`).
+pub struct DvScanFile {
+    pub file: PartitionedFile,
+    /// Full URL of the data file (proto `file_path`).
+    pub file_path: String,
+    pub dv: Option<DeltaSparkDvDescriptor>,
+}
+
+/// 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.
+pub async fn attach_access_plans(
+    runtime_env: Arc<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    files: Vec<DvScanFile>,
+) -> Result<Vec<PartitionedFile>, ExecutionError> {
+    futures::stream::iter(files)
+        .map(|scan_file| {
+            attach_access_plan(Arc::clone(&runtime_env), object_store_options, 
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<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    scan_file: DvScanFile,
+) -> Result<PartitionedFile, ExecutionError> {
+    let DvScanFile {
+        file,
+        file_path,
+        dv,
+    } = scan_file;
+    let dv = match dv {
+        Some(dv) => dv,
+        None => 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
+        )));
+    }
+
+    let data: Vec<u8> = 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 (dv_url, dv_store_path) = prepare_object_store_with_configs(
+            Arc::clone(&runtime_env),
+            dv_path.clone(),
+            object_store_options,
+        )?;

Review Comment:
   **[P2] Avoid constructing a cold S3 store inside the DV runtime**
   
   Could we resolve the required stores before entering `attach_access_plans`, 
or make their initialization async-safe? The caller enters 
`get_runtime().block_on(...)`, but an uncached S3 sidecar reaches this 
synchronous helper and then `objectstore/s3.rs` calls 
`get_runtime().block_on(build_credential_provider(...))` again. Tokio rejects 
that nested `Handle::block_on` with a panic. A fresh executor reading a shallow 
clone whose data is in bucket A and whose new DV is in bucket B reaches a cold 
cache entry. Same-bucket tests hide the problem because the data store was 
created before the outer `block_on`. Explicit endpoint/region or static Hadoop 
credentials do not avoid the inner credential-provider call. Please add a test 
with distinct data-file and DV buckets.



##########
contrib/delta-spark/src/main/scala/org/apache/comet/contrib/delta/CometDeltaNativeScan.scala:
##########
@@ -0,0 +1,450 @@
+/*
+ * 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.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 
surviving rows are
+ *     by construction not deleted: 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 the native side 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.
+  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 all positional 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 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. createPhysicalSchema 
also stamps
+      // parquet.field.id metadata, but files written before the 
column-mapping upgrade have
+      // no field ids and would fail the reader's id expectations, strip the 
ids so the
+      // reader stays purely name-based (id mode, when enabled, will keep 
them).
+      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.
+   */
+  def convert(scanExec: FileSourceScanExec, scanHelper: CometScanExec): 
Option[Operator] = {
+    val relation = scanExec.relation
+
+    val firstFileUri = scanHelper.selectedPartitions
+      .flatMap(_.files.headOption)
+      .headOption
+      .map(_.getPath.toUri)

Review Comment:
   **[P2] Include configuration for external DV providers**
   
   Could we derive execution-time object-store options from every selected 
data-file and external-DV authority, or decline combinations that cannot be 
represented? The common configuration is extracted using only this first data 
file's scheme, while `extractDvDescriptor` permits an absolute DV URI on 
another provider. For an S3 data file with an ABFS sidecar, `NativeConfig` 
forwards `fs.s3a.*` but omits the Hadoop Azure account-key/OAuth settings. The 
native DV loader then opens the ABFS URI using that same S3-only map, so a 
table readable by Spark can fail authentication. This is separate from 
mixed-bucket data-file routing and occurs with a single data file. Same-scheme 
S3 bucket overrides are already forwarded. A cross-provider sidecar test would 
cover the missing case.



##########
native/core/src/execution/delta_dv.rs:
##########
@@ -0,0 +1,587 @@
+// 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::collections::HashMap;
+use std::sync::Arc;
+
+use datafusion::datasource::listing::PartitionedFile;
+use 
datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata;
+use datafusion::datasource::physical_plan::parquet::ParquetAccessPlan;
+use datafusion::execution::runtime_env::RuntimeEnv;
+use futures::{StreamExt, TryStreamExt};
+use object_store::ObjectStoreExt;
+use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
+use parquet::file::metadata::PageIndexPolicy;
+use roaring::{RoaringBitmap, RoaringTreemap};
+
+use crate::execution::operators::ExecutionError;
+use crate::execution::operators::ExecutionError::GeneralError;
+use crate::parquet::parquet_support::prepare_object_store_with_configs;
+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<RoaringTreemap, 
ExecutionError> {
+    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<ParquetAccessPlan, ExecutionError> {
+    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() {
+        let num_rows = num_rows as u64;
+        let group_end = group_start + num_rows;
+        let mut selectors: Vec<RowSelector> = 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)
+}
+
+/// 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`).
+pub struct DvScanFile {
+    pub file: PartitionedFile,
+    /// Full URL of the data file (proto `file_path`).
+    pub file_path: String,
+    pub dv: Option<DeltaSparkDvDescriptor>,
+}
+
+/// 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.
+pub async fn attach_access_plans(
+    runtime_env: Arc<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    files: Vec<DvScanFile>,
+) -> Result<Vec<PartitionedFile>, ExecutionError> {
+    futures::stream::iter(files)
+        .map(|scan_file| {
+            attach_access_plan(Arc::clone(&runtime_env), object_store_options, 
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<RuntimeEnv>,
+    object_store_options: &HashMap<String, String>,
+    scan_file: DvScanFile,
+) -> Result<PartitionedFile, ExecutionError> {
+    let DvScanFile {
+        file,
+        file_path,
+        dv,
+    } = scan_file;
+    let dv = match dv {
+        Some(dv) => dv,
+        None => return Ok(file),
+    };

Review Comment:
   **[P2] Treat an empty DV as a pass-through file**
   
   Could we handle the canonical zero-cardinality descriptor before attempting 
bitmap decoding? Delta's `DeletionVectorDescriptor.EMPTY` has inline storage, 
an empty payload, size 0, and cardinality 0. I checked a committed Delta 4 
table containing that exact descriptor: the actual Spark `FilePartition` 
carries it and the normal Delta reader returns all rows. Here it becomes 
`Some(empty_bytes)` and `deserialize_dv_bitmap` returns `Deletion vector bitmap 
too short for magic number`. Returning the unchanged file for the empty-DV case 
would match Spark. Please add this to the existing `attach_access_plans` test.



##########
native/core/src/execution/delta_dv.rs:
##########
@@ -0,0 +1,587 @@
+// 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::collections::HashMap;
+use std::sync::Arc;
+
+use datafusion::datasource::listing::PartitionedFile;
+use 
datafusion::datasource::physical_plan::parquet::metadata::DFParquetMetadata;
+use datafusion::datasource::physical_plan::parquet::ParquetAccessPlan;
+use datafusion::execution::runtime_env::RuntimeEnv;
+use futures::{StreamExt, TryStreamExt};
+use object_store::ObjectStoreExt;
+use parquet::arrow::arrow_reader::{RowSelection, RowSelector};
+use parquet::file::metadata::PageIndexPolicy;
+use roaring::{RoaringBitmap, RoaringTreemap};
+
+use crate::execution::operators::ExecutionError;
+use crate::execution::operators::ExecutionError::GeneralError;
+use crate::parquet::parquet_support::prepare_object_store_with_configs;
+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<RoaringTreemap, 
ExecutionError> {
+    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<ParquetAccessPlan, ExecutionError> {
+    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() {
+        let num_rows = num_rows as u64;
+        let group_end = group_start + num_rows;
+        let mut selectors: Vec<RowSelector> = 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)),

Review Comment:
   **[P1] Bound memory used by expanded DV row selections**
   
   Could we account for and bound these allocations, preferably creating the 
access plan when its file is opened? An alternating deleted/retained bitmap 
creates one non-coalescing `RowSelector` per row. A bounded probe using these 
unchanged functions and the exact Roaring 0.11.4 / Parquet 58.4.0 types 
expanded an 8,224-byte bitmap for 65,536 rows into 65,536 selectors, retaining 
1,048,600 bytes and peaking at 2,097,176 bytes. An 8-million-row group 
therefore needs roughly 128 MiB for the retained selectors alone. 
`attach_access_plans().buffered(8).try_collect()` bounds fetch concurrency, but 
retains all completed file plans before the scan starts. None of this 
allocation is reserved against the execution memory pool. A multi-file test 
with distinct alternating-row DVs would exercise the executor-OOM case without 
relying on malformed input.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to