use crate::Result;
use crate::error::CoreError;
use crate::hfile::{HFileReader, HFileRecord};
use apache_avro::Schema as AvroSchema;
use apache_avro::types::Value as AvroValue;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetadataPartitionType {
Files,
ColumnStats,
PartitionStats,
RecordIndex,
}
impl MetadataPartitionType {
pub fn partition_name(&self) -> &'static str {
match self {
Self::Files => "files",
Self::ColumnStats => "column_stats",
Self::PartitionStats => "partition_stats",
Self::RecordIndex => "record_index",
}
}
}
impl std::fmt::Display for MetadataPartitionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.partition_name())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HoodieMetadataFileInfo {
pub name: String,
pub size: i64,
pub is_deleted: bool,
}
impl HoodieMetadataFileInfo {
pub fn new(name: String, size: i64, is_deleted: bool) -> Self {
Self {
name,
size,
is_deleted,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum MetadataRecordType {
AllPartitions = 1,
Files = 2,
ColumnStats = 3,
BloomFilters = 4,
RecordIndex = 5,
PartitionStats = 6,
SecondaryIndex = 7,
Unknown = -1,
}
impl From<i32> for MetadataRecordType {
fn from(value: i32) -> Self {
match value {
1 => MetadataRecordType::AllPartitions,
2 => MetadataRecordType::Files,
3 => MetadataRecordType::ColumnStats,
4 => MetadataRecordType::BloomFilters,
5 => MetadataRecordType::RecordIndex,
6 => MetadataRecordType::PartitionStats,
7 => MetadataRecordType::SecondaryIndex,
_ => MetadataRecordType::Unknown,
}
}
}
#[derive(Debug, Clone)]
pub struct FilesPartitionRecord {
pub key: String,
pub record_type: MetadataRecordType,
pub files: HashMap<String, HoodieMetadataFileInfo>,
}
impl FilesPartitionRecord {
pub const PARTITION_NAME: &'static str = "files";
pub const ALL_PARTITIONS_KEY: &'static str = "__all_partitions__";
pub const NON_PARTITIONED_NAME: &'static str = ".";
pub fn is_all_partitions(&self) -> bool {
self.record_type == MetadataRecordType::AllPartitions
}
pub fn partition_names(&self) -> Vec<&str> {
if self.is_all_partitions() {
let mut names: Vec<&str> = self.files.keys().map(|s| s.as_str()).collect();
names.sort();
names
} else {
vec![]
}
}
pub fn active_file_names(&self) -> Vec<&str> {
self.files
.values()
.filter(|f| !f.is_deleted)
.map(|f| f.name.as_str())
.collect()
}
pub fn active_files_with_sizes(&self) -> impl Iterator<Item = (&str, u64)> {
self.files
.values()
.filter(|f| !f.is_deleted)
.map(|f| (f.name.as_str(), f.size.max(0) as u64))
}
pub fn all_file_names(&self) -> Vec<&str> {
self.files.keys().map(|s| s.as_str()).collect()
}
pub fn has_active_file(&self, name: &str) -> bool {
self.files.get(name).map(|f| !f.is_deleted).unwrap_or(false)
}
pub fn total_size(&self) -> i64 {
self.files
.values()
.filter(|info| !info.is_deleted)
.map(|info| info.size)
.sum()
}
}
pub fn decode_files_partition_record(
reader: &HFileReader,
record: &HFileRecord,
) -> Result<FilesPartitionRecord> {
let schema = reader
.get_avro_schema()
.map_err(|e| CoreError::MetadataTable(format!("Failed to get schema: {e}")))?
.ok_or_else(|| CoreError::MetadataTable("No Avro schema in HFile".to_string()))?;
decode_files_partition_record_with_schema(record, schema)
}
pub fn decode_files_partition_record_with_schema(
record: &HFileRecord,
schema: &AvroSchema,
) -> Result<FilesPartitionRecord> {
let raw_key = record
.key_as_str()
.ok_or_else(|| CoreError::MetadataTable("Invalid UTF-8 key".to_string()))?;
let key = if raw_key == FilesPartitionRecord::NON_PARTITIONED_NAME {
String::new()
} else {
raw_key.to_string()
};
let value = record.value();
if value.is_empty() {
return Ok(FilesPartitionRecord {
key,
record_type: MetadataRecordType::Files,
files: HashMap::new(),
});
}
let avro_value = decode_avro_value(value, schema)?;
let record_type = get_record_type(&avro_value);
let mut files = extract_filesystem_metadata(&avro_value);
if record_type == MetadataRecordType::AllPartitions
&& let Some(mut file_info) = files.remove(FilesPartitionRecord::NON_PARTITIONED_NAME)
{
file_info.name = String::new();
files.insert(String::new(), file_info);
}
Ok(FilesPartitionRecord {
key,
record_type,
files,
})
}
pub fn extract_filesystem_metadata(
avro_value: &AvroValue,
) -> HashMap<String, HoodieMetadataFileInfo> {
let mut files = HashMap::new();
let fs_metadata = match avro_value {
AvroValue::Record(fields) => fields.iter().find_map(|(name, val)| {
if name == "filesystemMetadata" {
match val {
AvroValue::Map(map) => Some(map),
AvroValue::Union(_, inner) => {
if let AvroValue::Map(map) = inner.as_ref() {
Some(map)
} else {
None
}
}
_ => None,
}
} else {
None
}
}),
_ => None,
};
if let Some(map) = fs_metadata {
for (name, value) in map {
if let Some(file_info) = extract_file_info(name, value) {
files.insert(name.clone(), file_info);
}
}
}
files
}
fn extract_file_info(name: &str, value: &AvroValue) -> Option<HoodieMetadataFileInfo> {
let record = match value {
AvroValue::Record(fields) => fields,
AvroValue::Union(_, inner) => {
if let AvroValue::Record(fields) = inner.as_ref() {
fields
} else {
return None;
}
}
_ => return None,
};
let mut size: i64 = 0;
let mut is_deleted = false;
for (field_name, field_value) in record {
match field_name.as_str() {
"size" => {
if let Some(n) = extract_long(field_value) {
size = n;
}
}
"isDeleted" => {
if let Some(b) = extract_bool(field_value) {
is_deleted = b;
}
}
_ => {}
}
}
Some(HoodieMetadataFileInfo::new(
name.to_string(),
size,
is_deleted,
))
}
fn extract_long(value: &AvroValue) -> Option<i64> {
match value {
AvroValue::Long(n) => Some(*n),
AvroValue::Int(n) => Some(*n as i64),
AvroValue::Union(_, inner) => extract_long(inner),
_ => None,
}
}
fn extract_bool(value: &AvroValue) -> Option<bool> {
match value {
AvroValue::Boolean(b) => Some(*b),
AvroValue::Union(_, inner) => extract_bool(inner),
_ => None,
}
}
pub fn decode_avro_value(value: &[u8], schema: &AvroSchema) -> Result<AvroValue> {
if value.is_empty() {
return Err(CoreError::MetadataTable("Empty value".to_string()));
}
apache_avro::from_avro_datum(schema, &mut &value[..], None)
.map_err(|e| CoreError::MetadataTable(format!("Avro decode error: {e}")))
}
pub fn parse_avro_schema(schema_json: &str) -> Result<AvroSchema> {
AvroSchema::parse_str(schema_json)
.map_err(|e| CoreError::MetadataTable(format!("Invalid Avro schema: {e}")))
}
fn get_avro_int(value: &AvroValue, field: &str) -> Option<i32> {
if let AvroValue::Record(fields) = value {
for (name, val) in fields {
if name == field {
return match val {
AvroValue::Int(n) => Some(*n),
AvroValue::Union(_, inner) => {
if let AvroValue::Int(n) = inner.as_ref() {
Some(*n)
} else {
None
}
}
_ => None,
};
}
}
}
None
}
pub fn get_record_type(avro_value: &AvroValue) -> MetadataRecordType {
get_avro_int(avro_value, "type")
.map(MetadataRecordType::from)
.unwrap_or(MetadataRecordType::Unknown)
}
#[cfg(test)]
mod tests {
use super::*;
use hudi_test::QuickstartTripsTable;
use std::path::PathBuf;
fn files_partition_dir() -> PathBuf {
let table_path = QuickstartTripsTable::V8Trips8I3U1D.path_to_mor_avro();
PathBuf::from(table_path)
.join(".hoodie")
.join("metadata")
.join("files")
}
fn files_partition_hfile_path() -> PathBuf {
let dir = files_partition_dir();
let mut hfiles: Vec<_> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("Failed to read directory {dir:?}: {e}"))
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "hfile")
.unwrap_or(false)
})
.collect();
hfiles.sort_by_key(|e| e.file_name());
hfiles
.last()
.map(|e| e.path())
.unwrap_or_else(|| panic!("No HFile found in {dir:?}"))
}
#[test]
fn test_metadata_partition_type_partition_name() {
assert_eq!(MetadataPartitionType::Files.partition_name(), "files");
assert_eq!(
MetadataPartitionType::ColumnStats.partition_name(),
"column_stats"
);
assert_eq!(
MetadataPartitionType::PartitionStats.partition_name(),
"partition_stats"
);
assert_eq!(
MetadataPartitionType::RecordIndex.partition_name(),
"record_index"
);
}
#[test]
fn test_metadata_partition_type_display() {
assert_eq!(format!("{}", MetadataPartitionType::Files), "files");
assert_eq!(
format!("{}", MetadataPartitionType::ColumnStats),
"column_stats"
);
assert_eq!(
format!("{}", MetadataPartitionType::PartitionStats),
"partition_stats"
);
assert_eq!(
format!("{}", MetadataPartitionType::RecordIndex),
"record_index"
);
}
#[test]
fn test_metadata_record_type_from_i32() {
assert_eq!(
MetadataRecordType::from(1),
MetadataRecordType::AllPartitions
);
assert_eq!(MetadataRecordType::from(2), MetadataRecordType::Files);
assert_eq!(MetadataRecordType::from(3), MetadataRecordType::ColumnStats);
assert_eq!(
MetadataRecordType::from(4),
MetadataRecordType::BloomFilters
);
assert_eq!(MetadataRecordType::from(5), MetadataRecordType::RecordIndex);
assert_eq!(
MetadataRecordType::from(6),
MetadataRecordType::PartitionStats
);
assert_eq!(
MetadataRecordType::from(7),
MetadataRecordType::SecondaryIndex
);
assert_eq!(MetadataRecordType::from(99), MetadataRecordType::Unknown);
}
#[test]
fn test_files_partition_record_active_files() {
let mut files = HashMap::new();
files.insert(
"active.parquet".to_string(),
HoodieMetadataFileInfo::new("active.parquet".to_string(), 1000, false),
);
files.insert(
"deleted.parquet".to_string(),
HoodieMetadataFileInfo::new("deleted.parquet".to_string(), 500, true),
);
let record = FilesPartitionRecord {
key: "partition".to_string(),
record_type: MetadataRecordType::Files,
files,
};
let active = record.active_file_names();
assert_eq!(active.len(), 1);
assert!(active.contains(&"active.parquet"));
assert!(record.has_active_file("active.parquet"));
assert!(!record.has_active_file("deleted.parquet"));
assert!(!record.has_active_file("nonexistent.parquet"));
assert_eq!(record.total_size(), 1000);
assert!(!record.is_all_partitions());
}
#[test]
fn test_files_partition_avro_decode() {
let path = files_partition_hfile_path();
let bytes = std::fs::read(&path).expect("Failed to read test file");
let reader = HFileReader::new(bytes.clone()).expect("Failed to create reader");
let mut reader_mut = HFileReader::new(bytes).expect("Failed to create reader");
let records = reader_mut
.collect_records()
.expect("Failed to collect records");
let all_partitions_record = records
.iter()
.find(|r| r.key_as_str() == Some(FilesPartitionRecord::ALL_PARTITIONS_KEY))
.expect("__all_partitions__ record not found");
let decoded = decode_files_partition_record(&reader, all_partitions_record)
.expect("Failed to decode ALL_PARTITIONS");
assert_eq!(decoded.record_type, MetadataRecordType::AllPartitions);
assert!(decoded.is_all_partitions());
let partition_names = decoded.partition_names();
assert_eq!(partition_names.len(), 3, "Should have 3 partitions");
assert!(decoded.files.contains_key("city=chennai"));
assert!(decoded.files.contains_key("city=san_francisco"));
assert!(decoded.files.contains_key("city=sao_paulo"));
let chennai_record = records
.iter()
.find(|r| r.key_as_str() == Some("city=chennai"))
.expect("chennai record not found");
let files_record =
decode_files_partition_record(&reader, chennai_record).expect("Failed to decode FILES");
assert_eq!(files_record.record_type, MetadataRecordType::Files);
assert!(!files_record.is_all_partitions());
println!("Chennai files ({}):", files_record.files.len());
for (name, info) in &files_record.files {
println!(
" - {} (size={}, deleted={})",
name, info.size, info.is_deleted
);
}
assert!(
files_record.files.len() >= 2,
"chennai should have at least 2 files"
);
let parquet_files: Vec<_> = files_record
.files
.iter()
.filter(|(name, _)| name.ends_with(".parquet"))
.collect();
assert_eq!(
parquet_files.len(),
2,
"chennai should have 2 parquet files"
);
for (file_name, file_info) in &parquet_files {
assert!(
file_name.contains("6e1d5cc4-c487-487d-abbe-fe9b30b1c0cc"),
"File should contain chennai UUID: {file_name}"
);
assert!(file_info.size > 0, "File size should be > 0: {file_info:?}");
assert!(!file_info.is_deleted, "File should not be deleted");
}
assert!(files_record.total_size() > 0, "Total size should be > 0");
}
#[test]
fn test_hoodie_metadata_file_info_new() {
let info = HoodieMetadataFileInfo::new("test.parquet".to_string(), 12345, false);
assert_eq!(info.name, "test.parquet");
assert_eq!(info.size, 12345);
assert!(!info.is_deleted);
let deleted_info = HoodieMetadataFileInfo::new("deleted.parquet".to_string(), 0, true);
assert_eq!(deleted_info.name, "deleted.parquet");
assert_eq!(deleted_info.size, 0);
assert!(deleted_info.is_deleted);
}
#[test]
fn test_files_partition_record_all_file_names() {
let mut files = HashMap::new();
files.insert(
"file1.parquet".to_string(),
HoodieMetadataFileInfo::new("file1.parquet".to_string(), 1000, false),
);
files.insert(
"file2.parquet".to_string(),
HoodieMetadataFileInfo::new("file2.parquet".to_string(), 500, true),
);
files.insert(
"file3.parquet".to_string(),
HoodieMetadataFileInfo::new("file3.parquet".to_string(), 2000, false),
);
let record = FilesPartitionRecord {
key: "partition".to_string(),
record_type: MetadataRecordType::Files,
files,
};
let all_names = record.all_file_names();
assert_eq!(all_names.len(), 3);
assert!(all_names.contains(&"file1.parquet"));
assert!(all_names.contains(&"file2.parquet"));
assert!(all_names.contains(&"file3.parquet"));
}
#[test]
fn test_files_partition_record_partition_names_for_non_all_partitions() {
let mut files = HashMap::new();
files.insert(
"file.parquet".to_string(),
HoodieMetadataFileInfo::new("file.parquet".to_string(), 1000, false),
);
let record = FilesPartitionRecord {
key: "city=chennai".to_string(),
record_type: MetadataRecordType::Files,
files,
};
let partition_names = record.partition_names();
assert!(partition_names.is_empty());
}
#[test]
fn test_parse_avro_schema() {
let schema_json =
r#"{"type": "record", "name": "Test", "fields": [{"name": "id", "type": "int"}]}"#;
assert!(parse_avro_schema(schema_json).is_ok());
let result = parse_avro_schema("not valid json");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid Avro schema")
);
}
#[test]
fn test_get_record_type() {
let no_type = AvroValue::Record(vec![("other".to_string(), AvroValue::Int(42))]);
assert_eq!(get_record_type(&no_type), MetadataRecordType::Unknown);
let non_record = AvroValue::String("test".to_string());
assert_eq!(get_record_type(&non_record), MetadataRecordType::Unknown);
let union_int = AvroValue::Record(vec![(
"type".to_string(),
AvroValue::Union(0, Box::new(AvroValue::Int(2))),
)]);
assert_eq!(get_record_type(&union_int), MetadataRecordType::Files);
}
#[test]
fn test_extract_long() {
assert_eq!(extract_long(&AvroValue::Int(42)), Some(42));
assert_eq!(extract_long(&AvroValue::Long(123456789)), Some(123456789));
assert_eq!(
extract_long(&AvroValue::Union(0, Box::new(AvroValue::Long(999)))),
Some(999)
);
assert_eq!(
extract_long(&AvroValue::String("not a number".to_string())),
None
);
}
#[test]
fn test_extract_bool() {
assert_eq!(extract_bool(&AvroValue::Boolean(true)), Some(true));
assert_eq!(extract_bool(&AvroValue::Boolean(false)), Some(false));
assert_eq!(
extract_bool(&AvroValue::Union(0, Box::new(AvroValue::Boolean(true)))),
Some(true)
);
assert_eq!(
extract_bool(&AvroValue::String("not a bool".to_string())),
None
);
}
#[test]
fn test_extract_filesystem_metadata() {
assert!(
extract_filesystem_metadata(&AvroValue::String("not a record".to_string())).is_empty()
);
let no_field = AvroValue::Record(vec![("other".to_string(), AvroValue::Int(42))]);
assert!(extract_filesystem_metadata(&no_field).is_empty());
let union_null = AvroValue::Record(vec![(
"filesystemMetadata".to_string(),
AvroValue::Union(0, Box::new(AvroValue::Null)),
)]);
assert!(extract_filesystem_metadata(&union_null).is_empty());
let invalid_type = AvroValue::Record(vec![(
"filesystemMetadata".to_string(),
AvroValue::String("not a map".to_string()),
)]);
assert!(extract_filesystem_metadata(&invalid_type).is_empty());
use std::collections::HashMap as StdMap;
let mut map = StdMap::new();
map.insert(
"test.parquet".to_string(),
AvroValue::Record(vec![
("size".to_string(), AvroValue::Long(1000)),
("isDeleted".to_string(), AvroValue::Boolean(false)),
]),
);
let union_map = AvroValue::Record(vec![(
"filesystemMetadata".to_string(),
AvroValue::Union(1, Box::new(AvroValue::Map(map))),
)]);
let result = extract_filesystem_metadata(&union_map);
assert_eq!(result.len(), 1);
let info = result.get("test.parquet").unwrap();
assert_eq!(info.size, 1000);
assert!(!info.is_deleted);
}
#[test]
fn test_extract_file_info() {
assert!(
extract_file_info(
"test.parquet",
&AvroValue::String("not a record".to_string())
)
.is_none()
);
let union_string =
AvroValue::Union(0, Box::new(AvroValue::String("not a record".to_string())));
assert!(extract_file_info("test.parquet", &union_string).is_none());
let union_record = AvroValue::Union(
1,
Box::new(AvroValue::Record(vec![
("size".to_string(), AvroValue::Long(5000)),
("isDeleted".to_string(), AvroValue::Boolean(true)),
])),
);
let info = extract_file_info("deleted.parquet", &union_record).unwrap();
assert_eq!(info.name, "deleted.parquet");
assert_eq!(info.size, 5000);
assert!(info.is_deleted);
}
#[test]
fn test_get_avro_int() {
let union_string = AvroValue::Record(vec![(
"type".to_string(),
AvroValue::Union(0, Box::new(AvroValue::String("not int".to_string()))),
)]);
assert!(get_avro_int(&union_string, "type").is_none());
let direct_string = AvroValue::Record(vec![(
"type".to_string(),
AvroValue::String("not int".to_string()),
)]);
assert!(get_avro_int(&direct_string, "type").is_none());
let direct_int = AvroValue::Record(vec![("type".to_string(), AvroValue::Int(3))]);
assert_eq!(get_avro_int(&direct_int, "type"), Some(3));
}
#[test]
fn test_decode_files_partition_record_with_schema_tombstone() {
let record = crate::hfile::HFileRecord::new(b"deleted_partition".to_vec(), vec![]);
let schema = parse_avro_schema(
r#"{"type": "record", "name": "Test", "fields": [{"name": "type", "type": "int"}]}"#,
)
.unwrap();
let result = decode_files_partition_record_with_schema(&record, &schema);
assert!(result.is_ok());
let decoded = result.unwrap();
assert_eq!(decoded.key, "deleted_partition");
assert_eq!(decoded.record_type, MetadataRecordType::Files);
assert!(decoded.files.is_empty());
}
#[test]
fn test_decode_files_partition_record_with_schema_invalid_key() {
let record = crate::hfile::HFileRecord::new(vec![0xff, 0xfe], b"value".to_vec());
let schema = parse_avro_schema(
r#"{"type": "record", "name": "Test", "fields": [{"name": "type", "type": "int"}]}"#,
)
.unwrap();
let result = decode_files_partition_record_with_schema(&record, &schema);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid UTF-8 key")
);
}
#[test]
fn test_decode_avro_value() {
let schema = parse_avro_schema(
r#"{"type": "record", "name": "Test", "fields": [{"name": "name", "type": "string"}, {"name": "value", "type": "long"}]}"#,
)
.unwrap();
let empty_result = decode_avro_value(&[], &schema);
assert!(empty_result.is_err());
assert!(
empty_result
.unwrap_err()
.to_string()
.contains("Empty value")
);
let invalid_bytes: &[u8] = &[0xff, 0xff, 0xff, 0xff, 0xff];
let invalid_result = decode_avro_value(invalid_bytes, &schema);
assert!(invalid_result.is_err());
assert!(
invalid_result
.unwrap_err()
.to_string()
.contains("Avro decode error")
);
}
}