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 9b09398d feat(core): select the file group reader by version, 
defaulting to 2 (#677)
9b09398d is described below

commit 9b09398d2848eeae722847c5b6276c719a294c40
Author: Lin Liu <[email protected]>
AuthorDate: Thu Aug 13 08:42:43 2026 -0700

    feat(core): select the file group reader by version, defaulting to 2 (#677)
---
 crates/core/src/config/mod.rs        |   9 +
 crates/core/src/config/read.rs       |  87 ++++++++
 crates/core/src/file_group/reader.rs | 375 ++++++++++++++++++++++++++++++++++-
 3 files changed, 469 insertions(+), 2 deletions(-)

diff --git a/crates/core/src/config/mod.rs b/crates/core/src/config/mod.rs
index 82e24d6a..0de21ffa 100644
--- a/crates/core/src/config/mod.rs
+++ b/crates/core/src/config/mod.rs
@@ -250,6 +250,15 @@ impl HudiConfigs {
         self.raw_options.contains_key(key.as_ref())
     }
 
+    /// Look up a raw value without copying the option map.
+    ///
+    /// For a key this crate has no typed config for. Prefer [`Self::try_get`]
+    /// where one exists — it parses, and errors rather than silently returning
+    /// the default when a value is malformed.
+    pub fn get_raw(&self, key: impl AsRef<str>) -> Option<&str> {
+        self.raw_options.get(key.as_ref()).map(String::as_str)
+    }
+
     /// Get value for the given config. Return [Result] with the value.
     /// If the config is not found or value was not parsed properly, return 
[Err].
     pub fn get(
diff --git a/crates/core/src/config/read.rs b/crates/core/src/config/read.rs
index 10b0e5b2..501245a8 100644
--- a/crates/core/src/config/read.rs
+++ b/crates/core/src/config/read.rs
@@ -97,6 +97,17 @@ pub enum HudiReadConfig {
     /// When set to true, only base files will be read for optimized reads.
     /// This is only applicable to Merge-On-Read (MOR) tables.
     UseReadOptimizedMode,
+    /// Which implementation of the file group reader serves a read: `2`
+    /// (default) or `1`.
+    ///
+    /// A read version 2 cannot serve is served by version 1 instead, so 
neither
+    /// value can make a working read fail. Set `1` explicitly to opt out of 
the
+    /// newer reader entirely.
+    ///
+    /// An unrecognised value is an error rather than a fall back to the 
default
+    /// — silently reading with the other implementation would leave a caller
+    /// convinced they had exercised the one they asked for.
+    FileGroupReaderVersion,
 
     /// Target number of rows per batch for streaming reads.
     /// This controls the batch size when using streaming APIs.
@@ -118,6 +129,7 @@ impl HudiReadConfig {
             Self::EndTimestamp => "hoodie.read.end.timestamp",
             Self::InputPartitions => "hoodie.read.input.partitions",
             Self::UseReadOptimizedMode => 
"hoodie.read.use.read_optimized.mode",
+            Self::FileGroupReaderVersion => 
"hoodie.read.file.group.reader.version",
             Self::StreamBatchSize => "hoodie.read.stream.batch_size",
             Self::FileSliceReadConcurrency => 
"hoodie.read.file.slice.read.concurrency",
         }
@@ -136,6 +148,62 @@ impl Display for HudiReadConfig {
     }
 }
 
+/// Which implementation of the file group reader serves a read.
+///
+/// Numbered rather than named after a strategy, because the older one is being
+/// retired rather than kept as an alternative: a version says newer supersedes
+/// older, where a name like `batch_merge` would imply a permanent choice.
+/// Matches how Hudi already versions `hoodie.table.version` and
+/// `hoodie.timeline.layout.version`.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum FileGroupReaderVersion {
+    /// The reader that has always served reads: whole-batch sort and dedup.
+    /// Reachable explicitly, as an escape hatch, and reached by fall back
+    /// whenever [`Self::Two`] cannot serve a read.
+    One,
+    /// The merge-on-read reader being ported in, and the default.
+    ///
+    /// It does not serve every read yet. Anything it cannot serve is served by
+    /// [`Self::One`] instead, which is why it can be the default this early:
+    /// what changes a read is the reader gaining a capability, not this 
setting.
+    #[default]
+    Two,
+}
+
+impl FileGroupReaderVersion {
+    /// The integer a caller writes in config.
+    pub fn as_usize(&self) -> usize {
+        match self {
+            Self::One => 1,
+            Self::Two => 2,
+        }
+    }
+}
+
+impl TryFrom<usize> for FileGroupReaderVersion {
+    type Error = ConfigError;
+
+    fn try_from(value: usize) -> std::result::Result<Self, Self::Error> {
+        match value {
+            1 => Ok(Self::One),
+            2 => Ok(Self::Two),
+            v => Err(InvalidValue(v.to_string())),
+        }
+    }
+}
+
+impl FromStr for FileGroupReaderVersion {
+    type Err = ConfigError;
+
+    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+        match s.trim() {
+            "1" => Ok(Self::One),
+            "2" => Ok(Self::Two),
+            v => Err(InvalidValue(v.to_string())),
+        }
+    }
+}
+
 impl ConfigParser for HudiReadConfig {
     type Output = HudiConfigValue;
 
@@ -146,6 +214,9 @@ impl ConfigParser for HudiReadConfig {
             )),
             HudiReadConfig::InputPartitions => 
Some(HudiConfigValue::UInteger(0usize)),
             HudiReadConfig::UseReadOptimizedMode => 
Some(HudiConfigValue::Boolean(false)),
+            HudiReadConfig::FileGroupReaderVersion => 
Some(HudiConfigValue::UInteger(
+                FileGroupReaderVersion::default().as_usize(),
+            )),
             HudiReadConfig::StreamBatchSize => 
Some(HudiConfigValue::UInteger(1024usize)),
             HudiReadConfig::FileSliceReadConcurrency => 
Some(HudiConfigValue::UInteger(4usize)),
             _ => None,
@@ -170,6 +241,9 @@ impl ConfigParser for HudiReadConfig {
                     usize::from_str(v).map_err(|e| ParseInt(self.key(), 
v.to_string(), e))
                 })
                 .map(HudiConfigValue::UInteger),
