use super::metrics::{
FileStatistics, HudiMetrics, PartitionMetrics, TableMetadata, TimelineMetrics,
};
use chrono::NaiveDateTime;
use object_store::path::Path;
use object_store::{ObjectStore, ObjectStoreExt};
use serde::Deserialize;
use std::collections::HashMap;
use std::error::Error;
use std::sync::Arc;
use tracing::{info, warn};
use url::Url;
fn hudi_timestamp_to_millis(timestamp_str: &str) -> u64 {
if timestamp_str.len() < 14 {
return 0;
}
let datetime_str = ×tamp_str[..14];
let millis_str = if timestamp_str.len() >= 17 {
×tamp_str[14..17]
} else {
"000"
};
let millis: u64 = millis_str.parse().unwrap_or(0);
match NaiveDateTime::parse_from_str(datetime_str, "%Y%m%d%H%M%S") {
Ok(dt) => dt.and_utc().timestamp_millis() as u64 + millis,
Err(_) => 0,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum HudiTableType {
#[default]
CopyOnWrite,
MergeOnRead,
}
impl From<&str> for HudiTableType {
fn from(s: &str) -> Self {
match s.to_uppercase().as_str() {
"COPY_ON_WRITE" => HudiTableType::CopyOnWrite,
"MERGE_ON_READ" => HudiTableType::MergeOnRead,
_ => HudiTableType::CopyOnWrite,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct HoodieProperties {
pub table_name: String,
pub table_type: HudiTableType,
pub table_version: u32,
pub precombine_field: String,
pub record_key_fields: Vec<String>,
pub partition_fields: Vec<String>,
pub base_file_format: String,
pub timeline_layout_version: u32,
}
impl HoodieProperties {
pub fn parse(content: &str) -> Self {
let mut props = HoodieProperties::default();
let mut properties: HashMap<String, String> = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
properties.insert(key.trim().to_string(), value.trim().to_string());
}
}
props.table_name = properties
.get("hoodie.table.name")
.cloned()
.unwrap_or_default();
props.table_type = properties
.get("hoodie.table.type")
.map(|s| HudiTableType::from(s.as_str()))
.unwrap_or_default();
props.table_version = properties
.get("hoodie.table.version")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
props.precombine_field = properties
.get("hoodie.table.precombine.field")
.cloned()
.unwrap_or_default();
props.record_key_fields = properties
.get("hoodie.table.recordkey.fields")
.map(|s| s.split(',').map(|f| f.trim().to_string()).collect())
.unwrap_or_default();
props.partition_fields = properties
.get("hoodie.table.partition.fields")
.map(|s| {
s.split(',')
.map(|f| f.trim().to_string())
.filter(|f| !f.is_empty())
.collect()
})
.unwrap_or_default();
props.base_file_format = properties
.get("hoodie.table.base.file.format")
.cloned()
.unwrap_or_else(|| "PARQUET".to_string());
props.timeline_layout_version = properties
.get("hoodie.timeline.layout.version")
.and_then(|s| s.parse().ok())
.unwrap_or(1);
props
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HoodieWriteStat {
pub file_id: Option<String>,
pub path: Option<String>,
pub prev_commit: Option<String>,
pub num_writes: Option<i64>,
pub num_deletes: Option<i64>,
pub num_inserts: Option<i64>,
pub num_update_writes: Option<i64>,
pub total_write_bytes: Option<i64>,
pub file_size_in_bytes: Option<i64>,
pub total_log_blocks: Option<i64>,
pub total_log_records: Option<i64>,
pub total_log_files_size: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HoodieCommitMetadata {
pub partition_to_write_stats: Option<HashMap<String, Vec<HoodieWriteStat>>>,
pub compacted: Option<bool>,
pub extra_metadata: Option<HashMap<String, String>>,
pub operation_type: Option<String>,
}
impl HoodieCommitMetadata {
pub fn get_schema(&self) -> Option<serde_json::Value> {
self.extra_metadata
.as_ref()
.and_then(|m| m.get("schema"))
.and_then(|s| serde_json::from_str(s).ok())
}
pub fn get_file_paths(&self) -> Vec<String> {
let mut paths = Vec::new();
if let Some(partition_stats) = &self.partition_to_write_stats {
for stats in partition_stats.values() {
for stat in stats {
if let Some(path) = &stat.path {
paths.push(path.clone());
}
}
}
}
paths
}
pub fn total_records_written(&self) -> i64 {
self.partition_to_write_stats
.as_ref()
.map(|m| {
m.values()
.flat_map(|stats| stats.iter())
.map(|s| s.num_writes.unwrap_or(0))
.sum()
})
.unwrap_or(0)
}
pub fn total_deletes(&self) -> i64 {
self.partition_to_write_stats
.as_ref()
.map(|m| {
m.values()
.flat_map(|stats| stats.iter())
.map(|s| s.num_deletes.unwrap_or(0))
.sum()
})
.unwrap_or(0)
}
pub fn total_bytes_written(&self) -> i64 {
self.partition_to_write_stats
.as_ref()
.map(|m| {
m.values()
.flat_map(|stats| stats.iter())
.map(|s| s.total_write_bytes.unwrap_or(0))
.sum()
})
.unwrap_or(0)
}
pub fn total_log_size(&self) -> i64 {
self.partition_to_write_stats
.as_ref()
.map(|m| {
m.values()
.flat_map(|stats| stats.iter())
.map(|s| s.total_log_files_size.unwrap_or(0))
.sum()
})
.unwrap_or(0)
}
pub fn total_log_files(&self) -> i64 {
self.partition_to_write_stats
.as_ref()
.map(|m| {
m.values()
.flat_map(|stats| stats.iter())
.filter(|s| s.total_log_files_size.unwrap_or(0) > 0)
.count() as i64
})
.unwrap_or(0)
}
pub fn get_partition_paths(&self) -> Vec<String> {
self.partition_to_write_stats
.as_ref()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HudiActionType {
Commit,
DeltaCommit,
Clean,
Compaction,
Rollback,
Savepoint,
ReplaceCommit,
Indexing,
Unknown(String),
}
impl From<&str> for HudiActionType {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"commit" => HudiActionType::Commit,
"deltacommit" => HudiActionType::DeltaCommit,
"clean" => HudiActionType::Clean,
"compaction" => HudiActionType::Compaction,
"rollback" => HudiActionType::Rollback,
"savepoint" => HudiActionType::Savepoint,
"replacecommit" => HudiActionType::ReplaceCommit,
"indexing" => HudiActionType::Indexing,
_ => HudiActionType::Unknown(s.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HudiActionState {
Requested,
Inflight,
Completed,
}
#[derive(Debug, Clone)]
pub struct HudiTimelineAction {
pub timestamp: String,
pub action_type: HudiActionType,
pub state: HudiActionState,
}
fn parse_timeline_filename(filename: &str) -> Option<HudiTimelineAction> {
let parts: Vec<&str> = filename.split('.').collect();
if parts.is_empty() {
return None;
}
let timestamp = parts[0].to_string();
if timestamp.len() < 14 {
return None;
}
let (action_type, state) = if parts.len() >= 3 {
let action = HudiActionType::from(parts[1]);
let state = match parts[2] {
"requested" => HudiActionState::Requested,
"inflight" => HudiActionState::Inflight,
_ => HudiActionState::Completed,
};
(action, state)
} else if parts.len() == 2 {
(HudiActionType::from(parts[1]), HudiActionState::Completed)
} else {
return None;
};
Some(HudiTimelineAction {
timestamp,
action_type,
state,
})
}
pub struct HudiReader {
store: Arc<dyn ObjectStore>,
base_path: Path,
properties: HoodieProperties,
}
impl HudiReader {
pub async fn open(
location: &str,
storage_options: &HashMap<String, String>,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
info!("Opening Hudi table at location={}", location);
let url = Url::parse(location)?;
let (store, base_path) = object_store::parse_url_opts(&url, storage_options.iter())?;
let store = Arc::new(store);
let props_path = base_path.child(".hoodie").child("hoodie.properties");
let props_content = match store.get(&props_path).await {
Ok(result) => {
let bytes = result.bytes().await?;
String::from_utf8_lossy(&bytes).to_string()
}
Err(e) => {
warn!("Failed to read hoodie.properties: {}", e);
String::new()
}
};
let properties = HoodieProperties::parse(&props_content);
info!(
"Opened Hudi table: name={}, type={:?}, version={}",
properties.table_name, properties.table_type, properties.table_version
);
Ok(Self {
store,
base_path,
properties,
})
}
pub async fn extract_metrics(&self) -> Result<HudiMetrics, Box<dyn Error + Send + Sync>> {
info!("Extracting metrics from Hudi table");
let hoodie_path = self.base_path.child(".hoodie");
let timeline_files = self.list_timeline_files(&hoodie_path).await?;
let timeline_info = self.extract_timeline_metrics(&timeline_files).await?;
let file_stats = self.extract_file_statistics(&timeline_files).await?;
let partition_info = self.extract_partition_metrics(&timeline_files).await?;
let metadata = self.extract_table_metadata(&timeline_files).await?;
let mut table_properties = HashMap::new();
table_properties.insert(
"hoodie.table.name".to_string(),
self.properties.table_name.clone(),
);
table_properties.insert(
"hoodie.table.type".to_string(),
match self.properties.table_type {
HudiTableType::CopyOnWrite => "COPY_ON_WRITE".to_string(),
HudiTableType::MergeOnRead => "MERGE_ON_READ".to_string(),
},
);
table_properties.insert(
"hoodie.table.version".to_string(),
self.properties.table_version.to_string(),
);
if !self.properties.precombine_field.is_empty() {
table_properties.insert(
"hoodie.table.precombine.field".to_string(),
self.properties.precombine_field.clone(),
);
}
if !self.properties.record_key_fields.is_empty() {
table_properties.insert(
"hoodie.table.recordkey.fields".to_string(),
self.properties.record_key_fields.join(","),
);
}
let table_type = match self.properties.table_type {
HudiTableType::CopyOnWrite => "COPY_ON_WRITE".to_string(),
HudiTableType::MergeOnRead => "MERGE_ON_READ".to_string(),
};
Ok(HudiMetrics {
table_type,
table_name: self.properties.table_name.clone(),
metadata,
table_properties,
file_stats,
partition_info,
timeline_info,
})
}
async fn list_timeline_files(
&self,
hoodie_path: &Path,
) -> Result<Vec<(String, HudiTimelineAction)>, Box<dyn Error + Send + Sync>> {
use futures::TryStreamExt;
let mut timeline_files = Vec::new();
let mut list_stream = self.store.list(Some(hoodie_path));
while let Some(meta) = list_stream.try_next().await? {
let filename = meta.location.filename().unwrap_or_default().to_string();
if filename.starts_with("hoodie.") || filename.starts_with('.') {
continue;
}
if let Some(action) = parse_timeline_filename(&filename) {
if action.state == HudiActionState::Completed {
timeline_files.push((meta.location.to_string(), action));
}
}
}
timeline_files.sort_by(|a, b| a.1.timestamp.cmp(&b.1.timestamp));
Ok(timeline_files)
}
async fn extract_timeline_metrics(
&self,
timeline_files: &[(String, HudiTimelineAction)],
) -> Result<TimelineMetrics, Box<dyn Error + Send + Sync>> {
let mut total_commits = 0;
let mut total_delta_commits = 0;
let mut total_compactions = 0;
let mut total_cleans = 0;
let mut total_rollbacks = 0;
let mut total_savepoints = 0;
let mut pending_compactions = 0;
let mut earliest_timestamp: Option<String> = None;
let mut latest_timestamp: Option<String> = None;
for (_, action) in timeline_files {
match action.action_type {
HudiActionType::Commit => total_commits += 1,
HudiActionType::DeltaCommit => total_delta_commits += 1,
HudiActionType::Compaction => {
if action.state == HudiActionState::Completed {
total_compactions += 1;
} else {
pending_compactions += 1;
}
}
HudiActionType::Clean => total_cleans += 1,
HudiActionType::Rollback => total_rollbacks += 1,
HudiActionType::Savepoint => total_savepoints += 1,
_ => {}
}
if earliest_timestamp.is_none() {
earliest_timestamp = Some(action.timestamp.clone());
}
latest_timestamp = Some(action.timestamp.clone());
}
Ok(TimelineMetrics {
total_commits,
total_delta_commits,
total_compactions,
total_cleans,
total_rollbacks,
total_savepoints,
latest_commit_timestamp: latest_timestamp,
earliest_commit_timestamp: earliest_timestamp,
pending_compactions,
})
}
async fn extract_file_statistics(
&self,
timeline_files: &[(String, HudiTimelineAction)],
) -> Result<FileStatistics, Box<dyn Error + Send + Sync>> {
let mut total_size: u64 = 0;
let mut total_files: usize = 0;
let mut min_size: u64 = u64::MAX;
let mut max_size: u64 = 0;
let mut total_log_size: u64 = 0;
let mut total_log_files: usize = 0;
let commit_files: Vec<_> = timeline_files
.iter()
.filter(|(_, a)| {
matches!(
a.action_type,
HudiActionType::Commit | HudiActionType::DeltaCommit
)
})
.collect();
if let Some((path, _)) = commit_files.last() {
let obj_path = Path::from(path.as_str());
if let Ok(result) = self.store.get(&obj_path).await {
if let Ok(bytes) = result.bytes().await {
if let Ok(metadata) = serde_json::from_slice::<HoodieCommitMetadata>(&bytes) {
if let Some(partition_stats) = &metadata.partition_to_write_stats {
for stats in partition_stats.values() {
for stat in stats {
if let Some(size) = stat.file_size_in_bytes {
let size = size as u64;
total_size += size;
total_files += 1;
min_size = min_size.min(size);
max_size = max_size.max(size);
}
if let Some(log_size) = stat.total_log_files_size {
if log_size > 0 {
total_log_size += log_size as u64;
total_log_files += 1;
}
}
}
}
}
}
}
}
}
if min_size == u64::MAX {
min_size = 0;
}
let avg_size = if total_files > 0 {
total_size as f64 / total_files as f64
} else {
0.0
};
Ok(FileStatistics {
num_files: total_files,
total_size_bytes: total_size,
avg_file_size_bytes: avg_size,
min_file_size_bytes: min_size,
max_file_size_bytes: max_size,
num_log_files: total_log_files,
total_log_size_bytes: total_log_size,
})
}
async fn extract_partition_metrics(
&self,
timeline_files: &[(String, HudiTimelineAction)],
) -> Result<PartitionMetrics, Box<dyn Error + Send + Sync>> {
let mut partition_paths: Vec<String> = Vec::new();
let mut partition_sizes: HashMap<String, u64> = HashMap::new();
for (path, action) in timeline_files {
if !matches!(
action.action_type,
HudiActionType::Commit | HudiActionType::DeltaCommit
) {
continue;
}
let obj_path = Path::from(path.as_str());
if let Ok(result) = self.store.get(&obj_path).await {
if let Ok(bytes) = result.bytes().await {
if let Ok(metadata) = serde_json::from_slice::<HoodieCommitMetadata>(&bytes) {
for partition in metadata.get_partition_paths() {
if !partition_paths.contains(&partition) {
partition_paths.push(partition.clone());
}
}
if let Some(partition_stats) = &metadata.partition_to_write_stats {
for (partition, stats) in partition_stats {
let size: u64 = stats
.iter()
.map(|s| s.file_size_in_bytes.unwrap_or(0) as u64)
.sum();
*partition_sizes.entry(partition.clone()).or_insert(0) += size;
}
}
}
}
}
}
let sizes: Vec<u64> = partition_sizes.values().copied().collect();
let largest = sizes.iter().max().copied().unwrap_or(0);
let smallest = sizes.iter().min().copied().unwrap_or(0);
let avg = if !sizes.is_empty() {
sizes.iter().sum::<u64>() as f64 / sizes.len() as f64
} else {
0.0
};
Ok(PartitionMetrics {
num_partition_columns: self.properties.partition_fields.len(),
num_partitions: partition_paths.len(),
partition_paths,
largest_partition_size_bytes: largest,
smallest_partition_size_bytes: smallest,
avg_partition_size_bytes: avg,
})
}
async fn extract_table_metadata(
&self,
timeline_files: &[(String, HudiTimelineAction)],
) -> Result<TableMetadata, Box<dyn Error + Send + Sync>> {
let mut schema_string = "{}".to_string();
let mut field_count = 0;
let commit_files: Vec<_> = timeline_files
.iter()
.filter(|(_, a)| {
matches!(
a.action_type,
HudiActionType::Commit | HudiActionType::DeltaCommit
)
})
.collect();
if let Some((path, _)) = commit_files.last() {
let obj_path = Path::from(path.as_str());
if let Ok(result) = self.store.get(&obj_path).await {
if let Ok(bytes) = result.bytes().await {
if let Ok(metadata) = serde_json::from_slice::<HoodieCommitMetadata>(&bytes) {
if let Some(schema) = metadata.get_schema() {
schema_string = serde_json::to_string_pretty(&schema)?;
if let Some(fields) = schema.get("fields").and_then(|f| f.as_array()) {
field_count = fields.len();
}
}
}
}
}
}
let created_time = timeline_files
.first()
.map(|(_, a)| hudi_timestamp_to_millis(&a.timestamp) as i64);
Ok(TableMetadata {
name: self.properties.table_name.clone(),
base_path: self.base_path.to_string(),
schema_string,
field_count,
partition_columns: self.properties.partition_fields.clone(),
created_time,
format_provider: self.properties.base_file_format.to_lowercase(),
format_options: HashMap::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_hudi_table_path() -> String {
let current_dir = std::env::current_dir().unwrap();
let test_path = current_dir.join("examples/data/hudi_dataset");
format!("file://{}", test_path.to_str().unwrap())
}
#[test]
fn test_hudi_timestamp_to_millis() {
let ts = hudi_timestamp_to_millis("20251110222213846");
assert!(ts > 0);
assert!(ts >= 1762732800000);
assert!(ts <= 1762819199999);
}
#[test]
fn test_hudi_timestamp_to_millis_short() {
let ts = hudi_timestamp_to_millis("2025");
assert_eq!(ts, 0);
}
#[test]
fn test_hudi_table_type_from() {
assert!(matches!(
HudiTableType::from("COPY_ON_WRITE"),
HudiTableType::CopyOnWrite
));
assert!(matches!(
HudiTableType::from("MERGE_ON_READ"),
HudiTableType::MergeOnRead
));
assert!(matches!(
HudiTableType::from("unknown"),
HudiTableType::CopyOnWrite
));
}
#[test]
fn test_hoodie_properties_parse() {
let content = r#"
hoodie.table.name=test_table
hoodie.table.type=COPY_ON_WRITE
hoodie.table.version=6
hoodie.table.recordkey.fields=id
hoodie.table.precombine.field=ts
hoodie.table.partition.fields=date
hoodie.table.base.file.format=PARQUET
"#;
let props = HoodieProperties::parse(content);
assert_eq!(props.table_name, "test_table");
assert_eq!(props.table_type, HudiTableType::CopyOnWrite);
assert_eq!(props.table_version, 6);
assert_eq!(props.record_key_fields, vec!["id".to_string()]);
assert_eq!(props.precombine_field, "ts");
assert_eq!(props.partition_fields, vec!["date".to_string()]);
assert_eq!(props.base_file_format, "PARQUET");
}
#[test]
fn test_hudi_action_type_from() {
assert!(matches!(
HudiActionType::from("commit"),
HudiActionType::Commit
));
assert!(matches!(
HudiActionType::from("deltacommit"),
HudiActionType::DeltaCommit
));
assert!(matches!(
HudiActionType::from("clean"),
HudiActionType::Clean
));
assert!(matches!(
HudiActionType::from("compaction"),
HudiActionType::Compaction
));
assert!(matches!(
HudiActionType::from("rollback"),
HudiActionType::Rollback
));
assert!(matches!(
HudiActionType::from("savepoint"),
HudiActionType::Savepoint
));
assert!(matches!(
HudiActionType::from("replacecommit"),
HudiActionType::ReplaceCommit
));
assert!(matches!(
HudiActionType::from("indexing"),
HudiActionType::Indexing
));
match HudiActionType::from("unknown_action") {
HudiActionType::Unknown(s) => assert_eq!(s, "unknown_action"),
_ => panic!("Expected Unknown variant"),
}
}
#[test]
fn test_parse_timeline_filename() {
let action = parse_timeline_filename("20251110222213846.commit").unwrap();
assert_eq!(action.timestamp, "20251110222213846");
assert!(matches!(action.action_type, HudiActionType::Commit));
assert!(matches!(action.state, HudiActionState::Completed));
let action = parse_timeline_filename("20251110222213846.deltacommit.inflight").unwrap();
assert!(matches!(action.action_type, HudiActionType::DeltaCommit));
assert!(matches!(action.state, HudiActionState::Inflight));
assert!(parse_timeline_filename("invalid").is_none());
}
#[test]
fn test_hoodie_commit_metadata_helpers() {
let json = r#"{
"partitionToWriteStats": {
"": [{
"fileId": "file1",
"path": "file1.parquet",
"numWrites": 100,
"numDeletes": 5,
"totalWriteBytes": 1000,
"fileSizeInBytes": 900
}]
},
"extraMetadata": {
"schema": "{\"type\":\"record\",\"name\":\"test\"}"
}
}"#;
let metadata: HoodieCommitMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.total_records_written(), 100);
assert_eq!(metadata.total_deletes(), 5);
assert_eq!(metadata.total_bytes_written(), 1000);
assert_eq!(metadata.get_file_paths(), vec!["file1.parquet".to_string()]);
assert!(metadata.get_schema().is_some());
}
#[test]
fn test_hoodie_commit_metadata_log_files() {
let json = r#"{
"partitionToWriteStats": {
"date=2024-01-01": [{
"fileId": "file1",
"path": "date=2024-01-01/file1.parquet",
"numWrites": 50,
"totalLogFilesSize": 5000,
"totalLogBlocks": 2,
"totalLogRecords": 100
}],
"date=2024-01-02": [{
"fileId": "file2",
"path": "date=2024-01-02/file2.parquet",
"numWrites": 75,
"totalLogFilesSize": 3000
}]
}
}"#;
let metadata: HoodieCommitMetadata = serde_json::from_str(json).unwrap();
assert_eq!(metadata.total_log_size(), 8000);
assert_eq!(metadata.total_log_files(), 2);
let partition_paths = metadata.get_partition_paths();
assert_eq!(partition_paths.len(), 2);
assert!(partition_paths.contains(&"date=2024-01-01".to_string()));
assert!(partition_paths.contains(&"date=2024-01-02".to_string()));
}
#[test]
fn test_hoodie_commit_metadata_empty() {
let metadata = HoodieCommitMetadata::default();
assert_eq!(metadata.total_records_written(), 0);
assert_eq!(metadata.total_deletes(), 0);
assert_eq!(metadata.total_bytes_written(), 0);
assert_eq!(metadata.total_log_size(), 0);
assert_eq!(metadata.total_log_files(), 0);
assert!(metadata.get_file_paths().is_empty());
assert!(metadata.get_partition_paths().is_empty());
assert!(metadata.get_schema().is_none());
}
#[test]
fn test_hoodie_commit_metadata_no_schema() {
let json = r#"{
"partitionToWriteStats": {
"": [{
"fileId": "file1",
"path": "file1.parquet",
"numWrites": 10
}]
}
}"#;
let metadata: HoodieCommitMetadata = serde_json::from_str(json).unwrap();
assert!(metadata.get_schema().is_none());
}
#[test]
fn test_parse_timeline_filename_requested_state() {
let action = parse_timeline_filename("20251110222213846.compaction.requested").unwrap();
assert_eq!(action.timestamp, "20251110222213846");
assert!(matches!(action.action_type, HudiActionType::Compaction));
assert!(matches!(action.state, HudiActionState::Requested));
}
#[test]
fn test_parse_timeline_filename_various_actions() {
let action = parse_timeline_filename("20251110222213846.savepoint").unwrap();
assert!(matches!(action.action_type, HudiActionType::Savepoint));
let action = parse_timeline_filename("20251110222213846.replacecommit").unwrap();
assert!(matches!(action.action_type, HudiActionType::ReplaceCommit));
let action = parse_timeline_filename("20251110222213846.indexing").unwrap();
assert!(matches!(action.action_type, HudiActionType::Indexing));
let action = parse_timeline_filename("20251110222213846.clean").unwrap();
assert!(matches!(action.action_type, HudiActionType::Clean));
}
#[test]
fn test_hudi_timestamp_to_millis_no_millis() {
let ts = hudi_timestamp_to_millis("20251110222213");
assert!(ts > 0);
}
#[test]
fn test_hudi_timestamp_to_millis_invalid() {
let ts = hudi_timestamp_to_millis("invalid_timestamp");
assert_eq!(ts, 0);
}
#[test]
fn test_hoodie_properties_parse_empty() {
let props = HoodieProperties::parse("");
assert_eq!(props.table_name, "");
assert_eq!(props.table_type, HudiTableType::CopyOnWrite);
assert_eq!(props.table_version, 0);
}
#[test]
fn test_hoodie_properties_parse_with_comments() {
let content = r#"
# This is a comment
hoodie.table.name=my_table
# Another comment
hoodie.table.type=MERGE_ON_READ
"#;
let props = HoodieProperties::parse(content);
assert_eq!(props.table_name, "my_table");
assert_eq!(props.table_type, HudiTableType::MergeOnRead);
}
#[test]
fn test_hoodie_properties_parse_empty_partition_fields() {
let content = r#"
hoodie.table.name=test
hoodie.table.partition.fields=
"#;
let props = HoodieProperties::parse(content);
assert!(props.partition_fields.is_empty());
}
#[tokio::test]
async fn test_hudi_reader_open_success() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let result = HudiReader::open(&location, &storage_options).await;
assert!(
result.is_ok(),
"Failed to open Hudi table: {:?}",
result.err()
);
let reader = result.unwrap();
assert_eq!(reader.properties.table_name, "hudi_test_table");
assert_eq!(reader.properties.table_type, HudiTableType::CopyOnWrite);
}
#[tokio::test]
async fn test_hudi_reader_open_invalid_location() {
let location = "file:///nonexistent/path/to/hudi/table";
let storage_options = HashMap::new();
let result = HudiReader::open(location, &storage_options).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hudi_reader_extract_metrics_success() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let reader = HudiReader::open(&location, &storage_options)
.await
.expect("Failed to open Hudi table");
let result = reader.extract_metrics().await;
assert!(
result.is_ok(),
"Failed to extract metrics: {:?}",
result.err()
);
let metrics = result.unwrap();
assert_eq!(metrics.table_name, "hudi_test_table");
assert_eq!(metrics.table_type, "COPY_ON_WRITE");
assert!(metrics.timeline_info.total_commits > 0);
}
#[tokio::test]
async fn test_hudi_reader_extract_timeline_metrics() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let reader = HudiReader::open(&location, &storage_options)
.await
.expect("Failed to open Hudi table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(
metrics.timeline_info.total_commits >= 3,
"Expected at least 3 commits, got {}",
metrics.timeline_info.total_commits
);
assert!(metrics.timeline_info.earliest_commit_timestamp.is_some());
assert!(metrics.timeline_info.latest_commit_timestamp.is_some());
}
#[tokio::test]
async fn test_hudi_reader_extract_table_metadata() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let reader = HudiReader::open(&location, &storage_options)
.await
.expect("Failed to open Hudi table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert_eq!(metrics.metadata.name, "hudi_test_table");
assert!(!metrics.metadata.schema_string.is_empty());
assert!(metrics.metadata.field_count > 0);
assert_eq!(metrics.metadata.format_provider, "parquet");
}
#[tokio::test]
async fn test_hudi_reader_table_properties() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let reader = HudiReader::open(&location, &storage_options)
.await
.expect("Failed to open Hudi table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(metrics.table_properties.contains_key("hoodie.table.name"));
assert!(metrics.table_properties.contains_key("hoodie.table.type"));
assert!(metrics
.table_properties
.contains_key("hoodie.table.version"));
}
#[tokio::test]
async fn test_hudi_reader_multiple_extractions() {
let location = get_test_hudi_table_path();
let storage_options = HashMap::new();
let reader = HudiReader::open(&location, &storage_options)
.await
.expect("Failed to open Hudi table");
let metrics1 = reader
.extract_metrics()
.await
.expect("First extraction failed");
let metrics2 = reader
.extract_metrics()
.await
.expect("Second extraction failed");
assert_eq!(metrics1.table_name, metrics2.table_name);
assert_eq!(metrics1.table_type, metrics2.table_type);
assert_eq!(
metrics1.timeline_info.total_commits,
metrics2.timeline_info.total_commits
);
}
}