use std::collections::HashMap;
use std::io::Cursor;
use apache_avro::Reader as AvroReader;
use apache_avro::from_value;
use serde::{Deserialize, Serialize};
use crate::Result;
use crate::error::CoreError;
use crate::schema::avsc::{encode_with_schema, hoodie_compaction_plan_schema};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CompactionOperation {
pub base_instant_time: Option<String>,
pub delta_file_paths: Option<Vec<String>>,
pub data_file_path: Option<String>,
pub file_id: Option<String>,
pub partition_path: Option<String>,
pub metrics: Option<HashMap<String, f64>>,
pub bootstrap_file_path: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CompactionStrategy {
pub compactor_class_name: Option<String>,
pub strategy_params: Option<HashMap<String, String>>,
pub version: Option<i32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CompactionPlan {
pub operations: Option<Vec<CompactionOperation>>,
pub extra_metadata: Option<HashMap<String, String>>,
pub version: Option<i32>,
pub strategy: Option<CompactionStrategy>,
pub preserve_hoodie_metadata: Option<bool>,
pub missing_schedule_partitions: Option<Vec<String>>,
}
impl CompactionPlan {
pub const LATEST_VERSION: i32 = 2;
pub const COMPACTOR_KEY: &'static str = "compactor";
pub const HUDI_RS_COMPACTOR: &'static str = "hudi-rs";
pub fn is_scheduled_by_hudi_rs(&self) -> bool {
self.extra_metadata
.as_ref()
.and_then(|metadata| metadata.get(Self::COMPACTOR_KEY))
.is_some_and(|compactor| compactor == Self::HUDI_RS_COMPACTOR)
}
pub fn to_avro_bytes(&self) -> Result<Vec<u8>> {
encode_with_schema(self, hoodie_compaction_plan_schema()?)
}
pub fn from_avro_bytes(bytes: &[u8]) -> Result<Self> {
let mut records = AvroReader::new(Cursor::new(bytes))
.map_err(|e| CoreError::CommitMetadata(format!("Failed to create Avro reader: {e}")))?;
let value = records
.next()
.ok_or_else(|| CoreError::CommitMetadata("Avro file contains no records".to_string()))?
.map_err(|e| CoreError::CommitMetadata(format!("Failed to read Avro record: {e}")))?;
from_value::<Self>(&value).map_err(|e| {
CoreError::CommitMetadata(format!("Failed to deserialize Avro value: {e}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compaction_plan_avro_round_trip() {
let plan = CompactionPlan {
operations: Some(vec![
CompactionOperation {
base_instant_time: Some("20260101000000000".to_string()),
delta_file_paths: Some(vec![
".f1-0_20260101000001000.log.1_0-0-0".to_string(),
".f1-0_20260101000002000.log.1_0-0-0".to_string(),
]),
data_file_path: Some("f1-0_0-0-0_20260101000000000.parquet".to_string()),
file_id: Some("f1-0".to_string()),
partition_path: Some("city=sf".to_string()),
metrics: Some(HashMap::from([("TOTAL_LOG_FILES".to_string(), 2.0)])),
bootstrap_file_path: None,
},
CompactionOperation {
base_instant_time: Some("20260101000003000".to_string()),
delta_file_paths: Some(vec![".f2-0_20260101000003000.log.1_0-0-0".to_string()]),
data_file_path: None,
file_id: Some("f2-0".to_string()),
partition_path: Some(String::new()),
metrics: None,
bootstrap_file_path: None,
},
]),
extra_metadata: Some(HashMap::new()),
version: Some(CompactionPlan::LATEST_VERSION),
strategy: Some(CompactionStrategy {
compactor_class_name: None,
strategy_params: Some(HashMap::new()),
version: Some(1),
}),
preserve_hoodie_metadata: Some(true),
missing_schedule_partitions: None,
};
let bytes = plan.to_avro_bytes().unwrap();
assert_eq!(&bytes[..4], b"Obj\x01");
assert_eq!(CompactionPlan::from_avro_bytes(&bytes).unwrap(), plan);
}
#[test]
fn test_compaction_plan_rejects_non_avro_bytes() {
assert!(CompactionPlan::from_avro_bytes(b"not avro").is_err());
}
}