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

xushiyan 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 f02cabf  feat: add `TimelineSelector` to support timeline loading 
(#233)
f02cabf is described below

commit f02cabfee4985cf9609690d70c852bb9fb9c0cd6
Author: Shiyan Xu <[email protected]>
AuthorDate: Fri Jan 3 20:01:13 2025 -0600

    feat: add `TimelineSelector` to support timeline loading (#233)
---
 Cargo.toml                                         |   1 +
 crates/core/Cargo.toml                             |   1 +
 crates/core/src/config/table.rs                    |  59 ++++
 crates/core/src/lib.rs                             |   1 +
 crates/core/src/storage/util.rs                    |  20 --
 crates/core/src/table/builder.rs                   |   2 +-
 crates/core/src/table/mod.rs                       |   3 +-
 crates/core/src/timeline/instant.rs                | 379 +++++++++++++++++++++
 .../src/{table/timeline.rs => timeline/mod.rs}     | 148 +++-----
 crates/core/src/timeline/selector.rs               | 312 +++++++++++++++++
 10 files changed, 802 insertions(+), 124 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 971e9eb..b5174d9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -63,6 +63,7 @@ serde_json = { version = "1" }
 # "stdlib"
 thiserror = { version = "2.0.3" }
 bytes = { version = "1" }
+chrono = { version = "0.4" }
 paste = { version = "1.0.15" }
 once_cell = { version = "1.19.0" }
 strum = { version = "0.26.3", features = ["derive"] }
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index d7c9d06..25a5158 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -50,6 +50,7 @@ serde_json = { workspace = true }
 # "stdlib"
 thiserror = { workspace = true }
 bytes = { workspace = true }
+chrono = { workspace = true }
 paste = { workspace = true }
 strum = { workspace = true }
 strum_macros = { workspace = true }
diff --git a/crates/core/src/config/table.rs b/crates/core/src/config/table.rs
index 5b82b4c..c058582 100644
--- a/crates/core/src/config/table.rs
+++ b/crates/core/src/config/table.rs
@@ -103,6 +103,9 @@ pub enum HudiTableConfig {
 
     /// Version of timeline used, by the table.
     TimelineLayoutVersion,
+
+    /// Timezone of the timeline timestamps. Default to UTC.
+    TimelineTimezone,
 }
 
 impl AsRef<str> for HudiTableConfig {
@@ -124,6 +127,7 @@ impl AsRef<str> for HudiTableConfig {
             Self::TableType => "hoodie.table.type",
             Self::TableVersion => "hoodie.table.version",
             Self::TimelineLayoutVersion => "hoodie.timeline.layout.version",
+            Self::TimelineTimezone => "hoodie.table.timeline.timezone",
         }
     }
 }
@@ -137,6 +141,9 @@ impl ConfigParser for HudiTableConfig {
             Self::DropsPartitionFields => 
Some(HudiConfigValue::Boolean(false)),
             Self::PartitionFields => Some(HudiConfigValue::List(vec![])),
             Self::PopulatesMetaFields => Some(HudiConfigValue::Boolean(true)),
+            Self::TimelineTimezone => Some(HudiConfigValue::String(
+                TimelineTimezoneValue::UTC.as_ref().to_string(),
+            )),
             _ => None,
         }
     }
@@ -202,6 +209,9 @@ impl ConfigParser for HudiTableConfig {
                     isize::from_str(v).map_err(|e| ParseInt(self.key(), 
v.to_string(), e))
                 })
                 .map(HudiConfigValue::Integer),
+            Self::TimelineTimezone => get_result
+                .and_then(TimelineTimezoneValue::from_str)
+                .map(|v| HudiConfigValue::String(v.as_ref().to_string())),
         }
     }
 }
@@ -246,6 +256,27 @@ impl FromStr for BaseFileFormatValue {
     }
 }
 
+/// Config value for [HudiTableConfig::TimelineTimezone].
+#[derive(Clone, Debug, PartialEq, AsRefStr)]
+pub enum TimelineTimezoneValue {
+    #[strum(serialize = "utc")]
+    UTC,
+    #[strum(serialize = "local")]
+    Local,
+}
+
+impl FromStr for TimelineTimezoneValue {
+    type Err = ConfigError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_ascii_lowercase().as_str() {
+            "utc" => Ok(Self::UTC),
+            "local" => Ok(Self::Local),
+            v => Err(InvalidValue(v.to_string())),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -313,4 +344,32 @@ mod tests {
             UnsupportedValue(_)
         ));
     }
