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 3c8ad5e9 feat(core): read any metadata table partition as Arrow (#819)
3c8ad5e9 is described below

commit 3c8ad5e9cd9b6d5194414bc4fa10eb183b52d748
Author: Steven Lee <[email protected]>
AuthorDate: Wed Oct 28 07:29:47 2026 +0800

    feat(core): read any metadata table partition as Arrow (#819)
    
    * feat(core): read any metadata table partition as Arrow
    
    Generalises the metadata-table Arrow read so a caller can read one
    partition — record_index, column_stats, bloom_filters, partition_stats,
    or a secondary_index_* — not just files.
    
    - MetadataTableV2Reader::read_files_partition_batch becomes
      read_partition_batch (partition name parameterised); the read path is
      identical, only the partition the reader is pointed at changes.
    - MetadataTable::read_files_partition_batch becomes a thin wrapper over a
      new read_metadata_table_partition_batch.
    - New public Table::read_metadata_table_partition_arrow(partition, keys)
      for DATA tables, mirroring read_metadata_table_files_partition_arrow.
    
    Motivated by the DuckDB Hudi extension (alitrack/duckdb-hudi) exposing
    Hudi index data under DuckDB SQL: the files index is already exposed
    (hudi_mdt) and column_stats is derived from parquet footers (hudi_stats);
    record index / bloom / secondary indexes need this read.
    
    * test(core): cover reading the record index as Arrow and reject undeclared 
partitions
    
    Read `record_index` through `read_metadata_table_partition_arrow` on the v9
    txns fixture: a keyed read opens one shard and returns the key as stored, an
    unknown key is an empty batch, and a full scan holds one entry per live
    record. A partition the table does not declare in
    `hoodie.table.metadata.partitions` is now an error rather than an empty
    batch, since the two were indistinguishable.
    
    ---------
    
    Co-authored-by: lihongyan <[email protected]>
---
 crates/core/src/metadata/table/mod.rs       | 135 +++++++++++++++++++++++++++-
 crates/core/src/metadata/table/v2_reader.rs |  21 +++--
 2 files changed, 148 insertions(+), 8 deletions(-)

diff --git a/crates/core/src/metadata/table/mod.rs 
b/crates/core/src/metadata/table/mod.rs
index f3b499ab..bc039467 100644
--- a/crates/core/src/metadata/table/mod.rs
+++ b/crates/core/src/metadata/table/mod.rs
@@ -210,6 +210,53 @@ impl Table {
             .await
     }
 
+    /// Read one metadata-table partition as an Arrow batch, undecoded.
+    ///
+    /// The generalisation of 
[`Self::read_metadata_table_files_partition_arrow`]:
+    /// `partition` is the metadata table partition to read — one of `files`,
+    /// `column_stats`, `partition_stats`, `bloom_filters`, `record_index`, or 
a
+    /// `secondary_index_<name>` value (see
+    /// [`Self::get_metadata_table_partitions`]). `files` is the only partition
+    /// with a decoded record type; the others come back as raw Arrow in their
+    /// stored schema, so callers that want them should consume the batch
+    /// directly.
+    ///
+    /// `keys` empty reads every record; otherwise only those keys, matched
+    /// **as stored**.
+    ///
+    /// An empty batch when the partition has no commits yet.
+    ///
+    /// # Errors
+    /// [`CoreError::MetadataTable`] when `partition` is not one the table
+    /// declares in `hoodie.table.metadata.partitions`. An empty batch there
+    /// would be indistinguishable from a declared partition that holds no
+    /// records, and an undeclared name is most likely a typo.
+    ///
+    /// A file group that is log-only and holds nothing but tombstones fails in
+    /// the merge with "No schema available for merge output", since no record
+    /// survives to lend the output its schema. Not yet handled.
+    ///
+    /// # Note
+    /// Must be called on a DATA table, not a METADATA table.
+    pub async fn read_metadata_table_partition_arrow(
+        &self,
+        partition: &str,
+        keys: &[&str],
+    ) -> Result<arrow_array::RecordBatch> {
+        let declared = self.get_metadata_table_partitions();
+        if !declared.iter().any(|p| p == partition) {
+            return Err(CoreError::MetadataTable(format!(
+                "Metadata table partition '{partition}' is not declared on 
this table; \
+                 declared partitions: {declared:?}"
+            )));
+        }
+        let metadata_table = self.get_or_init_metadata_table().await?;
+        let valid = self.valid_instant_timestamps(metadata_table).await?;
+        metadata_table
+            .read_metadata_table_partition_batch(partition, keys, &valid)
+            .await
+    }
+
     /// Fetch records from the `files` partition with optional partition 
pruning.
     ///
     /// For non-partitioned tables, directly fetches the "." record.
@@ -306,7 +353,28 @@ impl Table {
         keys: &[&str],
         valid_instants: &HashSet<String>,
     ) -> Result<arrow_array::RecordBatch> {
-        let Some((reader, file_slices)) = self.files_partition_reader().await? 
else {
+        self.read_metadata_table_partition_batch(
+            FilesPartitionRecord::PARTITION_NAME,
+            keys,
+            valid_instants,
+        )
+        .await
+    }
+
+    /// The merged batch of one metadata partition, for a caller that wants 
Arrow.
+    ///
+    /// An empty batch when the partition is not enabled or has no commits,
+    /// matching the empty map the decoded form returns in that case.
+    ///
+    /// # Note
+    /// Must be called on a METADATA table instance.
+    pub(crate) async fn read_metadata_table_partition_batch(
+        &self,
+        partition: &str,
+        keys: &[&str],
+        valid_instants: &HashSet<String>,
+    ) -> Result<arrow_array::RecordBatch> {
+        let Some((reader, file_slices)) = 
self.partition_reader(partition).await? else {
             return Ok(arrow_array::RecordBatch::new_empty(std::sync::Arc::new(
                 Schema::empty(),
             )));
@@ -316,7 +384,7 @@ impl Table {
         let batches = crate::util::concurrency::bounded_in_order(
             &targets,
             self.bounded_read_concurrency_for(&targets),
-            |file_slice| reader.read_files_partition_batch(file_slice, keys),
+            |file_slice| reader.read_partition_batch(file_slice, keys, 
partition),
         )
         .await?;
         concat_metadata_batches(batches)
@@ -1549,4 +1617,67 @@ mod tests {
         );
         Ok(())
     }
+
+    /// The record index through the public Arrow API, on a table whose index 
is
+    /// sharded across file groups. A keyed read opens one shard and comes back
+    /// keyed as stored; an unknown key is empty rather than an error; a full
+    /// scan holds one entry per live record.
+    #[tokio::test]
+    async fn read_metadata_table_partition_arrow_reads_the_record_index() -> 
Result<()> {
+        use arrow_array::{Array, StringArray};
+
+        let table = 
Table::new(&SampleTable::V9TxnsSimpleMeta.path_to_cow()).await?;
+
+        let batch = table
+            .read_metadata_table_partition_arrow("record_index", &["TXN-011"])
+            .await?;
+        assert_eq!(batch.num_rows(), 1, "one live key is one index entry");
+        let keys = batch
+            .column_by_name("key")
+            .expect("the record index keys on a `key` column")
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .expect("`key` is a string column");
+        assert_eq!(keys.value(0), "TXN-011");
+
+        let batch = table
+            .read_metadata_table_partition_arrow("record_index", 
&["no-such-key"])
+            .await?;
+        assert_eq!(
+            batch.num_rows(),
+            0,
+            "a missing key is an empty batch, not an error"
+        );
+
+        let live_rows: usize = table
+            .read(&crate::table::ReadOptions::new())
+            .await?
+            .iter()
+            .map(|batch| batch.num_rows())
+            .sum();
+        let batch = table
+            .read_metadata_table_partition_arrow("record_index", &[])
+            .await?;
+        assert_eq!(
+            batch.num_rows(),
+            live_rows,
+            "a full scan holds one entry per live record"
+        );
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn 
read_metadata_table_partition_arrow_rejects_an_undeclared_partition() -> 
Result<()> {
+        let table = 
Table::new(&SampleTable::V9TxnsSimpleMeta.path_to_cow()).await?;
+        let err = table
+            .read_metadata_table_partition_arrow("secondary_idx_merchant", &[])
+            .await
+            .unwrap_err();
+        assert!(matches!(err, CoreError::MetadataTable(_)), "got {err}");
+        assert!(
+            err.to_string().contains("secondary_idx_merchant"),
+            "the error must name the partition, got {err}"
+        );
+        Ok(())
+    }
 }
diff --git a/crates/core/src/metadata/table/v2_reader.rs 
b/crates/core/src/metadata/table/v2_reader.rs
index 69f09125..a0176ceb 100644
--- a/crates/core/src/metadata/table/v2_reader.rs
+++ b/crates/core/src/metadata/table/v2_reader.rs
@@ -103,20 +103,29 @@ impl MetadataTableV2Reader {
         file_slice: &FileSlice,
         keys: &[&str],
     ) -> Result<HashMap<String, FilesPartitionRecord>> {
-        let batch = self.read_files_partition_batch(file_slice, keys).await?;
+        let batch = self
+            .read_partition_batch(file_slice, keys, 
FilesPartitionRecord::PARTITION_NAME)
+            .await?;
         Self::records_from_batch(&batch, keys)
     }
 
-    /// The merged `files` partition batch, before it is decoded into records.
+    /// The merged batch of one metadata partition, before it is decoded into
+    /// records.
     ///
     /// Separate because the batch is what an Arrow consumer wants and 
decoding it
     /// only to re-encode would be work in both directions. Nothing about the 
read
     /// differs between the two; this is the read, and the other is this plus a
     /// decode.
-    pub(crate) async fn read_files_partition_batch(
+    ///
+    /// `partition` is the metadata table partition to read (its Hudi partition
+    /// value: `files`, `record_index`, `column_stats`, `bloom_filters`,
+    /// `partition_stats`, `secondary_index_*`). The decoded-record layer only
+    /// exists for `files`; the others come back as raw Arrow.
+    pub(crate) async fn read_partition_batch(
         &self,
         file_slice: &FileSlice,
         keys: &[&str],
+        partition: &str,
     ) -> Result<RecordBatch> {
         let base_file_path = file_slice.base_file_relative_path()?;
         let log_file_paths = if file_slice.has_log_file() {
@@ -137,7 +146,7 @@ impl MetadataTableV2Reader {
         // The metadata table sets `hoodie.populate.meta.fields=false`, so the 
record
         // key is its own `key` column rather than `_hoodie_record_key`. 
Without this
         // the merge keys every record on an empty string and collapses them 
into one.
-        
reader_context.rebuild_record_context(FilesPartitionRecord::PARTITION_NAME.to_string());
+        reader_context.rebuild_record_context(partition.to_string());
 
         // A named-key read is a point lookup, which is the normal shape for 
the
         // metadata table; an empty key list is the full scan the `files` 
partition
@@ -165,7 +174,7 @@ impl MetadataTableV2Reader {
                 base_file_path,
                 base_file_commit_time,
                 log_file_paths,
-                FilesPartitionRecord::PARTITION_NAME.to_string(),
+                partition.to_string(),
             ),
             ReaderParameters::default(),
             None,
@@ -175,7 +184,7 @@ impl MetadataTableV2Reader {
         let batch = reader.read().await?;
         log::debug!(
             "metadata read of '{}' with {} named key(s): merge map peaked at 
{} entries",
-            FilesPartitionRecord::PARTITION_NAME,
+            partition,
             keys.len(),
             reader.read_stats().merge_map_peak_entries
         );

Reply via email to