use crate::analyze::common::{
AVG_FILE_SIZE_MEDIUM_THRESHOLD, COMPACTION_TARGET_LARGE_AVG, COMPACTION_TARGET_MEDIUM_AVG,
COMPACTION_TARGET_SMALL_AVG, SMALL_FILE_THRESHOLD_BYTES, TARGET_COMPACTION_SIZE_BYTES,
};
use crate::analyze::delta::DeltaAnalyzer;
#[cfg(feature = "hudi")]
use crate::analyze::hudi::HudiAnalyzer;
use crate::analyze::iceberg::IcebergAnalyzer;
#[cfg(feature = "lance")]
use crate::analyze::lance::analyze::LanceAnalyzer;
use crate::analyze::metrics::{
FileCompactionMetrics, FileInfo, HealthMetrics, HealthReport, PartitionInfo, TimedLikeMetrics,
};
use crate::analyze::table_analyzer::TableAnalyzer;
use crate::reader::delta::reader::DeltaReader;
#[cfg(feature = "hudi")]
use crate::reader::hudi::reader::HudiReader;
use crate::reader::iceberg::reader::IcebergReader;
#[cfg(feature = "lance")]
use crate::reader::lance::reader::LanceReader;
use crate::storage::{FileMetadata, StorageConfig, StorageProvider, StorageProviderFactory};
use crate::util::helpers::{
detect_table_type, measure_dur, measure_dur_async, measure_dur_with_error,
};
use std::collections::{HashMap, HashSet, LinkedList};
use std::error::Error;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
pub struct AnalyzerBuilder {
config: StorageConfig,
parallelism: Option<usize>,
}
impl AnalyzerBuilder {
pub fn new(config: StorageConfig) -> Self {
Self {
config,
parallelism: None,
}
}
pub fn with_parallelism(mut self, parallelism: usize) -> Self {
self.parallelism = Some(parallelism);
self
}
pub async fn build(self) -> Result<Analyzer, Box<dyn Error + Send + Sync>> {
let storage_provider = StorageProviderFactory::from_config(self.config).await?;
Ok(Analyzer {
storage_provider,
parallelism: self.parallelism.unwrap_or(1),
})
}
}
pub struct Analyzer {
storage_provider: Arc<dyn StorageProvider>,
parallelism: usize,
}
impl Analyzer {
pub fn builder(config: StorageConfig) -> AnalyzerBuilder {
AnalyzerBuilder::new(config)
}
pub async fn analyze(
&self,
location: &str,
) -> Result<HealthReport, Box<dyn Error + Send + Sync>> {
let mut internal_metrics: LinkedList<(&str, SystemTime, Duration)> = LinkedList::new();
let mut metrics = HealthMetrics::new();
info!(
"Analyzing, base_path={}/{}",
self.storage_provider.base_path(),
location
);
measure_dur_async(
"validate_connection_dur",
&mut internal_metrics,
|| async { self.storage_provider.validate_connection(location).await },
Some(|_| "Successfully validated connection".to_string()),
)
.await?;
let partitions = measure_dur_async(
"discover_partitions",
&mut internal_metrics,
|| async {
self.storage_provider
.discover_partitions(location, vec![])
.await
},
Some(|c: &Vec<String>| format!("Discovered partitions count={}", c.len())),
)
.await?;
let list_files_start = SystemTime::now();
let all_objects = measure_dur_async(
"list_files_parallel",
&mut internal_metrics,
|| async {
self.storage_provider
.list_files_parallel(location, partitions.clone(), self.parallelism)
.await
},
Some(|c: &Vec<FileMetadata>| format!("Listed files count={}", c.len())),
)
.await?;
let table_type: String = measure_dur(
"detect_table_type",
&mut internal_metrics,
|| detect_table_type(&all_objects),
Some(|t: String| format!("Detected table type={}", t)),
)
.await;
let table_analyzer: Arc<dyn TableAnalyzer> = match table_type.as_str() {
"delta" => Arc::new(DeltaAnalyzer::new(
Arc::clone(&self.storage_provider),
self.parallelism,
)),
#[cfg(feature = "hudi")]
"hudi" => Arc::new(HudiAnalyzer::new(
Arc::clone(&self.storage_provider),
self.parallelism,
)),
"iceberg" => Arc::new(IcebergAnalyzer::new(
Arc::clone(&self.storage_provider),
self.parallelism,
)),
#[cfg(feature = "lance")]
"lance" => Arc::new(LanceAnalyzer::new(
Arc::clone(&self.storage_provider),
self.parallelism,
)),
_ => {
return Err(format!(
"Unknown, unsupported or not enabled table type={}. \
Check documentation on what other table formats you can enable.",
table_type
)
.into());
}
};
let (data_files, metadata_files) = measure_dur(
"categorize_files",
&mut internal_metrics,
|| table_analyzer.categorize_files(all_objects.clone()),
Some(|(data, meta): (Vec<FileMetadata>, Vec<FileMetadata>)| {
format!(
"Categorized data files count={}, metadata files count={}",
data.len(),
meta.len()
)
}),
)
.await;
let referenced_files = measure_dur_async(
"find_referenced_files",
&mut internal_metrics,
|| async { table_analyzer.find_referenced_files(&metadata_files).await },
Some(|c: &Vec<String>| format!("Found referenced files, count={}", c.len())),
)
.await?;
metrics.total_files = data_files.len();
metrics.total_size_bytes = data_files.iter().map(|f| f.size).sum();
let referenced_set: HashSet<String> = referenced_files.into_iter().collect();
let unreferenced_files = measure_dur(
"find_unreferenced_files",
&mut internal_metrics,
|| {
data_files
.iter()
.filter(|f| !referenced_set.iter().any(|i| f.path.contains(i)))
.map(|f| FileInfo {
path: format!("{}/{}", self.storage_provider.base_path(), f.path),
size_bytes: f.size,
last_modified: f.last_modified.map(|m| m.to_rfc3339()),
is_referenced: false,
})
.collect::<Vec<FileInfo>>()
},
Some(|c: Vec<FileInfo>| format!("Found unreferenced files, count={}", c.len())),
)
.await;
metrics.unreferenced_files = unreferenced_files;
metrics.unreferenced_size_bytes = metrics
.unreferenced_files
.iter()
.map(|f| f.size_bytes)
.sum();
metrics.partitions = measure_dur_with_error(
"analyze_partitioning",
&mut internal_metrics,
|| self.analyze_partitioning(&data_files),
Some(|c: &Vec<PartitionInfo>| format!("Analyzed partitions count={}", c.len())),
)?;
metrics.partition_count = metrics.partitions.len();
let data_files_total_size: u64 = data_files.iter().map(|f| f.size).sum();
measure_dur_async(
"update_metrics_from_metadata",
&mut internal_metrics,
|| async {
table_analyzer
.update_metrics_from_metadata(
&metadata_files,
data_files_total_size,
data_files.len(),
&mut metrics,
)
.await
},
Some(|_| "Updated metrics from metadata".to_string()),
)
.await?;
let (small_files, medium_files, large_files, very_large_files) =
measure_dur(
"calculate_file_size_distribution",
&mut internal_metrics,
|| { self.calculate_file_size_distribution(&data_files) },
Some(|(small, medium, large, very_large): (usize, usize, usize, usize)| {
format!(
"Calculated file size distribution, small={}, medium={}, large={}, very_large={}",
small, medium, large, very_large
)
}),
).await;
metrics.file_size_distribution.small_files = small_files;
metrics.file_size_distribution.medium_files = medium_files;
metrics.file_size_distribution.large_files = large_files;
metrics.file_size_distribution.very_large_files = very_large_files;
if metrics.total_files > 0 {
metrics.avg_file_size_bytes =
metrics.total_size_bytes as f64 / metrics.total_files as f64;
}
let (
metadata_file_count,
metadata_total_size_bytes,
avg_metadata_file_size,
metadata_growth_rate,
) = measure_dur(
"calculate_metadata_health",
&mut internal_metrics,
|| { self.calculate_metadata_health(&metadata_files) },
Some(|(count, size, avg, growth): (usize, u64, f64, f64)| {
format!(
"Calculated metadata health, file_count={}, bytesize={}, avg_bytesize={}, growth_rate={}",
count, size, avg, growth
)
}),
).await;
metrics.metadata_health.metadata_file_count = metadata_file_count;
metrics.metadata_health.metadata_total_size_bytes = metadata_total_size_bytes;
metrics.metadata_health.avg_metadata_file_size = avg_metadata_file_size;
metrics.metadata_health.metadata_growth_rate = metadata_growth_rate;
measure_dur(
"calculate_data_skew",
&mut internal_metrics,
|| metrics.calculate_data_skew(),
None,
)
.await;
measure_dur(
"calculate_snapshot_health",
&mut internal_metrics,
|| metrics.calculate_snapshot_health(metadata_files.len()),
None,
)
.await;
metrics.file_compaction = measure_dur_async(
"analyze_file_compaction",
&mut internal_metrics,
|| async {
if let Some(file_compaction) = metrics.file_compaction.clone() {
let z_order_opportunity = file_compaction.z_order_opportunity;
let z_order_columns = file_compaction.z_order_columns;
self.analyze_file_compaction(
&data_files,
z_order_opportunity,
z_order_columns.clone(),
)
.await
} else {
Ok(None)
}
},
Some(|_| "Calculated compaction metrics".to_string()),
)
.await?;
measure_dur(
"generate_recommendations",
&mut internal_metrics,
|| metrics.generate_recommendations(),
Some(|_| "Generated recommendations".to_string()),
)
.await;
metrics.health_score = measure_dur(
"calculate_health_score",
&mut internal_metrics,
|| metrics.calculate_health_score(),
Some(|score: f64| format!("Calculated health score={}", score)),
)
.await;
let table_path = self.storage_provider.uri_from_path(location);
let analyze_after_validation_dur = list_files_start.elapsed()?;
internal_metrics.push_back((
"analyze_after_validation_dur",
list_files_start,
analyze_after_validation_dur,
));
info!("Analysis took={}", analyze_after_validation_dur.as_millis());
if table_type == "delta" {
measure_dur_async(
"delta_reader",
&mut internal_metrics,
|| async {
let delta_reader = DeltaReader::open(
self.storage_provider.uri_from_path(location).as_str(),
&self.storage_provider.clean_options(),
)
.await;
match delta_reader {
Ok(reader) => {
metrics.delta_table_specific_metrics =
Some(reader.extract_metrics().await?);
Ok(())
}
Err(e) => {
warn!("Failed to open Delta reader: {}", e);
Err(e)
}
}
},
Some(|_| "Opened Delta reader".to_string()),
)
.await?;
} else if table_type == "iceberg" {
measure_dur_async(
"iceberg_reader",
&mut internal_metrics,
|| async {
let iceberg_reader = IcebergReader::open(
self.storage_provider.uri_from_path(location).as_str(),
&self.storage_provider.clean_options(),
)
.await;
match iceberg_reader {
Ok(reader) => {
metrics.iceberg_table_specific_metrics =
Some(reader.extract_metrics().await?);
Ok(())
}
Err(e) => {
warn!("Failed to open Iceberg reader: {}", e);
Err(e)
}
}
},
Some(|_| "Opened Iceberg reader".to_string()),
)
.await?;
}
#[cfg(feature = "hudi")]
if table_type == "hudi" {
measure_dur_async(
"hudi_reader",
&mut internal_metrics,
|| async {
let storage_options = HashMap::new();
let hudi_reader = HudiReader::open(
self.storage_provider.uri_from_path(location).as_str(),
&storage_options,
)
.await;
match hudi_reader {
Ok(reader) => {
metrics.hudi_table_specific_metrics =
Some(reader.extract_metrics().await?);
Ok(())
}
Err(e) => {
warn!("Failed to open Hudi reader: {}", e);
Err(e)
}
}
},
Some(|_| "Opened Hudi reader".to_string()),
)
.await?;
}
#[cfg(feature = "lance")]
if table_type == "lance" {
measure_dur_async(
"lance_reader",
&mut internal_metrics,
|| async {
let lance_reader =
LanceReader::open(self.storage_provider.uri_from_path(location).as_str())
.await;
match lance_reader {
Ok(reader) => {
metrics.lance_table_specific_metrics =
Some(reader.extract_metrics().await?);
Ok(())
}
Err(e) => {
warn!("Failed to open Lance reader: {}", e);
Err(e)
}
}
},
Some(|_| "Opened Lance reader".to_string()),
)
.await?;
}
let timed_metrics: LinkedList<(String, u128, u128)> = internal_metrics
.iter()
.map(
|(k, st, dur)| -> Result<(String, u128, u128), Box<dyn Error + Send + Sync>> {
Ok((
k.to_string(),
st.duration_since(UNIX_EPOCH)?.as_millis(),
dur.as_millis(),
))
},
)
.collect::<Result<LinkedList<(_, _, _)>, _>>()?;
let report = HealthReport {
table_path,
table_type,
analysis_timestamp: chrono::Utc::now().to_rfc3339(),
metrics: metrics.clone(),
health_score: metrics.health_score,
timed_metrics: TimedLikeMetrics {
duration_collection: timed_metrics,
},
};
Ok(report)
}
fn analyze_partitioning(
&self,
data_files: &Vec<FileMetadata>,
) -> Result<Vec<PartitionInfo>, Box<dyn Error + Send + Sync>> {
let mut partition_map: HashMap<String, PartitionInfo> = HashMap::new();
for file in data_files {
let path_parts: Vec<&str> = file.path.split('/').collect();
let mut partition_values = HashMap::new();
let mut _file_name = "";
for part in &path_parts {
if part.contains('=') {
let kv: Vec<&str> = part.split('=').collect();
if kv.len() == 2 {
partition_values.insert(kv[0].to_string(), kv[1].to_string());
}
} else if part.ends_with(".parquet") {
_file_name = part;
}
}
let partition_key = serde_json::to_string(&partition_values).unwrap_or_default();
let partition_info =
partition_map
.entry(partition_key)
.or_insert_with(|| PartitionInfo {
partition_values: partition_values.clone(),
file_count: 0,
total_size_bytes: 0,
avg_file_size_bytes: 0.0,
files: Vec::new(),
});
partition_info.file_count += 1;
partition_info.total_size_bytes += file.size;
partition_info.files.push(FileInfo {
path: format!("{}/{}", self.storage_provider.base_path(), file.path),
size_bytes: file.size,
last_modified: file.last_modified.map(|m| m.to_rfc3339()),
is_referenced: true, });
}
for partition in partition_map.values_mut() {
if partition.file_count > 0 {
partition.avg_file_size_bytes =
partition.total_size_bytes as f64 / partition.file_count as f64;
}
}
let partitions = partition_map.into_values().collect::<Vec<PartitionInfo>>();
Ok(partitions)
}
fn calculate_file_size_distribution(
&self,
data_files: &Vec<FileMetadata>,
) -> (usize, usize, usize, usize) {
let mut small_files = 0;
let mut medium_files = 0;
let mut large_files = 0;
let mut very_large_files = 0;
for file in data_files {
let size_mb = file.size as f64 / (1024.0 * 1024.0);
if size_mb < 16.0 {
small_files += 1;
} else if size_mb < 128.0 {
medium_files += 1;
} else if size_mb < 1024.0 {
large_files += 1;
} else {
very_large_files += 1;
}
}
(small_files, medium_files, large_files, very_large_files)
}
pub fn calculate_metadata_health(
&self,
metadata_files: &[FileMetadata],
) -> (usize, u64, f64, f64) {
let mut metadata_health: f64 = 0.0;
let metadata_file_count = metadata_files.len();
let metadata_total_size_bytes: u64 = metadata_files.iter().map(|f| f.size).sum();
if !metadata_files.is_empty() {
metadata_health = metadata_total_size_bytes as f64 / metadata_files.len() as f64;
}
let metadata_growth_rate = 0.0;
(
metadata_file_count,
metadata_total_size_bytes,
metadata_health,
metadata_growth_rate,
)
}
async fn analyze_file_compaction(
&self,
data_files: &Vec<FileMetadata>,
z_order_opportunity: bool,
z_order_columns: Vec<String>,
) -> Result<Option<FileCompactionMetrics>, Box<dyn Error + Send + Sync>> {
let mut small_files_count = 0;
let mut small_files_size = 0u64;
let mut potential_compaction_files = 0;
let mut estimated_savings = 0u64;
for file in data_files {
let file_size = file.size;
if file_size < SMALL_FILE_THRESHOLD_BYTES {
small_files_count += 1;
small_files_size += file_size;
potential_compaction_files += 1;
}
}
if small_files_count > 1 {
let files_per_target = (TARGET_COMPACTION_SIZE_BYTES as f64
/ (small_files_size as f64 / small_files_count as f64))
.ceil() as usize;
let target_files = (small_files_count as f64 / files_per_target as f64).ceil() as usize;
let estimated_target_size = target_files as u64 * TARGET_COMPACTION_SIZE_BYTES / 2; estimated_savings = small_files_size.saturating_sub(estimated_target_size);
}
let compaction_opportunity = self.calculate_compaction_opportunity(
small_files_count,
small_files_size,
data_files.len(),
);
let recommended_target_size = self.calculate_recommended_target_size(data_files);
let compaction_priority =
self.calculate_compaction_priority(compaction_opportunity, small_files_count);
Ok(Some(FileCompactionMetrics {
compaction_opportunity_score: compaction_opportunity,
small_files_count,
small_files_size_bytes: small_files_size,
potential_compaction_files,
estimated_compaction_savings_bytes: estimated_savings,
recommended_target_file_size_bytes: recommended_target_size,
compaction_priority,
z_order_opportunity,
z_order_columns,
}))
}
fn calculate_compaction_opportunity(
&self,
small_files: usize,
small_files_size: u64,
total_files: usize,
) -> f64 {
if total_files == 0 {
return 0.0;
}
let small_file_ratio = small_files as f64 / total_files as f64;
let _size_ratio = small_files_size as f64 / (small_files_size as f64 + 1.0);
if small_file_ratio > 0.8 {
1.0
} else if small_file_ratio > 0.6 {
0.8
} else if small_file_ratio > 0.4 {
0.6
} else if small_file_ratio > 0.2 {
0.4
} else {
0.2
}
}
fn calculate_recommended_target_size(&self, data_files: &[FileMetadata]) -> u64 {
if data_files.is_empty() {
return COMPACTION_TARGET_SMALL_AVG;
}
let total_size = data_files.iter().map(|f| f.size).sum::<u64>();
let avg_size = total_size as f64 / data_files.len() as f64;
if avg_size < SMALL_FILE_THRESHOLD_BYTES as f64 {
COMPACTION_TARGET_SMALL_AVG
} else if avg_size < AVG_FILE_SIZE_MEDIUM_THRESHOLD {
COMPACTION_TARGET_MEDIUM_AVG
} else {
COMPACTION_TARGET_LARGE_AVG
}
}
fn calculate_compaction_priority(&self, opportunity_score: f64, small_files: usize) -> String {
if opportunity_score > 0.8 || small_files > 100 {
"critical".to_string()
} else if opportunity_score > 0.6 || small_files > 50 {
"high".to_string()
} else if opportunity_score > 0.4 || small_files > 20 {
"medium".to_string()
} else {
"low".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::error::StorageResult;
use crate::storage::{FileMetadata, StorageProvider};
use async_trait::async_trait;
use chrono::Utc;
struct MockStorageProvider {
base_path: String,
options: HashMap<String, String>,
}
#[async_trait]
impl StorageProvider for MockStorageProvider {
fn base_path(&self) -> &str {
&self.base_path
}
fn uri_from_path(&self, path: &str) -> String {
format!("{}/{}", self.base_path, path)
}
async fn validate_connection(&self, _location: &str) -> StorageResult<()> {
Ok(())
}
async fn list_files(
&self,
_path: &str,
_recursive: bool,
) -> StorageResult<Vec<FileMetadata>> {
Ok(vec![])
}
async fn discover_partitions(
&self,
_path: &str,
_exclude_prefixes: Vec<&str>,
) -> StorageResult<Vec<String>> {
Ok(vec![])
}
async fn list_files_parallel(
&self,
_path: &str,
_partitions: Vec<String>,
_parallelism: usize,
) -> StorageResult<Vec<FileMetadata>> {
Ok(vec![])
}
async fn read_file(&self, _path: &str) -> StorageResult<Vec<u8>> {
Ok(vec![])
}
async fn exists(&self, _path: &str) -> StorageResult<bool> {
Ok(true)
}
async fn get_metadata(&self, _path: &str) -> StorageResult<FileMetadata> {
Ok(FileMetadata {
path: "test".to_string(),
size: 0,
last_modified: None,
})
}
fn options(&self) -> &HashMap<String, String> {
&self.options
}
fn clean_options(&self) -> HashMap<String, String> {
HashMap::new()
}
}
struct ConfigurableMockStorageProvider {
base_path: String,
options: HashMap<String, String>,
files: Vec<FileMetadata>,
file_contents: HashMap<String, Vec<u8>>,
}
#[async_trait]
impl StorageProvider for ConfigurableMockStorageProvider {
fn base_path(&self) -> &str {
&self.base_path
}
fn uri_from_path(&self, path: &str) -> String {
format!("{}/{}", self.base_path, path)
}
async fn validate_connection(&self, _location: &str) -> StorageResult<()> {
Ok(())
}
async fn list_files(
&self,
_path: &str,
_recursive: bool,
) -> StorageResult<Vec<FileMetadata>> {
Ok(self.files.clone())
}
async fn discover_partitions(
&self,
_path: &str,
_exclude_prefixes: Vec<&str>,
) -> StorageResult<Vec<String>> {
Ok(vec!["".to_string()])
}
async fn list_files_parallel(
&self,
_path: &str,
_partitions: Vec<String>,
_parallelism: usize,
) -> StorageResult<Vec<FileMetadata>> {
Ok(self.files.clone())
}
async fn read_file(&self, path: &str) -> StorageResult<Vec<u8>> {
Ok(self
.file_contents
.get(path)
.cloned()
.unwrap_or_else(Vec::new))
}
async fn exists(&self, _path: &str) -> StorageResult<bool> {
Ok(true)
}
async fn get_metadata(&self, path: &str) -> StorageResult<FileMetadata> {
Ok(self
.files
.iter()
.find(|f| f.path == path)
.cloned()
.unwrap_or(FileMetadata {
path: path.to_string(),
size: 0,
last_modified: None,
}))
}
fn options(&self) -> &HashMap<String, String> {
&self.options
}
fn clean_options(&self) -> HashMap<String, String> {
HashMap::new()
}
}
fn create_mock_analyzer() -> Analyzer {
Analyzer {
storage_provider: Arc::new(MockStorageProvider {
base_path: "/tmp/test".to_string(),
options: HashMap::new(),
}),
parallelism: 1,
}
}
fn create_file_metadata(path: &str, size: u64) -> FileMetadata {
FileMetadata {
path: path.to_string(),
size,
last_modified: Some(Utc::now()),
}
}
#[test]
fn test_analyzer_builder_new() {
let config = StorageConfig::local().with_option("path", "/data");
let builder = AnalyzerBuilder::new(config.clone());
assert_eq!(builder.config.storage_type, config.storage_type);
assert_eq!(builder.parallelism, None);
}
#[test]
fn test_analyzer_builder_with_parallelism() {
let config = StorageConfig::local().with_option("path", "/data");
let builder = AnalyzerBuilder::new(config).with_parallelism(10);
assert_eq!(builder.parallelism, Some(10));
}
#[test]
fn test_analyzer_builder_chaining() {
let config = StorageConfig::local().with_option("path", "/data");
let builder = AnalyzerBuilder::new(config)
.with_parallelism(5)
.with_parallelism(10);
assert_eq!(builder.parallelism, Some(10));
}
#[test]
fn test_calculate_file_size_distribution_empty() {
let analyzer = create_mock_analyzer();
let files = vec![];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 0);
assert_eq!(medium, 0);
assert_eq!(large, 0);
assert_eq!(very_large, 0);
}
#[test]
fn test_calculate_file_size_distribution_small_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 1024 * 1024), create_file_metadata("file2.parquet", 10 * 1024 * 1024), create_file_metadata("file3.parquet", 15 * 1024 * 1024), ];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 3); assert_eq!(medium, 0);
assert_eq!(large, 0);
assert_eq!(very_large, 0);
}
#[test]
fn test_calculate_file_size_distribution_medium_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 20 * 1024 * 1024), create_file_metadata("file2.parquet", 64 * 1024 * 1024), create_file_metadata("file3.parquet", 100 * 1024 * 1024), ];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 0);
assert_eq!(medium, 3); assert_eq!(large, 0);
assert_eq!(very_large, 0);
}
#[test]
fn test_calculate_file_size_distribution_large_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 200 * 1024 * 1024), create_file_metadata("file2.parquet", 512 * 1024 * 1024), create_file_metadata("file3.parquet", 900 * 1024 * 1024), ];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 0);
assert_eq!(medium, 0);
assert_eq!(large, 3); assert_eq!(very_large, 0);
}
#[test]
fn test_calculate_file_size_distribution_very_large_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 1100 * 1024 * 1024), create_file_metadata("file2.parquet", 2048 * 1024 * 1024), ];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 0);
assert_eq!(medium, 0);
assert_eq!(large, 0);
assert_eq!(very_large, 2); }
#[test]
fn test_calculate_file_size_distribution_mixed() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 5 * 1024 * 1024), create_file_metadata("file2.parquet", 50 * 1024 * 1024), create_file_metadata("file3.parquet", 500 * 1024 * 1024), create_file_metadata("file4.parquet", 1500 * 1024 * 1024), ];
let (small, medium, large, very_large) = analyzer.calculate_file_size_distribution(&files);
assert_eq!(small, 1);
assert_eq!(medium, 1);
assert_eq!(large, 1);
assert_eq!(very_large, 1);
}
#[test]
fn test_calculate_metadata_health_empty() {
let analyzer = create_mock_analyzer();
let files = vec![];
let (count, total_size, avg_size, growth_rate) = analyzer.calculate_metadata_health(&files);
assert_eq!(count, 0);
assert_eq!(total_size, 0);
assert_eq!(avg_size, 0.0);
assert_eq!(growth_rate, 0.0);
}
#[test]
fn test_calculate_metadata_health_single_file() {
let analyzer = create_mock_analyzer();
let files = vec![create_file_metadata("_delta_log/00000.json", 1024)];
let (count, total_size, avg_size, growth_rate) = analyzer.calculate_metadata_health(&files);
assert_eq!(count, 1);
assert_eq!(total_size, 1024);
assert_eq!(avg_size, 1024.0);
assert_eq!(growth_rate, 0.0); }
#[test]
fn test_calculate_metadata_health_multiple_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("_delta_log/00000.json", 1024),
create_file_metadata("_delta_log/00001.json", 2048),
create_file_metadata("_delta_log/00002.json", 3072),
];
let (count, total_size, avg_size, growth_rate) = analyzer.calculate_metadata_health(&files);
assert_eq!(count, 3);
assert_eq!(total_size, 6144);
assert_eq!(avg_size, 2048.0); assert_eq!(growth_rate, 0.0); }
#[test]
fn test_calculate_compaction_opportunity_no_files() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(0, 0, 0);
assert_eq!(score, 0.0);
}
#[test]
fn test_calculate_compaction_opportunity_no_small_files() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(0, 0, 100);
assert_eq!(score, 0.2); }
#[test]
fn test_calculate_compaction_opportunity_low() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(25, 1024, 100);
assert_eq!(score, 0.4); }
#[test]
fn test_calculate_compaction_opportunity_medium() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(50, 1024, 100);
assert_eq!(score, 0.6); }
#[test]
fn test_calculate_compaction_opportunity_high() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(70, 1024, 100);
assert_eq!(score, 0.8); }
#[test]
fn test_calculate_compaction_opportunity_critical() {
let analyzer = create_mock_analyzer();
let score = analyzer.calculate_compaction_opportunity(90, 1024, 100);
assert_eq!(score, 1.0); }
#[test]
fn test_calculate_recommended_target_size_empty() {
let analyzer = create_mock_analyzer();
let files = vec![];
let target_size = analyzer.calculate_recommended_target_size(&files);
assert_eq!(target_size, 128 * 1024 * 1024); }
#[test]
fn test_calculate_recommended_target_size_small_avg() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 5 * 1024 * 1024),
create_file_metadata("file2.parquet", 10 * 1024 * 1024),
];
let target_size = analyzer.calculate_recommended_target_size(&files);
assert_eq!(target_size, 128 * 1024 * 1024); }
#[test]
fn test_calculate_recommended_target_size_medium_avg() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 30 * 1024 * 1024),
create_file_metadata("file2.parquet", 50 * 1024 * 1024),
];
let target_size = analyzer.calculate_recommended_target_size(&files);
assert_eq!(target_size, 256 * 1024 * 1024); }
#[test]
fn test_calculate_recommended_target_size_large_avg() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 100 * 1024 * 1024),
create_file_metadata("file2.parquet", 200 * 1024 * 1024),
];
let target_size = analyzer.calculate_recommended_target_size(&files);
assert_eq!(target_size, 512 * 1024 * 1024); }
#[test]
fn test_calculate_compaction_priority_low() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.3, 10);
assert_eq!(priority, "low");
}
#[test]
fn test_calculate_compaction_priority_medium() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.5, 25);
assert_eq!(priority, "medium");
}
#[test]
fn test_calculate_compaction_priority_high_by_score() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.7, 30);
assert_eq!(priority, "high");
}
#[test]
fn test_calculate_compaction_priority_high_by_count() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.3, 60);
assert_eq!(priority, "high");
}
#[test]
fn test_calculate_compaction_priority_critical_by_score() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.9, 50);
assert_eq!(priority, "critical");
}
#[test]
fn test_calculate_compaction_priority_critical_by_count() {
let analyzer = create_mock_analyzer();
let priority = analyzer.calculate_compaction_priority(0.5, 150);
assert_eq!(priority, "critical");
}
#[test]
fn test_analyze_partitioning_empty() {
let analyzer = create_mock_analyzer();
let files = vec![];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 0);
}
#[test]
fn test_analyze_partitioning_no_partitions() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 1024),
create_file_metadata("file2.parquet", 2048),
];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 1);
assert_eq!(partitions[0].file_count, 2);
assert_eq!(partitions[0].total_size_bytes, 3072);
assert_eq!(partitions[0].avg_file_size_bytes, 1536.0);
}
#[test]
fn test_analyze_partitioning_single_partition_column() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("year=2024/file1.parquet", 1024),
create_file_metadata("year=2024/file2.parquet", 2048),
create_file_metadata("year=2023/file3.parquet", 3072),
];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 2);
let partition_2024 = partitions
.iter()
.find(|p| p.partition_values.get("year") == Some(&"2024".to_string()))
.expect("Should have year=2024 partition");
assert_eq!(partition_2024.file_count, 2);
assert_eq!(partition_2024.total_size_bytes, 3072);
assert_eq!(partition_2024.avg_file_size_bytes, 1536.0);
let partition_2023 = partitions
.iter()
.find(|p| p.partition_values.get("year") == Some(&"2023".to_string()))
.expect("Should have year=2023 partition");
assert_eq!(partition_2023.file_count, 1);
assert_eq!(partition_2023.total_size_bytes, 3072);
assert_eq!(partition_2023.avg_file_size_bytes, 3072.0);
}
#[test]
fn test_analyze_partitioning_multiple_partition_columns() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("year=2024/month=01/file1.parquet", 1024),
create_file_metadata("year=2024/month=01/file2.parquet", 2048),
create_file_metadata("year=2024/month=02/file3.parquet", 3072),
create_file_metadata("year=2023/month=12/file4.parquet", 4096),
];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert!(partitions.len() >= 3 && partitions.len() <= 4);
for partition in &partitions {
assert!(partition.partition_values.contains_key("year"));
assert!(partition.partition_values.contains_key("month"));
assert!(partition.file_count > 0);
assert!(partition.total_size_bytes > 0);
assert!(partition.avg_file_size_bytes > 0.0);
}
let total_files: usize = partitions.iter().map(|p| p.file_count).sum();
assert_eq!(total_files, 4);
let total_size: u64 = partitions.iter().map(|p| p.total_size_bytes).sum();
assert_eq!(total_size, 10240); }
#[test]
fn test_analyze_partitioning_mixed_partitioned_and_unpartitioned() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("year=2024/file1.parquet", 1024),
create_file_metadata("file2.parquet", 2048), ];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 2); }
#[test]
fn test_analyze_partitioning_file_info_populated() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("year=2024/file1.parquet", 1024),
create_file_metadata("year=2024/file2.parquet", 2048),
];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 1);
let partition = &partitions[0];
assert_eq!(partition.files.len(), 2);
for file_info in &partition.files {
assert!(file_info.path.contains("file"));
assert!(file_info.size_bytes > 0);
assert!(file_info.is_referenced); }
}
#[test]
fn test_analyze_partitioning_avg_calculation() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("year=2024/file1.parquet", 1000),
create_file_metadata("year=2024/file2.parquet", 2000),
create_file_metadata("year=2024/file3.parquet", 3000),
];
let result = analyzer.analyze_partitioning(&files);
assert!(result.is_ok());
let partitions = result.unwrap();
assert_eq!(partitions.len(), 1);
let partition = &partitions[0];
assert_eq!(partition.file_count, 3);
assert_eq!(partition.total_size_bytes, 6000);
assert_eq!(partition.avg_file_size_bytes, 2000.0); }
#[test]
fn test_analyzer_builder_static_method() {
let config = StorageConfig::local().with_option("path", "/data");
let builder = Analyzer::builder(config.clone());
assert_eq!(builder.config.storage_type, config.storage_type);
assert_eq!(builder.parallelism, None);
}
#[test]
fn test_analyzer_builder_static_method_with_parallelism() {
let config = StorageConfig::local().with_option("path", "/data");
let builder = Analyzer::builder(config).with_parallelism(8);
assert_eq!(builder.parallelism, Some(8));
}
#[tokio::test]
async fn test_analyzer_builder_build_local_storage() {
let temp_dir = std::env::temp_dir();
let config =
StorageConfig::local().with_option("path", temp_dir.to_str().unwrap_or("/tmp"));
let result = Analyzer::builder(config).build().await;
assert!(result.is_ok());
let analyzer = result.unwrap();
assert_eq!(analyzer.parallelism, 1); }
#[tokio::test]
async fn test_analyzer_builder_build_with_parallelism() {
let temp_dir = std::env::temp_dir();
let config =
StorageConfig::local().with_option("path", temp_dir.to_str().unwrap_or("/tmp"));
let result = Analyzer::builder(config).with_parallelism(4).build().await;
assert!(result.is_ok());
let analyzer = result.unwrap();
assert_eq!(analyzer.parallelism, 4);
}
#[tokio::test]
async fn test_analyze_file_compaction_empty_files() {
let analyzer = create_mock_analyzer();
let files: Vec<FileMetadata> = vec![];
let result = analyzer
.analyze_file_compaction(&files, false, vec![])
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert_eq!(metrics.small_files_count, 0);
assert_eq!(metrics.small_files_size_bytes, 0);
assert_eq!(metrics.potential_compaction_files, 0);
assert_eq!(metrics.estimated_compaction_savings_bytes, 0);
assert!(!metrics.z_order_opportunity);
assert!(metrics.z_order_columns.is_empty());
}
#[tokio::test]
async fn test_analyze_file_compaction_small_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 1024 * 1024), create_file_metadata("file2.parquet", 2 * 1024 * 1024), create_file_metadata("file3.parquet", 5 * 1024 * 1024), create_file_metadata("file4.parquet", 10 * 1024 * 1024), ];
let result = analyzer
.analyze_file_compaction(&files, false, vec![])
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert_eq!(metrics.small_files_count, 4); assert_eq!(metrics.small_files_size_bytes, 18 * 1024 * 1024);
assert_eq!(metrics.potential_compaction_files, 4);
assert_eq!(metrics.compaction_priority, "critical"); }
#[tokio::test]
async fn test_analyze_file_compaction_mixed_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("small1.parquet", 5 * 1024 * 1024), create_file_metadata("small2.parquet", 10 * 1024 * 1024), create_file_metadata("medium.parquet", 50 * 1024 * 1024), create_file_metadata("large.parquet", 200 * 1024 * 1024), ];
let result = analyzer
.analyze_file_compaction(&files, false, vec![])
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert_eq!(metrics.small_files_count, 2); assert_eq!(metrics.small_files_size_bytes, 15 * 1024 * 1024);
assert_eq!(metrics.potential_compaction_files, 2);
}
#[tokio::test]
async fn test_analyze_file_compaction_with_z_order() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("file1.parquet", 5 * 1024 * 1024),
create_file_metadata("file2.parquet", 8 * 1024 * 1024),
];
let z_order_columns = vec!["col1".to_string(), "col2".to_string()];
let result = analyzer
.analyze_file_compaction(&files, true, z_order_columns.clone())
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert!(metrics.z_order_opportunity);
assert_eq!(metrics.z_order_columns, z_order_columns);
}
#[tokio::test]
async fn test_analyze_file_compaction_no_small_files() {
let analyzer = create_mock_analyzer();
let files = vec![
create_file_metadata("large1.parquet", 100 * 1024 * 1024), create_file_metadata("large2.parquet", 200 * 1024 * 1024), ];
let result = analyzer
.analyze_file_compaction(&files, false, vec![])
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert_eq!(metrics.small_files_count, 0);
assert_eq!(metrics.small_files_size_bytes, 0);
assert_eq!(metrics.estimated_compaction_savings_bytes, 0);
assert_eq!(metrics.compaction_priority, "low");
}
#[tokio::test]
async fn test_analyze_file_compaction_single_small_file() {
let analyzer = create_mock_analyzer();
let files = vec![create_file_metadata("small.parquet", 5 * 1024 * 1024)];
let result = analyzer
.analyze_file_compaction(&files, false, vec![])
.await;
assert!(result.is_ok());
let metrics = result.unwrap().unwrap();
assert_eq!(metrics.small_files_count, 1);
assert_eq!(metrics.estimated_compaction_savings_bytes, 0);
}
#[tokio::test]
async fn test_analyze_unknown_table_type() {
let files = vec![
create_file_metadata("data/file1.parquet", 50 * 1024 * 1024),
create_file_metadata("data/file2.parquet", 60 * 1024 * 1024),
];
let analyzer = Analyzer {
storage_provider: Arc::new(ConfigurableMockStorageProvider {
base_path: "/tmp/test".to_string(),
options: HashMap::new(),
files,
file_contents: HashMap::new(),
}),
parallelism: 1,
};
let result = analyzer.analyze("test_table").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.to_string()
.contains("Unknown, unsupported or not enabled table type"));
}
#[tokio::test]
async fn test_analyze_delta_table() {
let files = vec![
create_file_metadata("_delta_log/00000000000000000000.json", 1024),
create_file_metadata("_delta_log/00000000000000000001.json", 2048),
create_file_metadata("part-00000.parquet", 50 * 1024 * 1024),
create_file_metadata("part-00001.parquet", 60 * 1024 * 1024),
];
let delta_log_content = r#"{"add":{"path":"part-00000.parquet","size":52428800}}"#;
let mut file_contents = HashMap::new();
file_contents.insert(
"_delta_log/00000000000000000000.json".to_string(),
delta_log_content.as_bytes().to_vec(),
);
file_contents.insert(
"_delta_log/00000000000000000001.json".to_string(),
delta_log_content.as_bytes().to_vec(),
);
let analyzer = Analyzer {
storage_provider: Arc::new(ConfigurableMockStorageProvider {
base_path: "/tmp/delta_test".to_string(),
options: HashMap::new(),
files,
file_contents,
}),
parallelism: 1,
};
let result = analyzer.analyze("test_delta_table").await;
if let Ok(report) = result {
assert_eq!(report.table_type, "delta");
assert!(report.metrics.total_files > 0);
}
}
#[tokio::test]
async fn test_analyze_iceberg_table() {
let files = vec![
create_file_metadata("metadata/v1.metadata.json", 4096),
create_file_metadata("data/file1.parquet", 50 * 1024 * 1024),
create_file_metadata("data/file2.parquet", 60 * 1024 * 1024),
];
let iceberg_metadata = r#"{"format-version":2,"table-uuid":"test"}"#;
let mut file_contents = HashMap::new();
file_contents.insert(
"metadata/v1.metadata.json".to_string(),
iceberg_metadata.as_bytes().to_vec(),
);
let analyzer = Analyzer {
storage_provider: Arc::new(ConfigurableMockStorageProvider {
base_path: "/tmp/iceberg_test".to_string(),
options: HashMap::new(),
files,
file_contents,
}),
parallelism: 1,
};
let result = analyzer.analyze("test_iceberg_table").await;
if let Ok(report) = result {
assert_eq!(report.table_type, "iceberg");
}
}
#[cfg(feature = "hudi")]
#[tokio::test]
async fn test_analyze_hudi_table() {
let files = vec![
create_file_metadata(".hoodie/hoodie.properties", 512),
create_file_metadata(".hoodie/metadata/files/00000000000000000.log.1", 1024),
create_file_metadata("2024/01/01/file1.parquet", 50 * 1024 * 1024),
create_file_metadata("2024/01/02/file2.parquet", 60 * 1024 * 1024),
];
let hoodie_properties = "hoodie.table.name=test_table\nhoodie.table.type=COPY_ON_WRITE";
let mut file_contents = HashMap::new();
file_contents.insert(
".hoodie/hoodie.properties".to_string(),
hoodie_properties.as_bytes().to_vec(),
);
let analyzer = Analyzer {
storage_provider: Arc::new(ConfigurableMockStorageProvider {
base_path: "/tmp/hudi_test".to_string(),
options: HashMap::new(),
files,
file_contents,
}),
parallelism: 1,
};
let result = analyzer.analyze("test_hudi_table").await;
if let Ok(report) = result {
assert_eq!(report.table_type, "hudi");
assert!(report.metrics.total_files > 0);
}
}
#[cfg(feature = "lance")]
#[tokio::test]
async fn test_analyze_lance_table() {
let files = vec![
create_file_metadata("_versions/1.manifest", 1024),
create_file_metadata("_versions/2.manifest", 2048),
create_file_metadata("data/fragment-0.lance", 50 * 1024 * 1024),
create_file_metadata("data/fragment-1.lance", 60 * 1024 * 1024),
];
let analyzer = Analyzer {
storage_provider: Arc::new(ConfigurableMockStorageProvider {
base_path: "/tmp/lance_test".to_string(),
options: HashMap::new(),
files,
file_contents: HashMap::new(),
}),
parallelism: 1,
};
let result = analyzer.analyze("test_lance_table").await;
if let Ok(report) = result {
assert_eq!(report.table_type, "lance");
assert!(report.metrics.total_files > 0);
}
}
}