blob: 8d1f1cac343a3454fcee021fa179ecf02c30699d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Collect deduplicated referenced file size summaries for all snapshots of a table.
//!
//! Reference: [LocalOrphanFilesClean](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/operation/LocalOrphanFilesClean.java)
use std::collections::HashMap;
use std::sync::Mutex;
use crate::io::FileIO;
use crate::spec::{
bucket_dir_name, BinaryRow, DataField, DataFileMeta, IndexManifest, Manifest, ManifestEntry,
ManifestFileMeta, PartitionComputer,
};
use crate::table::{BranchManager, SnapshotManager, TagManager};
use futures::future::try_join_all;
use futures::stream::{self, StreamExt, TryStreamExt};
/// Per-scope aggregated summary of referenced files (deduplicated).
///
/// Each row represents the unique referenced files for a scope:
/// - `"total"`: all snapshots across all branches and tags
/// - `"branch:main"`: main branch snapshots + tags
/// - `"branch:<name>"`: a specific branch
///
/// Files are deduplicated by file name within each scope, so the sum
/// represents actual disk usage that is still referenced (protected from cleanup).
/// Both ADD and DELETE manifest entries are included since both reference
/// physical files that cannot be removed until the snapshot expires.
#[derive(Debug, Clone, Default)]
pub struct ReferencedFilesSummary {
pub source: String,
pub manifest_file_count: i64,
pub manifest_file_size: i64,
pub data_file_count: i64,
pub data_file_size: i64,
pub index_file_count: i64,
pub index_file_size: i64,
}
/// Deduplicated file set for a scope, keyed by file name.
#[derive(Default)]
struct ScopeFileSet {
manifest_files: HashMap<String, i64>,
data_files: HashMap<String, i64>,
index_files: HashMap<String, i64>,
}
impl ScopeFileSet {
fn to_summary(&self, source: &str) -> ReferencedFilesSummary {
ReferencedFilesSummary {
source: source.to_string(),
manifest_file_count: self.manifest_files.len() as i64,
manifest_file_size: self.manifest_files.values().sum(),
data_file_count: self.data_files.len() as i64,
data_file_size: self.data_files.values().sum(),
index_file_count: self.index_files.len() as i64,
index_file_size: self.index_files.values().sum(),
}
}
fn merge(&mut self, other: &ScopeFileSet) {
for (k, v) in &other.manifest_files {
self.manifest_files.entry(k.clone()).or_insert(*v);
}
for (k, v) in &other.data_files {
self.data_files.entry(k.clone()).or_insert(*v);
}
for (k, v) in &other.index_files {
self.index_files.entry(k.clone()).or_insert(*v);
}
}
}
const SNAPSHOT_CONCURRENCY: usize = 32;
/// Cached data file entries (file_name, file_size) per manifest file full path.
type ManifestCache = Mutex<HashMap<String, Vec<(String, i64)>>>;
/// Resolves extra file paths for stat-ing their real sizes.
struct ExtraFileResolver {
table_location: String,
partition_computer: Option<PartitionComputer>,
}
impl ExtraFileResolver {
fn new(table_location: &str, partition_keys: &[String], schema_fields: &[DataField]) -> Self {
let partition_computer = if partition_keys.is_empty() {
None
} else {
PartitionComputer::new(
partition_keys,
schema_fields,
"__DEFAULT_PARTITION__",
false,
)
.ok()
};
Self {
table_location: table_location.to_string(),
partition_computer,
}
}
fn resolve_bucket_path(&self, partition_bytes: &[u8], bucket: i32) -> Option<String> {
let partition_path = if let Some(ref computer) = self.partition_computer {
let row = BinaryRow::from_serialized_bytes(partition_bytes).ok()?;
computer.generate_partition_path(&row).ok()?
} else {
String::new()
};
let bucket_dir = bucket_dir_name(bucket);
Some(format!(
"{}/{}{}",
self.table_location, partition_path, bucket_dir
))
}
fn resolve_extra_file_path(
&self,
partition_bytes: &[u8],
bucket: i32,
data_file: &DataFileMeta,
extra_file_name: &str,
) -> Option<String> {
let bucket_path = self.resolve_bucket_path(partition_bytes, bucket)?;
Some(data_file.aligned_file_path(&bucket_path, extra_file_name))
}
}
/// Collect per-scope deduplicated referenced file size summaries for a table.
///
/// Returns rows:
/// 1. `"total"` — union of all referenced files from main branch, tags, and branches
/// 2. `"branch:main"` — main branch snapshots + tag snapshots
/// 3. `"branch:<name>"` — one row per branch
///
/// Snapshots are processed concurrently (up to 32 at a time). Within each
/// snapshot, manifest list and manifest file reads are also concurrent.
/// A shared cache avoids re-reading the same manifest file across snapshots.
///
/// Files are deduplicated by name within each scope to produce an accurate
/// count of unique referenced files. Both ADD and DELETE entries are included
/// since both reference physical files protected from cleanup.
///
/// Manifest list files and index manifest files are counted as manifest files,
/// consistent with `physical_files_size` classification.
///
/// Extra files referenced by data file entries are stat-ed to obtain their
/// real sizes, using partition/bucket info to construct full paths.
pub async fn collect_referenced_files_summary(
file_io: &FileIO,
table_location: &str,
partition_keys: &[String],
schema_fields: &[DataField],
) -> crate::Result<Vec<ReferencedFilesSummary>> {
let manifest_cache: ManifestCache = Mutex::new(HashMap::new());
let manifest_cache_ref = &manifest_cache;
let extra_resolver = ExtraFileResolver::new(table_location, partition_keys, schema_fields);
let extra_resolver_ref = &extra_resolver;
let sm = SnapshotManager::new(file_io.clone(), table_location.to_string());
let tm = TagManager::new(file_io.clone(), table_location.to_string());
// 1. Main branch snapshots + tags (concurrently)
// For main branch, snapshot reading and manifest resolution both use root SM.
let (main_files, tag_files) = tokio::try_join!(
collect_scope_files(file_io, &sm, &sm, manifest_cache_ref, extra_resolver_ref),
collect_tag_files(
file_io,
&sm,
&sm,
&tm,
manifest_cache_ref,
extra_resolver_ref
),
)?;
let mut main_files = main_files;
main_files.merge(&tag_files);
// 2. Branch file sets (all branches concurrently)
let bm = BranchManager::new(file_io.clone(), table_location.to_string());
let branch_names = bm.list_all().await?;
let sm_ref = &sm;
let branch_futures: Vec<_> = branch_names
.iter()
.map(|branch_name| {
let branch_sm = sm.with_branch(branch_name);
let branch_tm = tm.with_branch(branch_name);
async move {
// Branch SM reads snapshot/tag files from branch path,
// but manifest paths are always resolved from the table root.
let (mut branch_files, branch_tag_files) = tokio::try_join!(
collect_scope_files(
file_io,
&branch_sm,
sm_ref,
manifest_cache_ref,
extra_resolver_ref
),
collect_tag_files(
file_io,
&branch_sm,
sm_ref,
&branch_tm,
manifest_cache_ref,
extra_resolver_ref
),
)?;
branch_files.merge(&branch_tag_files);
Ok::<_, crate::Error>(branch_files)
}
})
.collect();
let branch_results = try_join_all(branch_futures).await?;
// 3. Assemble output: total, main, branches
let mut total_files = ScopeFileSet::default();
total_files.merge(&main_files);
for bs in &branch_results {
total_files.merge(bs);
}
let mut result = vec![
total_files.to_summary("total"),
main_files.to_summary("branch:main"),
];
for (name, files) in branch_names.iter().zip(&branch_results) {
result.push(files.to_summary(&format!("branch:{name}")));
}
Ok(result)
}
async fn collect_scope_files(
file_io: &FileIO,
sm: &SnapshotManager,
manifest_sm: &SnapshotManager,
manifest_cache: &ManifestCache,
extra_resolver: &ExtraFileResolver,
) -> crate::Result<ScopeFileSet> {
let snapshot_ids = sm.list_all_ids().await?;
let per_snapshot: Vec<Option<ScopeFileSet>> = stream::iter(snapshot_ids)
.map(|snapshot_id| {
let sm = sm.clone();
async move {
collect_single_snapshot_files(
file_io,
&sm,
manifest_sm,
snapshot_id,
manifest_cache,
extra_resolver,
)
.await
}
})
.buffer_unordered(SNAPSHOT_CONCURRENCY)
.try_collect()
.await?;
let mut merged = ScopeFileSet::default();
for fs in per_snapshot.into_iter().flatten() {
merged.merge(&fs);
}
Ok(merged)
}
async fn collect_tag_files(
file_io: &FileIO,
_sm: &SnapshotManager,
manifest_sm: &SnapshotManager,
tm: &TagManager,
manifest_cache: &ManifestCache,
extra_resolver: &ExtraFileResolver,
) -> crate::Result<ScopeFileSet> {
let tag_names = tm.list_all_names().await?;
let tag_futures: Vec<_> = tag_names
.iter()
.map(|tag_name| async move {
let snapshot = match tm.get(tag_name).await? {
Some(s) => s,
None => return Ok(None),
};
collect_snapshot_files(
file_io,
manifest_sm,
&snapshot,
manifest_cache,
extra_resolver,
)
.await
})
.collect();
let tag_results = try_join_all(tag_futures).await?;
let mut merged = ScopeFileSet::default();
for fs in tag_results.into_iter().flatten() {
merged.merge(&fs);
}
Ok(merged)
}
async fn collect_single_snapshot_files(
file_io: &FileIO,
sm: &SnapshotManager,
manifest_sm: &SnapshotManager,
snapshot_id: i64,
manifest_cache: &ManifestCache,
extra_resolver: &ExtraFileResolver,
) -> crate::Result<Option<ScopeFileSet>> {
let snapshot = match try_get_snapshot(sm, snapshot_id).await? {
Some(s) => s,
None => return Ok(None),
};
collect_snapshot_files(
file_io,
manifest_sm,
&snapshot,
manifest_cache,
extra_resolver,
)
.await
}
async fn collect_snapshot_files(
file_io: &FileIO,
manifest_sm: &SnapshotManager,
snapshot: &crate::spec::Snapshot,
manifest_cache: &ManifestCache,
extra_resolver: &ExtraFileResolver,
) -> crate::Result<Option<ScopeFileSet>> {
let mut file_set = ScopeFileSet::default();
// Collect manifest list file names (these are manifest-type files themselves)
let mut manifest_list_names = vec![
snapshot.base_manifest_list().to_string(),
snapshot.delta_manifest_list().to_string(),
];
if let Some(cl) = snapshot.changelog_manifest_list() {
manifest_list_names.push(cl.to_string());
}
// Pre-compute paths (always resolved from table root)
let manifest_list_paths: Vec<String> = manifest_list_names
.iter()
.map(|name| manifest_sm.manifest_path(name))
.collect();
// Read all manifest lists concurrently and record their sizes
let manifest_list_futures: Vec<_> = manifest_list_paths
.iter()
.map(|path| try_read_manifest_list_with_size(file_io, path))
.collect();
let manifest_list_results = try_join_all(manifest_list_futures).await?;
// Register manifest list files themselves as manifest files
for (name, (_, size)) in manifest_list_names.iter().zip(&manifest_list_results) {
if *size > 0 {
file_set.manifest_files.entry(name.clone()).or_insert(*size);
}
}
// Flatten all manifest file metas from all manifest lists
let all_manifest_metas: Vec<&ManifestFileMeta> = manifest_list_results
.iter()
.flat_map(|(metas, _)| metas.iter())
.collect();
// Register manifest files
for meta in &all_manifest_metas {
file_set
.manifest_files
.entry(meta.file_name().to_string())
.or_insert(meta.file_size());
}
// Read manifest files to get data file entries, using cache by full path
let manifest_paths: Vec<String> = all_manifest_metas
.iter()
.map(|meta| manifest_sm.manifest_path(meta.file_name()))
.collect();
let uncached_indices: Vec<usize> = manifest_paths
.iter()
.enumerate()
.filter(|(_, path)| {
let cache = manifest_cache.lock().unwrap();
!cache.contains_key(path.as_str())
})
.map(|(i, _)| i)
.collect();
if !uncached_indices.is_empty() {
let uncached_paths: Vec<&str> = uncached_indices
.iter()
.map(|&i| manifest_paths[i].as_str())
.collect();
let manifest_futures: Vec<_> = uncached_paths
.iter()
.map(|path| try_read_manifest(file_io, path))
.collect();
let results = try_join_all(manifest_futures).await?;
// Collect extra files that need stat-ing
let mut extra_file_stat_tasks: Vec<(usize, usize, String)> = Vec::new();
let mut all_file_entries: Vec<Vec<(String, i64)>> = Vec::with_capacity(results.len());
for (manifest_idx, entries) in results.iter().enumerate() {
let mut file_entries: Vec<(String, i64)> = Vec::new();
for e in entries {
file_entries.push((e.file().file_name.clone(), e.file().file_size));
for extra in &e.file().extra_files {
let entry_idx = file_entries.len();
let full_path = extra_resolver.resolve_extra_file_path(
e.partition(),
e.bucket(),
e.file(),
extra,
);
if let Some(path) = full_path {
extra_file_stat_tasks.push((manifest_idx, entry_idx, path));
}
file_entries.push((extra.clone(), 0));
}
}
all_file_entries.push(file_entries);
}
// Batch stat extra files concurrently
if !extra_file_stat_tasks.is_empty() {
let stat_futures: Vec<_> = extra_file_stat_tasks
.iter()
.map(|(_, _, path)| try_stat_file_size(file_io, path))
.collect();
let stat_results = try_join_all(stat_futures).await?;
for ((manifest_idx, entry_idx, _), size) in
extra_file_stat_tasks.iter().zip(stat_results)
{
if size > 0 {
all_file_entries[*manifest_idx][*entry_idx].1 = size;
}
}
}
let mut cache = manifest_cache.lock().unwrap();
for (path, file_entries) in uncached_paths.into_iter().zip(all_file_entries) {
cache.insert(path.to_string(), file_entries);
}
}
// Collect data files from cache (deduplicated by HashMap key)
{
let cache = manifest_cache.lock().unwrap();
for path in &manifest_paths {
if let Some(entries) = cache.get(path.as_str()) {
for (name, size) in entries {
file_set.data_files.entry(name.clone()).or_insert(*size);
}
}
}
}
// Read index manifest if present
if let Some(index_manifest_name) = snapshot.index_manifest() {
// The index manifest file itself is a manifest-type file
let index_manifest_path = manifest_sm.manifest_path(index_manifest_name);
let index_entries =
try_read_index_manifest_with_size(file_io, &index_manifest_path).await?;
if index_entries.1 > 0 {
file_set
.manifest_files
.entry(index_manifest_name.to_string())
.or_insert(index_entries.1);
}
for entry in &index_entries.0 {
file_set
.index_files
.entry(entry.index_file.file_name.clone())
.or_insert(entry.index_file.file_size as i64);
}
}
// Collect statistics file if present
if let Some(statistics_name) = snapshot.statistics() {
let statistics_path = format!(
"{}/statistics/{}",
extra_resolver.table_location, statistics_name
);
let size = try_stat_file_size(file_io, &statistics_path).await?;
if size > 0 {
file_set
.manifest_files
.entry(statistics_name.to_string())
.or_insert(size);
}
}
Ok(Some(file_set))
}
async fn try_get_snapshot(
sm: &SnapshotManager,
snapshot_id: i64,
) -> crate::Result<Option<crate::spec::Snapshot>> {
match sm.get_snapshot(snapshot_id).await {
Ok(s) => Ok(Some(s)),
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok(None)
}
Err(crate::Error::DataInvalid { ref message, .. })
if message.contains("does not exist") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
/// Read a manifest list file. Returns (entries, file_size_in_bytes).
async fn try_read_manifest_list_with_size(
file_io: &FileIO,
path: &str,
) -> crate::Result<(Vec<ManifestFileMeta>, i64)> {
let input = file_io.new_input(path)?;
match input.read().await {
Ok(bytes) => {
let size = bytes.len() as i64;
let metas = crate::spec::avro::from_avro_bytes_fast(&bytes)?;
Ok((metas, size))
}
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok((Vec::new(), 0))
}
Err(e) => Err(e),
}
}
async fn try_read_manifest(file_io: &FileIO, path: &str) -> crate::Result<Vec<ManifestEntry>> {
match Manifest::read(file_io, path).await {
Ok(entries) => Ok(entries),
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok(Vec::new())
}
Err(e) => Err(e),
}
}
/// Stat a file to get its size. Returns 0 if the file is not found.
async fn try_stat_file_size(file_io: &FileIO, path: &str) -> crate::Result<i64> {
let input = file_io.new_input(path)?;
match input.metadata().await {
Ok(status) => Ok(status.size as i64),
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok(0)
}
Err(e) => Err(e),
}
}
/// Read an index manifest file. Returns (entries, file_size_in_bytes).
async fn try_read_index_manifest_with_size(
file_io: &FileIO,
path: &str,
) -> crate::Result<(Vec<crate::spec::IndexManifestEntry>, i64)> {
match IndexManifest::read_with_size(file_io, path).await {
Ok(result) => Ok(result),
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok((Vec::new(), 0))
}
Err(e) => Err(e),
}
}
/// Summary of all physical files in the table directory, categorized by file type.
#[derive(Debug, Clone, Default)]
pub struct PhysicalFilesSummary {
pub manifest_file_count: i64,
pub manifest_file_size: i64,
pub data_file_count: i64,
pub data_file_size: i64,
pub index_file_count: i64,
pub index_file_size: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PhysicalFileKind {
Manifest,
Statistics,
Data,
Index,
Other,
}
fn table_relative_path<'a>(table_location: &str, path: &'a str) -> Option<&'a str> {
let table_location = table_location.trim_end_matches('/');
if path == table_location {
Some("")
} else {
path.strip_prefix(table_location)
.and_then(|rest| rest.strip_prefix('/'))
}
}
fn is_manifest_file_name(file_name: &str) -> bool {
file_name.starts_with("manifest-")
|| file_name.starts_with("manifest-list-")
|| file_name.starts_with("index-manifest-")
}
fn is_bucket_dir_name(segment: &str) -> bool {
segment == "bucket-postpone"
|| segment
.strip_prefix("bucket-")
.is_some_and(|bucket| !bucket.is_empty() && bucket.chars().all(|c| c.is_ascii_digit()))
}
fn is_partition_segment(segment: &str) -> bool {
let Some((key, _value)) = segment.split_once('=') else {
return false;
};
!key.is_empty()
}
fn is_data_file_in_bucket(segments: &[&str], partition_depth: usize) -> bool {
if segments.len() != partition_depth + 2 {
return false;
}
segments[..partition_depth]
.iter()
.all(|segment| is_partition_segment(segment))
&& is_bucket_dir_name(segments[partition_depth])
&& !segments[partition_depth + 1].starts_with("index-")
}
fn is_data_file_in_data_dir(
relative_path: &str,
data_dir_relative_path: &str,
partition_depth: usize,
) -> bool {
let data_dir_relative_path = data_dir_relative_path.trim_matches('/');
let data_relative_path = if data_dir_relative_path.is_empty() {
relative_path
} else {
let Some(rest) = relative_path
.strip_prefix(data_dir_relative_path)
.and_then(|rest| rest.strip_prefix('/'))
else {
return false;
};
rest
};
let segments = data_relative_path.split('/').collect::<Vec<_>>();
is_data_file_in_bucket(&segments, partition_depth)
}
fn classify_physical_path(
table_location: &str,
path: &str,
partition_depth: usize,
data_file_path_directory: Option<&str>,
) -> PhysicalFileKind {
let Some(relative_path) = table_relative_path(table_location, path) else {
return PhysicalFileKind::Other;
};
let relative_path = relative_path.trim_matches('/');
if relative_path.is_empty() {
return PhysicalFileKind::Other;
}
let segments = relative_path.split('/').collect::<Vec<_>>();
match segments.as_slice() {
["manifest", name] if is_manifest_file_name(name) => PhysicalFileKind::Manifest,
["statistics", _] => PhysicalFileKind::Statistics,
["index", _] => PhysicalFileKind::Index,
_ => {
if let Some(data_dir) = data_file_path_directory {
let data_dir = table_relative_path(table_location, data_dir).unwrap_or(data_dir);
if is_data_file_in_data_dir(relative_path, data_dir, partition_depth) {
PhysicalFileKind::Data
} else {
PhysicalFileKind::Other
}
} else if is_data_file_in_bucket(&segments, partition_depth) {
PhysicalFileKind::Data
} else {
PhysicalFileKind::Other
}
}
}
}
const DIR_LIST_CONCURRENCY: usize = 32;
/// Scan the table directory and compute total file sizes grouped by type.
///
/// First lists top-level subdirectories, then concurrently lists each
/// subdirectory recursively (up to 32 in parallel) to maximize throughput
/// on object stores with many partition directories.
///
/// Files are classified by their table-relative path. Only recognized Paimon
/// metadata directories and partition/bucket data paths are counted; unknown
/// files are ignored by this summary.
pub async fn collect_physical_files_summary(
file_io: &FileIO,
table_location: &str,
partition_depth: usize,
) -> crate::Result<PhysicalFilesSummary> {
// List top-level entries to discover subdirectories and top-level files
let top_entries = match file_io.list_status(table_location).await {
Ok(s) => s,
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
return Ok(PhysicalFilesSummary::default());
}
Err(e) => return Err(e),
};
let mut summary = PhysicalFilesSummary::default();
// Classify top-level files directly
let mut sub_dirs = Vec::new();
for entry in &top_entries {
if entry.is_dir {
sub_dirs.push(entry.path.clone());
} else {
accumulate_file(
&mut summary,
table_location,
&entry.path,
partition_depth,
entry.size,
);
}
}
// Concurrently list each subdirectory recursively
let dir_results: Vec<crate::Result<Vec<crate::io::FileStatus>>> = stream::iter(sub_dirs)
.map(|dir_path| async move {
match file_io.list_status_recursive(&dir_path).await {
Ok(s) => Ok(s),
Err(crate::Error::IoUnexpected { ref source, .. })
if source.kind() == opendal::ErrorKind::NotFound =>
{
Ok(Vec::new())
}
Err(e) => Err(e),
}
})
.buffer_unordered(DIR_LIST_CONCURRENCY)
.collect()
.await;
for result in dir_results {
let statuses = result?;
for status in &statuses {
accumulate_file(
&mut summary,
table_location,
&status.path,
partition_depth,
status.size,
);
}
}
Ok(summary)
}
fn accumulate_file(
summary: &mut PhysicalFilesSummary,
table_location: &str,
path: &str,
partition_depth: usize,
size: u64,
) {
match classify_physical_path(table_location, path, partition_depth, None) {
PhysicalFileKind::Manifest | PhysicalFileKind::Statistics => {
summary.manifest_file_count += 1;
summary.manifest_file_size += size as i64;
}
PhysicalFileKind::Data => {
summary.data_file_count += 1;
summary.data_file_size += size as i64;
}
PhysicalFileKind::Index => {
summary.index_file_count += 1;
summary.index_file_size += size as i64;
}
PhysicalFileKind::Other => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::FileIOBuilder;
use crate::spec::{CommitKind, Snapshot};
use crate::table::{BranchManager, SnapshotManager, TagManager};
use bytes::Bytes;
fn test_file_io() -> FileIO {
FileIOBuilder::new("memory").build().unwrap()
}
async fn write_test_file(file_io: &FileIO, path: &str, content: &str) {
file_io
.new_output(path)
.unwrap()
.write(Bytes::from(content.to_string()))
.await
.unwrap();
}
#[test]
fn test_extra_file_resolver_uses_external_path_parent() {
use crate::spec::stats::BinaryTableStats;
let resolver = ExtraFileResolver::new("s3://warehouse/table", &[], &[]);
let stats = BinaryTableStats::empty();
let file = DataFileMeta {
file_name: "data-0.row".to_string(),
file_size: 1,
row_count: 1,
min_key: vec![],
max_key: vec![],
key_stats: stats.clone(),
value_stats: stats,
min_sequence_number: 0,
max_sequence_number: 0,
schema_id: 0,
level: 0,
extra_files: vec!["data-0.row.index".to_string()],
creation_time: None,
delete_row_count: None,
embedded_index: None,
file_source: None,
value_stats_cols: None,
external_path: Some("s3://bucket/external/data-0.row".to_string()),
first_row_id: None,
write_cols: None,
};
assert_eq!(
resolver.resolve_extra_file_path(&[], 0, &file, "data-0.row.index"),
Some("s3://bucket/external/data-0.row.index".to_string())
);
}
#[tokio::test]
async fn test_referenced_files_stats_external_sidecar_from_parent() {
use crate::spec::stats::BinaryTableStats;
use crate::spec::{DataFileMeta, FileKind, Manifest, ManifestFileMeta, ManifestList};
let table_path = "memory:/test_external_sidecar_references";
let external_dir = "memory:/external_sidecar_references";
let sidecar_name = "data-0.row.index";
let sidecar_content = "sidecar-bytes";
let file_io = test_file_io();
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
write_test_file(
&file_io,
&format!("{external_dir}/{sidecar_name}"),
sidecar_content,
)
.await;
let manifest_name = "manifest-external-sidecar-0";
let manifest_path = format!("{table_path}/manifest/{manifest_name}");
let data_file = DataFileMeta {
file_name: "data-0.row".to_string(),
file_size: 100,
row_count: 10,
min_key: vec![],
max_key: vec![],
key_stats: BinaryTableStats::empty(),
value_stats: BinaryTableStats::empty(),
min_sequence_number: 0,
max_sequence_number: 0,
schema_id: 0,
level: 0,
extra_files: vec![sidecar_name.to_string()],
creation_time: None,
delete_row_count: Some(0),
embedded_index: None,
file_source: None,
value_stats_cols: None,
external_path: Some(format!("{external_dir}/data-0.row")),
first_row_id: None,
write_cols: None,
};
let entry = ManifestEntry::new(FileKind::Add, vec![0u8; 12], 0, 1, data_file, 0);
Manifest::write(&file_io, &manifest_path, &[entry])
.await
.unwrap();
let manifest_list_name = "manifest-list-external-sidecar";
let manifest_list_path = format!("{table_path}/manifest/{manifest_list_name}");
let manifest_meta = ManifestFileMeta::new(
manifest_name.to_string(),
512,
1,
0,
BinaryTableStats::empty(),
0,
);
ManifestList::write(&file_io, &manifest_list_path, &[manifest_meta])
.await
.unwrap();
let delta_list_name = "manifest-list-external-sidecar-delta";
let delta_list_path = format!("{table_path}/manifest/{delta_list_name}");
ManifestList::write(&file_io, &delta_list_path, &[])
.await
.unwrap();
let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
let snapshot = Snapshot::builder()
.version(3)
.id(1)
.schema_id(0)
.base_manifest_list(manifest_list_name.to_string())
.delta_manifest_list(delta_list_name.to_string())
.commit_user("test".to_string())
.commit_identifier(0)
.commit_kind(CommitKind::APPEND)
.time_millis(1000)
.build();
sm.commit_snapshot(&snapshot).await.unwrap();
let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
.await
.unwrap();
let total = result.iter().find(|r| r.source == "total").unwrap();
assert_eq!(total.data_file_count, 2);
assert_eq!(total.data_file_size, 100 + sidecar_content.len() as i64);
}
#[tokio::test]
async fn test_collect_empty_table() {
let file_io = test_file_io();
let result =
collect_referenced_files_summary(&file_io, "memory:/test_empty_table", &[], &[])
.await
.unwrap();
// total + branch:main
assert_eq!(result.len(), 2);
assert_eq!(result[0].source, "total");
assert_eq!(result[0].data_file_count, 0);
assert_eq!(result[1].source, "branch:main");
assert_eq!(result[1].data_file_count, 0);
}
#[tokio::test]
async fn test_collect_with_missing_manifest() {
let table_path = "memory:/test_missing_manifest";
let file_io = test_file_io();
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
// Create a snapshot that references non-existent manifest lists
let snapshot = Snapshot::builder()
.version(3)
.id(1)
.schema_id(0)
.base_manifest_list("non-existent-base".to_string())
.delta_manifest_list("non-existent-delta".to_string())
.commit_user("test".to_string())
.commit_identifier(0)
.commit_kind(CommitKind::APPEND)
.time_millis(1000)
.build();
sm.commit_snapshot(&snapshot).await.unwrap();
let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
.await
.unwrap();
// total + branch:main
assert_eq!(result.len(), 2);
assert_eq!(result[0].source, "total");
assert_eq!(result[0].manifest_file_count, 0);
assert_eq!(result[0].data_file_count, 0);
assert_eq!(result[1].source, "branch:main");
assert_eq!(result[1].manifest_file_count, 0);
assert_eq!(result[1].data_file_count, 0);
}
#[tokio::test]
async fn test_physical_files_summary_uses_path_context_for_unpartitioned_table() {
let table_path = "memory:/test_physical_files_summary_path_context";
let file_io = test_file_io();
write_test_file(
&file_io,
&format!("{table_path}/manifest/manifest-list-0"),
"manifest list",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/manifest/manifest-0"),
"manifest",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/manifest/index-manifest-0"),
"index manifest",
)
.await;
write_test_file(&file_io, &format!("{table_path}/index/index-0"), "index").await;
write_test_file(&file_io, &format!("{table_path}/bucket-0/data-0"), "data").await;
write_test_file(
&file_io,
&format!("{table_path}/bucket-0/part-0.parquet"),
"data without prefix",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/bucket-0/index-should-not-be-data"),
"bucket index",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/bucket-postpone/data-u-0"),
"postpone data",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/nested/bucket-0/data-too-deep"),
"not a data bucket",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/data-root-file.parquet"),
"root data prefix",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/snapshot/snapshot-1"),
"snapshot",
)
.await;
write_test_file(&file_io, &format!("{table_path}/schema/schema-0"), "schema").await;
write_test_file(&file_io, &format!("{table_path}/tag/tag-v1"), "tag").await;
write_test_file(&file_io, &format!("{table_path}/_SUCCESS"), "success").await;
write_test_file(&file_io, &format!("{table_path}/random-file"), "random").await;
write_test_file(
&file_io,
&format!("{table_path}/statistics/stat-0"),
"statistics",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/manifest/stat-0"),
"not classified by statistics prefix",
)
.await;
let result = collect_physical_files_summary(&file_io, table_path, 0)
.await
.unwrap();
assert_eq!(result.manifest_file_count, 4);
assert_eq!(result.index_file_count, 1);
assert_eq!(result.data_file_count, 3);
}
#[tokio::test]
async fn test_physical_files_summary_uses_partition_depth() {
let table_path = "memory:/test_physical_files_summary_partitioned";
let file_io = test_file_io();
write_test_file(
&file_io,
&format!("{table_path}/dt=2026-05-21/bucket-0/part-0.parquet"),
"partition data",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/dt=2026-05-21/bucket-0/index-0"),
"partition bucket index",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/dt=2026-05-21/not-bucket/data-0"),
"not bucket",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/not_partition/bucket-0/data-0"),
"not partition",
)
.await;
write_test_file(
&file_io,
&format!("{table_path}/bucket-0/root-bucket-file"),
"wrong depth",
)
.await;
let result = collect_physical_files_summary(&file_io, table_path, 1)
.await
.unwrap();
assert_eq!(result.data_file_count, 1);
assert_eq!(result.index_file_count, 0);
}
#[tokio::test]
async fn test_branch_tag_referenced_files() {
use crate::spec::stats::BinaryTableStats;
use crate::spec::{DataFileMeta, FileKind, Manifest, ManifestFileMeta, ManifestList};
let table_path = "memory:/test_branch_tag";
let file_io = test_file_io();
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io
.mkdirs(&format!("{table_path}/manifest/"))
.await
.unwrap();
let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
let empty_stats = BinaryTableStats::new(vec![0u8; 8], vec![0u8; 8], vec![Some(0)]);
// Write a manifest file (referenced by branch tag only) at the TABLE ROOT
let manifest_name = "manifest-branch-only-1";
let manifest_path = format!("{table_path}/manifest/{manifest_name}");
let data_file = DataFileMeta {
file_name: "data-branch-tag-file-1.parquet".to_string(),
file_size: 4096,
row_count: 100,
min_key: vec![],
max_key: vec![],
key_stats: BinaryTableStats::empty(),
value_stats: BinaryTableStats::empty(),
min_sequence_number: 0,
max_sequence_number: 0,
schema_id: 0,
level: 0,
extra_files: vec![],
creation_time: None,
delete_row_count: Some(0),
embedded_index: None,
file_source: None,
value_stats_cols: None,
external_path: None,
first_row_id: None,
write_cols: None,
};
let entry = ManifestEntry::new(FileKind::Add, vec![0u8; 12], 0, 1, data_file, 2);
Manifest::write(&file_io, &manifest_path, &[entry])
.await
.unwrap();
// Write a manifest list that references the above manifest (at the table root)
let manifest_list_name = "manifest-list-branch-tag-base";
let manifest_list_path = format!("{table_path}/manifest/{manifest_list_name}");
let manifest_meta =
ManifestFileMeta::new(manifest_name.to_string(), 512, 1, 0, empty_stats.clone(), 0);
ManifestList::write(&file_io, &manifest_list_path, &[manifest_meta])
.await
.unwrap();
// Write an empty delta manifest list at the table root
let delta_list_name = "manifest-list-branch-tag-delta";
let delta_list_path = format!("{table_path}/manifest/{delta_list_name}");
ManifestList::write(&file_io, &delta_list_path, &[])
.await
.unwrap();
// Create a main branch snapshot (with non-existent manifest lists)
let main_snapshot = Snapshot::builder()
.version(3)
.id(1)
.schema_id(0)
.base_manifest_list("manifest-list-main-base".to_string())
.delta_manifest_list("manifest-list-main-delta".to_string())
.commit_user("test".to_string())
.commit_identifier(0)
.commit_kind(CommitKind::APPEND)
.time_millis(1000)
.build();
sm.commit_snapshot(&main_snapshot).await.unwrap();
// Create branch b1 with NO snapshots
let bm = BranchManager::new(file_io.clone(), table_path.to_string());
bm.create_branch("b1").await.unwrap();
// Create a tag under branch b1 that references the readable manifest lists
let branch_tm = TagManager::new(file_io.clone(), table_path.to_string()).with_branch("b1");
let branch_tag_snapshot = Snapshot::builder()
.version(3)
.id(100)
.schema_id(0)
.base_manifest_list(manifest_list_name.to_string())
.delta_manifest_list(delta_list_name.to_string())
.commit_user("test".to_string())
.commit_identifier(0)
.commit_kind(CommitKind::APPEND)
.time_millis(2000)
.build();
branch_tm.create("v1", &branch_tag_snapshot).await.unwrap();
let result = collect_referenced_files_summary(&file_io, table_path, &[], &[])
.await
.unwrap();
// Should have: total, branch:main, branch:b1
assert_eq!(result.len(), 3);
assert_eq!(result[0].source, "total");
assert_eq!(result[1].source, "branch:main");
assert_eq!(result[2].source, "branch:b1");
// branch:b1 must have non-zero counts from the branch tag's readable manifests.
// The manifest list + manifest file + delta manifest list = 3 manifest files.
assert!(
result[2].manifest_file_count > 0,
"branch:b1 must have manifest files from branch tag, got {}",
result[2].manifest_file_count
);
assert!(
result[2].manifest_file_size > 0,
"branch:b1 must have non-zero manifest file size, got {}",
result[2].manifest_file_size
);
// The manifest references one data file
assert_eq!(result[2].data_file_count, 1);
assert_eq!(result[2].data_file_size, 4096);
// total should include branch:b1's files
assert!(result[0].data_file_count >= 1);
assert!(result[0].data_file_size >= 4096);
}
}