use std::collections::HashMap;
use crate::metadata::table::encode::{ColumnStatValue, ColumnStatsMetadata};
use crate::metadata::table_record::HoodieMetadataFileInfo;
#[derive(Clone, Copy, Debug, Default)]
pub struct MetadataPayloadMerger;
impl MetadataPayloadMerger {
pub fn merge_file_infos(
&self,
existing: &mut HashMap<String, HoodieMetadataFileInfo>,
newer: &HashMap<String, HoodieMetadataFileInfo>,
) {
for (name, new_info) in newer {
match existing.get(name) {
Some(old_info) => {
if new_info.is_deleted {
if old_info.is_deleted {
existing.insert(name.clone(), new_info.clone());
} else {
existing.remove(name);
}
} else {
existing.insert(
name.clone(),
HoodieMetadataFileInfo::new(
name.clone(),
old_info.size.max(new_info.size),
false,
),
);
}
}
None => {
existing.insert(name.clone(), new_info.clone());
}
}
}
}
pub fn merge_column_stats(
&self,
older: &ColumnStatsMetadata,
newer: &ColumnStatsMetadata,
) -> ColumnStatsMetadata {
if newer.is_deleted || older.is_deleted || newer.is_tight_bound {
return newer.clone();
}
ColumnStatsMetadata {
file_name: newer.file_name.clone(),
column_name: newer.column_name.clone(),
min_value: merge_bound(&older.min_value, &newer.min_value, Bound::Min),
max_value: merge_bound(&older.max_value, &newer.max_value, Bound::Max),
value_count: older.value_count + newer.value_count,
null_count: older.null_count + newer.null_count,
total_size: older.total_size + newer.total_size,
total_uncompressed_size: older.total_uncompressed_size + newer.total_uncompressed_size,
is_deleted: newer.is_deleted,
is_tight_bound: newer.is_tight_bound,
decoded_value_type_ordinal: newer.decoded_value_type_ordinal,
}
}
}
enum Bound {
Min,
Max,
}
fn merge_bound(
older: &Option<ColumnStatValue>,
newer: &Option<ColumnStatValue>,
bound: Bound,
) -> Option<ColumnStatValue> {
match (older, newer) {
(Some(old), Some(new)) => match compare_stat_values(old, new) {
Some(ordering) => {
let take_old = match bound {
Bound::Min => ordering.is_lt(),
Bound::Max => ordering.is_gt(),
};
Some(if take_old { old.clone() } else { new.clone() })
}
None => Some(new.clone()),
},
(Some(old), None) => Some(old.clone()),
(None, new) => new.clone(),
}
}
fn compare_stat_values(a: &ColumnStatValue, b: &ColumnStatValue) -> Option<std::cmp::Ordering> {
use ColumnStatValue::*;
match (a, b) {
(Boolean(x), Boolean(y)) => x.partial_cmp(y),
(Int(x), Int(y)) => x.partial_cmp(y),
(Long(x), Long(y)) => x.partial_cmp(y),
(Float(x), Float(y)) => x.partial_cmp(y),
(Double(x), Double(y)) => x.partial_cmp(y),
(Bytes(x), Bytes(y)) => x.partial_cmp(y),
(String(x), String(y)) => x.partial_cmp(y),
(Date(x), Date(y)) => x.partial_cmp(y),
(TimeMicros(x), TimeMicros(y)) => x.partial_cmp(y),
(TimestampMicros(x), TimestampMicros(y)) => x.partial_cmp(y),
(LocalTimestampMicros(x), LocalTimestampMicros(y)) => x.partial_cmp(y),
(
Decimal {
unscaled: x,
scale: sx,
..
},
Decimal {
unscaled: y,
scale: sy,
..
},
) => {
use crate::metadata::table::encode::rescale_decimal;
let scale = (*sx).max(*sy);
rescale_decimal(*x, *sx, scale)?.partial_cmp(&rescale_decimal(*y, *sy, scale)?)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stats(min: i64, max: i64, count: i64, tight: bool) -> ColumnStatsMetadata {
ColumnStatsMetadata {
file_name: "f".to_string(),
column_name: "c".to_string(),
min_value: Some(ColumnStatValue::Long(min)),
max_value: Some(ColumnStatValue::Long(max)),
value_count: count,
null_count: 0,
total_size: 10,
total_uncompressed_size: 20,
is_deleted: false,
is_tight_bound: tight,
decoded_value_type_ordinal: None,
}
}
#[test]
fn test_merge_column_stats_unions_ranges_and_sums_counts() {
let merger = MetadataPayloadMerger;
let merged = merger.merge_column_stats(&stats(5, 10, 3, false), &stats(1, 7, 2, false));
assert_eq!(merged.min_value, Some(ColumnStatValue::Long(1)));
assert_eq!(merged.max_value, Some(ColumnStatValue::Long(10)));
assert_eq!(merged.value_count, 5);
assert_eq!(merged.total_size, 20);
}
#[test]
fn test_merge_column_stats_tight_bound_newer_wins() {
let merger = MetadataPayloadMerger;
let merged = merger.merge_column_stats(&stats(1, 100, 50, false), &stats(5, 10, 3, true));
assert_eq!(merged.min_value, Some(ColumnStatValue::Long(5)));
assert_eq!(merged.max_value, Some(ColumnStatValue::Long(10)));
assert_eq!(merged.value_count, 3);
}
#[test]
fn test_merge_column_stats_tombstone_replaces() {
let merger = MetadataPayloadMerger;
let mut tombstone = stats(0, 0, 0, false);
tombstone.is_deleted = true;
let merged = merger.merge_column_stats(&stats(1, 100, 50, false), &tombstone);
assert!(merged.is_deleted);
}
#[test]
fn test_merge_file_infos_java_semantics() {
let merger = MetadataPayloadMerger;
let mut existing = HashMap::from([
(
"a.parquet".to_string(),
HoodieMetadataFileInfo::new("a.parquet".to_string(), 100, false),
),
(
"b.parquet".to_string(),
HoodieMetadataFileInfo::new("b.parquet".to_string(), 50, false),
),
]);
let newer = HashMap::from([
(
"a.parquet".to_string(),
HoodieMetadataFileInfo::new("a.parquet".to_string(), 200, false),
),
(
"b.parquet".to_string(),
HoodieMetadataFileInfo::new("b.parquet".to_string(), 0, true),
),
(
"c.parquet".to_string(),
HoodieMetadataFileInfo::new("c.parquet".to_string(), 0, true),
),
]);
merger.merge_file_infos(&mut existing, &newer);
assert_eq!(existing["a.parquet"].size, 200);
assert!(!existing.contains_key("b.parquet"));
assert!(existing["c.parquet"].is_deleted);
}
#[test]
fn test_compare_stat_values_same_and_cross_variant() {
use crate::metadata::table::encode::ColumnStatValue as V;
use std::cmp::Ordering;
let cases = vec![
(V::Boolean(false), V::Boolean(true)),
(V::Int(1), V::Int(2)),
(V::Long(1), V::Long(2)),
(V::Float(1.0), V::Float(2.0)),
(V::Double(1.0), V::Double(2.0)),
(V::Bytes(vec![1]), V::Bytes(vec![2])),
(V::String("a".into()), V::String("b".into())),
(V::Date(1), V::Date(2)),
(V::TimeMicros(1), V::TimeMicros(2)),
(V::TimestampMicros(1), V::TimestampMicros(2)),
(V::LocalTimestampMicros(1), V::LocalTimestampMicros(2)),
];
for (a, b) in &cases {
assert_eq!(compare_stat_values(a, b), Some(Ordering::Less));
}
assert_eq!(compare_stat_values(&V::Int(1), &V::Long(1)), None);
}
}