This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new cea0caaf [core] Read sparse bitmap matches from data evolution ROW 
sidecars (#1173)
cea0caaf is described below

commit cea0caafd638f11203357a92c69d3629e880a848
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 20:17:25 2026 +0800

    [core] Read sparse bitmap matches from data evolution ROW sidecars (#1173)
---
 crates/paimon/src/table/data_evolution_reader.rs   | 152 +++++-
 crates/paimon/src/table/data_file_reader.rs        |  28 +-
 crates/paimon/src/table/row_sidecar.rs             |   5 +-
 .../tests/data_evolution_bitmap_merge_test.rs      | 577 +++++++++++++++++++++
 .../tests/data_evolution_row_sidecar_test.rs       | 145 +++++-
 .../tests/data_evolution_search_sidecar_test.rs    | 440 ++++++++++++++++
 .../tests/data_evolution_variant_sidecar_test.rs   | 457 ++++++++++++++++
 7 files changed, 1758 insertions(+), 46 deletions(-)

diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 77ac3f90..da0f950c 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -477,7 +477,10 @@ impl DataEvolutionReader {
             let push_down_raw_predicates = !self.predicates.is_empty()
                 && self.row_id_index.is_none()
                 && filter_before_blob_resolution;
-            let read_raw_file_index = push_down_raw_predicates
+            // A bitmap selection preserves physical positions, including when
+            // `_ROW_ID` needs attaching before the residual predicate runs.
+            let read_file_index = !self.predicates.is_empty()
+                && filter_before_blob_resolution
                 && 
CoreOptions::new(&self.table_options).file_index_read_enabled();
             // A managed BLOB file fetches payloads as its batch is decoded.
             // Keep batches small and restrict predicate-free file selections
@@ -508,7 +511,9 @@ impl DataEvolutionReader {
                 if remaining == Some(0) {
                     break;
                 }
-                let row_ranges = split.row_ranges().map(|r| r.to_vec());
+                let row_ranges = split
+                    .row_ranges()
+                    .map(|ranges| 
crate::table::merge_row_ranges(ranges.to_vec()));
                 // A chunk may span several disjoint row-id groups while one
                 // group still needs column-wise merging. Process each aligned
                 // group independently; treating the whole chunk as one merge
@@ -541,10 +546,8 @@ impl DataEvolutionReader {
                             let has_row_id = file_meta.first_row_id.is_some();
                             let requested_row_ranges = if has_row_id { 
row_ranges.clone() } else { None };
                             let mut effective_row_ranges = 
requested_row_ranges.clone();
-                            if read_raw_file_index {
-                                // Independent files can apply a physical row 
selection
-                                // before decoding. Column-merge groups 
cannot: an older
-                                // file may hold values still needed by a 
newer file.
+                            let mut has_bitmap_selection = false;
+                            if read_file_index {
                                 match evaluate_file_index(
                                     &self.file_io,
                                     split.bucket_path(),
@@ -560,6 +563,7 @@ impl DataEvolutionReader {
                                                 &selection,
                                                 file_meta.row_count,
                                             )? {
+                                            has_bitmap_selection = 
selection.len() < file_meta.row_count as u64;
                                             let base = 
file_meta.first_row_id.unwrap_or(0);
                                             let index_ranges = local_ranges
                                                 .into_iter()
@@ -578,6 +582,18 @@ impl DataEvolutionReader {
                                     FileIndexResult::Remain => {}
                                 }
                             }
+                            // Java applies DV to a selective bitmap before
+                            // choosing ROW. DV alone and LIMIT cannot trigger 
it.
+                            let sidecar_row_ranges = if has_bitmap_selection {
+                                selected_absolute_row_ranges_for_file(
+                                    file_meta.first_row_id.unwrap_or(0),
+                                    file_meta.row_count,
+                                    effective_row_ranges.as_deref(),
+                                    deletion_vector.as_deref(),
+                                )?
+                            } else {
+                                effective_row_ranges.clone()
+                            };
                             if self.predicates.is_empty() {
                                 if let Some(left) = remaining {
                                     let selected = 
selected_absolute_row_ranges_for_file(
@@ -628,7 +644,7 @@ impl DataEvolutionReader {
                                 data_fields,
                                 deletion_vector,
                                 effective_row_ranges,
-                                requested_row_ranges,
+                                sidecar_row_ranges,
                             )?;
                             while remaining != Some(0) {
                                 let Some(batch) = stream.next().await else { 
break };
@@ -659,6 +675,35 @@ impl DataEvolutionReader {
                         }
                     } else {
                         let prepared_group = PreparedMergeGroup::new(&files)?;
+                        let mut indexed_row_ranges = row_ranges.clone();
+                        let mut has_bitmap_selection = false;
+                        if read_file_index {
+                            match self.evaluate_group_file_indexes(&split, 
&prepared_group).await? {
+                                FileIndexResult::Skip => continue,
+                                FileIndexResult::Selection(selection) => {
+                                    if let Some(local_ranges) = 
file_index_selection_to_local_ranges(
+                                        &selection,
+                                        prepared_group.logical_row_count,
+                                    )? {
+                                        has_bitmap_selection = selection.len() 
< prepared_group.logical_row_count as u64;
+                                        let index_ranges = 
local_ranges.into_iter().map(|range| {
+                                            RowRange::new(
+                                                prepared_group.first_row_id + 
range.from(),
+                                                prepared_group.first_row_id + 
range.to(),
+                                            )
+                                        }).collect::<Vec<_>>();
+                                        indexed_row_ranges = Some(match 
indexed_row_ranges {
+                                            Some(ref ranges) => 
intersect_local_ranges(ranges, &index_ranges),
+                                            None => index_ranges,
+                                        });
+                                    }
+                                }
+                                FileIndexResult::Remain => {}
+                            }
+                        }
+                        if 
indexed_row_ranges.as_ref().is_some_and(Vec::is_empty) {
+                            continue;
+                        }
                         let anchor_deletion_vector = 
read_anchor_deletion_vector(
                             &self.file_io,
                             &split,
@@ -668,11 +713,19 @@ impl DataEvolutionReader {
                         let mut selected_ranges = 
selected_absolute_row_ranges_for_file(
                             prepared_group.first_row_id,
                             prepared_group.logical_row_count,
-                            row_ranges.as_deref(),
+                            indexed_row_ranges.as_deref(),
                             anchor_deletion_vector
                                 .as_ref()
                                 .map(|ctx| ctx.deletion_vector.as_ref()),
                         )?;
+                        // Every provider shares the DV-aware bitmap. Without
+                        // a selective bitmap, only caller ranges choose ROW.
+                        // LIMIT never changes sidecar eligibility.
+                        let sidecar_row_ranges = if has_bitmap_selection {
+                            selected_ranges.clone()
+                        } else {
+                            indexed_row_ranges.clone()
+                        };
                         let effective_row_ranges = if 
self.predicates.is_empty() {
                             if let Some(left) = remaining {
                                 selected_ranges = Some(prefix_row_ranges(
@@ -683,10 +736,10 @@ impl DataEvolutionReader {
                                 ));
                                 selected_ranges.clone()
                             } else {
-                                row_ranges.clone()
+                                indexed_row_ranges.clone()
                             }
                         } else {
-                            row_ranges.clone()
+                            indexed_row_ranges
                         };
                         let expected_output_rows = match 
selected_ranges.as_ref() {
                             Some(ranges) => ranges.iter().map(|r| r.count() as 
usize).sum(),
@@ -711,7 +764,7 @@ impl DataEvolutionReader {
                             &split,
                             &prepared_group,
                             effective_row_ranges,
-                            row_ranges.clone(),
+                            sidecar_row_ranges,
                             expected_output_rows,
                             anchor_deletion_vector,
                         )?;
@@ -944,6 +997,83 @@ impl DataEvolutionReader {
         .await
     }
 
+    /// Evaluate only the fields each normal file wins in the merged row.
+    ///
+    /// The group is newest first. A newer writer claims its fields even when
+    /// it has no index, so an obsolete value can never reject a live row.
+    async fn evaluate_group_file_indexes(
+        &self,
+        split: &DataSplit,
+        group: &PreparedMergeGroup,
+    ) -> crate::Result<FileIndexResult> {
+        // Dedicated sources may cover only part of the normal files' range.
+        // Like Java, they prevent a shared positional bitmap selection.
+        let can_select = group.files.iter().all(|file| {
+            !is_blob_or_video_file_name(&file.file_name)
+                && !is_vector_store_file_name(&file.file_name)
+        });
+        let mut claimed = HashSet::new();
+        let mut result = FileIndexResult::Remain;
+        for file in &group.files {
+            if is_blob_or_video_file_name(&file.file_name)
+                || is_vector_store_file_name(&file.file_name)
+            {
+                continue;
+            }
+            let (data_fields, _) = raw_file_physical_fields(
+                &self.schema_manager,
+                self.table_schema_id,
+                &self.table_fields,
+                file,
+            )
+            .await?;
+            let data_fields = 
data_fields.as_deref().unwrap_or(&self.table_fields);
+            let winning_ids = data_fields
+                .iter()
+                .filter_map(|field| 
claimed.insert(field.id()).then_some(field.id()))
+                .collect::<HashSet<_>>();
+            let mapping = self
+                .table_fields
+                .iter()
+                .enumerate()
+                .map(|(index, field)| {
+                    // Partition values come from the split. A partially 
written
+                    // ROW can have several providers for its parent nullness.
+                    (winning_ids.contains(&field.id())
+                        && !matches!(field.data_type(), DataType::Row(_))
+                        && !self.partition_fields.iter().any(|p| p.id() == 
field.id()))
+                    .then_some(index)
+                })
+                .collect::<Vec<_>>();
+            let predicates = self
+                .predicates
+                .iter()
+                .filter_map(|predicate| 
predicate.project_field_index_inclusive(&mapping))
+                .collect::<Vec<_>>();
+            let file_result = evaluate_file_index(
+                &self.file_io,
+                split.bucket_path(),
+                file,
+                &self.table_fields,
+                data_fields,
+                &predicates,
+            )
+            .await?;
+            if !file_result.remain() {
+                return Ok(FileIndexResult::Skip);
+            }
+            if can_select {
+                // Each file contributes a necessary condition, so intersect
+                // candidates rather than independently filtering its stream.
+                result = result.and(file_result);
+                if !result.remain() {
+                    return Ok(FileIndexResult::Skip);
+                }
+            }
+        }
+        Ok(result)
+    }
+
     /// Merge multiple logical sources column-wise for data evolution.
     ///
     /// Normal partial-column files remain one source per file. Rolling `.blob`
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 841e347e..4ea4c9bd 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -361,7 +361,7 @@ impl DataFileReader {
                         )
                     });
                     split_file_offset += file_meta.row_count;
-                    let sidecar_ranges = split_ranges.clone();
+                    let mut has_bitmap_selection = false;
                     let selected_ranges = match file_index_result {
                         FileIndexResult::Remain => split_ranges,
                         FileIndexResult::Skip => Some(Vec::new()),
@@ -370,12 +370,15 @@ impl DataFileReader {
                                 &selection,
                                 file_meta.row_count,
                             )? {
-                                Some(index_ranges) => Some(match split_ranges {
-                                    Some(split_ranges) => {
-                                        intersect_sorted_ranges(&index_ranges, 
&split_ranges)
-                                    }
-                                    None => index_ranges,
-                                }),
+                                Some(index_ranges) => {
+                                    has_bitmap_selection = selection.len() < 
file_meta.row_count as u64;
+                                    Some(match split_ranges {
+                                        Some(split_ranges) => {
+                                            
intersect_sorted_ranges(&index_ranges, &split_ranges)
+                                        }
+                                        None => index_ranges,
+                                    })
+                                }
                                 None => split_ranges,
                             }
                         }
@@ -385,6 +388,13 @@ impl DataFileReader {
                         dv.as_deref(),
                         selected_ranges.as_deref(),
                     );
+                    // Like Java, only a selective bitmap allows DV to narrow
+                    // sidecar eligibility. DV alone cannot trigger a ROW read.
+                    let sidecar_ranges = if has_bitmap_selection {
+                        row_selection.clone()
+                    } else {
+                        selected_ranges
+                    };
 
                     let mut stream = 
reader.read_single_file_stream_with_selection(
                         &split,
@@ -480,8 +490,8 @@ impl DataFileReader {
         )
     }
 
-    /// Physical selection may be narrowed by file indexes or a LIMIT, while
-    /// Java selects the ROW sidecar from the caller's original row ranges.
+    /// The caller supplies sidecar candidates separately from physical ranges:
+    /// a selective bitmap includes DV filtering, while LIMIT never affects it.
     pub(super) fn read_single_file_stream_with_requested_ranges(
         &self,
         split: &DataSplit,
diff --git a/crates/paimon/src/table/row_sidecar.rs 
b/crates/paimon/src/table/row_sidecar.rs
index 9e7dd87d..624ca6ef 100644
--- a/crates/paimon/src/table/row_sidecar.rs
+++ b/crates/paimon/src/table/row_sidecar.rs
@@ -213,8 +213,9 @@ fn selected_sidecar<'a>(
     {
         return Ok(None);
     }
-    // The caller clips, sorts and merges the requested ranges into local
-    // positions before intersecting them with indexes and deletion vectors.
+    // The caller intersects requested ranges with bitmap indexes in local
+    // positions, including DV only when a selective bitmap exists. LIMIT
+    // never affects this decision.
     let selected: i64 = ranges.iter().map(RowRange::count).sum();
     Ok((selected > 0
         && selected < file.row_count
diff --git a/crates/paimon/tests/data_evolution_bitmap_merge_test.rs 
b/crates/paimon/tests/data_evolution_bitmap_merge_test.rs
new file mode 100644
index 00000000..1118cddd
--- /dev/null
+++ b/crates/paimon/tests/data_evolution_bitmap_merge_test.rs
@@ -0,0 +1,577 @@
+// 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.
+
+mod common;
+
+use arrow_array::{Array, ArrayRef, BinaryArray, Int32Array, Int64Array, 
RecordBatch, StructArray};
+use arrow_buffer::NullBuffer;
+use arrow_schema::DataType as ArrowDataType;
+use arrow_select::concat::concat_batches;
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::arrow::variant_arrow_type;
+use paimon::spec::{
+    DataType, Datum, IntType, Predicate, PredicateBuilder, Schema, 
SchemaChange, TableSchema,
+    VariantType,
+};
+use paimon::table::{CommitMessage, RowRange, Table};
+use paimon::variant::GenericVariant;
+use std::sync::Arc;
+
+async fn table(name: &str) -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("a", DataType::Int(IntType::new()))
+        .column("b", DataType::Int(IntType::new()))
+        .column("payload", DataType::Variant(VariantType::new()))
+        .option("file.format", "parquet")
+        .option("row-tracking.enabled", "true")
+        .option("data-evolution.enabled", "true")
+        .option("deletion-vectors.enabled", "true")
+        .option("data-evolution.row-sidecar.enabled", "true")
+        .option("global-index.enabled", "false")
+        .option("file-index.bitmap.columns", "id,a,b")
+        .option("file-index.in-manifest-threshold", "1mb")
+        .build()
+        .unwrap();
+    let (io, table) = memory_table(
+        &format!("memory:/bitmap_merge_{name}"),
+        TableSchema::new(0, &schema),
+    );
+    setup_dirs(&io, table.location()).await;
+    persist_table_schema(&io, table.location(), table.schema()).await;
+    table
+}
+
+fn values(start: i32, count: i32, updated: bool) -> 
Vec<Option<GenericVariant>> {
+    (start..start + count)
+        .map(|id| {
+            let json = match id.rem_euclid(5) {
+                1 => return None,
+                2 => "null".to_string(),
+                3 => format!(r#""{}""#, if updated { "updated" } else { 
"scalar" }),
+                4 => format!("[{},null,true]", id + if updated { 1000 } else { 
0 }),
+                _ => format!(
+                    r#"{{"x":{},"nested":{{"items":[true,null,"中"]}}}}"#,
+                    id + if updated { 1000 } else { 0 }
+                ),
+            };
+            Some(GenericVariant::parse_json(&json).unwrap())
+        })
+        .collect()
+}
+
+fn variants(values: &[Option<GenericVariant>]) -> StructArray {
+    let ArrowDataType::Struct(fields) = variant_arrow_type() else {
+        unreachable!()
+    };
+    StructArray::new(
+        fields,
+        vec![
+            Arc::new(BinaryArray::from_iter_values(
+                values
+                    .iter()
+                    .map(|v| v.as_ref().map_or(&[][..], 
GenericVariant::value)),
+            )),
+            Arc::new(BinaryArray::from_iter_values(
+                values
+                    .iter()
+                    .map(|v| v.as_ref().map_or(&[][..], 
GenericVariant::metadata)),
+            )),
+        ],
+        Some(NullBuffer::from(
+            values.iter().map(Option::is_some).collect::<Vec<_>>(),
+        )),
+    )
+}
+
+async fn append(table: &Table, start: i32, count: i32) -> Vec<CommitMessage> {
+    let ids = (start..start + count).collect::<Vec<_>>();
+    let batch = RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(ids.clone())) as ArrayRef),
+        ("a", Arc::new(Int32Array::from(ids.clone())) as ArrayRef),
+        ("b", Arc::new(Int32Array::from(ids)) as ArrayRef),
+        (
+            "payload",
+            Arc::new(variants(&values(start, count, false))) as ArrayRef,
+        ),
+    ])
+    .unwrap();
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer.write_arrow_batch(&batch).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    writer.close().await;
+    builder.new_commit().commit(messages.clone()).await.unwrap();
+    messages
+}
+
+async fn patch(table: &Table, column: &str, values: ArrayRef) -> 
Vec<CommitMessage> {
+    let batch = RecordBatch::try_from_iter([
+        (
+            "_ROW_ID",
+            Arc::new(Int64Array::from_iter_values(20..120)) as ArrayRef,
+        ),
+        (column, values),
+    ])
+    .unwrap();
+    let messages = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .update_by_arrow_with_row_id(vec![batch])
+        .await
+        .unwrap();
+    let files = messages
+        .iter()
+        .flat_map(|m| &m.new_files)
+        .collect::<Vec<_>>();
+    assert_eq!(files.len(), 1);
+    assert_eq!(files[0].row_count, 100);
+    assert_eq!(files[0].first_row_id, Some(20));
+    assert_eq!(
+        files[0].write_cols.as_deref(),
+        Some(&[column.to_string()][..])
+    );
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(messages.clone())
+        .await
+        .unwrap();
+    messages
+}
+
+async fn remove_primaries(table: &Table, messages: &[CommitMessage]) {
+    for file in messages.iter().flat_map(|m| &m.new_files) {
+        assert!(file.file_name.ends_with(".parquet"));
+        assert_eq!(file.extra_files, vec![format!("{}.row", file.file_name)]);
+        assert!(table
+            .file_io()
+            .exists(&file.aligned_file_path(
+                &format!("{}/bucket-0", table.location()),
+                &file.extra_files[0]
+            ))
+            .await
+            .unwrap());
+        table
+            .file_io()
+            .delete_file(&file.data_file_path(&format!("{}/bucket-0", 
table.location())))
+            .await
+            .unwrap();
+    }
+}
+
+async fn query(table: &Table, predicate: Predicate, projection: &[&str]) -> 
RecordBatch {
+    let mut builder = table.new_read_builder();
+    builder.with_filter(predicate);
+    builder.with_projection(projection).unwrap();
+    let plan = builder.new_scan().plan().await.unwrap();
+    assert!(!plan.splits().is_empty());
+    for split in plan.splits() {
+        assert!(
+            split.row_ranges().is_none(),
+            "no global index or caller row selection"
+        );
+        assert!(split
+            .data_files()
+            .iter()
+            .all(|f| f.first_row_id == Some(20)));
+    }
+    let batches = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    assert!(!batches.is_empty());
+    concat_batches(&batches[0].schema(), &batches).unwrap()
+}
+
+fn assert_payload(batch: &RecordBatch, ids: &[i32], updated: bool) {
+    let payload = batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<StructArray>()
+        .unwrap();
+    let row_ids = batch
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+    assert_eq!(batch.num_rows(), ids.len());
+    for (row, id) in ids.iter().enumerate() {
+        assert_eq!(row_ids.value(row), i64::from(*id) + 20);
+        let expected = values(*id, 1, updated).pop().unwrap();
+        assert_eq!(payload.is_null(row), expected.is_none());
+        if let Some(expected) = expected {
+            for (column, expected) in [expected.value(), 
expected.metadata()].iter().enumerate() {
+                let actual = payload
+                    .column(column)
+                    .as_any()
+                    .downcast_ref::<BinaryArray>()
+                    .unwrap();
+                assert!(actual.is_valid(row));
+                assert_eq!(actual.value(row), *expected);
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn bitmap_reads_variant_column_merge_sidecars_without_requested_ranges() 
{
+    let table = table("variant").await;
+    append(&table, -20, 20).await;
+    let mut messages = append(&table, 0, 100).await;
+    assert!(messages
+        .iter()
+        .flat_map(|m| &m.new_files)
+        .all(|f| f.embedded_index.is_some()));
+    messages.extend(patch(&table, "payload", Arc::new(variants(&values(0, 100, 
true)))).await);
+    remove_primaries(&table, &messages).await;
+    let predicate = PredicateBuilder::new(table.schema().fields())
+        .is_in("id", (5..10).map(Datum::Int).collect())
+        .unwrap();
+    // The indexed predicate field is absent from the output. The same sparse
+    // physical selection must still reach both the ID and Variant providers.
+    let batch = query(&table, predicate, &["payload", "_ROW_ID"]).await;
+    assert_payload(&batch, &[5, 6, 7, 8, 9], true);
+}
+
+#[tokio::test]
+async fn bitmap_uses_latest_column_provider_instead_of_overwritten_values() {
+    let table = table("latest").await;
+    append(&table, -20, 20).await;
+    let mut messages = append(&table, 0, 100).await;
+    let updated = (0..100)
+        .map(|id| match id {
+            5 => 999,
+            42 => 5,
+            _ => id,
+        })
+        .collect::<Vec<_>>();
+    let patches = patch(&table, "b", 
Arc::new(Int32Array::from(updated))).await;
+    assert!(patches
+        .iter()
+        .flat_map(|m| &m.new_files)
+        .all(|f| f.embedded_index.is_some()));
+    messages.extend(patches);
+    let schema = table
+        .schema()
+        .apply_changes(vec![SchemaChange::rename_column(
+            "b".into(),
+            "renamed_b".into(),
+        )])
+        .unwrap();
+    persist_table_schema(table.file_io(), table.location(), &schema).await;
+    let table = table.copy_with_resolved_schema(schema, "main").unwrap();
+    remove_primaries(&table, &messages).await;
+    let predicates = PredicateBuilder::new(table.schema().fields());
+    // The old index says b=5 is row 5, whereas the winning column provider
+    // moved it to row 42. The old file has no b=999 at all, so consulting its
+    // index as an independent conjunct would incorrectly prune the second hit.
+    for (value, expected_id) in [(5, 42), (999, 5)] {
+        let batch = query(
+            &table,
+            predicates.equal("renamed_b", Datum::Int(value)).unwrap(),
+            &["payload", "_ROW_ID"],
+        )
+        .await;
+        assert_payload(&batch, &[expected_id], false);
+    }
+}
+
+#[tokio::test]
+async fn bitmap_boolean_predicates_select_the_same_rows_for_every_column() {
+    let table = table("boolean").await;
+    append(&table, -20, 20).await;
+    let mut messages = append(&table, 0, 100).await;
+    messages.extend(
+        patch(
+            &table,
+            "b",
+            Arc::new(Int32Array::from_iter_values(1000..1100)),
+        )
+        .await,
+    );
+    messages.extend(patch(&table, "payload", Arc::new(variants(&values(0, 100, 
true)))).await);
+    let predicates = PredicateBuilder::new(table.schema().fields());
+    // A cross-provider OR has no safe per-file necessary condition. Reading
+    // the primaries must retain the union rather than intersect its branches.
+    let pure_or = Predicate::or(vec![
+        predicates.equal("a", Datum::Int(5)).unwrap(),
+        predicates.equal("b", Datum::Int(1007)).unwrap(),
+    ]);
+    let fallback = query(&table, pure_or, &["payload", "_ROW_ID"]).await;
+    assert_payload(&fallback, &[5, 7], true);
+    let cases = [
+        (
+            Predicate::and(vec![
+                predicates
+                    .is_in("a", vec![Datum::Int(5), Datum::Int(6)])
+                    .unwrap(),
+                predicates
+                    .is_in("b", vec![Datum::Int(1006), Datum::Int(1007)])
+                    .unwrap(),
+            ]),
+            vec![6],
+        ),
+        (
+            Predicate::and(vec![
+                Predicate::or(vec![
+                    predicates.equal("a", Datum::Int(5)).unwrap(),
+                    predicates.equal("b", Datum::Int(1007)).unwrap(),
+                ]),
+                predicates
+                    .is_in("id", vec![Datum::Int(5), Datum::Int(6), 
Datum::Int(7)])
+                    .unwrap(),
+            ]),
+            vec![5, 7],
+        ),
+        (
+            Predicate::and(vec![
+                Predicate::negate(Predicate::and(vec![
+                    predicates.equal("a", Datum::Int(5)).unwrap(),
+                    predicates.equal("b", Datum::Int(1007)).unwrap(),
+                ])),
+                predicates
+                    .is_in("id", (5..10).map(Datum::Int).collect())
+                    .unwrap(),
+            ]),
+            // No row satisfies both inner predicates. Negating only one
+            // provider's half would incorrectly discard row 5 or row 7.
+            vec![5, 6, 7, 8, 9],
+        ),
+    ];
+    let without_index = 
table.copy_with_options(std::collections::HashMap::from([(
+        "file-index.read.enabled".into(),
+        "false".into(),
+    )]));
+    for (predicate, expected_ids) in &cases {
+        let batch = query(&without_index, predicate.clone(), &["payload", 
"_ROW_ID"]).await;
+        assert_payload(&batch, expected_ids, true);
+    }
+    remove_primaries(&table, &messages).await;
+    for (predicate, expected_ids) in cases {
+        // Only the third provider is projected; predicates require both the
+        // original provider (a/id) and a later scalar-only provider (b).
+        let batch = query(&table, predicate, &["payload", "_ROW_ID"]).await;
+        assert_payload(&batch, &expected_ids, true);
+    }
+}
+
+#[tokio::test]
+async fn missing_latest_bitmap_keeps_primary_and_does_not_reuse_stale_index() {
+    use std::collections::HashMap;
+
+    let table = table("missing_latest_index").await;
+    append(&table, -20, 20).await;
+    let mut messages = append(&table, 0, 100).await;
+    let unindexed_writer = table.copy_with_options(HashMap::from([(
+        "file-index.bitmap.columns".into(),
+        "id,a".into(),
+    )]));
+    let updated = (0..100)
+        .map(|id| match id {
+            5 => 999,
+            42 => 5,
+            _ => id,
+        })
+        .collect::<Vec<_>>();
+    let patches = patch(&unindexed_writer, "b", 
Arc::new(Int32Array::from(updated))).await;
+    assert!(patches
+        .iter()
+        .flat_map(|m| &m.new_files)
+        .all(|f| f.embedded_index.is_none()));
+    messages.extend(patches);
+    for file in messages.iter().flat_map(|m| &m.new_files) {
+        assert_eq!(file.extra_files.len(), 1);
+        table
+            .file_io()
+            .delete_file(&file.aligned_file_path(
+                &format!("{}/bucket-0", table.location()),
+                &file.extra_files[0],
+            ))
+            .await
+            .unwrap();
+    }
+    let predicates = PredicateBuilder::new(table.schema().fields());
+    // The latest b has no bitmap. Fall back to reading primary providers;
+    // neither an old index result nor an empty result is a valid replacement.
+    for (value, expected_id) in [(5, 42), (999, 5)] {
+        let batch = query(
+            &table,
+            predicates.equal("b", Datum::Int(value)).unwrap(),
+            &["payload", "_ROW_ID"],
+        )
+        .await;
+        assert_payload(&batch, &[expected_id], false);
+    }
+    let batch = query(
+        &table,
+        Predicate::or(vec![
+            predicates.equal("a", Datum::Int(5)).unwrap(),
+            predicates.equal("b", Datum::Int(5)).unwrap(),
+        ]),
+        &["payload", "_ROW_ID"],
+    )
+    .await;
+    assert_payload(&batch, &[5, 42], false);
+}
+
+#[tokio::test]
+async fn 
bitmap_requested_ranges_deletions_and_limit_keep_merged_providers_aligned() {
+    use std::collections::HashMap;
+
+    for (name, max_rows, sidecar) in [
+        ("aligned", "4", true),
+        ("after_dv", "3", true),
+        ("before_limit", "2", false),
+    ] {
+        let table = table(name).await.copy_with_options(HashMap::from([(
+            "data-evolution.row-sidecar.max-selected-rows".into(),
+            max_rows.into(),
+        )]));
+        append(&table, -20, 20).await;
+        let mut messages = append(&table, 0, 100).await;
+        messages.extend(patch(&table, "payload", Arc::new(variants(&values(0, 
100, true)))).await);
+        let deletions = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_row_id(vec![27])
+            .await
+            .unwrap();
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(deletions)
+            .await
+            .unwrap();
+        if sidecar {
+            remove_primaries(&table, &messages).await;
+        } else {
+            for file in messages.iter().flat_map(|m| &m.new_files) {
+                table
+                    .file_io()
+                    .delete_file(&file.aligned_file_path(
+                        &format!("{}/bucket-0", table.location()),
+                        &file.extra_files[0],
+                    ))
+                    .await
+                    .unwrap();
+            }
+        }
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&["payload", "_ROW_ID"]).unwrap();
+        builder.with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .is_in("id", (5..10).map(Datum::Int).collect())
+                .unwrap(),
+        );
+        builder.with_row_ranges(vec![RowRange::new(26, 29)]);
+        builder.with_limit(2);
+        let plan = builder.new_scan().plan().await.unwrap();
+        assert!(!plan.splits().is_empty());
+        let batches = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert!(!batches.is_empty());
+        // Four index hits remain after requested-range intersection. Deleting
+        // global row 27 must remove local selected position 1 from every
+        // provider, then LIMIT returns rows 26/28, including a SQL-null 
Variant.
+        // DV reduces the bitmap's four candidates to three before choosing
+        // ROW. LIMIT must not reduce that threshold further to two.
+        let batch = concat_batches(&batches[0].schema(), &batches).unwrap();
+        assert_payload(&batch, &[6, 8], true);
+    }
+}
+
+#[tokio::test]
+async fn deletions_narrow_sidecar_candidates_only_for_a_selective_bitmap() {
+    use std::collections::HashMap;
+
+    for merged in [false, true] {
+        for selective in [false, true] {
+            let table = table(&format!("bitmap_dv_{merged}_{selective}"))
+                .await
+                .copy_with_options(HashMap::from([(
+                    "data-evolution.row-sidecar.max-selected-rows".into(),
+                    "3".into(),
+                )]));
+            append(&table, -20, 20).await;
+            let mut messages = append(&table, 0, 100).await;
+            if merged {
+                messages.extend(
+                    patch(&table, "payload", Arc::new(variants(&values(0, 100, 
true)))).await,
+                );
+            }
+            let deleted = if selective {
+                vec![25, 27]
+            } else {
+                (20..119).collect()
+            };
+            let deletions = table
+                .new_write_builder()
+                .new_update()
+                .unwrap()
+                .delete_by_row_id(deleted)
+                .await
+                .unwrap();
+            table
+                .new_write_builder()
+                .new_commit()
+                .commit(deletions)
+                .await
+                .unwrap();
+            if selective {
+                remove_primaries(&table, &messages).await;
+            } else {
+                // Java normalizes a full-coverage bitmap to REMAIN. Even
+                // though DV leaves one live row, it cannot choose ROW alone.
+                for file in messages.iter().flat_map(|m| &m.new_files) {
+                    table
+                        .file_io()
+                        .delete_file(&file.aligned_file_path(
+                            &format!("{}/bucket-0", table.location()),
+                            &file.extra_files[0],
+                        ))
+                        .await
+                        .unwrap();
+                }
+            }
+            let predicates = PredicateBuilder::new(table.schema().fields());
+            let predicate = if selective {
+                predicates
+                    .is_in("id", (5..10).map(Datum::Int).collect())
+                    .unwrap()
+            } else {
+                predicates.greater_or_equal("id", Datum::Int(0)).unwrap()
+            };
+            let batch = query(&table, predicate, &["payload", 
"_ROW_ID"]).await;
+            let expected = if selective { vec![6, 8, 9] } else { vec![99] };
+            assert_payload(&batch, &expected, merged);
+        }
+    }
+}
diff --git a/crates/paimon/tests/data_evolution_row_sidecar_test.rs 
b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
index b2d924ee..e25d7fd4 100644
--- a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
+++ b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
@@ -22,7 +22,7 @@ use common::incremental_helpers::{memory_table, 
persist_table_schema, setup_dirs
 use futures::TryStreamExt;
 use paimon::io::FileIO;
 use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
-use paimon::table::{CommitMessage, RowRange, Table};
+use paimon::table::{CommitMessage, DataSplitBuilder, RowRange, Table};
 use std::collections::HashMap;
 use std::sync::Arc;
 
@@ -371,8 +371,113 @@ async fn 
explicit_abort_deletes_sidecar_while_writer_close_keeps_prepared_files(
 }
 
 #[tokio::test]
-async fn file_index_and_limit_do_not_turn_full_reads_into_sidecar_reads() {
+async fn bitmap_index_selection_reads_sidecar_without_primary_file() {
     use paimon::spec::{Datum, PredicateBuilder};
+    let table = table(&[
+        ("file-index.bitmap.columns", "id"),
+        ("file-index.in-manifest-threshold", "1mb"),
+    ])
+    .await;
+    append(&table, -20, 20).await;
+    let messages = append(&table, 0, 100).await;
+    assert!(messages
+        .iter()
+        .flat_map(|message| &message.new_files)
+        .all(|file| file.embedded_index.is_some()));
+    remove_primary(table.file_io(), &table, &messages).await;
+
+    for project_row_id in [false, true] {
+        for (requested_ranges, expected) in [
+            (None, vec![5]),
+            (Some(vec![RowRange::new(20, 119)]), vec![5]),
+            (Some(vec![RowRange::new(25, 27)]), vec![5]),
+            (Some(vec![RowRange::new(26, 30)]), vec![]),
+        ] {
+            let mut builder = table.new_read_builder();
+            if project_row_id {
+                builder
+                    .with_projection(&["id", "value", "_ROW_ID"])
+                    .unwrap();
+            }
+            builder.with_filter(
+                PredicateBuilder::new(table.schema().fields())
+                    .equal("id", Datum::Int(5))
+                    .unwrap(),
+            );
+            if let Some(ranges) = requested_ranges {
+                builder.with_row_ranges(ranges);
+            }
+            let plan = builder.new_scan().plan().await.unwrap();
+            let actual: Vec<RecordBatch> = builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            let ids = actual
+                .iter()
+                .flat_map(|batch| {
+                    batch
+                        .column(0)
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap()
+                        .values()
+                        .iter()
+                        .copied()
+                })
+                .collect::<Vec<_>>();
+            assert_eq!(ids, expected);
+            if project_row_id && !actual.is_empty() {
+                assert_eq!(rows(&actual), vec![(5, "v5".into(), 25)]);
+            }
+        }
+    }
+
+    // Direct split callers need not pass through ReadBuilder's range
+    // normalization. A later range must not hide an earlier bitmap hit.
+    let mut builder = table.new_read_builder();
+    builder
+        .with_projection(&["id", "value", "_ROW_ID"])
+        .unwrap();
+    builder.with_filter(
+        PredicateBuilder::new(table.schema().fields())
+            .equal("id", Datum::Int(5))
+            .unwrap(),
+    );
+    let plan = builder.new_scan().plan().await.unwrap();
+    let direct_splits = plan
+        .splits()
+        .iter()
+        .map(|split| {
+            DataSplitBuilder::new()
+                .with_snapshot(split.snapshot_id())
+                .with_partition(split.partition().clone())
+                .with_bucket(split.bucket())
+                .with_bucket_path(split.bucket_path().to_string())
+                .with_total_buckets(split.total_buckets())
+                .with_data_files(split.data_files().to_vec())
+                .with_raw_convertible(split.raw_convertible())
+                .with_row_ranges(vec![RowRange::new(80, 90), RowRange::new(25, 
27)])
+                .build()
+                .unwrap()
+        })
+        .collect::<Vec<_>>();
+    let actual: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(&direct_splits)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(rows(&actual), vec![(5, "v5".into(), 25)]);
+}
+
+#[tokio::test]
+async fn limit_and_deletion_vectors_do_not_trigger_sidecar_reads() {
     let table = table(&[("file-index.bitmap.columns", "id")]).await;
     let messages = append(&table, 0, 100).await;
     for file in messages.iter().flat_map(|m| &m.new_files) {
@@ -389,28 +494,6 @@ async fn 
file_index_and_limit_do_not_turn_full_reads_into_sidecar_reads() {
             .await
             .unwrap();
     }
-    // A one-row bitmap selection must still decode the existing primary file.
-    for requested_ranges in [None, Some(vec![RowRange::new(0, 99)])] {
-        let mut builder = table.new_read_builder();
-        builder.with_filter(
-            PredicateBuilder::new(table.schema().fields())
-                .equal("id", Datum::Int(5))
-                .unwrap(),
-        );
-        if let Some(ranges) = requested_ranges {
-            builder.with_row_ranges(ranges);
-        }
-        let plan = builder.new_scan().plan().await.unwrap();
-        let actual: Vec<RecordBatch> = builder
-            .new_read()
-            .unwrap()
-            .to_arrow(plan.splits())
-            .unwrap()
-            .try_collect()
-            .await
-            .unwrap();
-        assert_eq!(actual.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
-    }
     let mut builder = table.new_read_builder();
     builder.with_limit(1);
     let plan = builder.new_scan().plan().await.unwrap();
@@ -475,6 +558,20 @@ async fn 
file_index_and_limit_do_not_turn_full_reads_into_sidecar_reads() {
             .value(0),
         "changed"
     );
+
+    // Keeping only one live row must not make an otherwise full read use a
+    // sidecar either. All the sidecars above are absent, but primaries remain.
+    let deletions = update.delete_by_row_id((1..100).collect()).await.unwrap();
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(deletions)
+        .await
+        .unwrap();
+    assert_eq!(
+        rows(&read(&table, None).await.unwrap()),
+        vec![(0, "changed".into(), 0)]
+    );
 }
 
 #[tokio::test]
diff --git a/crates/paimon/tests/data_evolution_search_sidecar_test.rs 
b/crates/paimon/tests/data_evolution_search_sidecar_test.rs
new file mode 100644
index 00000000..58fde33e
--- /dev/null
+++ b/crates/paimon/tests/data_evolution_search_sidecar_test.rs
@@ -0,0 +1,440 @@
+// 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.
+
+mod common;
+
+use arrow_array::types::Float32Type;
+use arrow_array::{
+    Array, ArrayRef, BinaryArray, Float32Array, Int32Array, Int64Array, 
ListArray, RecordBatch,
+    StringArray, StructArray,
+};
+use arrow_buffer::NullBuffer;
+use arrow_schema::DataType as ArrowDataType;
+use arrow_select::concat::concat_batches;
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::arrow::variant_arrow_type;
+use paimon::spec::{
+    ArrayType, DataType, Datum, FloatType, IntType, PredicateBuilder, Schema, 
TableSchema,
+    VarCharType, VariantType,
+};
+use paimon::table::{CommitMessage, GlobalIndexResult, Table};
+use paimon::variant::GenericVariant;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+fn value(id: i32, updated: bool) -> Option<GenericVariant> {
+    let json = match id.rem_euclid(5) {
+        1 => return None,
+        2 => "null".into(),
+        3 => r#""scalar""#.into(),
+        4 => "[1,null,true]".into(),
+        _ => format!(
+            r#"{{"x":{},"nested":{{"items":[true,null,"中"]}}}}"#,
+            id + if updated { 1000 } else { 0 }
+        ),
+    };
+    Some(GenericVariant::parse_json(&json).unwrap())
+}
+
+fn variants(start: i32, count: i32, updated: bool) -> StructArray {
+    let values = (start..start + count)
+        .map(|id| value(id, updated))
+        .collect::<Vec<_>>();
+    let ArrowDataType::Struct(fields) = variant_arrow_type() else {
+        unreachable!()
+    };
+    StructArray::new(
+        fields,
+        vec![
+            Arc::new(BinaryArray::from_iter_values(
+                values
+                    .iter()
+                    .map(|v| v.as_ref().map_or(&[][..], 
GenericVariant::value)),
+            )),
+            Arc::new(BinaryArray::from_iter_values(
+                values
+                    .iter()
+                    .map(|v| v.as_ref().map_or(&[][..], 
GenericVariant::metadata)),
+            )),
+        ],
+        Some(NullBuffer::from(
+            values.iter().map(Option::is_some).collect::<Vec<_>>(),
+        )),
+    )
+}
+
+fn input(start: i32, count: i32) -> RecordBatch {
+    let embeddings =
+        ListArray::from_iter_primitive::<Float32Type, _, _>((start..start + 
count).map(|id| {
+            let x = match id {
+                5 => 0.0,
+                7 => 1.0,
+                6 => 2.0,
+                8 => 3.0,
+                _ => 1000.0 + id.abs() as f32,
+            };
+            Some(vec![Some(x), Some(0.0)])
+        }));
+    let text = StringArray::from_iter_values((start..start + count).map(|id| 
match id {
+        5 => "needle spare spare spare spare spare spare spare",
+        6 => "needle",
+        7 => "needle needle needle needle",
+        _ => "unrelated",
+    }));
+    RecordBatch::try_from_iter([
+        (
+            "id",
+            Arc::new(Int32Array::from_iter_values(start..start + count)) as 
ArrayRef,
+        ),
+        ("embedding", Arc::new(embeddings) as ArrayRef),
+        ("text", Arc::new(text) as ArrayRef),
+        (
+            "payload",
+            Arc::new(variants(start, count, false)) as ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+async fn append(table: &Table, batch: &RecordBatch) -> Vec<CommitMessage> {
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer.write_arrow_batch(batch).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    writer.close().await;
+    builder.new_commit().commit(messages.clone()).await.unwrap();
+    messages
+}
+
+async fn fixture(name: &str, full_text: bool) -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column(
+            "embedding",
+            DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+        )
+        .column("text", DataType::VarChar(VarCharType::string_type()))
+        .column("payload", DataType::Variant(VariantType::new()))
+        .option("file.format", "parquet")
+        .option("row-tracking.enabled", "true")
+        .option("data-evolution.enabled", "true")
+        // Full-text index builds currently require DV to be disabled. The
+        // vector fixture separately verifies deletion-aware Top-K and reads.
+        .option(
+            "deletion-vectors.enabled",
+            if full_text { "false" } else { "true" },
+        )
+        .option("data-evolution.row-sidecar.enabled", "true")
+        .option("global-index.enabled", "true")
+        .option("global-index.row-count-per-shard", "1000")
+        .option("file-index.bitmap.columns", "id")
+        .option("file-index.in-manifest-threshold", "1mb")
+        .option("vector-index.search-mode", "fast")
+        .option("full-text-index.search-mode", "fast")
+        .option("ivf-flat.dimension", "2")
+        .option("ivf-flat.nlist", "1")
+        .option("ivf-flat.distance.metric", "l2")
+        .build()
+        .unwrap();
+    let (io, table) = memory_table(
+        &format!("memory:/search_sidecar_{name}"),
+        TableSchema::new(0, &schema),
+    );
+    setup_dirs(&io, table.location()).await;
+    persist_table_schema(&io, table.location(), table.schema()).await;
+    let mut messages = append(&table, &input(-20, 20)).await;
+    messages.extend(append(&table, &input(0, 100)).await);
+    assert!(
+        table
+            .new_global_index_build_builder()
+            .with_index_column("embedding")
+            .with_index_type("ivf-flat")
+            .execute()
+            .await
+            .unwrap()
+            > 0
+    );
+    if full_text {
+        assert!(
+            table
+                .new_global_index_build_builder()
+                .with_index_column("text")
+                .with_index_type("full-text")
+                .execute()
+                .await
+                .unwrap()
+                > 0
+        );
+    }
+    let patch = RecordBatch::try_from_iter([
+        (
+            "_ROW_ID",
+            Arc::new(Int64Array::from_iter_values(20..120)) as ArrayRef,
+        ),
+        ("payload", Arc::new(variants(0, 100, true)) as ArrayRef),
+    ])
+    .unwrap();
+    let updates = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .update_by_arrow_with_row_id(vec![patch])
+        .await
+        .unwrap();
+    assert_eq!(updates.iter().map(|m| m.new_files.len()).sum::<usize>(), 1);
+    let file = &updates[0].new_files[0];
+    assert_eq!(file.row_count, 100);
+    assert_eq!(file.first_row_id, Some(20));
+    assert_eq!(
+        file.write_cols.as_deref(),
+        Some(&["payload".to_string()][..])
+    );
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(updates.clone())
+        .await
+        .unwrap();
+    messages.extend(updates);
+    for file in messages.iter().flat_map(|m| &m.new_files) {
+        assert!(file.file_name.ends_with(".parquet"));
+        assert_eq!(file.extra_files, vec![format!("{}.row", file.file_name)]);
+        assert!(io
+            .exists(&file.aligned_file_path(
+                &format!("{}/bucket-0", table.location()),
+                &file.extra_files[0]
+            ))
+            .await
+            .unwrap());
+        io.delete_file(&file.data_file_path(&format!("{}/bucket-0", 
table.location())))
+            .await
+            .unwrap();
+    }
+    table
+}
+
+async fn assert_materialized(result: &GlobalIndexResult) {
+    let expected = result.row_ids().unwrap();
+    assert_eq!(expected.row_ids.len(), 3);
+    let batches = result
+        .new_read_builder()
+        .with_projection(&["payload", "id"])
+        .read()
+        .await
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    assert!(!batches.is_empty());
+    let batch = concat_batches(&batches[0].schema(), &batches).unwrap();
+    assert_eq!(
+        batch
+            .schema()
+            .fields()
+            .iter()
+            .map(|f| f.name().as_str())
+            .collect::<Vec<_>>(),
+        vec!["payload", "id", "__paimon_search_score"]
+    );
+    assert_eq!(batch.num_rows(), expected.row_ids.len());
+    let ids = batch
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int32Array>()
+        .unwrap();
+    let scores = batch
+        .column(2)
+        .as_any()
+        .downcast_ref::<Float32Array>()
+        .unwrap();
+    let payload = batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<StructArray>()
+        .unwrap();
+    for (row, (&global_id, &score)) in 
expected.row_ids.iter().zip(&expected.scores).enumerate() {
+        let id = i32::try_from(global_id - 20).unwrap();
+        assert_eq!(ids.value(row), id, "materialized order follows search 
rank");
+        assert!(
+            (scores.value(row) - score).abs() < 1e-6,
+            "score belongs to the same global row ID"
+        );
+        let expected = value(id, true);
+        assert_eq!(payload.is_null(row), expected.is_none());
+        if let Some(expected) = expected {
+            for (index, expected) in [expected.value(), 
expected.metadata()].iter().enumerate() {
+                let child = payload
+                    .column(index)
+                    .as_any()
+                    .downcast_ref::<BinaryArray>()
+                    .unwrap();
+                assert!(child.is_valid(row));
+                assert_eq!(
+                    child.value(row),
+                    *expected,
+                    "latest Variant buffer at row ID {global_id}"
+                );
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn 
real_vector_hits_read_updated_variant_sidecars_in_score_order_with_deletions() {
+    let table = fixture("vector", false).await;
+    let mut builder = table.new_vector_search_builder();
+    builder
+        .with_vector_column("embedding")
+        .with_query_vector(vec![0.0, 0.0])
+        .with_limit(3)
+        .with_options(vector_options());
+    let result = builder.execute().await.unwrap();
+    assert_eq!(result.row_ids().unwrap().row_ids, vec![25, 27, 26]);
+    for (actual, expected) in 
result.row_ids().unwrap().scores.iter().zip([1.0, 0.5, 0.2]) {
+        assert!((*actual - expected).abs() < 1e-6);
+    }
+    // Physical rows are 5/6/7; ranking is 5/7/6, crossing JSON and SQL null.
+    // Both the original ID provider and the latest Variant provider must read
+    // their ROW sidecars because every primary Parquet has been deleted.
+    assert_materialized(&result).await;
+
+    // Scalar search filters materialize matching row IDs before searching.
+    // That internal `_ROW_ID` projection must retain bitmap pruning and ROW
+    // access when its internal projection contains only the row-ID column.
+    let mut filtered = table.new_vector_search_builder();
+    filtered
+        .with_vector_column("embedding")
+        .with_query_vector(vec![0.0, 0.0])
+        .with_limit(3)
+        .with_options(vector_options())
+        .with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .is_in("id", (6..10).map(Datum::Int).collect())
+                .unwrap(),
+        );
+    let filtered = filtered.execute().await.unwrap();
+    assert_eq!(filtered.row_ids().unwrap().row_ids, vec![27, 26, 28]);
+    assert_materialized(&filtered).await;
+
+    let deletions = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .delete_by_row_id(vec![25])
+        .await
+        .unwrap();
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(deletions)
+        .await
+        .unwrap();
+    // Also exercise the public Scan -> Plan -> Read API, without rerouting
+    // materialization through a hand-constructed row-range request.
+    let result = builder
+        .new_read()
+        .unwrap()
+        .read(builder.new_scan().unwrap().plan().await.unwrap())
+        .await
+        .unwrap();
+    assert_eq!(result.row_ids().unwrap().row_ids, vec![27, 26, 28]);
+    for (actual, expected) in 
result.row_ids().unwrap().scores.iter().zip([0.5, 0.2, 0.1]) {
+        assert!((*actual - expected).abs() < 1e-6);
+    }
+    assert_materialized(&result).await;
+}
+
+fn vector_options() -> HashMap<String, String> {
+    HashMap::from([
+        ("index_type".into(), "ivf-flat".into()),
+        ("ivf.nprobe".into(), "1".into()),
+    ])
+}
+
+#[cfg(feature = "fulltext")]
+#[tokio::test]
+async fn 
full_text_and_hybrid_hits_read_latest_variant_sidecars_with_ranked_scores() {
+    let table = fixture("full_text_hybrid", true).await;
+    let text = table
+        .new_full_text_search_builder()
+        .with_text_column("text")
+        .with_query_text("needle")
+        .with_limit(3)
+        .execute_scored()
+        .await
+        .unwrap();
+    let text_hits = text.row_ids().unwrap();
+    let mut ids = text_hits.row_ids.clone();
+    ids.sort_unstable();
+    assert_eq!(ids, vec![25, 26, 27]);
+    assert_ne!(
+        text_hits.row_ids,
+        vec![25, 26, 27],
+        "different document lengths must produce a nonphysical relevance order"
+    );
+    assert!(text_hits.scores.iter().all(|s| s.is_finite() && *s > 0.0));
+    assert!(text_hits.scores.windows(2).all(|s| s[0] >= s[1]));
+    assert_materialized(&text).await;
+
+    let vector = table
+        .new_vector_search_builder()
+        .with_vector_column("embedding")
+        .with_query_vector(vec![0.0, 0.0])
+        .with_limit(3)
+        .with_options(vector_options())
+        .execute()
+        .await
+        .unwrap();
+    assert_eq!(vector.row_ids().unwrap().row_ids, vec![25, 27, 26]);
+    let mut expected_scores = HashMap::<u64, f32>::new();
+    for hits in [vector.row_ids().unwrap(), text_hits] {
+        for (rank, row_id) in hits.row_ids.iter().enumerate() {
+            *expected_scores.entry(*row_id).or_default() += 1.0 / (60.0 + rank 
as f32 + 1.0);
+        }
+    }
+    let mut expected = expected_scores.into_iter().collect::<Vec<_>>();
+    expected.sort_by(|left, right| {
+        right
+            .1
+            .total_cmp(&left.1)
+            .then_with(|| left.0.cmp(&right.0))
+    });
+    let mut hybrid = table.new_hybrid_search_builder();
+    hybrid
+        .add_vector_route("embedding", vec![0.0, 0.0], 3, 1.0, 
vector_options())
+        .unwrap()
+        .add_full_text_route(
+            "text",
+            r#"{"match":{"query":"needle"}}"#,
+            3,
+            1.0,
+            HashMap::new(),
+        )
+        .unwrap()
+        .with_limit(3);
+    let fused = hybrid.execute_scored().await.unwrap();
+    let hits = fused.row_ids().unwrap();
+    assert_eq!(
+        hits.row_ids,
+        expected.iter().map(|(id, _)| *id).collect::<Vec<_>>()
+    );
+    for (score, (_, expected)) in hits.scores.iter().zip(expected) {
+        assert!((*score - expected).abs() < 1e-6);
+    }
+    assert_materialized(&fused).await;
+}
diff --git a/crates/paimon/tests/data_evolution_variant_sidecar_test.rs 
b/crates/paimon/tests/data_evolution_variant_sidecar_test.rs
new file mode 100644
index 00000000..93983f02
--- /dev/null
+++ b/crates/paimon/tests/data_evolution_variant_sidecar_test.rs
@@ -0,0 +1,457 @@
+// 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.
+
+mod common;
+
+use arrow_array::{Array, ArrayRef, BinaryArray, Int32Array, Int64Array, 
RecordBatch, StructArray};
+use arrow_buffer::NullBuffer;
+use arrow_schema::{DataType as ArrowDataType, Field};
+use arrow_select::concat::concat_batches;
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::arrow::variant_arrow_type;
+use paimon::spec::{
+    variant_extraction_row, BigIntType, DataField, DataType, Datum, IntType, 
Predicate,
+    PredicateBuilder, RowType, Schema, SchemaChange, TableSchema, VariantType, 
ROW_ID_FIELD_ID,
+    ROW_ID_FIELD_NAME,
+};
+use paimon::table::{CommitMessage, Table};
+use paimon::variant::GenericVariant;
+use std::sync::Arc;
+
+fn variants(values: &[Option<GenericVariant>]) -> StructArray {
+    let ArrowDataType::Struct(fields) = variant_arrow_type() else {
+        unreachable!()
+    };
+    StructArray::new(
+        fields,
+        vec![
+            Arc::new(BinaryArray::from_iter_values(values.iter().map(|value| {
+                value.as_ref().map_or(&[][..], GenericVariant::value)
+            }))),
+            Arc::new(BinaryArray::from_iter_values(values.iter().map(|value| {
+                value.as_ref().map_or(&[][..], GenericVariant::metadata)
+            }))),
+        ],
+        Some(NullBuffer::from(
+            values.iter().map(Option::is_some).collect::<Vec<_>>(),
+        )),
+    )
+}
+
+fn values(start: i32, count: i32, updated: bool) -> 
Vec<Option<GenericVariant>> {
+    (start..start + count)
+        .map(|id| {
+            let json = if updated && id == 5 {
+                Some(r#"{"x":999,"other":[true,null]}"#.to_string())
+            } else if id == 50 {
+                // An unselected value must not fail the strict extraction.
+                Some(r#"{"x":"invalid integer"}"#.to_string())
+            } else {
+                match id.rem_euclid(5) {
+                    1 => None,
+                    2 => Some("null".to_string()),
+                    4 => Some(r#""scalar""#.to_string()),
+                    _ => Some(format!(r#"{{"x":{id},"other":[true,null]}}"#)),
+                }
+            };
+            json.map(|json| GenericVariant::parse_json(&json).unwrap())
+        })
+        .collect()
+}
+
+fn input(start: i32, count: i32) -> RecordBatch {
+    let nested_values = (start..start + count)
+        .map(|id| {
+            if id.rem_euclid(5) == 1 {
+                None
+            } else if id.rem_euclid(5) == 2 {
+                Some(GenericVariant::parse_json("null").unwrap())
+            } else {
+                Some(GenericVariant::parse_json(&format!(r#"{{"x":{}}}"#, id + 
100)).unwrap())
+            }
+        })
+        .collect::<Vec<_>>();
+    let nested = StructArray::new(
+        vec![Arc::new(Field::new("detail", variant_arrow_type(), 
true))].into(),
+        vec![Arc::new(variants(&nested_values))],
+        Some(NullBuffer::from(
+            (start..start + count)
+                .map(|id| id.rem_euclid(5) != 3)
+                .collect::<Vec<_>>(),
+        )),
+    );
+    RecordBatch::try_from_iter([
+        (
+            "id",
+            Arc::new(Int32Array::from_iter_values(start..start + count)) as 
ArrayRef,
+        ),
+        (
+            "payload",
+            Arc::new(variants(&values(start, count, false))) as ArrayRef,
+        ),
+        ("nested", Arc::new(nested) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+async fn append(table: &Table, batch: &RecordBatch) -> Vec<CommitMessage> {
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer.write_arrow_batch(batch).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages.clone()).await.unwrap();
+    messages
+}
+
+fn extraction_type() -> DataType {
+    DataType::Row(
+        variant_extraction_row(
+            true,
+            ["$.x", "$.missing"].map(|path| {
+                (
+                    DataType::Int(IntType::new()),
+                    path.into(),
+                    true,
+                    "UTC".into(),
+                )
+            }),
+        )
+        .unwrap(),
+    )
+}
+
+async fn query(table: &Table, extraction: bool) -> RecordBatch {
+    let fields = table.schema().fields();
+    let mut builder = table.new_read_builder();
+    if extraction {
+        let DataType::Row(nested) = fields[2].data_type() else {
+            unreachable!()
+        };
+        let detail = &nested.fields()[0];
+        builder.with_read_type(vec![
+            DataField::new(
+                fields[2].id(),
+                "nested".into(),
+                DataType::Row(RowType::new(vec![DataField::new(
+                    detail.id(),
+                    "detail".into(),
+                    extraction_type(),
+                )])),
+            ),
+            DataField::new(
+                ROW_ID_FIELD_ID,
+                ROW_ID_FIELD_NAME.into(),
+                DataType::BigInt(BigIntType::new()),
+            ),
+            DataField::new(fields[1].id(), fields[1].name().into(), 
extraction_type()),
+            fields[0].clone(),
+        ]);
+    } else {
+        builder
+            .with_projection(&["nested", "_ROW_ID", fields[1].name(), "id"])
+            .unwrap();
+    }
+    let predicates = PredicateBuilder::new(fields);
+    builder.with_filter(Predicate::and(vec![
+        predicates.greater_or_equal("id", Datum::Int(5)).unwrap(),
+        predicates.less_or_equal("id", Datum::Int(9)).unwrap(),
+    ]));
+    let plan = builder.new_scan().plan().await.unwrap();
+    assert!(!plan.splits().is_empty());
+    for split in plan.splits() {
+        let ranges = split
+            .row_ranges()
+            .expect("the real global index must select row IDs");
+        assert!(ranges
+            .iter()
+            .all(|range| range.from() >= 25 && range.to() <= 29));
+        assert!(split
+            .data_files()
+            .iter()
+            .all(|file| file.file_name.ends_with(".parquet")));
+        assert!(split
+            .data_files()
+            .iter()
+            .all(|file| file.first_row_id == Some(20)));
+    }
+    let batches = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect::<Vec<_>>()
+        .await
+        .unwrap();
+    concat_batches(&batches[0].schema(), &batches).unwrap()
+}
+
+fn assert_result(batch: &RecordBatch, updated: bool, extraction: bool, 
deleted: bool) {
+    let expected_ids = if deleted {
+        vec![5, 6, 7, 9]
+    } else {
+        vec![5, 6, 7, 8, 9]
+    };
+    let ids = batch
+        .column(3)
+        .as_any()
+        .downcast_ref::<Int32Array>()
+        .unwrap();
+    assert_eq!(ids.values().as_ref(), expected_ids.as_slice());
+    let row_ids = batch
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap();
+    assert_eq!(
+        row_ids.values().as_ref(),
+        expected_ids
+            .iter()
+            .map(|id| i64::from(*id) + 20)
+            .collect::<Vec<_>>()
+    );
+    let payload = batch
+        .column(2)
+        .as_any()
+        .downcast_ref::<StructArray>()
+        .unwrap();
+    assert!(payload.is_null(1), "SQL NULL keeps a null Variant parent");
+    assert!(payload.is_valid(2), "JSON null remains a non-null Variant");
+    if extraction {
+        let x = payload
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let expected = expected_ids
+            .iter()
+            .map(|id| match *id {
+                5 => Some(if updated { 999 } else { 5 }),
+                8 => Some(8),
+                _ => None,
+            })
+            .collect::<Vec<_>>();
+        assert_eq!(x, &Int32Array::from(expected));
+        assert_eq!(payload.column(1).null_count(), expected_ids.len());
+        let nested = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        let detail = nested
+            .column(0)
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        let x = detail
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        for (row, id) in expected_ids.iter().enumerate() {
+            if *id == 5 || *id == 9 {
+                assert!(detail.is_valid(row));
+                assert_eq!(x.value(row), id + 100);
+            }
+            if *id == 6 {
+                assert!(nested.is_valid(row));
+                assert!(detail.is_null(row), "nested SQL NULL keeps a null 
parent");
+            }
+            if *id == 7 {
+                assert!(nested.is_valid(row));
+                assert!(detail.is_valid(row), "nested JSON null keeps its 
parent");
+                assert!(x.is_null(row));
+            }
+            if *id == 8 {
+                assert!(nested.is_null(row));
+            } else {
+                assert!(detail.column(1).is_null(row), "missing paths remain 
NULL");
+            }
+        }
+    } else {
+        let expected = values(0, 100, updated);
+        let actual_values = payload
+            .column(0)
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        let metadata = payload
+            .column(1)
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        for (row, id) in expected_ids.iter().enumerate() {
+            if let Some(expected) = &expected[*id as usize] {
+                assert_eq!(actual_values.value(row), expected.value());
+                assert_eq!(metadata.value(row), expected.metadata());
+            }
+        }
+        let nested = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        let detail = nested
+            .column(0)
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        let detail_values = detail
+            .column(0)
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        let detail_metadata = detail
+            .column(1)
+            .as_any()
+            .downcast_ref::<BinaryArray>()
+            .unwrap();
+        for (row, id) in expected_ids.iter().enumerate() {
+            assert_eq!(nested.is_null(row), id.rem_euclid(5) == 3);
+            if nested.is_null(row) {
+                continue;
+            }
+            assert_eq!(detail.is_null(row), *id == 6);
+            if detail.is_valid(row) {
+                let expected = GenericVariant::parse_json(&if *id == 7 {
+                    "null".to_string()
+                } else {
+                    format!(r#"{{"x":{}}}"#, id + 100)
+                })
+                .unwrap();
+                assert_eq!(detail_values.value(row), expected.value());
+                assert_eq!(detail_metadata.value(row), expected.metadata());
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn 
global_indexes_read_variant_sidecars_after_column_updates_and_rename() {
+    for (index_type, shredded) in [("bitmap", false), ("btree", true)] {
+        let mut schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("payload", DataType::Variant(VariantType::new()))
+            .column(
+                "nested",
+                DataType::Row(RowType::new(vec![DataField::new(
+                    0,
+                    "detail".into(),
+                    DataType::Variant(VariantType::new()),
+                )])),
+            )
+            .option("file.format", "parquet")
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", "true")
+            .option("deletion-vectors.enabled", "true")
+            .option("data-evolution.row-sidecar.enabled", "true")
+            .option("global-index.enabled", "true");
+        if shredded {
+            schema = schema.option("parquet.variant.shreddingSchema",
+                
r#"{"type":"ROW","fields":[{"name":"payload","type":{"type":"ROW","fields":[{"name":"x","type":"INT"}]}}]}"#);
+        }
+        let (io, table) = memory_table(
+            &format!("memory:/variant_sidecar_{index_type}"),
+            TableSchema::new(0, &schema.build().unwrap()),
+        );
+        setup_dirs(&io, table.location()).await;
+        persist_table_schema(&io, table.location(), table.schema()).await;
+        append(&table, &input(-20, 20)).await;
+        let initial = append(&table, &input(0, 100)).await;
+        assert!(
+            table
+                .new_global_index_build_builder()
+                .with_index_column("id")
+                .with_index_type(index_type)
+                .execute()
+                .await
+                .unwrap()
+                > 0
+        );
+        assert_result(&query(&table, false).await, false, false, false);
+        let file = &initial[0].new_files[0];
+        assert_eq!(file.extra_files, vec![format!("{}.row", file.file_name)]);
+        let path = file.data_file_path(&format!("{}/bucket-0", 
table.location()));
+        let primary = io.new_input(&path).unwrap().read().await.unwrap();
+        io.delete_file(&path).await.unwrap();
+        for extraction in [false, true] {
+            assert_result(&query(&table, extraction).await, false, extraction, 
false);
+        }
+        // Restore only for producing the column patch; reads below remove all 
providers again.
+        io.new_output(&path).unwrap().write(primary).await.unwrap();
+        let patch = RecordBatch::try_from_iter([
+            (
+                "_ROW_ID",
+                Arc::new(Int64Array::from_iter_values(20..120)) as ArrayRef,
+            ),
+            (
+                "payload",
+                Arc::new(variants(&values(0, 100, true))) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        let update = table.new_write_builder().new_update().unwrap();
+        let updates = update
+            .update_by_arrow_with_row_id(vec![patch])
+            .await
+            .unwrap();
+        assert!(updates.iter().flat_map(|m| &m.new_files).all(|f| f
+            .write_cols
+            .as_ref()
+            .is_some_and(|columns| columns == &["payload"])));
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(updates.clone())
+            .await
+            .unwrap();
+        let schema = table
+            .schema()
+            .apply_changes(vec![SchemaChange::rename_column(
+                "payload".into(),
+                "renamed_payload".into(),
+            )])
+            .unwrap();
+        persist_table_schema(&io, table.location(), &schema).await;
+        let table = table.copy_with_resolved_schema(schema, "main").unwrap();
+        for file in initial.iter().chain(&updates).flat_map(|m| &m.new_files) {
+            assert_eq!(file.row_count, 100);
+            io.delete_file(&file.data_file_path(&format!("{}/bucket-0", 
table.location())))
+                .await
+                .unwrap();
+        }
+        for extraction in [false, true] {
+            assert_result(&query(&table, extraction).await, true, extraction, 
false);
+        }
+        let deletions = table
+            .new_write_builder()
+            .new_update()
+            .unwrap()
+            .delete_by_row_id(vec![28])
+            .await
+            .unwrap();
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(deletions)
+            .await
+            .unwrap();
+        for extraction in [false, true] {
+            assert_result(&query(&table, extraction).await, true, extraction, 
true);
+        }
+    }
+}

Reply via email to