use crate::Result;
#[cfg(feature = "write-postgres")]
use crate::path_resolver::join_paths;
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,
pub scheduled_by_owner: Option<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<ScheduledFile>) -> 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());
for file in &files {
let abs = resolve_path(&base_key, &file.path, file.path_is_relative)?;
resolved.push(abs);
}
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(files).await?;
Ok(resolved)
}
#[cfg(feature = "write-postgres")]
fn canonical_key(resolved: &str) -> String {
ObjectPath::from(resolved.trim_start_matches('/')).to_string()
}
#[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,
|files| async move { writer.remove_scheduled(&scheduled_ids(&files)) },
)
.await
}
#[cfg(any(feature = "write-sqlite", feature = "write-duckdb", feature = "write-mysql"))]
fn scheduled_ids(files: &[ScheduledFile]) -> Vec<i64> {
files.iter().map(|f| f.data_file_id).collect()
}
#[cfg(feature = "write-duckdb")]
pub async fn cleanup_old_files_duckdb(
writer: &crate::metadata_writer_duckdb::DuckdbMetadataWriter,
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,
|files| async move { writer.remove_scheduled(&scheduled_ids(&files)) },
)
.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?;
let files = retain_unreferenced(mgr, catalog_name, &data_path, files, dry_run).await?;
run_cleanup(
&data_path,
files,
object_store,
dry_run,
|files| async move {
let paths: Vec<String> = files.into_iter().map(|f| f.path).collect();
mgr.remove_scheduled_paths_in_catalog(catalog_name, &paths)
.await
},
)
.await
}
#[cfg(feature = "write-postgres")]
async fn retain_unreferenced(
mgr: &crate::multicatalog::MulticatalogManager,
catalog_name: &str,
data_path: &str,
files: Vec<ScheduledFile>,
dry_run: bool,
) -> Result<Vec<ScheduledFile>> {
if files.is_empty() {
return Ok(files);
}
let (_, base_key) = parse_object_store_url(data_path)?;
let own_catalog_id = mgr.find_catalog_id(catalog_name).await?;
let own_prefix = match own_catalog_id {
Some(id) => Some(canonical_key(&join_paths(&base_key, &format!("cat_{id}"))?)),
None => None,
};
let mut foreign_prefixes: Vec<String> = Vec::new();
for (id, path) in mgr.list_catalog_data_paths().await? {
if Some(id) == own_catalog_id {
continue;
}
let (_, foreign_base) = parse_object_store_url(&path)?;
foreign_prefixes.push(canonical_key(&join_paths(
&foreign_base,
&format!("cat_{id}"),
)?));
}
let mut resolved = Vec::with_capacity(files.len());
for file in &files {
resolved.push(resolve_path(&base_key, &file.path, file.path_is_relative)?);
}
let referenced: HashSet<String> = mgr
.all_reference_paths()
.await?
.iter()
.map(|p| canonical_key(p))
.collect();
let mut kept = Vec::with_capacity(files.len());
let mut deferred = Vec::new();
let under = |key: &str, prefix: &str| key == prefix || key.starts_with(&format!("{prefix}/"));
for (file, abs) in files.into_iter().zip(resolved) {
let key = canonical_key(&abs);
if let Some(foreign) = foreign_prefixes.iter().find(|p| under(&key, p)) {
tracing::warn!(
catalog = catalog_name,
path = %abs,
owner_prefix = %foreign,
"refusing to reclaim a scheduled file inside another catalog's own \
layout; that catalog owns it and is left to reclaim it"
);
continue;
}
if file.scheduled_by_owner != Some(true)
&& !file.path_is_relative
&& let Some(prefix) = &own_prefix
&& !under(&key, prefix)
{
tracing::warn!(
catalog = catalog_name,
path = %abs,
own_prefix = %prefix,
"refusing to reclaim a scheduled file outside this catalog's own layout \
that was scheduled before file ownership was recorded; it may name an \
object another catalog owns and is left scheduled"
);
continue;
}
if referenced.contains(&key) {
deferred.push(file.path);
} else {
kept.push(file);
}
}
if !dry_run {
mgr.defer_scheduled_in_catalog(catalog_name, &deferred)
.await?;
}
Ok(kept)
}
#[cfg(feature = "write-postgres")]
pub async fn purge_orphaned_metadata_postgres(pool: &sqlx::PgPool) -> Result<()> {
crate::metadata_writer_postgres::purge_orphaned_metadata(pool).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-duckdb")]
pub async fn delete_orphaned_files_duckdb(
writer: &crate::metadata_writer_duckdb::DuckdbMetadataWriter,
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
}