+
+    #[test]
+    fn create_timeline_timezone() {
+        assert_eq!(
+            TimelineTimezoneValue::from_str("utc").unwrap(),
+            TimelineTimezoneValue::UTC
+        );
+        assert_eq!(
+            TimelineTimezoneValue::from_str("uTc").unwrap(),
+            TimelineTimezoneValue::UTC
+        );
+        assert_eq!(
+            TimelineTimezoneValue::from_str("local").unwrap(),
+            TimelineTimezoneValue::Local
+        );
+        assert_eq!(
+            TimelineTimezoneValue::from_str("LOCAL").unwrap(),
+            TimelineTimezoneValue::Local
+        );
+        assert!(matches!(
+            TimelineTimezoneValue::from_str("").unwrap_err(),
+            InvalidValue(_)
+        ));
+        assert!(matches!(
+            TimelineTimezoneValue::from_str("foo").unwrap_err(),
+            InvalidValue(_)
+        ));
+    }
 }
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index c9ce815..58f7417 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -49,6 +49,7 @@ pub mod expr;
 pub mod file_group;
 pub mod storage;
 pub mod table;
+pub mod timeline;
 pub mod util;
 
 use error::Result;
diff --git a/crates/core/src/storage/util.rs b/crates/core/src/storage/util.rs
index e3f32b9..b26e865 100644
--- a/crates/core/src/storage/util.rs
+++ b/crates/core/src/storage/util.rs
@@ -18,31 +18,11 @@
  */
 
 //! Utility functions for storage.
-use std::path::Path;
 use url::Url;
 
 use crate::storage::error::StorageError::{InvalidPath, UrlParseError};
 use crate::storage::Result;
 
