use crate::Result;
use crate::path_resolver::{parse_object_store_url, resolve_path};
use chrono::{DateTime, Utc};
#[cfg(feature = "write-postgres")]
use datafusion::datasource::object_store::ObjectStoreUrl;
use futures::TryStreamExt;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, ObjectStoreExt};
#[cfg(feature = "write-postgres")]
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum ExpireCriteria {
Versions(Vec<i64>),
OlderThan(DateTime<Utc>),
}
#[derive(Debug, Clone)]
pub enum CleanupCriteria {
All,
OlderThan(DateTime<Utc>),
}
#[cfg(feature = "write-postgres")]
struct DataPathGroup {
object_store_url: ObjectStoreUrl,
object_path: ObjectPath,
data_paths: Vec<String>,
}
pub(crate) fn format_sql_timestamp(dt: &DateTime<Utc>) -> String {
dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpiredSnapshot {
pub snapshot_id: i64,
pub snapshot_time: String,
}
#[derive(Debug, Clone)]
pub struct ScheduledFile {
pub data_file_id: i64,
pub path: String,
pub path_is_relative: bool,
}
async fn run_cleanup<RemoveFut>(
data_path: &str,
files: Vec<ScheduledFile>,
object_store: Arc<dyn ObjectStore>,
dry_run: bool,
remove_rows: impl FnOnce(Vec<i64>) -> RemoveFut,
) -> Result<Vec<String>>
where
RemoveFut: std::future::Future<Output = Result<()>>,
{
if files.is_empty() {
return Ok(Vec::new());
}
let (_, base_key) = parse_object_store_url(data_path)?;
let mut resolved = Vec::with_capacity(files.len());
let mut ids = Vec::with_capacity(files.len());
for file in &files {
let abs = resolve_path(&base_key, &file.path, file.path_is_relative)?;
resolved.push(abs);
ids.push(file.data_file_id);
}
if dry_run {
return Ok(resolved);
}
for abs in &resolved {
let key = ObjectPath::from(abs.trim_start_matches('/'));
match object_store.delete(&key).await {
Ok(()) => {},
Err(object_store::Error::NotFound {
..
}) => {},
Err(e) => return Err(e.into()),
}
}
remove_rows(ids).await?;
Ok(resolved)
}
#[cfg(feature = "write-sqlite")]
pub async fn cleanup_old_files_sqlite(
writer: &crate::metadata_writer_sqlite::SqliteMetadataWriter,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let data_path = crate::metadata_writer::MetadataWriter::get_data_path(writer)?;
let files = writer.list_scheduled_for_deletion(&criteria)?;
run_cleanup(&data_path, files, object_store, dry_run, |ids| async move {
writer.remove_scheduled(&ids)
})
.await
}
#[cfg(feature = "write-postgres")]
pub async fn cleanup_old_files_in_catalog(
mgr: &crate::multicatalog::MulticatalogManager,
catalog_name: &str,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let data_path = mgr.get_data_path_in_catalog(catalog_name).await?;
let files = mgr
.list_scheduled_for_deletion_in_catalog(catalog_name, &criteria)
.await?;
run_cleanup(&data_path, files, object_store, dry_run, |ids| async move {
mgr.remove_scheduled_in_catalog(catalog_name, &ids).await
})
.await
}
async fn run_orphan_cleanup(
data_path: &str,
referenced: Vec<(String, bool)>,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let (_, base_key) = parse_object_store_url(data_path)?;
let mut referenced_set: HashSet<ObjectPath> = HashSet::with_capacity(referenced.len());
for (path, rel) in referenced {
let abs = resolve_path(&base_key, &path, rel)?;
referenced_set.insert(ObjectPath::from(abs.trim_start_matches('/')));
}
let prefix = ObjectPath::from(base_key.trim_start_matches('/'));
let entries: Vec<object_store::ObjectMeta> =
object_store.list(Some(&prefix)).try_collect().await?;
let mut orphans: Vec<ObjectPath> = Vec::new();
for meta in entries {
if !meta.location.as_ref().ends_with(".parquet") {
continue;
}
if let CleanupCriteria::OlderThan(cutoff) = &criteria
&& meta.last_modified >= *cutoff
{
continue;
}
if !referenced_set.contains(&meta.location) {
orphans.push(meta.location);
}
}
if dry_run {
return Ok(orphans.into_iter().map(|p| format!("/{p}")).collect());
}
let mut deleted = Vec::with_capacity(orphans.len());
for orphan in orphans {
match object_store.delete(&orphan).await {
Ok(()) => {},
Err(object_store::Error::NotFound {
..
}) => {},
Err(e) => return Err(e.into()),
}
deleted.push(format!("/{orphan}"));
}
Ok(deleted)
}
#[cfg(feature = "write-sqlite")]
pub async fn delete_orphaned_files_sqlite(
writer: &crate::metadata_writer_sqlite::SqliteMetadataWriter,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let data_path = crate::metadata_writer::MetadataWriter::get_data_path(writer)?;
let referenced = writer.list_referenced_paths()?;
run_orphan_cleanup(&data_path, referenced, object_store, criteria, dry_run).await
}
#[cfg(feature = "write-postgres")]
pub async fn delete_orphaned_files_multicatalog(
mgr: &crate::multicatalog::MulticatalogManager,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let data_path_groups = merge_data_path_groups(group_data_paths(mgr.list_data_paths().await?)?);
if data_path_groups.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"Missing required catalog metadata: 'data_path' not configured.".to_string(),
));
}
let mut authority = None;
for group in &data_path_groups {
if authority
.as_ref()
.is_some_and(|expected| expected != &group.object_store_url)
{
return Err(crate::DuckLakeError::InvalidConfig(
"Multicatalog data paths span multiple object-store authorities; clean each path with delete_orphaned_files_in_data_path"
.to_string(),
));
}
authority = Some(group.object_store_url.clone());
}
let mut deleted = Vec::new();
for group in data_path_groups {
deleted.extend(
delete_orphaned_files_in_data_path_inner(
mgr,
&group,
Arc::clone(&object_store),
criteria.clone(),
dry_run,
)
.await?,
);
}
deleted.sort();
deleted.dedup();
Ok(deleted)
}
#[cfg(feature = "write-postgres")]
pub async fn delete_orphaned_files_in_catalog(
mgr: &crate::multicatalog::MulticatalogManager,
catalog_name: &str,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let data_path = mgr.get_data_path_in_catalog(catalog_name).await?;
let group = registered_data_path_group(mgr, &data_path).await?;
delete_orphaned_files_in_data_path_inner(mgr, &group, object_store, criteria, dry_run).await
}
#[cfg(feature = "write-postgres")]
pub async fn delete_orphaned_files_in_data_path(
mgr: &crate::multicatalog::MulticatalogManager,
data_path: &str,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let group = registered_data_path_group(mgr, data_path).await?;
delete_orphaned_files_in_data_path_inner(mgr, &group, object_store, criteria, dry_run).await
}
#[cfg(feature = "write-postgres")]
async fn delete_orphaned_files_in_data_path_inner(
mgr: &crate::multicatalog::MulticatalogManager,
group: &DataPathGroup,
object_store: Arc<dyn ObjectStore>,
criteria: CleanupCriteria,
dry_run: bool,
) -> Result<Vec<String>> {
let clear_tombstone = !dry_run && matches!(&criteria, CleanupCriteria::All);
let dropped_before = mgr.current_timestamp().await?;
let mut referenced = Vec::new();
for data_path in &group.data_paths {
let (_, base_key) = parse_object_store_url(data_path)?;
for (path, relative) in mgr
.list_referenced_paths_in_data_paths(std::slice::from_ref(data_path))
.await?
{
referenced.push((resolve_path(&base_key, &path, relative)?, false));
}
}
let deleted = run_orphan_cleanup(
&group.data_paths[0],
referenced,
object_store,
criteria,
dry_run,
)
.await?;
if clear_tombstone {
mgr.clear_dropped_data_paths(&group.data_paths, dropped_before)
.await?;
}
Ok(deleted)
}
#[cfg(feature = "write-postgres")]
async fn registered_data_path_group(
mgr: &crate::multicatalog::MulticatalogManager,
data_path: &str,
) -> Result<DataPathGroup> {
let mut groups = group_data_paths(mgr.list_data_paths().await?)?;
let index = groups
.iter()
.position(|group| group.data_paths.iter().any(|path| path == data_path))
.ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"Data path {data_path:?} is not registered for multicatalog cleanup"
))
})?;
let mut root = groups.remove(index);
for group in groups {
if group.object_store_url == root.object_store_url
&& group.object_path.prefix_matches(&root.object_path)
{
root.data_paths.extend(group.data_paths);
}
}
Ok(root)
}
#[cfg(feature = "write-postgres")]
fn group_data_paths(data_paths: Vec<String>) -> Result<Vec<DataPathGroup>> {
let mut groups = BTreeMap::new();
for data_path in data_paths {
let (object_store_url, base_key) = parse_object_store_url(&data_path)?;
let object_path = ObjectPath::from(base_key.trim_start_matches('/'));
groups
.entry((object_store_url, object_path))
.or_insert_with(Vec::new)
.push(data_path);
}
Ok(groups
.into_iter()
.map(
|((object_store_url, object_path), data_paths)| DataPathGroup {
object_store_url,
object_path,
data_paths,
},
)
.collect())
}
#[cfg(feature = "write-postgres")]
fn merge_data_path_groups(groups: Vec<DataPathGroup>) -> Vec<DataPathGroup> {
let mut roots: Vec<DataPathGroup> = Vec::new();
for group in groups {
if let Some(root) = roots.iter_mut().find(|root| {
group.object_store_url == root.object_store_url
&& group.object_path.prefix_matches(&root.object_path)
}) {
root.data_paths.extend(group.data_paths);
} else {
roots.push(group);
}
}
roots
}