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

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c259bdc feat(core): read only the file groups the record index names 
for key filters (#907)
4c259bdc is described below

commit 4c259bdc551ce92629e0952b775730d3eda2a09c
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Oct 30 01:42:55 2026 -0700

    feat(core): read only the file groups the record index names for key 
filters (#907)
    
    `Table::get_file_slices_inner` now asks the record-level index which file 
groups hold the keys an `=` or `IN` filter on the record key field (or 
`_hoodie_record_key`) names, and plans only those file slices; the row-level 
mask still applies the filter to what is read. The lookup runs for a snapshot 
read at the latest commit, on a table with a single string record key field and 
a populated index; a time-travel read, a read-optimized read of a merge-on-read 
table, a composite key or an  [...]
    
    The index is read as of the reader's snapshot rather than its latest state: 
log blocks count only for instants at or before the snapshot's latest commit 
that had completed when the table was opened, and a record index base file 
compacted after the snapshot falls back to the full scan. One listing of the 
record index partition serves both that check and the lookup. Metadata-table 
instants newer than the snapshot that do not touch the index (a clean, an 
indexing action, a metadata-table [...]
    
    Reading one key out of a 10M-row table went from a scan of every file slice 
to one file slice.
---
 crates/core/src/index/mod.rs                       |   1 +
 crates/core/src/index/record.rs                    |  73 +++-
 crates/core/src/table/mod.rs                       | 130 +++++-
 crates/core/src/timeline/mod.rs                    |  26 +-
 crates/core/tests/table_read_record_index_tests.rs | 478 +++++++++++++++++++++
 5 files changed, 685 insertions(+), 23 deletions(-)

diff --git a/crates/core/src/index/mod.rs b/crates/core/src/index/mod.rs
index e6cd0bee..806e8321 100644
--- a/crates/core/src/index/mod.rs
+++ b/crates/core/src/index/mod.rs
@@ -21,6 +21,7 @@
 mod record;
 mod simple;
 
+pub(crate) use record::locate_file_groups;
 pub use record::{RecordIndex, is_record_index_enabled};
 pub use simple::SimpleIndex;
 
diff --git a/crates/core/src/index/record.rs b/crates/core/src/index/record.rs
index 808141d1..c11a561f 100644
--- a/crates/core/src/index/record.rs
+++ b/crates/core/src/index/record.rs
@@ -18,12 +18,13 @@
  */
 //! Record-level index backed by the metadata table `record_index` partition.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 use crate::Result;
 use crate::config::read::HudiReadConfig;
 use crate::error::CoreError;
+use crate::file_group::base_file::BaseFile;
 use crate::file_group::base_file::reader::{KeyMatcher, KeyPredicate};
 use crate::file_group::log_file::scanner::LogFileScanner;
 use crate::hfile::{HFileReader, HFileRecord};
@@ -85,23 +86,72 @@ struct LoadedLocation {
     instant_time_millis: i64,
 }
 
+/// Locations of `record_keys` in the index's latest state.
+async fn lookup_record_index(
+    table: &Table,
+    storage: Arc<Storage>,
+    record_keys: &[&str],
+) -> Result<HashMap<String, LoadedLocation>> {
+    Ok(lookup_as_of(table, storage, record_keys, None)
+        .await?
+        .unwrap_or_default())
+}
+
+/// The file groups holding `record_keys` in the snapshot whose latest commit
+/// is `as_of`, or `None` when the index cannot answer for that snapshot.
+pub(crate) async fn locate_file_groups(
+    table: &Table,
+    record_keys: &[&str],
+    as_of: &str,
+) -> Result<Option<HashSet<(String, String)>>> {
+    let storage = table.file_system_view.storage.clone();
+    Ok(lookup_as_of(table, storage, record_keys, Some(as_of))
+        .await?
+        .map(|found| {
+            found
+                .into_values()
+                .map(|location| (location.partition_path, location.file_id))
+                .collect()
+        }))
+}
+
 /// Locations of `record_keys`, read from only the index shards and HFile
-/// blocks that can hold them.
+/// blocks that can hold them; with `as_of`, as of the snapshot whose latest
+/// commit that is, and `None` when the index cannot describe it.
 ///
 /// Keys are hashed to their `record_index` file group the way the writer
 /// placed them, so a lookup opens the touched shards rather than the whole
 /// partition, and inside a shard the HFile block index narrows the read to the
 /// blocks the keys fall in. Values are Avro-decoded only for the requested 
keys.
-async fn lookup_record_index(
+async fn lookup_as_of(
     table: &Table,
     storage: Arc<Storage>,
     record_keys: &[&str],
-) -> Result<HashMap<String, LoadedLocation>> {
+    as_of: Option<&str>,
+) -> Result<Option<HashMap<String, LoadedLocation>>> {
     let partition = MetadataPartitionType::RecordIndex.partition_name();
     // Slice-resolved: only each file group's LIVE slice (multi-base aware
     // after a Spark MDT compaction), logs in version order. One listing serves
     // both the shard modulus and the routing, so the two cannot disagree.
     let slices = mdt_partition_latest_slices(storage.as_ref(), 
partition).await?;
+    if let Some(as_of) = as_of {
+        // An empty partition answers every key with "absent", and a base file
+        // compacted after the snapshot may hold later commits' changes, which
+        // no instant filter can take back out.
+        let compacted_after = |base: &str| {
+            base.rsplit('/')
+                .next()
+                .and_then(|name| name.parse::<BaseFile>().ok())
+                .is_none_or(|base| base.commit_timestamp.as_str() > as_of)
+        };
+        if slices.is_empty()
+            || slices
+                .iter()
+                .any(|(_, base, _)| 
base.as_deref().is_some_and(compacted_after))
+        {
+            return Ok(None);
+        }
+    }
     let num_groups = shard_count_from_slices(
         &slices,
         RECORD_INDEX_FILE_ID_PREFIX,
@@ -135,10 +185,15 @@ async fn lookup_record_index(
     // Only trust log blocks whose instant completed on the data timeline
     // (Java getValidInstantTimestamps): orphan MDT commits are skipped.
     let range = if touched.iter().any(|(_, _, logs)| !logs.is_empty()) {
-        Some(InstantRange::exact_match(
-            table.valid_metadata_instants().await?,
-            "UTC",
-        ))
+        let mut valid = table.valid_metadata_instants().await?;
+        if let Some(as_of) = as_of {
+            // The snapshot holds the commits that had completed by `as_of` 
when
+            // the table was opened; one pending then may have completed since.
+            valid.retain(|instant| {
+                instant.as_str() <= as_of && 
!table.timeline.pending_instants.contains(instant)
+            });
+        }
+        Some(InstantRange::exact_match(valid, "UTC"))
     } else {
         None
     };
@@ -190,7 +245,7 @@ async fn lookup_record_index(
         }
     })
     .await?;
-    Ok(per_shard.into_iter().flatten().collect())
+    Ok(Some(per_shard.into_iter().flatten().collect()))
 }
 
 /// Fold `records` into `merged`, later records overriding earlier ones and a
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index 57d20cae..dd9f19a9 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -129,7 +129,7 @@ use crate::table::partition::{PartitionPruner, 
project_partition_schema};
 use crate::timeline::util::format_timestamp;
 use crate::timeline::{EARLIEST_START_TIMESTAMP, Timeline};
 use arrow::record_batch::RecordBatch;
-use arrow_schema::{Field, Schema};
+use arrow_schema::{DataType, Field, Schema};
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 use tokio::sync::OnceCell;
@@ -721,6 +721,30 @@ impl Table {
             )
             .await?;
 
+        // The index describes merged rows, which a read-optimized read of a
+        // merge-on-read table does not see.
+        let read_optimized = base_file_only && self.is_mor();
+        if !read_optimized
+            && let Some(located) = self
+                .file_groups_for_record_keys(filters, timestamp, &table_schema)
+                .await
+        {
+            let planned: HashSet<(&str, &str)> = file_slices
+                .iter()
+                .map(|fs| (fs.partition_path.as_str(), fs.file_id()))
+                .collect();
+            // A file group the index names that this plan lacks (another
+            // filter pruned it, or the index disagrees with the snapshot)
+            // leaves the plan as it is.
+            if located.iter().all(|(partition, file_id)| {
+                planned.contains(&(partition.as_str(), file_id.as_str()))
+            }) {
+                file_slices.retain(|fs| {
+                    located.contains(&(fs.partition_path.clone(), 
fs.file_id().to_string()))
+                });
+            }
+        }
+
         if base_file_only {
             for fs in &mut file_slices {
                 fs.log_files.clear();
@@ -729,6 +753,110 @@ impl Table {
         Ok(file_slices)
     }
 
+    /// The file groups the record-level index names for the record keys
+    /// `filters` ask for, or `None` when the index cannot answer.
+    ///
+    /// Only an equality or `IN` filter on the table's single string record key
+    /// field (or on `_hoodie_record_key`) is a key lookup, and only for a read
+    /// of the latest commit on table version 8 or later, whose metadata table
+    /// this reader can list. Anything the lookup cannot handle, including an
+    /// error, leaves the full scan in place; the row-level mask still applies
+    /// the filter to whatever is read.
+    async fn file_groups_for_record_keys(
+        &self,
+        filters: &[Filter],
+        timestamp: &str,
+        table_schema: &Schema,
+    ) -> Option<HashSet<(String, String)>> {
+        use crate::expr::ExprOperator;
+        use crate::index::is_record_index_enabled;
+        use crate::keygen::{KeyGeneratorKind, key_generator_kind};
+
+        let metadata_off = self
+            .hudi_configs
+            .get_raw(HudiTableConfig::MetadataTableEnabled.as_ref())
+            .is_some_and(|v| v.trim().eq_ignore_ascii_case("false"));
+        if filters.is_empty()
+            || metadata_off
+            || !self.is_metadata_table_enabled()
+            || !is_record_index_enabled(self)
+            || self.timeline.get_latest_commit_timestamp_as_option() != 
Some(timestamp)
+        {
+            return None;
+        }
+        let version: isize = self
+            .hudi_configs
+            .try_get(HudiTableConfig::TableVersion)
+            .ok()
+            .flatten()
+            .map(Into::into)?;
+        if version < 8 {
+            return None;
+        }
+        let key_fields: Vec<String> = self
+            .hudi_configs
+            .try_get(HudiTableConfig::RecordKeyFields)
+            .ok()
+            .flatten()
+            .map(Into::into)?;
+        let [key_field] = key_fields.as_slice() else {
+            return None;
+        };
+        // The index stores the key as a string; only a string column's filter
+        // value is that same text.
+        let key_is_string = matches!(
+            table_schema.field_with_name(key_field).ok()?.data_type(),
+            DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
+        );
+        // A complex key generator stores a single-field key as `field:value`
+        // from table version 9; below it the stored form depends on the writer
+        // release, so the index cannot be asked.
+        let prefixed = match key_generator_kind(&self.hudi_configs).ok()? {
+            KeyGeneratorKind::Simple | KeyGeneratorKind::NonPartitioned => 
false,
+            KeyGeneratorKind::Complex | KeyGeneratorKind::SparkSql if version 
>= 9 => true,
+            _ => return None,
+        };
+        let mut keys: Option<HashSet<String>> = None;
+        for filter in filters {
+            if !matches!(filter.operator, ExprOperator::Eq | ExprOperator::In) 
{
+                continue;
+            }
+            let values: HashSet<String> = if filter.field == *key_field {
+                if !key_is_string {
+                    return None;
+                }
+                filter
+                    .values
+                    .iter()
+                    .map(|v| {
+                        if prefixed {
+                            format!("{key_field}:{v}")
+                        } else {
+                            v.clone()
+                        }
+                    })
+                    .collect()
+            } else if filter.field == MetaField::RecordKey.as_ref() {
+                filter.values.iter().cloned().collect()
+            } else {
+                continue;
+            };
+            keys = Some(match keys {
+                Some(prior) => prior.intersection(&values).cloned().collect(),
+                None => values,
+            });
+        }
+        let keys = keys?;
+        let keys: Vec<&str> = keys.iter().map(String::as_str).collect();
+        match crate::index::locate_file_groups(self, &keys, timestamp).await {
+            Ok(located) => located,
+            Err(e) => {
+                log::warn!("record index lookup failed, reading every file 
slice: {e}");
+                None
+            }
+        }
+    }
+
     async fn get_file_slices_between_inner(
         &self,
         start_timestamp: &str,
diff --git a/crates/core/src/timeline/mod.rs b/crates/core/src/timeline/mod.rs
index 5c709230..70d8613b 100644
--- a/crates/core/src/timeline/mod.rs
+++ b/crates/core/src/timeline/mod.rs
@@ -135,7 +135,14 @@ impl Timeline {
             None,
         )?;
         let all_active = timeline.load_instants(&selector, false).await?;
-        timeline.earliest_active_instant = all_active
+        timeline.set_active_instants(all_active);
+        Ok(timeline)
+    }
+
+    /// Set the completed commits, the pending instants and the archival
+    /// boundary from one listing of the active timeline in every state.
+    fn set_active_instants(&mut self, all_active: Vec<Instant>) {
+        self.earliest_active_instant = all_active
             .iter()
             .map(|instant| instant.timestamp.clone())
             .min();
@@ -151,13 +158,12 @@ impl Timeline {
             .iter()
             .map(|instant| instant.timestamp.clone())
             .collect();
-        timeline.pending_instants = pending
+        self.pending_instants = pending
             .into_iter()
             .map(|instant| instant.timestamp)
             .filter(|timestamp| !completed_times.contains(timestamp))
             .collect();
-        timeline.completed_commits = completed;
-        Ok(timeline)
+        self.completed_commits = completed;
     }
 
     /// The inputs the log-block scan needs to tell a committed instant from 
one
@@ -196,7 +202,8 @@ impl Timeline {
     /// Reload completed commits from storage after a write.
     pub(crate) async fn reload_completed_commits(&mut self) -> Result<()> {
         // Same listing as `new_from_storage`: every state, so the archival
-        // boundary can be refreshed together with the completed commits.
+        // boundary and the pending instants are refreshed together with the
+        // completed commits.
         // Archival (running inside writes) raises the boundary; a reload that
         // kept the old one would leave newly-archived instants neither in the
         // completed set nor below the boundary — read as uncommitted, silently
@@ -209,14 +216,7 @@ impl Timeline {
             None,
         )?;
         let all_active = self.load_instants(&selector, false).await?;
-        self.earliest_active_instant = all_active
-            .iter()
-            .map(|instant| instant.timestamp.clone())
-            .min();
-        self.completed_commits = all_active
-            .into_iter()
-            .filter(|instant| instant.state == State::Completed)
-            .collect();
+        self.set_active_instants(all_active);
         Ok(())
     }
 
diff --git a/crates/core/tests/table_read_record_index_tests.rs 
b/crates/core/tests/table_read_record_index_tests.rs
new file mode 100644
index 00000000..8863814f
--- /dev/null
+++ b/crates/core/tests/table_read_record_index_tests.rs
@@ -0,0 +1,478 @@
+/*
+ * 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.
+ */
+//! Record-level index lookups on the read path: a key filter reads only the
+//! file groups the index names.
+
+use std::sync::Arc;
+
+use arrow_array::{Int64Array, RecordBatch, StringArray};
+use arrow_schema::{DataType, Field, Schema};
+use hudi_core::config::table::TableTypeValue;
+use hudi_core::index::HoodieKey;
+use hudi_core::table::{ReadOptions, Table};
+use tempfile::tempdir;
+
+/// Ten keys `k{group + 3i}` with values `base + i`: the three groups' key
+/// ranges overlap, so footer min/max statistics cannot tell them apart and
+/// only the index can.
+fn batch(group: usize, base: i64) -> RecordBatch {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Utf8, false),
+        Field::new("value", DataType::Int64, false),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(StringArray::from(
+                (0..10)
+                    .map(|i| format!("k{:02}", group + 3 * i))
+                    .collect::<Vec<_>>(),
+            )),
+            Arc::new(Int64Array::from_iter_values((0..10).map(|i| base + i))),
+        ],
+    )
+    .unwrap()
+}
+
+/// Three file groups: group 0 holds `k00, k03, ...`, group 1 `k01, k04, ...`,
+/// group 2 `k02, k05, ...`.
+async fn three_groups(
+    dir: &std::path::Path,
+    table_type: TableTypeValue,
+    record_index: bool,
+) -> Table {
+    let mut table = Table::create(dir.to_str().unwrap())
+        .with_table_name("t")
+        .with_record_key_fields(["id"])
+        .with_table_type(table_type)
+        .with_record_index(record_index)
+        // One file group per append.
+        .with_option("hoodie.parquet.small.file.limit", "0")
+        .create()
+        .await
+        .unwrap();
+    for (group, base) in [(0, 0), (1, 100), (2, 200)] {
+        table.append([batch(group, base)]).await.unwrap();
+    }
+    Table::new(dir.to_str().unwrap()).await.unwrap()
+}
+
+fn filters(filters: &[(&str, &str, &str)]) -> ReadOptions {
+    ReadOptions::new()
+        .with_filters(filters.iter().copied())
+        .unwrap()
+}
+
+/// How many file slices the read plans, and the ids it returns.
+async fn slices_and_ids(table: &Table, options: &ReadOptions) -> (usize, 
Vec<String>) {
+    let slices = table.get_file_slices(options).await.unwrap().len();
+    let mut ids: Vec<String> = table
+        .read(options)
+        .await
+        .unwrap()
+        .iter()
+        .flat_map(|b| {
+            b.column_by_name("id")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap()
+                .iter()
+                .map(|v| v.unwrap().to_string())
+                .collect::<Vec<_>>()
+        })
+        .collect();
+    ids.sort();
+    (slices, ids)
+}
+
+#[tokio::test]
+async fn test_key_filter_reads_only_the_indexed_file_group() {
+    let dir = tempdir().unwrap();
+    let table = three_groups(dir.path(), TableTypeValue::CopyOnWrite, 
true).await;
+
+    assert_eq!(
+        table
+            .get_file_slices(&ReadOptions::new())
+            .await
+            .unwrap()
+            .len(),
+        3
+    );
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "k04")])).await,
+        (1, vec!["k04".to_string()])
+    );
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("_hoodie_record_key", "IN", 
"k03,k05")])).await,
+        (2, vec!["k03".to_string(), "k05".to_string()])
+    );
+    // Two key filters intersect; a filter on another column changes nothing.
+    assert_eq!(
+        slices_and_ids(
+            &table,
+            &filters(&[
+                ("id", "IN", "k03,k04"),
+                ("id", "=", "k04"),
+                ("value", ">", "0")
+            ])
+        )
+        .await,
+        (1, vec!["k04".to_string()])
+    );
+    // A key the index does not know reads nothing.
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "zz")])).await,
+        (0, vec![])
+    );
+}
+
+#[tokio::test]
+async fn test_key_filter_does_not_use_the_index_for_an_earlier_snapshot() {
+    let dir = tempdir().unwrap();
+    let table = three_groups(dir.path(), TableTypeValue::CopyOnWrite, 
true).await;
+    let mut instants: Vec<String> = table
+        .get_timeline()
+        .completed_commits
+        .iter()
+        .map(|i| i.timestamp.clone())
+        .collect();
+    instants.sort();
+    let options = filters(&[("id", "=", 
"k04")]).with_as_of_timestamp(&instants[1]);
+    // Both file groups of that snapshot are read; the row mask still applies.
+    assert_eq!(
+        slices_and_ids(&table, &options).await,
+        (2, vec!["k04".to_string()])
+    );
+}
+
+#[tokio::test]
+async fn test_key_filter_without_the_index_reads_every_file_group() {
+    let dir = tempdir().unwrap();
+    let table = three_groups(dir.path(), TableTypeValue::CopyOnWrite, 
false).await;
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "k04")])).await,
+        (3, vec!["k04".to_string()])
+    );
+}
+
+#[tokio::test]
+async fn test_key_filter_on_a_merge_on_read_table_reads_the_updated_row() {
+    let dir = tempdir().unwrap();
+    three_groups(dir.path(), TableTypeValue::MergeOnRead, true).await;
+    let mut writer = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    writer.upsert([batch(1, 1000)]).await.unwrap();
+
+    let reader = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    let options = filters(&[("id", "=", "k04")]);
+    let slices = reader.get_file_slices(&options).await.unwrap();
+    assert_eq!(slices.len(), 1);
+    assert!(slices[0].has_log_file(), "the upsert went to a log file");
+    let values: Vec<i64> = reader
+        .read(&options)
+        .await
+        .unwrap()
+        .iter()
+        .flat_map(|b| {
+            b.column_by_name("value")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    assert_eq!(values, vec![1001]);
+}
+
+/// A reader opened before a delete keeps its snapshot: the index is read as
+/// of it, so the key still reads back from its own file group.
+#[tokio::test]
+async fn test_a_stale_reader_reads_the_index_as_of_its_snapshot() {
+    let dir = tempdir().unwrap();
+    let reader = three_groups(dir.path(), TableTypeValue::CopyOnWrite, 
true).await;
+    let mut writer = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    writer
+        .delete_keys([HoodieKey {
+            record_key: "k04".to_string(),
+            partition_path: String::new(),
+        }])
+        .await
+        .unwrap();
+    assert_eq!(
+        slices_and_ids(&reader, &filters(&[("id", "=", "k04")])).await,
+        (1, vec!["k04".to_string()])
+    );
+    let fresh = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    assert_eq!(
+        slices_and_ids(&fresh, &filters(&[("id", "=", "k04")])).await,
+        (0, vec![])
+    );
+}
+
+/// `hoodie.metadata.enable=false` on the reader turns the lookup off.
+#[tokio::test]
+async fn test_metadata_disabled_on_the_reader_reads_every_file_group() {
+    let dir = tempdir().unwrap();
+    three_groups(dir.path(), TableTypeValue::CopyOnWrite, true).await;
+    let table = Table::new_with_options(
+        dir.path().to_str().unwrap(),
+        [("hoodie.metadata.enable", "false")],
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "k04")])).await,
+        (3, vec!["k04".to_string()])
+    );
+}
+
+/// Sorted values of the string column `column` across `batches`.
+fn strings(batches: &[RecordBatch], column: &str) -> Vec<String> {
+    let mut values: Vec<String> = batches
+        .iter()
+        .flat_map(|b| {
+            b.column_by_name(column)
+                .unwrap()
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap()
+                .iter()
+                .map(|v| v.unwrap().to_string())
+                .collect::<Vec<_>>()
+        })
+        .collect();
+    values.sort();
+    values
+}
+
+/// A commit still pending when the reader opened is not part of its
+/// snapshot, so its index entries are not either, even when it completes
+/// before the read.
+#[tokio::test]
+async fn 
test_a_commit_pending_when_the_reader_opened_is_not_read_from_the_index() {
+    let dir = tempdir().unwrap();
+    three_groups(dir.path(), TableTypeValue::CopyOnWrite, true).await;
+    let mut writer = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    writer
+        .delete_keys([HoodieKey {
+            record_key: "k04".to_string(),
+            partition_path: String::new(),
+        }])
+        .await
+        .unwrap();
+    let delete_instant = writer
+        .get_timeline()
+        .completed_commits
+        .iter()
+        .map(|i| i.timestamp.clone())
+        .max()
+        .unwrap();
+    writer.upsert([batch(0, 500)]).await.unwrap();
+
+    // Open the reader while the delete looks in flight, then let it complete.
+    let timeline = dir.path().join(".hoodie/timeline");
+    let completed = std::fs::read_dir(&timeline)
+        .unwrap()
+        .map(|e| e.unwrap().path())
+        .find(|p| {
+            let name = p.file_name().unwrap().to_string_lossy().to_string();
+            name.starts_with(&format!("{delete_instant}_")) && 
name.ends_with(".commit")
+        })
+        .unwrap();
+    let hidden = dir.path().join("hidden.commit");
+    std::fs::rename(&completed, &hidden).unwrap();
+    let reader = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    std::fs::rename(&hidden, &completed).unwrap();
+
+    let (_, ids) = slices_and_ids(&reader, &filters(&[("id", "=", 
"k04")])).await;
+    assert_eq!(ids, vec!["k04".to_string()]);
+}
+
+/// Spark-written partitioned tables whose metadata table holds an instant
+/// newer than the latest data commit (an indexing action). Footer statistics
+/// already narrow the copy-on-write read; the merge-on-read one, whose slices
+/// carry logs, narrows only through the index.
+#[tokio::test]
+async fn test_key_filter_on_spark_written_tables() {
+    use hudi_test::{SampleTable, TableFormat};
+    for format in [TableFormat::Cow, TableFormat::MorAvro] {
+        let path = SampleTable::V9TxnsSimpleMeta.path(format);
+        let table = Table::new(&path).await.unwrap();
+        let all = table
+            .get_file_slices(&ReadOptions::new())
+            .await
+            .unwrap()
+            .len();
+        let keys = strings(&table.read(&ReadOptions::new()).await.unwrap(), 
"txn_id");
+        let probe = format!("{},{}", keys[0], keys[keys.len() - 1]);
+        let options = filters(&[("txn_id", "IN", probe.as_str())]);
+        let slices = table.get_file_slices(&options).await.unwrap().len();
+        let got = strings(&table.read(&options).await.unwrap(), "txn_id");
+
+        let scan = Table::new_with_options(&path, [("hoodie.metadata.enable", 
"false")])
+            .await
+            .unwrap();
+        let want = strings(&scan.read(&options).await.unwrap(), "txn_id");
+        assert_eq!(got, want, "{format:?}");
+        assert!(slices < all, "{format:?}: {slices} of {all} slices planned");
+    }
+}
+
+/// Create the three-group table with a declared complex key generator at
+/// `version`, so a single-field key is stored as `id:<value>`.
+async fn complex_three_groups(dir: &std::path::Path, version: isize) {
+    Table::create(dir.to_str().unwrap())
+        .with_table_name("t")
+        .with_table_version(version)
+        .with_record_key_fields(["id"])
+        .with_record_index(true)
+        .with_option("hoodie.parquet.small.file.limit", "0")
+        .create()
+        .await
+        .unwrap();
+    let props = dir.join(".hoodie/hoodie.properties");
+    let rewritten: String = std::fs::read_to_string(&props)
+        .unwrap()
+        .lines()
+        .map(|line| {
+            if line.starts_with("hoodie.table.keygenerator.type=") {
+                "hoodie.table.keygenerator.type=COMPLEX\n".to_string()
+            } else {
+                format!("{line}\n")
+            }
+        })
+        .collect();
+    std::fs::write(&props, rewritten).unwrap();
+    let mut writer = Table::new_with_options(
+        dir.to_str().unwrap(),
+        [("hoodie.write.complex.keygen.validation.enable", "false")],
+    )
+    .await
+    .unwrap();
+    for (group, base) in [(0, 0), (1, 100), (2, 200)] {
+        writer.append([batch(group, base)]).await.unwrap();
+    }
+}
+
+#[tokio::test]
+async fn test_key_filter_with_a_complex_key_generator() {
+    let dir = tempdir().unwrap();
+    complex_three_groups(dir.path(), 9).await;
+    let table = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    assert_eq!(
+        strings(
+            &table.read(&ReadOptions::new()).await.unwrap(),
+            "_hoodie_record_key"
+        )[0],
+        "id:k00"
+    );
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "k04")])).await,
+        (1, vec!["k04".to_string()])
+    );
+}
+
+/// Below table version 9 the stored form is not knowable from the table, so
+/// the lookup stays off whatever the reader claims.
+#[tokio::test]
+async fn test_key_filter_with_a_complex_key_generator_below_version_9() {
+    let dir = tempdir().unwrap();
+    complex_three_groups(dir.path(), 8).await;
+    let table = Table::new_with_options(
+        dir.path().to_str().unwrap(),
+        [("hoodie.write.complex.keygen.new.encoding", "true")],
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        slices_and_ids(&table, &filters(&[("id", "=", "k04")])).await,
+        (3, vec!["k04".to_string()])
+    );
+}
+
+/// The index describes merged rows, so a read-optimized read, which reads
+/// base files only, does not narrow by it: a key deleted in a log file is
+/// still in its base file.
+#[tokio::test]
+async fn test_a_read_optimized_read_does_not_use_the_index() {
+    let dir = tempdir().unwrap();
+    three_groups(dir.path(), TableTypeValue::MergeOnRead, true).await;
+    let mut writer = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    writer
+        .delete_keys([HoodieKey {
+            record_key: "k04".to_string(),
+            partition_path: String::new(),
+        }])
+        .await
+        .unwrap();
+
+    let reader = Table::new(dir.path().to_str().unwrap()).await.unwrap();
+    let options = filters(&[("id", "=", "k04")])
+        .with_hudi_option("hoodie.read.use.read_optimized.mode", "true");
+    let (_, ids) = slices_and_ids(&reader, &options).await;
+    assert_eq!(ids, vec!["k04".to_string()]);
+}
+
+/// A commit pending when the table was opened and completed before this
+/// handle writes is part of the snapshot the handle reads after its write.
+#[tokio::test]
+async fn test_a_read_after_a_write_sees_a_commit_that_was_pending_at_open() {
+    let dir = tempdir().unwrap();
+    let path = dir.path().to_str().unwrap();
+    let mut table = Table::create(path)
+        .with_table_name("t")
+        .with_record_key_fields(["id"])
+        .with_record_index(true)
+        .with_option("hoodie.parquet.small.file.limit", "0")
+        .create()
+        .await
+        .unwrap();
+    table.append([batch(0, 0)]).await.unwrap();
+    table.append([batch(1, 100)]).await.unwrap();
+    let pending_instant = Table::new(path)
+        .await
+        .unwrap()
+        .get_timeline()
+        .completed_commits
+        .iter()
+        .map(|i| i.timestamp.clone())
+        .max()
+        .unwrap();
+
+    // Open the handle while the second append looks in flight.
+    let timeline = dir.path().join(".hoodie/timeline");
+    let completed = std::fs::read_dir(&timeline)
+        .unwrap()
+        .map(|e| e.unwrap().path())
+        .find(|p| {
+            let name = p.file_name().unwrap().to_string_lossy().to_string();
+            name.starts_with(&format!("{pending_instant}_")) && 
name.ends_with(".commit")
+        })
+        .unwrap();
+    let hidden = dir.path().join("hidden.commit");
+    std::fs::rename(&completed, &hidden).unwrap();
+    let mut handle = Table::new(path).await.unwrap();
+    std::fs::rename(&hidden, &completed).unwrap();
+
+    handle.append([batch(2, 200)]).await.unwrap();
+    let (_, ids) = slices_and_ids(&handle, &filters(&[("id", "=", 
"k04")])).await;
+    assert_eq!(ids, vec!["k04".to_string()]);
+}

Reply via email to