-/// Splits a filename into a stem and an extension.
-pub fn split_filename(filename: &str) -> Result<(String, String)> {
-    let path = Path::new(filename);
-
-    let stem = path
-        .file_stem()
-        .and_then(|s| s.to_str())
-        .ok_or(InvalidPath(format!("No file stem found in {}", filename)))?
-        .to_string();
-
-    let extension = path
-        .extension()
-        .and_then(|e| e.to_str())
-        .unwrap_or_default()
-        .to_string();
-
-    Ok((stem, extension))
-}
-
 /// Parses a URI string into a URL.
 pub fn parse_uri(uri: &str) -> Result<Url> {
     let mut url = match Url::parse(uri) {
diff --git a/crates/core/src/table/builder.rs b/crates/core/src/table/builder.rs
index 4ca2997..411ff09 100644
--- a/crates/core/src/table/builder.rs
+++ b/crates/core/src/table/builder.rs
@@ -38,8 +38,8 @@ use crate::config::{HudiConfigs, HUDI_CONF_DIR};
 use crate::error::CoreError;
 use crate::storage::Storage;
 use crate::table::fs_view::FileSystemView;
-use crate::table::timeline::Timeline;
 use crate::table::Table;
+use crate::timeline::Timeline;
 use crate::Result;
 
 /// Builder for creating a [Table] instance.
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index f83aa91..3f6b2c2 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -87,7 +87,6 @@
 pub mod builder;
 mod fs_view;
 pub mod partition;
-mod timeline;
 
 use crate::config::read::HudiReadConfig::AsOfTimestamp;
 use crate::config::table::HudiTableConfig;
@@ -100,7 +99,7 @@ use crate::file_group::FileSlice;
 use crate::table::builder::TableBuilder;
 use crate::table::fs_view::FileSystemView;
 use crate::table::partition::PartitionPruner;
-use crate::table::timeline::Timeline;
+use crate::timeline::Timeline;
 use crate::Result;
 
 use arrow::record_batch::RecordBatch;
diff --git a/crates/core/src/timeline/instant.rs 
b/crates/core/src/timeline/instant.rs
new file mode 100644
index 0000000..f089e90
--- /dev/null
+++ b/crates/core/src/timeline/instant.rs
@@ -0,0 +1,379 @@
+/*
+ * 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.
+ */
+use crate::config::table::TimelineTimezoneValue;
+use crate::error::CoreError;
+use crate::storage::error::StorageError;
+use crate::Result;
+use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Timelike, Utc};
+use std::cmp::Ordering;
+use std::path::PathBuf;
+use std::str::FromStr;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Action {
+    Commit,
+    ReplaceCommit,
+}
+
+impl FromStr for Action {
+    type Err = CoreError;
+
+    fn from_str(s: &str) -> Result<Self> {
+        match s {
+            "commit" => Ok(Action::Commit),
+            "replacecommit" => Ok(Action::ReplaceCommit),
+            _ => Err(CoreError::Timeline(format!("Invalid action: {}", s))),
+        }
+    }
+}
+
+impl Action {
+    pub fn as_str(&self) -> &str {
+        match self {
+            Action::Commit => "commit",
+            Action::ReplaceCommit => "replacecommit",
+        }
+    }
+
+    pub fn is_replacecommit(&self) -> bool {
+        self == &Action::ReplaceCommit
+    }
+}
+
+/// The [State] of an [Instant] represents the status of the action performed 
on the table.
+#[allow(dead_code)]
+#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub enum State {
+    Requested,
+    Inflight,
+    Completed,
+}
+
+impl FromStr for State {
+    type Err = CoreError;
+
+    fn from_str(suffix: &str) -> Result<Self> {
+        match suffix {
+            "requested" => Ok(State::Requested),
+            "inflight" => Ok(State::Inflight),
+            "" => Ok(State::Completed),
+            _ => Err(CoreError::Timeline(format!(
+                "Invalid state suffix: {}",
+                suffix
+            ))),
+        }
+    }
+}
+
+impl State {
+    pub fn as_str(&self) -> &str {
+        match self {
+            State::Requested => "requested",
+            State::Inflight => "inflight",
+            State::Completed => "",
+        }
+    }
+}
+
+/// An [Instant] represents a point in time when an action was performed on 
the table.
+#[allow(dead_code)]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Instant {
+    pub timestamp: String,
+    pub action: Action,
+    pub state: State,
+    pub epoch_millis: i64,
+}
+
+impl PartialOrd for Instant {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for Instant {
+    fn cmp(&self, other: &Self) -> Ordering {
+        self.epoch_millis
+            .cmp(&other.epoch_millis)
+            .then_with(|| self.state.cmp(&other.state))
+    }
+}
+
+impl TryFrom<&str> for Instant {
+    type Error = CoreError;
+
+    /// Parse a timeline file name into an [Instant]. Timezone is assumed to 
be UTC.
+    fn try_from(file_name: &str) -> Result<Self> {
+        Self::try_from((file_name, "UTC"))
+    }
+}
+
+impl TryFrom<(&str, &str)> for Instant {
+    type Error = CoreError;
+
+    /// Parse a timeline file name and a timezone value into an [Instant].
+    /// See [TimelineTimezoneValue] for valid timezone values.
+    fn try_from(file_name_and_timezone: (&str, &str)) -> Result<Self> {
+        let (file_name, timezone) = file_name_and_timezone;
+        let (timestamp, action_suffix) = file_name
+            .split_once('.')
+            .ok_or_else(|| CoreError::Timeline(format!("Invalid file name: 
{}", file_name)))?;
+        Self::validate_timestamp(timestamp)?;
+        let dt = Self::parse_datetime(timestamp, timezone)?;
+        let (action, state) = Self::parse_action_and_state(action_suffix)?;
+
+        Ok(Self {
+            timestamp: timestamp.to_string(),
+            state,
+            action,
+            epoch_millis: dt.timestamp_millis(),
+        })
+    }
+}
+
+impl Instant {
+    fn validate_timestamp(timestamp: &str) -> Result<()> {
+        if !matches!(timestamp.len(), 14 | 17) {
+            return Err(CoreError::Timeline(format!(
+                "Timestamp must be in format yyyyMMddHHmmss or 
yyyyMMddHHmmssSSS, but got: {}",
+                timestamp
+            )));
+        }
+        Ok(())
+    }
+
+    /// Convert a timeline timestamp string in UTC to epoch milliseconds.
+    pub fn parse_utc_as_epoch_millis(timestamp: &str) -> Result<i64> {
+        Self::parse_datetime(timestamp, "UTC").map(|dt| dt.timestamp_millis())
+    }
+
+    pub fn parse_datetime(timestamp: &str, timezone: &str) -> 
Result<DateTime<Utc>> {
+        let naive_dt = Self::parse_naive_datetime(timestamp)?;
+        Self::convert_to_timezone(naive_dt, timezone)
+    }
+
+    fn parse_naive_datetime(timestamp: &str) -> Result<NaiveDateTime> {
+        let naive_dt = NaiveDateTime::parse_from_str(&timestamp[..14], 
"%Y%m%d%H%M%S")
+            .map_err(|e| CoreError::Timeline(format!("Failed to parse 
timestamp: {}", e)))?;
+
+        if timestamp.len() == 17 {
+            let millis: u32 = timestamp[14..]
+                .parse()
+                .map_err(|e| CoreError::Timeline(format!("Failed to parse 
milliseconds: {}", e)))?;
+            naive_dt
+                .with_nanosecond(millis * 1_000_000)
+                .ok_or_else(|| CoreError::Timeline("Invalid 
milliseconds".to_string()))
+        } else {
+            Ok(naive_dt)
+        }
+    }
+
+    fn convert_to_timezone(naive_dt: NaiveDateTime, timezone: &str) -> 
Result<DateTime<Utc>> {
+        match TimelineTimezoneValue::from_str(timezone)? {
+            TimelineTimezoneValue::UTC => 
Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc)),
+            TimelineTimezoneValue::Local => Ok(Local
+                .from_local_datetime(&naive_dt)
+                .earliest()
+                .ok_or_else(|| CoreError::Timeline("Invalid local 
datetime".to_string()))?
+                .to_utc()),
+        }
+    }
+
+    fn parse_action_and_state(action_suffix: &str) -> Result<(Action, State)> {
+        match action_suffix.split_once('.') {
+            Some((action_str, state_str)) => {
+                Ok((Action::from_str(action_str)?, 
State::from_str(state_str)?))
+            }
+            None => match action_suffix {
+                "inflight" => Ok((Action::Commit, State::Inflight)),
+                action_str => Ok((Action::from_str(action_str)?, 
State::Completed)),
+            },
+        }
+    }
+
+    pub fn file_name(&self) -> String {
+        match (&self.action, &self.state) {
+            (_, State::Completed) => format!("{}.{}", self.timestamp, 
self.action.as_str()),
+            (Action::Commit, State::Inflight) => {
+                format!("{}.{}", self.timestamp, self.state.as_str())
+            }
+            _ => format!(
+                "{}.{}.{}",
+                self.timestamp,
+                self.action.as_str(),
+                self.state.as_str()
+            ),
+        }
+    }
+
+    pub fn relative_path(&self) -> Result<String> {
+        let mut commit_file_path = PathBuf::from(".hoodie");
+        commit_file_path.push(self.file_name());
+        commit_file_path
+            .to_str()
+            .ok_or(StorageError::InvalidPath(format!(
+                "Failed to get file path for {:?}",
+                self
+            )))
+            .map_err(CoreError::Storage)
+            .map(|s| s.to_string())
+    }
+
+    pub fn is_replacecommit(&self) -> bool {
+        self.action.is_replacecommit()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_action_methods() {
+        assert_eq!(Action::Commit.as_str(), "commit");
+        assert_eq!(Action::ReplaceCommit.as_str(), "replacecommit");
+
+        assert!(!Action::Commit.is_replacecommit());
+        assert!(Action::ReplaceCommit.is_replacecommit());
+    }
+
+    #[test]
+    fn test_action_from_str() {
+        assert_eq!(Action::from_str("commit").unwrap(), Action::Commit);
+        assert_eq!(
+            Action::from_str("replacecommit").unwrap(),
+            Action::ReplaceCommit
+        );
+        assert!(Action::from_str("invalid").is_err());
+    }
+
+    #[test]
+    fn test_state_methods() {
+        assert_eq!(State::Requested.as_str(), "requested");
+        assert_eq!(State::Inflight.as_str(), "inflight");
+        assert_eq!(State::Completed.as_str(), "");
+    }
+
+    #[test]
+    fn test_state_from_str() {
+        assert_eq!(State::from_str("requested").unwrap(), State::Requested);
+        assert_eq!(State::from_str("inflight").unwrap(), State::Inflight);
+        assert_eq!(State::from_str("").unwrap(), State::Completed);
+        assert!(State::from_str("invalid").is_err());
+    }
+
+    #[test]
+    fn test_state_ordering() {
+        assert!(State::Requested < State::Inflight);
+        assert!(State::Inflight < State::Completed);
+        assert!(State::Requested < State::Completed);
+
+        assert!(State::Completed > State::Inflight);
+        assert!(State::Inflight > State::Requested);
+        assert!(State::Completed > State::Requested);
+    }
+
+    #[test]
+    fn test_instant_from_file_name() -> Result<()> {
+        // Test completed commit
+        let instant = Instant::try_from("20240103153000.commit")?;
+        assert_eq!(instant.timestamp, "20240103153000");
+        assert_eq!(instant.action, Action::Commit);
+        assert_eq!(instant.state, State::Completed);
+
+        // Test inflight replacecommit with milliseconds
+        let instant = 
Instant::try_from("20240103153000123.replacecommit.inflight")?;
+        assert_eq!(instant.timestamp, "20240103153000123");
+        assert_eq!(instant.action, Action::ReplaceCommit);
+        assert_eq!(instant.state, State::Inflight);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_invalid_file_names() {
+        // Invalid timestamp format
+        assert!(Instant::try_from("2024010315.commit").is_err());
+
+        // Invalid action
+        assert!(Instant::try_from("20240103153000.invalid").is_err());
+
+        // Invalid state
+        assert!(Instant::try_from("20240103153000.commit.invalid").is_err());
+
+        // No dot separator
+        assert!(Instant::try_from("20240103153000commit").is_err());
+    }
+
+    #[test]
+    fn test_file_name_roundtrip() -> Result<()> {
+        let test_cases = vec![
+            "20240103153000.commit",
+            "20240103153000.commit.requested",
+            "20240103153000.replacecommit",
+            "20240103153000123.inflight",
+        ];
+
+        for original_name in test_cases {
+            let instant = Instant::try_from(original_name)?;
+            assert_eq!(instant.file_name(), original_name);
+        }
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_instant_ordering() -> Result<()> {
+        let instant1 = Instant::try_from("20240103153000.commit")?;
+        let instant2 = Instant::try_from("20240103153000001.commit")?;
+        let instant3 = 
Instant::try_from("20240103153000999.commit.requested")?;
+        let instant4 = Instant::try_from("20240103153000999.inflight")?;
+        let instant5 = Instant::try_from("20240103153000999.commit")?;
+        let instant6 = Instant::try_from("20240103153001.commit")?;
+
+        assert!(instant1 < instant2);
+        assert!(instant2 < instant3);
+        assert!(instant3 < instant4);
+        assert!(instant4 < instant5);
+        assert!(instant5 < instant6);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_relative_path() {
+        let instant = Instant::try_from("20240103153000.commit").unwrap();
+        assert_eq!(
+            instant.relative_path().unwrap(),
+            ".hoodie/20240103153000.commit"
+        );
+    }
+
+    #[test]
+    fn test_create_instant_using_local_timezone() {
+        let file_name = "20240103153000.commit";
+        let instant_local = Instant::try_from((file_name, "local")).unwrap();
+        let instant_utc = Instant::try_from((file_name, "utc")).unwrap();
+        let offset_seconds = (instant_local.epoch_millis - 
instant_utc.epoch_millis) / 1000;
+        assert_eq!(
+            offset_seconds,
+            Local::now().offset().utc_minus_local() as i64
+        );
+    }
+}
diff --git a/crates/core/src/table/timeline.rs b/crates/core/src/timeline/mod.rs
similarity index 72%
rename from crates/core/src/table/timeline.rs
rename to crates/core/src/timeline/mod.rs
index d48aa55..c414a17 100644
--- a/crates/core/src/table/timeline.rs
+++ b/crates/core/src/timeline/mod.rs
@@ -16,84 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+mod instant;
+mod selector;
 
-use std::cmp::{Ordering, PartialOrd};
-use std::collections::{HashMap, HashSet};
-use std::fmt::Debug;
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use arrow_schema::Schema;
-use parquet::arrow::parquet_to_arrow_schema;
-use serde_json::{Map, Value};
-
+use crate::config::table::HudiTableConfig;
 use crate::config::HudiConfigs;
 use crate::error::CoreError;
 use crate::file_group::FileGroup;
-use crate::storage::error::StorageError;
-use crate::storage::util::split_filename;
 use crate::storage::Storage;
+use crate::timeline::selector::TimelineSelector;
 use crate::Result;
-
-/// The [State] of an [Instant] represents the status of the action performed 
on the table.
-#[allow(dead_code)]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum State {
-    Requested,
-    Inflight,
-    Completed,
-}
-
-/// An [Instant] represents a point in time when an action was performed on 
the table.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Instant {
-    state: State,
-    action: String,
-    timestamp: String,
-}
-
-impl PartialOrd for Instant {
-    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
-        Some(self.timestamp.cmp(&other.timestamp))
-    }
-}
-
-impl Ord for Instant {
-    fn cmp(&self, other: &Self) -> Ordering {
-        self.timestamp.cmp(&other.timestamp)
-    }
-}
-
-impl Instant {
-    pub fn state_suffix(&self) -> String {
-        match self.state {
-            State::Requested => ".requested".to_owned(),
-            State::Inflight => ".inflight".to_owned(),
-            State::Completed => "".to_owned(),
-        }
-    }
-
-    pub fn file_name(&self) -> String {
-        format!("{}.{}{}", self.timestamp, self.action, self.state_suffix())
-    }
-
-    pub fn relative_path(&self) -> Result<String> {
-        let mut commit_file_path = PathBuf::from(".hoodie");
-        commit_file_path.push(self.file_name());
-        commit_file_path
-            .to_str()
-            .ok_or(StorageError::InvalidPath(format!(
-                "Failed to get file path for {:?}",
-                self
-            )))
-            .map_err(CoreError::Storage)
-            .map(|s| s.to_string())
-    }
-
-    pub fn is_replacecommit(&self) -> bool {
-        self.action == "replacecommit"
-    }
-}
+use arrow_schema::Schema;
+use instant::Instant;
+use parquet::arrow::parquet_to_arrow_schema;
+use serde_json::{Map, Value};
+use std::collections::{HashMap, HashSet};
+use std::fmt::Debug;
+use std::sync::Arc;
 
 /// A [Timeline] contains transaction logs of all actions performed on the 
table at different [Instant]s of time.
 #[derive(Clone, Debug)]
@@ -110,7 +49,7 @@ impl Timeline {
         storage_options: Arc<HashMap<String, String>>,
     ) -> Result<Self> {
         let storage = Storage::new(storage_options.clone(), 
hudi_configs.clone())?;
-        let instants = Self::load_completed_commits(&storage).await?;
+        let instants = Self::load_active_completed_commits(&storage, 
hudi_configs.clone()).await?;
         Ok(Self {
             storage,
             hudi_configs,
@@ -118,16 +57,39 @@ impl Timeline {
         })
     }
 
-    async fn load_completed_commits(storage: &Storage) -> Result<Vec<Instant>> 
{
+    pub async fn from_instants(
+        hudi_configs: Arc<HudiConfigs>,
+        storage_options: Arc<HashMap<String, String>>,
+        instants: Vec<Instant>,
+    ) -> Result<Self> {
+        let storage = Storage::new(storage_options.clone(), 
hudi_configs.clone())?;
+        Ok(Self {
+            storage,
+            hudi_configs,
+            instants,
+        })
+    }
+
+    async fn load_active_completed_commits(
+        storage: &Storage,
+        hudi_configs: Arc<HudiConfigs>,
+    ) -> Result<Vec<Instant>> {
         let mut completed_commits = Vec::new();
+        let selector = TimelineSelector::active_completed_commits();
+        let timezone = hudi_configs
+            .get_or_default(HudiTableConfig::TimelineTimezone)
+            .to::<String>();
         for file_info in storage.list_files(Some(".hoodie")).await? {
-            let (file_stem, file_ext) = 
split_filename(file_info.name.as_str())?;
-            if matches!(file_ext.as_str(), "commit" | "replacecommit") {
-                completed_commits.push(Instant {
-                    state: State::Completed,
-                    timestamp: file_stem,
-                    action: file_ext.to_owned(),
-                })
+            match Instant::try_from((file_info.name.as_str(), 
timezone.as_str())) {
+                Ok(instant) => {
+                    if selector.should_select(&instant) {
+                        completed_commits.push(instant)
+                    }
+                }
+                Err(_) => {
+                    // Ignore files like `hoodie.properties` that are not 
valid timeline files
+                    continue;
+                }
             }
         }
         completed_commits.sort();
@@ -274,16 +236,8 @@ mod tests {
         assert_eq!(
             timeline.instants,
             vec![
-                Instant {
-                    state: State::Completed,
-                    action: "commit".to_owned(),
-                    timestamp: "20240402123035233".to_owned(),
-                },
-                Instant {
-                    state: State::Completed,
-                    action: "commit".to_owned(),
-                    timestamp: "20240402144910683".to_owned(),
-                },
+                Instant::try_from("20240402123035233.commit").unwrap(),
+                Instant::try_from("20240402144910683.commit").unwrap(),
             ]
         )
     }
@@ -298,11 +252,7 @@ mod tests {
         )
         .unwrap();
         let timeline = create_test_timeline(base_url).await;
-        let instant = Instant {
-            state: State::Completed,
-            action: "commit".to_owned(),
-            timestamp: "20240402123035233".to_owned(),
-        };
+        let instant = Instant::try_from("20240402123035233.commit").unwrap();
 
         // Test error when reading empty commit metadata file
         let result = timeline.get_commit_metadata(&instant).await;
@@ -311,11 +261,7 @@ mod tests {
         assert!(matches!(err, CoreError::Timeline(_)));
         assert!(err.to_string().contains("Failed to get commit metadata"));
 
-        let instant = Instant {
-            state: State::Completed,
-            action: "commit".to_owned(),
-            timestamp: "20240402144910683".to_owned(),
-        };
+        let instant = Instant::try_from("20240402144910683.commit").unwrap();
 
         // Test error when reading a commit metadata file with invalid JSON
         let result = timeline.get_commit_metadata(&instant).await;
diff --git a/crates/core/src/timeline/selector.rs 
b/crates/core/src/timeline/selector.rs
new file mode 100644
index 0000000..a4c76d1
--- /dev/null
+++ b/crates/core/src/timeline/selector.rs
@@ -0,0 +1,312 @@
+/*
+ * 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.
+ */
+use crate::timeline::instant::{Action, Instant, State};
+use crate::timeline::Timeline;
+use crate::Result;
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, PartialEq)]
+pub struct TimelineSelector {
+    states: Vec<State>,
+    actions: Vec<Action>,
+    start_timestamp: Option<i64>,
+    end_timestamp: Option<i64>,
+    include_archived: bool,
+}
+
+impl Default for TimelineSelector {
+    fn default() -> Self {
+        Self::empty()
+    }
+}
+
+#[allow(dead_code)]
+impl TimelineSelector {
+    /// An empty [TimelineSelector] that selects no instants.
+    pub fn empty() -> Self {
+        Self {
+            states: vec![],
+            start_timestamp: None,
+            end_timestamp: None,
+            actions: vec![],
+            include_archived: false,
+        }
+    }
+
+    pub fn active_completed_commits() -> Self {
+        Self {
+            states: vec![State::Completed],
+            start_timestamp: None,
+            end_timestamp: None,
+            actions: vec![Action::Commit, Action::ReplaceCommit],
+            include_archived: false,
+        }
+    }
+
+    /// Check if a single instant should be selected based on the given 
criteria.
+    pub fn should_select(&self, instant: &Instant) -> bool {
+        if let Some(start) = self.start_timestamp {
+            if instant.epoch_millis < start {
+                return false;
+            }
+        }
+
+        if let Some(end) = self.end_timestamp {
+            if instant.epoch_millis >= end {
+                return false;
+            }
+        }
+
+        if !self.actions.contains(&instant.action) {
+            return false;
+        }
+
+        if !self.states.contains(&instant.state) {
+            return false;
+        }
+
+        true
+    }
+
+    /// Select loaded instants based on the given criteria.
+    pub fn select(&self, timeline: &Timeline) -> Result<Vec<Instant>> {
+        if self.states.is_empty() || self.actions.is_empty() {
+            return Ok(vec![]);
+        }
+
+        let time_pruned_instants = if let Some(start) = self.start_timestamp {
+            // Find first instant >= start using binary search
+            let start_pos = timeline
+                .instants
+                .partition_point(|instant| instant.epoch_millis < start);
+
+            if let Some(end) = self.end_timestamp {
+                // Find first instant >= end using binary search
+                let end_pos = timeline.instants[start_pos..]
+                    .partition_point(|instant| instant.epoch_millis < end);
+                &timeline.instants[start_pos..start_pos + end_pos]
+            } else {
+                &timeline.instants[start_pos..]
+            }
+        } else if let Some(end) = self.end_timestamp {
+            let end_pos = timeline
+                .instants
+                .partition_point(|instant| instant.epoch_millis < end);
+            &timeline.instants[..end_pos]
+        } else {
+            &timeline.instants[..]
+        };
+
+        Ok(time_pruned_instants
+            .iter()
+            .filter(|instant| {
+                self.actions.contains(&instant.action) && 
self.states.contains(&instant.state)
+            })
+            .cloned()
+            .collect())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::table::HudiTableConfig;
+    use crate::config::HudiConfigs;
+    use hudi_tests::assert_not;
+    use std::collections::HashMap;
+    use std::sync::Arc;
+
+    fn create_test_instant(file_name: &str) -> Instant {
+        Instant::try_from(file_name).unwrap()
+    }
+
+    async fn create_test_timeline() -> Timeline {
+        let instants = vec![
+            create_test_instant("20240103153000.commit"),
+            create_test_instant("20240103153010999.commit"),
+            create_test_instant("20240103153020999.commit.requested"),
+            create_test_instant("20240103153020999.inflight"),
+            create_test_instant("20240103153020999.commit"),
+            create_test_instant("20240103153030999.commit"),
+        ];
+        Timeline::from_instants(
+            Arc::new(HudiConfigs::new([(
+                HudiTableConfig::BasePath,
+                "file:///tmp/base",
+            )])),
+            Arc::new(HashMap::new()),
+            instants,
+        )
+        .await
+        .unwrap()
+    }
+
+    #[test]
+    fn test_default() {
+        assert_eq!(TimelineSelector::default(), TimelineSelector::empty());
+    }
+
+    #[test]
+    fn test_should_select() {
+        let instant = create_test_instant("20240103153030999.commit");
+
+        let selector = TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![Action::Commit],
+            start_timestamp: None,
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert!(selector.should_select(&instant));
+
+        let selector = TimelineSelector {
+            states: vec![State::Requested],
+            actions: vec![Action::Commit],
+            start_timestamp: None,
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert_not!(
+            selector.should_select(&instant),
+            "Should not select as state is different"
+        );
+
+        let selector = TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![Action::ReplaceCommit],
+            start_timestamp: None,
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert_not!(
+            selector.should_select(&instant),
+            "Should not select as action is different"
+        );
+
+        let selector = TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![Action::Commit],
+            start_timestamp: 
Instant::parse_utc_as_epoch_millis("20240103153031").ok(),
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert_not!(
+            selector.should_select(&instant),
+            "Should not select as timestamp is before start"
+        );
+
+        let selector = TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![Action::Commit],
+            start_timestamp: None,
+            end_timestamp: 
Instant::parse_utc_as_epoch_millis("20240103153030999").ok(),
+            include_archived: false,
+        };
+        assert_not!(
+            selector.should_select(&instant),
+            "Should not select as timestamp is at the end timestamp 
(exclusive)"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_select_no_instants() {
+        let timeline = create_test_timeline().await;
+        assert_not!(timeline.instants.is_empty());
+
+        let selector = TimelineSelector::empty();
+        assert!(selector.select(&timeline).unwrap().is_empty());
+
+        let selector = TimelineSelector {
+            states: vec![],
+            actions: vec![Action::Commit],
+            start_timestamp: None,
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert!(selector.select(&timeline).unwrap().is_empty());
+
+        let selector = TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![],
+            start_timestamp: None,
+            end_timestamp: None,
+            include_archived: false,
+        };
+        assert!(selector.select(&timeline).unwrap().is_empty());
+    }
+
+    fn create_test_active_completed_selector(
+        start: Option<&str>,
+        end: Option<&str>,
+    ) -> TimelineSelector {
+        TimelineSelector {
+            states: vec![State::Completed],
+            actions: vec![Action::Commit, Action::ReplaceCommit],
+            start_timestamp: start.map(|s| {
+                Instant::parse_datetime(s, "UTC")
+                    .unwrap()
+                    .timestamp_millis()
+            }),
+            end_timestamp: end.map(|s| {
+                Instant::parse_datetime(s, "UTC")
+                    .unwrap()
+                    .timestamp_millis()
+            }),
+            include_archived: false,
+        }
+    }
+
+    #[tokio::test]
+    async fn test_timestamp_filtering() -> Result<()> {
+        let timeline = create_test_timeline().await;
+
+        // starting from the earliest timestamp (inclusive)
+        let selector = 
create_test_active_completed_selector(Some("20240103153000000"), None);
+        let selected = selector.select(&timeline)?;
+        assert_eq!(
+            selected.iter().map(|i| &i.timestamp).collect::<Vec<_>>(),
+            &[
+                "20240103153000",
+                "20240103153010999",
+                "20240103153020999",
+                "20240103153030999",
+            ]
+        );
+
+        // ending until the latest timestamp (exclusive)
+        let selector = create_test_active_completed_selector(None, 
Some("20240103153030999"));
+        let selected = selector.select(&timeline)?;
+        assert_eq!(
+            selected.iter().map(|i| &i.timestamp).collect::<Vec<_>>(),
+            &["20240103153000", "20240103153010999", "20240103153020999",]
+        );
+
+        // start and end in the middle
+        let selector = create_test_active_completed_selector(
+            Some("20240103153010000"),
+            Some("20240103153030000"),
+        );
+        let selected = selector.select(&timeline)?;
+        assert_eq!(
+            selected.iter().map(|i| &i.timestamp).collect::<Vec<_>>(),
+            &["20240103153010999", "20240103153020999",]
+        );
+        Ok(())
+    }
+}


Reply via email to