use super::metrics::{DeltaMetrics, FileStatistics, PartitionMetrics, ProtocolInfo, TableMetadata};
use crate::util::retry::retry_with_max_retries;
use deltalake::kernel::{Metadata, StructType};
use deltalake::{open_table_with_storage_options, DeltaTable};
use std::collections::HashMap;
use std::error::Error;
use tracing::{info, warn};
use url::Url;
pub struct DeltaReader {
table: DeltaTable,
}
impl DeltaReader {
pub async fn open(
location: &str,
cleaned_storage_options: &HashMap<String, String>,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
info!("Opening Delta table at location={}", location);
deltalake_azure::register_handlers(None);
deltalake_aws::register_handlers(None);
let url = Url::parse(location)?;
let table = retry_with_max_retries(10, "open_table_with_storage_options", || async {
open_table_with_storage_options(url.clone(), cleaned_storage_options.clone()).await
})
.await?;
info!(
"Successfully opened Delta table, version={:?}",
table.version()
);
Ok(Self { table })
}
pub async fn extract_metrics(&self) -> Result<DeltaMetrics, Box<dyn Error + Send + Sync>> {
info!("Extracting metrics from Delta table");
let snapshot = self.table.snapshot()?;
let version = self.table.version().unwrap_or(0);
let protocol = snapshot.protocol();
let protocol_info = ProtocolInfo {
min_reader_version: protocol.min_reader_version(),
min_writer_version: protocol.min_writer_version(),
reader_features: protocol
.reader_features()
.map(|features| features.iter().map(|f| format!("{:?}", f)).collect()),
writer_features: protocol
.writer_features()
.map(|features| features.iter().map(|f| format!("{:?}", f)).collect()),
};
let metadata = snapshot.metadata();
let schema = snapshot.schema();
let table_metadata = self.extract_table_metadata(metadata, schema.as_ref())?;
let table_properties = metadata.configuration().clone();
let file_stats = self.extract_file_statistics().await?;
let partition_info = PartitionMetrics {
num_partition_columns: metadata.partition_columns().len(),
partition_columns: metadata.partition_columns().to_vec(),
estimated_partition_count: None, };
let metrics = DeltaMetrics {
version,
protocol: protocol_info,
metadata: table_metadata,
table_properties,
file_stats,
partition_info,
};
info!("Successfully extracted Delta table metrics");
Ok(metrics)
}
fn extract_table_metadata(
&self,
metadata: &Metadata,
schema: &StructType,
) -> Result<TableMetadata, Box<dyn Error + Send + Sync>> {
let field_count = schema.fields().len();
let schema_string = serde_json::to_string_pretty(schema)?;
let table_metadata = TableMetadata {
id: metadata.id().to_string(),
name: metadata.name().map(|s| s.to_string()),
description: metadata.description().map(|s| s.to_string()),
schema_string,
field_count,
partition_columns: metadata.partition_columns().to_vec(),
created_time: metadata.created_time(),
format_provider: "parquet".to_string(), format_options: HashMap::new(), };
Ok(table_metadata)
}
async fn extract_file_statistics(
&self,
) -> Result<FileStatistics, Box<dyn Error + Send + Sync>> {
info!("Extracting file statistics");
let file_uris: Vec<String> = self.table.get_file_uris()?.collect();
let num_files = file_uris.len();
if num_files == 0 {
warn!("No files found in Delta table");
return Ok(FileStatistics {
num_files: 0,
total_size_bytes: 0,
avg_file_size_bytes: 0.0,
min_file_size_bytes: 0,
max_file_size_bytes: 0,
files_with_deletion_vectors: 0,
});
}
use futures::StreamExt;
let mut total_size: u64 = 0;
let mut min_size: u64 = u64::MAX;
let mut max_size: u64 = 0;
let files_with_dv = 0;
let mut file_stream = self.table.get_active_add_actions_by_partitions(&[]);
while let Some(file_result) = file_stream.next().await {
match file_result {
Ok(file_view) => {
let size = file_view.size() as u64;
total_size += size;
min_size = min_size.min(size);
max_size = max_size.max(size);
}
Err(e) => {
warn!("Error reading file metadata, error={}", e);
}
}
}
let avg_size = if num_files > 0 {
total_size as f64 / num_files as f64
} else {
0.0
};
if min_size == u64::MAX {
min_size = 0;
}
let stats = FileStatistics {
num_files,
total_size_bytes: total_size,
avg_file_size_bytes: avg_size,
min_file_size_bytes: min_size,
max_file_size_bytes: max_size,
files_with_deletion_vectors: files_with_dv,
};
info!(
"File statistics: file_count={}, total_bytesize={}, avg_bytesize={}",
stats.num_files, stats.total_size_bytes, stats.avg_file_size_bytes
);
Ok(stats)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_delta_table_path() -> String {
let current_dir = std::env::current_dir().unwrap();
let test_path = current_dir.join("examples/data/delta_dataset");
format!("file://{}", test_path.to_str().unwrap())
}
#[tokio::test]
async fn test_delta_reader_open_success() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let result = DeltaReader::open(&location, &storage_options).await;
assert!(
result.is_ok(),
"Failed to open Delta table: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_delta_reader_open_invalid_location() {
let location = "file:///nonexistent/path/to/delta/table";
let storage_options = HashMap::new();
let result = DeltaReader::open(location, &storage_options).await;
assert!(result.is_err(), "Expected error for nonexistent table");
}
#[tokio::test]
async fn test_delta_reader_open_invalid_url() {
let location = "not-a-valid-url";
let storage_options = HashMap::new();
let result = DeltaReader::open(location, &storage_options).await;
assert!(result.is_err(), "Expected error for invalid URL");
}
#[tokio::test]
async fn test_delta_reader_extract_metrics_success() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let result = reader.extract_metrics().await;
assert!(
result.is_ok(),
"Failed to extract metrics: {:?}",
result.err()
);
let metrics = result.unwrap();
assert!(metrics.version >= 0, "Version should be non-negative");
assert!(metrics.protocol.min_reader_version >= 0);
assert!(metrics.protocol.min_writer_version >= 0);
assert!(
!metrics.metadata.id.is_empty(),
"Table ID should not be empty"
);
assert!(
!metrics.metadata.schema_string.is_empty(),
"Schema should not be empty"
);
assert!(
metrics.metadata.field_count > 0,
"Should have at least one field"
);
}
#[tokio::test]
async fn test_delta_reader_extract_protocol_info() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert_eq!(metrics.protocol.min_reader_version, 1);
assert_eq!(metrics.protocol.min_writer_version, 2);
assert!(
metrics.protocol.reader_features.is_none()
|| metrics
.protocol
.reader_features
.as_ref()
.unwrap()
.is_empty()
);
assert!(
metrics.protocol.writer_features.is_none()
|| metrics
.protocol
.writer_features
.as_ref()
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn test_delta_reader_extract_table_metadata() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(!metrics.metadata.id.is_empty());
assert_eq!(metrics.metadata.format_provider, "parquet");
assert_eq!(metrics.metadata.field_count, 3);
assert!(metrics.metadata.schema_string.contains("id"));
assert!(metrics.metadata.schema_string.contains("f1"));
assert!(metrics.metadata.schema_string.contains("f2"));
assert_eq!(metrics.metadata.partition_columns.len(), 0);
}
#[tokio::test]
async fn test_delta_reader_extract_file_statistics() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(
metrics.file_stats.num_files > 0,
"Should have at least one file"
);
assert!(
metrics.file_stats.total_size_bytes > 0,
"Total size should be greater than 0"
);
assert!(
metrics.file_stats.avg_file_size_bytes > 0.0,
"Average file size should be greater than 0"
);
assert!(
metrics.file_stats.min_file_size_bytes > 0,
"Min file size should be greater than 0"
);
assert!(
metrics.file_stats.max_file_size_bytes > 0,
"Max file size should be greater than 0"
);
assert!(
metrics.file_stats.min_file_size_bytes <= metrics.file_stats.max_file_size_bytes,
"Min size should be <= max size"
);
let expected_avg =
metrics.file_stats.total_size_bytes as f64 / metrics.file_stats.num_files as f64;
assert!(
(metrics.file_stats.avg_file_size_bytes - expected_avg).abs() < 0.01,
"Average file size calculation should be correct"
);
}
#[tokio::test]
async fn test_delta_reader_extract_partition_info() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert_eq!(metrics.partition_info.num_partition_columns, 0);
assert_eq!(metrics.partition_info.partition_columns.len(), 0);
}
#[tokio::test]
async fn test_delta_reader_table_version() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(metrics.version >= 0);
}
#[tokio::test]
async fn test_delta_reader_with_storage_options() {
let location = get_test_delta_table_path();
let mut storage_options = HashMap::new();
storage_options.insert("timeout".to_string(), "30s".to_string());
let result = DeltaReader::open(&location, &storage_options).await;
assert!(
result.is_ok(),
"Should open successfully with storage options"
);
}
#[tokio::test]
async fn test_delta_reader_schema_parsing() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
let schema_json: serde_json::Value = serde_json::from_str(&metrics.metadata.schema_string)
.expect("Schema should be valid JSON");
assert_eq!(schema_json["type"], "struct");
assert!(schema_json["fields"].is_array());
let fields = schema_json["fields"].as_array().unwrap();
assert_eq!(fields.len(), metrics.metadata.field_count);
}
#[tokio::test]
async fn test_delta_reader_table_properties() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
let _props = &metrics.table_properties;
}
#[tokio::test]
async fn test_delta_reader_multiple_extractions() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics1 = reader
.extract_metrics()
.await
.expect("First extraction failed");
let metrics2 = reader
.extract_metrics()
.await
.expect("Second extraction failed");
assert_eq!(metrics1.version, metrics2.version);
assert_eq!(metrics1.metadata.id, metrics2.metadata.id);
assert_eq!(metrics1.file_stats.num_files, metrics2.file_stats.num_files);
}
#[tokio::test]
async fn test_delta_reader_file_statistics_consistency() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
if metrics.file_stats.num_files > 0 {
assert!(metrics.file_stats.total_size_bytes > 0);
assert!(metrics.file_stats.avg_file_size_bytes > 0.0);
assert!(metrics.file_stats.min_file_size_bytes > 0);
assert!(metrics.file_stats.max_file_size_bytes > 0);
assert!(
metrics.file_stats.min_file_size_bytes
<= metrics.file_stats.avg_file_size_bytes as u64
);
assert!(
metrics.file_stats.avg_file_size_bytes
<= metrics.file_stats.max_file_size_bytes as f64
);
}
}
#[tokio::test]
async fn test_delta_reader_metadata_id_format() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert!(!metrics.metadata.id.is_empty());
assert!(metrics.metadata.id.contains('-'));
}
#[tokio::test]
async fn test_delta_reader_format_provider() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert_eq!(metrics.metadata.format_provider, "parquet");
}
#[tokio::test]
async fn test_delta_reader_deletion_vectors() {
let location = get_test_delta_table_path();
let storage_options = HashMap::new();
let reader = DeltaReader::open(&location, &storage_options)
.await
.expect("Failed to open Delta table");
let metrics = reader
.extract_metrics()
.await
.expect("Failed to extract metrics");
assert_eq!(metrics.file_stats.files_with_deletion_vectors, 0);
}
}