+            Self::FileGroupReaderVersion => get_result
+                .and_then(FileGroupReaderVersion::from_str)
+                .map(|v| HudiConfigValue::UInteger(v.as_usize())),
             Self::UseReadOptimizedMode => get_result
                 .and_then(|v| {
                     bool::from_str(v).map_err(|e| ParseBool(self.key(), 
v.to_string(), e))
@@ -310,6 +384,19 @@ mod tests {
         assert!(EndTimestamp.default_value().is_none());
     }
 
+    #[test]
+    fn 
file_group_reader_version_try_from_usize_accepts_1_and_2_and_rejects_others() {
+        assert_eq!(
+            FileGroupReaderVersion::try_from(1).unwrap(),
+            FileGroupReaderVersion::One
+        );
+        assert_eq!(
+            FileGroupReaderVersion::try_from(2).unwrap(),
+            FileGroupReaderVersion::Two
+        );
+        assert!(FileGroupReaderVersion::try_from(3).is_err());
+    }
+
     #[test]
     fn query_type_from_str_accepts_case_insensitive_and_rejects_invalid() {
         assert_eq!(
diff --git a/crates/core/src/file_group/reader.rs 
b/crates/core/src/file_group/reader.rs
index df691160..42999f12 100644
--- a/crates/core/src/file_group/reader.rs
+++ b/crates/core/src/file_group/reader.rs
@@ -18,7 +18,7 @@
  */
 use crate::Result;
 use crate::config::HudiConfigs;
-use crate::config::read::HudiReadConfig;
+use crate::config::read::{FileGroupReaderVersion, HudiReadConfig};
 use crate::config::table::{BaseFileFormatValue, HudiTableConfig};
 use crate::error::CoreError;
 use crate::error::CoreError::ReadFileSliceError;
@@ -221,6 +221,98 @@ impl FileGroupReader {
             .await
     }
 
+    /// Which merge implementation serves this read.
+    ///
+    /// A metadata table is always served by version 1 whatever the
+    /// setting says: its base files and log blocks are HFile, which the
+    /// file group reader version 2 has no support for. That is permanent, not
+    /// transitional.
+    ///
+    /// The value is read raw rather than through `get_or_default`, which falls
+    /// back to the default when a value fails to parse. A typo in the version
+    /// would then silently read with the other version, leaving a caller
+    /// convinced they had exercised it — the one outcome this switch must not
+    /// produce.
+    fn file_group_reader_version(&self) -> Result<FileGroupReaderVersion> {
+        if self.is_metadata_table() {
+            return Ok(FileGroupReaderVersion::One);
+        }
+        // `try_get` rather than `get_or_default`: the latter returns the 
default
+        // when a value fails to parse, so a typo would silently read with the
+        // other version and leave a caller convinced they had exercised the 
one
+        // they asked for. Borrowing, so no copy of the config map per read.
+        match self
+            .hudi_configs
+            .try_get(HudiReadConfig::FileGroupReaderVersion)?
+        {
+            Some(value) => {
+                
FileGroupReaderVersion::try_from(usize::from(value)).map_err(CoreError::Config)
+            }
+            None => Ok(FileGroupReaderVersion::default()),
+        }
+    }
+
+    /// Why file group reader version 2 cannot serve this read, if it cannot.
+    ///
+    /// This is a capability check, decided from config before any I/O — never 
a
+    /// catch-all on error. A read that fails *inside* version 2 propagates:
+    /// retrying it on version 1 would make a bug look like a success,
+    /// make results depend on which reader happened to win, and leave the
+    /// differential tests unable to see anything.
+    ///
+    /// Every reason here means version 1 serves the read instead, so
+    /// selecting a version cannot turn a working read into a failing one. Each
+    /// reason is logged, because a fallback nobody can observe is
+    /// indistinguishable from a reader that is never used.
+    fn version_two_unsupported_reason(
+        &self,
+        options: &ReadOptions,
+        base_file_only: bool,
+    ) -> Result<Option<&'static str>> {
+        // Deliberately an error rather than a fallback: falling back would use
+        // version 1's own merge derivation, which drops deletes on a
+        // commit-time-ordered table. Wrong rows are worse than a refusal.
+        //
+        // Only when a merge actually happens, though. A slice with nothing to
+        // merge — copy-on-write, or read-optimized — returns base rows without
+        // consulting a merger at all, so refusing it would break reads that 
work
+        // today over a mode they never reach.
+        if !base_file_only
+            // Read by raw key: this crate has no typed config for it yet, and
+            // adding one belongs with the reader that acts on it. Borrowed, so
+            // no copy of the option map per read.
+            && let Some(mode) = 
self.hudi_configs.get_raw("hoodie.record.merge.mode")
+            && mode.eq_ignore_ascii_case("CUSTOM")
+        {
+            return Err(CoreError::Unsupported(
+                "A table with a CUSTOM record merge mode needs its own merger, 
\
+                 which no reader here implements"
+                    .to_string(),
+            ));
+        }
+
+        // Unreachable while `file_group_reader_version` routes a metadata 
table
+        // to version 1 above; kept so a future change to that routing fails
+        // loudly here rather than reaching a reader that cannot read HFile.
+        if self.is_metadata_table() {
+            return Err(CoreError::Unsupported(
+                "File group reader version 2 cannot read a metadata table's 
HFile \
+                 base files and log blocks"
+                    .to_string(),
+            ));
+        }
+
+        if options.is_read_optimized()? {
+            return Ok(Some("read-optimized reads are not served yet"));
+        }
+        // The fallthrough reports *unsupported*, deliberately: capability is
+        // enumerated, not assumed, so a situation nobody considered falls back
+        // rather than being served by a reader that has never seen it.
+        // Inverting this is a one-line change with no visible symptom, which 
is
+        // why it is called out here.
+        Ok(Some("file group reader version 2 is not wired up yet"))
+    }
+
     /// Reads a file slice from a base file and a list of log files.
     ///
     /// `options.filters` are applied as a row-level mask after reading;
@@ -244,7 +336,35 @@ impl FileGroupReader {
             .collect();
         let base_file_only = log_file_paths.is_empty() || 
options.is_read_optimized()?;
 
+        if self.file_group_reader_version()? == FileGroupReaderVersion::Two {
+            match self.version_two_unsupported_reason(&options, 
base_file_only)? {
+                None => {
+                    // Claiming a capability is claiming the rows are right, 
and
+                    // nothing in this crate can check that at runtime: a 
reader
+                    // that knew its answer was wrong would not be wrong. Only 
a
+                    // differential comparison against Hudi's own reader can, 
and
+                    // that is a test harness. So a capability may only be 
added
+                    // together with fixture coverage proving it.
+                    return Err(CoreError::Unsupported(
+                        "File group reader version 2 reports itself able to 
serve this read, \
+                         but nothing is wired up behind the switch yet. A 
capability must \
+                         not be claimed here before there is fixture coverage 
comparing its \
+                         output against Hudi's reader"
+                            .to_string(),
+                    ));
+                }
+                Some(reason) => {
+                    log::debug!(
+                        "reading '{base_file_path}' with file group reader 
version 1: {reason}"
+                    )
+                }
+            }
+        }
+
         let merged = if base_file_only {
+            // Nothing to merge — a copy-on-write slice, or a read-optimized 
read
+            // that ignores the log files. Served by the base file reader, not 
by
+            // either file group reader, so the version above does not reach 
it.
             self.read_base_file_eager(base_file_path).await?
         } else {
             let instant_range = self.create_instant_range_for_log_file_scan()?;
@@ -1288,7 +1408,7 @@ mod tests {
     // Metadata Table File Slice Reading Tests
     // 
=========================================================================
 
-    fn get_metadata_table_base_uri() -> String {
+    pub(super) fn get_metadata_table_base_uri() -> String {
         use hudi_test::QuickstartTripsTable;
         let table_path = 
QuickstartTripsTable::V8Trips8I3U1D.path_to_mor_avro();
         let metadata_table_path = 
PathBuf::from(table_path).join(".hoodie").join("metadata");
@@ -1568,3 +1688,254 @@ mod tests {
         Ok(())
     }
 }
+
+#[cfg(test)]
+mod file_group_reader_version_tests {
+    use super::*;
+    use hudi_test::SampleTable;
+
+    async fn reader_with(
+        options: impl IntoIterator<Item = (&'static str, String)>,
+    ) -> Result<FileGroupReader> {
+        let base_url = SampleTable::V6Nonpartitioned.url_to_mor_parquet();
+        FileGroupReader::new_with_options(base_url.as_ref(), options).await
+    }
+
+    /// File group reader version 2 is the default, and nothing changes for a 
caller
+    /// who sets nothing — because every capability falls back today. Making it
+    /// the default only once it were capable would put the whole behaviour 
change
+    /// in one commit; this way each capability carries its own.
+    #[tokio::test]
+    async fn test_file_group_reader_version_unset_returns_two() -> Result<()> {
+        let reader = reader_with(Vec::<(&'static str, String)>::new()).await?;
+        assert_eq!(
+            reader.file_group_reader_version()?,
+            FileGroupReaderVersion::Two
+        );
+        Ok(())
+    }
+
+    /// Version 1 remains reachable, so a caller can opt out of version 2
+    /// entirely rather than relying on it to keep falling back.
+    #[tokio::test]
+    async fn test_file_group_reader_version_one_returns_one() -> Result<()> {
+        let reader = reader_with([(
+            HudiReadConfig::FileGroupReaderVersion.as_ref(),
+            "1".to_string(),
+        )])
+        .await?;
+        assert_eq!(
+            reader.file_group_reader_version()?,
+            FileGroupReaderVersion::One
+        );
+        Ok(())
+    }
+
+    /// The guard inside the check, reached only if the dispatch's metadata
+    /// routing were ever removed. Asserted directly because the dispatch 
answers
+    /// metadata tables before the check runs, so no read can reach it today —
+    /// which is exactly why it must keep erroring rather than fall through to 
a
+    /// reader that cannot read HFile.
+    #[tokio::test]
+    async fn 
test_version_two_unsupported_reason_metadata_table_returns_error() -> 
Result<()> {
+        use crate::config::HudiConfigs;
+        use crate::config::table::HudiTableConfig;
+        use std::collections::HashMap;
+        use std::sync::Arc;
+
+        let configs = Arc::new(HudiConfigs::new([(
+            HudiTableConfig::BasePath.as_ref(),
+            super::tests::get_metadata_table_base_uri(),
+        )]));
+        let reader = FileGroupReader::new_with_overrides(configs, 
HashMap::new(), HashMap::new())?;
+        let err = reader
+            .version_two_unsupported_reason(&ReadOptions::new(), false)
+            .unwrap_err();
+        assert!(
+            matches!(err, CoreError::Unsupported(ref m) if 
m.contains("metadata table")),
+            "expected an unsupported error naming the metadata table, got 
{err:?}"
+        );
+        Ok(())
+    }
+
+    /// A typo must not read with the other version. `get_or_default` would 
have
+    /// swallowed this and left the caller believing they had exercised `v2`.
+    #[tokio::test]
+    async fn 
test_file_group_reader_version_unrecognised_returns_config_error() -> 
Result<()> {
+        let reader = reader_with([(
+            HudiReadConfig::FileGroupReaderVersion.as_ref(),
+            "9".to_string(),
+        )])
+        .await?;
+        let err = reader.file_group_reader_version().unwrap_err();
+        assert!(
+            matches!(err, CoreError::Config(_)),
+            "expected a config error, got {err:?}"
+        );
+        assert!(
+            err.to_string().contains("9"),
+            "the error must name the value"
+        );
+        Ok(())
+    }
+
+    /// Asking for a version is a request, not a guarantee: every capability is
+    /// unimplemented so far, so version 1 serves the read and says why.
+    #[tokio::test]
+    async fn 
test_version_two_unsupported_reason_nothing_implemented_returns_reason() -> 
Result<()>
+    {
+        let reader = reader_with([(
+            HudiReadConfig::FileGroupReaderVersion.as_ref(),
+            "2".to_string(),
+        )])
+        .await?;
+        assert_eq!(
+            reader.file_group_reader_version()?,
+            FileGroupReaderVersion::Two
+        );
+
+        let reason = 
reader.version_two_unsupported_reason(&ReadOptions::new(), false)?;
+        assert!(
+            reason.is_some(),
+            "with nothing wired up, every read must fall back"
+        );
+        Ok(())
+    }
+
+    /// The fall back is what makes the default safe: a read works exactly as 
it
+    /// did, because the existing reader served it either way.
+    #[tokio::test]
+    async fn 
test_read_file_slice_from_paths_default_version_matches_version_one() -> 
Result<()> {
+        let base_url = SampleTable::V6Nonpartitioned.url_to_mor_parquet();
+        let table = crate::table::Table::new(base_url.path()).await?;
+        let slices = table.get_file_slices(&ReadOptions::new()).await?;
+        assert!(!slices.is_empty(), "fixture must have a file slice to read");
+
+        let read_with = async |version: Option<&str>| -> Result<Vec<String>> {
+            let options: Vec<(&str, String)> = match version {
+                Some(e) => vec![(
+                    HudiReadConfig::FileGroupReaderVersion.as_ref(),
+                    e.to_string(),
+                )],
+                None => Vec::new(),
+            };
+            let reader = FileGroupReader::new_with_options(base_url.as_ref(), 
options).await?;
+            // Rendered cell by cell rather than counted: a row count would
+            // match even if the columns or the values differed.
+            let mut rendered: Vec<String> = Vec::new();
+            for slice in &slices {
+                let batch = reader.read_file_slice(slice, 
&ReadOptions::new()).await?;
+                let names: Vec<String> = batch
+                    .schema()
+                    .fields()
+                    .iter()
+                    .map(|f| f.name().clone())
+                    .collect();
+                rendered.push(names.join(","));
+                for row in 0..batch.num_rows() {
+                    let cells: Vec<String> = batch
+                        .columns()
+                        .iter()
+                        .map(|col| {
+                            
arrow_cast::display::array_value_to_string(col.as_ref(), row)
+                                .unwrap_or_else(|_| 
"<unrenderable>".to_string())
+                        })
+                        .collect();
+                    rendered.push(cells.join(" | "));
+                }
+            }
+            rendered.sort();
+            Ok(rendered)
+        };
+
+        assert_eq!(
+            read_with(None).await?,
+            read_with(Some("1")).await?,
+            "the default must return what an explicit version 1 read returns"
+        );
+        Ok(())
+    }
+
+    /// A table declaring a CUSTOM record merge mode still reads when there is
+    /// nothing to merge.
+    ///
+    /// The refusal exists because falling back would merge with version 1's 
own
+    /// derivation, which drops deletes. But a copy-on-write slice and a
+    /// read-optimized read never consult a merger, so refusing them would 
break
+    /// reads that work today over a mode they never reach — and version 2 
being
+    /// the default means nobody opted in to that.
+    #[tokio::test]
+    async fn 
test_version_two_unsupported_reason_custom_merge_mode_without_merge_returns_reason()
+    -> Result<()> {
+        let reader = reader_with([("hoodie.record.merge.mode", 
"CUSTOM".to_string())]).await?;
+
+        // Nothing to merge: falls back like any other unimplemented 
capability.
+        assert!(
+            reader
+                .version_two_unsupported_reason(&ReadOptions::new(), true)?
+                .is_some(),
+            "a read with nothing to merge must not be refused for a merge mode"
+        );
+
+        // A read-optimized read reaches the same conclusion through `options`.
+        let read_optimized = ReadOptions::new()
+            .with_hudi_option(HudiReadConfig::UseReadOptimizedMode.as_ref(), 
"true");
+        assert!(
+            reader
+                .version_two_unsupported_reason(&read_optimized, true)?
+                .is_some()
+        );
+        Ok(())
+    }
+
+    /// The same table is refused once a merge is actually involved.
+    #[tokio::test]
+    async fn 
test_version_two_unsupported_reason_custom_merge_mode_with_merge_returns_error()
+    -> Result<()> {
+        let reader = reader_with([("hoodie.record.merge.mode", 
"CUSTOM".to_string())]).await?;
+
+        let err = reader
+            .version_two_unsupported_reason(&ReadOptions::new(), false)
+            .unwrap_err();
+        assert!(
+            matches!(err, CoreError::Unsupported(_)),
+            "expected a refusal, got {err:?}"
+        );
+        assert!(
+            err.to_string().contains("CUSTOM"),
+            "the error must name why"
+        );
+        Ok(())
+    }
+
+    /// A metadata table is served by version 1 whatever the setting
+    /// says, so setting the version globally cannot make one unreadable — 
table
+    /// listing itself reads one.
+    #[tokio::test]
+    async fn test_file_group_reader_version_metadata_table_returns_one() -> 
Result<()> {
+        use crate::config::HudiConfigs;
+        use crate::config::table::HudiTableConfig;
+        use std::collections::HashMap;
+        use std::sync::Arc;
+
+        // Built from configs rather than resolved from storage: a metadata 
table
+        // has no `hoodie.properties` of its own to load.
+        let configs = Arc::new(HudiConfigs::new([
+            (
+                HudiTableConfig::BasePath.as_ref(),
+                super::tests::get_metadata_table_base_uri(),
+            ),
+            (
+                HudiReadConfig::FileGroupReaderVersion.as_ref(),
+                "2".to_string(),
+            ),
+        ]));
+        let reader = FileGroupReader::new_with_overrides(configs, 
HashMap::new(), HashMap::new())?;
+        assert!(reader.is_metadata_table());
+        assert_eq!(
+            reader.file_group_reader_version()?,
+            FileGroupReaderVersion::One
+        );
+        Ok(())
+    }
+}

Reply via email to