Skip to main content

lance/dataset/
cleanup.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! A task to clean up a lance dataset, removing files that are no longer
5//! needed.
6//!
7//! Currently we try and be rather conservative about what we delete.
8//!
9//! The following types of files may be deleted by the cleanup function:
10//!
11//! * Old manifest files - If a manifest file is older than the threshold
12//!   and is not the latest manifest then it will be deleted.
13//! * Unreferenced data files - If a data file is not referenced by any
14//!   fragment in a valid manifest file then it will be deleted.
15//! * Unreferenced delete files - If a delete file is not referenced by
16//!   any fragment in a valid manifest file then it will be deleted.
17//! * Unreferenced index files - If an index file is not referenced by
18//!   any valid manifest file then it will be deleted.
19//!
20//! It is also difficult to distinguish between a data/tx/idx file which was
21//! leftover from an abandoned transaction and a data file which is part
22//! of an ongoing operation (both will look like unreferenced data files).
23//!
24//! If the file is referenced by at least one manifest (even if that manifest
25//! is old and being deleted) then we assume it is not part of an ongoing
26//! operation and can be safely deleted.
27//!
28//! If the data is not referenced by any manifest then we look at the age of
29//! the file.  If the file is at least 7 days old then we assume it is probably
30//! not part of any ongoing operation and we will delete it.
31//!
32//! Otherwise we will leave the file unless delete_unverified is set to true.
33//! (which should only be done if the caller can guarantee there are no updates
34//! happening at the same time)
35
36use super::refs::TagContents;
37use crate::dataset::TRANSACTIONS_DIR;
38use crate::{Dataset, utils::temporal::utc_now};
39use chrono::{DateTime, TimeDelta, Utc};
40use dashmap::DashSet;
41use futures::future::try_join_all;
42use futures::stream::BoxStream;
43use futures::{StreamExt, TryStreamExt, stream};
44use humantime::parse_duration;
45use lance_core::{
46    Error, Result,
47    utils::tracing::{
48        AUDIT_MODE_DELETE, AUDIT_MODE_DELETE_UNVERIFIED, AUDIT_TYPE_DATA, AUDIT_TYPE_DELETION,
49        AUDIT_TYPE_INDEX, AUDIT_TYPE_MANIFEST, DATASET_CLEANING_EVENT, TRACE_DATASET_EVENTS,
50        TRACE_FILE_AUDIT,
51    },
52};
53use lance_table::{
54    format::{IndexMetadata, Manifest},
55    io::{
56        commit::ManifestLocation,
57        deletion::deletion_file_path,
58        manifest::{read_manifest, read_manifest_indexes},
59    },
60};
61use object_store::ObjectMeta;
62use object_store::path::Path;
63use std::fmt::Debug;
64use std::{
65    collections::{HashMap, HashSet},
66    future,
67    sync::{Mutex, MutexGuard},
68    time::Duration,
69};
70use tokio::time::{MissedTickBehavior, interval};
71use tokio_stream::wrappers::IntervalStream;
72use tracing::{Span, debug, info, instrument};
73
74#[derive(Clone, Debug, Default)]
75struct ReferencedFiles {
76    data_paths: HashSet<Path>,
77    delete_paths: HashSet<Path>,
78    tx_paths: HashSet<Path>,
79    index_uuids: HashSet<String>,
80}
81
82#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct RemovalStats {
84    pub bytes_removed: u64,
85    pub old_versions: u64,
86    pub data_files_removed: u64,
87    pub transaction_files_removed: u64,
88    pub index_files_removed: u64,
89    pub deletion_files_removed: u64,
90}
91
92/// A read-only explanation of what a cleanup operation would remove.
93///
94/// This is an explanation, not a deletion plan.  Calling
95/// [`CleanupOperation::execute`] re-evaluates the current dataset and reference
96/// state before deleting files.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct CleanupExplanation {
99    /// Dataset version observed when the explanation was produced.
100    pub read_version: u64,
101    /// Aggregate statistics for files that would be removed.
102    pub stats: RemovalStats,
103    /// Candidate files that would be removed, capped by `candidate_file_limit`.
104    pub candidate_files: Vec<CleanupCandidateFile>,
105    /// True if more candidate files were found than are included.
106    pub candidate_files_truncated: bool,
107    /// Maximum number of candidate files included in this explanation.
108    pub candidate_file_limit: usize,
109    /// Referenced child branches and whether cleanup would cascade into them.
110    pub referenced_branches: Vec<CleanupReferencedBranch>,
111    /// Non-fatal warnings about the explanation.
112    pub warnings: Vec<String>,
113}
114
115/// A file that cleanup identified as removable.
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct CleanupCandidateFile {
118    /// Dataset-relative or storage path for the candidate file.
119    pub path: String,
120    /// Kind of file identified by cleanup.
121    pub kind: CleanupFileKind,
122    /// True if the file is removable only because it aged past the unverified
123    /// retention threshold or `delete_unverified` is enabled.
124    pub unverified: bool,
125    /// Candidate file size in bytes.
126    pub size_bytes: u64,
127}
128
129/// A branch that references the current branch lineage.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct CleanupReferencedBranch {
132    /// Branch name.
133    pub name: String,
134    /// Version of the current lineage referenced by this branch.
135    pub referenced_version: u64,
136    /// True if this branch would be cleaned when cascading cleanup is enabled.
137    pub cleanup_candidate: bool,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum CleanupFileKind {
142    Manifest,
143    Data,
144    Transaction,
145    Index,
146    Deletion,
147    /// A leftover `_versions/.tmp` manifest from a failed transaction.  These
148    /// are deleted but excluded from per-kind `RemovalStats` counts and audit
149    /// logs to match the long-standing cleanup behavior.  Their bytes
150    /// are still included in `bytes_removed`.
151    TemporaryManifest,
152}
153
154impl CleanupCandidateFile {
155    fn from_cleanup_file(file: &CleanupFile) -> Self {
156        Self {
157            path: file.path.to_string(),
158            kind: file.kind,
159            unverified: file.unverified,
160            size_bytes: file.size_bytes,
161        }
162    }
163}
164
165fn cleanup_file(
166    path: Path,
167    kind: CleanupFileKind,
168    unverified: bool,
169    size_bytes: u64,
170) -> Option<CleanupFile> {
171    Some(CleanupFile {
172        path,
173        kind,
174        unverified,
175        size_bytes,
176    })
177}
178
179#[derive(Clone, Debug)]
180struct CleanupFile {
181    path: Path,
182    kind: CleanupFileKind,
183    /// True when the file was kept on disk past its referenced lifetime
184    /// because we could not verify it was safe to remove (e.g. produced by an
185    /// unfinished commit) and is being deleted only because it has aged past
186    /// the unverified-retention threshold or `delete_unverified` is set.
187    unverified: bool,
188    size_bytes: u64,
189}
190
191impl RemovalStats {
192    fn record_file(&mut self, file: &CleanupFile) {
193        self.bytes_removed += file.size_bytes;
194        match file.kind {
195            CleanupFileKind::Manifest => self.old_versions += 1,
196            CleanupFileKind::Data => self.data_files_removed += 1,
197            CleanupFileKind::Transaction => self.transaction_files_removed += 1,
198            CleanupFileKind::Index => self.index_files_removed += 1,
199            CleanupFileKind::Deletion => self.deletion_files_removed += 1,
200            CleanupFileKind::TemporaryManifest => {}
201        }
202    }
203
204    fn merge(&mut self, other: &Self) {
205        self.bytes_removed += other.bytes_removed;
206        self.old_versions += other.old_versions;
207        self.data_files_removed += other.data_files_removed;
208        self.transaction_files_removed += other.transaction_files_removed;
209        self.index_files_removed += other.index_files_removed;
210        self.deletion_files_removed += other.deletion_files_removed;
211    }
212}
213
214#[derive(Debug, Default)]
215struct CleanupRunResult {
216    stats: RemovalStats,
217    removed_manifests: HashSet<Path>,
218    candidate_files: Vec<CleanupCandidateFile>,
219    candidate_files_truncated: bool,
220    referenced_branches: Vec<CleanupReferencedBranch>,
221}
222
223impl CleanupRunResult {
224    fn record_file(
225        &mut self,
226        file: &CleanupFile,
227        candidate_file_limit: Option<usize>,
228        track_removed_manifests: bool,
229    ) {
230        self.stats.record_file(file);
231        if track_removed_manifests && matches!(file.kind, CleanupFileKind::Manifest) {
232            self.removed_manifests.insert(file.path.clone());
233        }
234        if let Some(limit) = candidate_file_limit {
235            if self.candidate_files.len() < limit {
236                self.candidate_files
237                    .push(CleanupCandidateFile::from_cleanup_file(file));
238            } else {
239                self.candidate_files_truncated = true;
240            }
241        }
242    }
243
244    fn merge(&mut self, other: Self, candidate_file_limit: Option<usize>) {
245        self.stats.merge(&other.stats);
246        self.removed_manifests.extend(other.removed_manifests);
247        self.referenced_branches.extend(other.referenced_branches);
248        if let Some(limit) = candidate_file_limit {
249            for file in other.candidate_files {
250                if self.candidate_files.len() < limit {
251                    self.candidate_files.push(file);
252                } else {
253                    self.candidate_files_truncated = true;
254                }
255            }
256            self.candidate_files_truncated |= other.candidate_files_truncated;
257        }
258    }
259}
260
261#[derive(Clone, Copy, Debug)]
262enum CleanupAction {
263    Execute,
264    Explain { max_candidate_files: usize },
265}
266
267impl CleanupAction {
268    fn deletes_files(self) -> bool {
269        matches!(self, Self::Execute)
270    }
271
272    fn candidate_file_limit(self) -> Option<usize> {
273        match self {
274            Self::Execute => None,
275            Self::Explain {
276                max_candidate_files,
277            } => Some(max_candidate_files),
278        }
279    }
280}
281
282fn remove_prefix(path: &Path, prefix: &Path) -> Path {
283    let relative_parts = path.prefix_match(prefix);
284    if relative_parts.is_none() {
285        return path.clone();
286    }
287    Path::from_iter(relative_parts.unwrap())
288}
289
290#[derive(Clone, Debug)]
291struct CleanupTask<'a> {
292    dataset: &'a Dataset,
293    policy: CleanupPolicy,
294    action: CleanupAction,
295    read_version: u64,
296    ignored_manifests: HashSet<Path>,
297    track_removed_manifests: bool,
298    include_referenced_branches: bool,
299}
300
301/// Information about the dataset that we learn by inspecting all of the manifests
302#[derive(Clone, Debug, Default)]
303struct CleanupInspection {
304    old_manifests: HashMap<Path, u64>,
305    /// Referenced files are part of our working set
306    referenced_files: ReferencedFiles,
307    /// Verified files may or may not be part of the working set but they are
308    /// referenced by at least one manifest file (potentially an old one) and
309    /// so we know that they are not part of an ongoing operation.
310    verified_files: ReferencedFiles,
311    /// Track tagged old versions in case we want to raise a `CleanupError`.
312    tagged_old_versions: HashSet<u64>,
313    /// The earliest timestamp of all retained manifests.
314    earliest_retained_manifest_time: Option<DateTime<Utc>>,
315}
316
317/// If a file cannot be verified then it will only be deleted if it is at least
318/// this many days old.
319const UNVERIFIED_THRESHOLD_DAYS: i64 = 7;
320const S3_DELETE_STREAM_BATCH_SIZE: u64 = 1_000;
321const AZURE_DELETE_STREAM_BATCH_SIZE: u64 = 256;
322const DEFAULT_EXPLANATION_MAX_CANDIDATE_FILES: usize = 1_000;
323
324/// Builder-style cleanup operation.
325///
326/// Call [`Self::explain`] for a read-only explanation of what cleanup would
327/// remove, or [`Self::execute`] to re-evaluate the current dataset state and
328/// delete files.
329pub struct CleanupOperation<'a> {
330    dataset: &'a Dataset,
331    policy: CleanupPolicy,
332    max_candidate_files: usize,
333}
334
335impl<'a> CleanupOperation<'a> {
336    pub(crate) fn new(dataset: &'a Dataset, policy: CleanupPolicy) -> Self {
337        Self {
338            dataset,
339            policy,
340            max_candidate_files: DEFAULT_EXPLANATION_MAX_CANDIDATE_FILES,
341        }
342    }
343
344    /// Set the maximum number of candidate files included in explanations.
345    ///
346    /// The aggregate [`RemovalStats`] in [`CleanupExplanation`] still include
347    /// all files that would be removed.
348    pub fn with_max_candidate_files(mut self, max_candidate_files: usize) -> Self {
349        self.max_candidate_files = max_candidate_files;
350        self
351    }
352
353    /// Explain what cleanup would remove without deleting files.
354    pub async fn explain(&self) -> Result<CleanupExplanation> {
355        let cleanup = CleanupTask::new(
356            self.dataset,
357            self.policy.clone(),
358            CleanupAction::Explain {
359                max_candidate_files: self.max_candidate_files,
360            },
361        );
362        let read_version = cleanup.read_version;
363        let result = cleanup.run().await?;
364        let warnings = if result.candidate_files_truncated {
365            vec![format!(
366                "candidate_files truncated to {} entries",
367                self.max_candidate_files
368            )]
369        } else {
370            Vec::new()
371        };
372        Ok(CleanupExplanation {
373            read_version,
374            stats: result.stats,
375            candidate_files: result.candidate_files,
376            candidate_files_truncated: result.candidate_files_truncated,
377            candidate_file_limit: self.max_candidate_files,
378            referenced_branches: result.referenced_branches,
379            warnings,
380        })
381    }
382
383    /// Execute cleanup by re-evaluating the current dataset state.
384    pub async fn execute(&self) -> Result<RemovalStats> {
385        info!(target: TRACE_DATASET_EVENTS, event=DATASET_CLEANING_EVENT, uri=&self.dataset.uri);
386        let cleanup = CleanupTask::new(self.dataset, self.policy.clone(), CleanupAction::Execute);
387        Ok(cleanup.run().await?.stats)
388    }
389}
390
391impl<'a> CleanupTask<'a> {
392    fn new(dataset: &'a Dataset, policy: CleanupPolicy, action: CleanupAction) -> Self {
393        let track_removed_manifests = policy.clean_referenced_branches;
394        let include_referenced_branches = action.candidate_file_limit().is_some();
395        Self::new_with_ignored_manifests(
396            dataset,
397            policy,
398            action,
399            HashSet::new(),
400            track_removed_manifests,
401            include_referenced_branches,
402        )
403    }
404
405    fn new_with_ignored_manifests(
406        dataset: &'a Dataset,
407        policy: CleanupPolicy,
408        action: CleanupAction,
409        ignored_manifests: HashSet<Path>,
410        track_removed_manifests: bool,
411        include_referenced_branches: bool,
412    ) -> Self {
413        Self {
414            dataset,
415            policy,
416            action,
417            read_version: dataset.version().version,
418            ignored_manifests,
419            track_removed_manifests,
420            include_referenced_branches,
421        }
422    }
423
424    async fn run(self) -> Result<CleanupRunResult> {
425        let mut final_result = CleanupRunResult::default();
426        let candidate_file_limit = self.action.candidate_file_limit();
427        // First check if we need to clean referenced branches
428        // For cases that referenced branches never clean and the current cleanup cannot clean anything
429        // This must happen before cleaning the current branch if the setting is enabled.
430
431        let referenced_branches: Vec<(String, u64)> = self.find_referenced_branches().await?;
432        if self.include_referenced_branches {
433            final_result.referenced_branches = referenced_branches
434                .iter()
435                .map(|(name, referenced_version)| CleanupReferencedBranch {
436                    name: name.clone(),
437                    referenced_version: *referenced_version,
438                    cleanup_candidate: self.policy.clean_referenced_branches,
439                })
440                .collect();
441        }
442        if self.policy.clean_referenced_branches {
443            final_result.merge(
444                self.clean_referenced_branches(&referenced_branches).await?,
445                candidate_file_limit,
446            );
447        }
448
449        // we process all manifest files in parallel to figure
450        // out which files are referenced by valid manifests
451
452        // get protected manifests first, and include those in process_manifests
453        // pass on option to process manifests around whether to return error
454        // or clean around the manifest
455        let tags = self.dataset.tags().list().await?;
456        let current_branch = &self.dataset.manifest.branch;
457
458        // Only retain tags on the current branch.
459        // Tags on other branches would take effect in retain_branch_lineage_files
460        let tagged_versions: HashSet<u64> = tags
461            .values()
462            .filter(|tag| match (tag.branch.as_ref(), current_branch.as_ref()) {
463                (Some(branch_of_tag), Some(current_branch)) => branch_of_tag == current_branch,
464                (None, None) => true,
465                _ => false,
466            })
467            .map(|tag_content| tag_content.version)
468            .collect();
469
470        let mut inspection = self.process_manifests(&tagged_versions).await?;
471
472        if self.policy.error_if_tagged_old_versions && !inspection.tagged_old_versions.is_empty() {
473            return Err(tagged_old_versions_cleanup_error(
474                &tags,
475                &inspection.tagged_old_versions,
476            ));
477        }
478
479        if !referenced_branches.is_empty() {
480            let ignored_manifests: HashSet<_> = final_result
481                .removed_manifests
482                .union(&self.ignored_manifests)
483                .cloned()
484                .collect();
485            inspection = self
486                .retain_branch_lineage_files(inspection, &referenced_branches, &ignored_manifests)
487                .await?
488        };
489
490        final_result.merge(
491            self.delete_unreferenced_files(inspection).await?,
492            candidate_file_limit,
493        );
494        Ok(final_result)
495    }
496
497    #[instrument(level = "debug", skip_all)]
498    async fn process_manifests(
499        &'a self,
500        tagged_versions: &HashSet<u64>,
501    ) -> Result<CleanupInspection> {
502        let inspection = Mutex::new(CleanupInspection::default());
503        self.dataset
504            .commit_handler
505            .list_manifest_locations(&self.dataset.base, &self.dataset.object_store, false)
506            .try_filter(|location| future::ready(!self.ignored_manifests.contains(&location.path)))
507            .try_for_each_concurrent(self.dataset.object_store.io_parallelism(), |location| {
508                self.process_manifest_file(location, &inspection, tagged_versions)
509            })
510            .await?;
511        Ok(inspection.into_inner().unwrap())
512    }
513
514    async fn process_manifest_file(
515        &self,
516        location: ManifestLocation,
517        inspection: &Mutex<CleanupInspection>,
518        tagged_versions: &HashSet<u64>,
519    ) -> Result<()> {
520        // TODO: We can't cleanup invalid manifests.  There is no way to distinguish
521        // between an invalid manifest and a temporary I/O error.  It's also not safe
522        // to ignore a manifest error because if it is a temporary I/O error and we
523        // ignore it then we might delete valid data files thinking they are not
524        // referenced.
525
526        let manifest =
527            read_manifest(&self.dataset.object_store, &location.path, location.size).await?;
528        // Don't delete the latest version, even if it is old. Don't delete tagged versions,
529        // regardless of age. Don't delete manifests if their version is newer than the dataset
530        // version.  These are either in-progress or newly added since we started.
531        let is_latest = self.read_version <= manifest.version;
532        let is_tagged = tagged_versions.contains(&manifest.version);
533        let in_working_set = is_latest || !self.policy.should_clean(&manifest) || is_tagged;
534        let indexes =
535            read_manifest_indexes(&self.dataset.object_store, &location, &manifest).await?;
536
537        let mut inspection = inspection.lock().unwrap();
538
539        // Track tagged old versions in case we want to return a `CleanupError` later.
540        // Only track tagged when it is old.
541        if is_tagged && !is_latest && self.policy.should_clean(&manifest) {
542            inspection.tagged_old_versions.insert(manifest.version);
543        }
544
545        self.process_manifest(&manifest, &indexes, in_working_set, &mut inspection)?;
546        if !in_working_set {
547            inspection
548                .old_manifests
549                .insert(location.path.clone(), manifest.version);
550        } else {
551            let commit_ts = manifest.timestamp();
552            if let Some(ts) = inspection.earliest_retained_manifest_time {
553                if commit_ts < ts {
554                    inspection.earliest_retained_manifest_time = Some(commit_ts);
555                }
556            } else {
557                inspection.earliest_retained_manifest_time = Some(commit_ts);
558            }
559        }
560        Ok(())
561    }
562
563    fn process_manifest(
564        &self,
565        manifest: &Manifest,
566        indexes: &Vec<IndexMetadata>,
567        in_working_set: bool,
568        inspection: &mut MutexGuard<CleanupInspection>,
569    ) -> Result<()> {
570        // If this part of our working set then update referenced_files.  Otherwise, just mark the
571        // file as verified.
572        let referenced_files = if in_working_set {
573            &mut inspection.referenced_files
574        } else {
575            &mut inspection.verified_files
576        };
577
578        for fragment in manifest.fragments.iter() {
579            for file in fragment.files.iter() {
580                let full_data_path = self.dataset.data_dir().clone().join(file.path.as_str());
581                let relative_data_path = remove_prefix(&full_data_path, &self.dataset.base);
582                referenced_files.data_paths.insert(relative_data_path);
583            }
584            let delpath = fragment
585                .deletion_file
586                .as_ref()
587                .map(|delfile| deletion_file_path(&self.dataset.base, fragment.id, delfile));
588            if let Some(delpath) = delpath {
589                let relative_path = remove_prefix(&delpath, &self.dataset.base);
590                referenced_files.delete_paths.insert(relative_path);
591            }
592        }
593        if let Some(relative_tx_path) = &manifest.transaction_file {
594            referenced_files
595                .tx_paths
596                .insert(Path::parse(TRANSACTIONS_DIR)?.join(relative_tx_path.as_str()));
597        }
598
599        for index in indexes {
600            let uuid_str = index.uuid.to_string();
601            referenced_files.index_uuids.insert(uuid_str);
602        }
603        Ok(())
604    }
605
606    #[instrument(
607        level = "debug",
608        skip_all,
609        fields(
610            old_versions = inspection.old_manifests.len(),
611            bytes_removed = tracing::field::Empty,
612            data_files_removed = tracing::field::Empty,
613            transaction_files_removed = tracing::field::Empty,
614            index_files_removed = tracing::field::Empty,
615            deletion_files_removed = tracing::field::Empty
616        )
617    )]
618    async fn delete_unreferenced_files(
619        &self,
620        inspection: CleanupInspection,
621    ) -> Result<CleanupRunResult> {
622        let cleanup_result = Mutex::new(CleanupRunResult::default());
623        let deletes_files = self.action.deletes_files();
624        let candidate_file_limit = self.action.candidate_file_limit();
625        let verification_threshold = utc_now()
626            - TimeDelta::try_days(UNVERIFIED_THRESHOLD_DAYS).expect("TimeDelta::try_days");
627
628        let is_not_found_err = |e: &Error| matches!(e, Error::NotFound { .. });
629        // Build stream for a managed subtree
630        let build_listing_stream = |dir: Path| {
631            let inspection_ref = &inspection;
632            self.dataset
633                .object_store
634                .read_dir_all(&dir, inspection.earliest_retained_manifest_time)
635                .map_ok(|obj| stream::once(future::ready(Ok(obj))).boxed())
636                .or_else(|e| {
637                    // If the directory doesn't exist then we can just return an empty stream.
638                    if is_not_found_err(&e) {
639                        future::ready(Ok(stream::empty::<Result<ObjectMeta>>().boxed()))
640                    } else {
641                        future::ready(Err(e))
642                    }
643                })
644                .try_flatten()
645                .try_filter_map(move |obj_meta| {
646                    // If a file is new-ish then it might be part of an ongoing operation and so we only
647                    // delete it if we can verify it is part of an old version.
648                    let maybe_in_progress = !self.policy.delete_unverified
649                        && obj_meta.last_modified >= verification_threshold;
650                    let file_to_remove = self.cleanup_file_if_not_referenced(
651                        obj_meta,
652                        maybe_in_progress,
653                        inspection_ref,
654                    );
655                    future::ready(file_to_remove)
656                })
657                .boxed()
658        };
659
660        // Restrict scanning to Lance-managed subtrees for safety and performance.
661        let streams = vec![
662            build_listing_stream(self.dataset.versions_dir()),
663            build_listing_stream(self.dataset.transactions_dir()),
664            build_listing_stream(self.dataset.data_dir()),
665            build_listing_stream(self.dataset.indices_dir()),
666            build_listing_stream(self.dataset.deletions_dir()),
667        ];
668        let unreferenced_files = stream::iter(streams).flatten().boxed();
669
670        let old_manifests = inspection.old_manifests.clone();
671        let manifest_files = stream::iter(old_manifests)
672            .map(|(path, _version)| async move {
673                let size_bytes = self.dataset.object_store.size(&path).await?;
674                Ok::<CleanupFile, Error>(CleanupFile {
675                    path,
676                    kind: CleanupFileKind::Manifest,
677                    unverified: false,
678                    size_bytes,
679                })
680            })
681            .buffer_unordered(self.dataset.object_store.io_parallelism())
682            .boxed();
683
684        let all_files = stream::iter(vec![unreferenced_files, manifest_files]).flatten();
685        let all_paths_to_remove = all_files.map(|file| {
686            let file = file?;
687            if deletes_files {
688                let mode = if file.unverified {
689                    AUDIT_MODE_DELETE_UNVERIFIED
690                } else {
691                    AUDIT_MODE_DELETE
692                };
693                let path_str = file.path.as_ref();
694                match file.kind {
695                    CleanupFileKind::Manifest => {
696                        info!(target: TRACE_FILE_AUDIT, mode=AUDIT_MODE_DELETE, r#type=AUDIT_TYPE_MANIFEST, path = path_str);
697                    }
698                    CleanupFileKind::Data => {
699                        info!(target: TRACE_FILE_AUDIT, mode=mode, r#type=AUDIT_TYPE_DATA, path = path_str);
700                    }
701                    CleanupFileKind::Deletion => {
702                        info!(target: TRACE_FILE_AUDIT, mode=mode, r#type=AUDIT_TYPE_DELETION, path = path_str);
703                    }
704                    CleanupFileKind::Index => {
705                        info!(target: TRACE_FILE_AUDIT, mode=mode, r#type=AUDIT_TYPE_INDEX, path = path_str);
706                    }
707                    CleanupFileKind::Transaction | CleanupFileKind::TemporaryManifest => {}
708                }
709            }
710            cleanup_result
711                .lock()
712                .unwrap()
713                .record_file(&file, candidate_file_limit, self.track_removed_manifests);
714            Ok(file.path)
715        });
716
717        if deletes_files {
718            let paths_to_delete: BoxStream<Result<Path>> =
719                if let Some(rate) = self.policy.delete_rate_limit {
720                    let duration =
721                        calculate_duration(self.dataset.object_store.scheme().to_string(), rate);
722                    let mut ticker = interval(duration);
723                    ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
724                    IntervalStream::new(ticker)
725                        .zip(all_paths_to_remove)
726                        .map(|(_, path)| path)
727                        .boxed()
728                } else {
729                    all_paths_to_remove.boxed()
730                };
731
732            self.dataset
733                .object_store
734                .remove_stream(paths_to_delete)
735                .try_for_each(|_| future::ready(Ok(())))
736                .await?;
737        } else {
738            // Drain the stream to populate stats, but do not call remove_stream.
739            all_paths_to_remove
740                .try_for_each(|_| future::ready(Ok(())))
741                .await?;
742        }
743
744        let cleanup_result = cleanup_result.into_inner().unwrap();
745
746        let span = Span::current();
747        span.record("bytes_removed", cleanup_result.stats.bytes_removed);
748        span.record(
749            "data_files_removed",
750            cleanup_result.stats.data_files_removed,
751        );
752        span.record(
753            "transaction_files_removed",
754            cleanup_result.stats.transaction_files_removed,
755        );
756        span.record(
757            "index_files_removed",
758            cleanup_result.stats.index_files_removed,
759        );
760        span.record(
761            "deletion_files_removed",
762            cleanup_result.stats.deletion_files_removed,
763        );
764
765        Ok(cleanup_result)
766    }
767
768    fn cleanup_file_if_not_referenced(
769        &self,
770        obj_meta: ObjectMeta,
771        maybe_in_progress: bool,
772        inspection: &CleanupInspection,
773    ) -> Result<Option<CleanupFile>> {
774        let path = obj_meta.location;
775        let relative_path = remove_prefix(&path, &self.dataset.base);
776        let size_bytes = obj_meta.size;
777        if relative_path.as_ref().starts_with("_versions/.tmp") {
778            // This is a temporary manifest file.
779            //
780            // If the file is old (or the user has verified there are no writes in progress) then
781            // it must be leftover from a failed tx.
782            if maybe_in_progress {
783                return Ok(None);
784            } else {
785                return Ok(cleanup_file(
786                    path,
787                    CleanupFileKind::TemporaryManifest,
788                    true,
789                    size_bytes,
790                ));
791            }
792        }
793        if relative_path.as_ref().starts_with("_indices") {
794            // Indices are referenced by UUID so we need to examine the UUID
795            // portion of the path.
796            if let Some(uuid) = relative_path.parts().nth(1) {
797                if inspection
798                    .referenced_files
799                    .index_uuids
800                    .contains(uuid.as_ref())
801                {
802                    return Ok(None);
803                } else if !maybe_in_progress {
804                    return Ok(cleanup_file(path, CleanupFileKind::Index, true, size_bytes));
805                } else if inspection
806                    .verified_files
807                    .index_uuids
808                    .contains(uuid.as_ref())
809                {
810                    return Ok(cleanup_file(
811                        path,
812                        CleanupFileKind::Index,
813                        false,
814                        size_bytes,
815                    ));
816                }
817            } else {
818                return Ok(None);
819            }
820        }
821        match path.extension() {
822            Some("lance") => {
823                if relative_path.as_ref().starts_with("data") {
824                    if inspection
825                        .referenced_files
826                        .data_paths
827                        .contains(&relative_path)
828                    {
829                        Ok(None)
830                    } else if !maybe_in_progress {
831                        Ok(cleanup_file(path, CleanupFileKind::Data, true, size_bytes))
832                    } else if inspection
833                        .verified_files
834                        .data_paths
835                        .contains(&relative_path)
836                    {
837                        Ok(cleanup_file(path, CleanupFileKind::Data, false, size_bytes))
838                    } else {
839                        Ok(None)
840                    }
841                } else {
842                    // If a .lance file isn't in the data directory we err on the side of leaving it alone
843                    Ok(None)
844                }
845            }
846            Some("blob") => {
847                // Blob v2 sidecar files are keyed by the data file stem:
848                //   data/{data_file_key}/{obfuscated_blob_id:032b}.blob
849                //
850                // These files are not referenced directly by the manifest.  Instead, treat them
851                // as referenced if their parent data file is referenced.
852                if !relative_path.as_ref().starts_with("data") {
853                    debug!(
854                        path = relative_path.as_ref(),
855                        "Will not garbage collect blob file because it does not follow convention"
856                    );
857                    return Ok(None);
858                }
859
860                let mut parts = relative_path.parts();
861                let data_dir = parts.next();
862                let data_file_key = parts.next();
863                let blob_file = parts.next();
864                // Be conservative: only handle the expected 3-part layout.
865                if !matches!(data_dir, Some(dir) if dir.as_ref() == "data")
866                    || data_file_key.is_none()
867                    || blob_file.is_none()
868                {
869                    debug!(
870                        path = relative_path.as_ref(),
871                        "Will not garbage collect blob file because it does not follow convention"
872                    );
873                    return Ok(None);
874                }
875                if parts.next().is_some() {
876                    debug!(
877                        path = relative_path.as_ref(),
878                        "Will not garbage collect blob file because it does not follow convention"
879                    );
880                    return Ok(None);
881                }
882
883                let data_file_key = data_file_key.expect("checked is_some");
884                let Ok(parent_data_path) =
885                    Path::parse(format!("data/{}.lance", data_file_key.as_ref()))
886                else {
887                    debug!(
888                        path = relative_path.as_ref(),
889                        derived_parent = format!("data/{}.lance", data_file_key.as_ref()),
890                        "Will not garbage collect blob file because derived parent data file path is invalid"
891                    );
892                    return Ok(None);
893                };
894
895                if inspection
896                    .referenced_files
897                    .data_paths
898                    .contains(&parent_data_path)
899                {
900                    Ok(None)
901                } else if !maybe_in_progress {
902                    Ok(cleanup_file(path, CleanupFileKind::Data, true, size_bytes))
903                } else if inspection
904                    .verified_files
905                    .data_paths
906                    .contains(&parent_data_path)
907                {
908                    Ok(cleanup_file(path, CleanupFileKind::Data, false, size_bytes))
909                } else {
910                    Ok(None)
911                }
912            }
913            Some("manifest") => {
914                // We already scanned the manifest files
915                Ok(None)
916            }
917            Some("arrow") | Some("bin") => {
918                if relative_path.as_ref().starts_with("_deletions") {
919                    if inspection
920                        .referenced_files
921                        .delete_paths
922                        .contains(&relative_path)
923                    {
924                        Ok(None)
925                    } else if !maybe_in_progress {
926                        Ok(cleanup_file(
927                            path,
928                            CleanupFileKind::Deletion,
929                            true,
930                            size_bytes,
931                        ))
932                    } else if inspection
933                        .verified_files
934                        .delete_paths
935                        .contains(&relative_path)
936                    {
937                        Ok(cleanup_file(
938                            path,
939                            CleanupFileKind::Deletion,
940                            false,
941                            size_bytes,
942                        ))
943                    } else {
944                        Ok(None)
945                    }
946                } else {
947                    Ok(None)
948                }
949            }
950            Some("txn") => {
951                if relative_path.as_ref().starts_with(TRANSACTIONS_DIR) {
952                    if inspection
953                        .referenced_files
954                        .tx_paths
955                        .contains(&relative_path)
956                    {
957                        Ok(None)
958                    } else if !maybe_in_progress
959                        || inspection.verified_files.tx_paths.contains(&relative_path)
960                    {
961                        let unverified =
962                            !inspection.verified_files.tx_paths.contains(&relative_path);
963                        Ok(cleanup_file(
964                            path,
965                            CleanupFileKind::Transaction,
966                            unverified,
967                            size_bytes,
968                        ))
969                    } else {
970                        Ok(None)
971                    }
972                } else {
973                    Ok(None)
974                }
975            }
976            _ => Ok(None),
977        }
978    }
979
980    async fn find_referenced_branches(&self) -> Result<Vec<(String, u64)>> {
981        let current_branch_id = self.dataset.branch_identifier().await?;
982        let all_branches = self.dataset.branches().list().await?;
983        let children = current_branch_id.collect_referenced_versions(&all_branches);
984
985        // Use a concurrent set to identify branches eligible for cleanup.
986        // The filter below preserves the original (branch_name, version) tuples.
987        let referenced_branches: DashSet<String> = DashSet::new();
988        let tasks: Vec<_> = children
989            .iter()
990            .map(|(branch_name, referenced_version)| {
991                let dataset = &self.dataset;
992                let policy = &self.policy;
993                let referenced_branches = &referenced_branches;
994
995                async move {
996                    let manifest_location = dataset
997                        .commit_handler
998                        .resolve_version_location(
999                            &dataset.base,
1000                            *referenced_version,
1001                            &dataset.object_store.inner,
1002                        )
1003                        .await?;
1004
1005                    let manifest = read_manifest(
1006                        &dataset.object_store,
1007                        &manifest_location.path,
1008                        manifest_location.size,
1009                    )
1010                    .await;
1011
1012                    if let Ok(manifest) = manifest
1013                        && policy.should_clean(&manifest)
1014                    {
1015                        referenced_branches.insert(branch_name.clone());
1016                    }
1017                    Ok::<(), Error>(())
1018                }
1019            })
1020            .collect();
1021
1022        try_join_all(tasks).await?;
1023
1024        // Filter children to only include branches that should be cleaned.
1025        // The DashSet contains branch names found eligible during concurrent scan.
1026        let referenced_branches = children
1027            .iter()
1028            .filter(|(branch_name, _)| referenced_branches.contains(branch_name))
1029            .cloned()
1030            .collect();
1031        Ok(referenced_branches)
1032    }
1033
1034    async fn clean_referenced_branches(
1035        &self,
1036        referenced_branches: &[(String, u64)],
1037    ) -> Result<CleanupRunResult> {
1038        let final_result = Mutex::new(CleanupRunResult::default());
1039
1040        // Group branches by their lineage identifier (BranchIdentifier).
1041        // Branches with the same identifier share a lineage and must be cleaned sequentially
1042        // to preserve cleanup order. Different lineages can be cleaned concurrently.
1043        let mut branches_chains = HashMap::new();
1044        for (branch, id) in referenced_branches {
1045            branches_chains
1046                .entry(*id)
1047                .or_insert_with(Vec::new)
1048                .push(branch.clone());
1049        }
1050        let action = self.action;
1051        let candidate_file_limit = self.action.candidate_file_limit();
1052        let tasks: Vec<_> = branches_chains
1053            .values()
1054            .map(|branch_chain| {
1055                let final_result = &final_result;
1056                async move {
1057                    for branch in branch_chain {
1058                        let branch_dataset = self
1059                            .dataset
1060                            .checkout_version((branch.as_str(), None))
1061                            .await?;
1062                        let ignored_manifests =
1063                            final_result.lock().unwrap().removed_manifests.clone();
1064                        if let Some(result) = cleanup_cascade_branch_run(
1065                            &branch_dataset,
1066                            branch_dataset.manifest.as_ref(),
1067                            action,
1068                            ignored_manifests,
1069                        )
1070                        .await?
1071                        {
1072                            final_result
1073                                .lock()
1074                                .unwrap()
1075                                .merge(result, candidate_file_limit);
1076                        }
1077                    }
1078                    Ok::<(), Error>(())
1079                }
1080            })
1081            .collect();
1082        try_join_all(tasks).await?;
1083        Ok(final_result.into_inner().unwrap())
1084    }
1085
1086    // Retain manifests containing files referenced by descendant branches.
1087    // This protects parent branch files that are still needed by child branches.
1088    async fn retain_branch_lineage_files(
1089        &self,
1090        inspection: CleanupInspection,
1091        referenced_branches: &[(String, u64)],
1092        removed_branch_manifests: &HashSet<Path>,
1093    ) -> Result<CleanupInspection> {
1094        let inspection = Mutex::new(inspection);
1095        for (branch, root_version_number) in referenced_branches {
1096            // Use find_branch to get the branch path directly without checkout.
1097            // This avoids creating a dataset instance and prevents manifest deletion
1098            // during the retain operation.
1099            let branch_location = self.dataset.branch_location().find_branch(Some(branch))?;
1100            self.dataset
1101                .commit_handler
1102                .list_manifest_locations(&branch_location.path, &self.dataset.object_store, false)
1103                .try_filter(|location| {
1104                    future::ready(!removed_branch_manifests.contains(&location.path))
1105                })
1106                .try_for_each_concurrent(self.dataset.object_store.io_parallelism(), |location| {
1107                    self.process_branch_referenced_manifests(
1108                        location,
1109                        *root_version_number,
1110                        &inspection,
1111                    )
1112                })
1113                .await?;
1114        }
1115        Ok(inspection.into_inner().unwrap())
1116    }
1117
1118    async fn process_branch_referenced_manifests(
1119        &self,
1120        location: ManifestLocation,
1121        referenced_version: u64,
1122        inspection: &Mutex<CleanupInspection>,
1123    ) -> Result<()> {
1124        let manifest =
1125            read_manifest(&self.dataset.object_store, &location.path, location.size).await?;
1126        let indexes =
1127            read_manifest_indexes(&self.dataset.object_store, &location, &manifest).await?;
1128        let mut inspection = inspection.lock().unwrap();
1129        let mut is_referenced = false;
1130
1131        for fragment in manifest.fragments.iter() {
1132            for file in fragment.files.iter() {
1133                if let Some(base_id) = file.base_id {
1134                    let base_path = manifest.base_paths.get(&base_id);
1135                    if let Some(base_path) = base_path
1136                        && base_path.path == self.dataset.uri
1137                    {
1138                        let full_data_path =
1139                            self.dataset.data_dir().clone().join(file.path.as_str());
1140                        let relative_data_path = remove_prefix(&full_data_path, &self.dataset.base);
1141                        inspection
1142                            .verified_files
1143                            .data_paths
1144                            .remove(&relative_data_path);
1145                        inspection
1146                            .referenced_files
1147                            .data_paths
1148                            .insert(relative_data_path);
1149                        is_referenced = true;
1150                    }
1151                }
1152            }
1153            if let Some(del_file) = fragment.deletion_file.as_ref()
1154                && let Some(base_id) = del_file.base_id
1155            {
1156                let base_path = manifest.base_paths.get(&base_id);
1157                if let Some(base_path) = base_path {
1158                    let deletion_path = fragment.deletion_file.as_ref().map(|deletion_file| {
1159                        deletion_file_path(&self.dataset.base, fragment.id, deletion_file)
1160                    });
1161                    if base_path.path == self.dataset.uri {
1162                        if let Some(deletion_path) = deletion_path {
1163                            let relative_del_path =
1164                                remove_prefix(&deletion_path, &self.dataset.base);
1165                            inspection
1166                                .verified_files
1167                                .delete_paths
1168                                .remove(&relative_del_path);
1169                            inspection
1170                                .referenced_files
1171                                .delete_paths
1172                                .insert(relative_del_path);
1173                        }
1174                        is_referenced = true;
1175                    }
1176                }
1177            }
1178        }
1179        for index in indexes {
1180            if let Some(base_id) = index.base_id {
1181                let base_path = manifest.base_paths.get(&base_id);
1182                if let Some(base_path) = base_path
1183                    && base_path.path == self.dataset.uri
1184                {
1185                    let uuid_str = index.uuid.to_string();
1186                    inspection.verified_files.index_uuids.remove(&uuid_str);
1187                    inspection.referenced_files.index_uuids.insert(uuid_str);
1188                    is_referenced = true;
1189                }
1190            }
1191        }
1192        if is_referenced {
1193            inspection
1194                .old_manifests
1195                .retain(|_path, version_number| *version_number != referenced_version);
1196        }
1197
1198        Ok(())
1199    }
1200}
1201
1202fn calculate_duration(scheme: String, rate: u64) -> Duration {
1203    let batch_size = if scheme.to_lowercase().contains("s3") {
1204        S3_DELETE_STREAM_BATCH_SIZE
1205    } else if scheme.to_lowercase().contains("az") {
1206        AZURE_DELETE_STREAM_BATCH_SIZE
1207    } else {
1208        1
1209    };
1210    let effective_rate = rate.max(1);
1211    let path_rate = effective_rate * batch_size;
1212    info!(
1213        "delete_rate_limit enabled: limit {} delete requests/sec",
1214        effective_rate
1215    );
1216    // convert user given op/s to the rate of issuing paths
1217    let duration_ns = 1_000_000_000u64.div_ceil(path_rate).max(1);
1218    Duration::from_nanos(duration_ns)
1219}
1220
1221#[derive(Clone, Debug)]
1222pub struct CleanupPolicy {
1223    /// If not none, cleanup all versions before the specified timestamp.
1224    pub before_timestamp: Option<DateTime<Utc>>,
1225    /// If not none, cleanup all versions before the specified version.
1226    pub before_version: Option<u64>,
1227    /// If true, delete unverified data files even if they are recent
1228    pub delete_unverified: bool,
1229    /// If true, return an Error if a tagged version is old
1230    pub error_if_tagged_old_versions: bool,
1231    /// If clean the referenced branches
1232    pub clean_referenced_branches: bool,
1233    /// Maximum number of delete requests per second. If None, no rate limiting is applied.
1234    ///
1235    /// Use this to avoid hitting S3 (or other object store) request rate limits during cleanup.
1236    /// On stores with bulk delete, each request can include multiple paths.
1237    /// For example, `Some(100)` limits deletions to 100 delete requests per second.
1238    pub delete_rate_limit: Option<u64>,
1239}
1240
1241impl CleanupPolicy {
1242    pub fn should_clean(&self, manifest: &Manifest) -> bool {
1243        let mut should_clean = true;
1244        if let Some(before_timestamp) = self.before_timestamp {
1245            should_clean &= manifest.timestamp() < before_timestamp;
1246        }
1247        if let Some(before_version) = self.before_version {
1248            should_clean &= manifest.version < before_version;
1249        }
1250        should_clean
1251    }
1252}
1253
1254impl Default for CleanupPolicy {
1255    fn default() -> Self {
1256        Self {
1257            before_timestamp: None,
1258            before_version: None,
1259            delete_unverified: false,
1260            error_if_tagged_old_versions: true,
1261            clean_referenced_branches: false,
1262            delete_rate_limit: None,
1263        }
1264    }
1265}
1266
1267#[derive(Default)]
1268pub struct CleanupPolicyBuilder {
1269    policy: CleanupPolicy,
1270}
1271
1272impl CleanupPolicyBuilder {
1273    /// If auto clean referenced branches.
1274    pub fn clean_referenced_branches(mut self, clean_referenced_branches: bool) -> Self {
1275        self.policy.clean_referenced_branches = clean_referenced_branches;
1276        self
1277    }
1278
1279    /// Cleanup all versions before the specified timestamp.
1280    pub fn before_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
1281        self.policy.before_timestamp = Some(timestamp);
1282        self
1283    }
1284
1285    /// Cleanup all versions except the last `n` versions of the dataset.
1286    pub async fn retain_n_versions(mut self, dataset: &Dataset, n: usize) -> Result<Self> {
1287        let versions = dataset.versions().await?;
1288        self.policy.before_version = if versions.len() <= n {
1289            Some(versions[0].version)
1290        } else {
1291            Some(versions[versions.len() - n].version)
1292        };
1293
1294        Ok(self)
1295    }
1296
1297    /// Delete without verification.
1298    ///
1299    /// By default, files will only be deleted if they are not referenced and are not in
1300    /// progress(at least 7 days old). Setting delete_unverified to true will not verify whether the
1301    /// file is in progress.
1302    /// This config is dangerous, only set to true when you are sure there are no other in-progress
1303    /// dataset operations.
1304    pub fn delete_unverified(mut self, delete: bool) -> Self {
1305        self.policy.delete_unverified = delete;
1306        self
1307    }
1308
1309    /// If this argument True, an exception will be raised if any tagged versions match the
1310    /// parameters.
1311    pub fn error_if_tagged_old_versions(mut self, error: bool) -> Self {
1312        self.policy.error_if_tagged_old_versions = error;
1313        self
1314    }
1315
1316    /// Limit the number of delete requests per second during cleanup.
1317    ///
1318    /// By default (None), deletions run at full speed. Set this to a positive value to
1319    /// throttle deletions and avoid hitting object store request rate limits (e.g. S3 HTTP 503).
1320    /// On backends with bulk delete APIs, effective path throughput scales with batch size.
1321    ///
1322    /// # Errors
1323    ///
1324    /// Returns an error if `rate` is zero.
1325    pub fn delete_rate_limit(mut self, rate: u64) -> Result<Self> {
1326        if rate == 0 {
1327            return Err(Error::Cleanup {
1328                message: format!("delete_rate_limit must be greater than 0, got {}", rate),
1329            });
1330        }
1331        self.policy.delete_rate_limit = Some(rate);
1332        Ok(self)
1333    }
1334
1335    pub fn build(self) -> CleanupPolicy {
1336        self.policy
1337    }
1338}
1339
1340/// Deletes old versions of a dataset, removing files that are no longer
1341/// needed.
1342///
1343/// This function will remove old manifest files, data files, indexes,
1344/// delete files, and transaction files.
1345///
1346/// It will only remove files that are not referenced by any valid manifest.
1347///
1348/// The latest manifest is always considered valid and will not be removed
1349/// even if it satisfied the cleanup policy.
1350pub async fn cleanup_old_versions(
1351    dataset: &Dataset,
1352    policy: CleanupPolicy,
1353) -> Result<RemovalStats> {
1354    CleanupOperation::new(dataset, policy).execute().await
1355}
1356
1357/// If the dataset config has `lance.auto_cleanup` parameters set,
1358/// this function automatically calls `dataset.cleanup_old_versions`
1359/// every `lance.auto_cleanup.interval` versions. This function calls
1360/// `dataset.cleanup_old_versions` with `lance.auto_cleanup.older_than`
1361/// for `older_than` and `Some(false)` for both `delete_unverified` and
1362/// `error_if_tagged_old_versions`.
1363pub async fn auto_cleanup_hook(
1364    dataset: &Dataset,
1365    manifest: &Manifest,
1366) -> Result<Option<RemovalStats>> {
1367    let policy = build_cleanup_policy(dataset, manifest).await?;
1368    if let Some(policy) = policy {
1369        Ok(Some(dataset.cleanup_with_policy(policy).await?))
1370    } else {
1371        Ok(None)
1372    }
1373}
1374
1375/// This is trigger when a parent branch is cleaning and `clean_referenced_branches` is set as true
1376/// For cascade branches, some cleanup parameters need be overridden.
1377pub async fn cleanup_cascade_branch(
1378    dataset: &Dataset,
1379    manifest: &Manifest,
1380) -> Result<Option<RemovalStats>> {
1381    Ok(
1382        cleanup_cascade_branch_run(dataset, manifest, CleanupAction::Execute, HashSet::new())
1383            .await?
1384            .map(|result| result.stats),
1385    )
1386}
1387
1388async fn cleanup_cascade_branch_run(
1389    dataset: &Dataset,
1390    manifest: &Manifest,
1391    action: CleanupAction,
1392    ignored_manifests: HashSet<Path>,
1393) -> Result<Option<CleanupRunResult>> {
1394    let policy = build_cleanup_policy(dataset, manifest).await?;
1395    if let Some(mut policy) = policy {
1396        policy.clean_referenced_branches = false;
1397        policy.error_if_tagged_old_versions = false;
1398        if action.deletes_files() {
1399            info!(target: TRACE_DATASET_EVENTS, event=DATASET_CLEANING_EVENT, uri=&dataset.uri);
1400        }
1401        let cleanup = CleanupTask::new_with_ignored_manifests(
1402            dataset,
1403            policy,
1404            action,
1405            ignored_manifests,
1406            true,
1407            false,
1408        );
1409        Ok(Some(cleanup.run().await?))
1410    } else {
1411        Ok(None)
1412    }
1413}
1414
1415pub async fn build_cleanup_policy(
1416    dataset: &Dataset,
1417    manifest: &Manifest,
1418) -> Result<Option<CleanupPolicy>> {
1419    if let Some(interval) = manifest.config.get("lance.auto_cleanup.interval") {
1420        let interval: u64 = match interval.parse() {
1421            Ok(i) => i,
1422            Err(e) => {
1423                return Err(Error::Cleanup {
1424                    message: format!(
1425                        "Error encountered while parsing lance.auto_cleanup.interval as u64: {}",
1426                        e
1427                    ),
1428                });
1429            }
1430        };
1431
1432        if interval != 0 && !manifest.version.is_multiple_of(interval) {
1433            return Ok(None);
1434        }
1435    } else {
1436        return Ok(None);
1437    }
1438
1439    let mut builder = CleanupPolicyBuilder::default();
1440    if let Some(older_than) = manifest.config.get("lance.auto_cleanup.older_than") {
1441        let std_older_than = match parse_duration(older_than) {
1442            Ok(t) => t,
1443            Err(e) => {
1444                return Err(Error::Cleanup {
1445                    message: format!(
1446                        "Error encountered while parsing lance.auto_cleanup.older_than as std::time::Duration: {}",
1447                        e
1448                    ),
1449                });
1450            }
1451        };
1452        let timestamp = utc_now() - TimeDelta::from_std(std_older_than).unwrap_or(TimeDelta::MAX);
1453        builder = builder.before_timestamp(timestamp);
1454    }
1455    if let Some(retain_versions) = manifest.config.get("lance.auto_cleanup.retain_versions") {
1456        let retain_versions: usize = match retain_versions.parse() {
1457            Ok(n) => n,
1458            Err(e) => {
1459                return Err(Error::Cleanup {
1460                    message: format!(
1461                        "Error encountered while parsing lance.auto_cleanup.retain_versions as u64: {}",
1462                        e
1463                    ),
1464                });
1465            }
1466        };
1467        builder = builder.retain_n_versions(dataset, retain_versions).await?;
1468    }
1469    if let Some(referenced_branch) = manifest.config.get("lance.auto_cleanup.referenced_branch") {
1470        let clean_referenced: bool = match referenced_branch.parse() {
1471            Ok(b) => b,
1472            Err(e) => {
1473                return Err(Error::Cleanup {
1474                    message: format!(
1475                        "Error encountered while parsing lance.auto_cleanup.referenced_branch as bool: {}",
1476                        e
1477                    ),
1478                });
1479            }
1480        };
1481        // Map config to policy flag controlling whether referenced branches are cleaned
1482        builder = builder.clean_referenced_branches(clean_referenced);
1483    }
1484    if let Some(delete_rate_limit) = manifest.config.get("lance.auto_cleanup.delete_rate_limit") {
1485        let rate: u64 = match delete_rate_limit.parse() {
1486            Ok(r) => r,
1487            Err(e) => {
1488                return Err(Error::Cleanup {
1489                    message: format!(
1490                        "Error encountered while parsing lance.auto_cleanup.delete_rate_limit as u64: {}",
1491                        e
1492                    ),
1493                });
1494            }
1495        };
1496        builder = match builder.delete_rate_limit(rate) {
1497            Ok(b) => b,
1498            Err(e) => return Err(e),
1499        };
1500    }
1501
1502    Ok(Some(builder.build()))
1503}
1504
1505fn tagged_old_versions_cleanup_error(
1506    tags: &HashMap<String, TagContents>,
1507    tagged_old_versions: &HashSet<u64>,
1508) -> Error {
1509    let unreferenced_tags: HashMap<String, u64> = tags
1510        .iter()
1511        .filter_map(|(k, v)| {
1512            if tagged_old_versions.contains(&v.version) {
1513                Some((k.clone(), v.version))
1514            } else {
1515                None
1516            }
1517        })
1518        .collect();
1519
1520    Error::Cleanup {
1521        message: format!(
1522            "{} tagged version(s) have been marked for cleanup. Either set `error_if_tagged_old_versions=false` or delete the following tag(s) to enable cleanup: {:?}",
1523            unreferenced_tags.len(),
1524            unreferenced_tags
1525        ),
1526    }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531    use std::{
1532        collections::HashMap,
1533        sync::{Arc, Mutex},
1534    };
1535
1536    use super::*;
1537    use crate::blob::{BlobArrayBuilder, blob_field};
1538    use crate::index::DatasetIndexExt;
1539    use crate::{
1540        dataset::transaction::{Operation, Transaction},
1541        dataset::{AutoCleanupParams, ReadParams, WriteMode, WriteParams, builder::DatasetBuilder},
1542        index::vector::VectorIndexParams,
1543    };
1544    use all_asserts::{assert_gt, assert_lt};
1545    use arrow::compute;
1546    use arrow_array::{
1547        Int32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, UInt64Array,
1548    };
1549    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
1550    use datafusion::common::assert_contains;
1551    use lance_core::utils::tempfile::TempStrDir;
1552    use lance_core::utils::testing::{ProxyObjectStore, ProxyObjectStorePolicy};
1553    use lance_index::IndexType;
1554    use lance_io::object_store::{
1555        ObjectStore, ObjectStoreParams, ObjectStoreRegistry, WrappingObjectStore,
1556    };
1557    use lance_linalg::distance::MetricType;
1558    use lance_table::io::commit::RenameCommitHandler;
1559    use lance_testing::datagen::{BatchGenerator, IncrementingInt32, RandomVector, some_batch};
1560    use mock_instant::thread_local::MockClock;
1561    use uuid::Uuid;
1562
1563    #[derive(Debug)]
1564    struct MockObjectStore {
1565        policy: Arc<Mutex<ProxyObjectStorePolicy>>,
1566        last_modified_times: Arc<Mutex<HashMap<Path, DateTime<Utc>>>>,
1567    }
1568
1569    impl WrappingObjectStore for MockObjectStore {
1570        fn wrap(
1571            &self,
1572            _storage_prefix: &str,
1573            original: Arc<dyn object_store::ObjectStore>,
1574        ) -> Arc<dyn object_store::ObjectStore> {
1575            Arc::new(ProxyObjectStore::new(original, self.policy.clone()))
1576        }
1577    }
1578
1579    impl MockObjectStore {
1580        pub(crate) fn new() -> Self {
1581            let instance = Self {
1582                policy: Arc::new(Mutex::new(ProxyObjectStorePolicy::new())),
1583                last_modified_times: Arc::new(Mutex::new(HashMap::new())),
1584            };
1585            instance.add_timestamp_policy();
1586            instance
1587        }
1588
1589        fn add_timestamp_policy(&self) {
1590            let mut policy = self.policy.lock().unwrap();
1591            let times_map = self.last_modified_times.clone();
1592            policy.set_before_policy(
1593                "record_file_time",
1594                Arc::new(move |_, path| {
1595                    let mut times_map = times_map.lock().unwrap();
1596                    times_map.insert(path.clone(), utc_now());
1597                    Ok(())
1598                }),
1599            );
1600            let times_map = self.last_modified_times.clone();
1601            policy.set_obj_meta_policy(
1602                "add_recorded_file_time",
1603                Arc::new(move |_, meta| {
1604                    let mut meta = meta;
1605                    if let Some(recorded) = times_map.lock().unwrap().get(&meta.location) {
1606                        meta.last_modified = *recorded;
1607                    }
1608                    Ok(meta)
1609                }),
1610            );
1611        }
1612    }
1613
1614    #[derive(Debug, PartialEq, Clone, Copy)]
1615    struct FileCounts {
1616        num_data_files: usize,
1617        num_manifest_files: usize,
1618        num_index_files: usize,
1619        num_delete_files: usize,
1620        num_tx_files: usize,
1621        num_bytes: u64,
1622    }
1623
1624    struct MockDatasetFixture {
1625        // This is a temporary directory that will be deleted when the fixture
1626        // is dropped
1627        _tmpdir: TempStrDir,
1628        dataset_path: String,
1629        mock_store: Arc<MockObjectStore>,
1630    }
1631
1632    impl MockDatasetFixture {
1633        fn try_new() -> Result<Self> {
1634            let tmpdir = TempStrDir::default();
1635            let tmpdir_path = tmpdir.as_str();
1636            // Use file-object-store:// scheme so that writes go through the ObjectStore
1637            // wrapper chain (MockObjectStore) instead of the optimized local writer path.
1638            // The path must always start with "/" (three slashes after the scheme) so that
1639            // on Windows, a drive letter like "C:" isn't parsed as the URL authority.
1640            let path_prefix = if tmpdir_path.starts_with('/') {
1641                ""
1642            } else {
1643                "/"
1644            };
1645            let dataset_path = format!("file-object-store://{path_prefix}{tmpdir_path}/my_db");
1646            Ok(Self {
1647                _tmpdir: tmpdir,
1648                dataset_path,
1649                mock_store: Arc::new(MockObjectStore::new()),
1650            })
1651        }
1652
1653        fn os_params(&self) -> ObjectStoreParams {
1654            ObjectStoreParams {
1655                object_store_wrapper: Some(self.mock_store.clone()),
1656                ..Default::default()
1657            }
1658        }
1659
1660        async fn write_data_impl(
1661            &self,
1662            data: impl RecordBatchReader + Send + 'static,
1663            mode: WriteMode,
1664        ) -> Result<()> {
1665            Dataset::write(
1666                data,
1667                &self.dataset_path,
1668                Some(WriteParams {
1669                    store_params: Some(self.os_params()),
1670                    commit_handler: Some(Arc::new(RenameCommitHandler)),
1671                    mode,
1672                    ..Default::default()
1673                }),
1674            )
1675            .await?;
1676            Ok(())
1677        }
1678
1679        async fn write_some_data_impl(&self, mode: WriteMode) -> Result<()> {
1680            self.write_data_impl(some_batch(), mode).await?;
1681            Ok(())
1682        }
1683
1684        async fn create_some_data(&self) -> Result<()> {
1685            self.write_some_data_impl(WriteMode::Create).await
1686        }
1687
1688        // Auto-cleanup is disabled by default; this helper creates a dataset
1689        // with auto-cleanup enabled using the default interval/older_than.
1690        async fn create_some_data_with_auto_cleanup(&self) -> Result<()> {
1691            Dataset::write(
1692                some_batch(),
1693                &self.dataset_path,
1694                Some(WriteParams {
1695                    store_params: Some(self.os_params()),
1696                    commit_handler: Some(Arc::new(RenameCommitHandler)),
1697                    mode: WriteMode::Create,
1698                    auto_cleanup: Some(AutoCleanupParams::default()),
1699                    ..Default::default()
1700                }),
1701            )
1702            .await?;
1703            Ok(())
1704        }
1705
1706        async fn overwrite_some_data(&self) -> Result<()> {
1707            self.write_some_data_impl(WriteMode::Overwrite).await
1708        }
1709
1710        async fn append_some_data(&self) -> Result<()> {
1711            self.write_some_data_impl(WriteMode::Append).await
1712        }
1713
1714        async fn create_with_data(
1715            &self,
1716            data: impl RecordBatchReader + Send + 'static,
1717        ) -> Result<()> {
1718            self.write_data_impl(data, WriteMode::Create).await
1719        }
1720
1721        async fn append_data(&self, data: impl RecordBatchReader + Send + 'static) -> Result<()> {
1722            self.write_data_impl(data, WriteMode::Append).await
1723        }
1724
1725        async fn overwrite_data(
1726            &self,
1727            data: impl RecordBatchReader + Send + 'static,
1728        ) -> Result<()> {
1729            self.write_data_impl(data, WriteMode::Overwrite).await
1730        }
1731
1732        async fn delete_data(&self, predicate: &str) -> Result<()> {
1733            let mut db = self.open().await?;
1734            db.delete(predicate).await?;
1735            Ok(())
1736        }
1737
1738        async fn create_some_index(&self) -> Result<()> {
1739            let mut db = self.open().await?;
1740            let index_params = Box::new(VectorIndexParams::ivf_pq(2, 8, 2, MetricType::L2, 5));
1741            db.create_index(
1742                &["indexable"],
1743                IndexType::Vector,
1744                Some("some_index".to_owned()),
1745                &*index_params,
1746                false,
1747            )
1748            .await?;
1749            Ok(())
1750        }
1751
1752        fn block_commits(&mut self) {
1753            let mut policy = self.mock_store.policy.lock().unwrap();
1754            policy.set_before_policy(
1755                "block_commit",
1756                Arc::new(|op, _| -> Result<()> {
1757                    if op.contains("copy") || op.contains("rename") {
1758                        return Err(Error::internal("Commit blocked".to_string()));
1759                    }
1760                    Ok(())
1761                }),
1762            );
1763        }
1764
1765        fn block_delete_manifest(&mut self) {
1766            let mut policy = self.mock_store.policy.lock().unwrap();
1767            policy.set_before_policy(
1768                "block_delete_manifest",
1769                Arc::new(|op, path| -> Result<()> {
1770                    if op.contains("delete") && path.extension() == Some("manifest") {
1771                        Err(Error::internal("Delete manifest blocked".to_string()))
1772                    } else {
1773                        Ok(())
1774                    }
1775                }),
1776            );
1777        }
1778
1779        fn unblock_delete_manifest(&mut self) {
1780            let mut policy = self.mock_store.policy.lock().unwrap();
1781            policy.clear_before_policy("block_delete_manifest");
1782        }
1783
1784        async fn run_cleanup(&self, before: DateTime<Utc>) -> Result<RemovalStats> {
1785            let db = self.open().await?;
1786            cleanup_old_versions(
1787                &db,
1788                CleanupPolicyBuilder::default()
1789                    .before_timestamp(before)
1790                    .build(),
1791            )
1792            .await
1793        }
1794
1795        async fn run_cleanup_with_policy(&self, policy: CleanupPolicy) -> Result<RemovalStats> {
1796            let db = self.open().await?;
1797            cleanup_old_versions(&db, policy).await
1798        }
1799
1800        async fn explain_cleanup_with_policy(
1801            &self,
1802            policy: CleanupPolicy,
1803        ) -> Result<CleanupExplanation> {
1804            let db = self.open().await?;
1805            db.cleanup(policy).explain().await
1806        }
1807
1808        async fn run_cleanup_with_override(
1809            &self,
1810            before: DateTime<Utc>,
1811            delete_unverified: Option<bool>,
1812            error_if_tagged_old_versions: Option<bool>,
1813        ) -> Result<RemovalStats> {
1814            let db = self.open().await?;
1815            cleanup_old_versions(
1816                &db,
1817                CleanupPolicyBuilder::default()
1818                    .before_timestamp(before)
1819                    .delete_unverified(delete_unverified.unwrap_or(false))
1820                    .error_if_tagged_old_versions(error_if_tagged_old_versions.unwrap_or(true))
1821                    .build(),
1822            )
1823            .await
1824        }
1825
1826        async fn open(&self) -> Result<Box<Dataset>> {
1827            let ds = DatasetBuilder::from_uri(&self.dataset_path)
1828                .with_read_params(ReadParams {
1829                    store_options: Some(self.os_params()),
1830                    ..Default::default()
1831                })
1832                .load()
1833                .await?;
1834            Ok(Box::new(ds))
1835        }
1836
1837        // Load the fixture's dataset.
1838        async fn load(&self) -> Result<Dataset> {
1839            self.load_dataset(&self.dataset_path).await
1840        }
1841
1842        // Helper to load a dataset with the mock store configured.
1843        async fn load_dataset(&self, uri: &str) -> Result<Dataset> {
1844            DatasetBuilder::from_uri(uri)
1845                .with_read_params(ReadParams {
1846                    store_options: Some(self.os_params()),
1847                    ..Default::default()
1848                })
1849                .load()
1850                .await
1851        }
1852
1853        // Helper to create a branch and load it as a Dataset.
1854        async fn create_branch_and_load<V: Into<crate::dataset::refs::Ref>>(
1855            &self,
1856            from_dataset: &mut Dataset,
1857            branch_name: &str,
1858            source_ref: V,
1859        ) -> Result<Dataset> {
1860            let branch_ds = from_dataset
1861                .create_branch(branch_name, source_ref, Some(self.os_params()))
1862                .await?;
1863            self.load_dataset(&branch_ds.uri).await
1864        }
1865
1866        async fn count_files(&self) -> Result<FileCounts> {
1867            let registry = Arc::new(ObjectStoreRegistry::default());
1868            let (os, path) =
1869                ObjectStore::from_uri_and_params(registry, &self.dataset_path, &self.os_params())
1870                    .await?;
1871            let mut file_stream = os.read_dir_all(&path, None);
1872            let mut file_count = FileCounts {
1873                num_data_files: 0,
1874                num_delete_files: 0,
1875                num_index_files: 0,
1876                num_manifest_files: 0,
1877                num_tx_files: 0,
1878                num_bytes: 0,
1879            };
1880            while let Some(path) = file_stream.try_next().await? {
1881                file_count.num_bytes += path.size;
1882                match path.location.extension() {
1883                    Some("lance") => file_count.num_data_files += 1,
1884                    Some("manifest") => file_count.num_manifest_files += 1,
1885                    Some("arrow") | Some("bin") => file_count.num_delete_files += 1,
1886                    Some("idx") => file_count.num_index_files += 1,
1887                    Some("txn") => file_count.num_tx_files += 1,
1888                    _ => (),
1889                }
1890            }
1891            Ok(file_count)
1892        }
1893
1894        async fn count_blob_files(&self) -> Result<usize> {
1895            let registry = Arc::new(ObjectStoreRegistry::default());
1896            let (os, path) =
1897                ObjectStore::from_uri_and_params(registry, &self.dataset_path, &self.os_params())
1898                    .await?;
1899            let mut file_stream = os.read_dir_all(&path, None);
1900            let mut blob_count = 0usize;
1901            while let Some(path) = file_stream.try_next().await? {
1902                if path.location.extension() == Some("blob") {
1903                    blob_count += 1;
1904                }
1905            }
1906            Ok(blob_count)
1907        }
1908
1909        async fn count_rows(&self) -> Result<usize> {
1910            let db = self.open().await?;
1911            let count = db.count_rows(None).await?;
1912            Ok(count)
1913        }
1914    }
1915
1916    async fn write_dummy_index_artifact(dataset: &Dataset, uuid: Uuid) -> Result<()> {
1917        let index_dir = dataset.indices_dir().join(uuid.to_string());
1918        dataset
1919            .object_store
1920            .as_ref()
1921            .put(&index_dir.clone().join("index.idx"), b"idx")
1922            .await?;
1923        dataset
1924            .object_store
1925            .as_ref()
1926            .put(&index_dir.clone().join("auxiliary.idx"), b"aux")
1927            .await?;
1928        Ok(())
1929    }
1930
1931    async fn write_dummy_staging_partial(
1932        dataset: &Dataset,
1933        staging_uuid: Uuid,
1934        shard_uuid: Uuid,
1935    ) -> Result<()> {
1936        let shard_dir = dataset
1937            .indices_dir()
1938            .join(staging_uuid.to_string())
1939            .join(format!("partial_{}", shard_uuid));
1940        dataset
1941            .object_store
1942            .as_ref()
1943            .put(&shard_dir.clone().join("index.idx"), b"idx")
1944            .await?;
1945        dataset
1946            .object_store
1947            .as_ref()
1948            .put(&shard_dir.clone().join("auxiliary.idx"), b"aux")
1949            .await?;
1950        Ok(())
1951    }
1952
1953    fn dummy_index_metadata(
1954        dataset: &Dataset,
1955        field_id: i32,
1956        uuid: Uuid,
1957        fragment_bitmap: impl IntoIterator<Item = u32>,
1958    ) -> IndexMetadata {
1959        IndexMetadata {
1960            uuid,
1961            name: "some_index".to_string(),
1962            fields: vec![field_id],
1963            dataset_version: dataset.version().version,
1964            fragment_bitmap: Some(fragment_bitmap.into_iter().collect()),
1965            index_details: None,
1966            index_version: IndexType::Vector.version(),
1967            created_at: None,
1968            base_id: None,
1969            files: None,
1970        }
1971    }
1972
1973    fn blob_v2_batch(blob_len: usize) -> Box<dyn RecordBatchReader + Send> {
1974        let mut blobs = BlobArrayBuilder::new(1);
1975        blobs.push_bytes(vec![0u8; blob_len]).unwrap();
1976
1977        let schema = Arc::new(ArrowSchema::new(vec![
1978            Field::new("id", DataType::Int32, false),
1979            blob_field("blob", true),
1980        ]));
1981
1982        let batch = RecordBatch::try_new(
1983            schema.clone(),
1984            vec![Arc::new(Int32Array::from(vec![1])), blobs.finish().unwrap()],
1985        )
1986        .unwrap();
1987
1988        Box::new(RecordBatchIterator::new(
1989            vec![Ok(batch)].into_iter(),
1990            schema,
1991        ))
1992    }
1993
1994    #[tokio::test]
1995    async fn cleanup_unreferenced_data_files() {
1996        // We should clean up data files that are only referenced
1997        // by old versions.  This can happen, for example, due to
1998        // an overwrite
1999        let fixture = MockDatasetFixture::try_new().unwrap();
2000        fixture.create_some_data().await.unwrap();
2001        fixture.overwrite_some_data().await.unwrap();
2002
2003        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2004
2005        let before_count = fixture.count_files().await.unwrap();
2006
2007        let removed = fixture
2008            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2009            .await
2010            .unwrap();
2011
2012        let after_count = fixture.count_files().await.unwrap();
2013        assert_eq!(removed.old_versions, 1);
2014        assert_eq!(removed.data_files_removed, 1);
2015        assert_eq!(
2016            removed.bytes_removed,
2017            before_count.num_bytes - after_count.num_bytes
2018        );
2019
2020        // There should be one less data file
2021        assert_lt!(after_count.num_data_files, before_count.num_data_files);
2022        // And one less manifest file
2023        assert_lt!(
2024            after_count.num_manifest_files,
2025            before_count.num_manifest_files
2026        );
2027        assert_lt!(after_count.num_tx_files, before_count.num_tx_files);
2028
2029        assert_gt!(after_count.num_manifest_files, 0);
2030        assert_gt!(after_count.num_data_files, 0);
2031        // We should keep referenced tx files
2032        assert_gt!(after_count.num_tx_files, 0);
2033    }
2034
2035    #[tokio::test]
2036    async fn explain_cleanup_does_not_delete_files() {
2037        let fixture = MockDatasetFixture::try_new().unwrap();
2038        fixture.create_some_data().await.unwrap();
2039        MockClock::set_system_time(TimeDelta::try_seconds(1).unwrap().to_std().unwrap());
2040        fixture.overwrite_some_data().await.unwrap();
2041
2042        let before_count = fixture.count_files().await.unwrap();
2043        let policy = CleanupPolicyBuilder::default()
2044            .before_timestamp(utc_now())
2045            .build();
2046
2047        let explanation = fixture
2048            .explain_cleanup_with_policy(policy.clone())
2049            .await
2050            .unwrap();
2051        let after_preview_count = fixture.count_files().await.unwrap();
2052
2053        // Files are not actually removed when explaining cleanup.
2054        assert_eq!(before_count, after_preview_count);
2055        assert_eq!(explanation.read_version, 2);
2056        assert_eq!(explanation.stats.old_versions, 1);
2057        assert_eq!(explanation.stats.data_files_removed, 1);
2058        assert_eq!(explanation.stats.transaction_files_removed, 1);
2059        assert_gt!(explanation.stats.bytes_removed, 0);
2060        assert!(!explanation.candidate_files.is_empty());
2061        assert!(!explanation.candidate_files_truncated);
2062
2063        // Running cleanup with the same policy should remove the same files the
2064        // explanation reported for this unchanged dataset.
2065        let removed = fixture.run_cleanup_with_policy(policy).await.unwrap();
2066        let after_cleanup_count = fixture.count_files().await.unwrap();
2067
2068        assert_eq!(
2069            removed.bytes_removed,
2070            before_count.num_bytes - after_cleanup_count.num_bytes
2071        );
2072        assert_eq!(removed.old_versions, explanation.stats.old_versions);
2073        assert_eq!(
2074            removed.data_files_removed,
2075            explanation.stats.data_files_removed
2076        );
2077        assert_eq!(removed.bytes_removed, explanation.stats.bytes_removed);
2078    }
2079
2080    #[tokio::test]
2081    async fn cleanup_blob_v2_sidecar_files() {
2082        let fixture = MockDatasetFixture::try_new().unwrap();
2083
2084        // First version: write a packed blob (sidecar .blob file).
2085        Dataset::write(
2086            blob_v2_batch(100 * 1024),
2087            &fixture.dataset_path,
2088            Some(WriteParams {
2089                store_params: Some(fixture.os_params()),
2090                commit_handler: Some(Arc::new(RenameCommitHandler)),
2091                mode: WriteMode::Create,
2092                data_storage_version: Some(lance_file::version::LanceFileVersion::V2_2),
2093                ..Default::default()
2094            }),
2095        )
2096        .await
2097        .unwrap();
2098        assert_gt!(fixture.count_blob_files().await.unwrap(), 0);
2099
2100        // Second version: overwrite with an inline blob (no sidecar).
2101        Dataset::write(
2102            blob_v2_batch(1024),
2103            &fixture.dataset_path,
2104            Some(WriteParams {
2105                store_params: Some(fixture.os_params()),
2106                commit_handler: Some(Arc::new(RenameCommitHandler)),
2107                mode: WriteMode::Overwrite,
2108                data_storage_version: Some(lance_file::version::LanceFileVersion::V2_2),
2109                ..Default::default()
2110            }),
2111        )
2112        .await
2113        .unwrap();
2114
2115        // Advance time so the unverified threshold doesn't interfere.
2116        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2117
2118        fixture
2119            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2120            .await
2121            .unwrap();
2122
2123        assert_eq!(fixture.count_blob_files().await.unwrap(), 0);
2124    }
2125
2126    #[tokio::test]
2127    async fn cleanup_recent_blob_v2_sidecar_files_when_verified() {
2128        let fixture = MockDatasetFixture::try_new().unwrap();
2129
2130        Dataset::write(
2131            blob_v2_batch(100 * 1024),
2132            &fixture.dataset_path,
2133            Some(WriteParams {
2134                store_params: Some(fixture.os_params()),
2135                commit_handler: Some(Arc::new(RenameCommitHandler)),
2136                mode: WriteMode::Create,
2137                data_storage_version: Some(lance_file::version::LanceFileVersion::V2_2),
2138                ..Default::default()
2139            }),
2140        )
2141        .await
2142        .unwrap();
2143
2144        Dataset::write(
2145            blob_v2_batch(1024),
2146            &fixture.dataset_path,
2147            Some(WriteParams {
2148                store_params: Some(fixture.os_params()),
2149                commit_handler: Some(Arc::new(RenameCommitHandler)),
2150                mode: WriteMode::Overwrite,
2151                data_storage_version: Some(lance_file::version::LanceFileVersion::V2_2),
2152                ..Default::default()
2153            }),
2154        )
2155        .await
2156        .unwrap();
2157
2158        // Old version is verified (referenced by an old manifest) even though the files are
2159        // recent; cleanup should remove them without waiting 7 days.
2160        fixture
2161            .run_cleanup(utc_now() + TimeDelta::seconds(1))
2162            .await
2163            .unwrap();
2164
2165        assert_eq!(fixture.count_blob_files().await.unwrap(), 0);
2166    }
2167
2168    #[tokio::test]
2169    async fn do_not_cleanup_newer_data() {
2170        // Even though an old manifest is removed the data files should
2171        // remain if they are still referenced by newer manifests
2172        let fixture = MockDatasetFixture::try_new().unwrap();
2173        fixture.create_some_data().await.unwrap();
2174        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2175        fixture.append_some_data().await.unwrap();
2176        fixture.append_some_data().await.unwrap();
2177
2178        let before_count = fixture.count_files().await.unwrap();
2179
2180        // 3 versions
2181        assert_eq!(before_count.num_data_files, 3);
2182        assert_eq!(before_count.num_manifest_files, 3);
2183
2184        let before = utc_now() - TimeDelta::try_days(7).unwrap();
2185        let removed = fixture.run_cleanup(before).await.unwrap();
2186
2187        let after_count = fixture.count_files().await.unwrap();
2188
2189        assert_eq!(removed.old_versions, 1);
2190        assert_eq!(
2191            removed.bytes_removed,
2192            before_count.num_bytes - after_count.num_bytes
2193        );
2194
2195        // The data files should all remain since they are referenced by
2196        // the latest version
2197        assert_eq!(after_count.num_data_files, 3);
2198        // Only the oldest manifest file should be removed
2199        assert_eq!(after_count.num_manifest_files, 2);
2200        assert_eq!(after_count.num_tx_files, 2);
2201    }
2202
2203    #[tokio::test]
2204    async fn cleanup_error_when_tagged_old_versions() {
2205        // We should not clean up old versions that are tagged.
2206        // This tests when `error_if_tagged_old_version=true`.
2207        // When `true`, no files should be cleaned and a `Error::CleanupError`
2208        // should be returned.
2209        let fixture = MockDatasetFixture::try_new().unwrap();
2210        fixture.create_some_data().await.unwrap();
2211        fixture.overwrite_some_data().await.unwrap();
2212        fixture.overwrite_some_data().await.unwrap();
2213
2214        let dataset = *(fixture.open().await.unwrap());
2215
2216        dataset.tags().create("old-tag", 1).await.unwrap();
2217        dataset.tags().create("another-old-tag", 2).await.unwrap();
2218
2219        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2220
2221        let removed = fixture
2222            .run_cleanup(utc_now() - TimeDelta::try_days(20).unwrap())
2223            .await
2224            .unwrap();
2225        assert_eq!(removed.old_versions, 0);
2226
2227        let mut cleanup_error = fixture
2228            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2229            .await
2230            .err()
2231            .unwrap();
2232        assert_contains!(
2233            cleanup_error.to_string(),
2234            "Cleanup error: 2 tagged version(s) have been marked for cleanup. Either set `error_if_tagged_old_versions=false` or delete the following tag(s) to enable cleanup:"
2235        );
2236
2237        dataset.tags().delete("old-tag").await.unwrap();
2238
2239        cleanup_error = fixture
2240            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2241            .await
2242            .err()
2243            .unwrap();
2244        assert_contains!(
2245            cleanup_error.to_string(),
2246            "Cleanup error: 1 tagged version(s) have been marked for cleanup. Either set `error_if_tagged_old_versions=false` or delete the following tag(s) to enable cleanup:"
2247        );
2248
2249        dataset.tags().delete("another-old-tag").await.unwrap();
2250
2251        let removed = fixture
2252            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2253            .await
2254            .unwrap();
2255        assert_eq!(removed.old_versions, 2);
2256    }
2257
2258    #[tokio::test]
2259    async fn cleanup_around_tagged_old_versions() {
2260        // We should not clean up old versions that are tagged.
2261        // This tests when `error_if_tagged_old_version=false`.
2262        // When `false`, old versions should be cleaned up except
2263        // latest and those that are tagged.
2264        let fixture = MockDatasetFixture::try_new().unwrap();
2265        fixture.create_some_data().await.unwrap();
2266        fixture.overwrite_some_data().await.unwrap();
2267        fixture.overwrite_some_data().await.unwrap();
2268
2269        let dataset = *(fixture.open().await.unwrap());
2270
2271        dataset.tags().create("old-tag", 1).await.unwrap();
2272        dataset.tags().create("another-old-tag", 2).await.unwrap();
2273        dataset.tags().create("tag-latest", 3).await.unwrap();
2274
2275        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2276
2277        let mut removed = fixture
2278            .run_cleanup_with_override(
2279                utc_now() - TimeDelta::try_days(8).unwrap(),
2280                None,
2281                Some(false),
2282            )
2283            .await
2284            .unwrap();
2285
2286        assert_eq!(removed.old_versions, 0);
2287
2288        dataset.tags().delete("old-tag").await.unwrap();
2289
2290        removed = fixture
2291            .run_cleanup_with_override(
2292                utc_now() - TimeDelta::try_days(8).unwrap(),
2293                None,
2294                Some(false),
2295            )
2296            .await
2297            .unwrap();
2298        assert_eq!(removed.old_versions, 1);
2299
2300        dataset.tags().delete("another-old-tag").await.unwrap();
2301
2302        removed = fixture
2303            .run_cleanup_with_override(
2304                utc_now() - TimeDelta::try_days(8).unwrap(),
2305                None,
2306                Some(false),
2307            )
2308            .await
2309            .unwrap();
2310
2311        assert_eq!(removed.old_versions, 1);
2312    }
2313
2314    // Helper function to check that the number of files is correct.
2315    async fn check_num_files(fixture: &MockDatasetFixture, num_expected_files: usize) {
2316        let file_count = fixture.count_files().await.unwrap();
2317
2318        assert_eq!(file_count.num_data_files, num_expected_files);
2319        assert_eq!(file_count.num_manifest_files, num_expected_files);
2320        assert_eq!(file_count.num_tx_files, num_expected_files);
2321    }
2322
2323    #[tokio::test]
2324    async fn auto_cleanup_old_versions() {
2325        // Every n commits, all versions older than T should be deleted.
2326        //
2327        // We first make many commits and check that all of the versions are
2328        // present. We then wait until the "older_than" period has elapsed and
2329        // make many more commits. We check that, without explicitly calling
2330        // `fixture.run_cleanup`, the old versions are automatically cleaned
2331        // up and only the new ones remain. File counts are made after every
2332        // commit.
2333        let fixture = MockDatasetFixture::try_new().unwrap();
2334
2335        fixture.create_some_data_with_auto_cleanup().await.unwrap();
2336
2337        let dataset_config = &fixture.open().await.unwrap().manifest.config;
2338        let cleanup_interval: usize = dataset_config
2339            .get("lance.auto_cleanup.interval")
2340            .unwrap()
2341            .parse()
2342            .unwrap();
2343
2344        let cleanup_older_than = TimeDelta::from_std(
2345            parse_duration(dataset_config.get("lance.auto_cleanup.older_than").unwrap()).unwrap(),
2346        )
2347        .unwrap();
2348
2349        // First, write many files within the "older_than" window. Check that
2350        // no files are automatically cleaned up.
2351        for num_expected_files in 2..2 * cleanup_interval {
2352            fixture.overwrite_some_data().await.unwrap();
2353            check_num_files(&fixture, num_expected_files).await;
2354        }
2355
2356        // Fast forward so we are outside of the "older_than" window.
2357        MockClock::set_system_time(
2358            (cleanup_older_than + TimeDelta::minutes(1))
2359                .to_std()
2360                .unwrap(),
2361        );
2362
2363        // Write more files and check that those outside of the "older_than" window
2364        // are cleaned up.
2365        for num_expected_files in 2..cleanup_interval {
2366            fixture.overwrite_some_data().await.unwrap();
2367            check_num_files(&fixture, num_expected_files).await;
2368        }
2369
2370        // Overwrite auto cleanup params with custom values
2371        let mut dataset = *(fixture.open().await.unwrap());
2372        let mut new_autoclean_params = HashMap::new();
2373
2374        let new_cleanup_older_than_str = "1month 2days 2h 42min 6sec";
2375        let new_cleanup_older_than =
2376            TimeDelta::from_std(parse_duration(new_cleanup_older_than_str).unwrap()).unwrap();
2377        new_autoclean_params.insert(
2378            "lance.auto_cleanup.older_than".to_string(),
2379            new_cleanup_older_than_str.to_string(),
2380        );
2381
2382        let new_cleanup_interval = 5;
2383        new_autoclean_params.insert(
2384            "lance.auto_cleanup.interval".to_string(),
2385            new_cleanup_interval.to_string(),
2386        );
2387
2388        // Convert to new API format
2389        let config_updates = new_autoclean_params
2390            .into_iter()
2391            .map(|(k, v)| (k, Some(v)))
2392            .collect::<HashMap<String, Option<String>>>();
2393        dataset.update_config(config_updates).await.unwrap();
2394
2395        // Fast forward so we are outside of the new "older_than" window.
2396        MockClock::set_system_time(
2397            (cleanup_older_than + new_cleanup_older_than + TimeDelta::minutes(2))
2398                .to_std()
2399                .unwrap(),
2400        );
2401
2402        fixture.overwrite_some_data().await.unwrap();
2403
2404        for num_expected_files in 2..new_cleanup_interval {
2405            fixture.overwrite_some_data().await.unwrap();
2406            check_num_files(&fixture, num_expected_files).await;
2407        }
2408    }
2409
2410    #[tokio::test]
2411    async fn test_auto_cleanup_interval_zero() {
2412        let fixture = MockDatasetFixture::try_new().unwrap();
2413
2414        fixture.create_some_data().await.unwrap();
2415        fixture.overwrite_some_data().await.unwrap();
2416        fixture.overwrite_some_data().await.unwrap();
2417        check_num_files(&fixture, 3).await;
2418
2419        let mut dataset = fixture.open().await.unwrap();
2420        let mut config_updates = HashMap::new();
2421        config_updates.insert(
2422            "lance.auto_cleanup.interval".to_string(),
2423            Some("0".to_string()),
2424        );
2425        config_updates.insert(
2426            "lance.auto_cleanup.retain_versions".to_string(),
2427            Some("1".to_string()),
2428        );
2429        dataset
2430            .update_config(config_updates)
2431            .replace()
2432            .await
2433            .unwrap();
2434
2435        fixture.overwrite_some_data().await.unwrap();
2436        fixture.overwrite_some_data().await.unwrap();
2437        // The last version before the new commit is retained, means we have 2 versions to assert
2438        check_num_files(&fixture, 2).await;
2439
2440        fixture.overwrite_some_data().await.unwrap();
2441        check_num_files(&fixture, 2).await;
2442    }
2443
2444    #[tokio::test]
2445    async fn cleanup_recent_verified_files() {
2446        let fixture = MockDatasetFixture::try_new().unwrap();
2447        fixture.create_some_data().await.unwrap();
2448        MockClock::set_system_time(TimeDelta::try_seconds(1).unwrap().to_std().unwrap());
2449        fixture.overwrite_some_data().await.unwrap();
2450
2451        let before_count = fixture.count_files().await.unwrap();
2452        assert_eq!(before_count.num_data_files, 2);
2453        assert_eq!(before_count.num_manifest_files, 2);
2454
2455        // Not much time has passed but we can still delete the old manifest
2456        // and the related data files
2457        let before = utc_now();
2458        let removed = fixture.run_cleanup(before).await.unwrap();
2459
2460        let after_count = fixture.count_files().await.unwrap();
2461        assert_eq!(removed.old_versions, 1);
2462        assert_eq!(
2463            removed.bytes_removed,
2464            before_count.num_bytes - after_count.num_bytes
2465        );
2466
2467        assert_eq!(after_count.num_data_files, 1);
2468        assert_eq!(after_count.num_manifest_files, 1);
2469    }
2470
2471    #[tokio::test]
2472    async fn dont_cleanup_recent_unverified_files() {
2473        for (override_opt, old_files) in [
2474            (Some(false), false), // User provides false, files are new - do not delete
2475            (Some(true), false),  // User provides true, files are new - delete
2476            (None, true),         // Default, files are old - delete
2477            (None, false),        // Default, files are new - do not delete
2478        ] {
2479            MockClock::set_system_time(std::time::Duration::from_secs(0));
2480            let mut fixture = MockDatasetFixture::try_new().unwrap();
2481            fixture.create_some_data().await.unwrap();
2482            fixture.block_commits();
2483            assert!(fixture.append_some_data().await.is_err());
2484
2485            let age = if old_files {
2486                TimeDelta::try_days(UNVERIFIED_THRESHOLD_DAYS + 1).unwrap()
2487            } else {
2488                TimeDelta::try_days(UNVERIFIED_THRESHOLD_DAYS - 1).unwrap()
2489            };
2490            MockClock::set_system_time(age.to_std().unwrap());
2491
2492            // The above created some unreferenced data files but, since they
2493            // are not referenced in any manifest, and 7 days has not passed, we
2494            // cannot safely delete them unless the user overrides the safety check
2495
2496            let before_count = fixture.count_files().await.unwrap();
2497            assert_eq!(before_count.num_data_files, 2);
2498            assert_eq!(before_count.num_manifest_files, 1);
2499
2500            let before = utc_now();
2501            let removed = fixture
2502                .run_cleanup_with_override(before, override_opt, None)
2503                .await
2504                .unwrap();
2505
2506            let should_delete = override_opt.unwrap_or(false) || old_files;
2507
2508            let after_count = fixture.count_files().await.unwrap();
2509            assert_eq!(removed.old_versions, 0);
2510            assert_eq!(
2511                removed.bytes_removed,
2512                before_count.num_bytes - after_count.num_bytes
2513            );
2514
2515            if should_delete {
2516                assert_gt!(removed.bytes_removed, 0);
2517            } else {
2518                assert_eq!(removed.bytes_removed, 0);
2519            }
2520        }
2521    }
2522
2523    #[tokio::test]
2524    async fn cleanup_old_index() {
2525        let fixture = MockDatasetFixture::try_new().unwrap();
2526        fixture.create_some_data().await.unwrap();
2527        fixture.create_some_index().await.unwrap();
2528        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2529        fixture.overwrite_some_data().await.unwrap();
2530
2531        let before_count = fixture.count_files().await.unwrap();
2532        // we store 2 files (index and quantized storage) for each index
2533        assert_eq!(before_count.num_index_files, 2);
2534        // Two user data files
2535        assert_eq!(before_count.num_data_files, 2);
2536        // Creating an index creates a new manifest so there are 3 total
2537        assert_eq!(before_count.num_manifest_files, 3);
2538
2539        let before = utc_now() - TimeDelta::try_days(8).unwrap();
2540        let removed = fixture.run_cleanup(before).await.unwrap();
2541
2542        let after_count = fixture.count_files().await.unwrap();
2543        assert_eq!(removed.old_versions, 2);
2544        assert_eq!(
2545            removed.bytes_removed,
2546            before_count.num_bytes - after_count.num_bytes
2547        );
2548
2549        assert_eq!(after_count.num_index_files, 0);
2550        assert_eq!(after_count.num_data_files, 1);
2551        assert_eq!(after_count.num_manifest_files, 1);
2552        assert_eq!(after_count.num_tx_files, 1);
2553    }
2554
2555    #[tokio::test]
2556    async fn clean_old_delete_files() {
2557        let fixture = MockDatasetFixture::try_new().unwrap();
2558        let mut data_gen = BatchGenerator::new().col(Box::new(
2559            IncrementingInt32::new().named("filter_me".to_owned()),
2560        ));
2561
2562        fixture.create_with_data(data_gen.batch(16)).await.unwrap();
2563        fixture.append_data(data_gen.batch(16)).await.unwrap();
2564        // This will keep some data from the appended file and should
2565        // completely remove the first file
2566        fixture.delete_data("filter_me < 20").await.unwrap();
2567        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2568        fixture.overwrite_data(data_gen.batch(16)).await.unwrap();
2569        // This will delete half of the last fragment
2570        fixture.delete_data("filter_me >= 40").await.unwrap();
2571
2572        let before_count = fixture.count_files().await.unwrap();
2573        assert_eq!(before_count.num_data_files, 3);
2574        assert_eq!(before_count.num_delete_files, 2);
2575        assert_eq!(before_count.num_manifest_files, 5);
2576
2577        let before = utc_now() - TimeDelta::try_days(8).unwrap();
2578        let removed = fixture.run_cleanup(before).await.unwrap();
2579
2580        let after_count = fixture.count_files().await.unwrap();
2581        assert_eq!(removed.old_versions, 3);
2582        assert_eq!(
2583            removed.bytes_removed,
2584            before_count.num_bytes - after_count.num_bytes
2585        );
2586
2587        assert_eq!(after_count.num_data_files, 1);
2588        assert_eq!(after_count.num_delete_files, 1);
2589        assert_eq!(after_count.num_manifest_files, 2);
2590        assert_eq!(after_count.num_tx_files, 2);
2591
2592        // Ensure we can still read the dataset
2593        let row_count_after = fixture.count_rows().await.unwrap();
2594        assert_eq!(row_count_after, 8);
2595    }
2596
2597    #[tokio::test]
2598    async fn cleanup_collects_removed_file_metrics() {
2599        let fixture = MockDatasetFixture::try_new().unwrap();
2600        let row_count = 512;
2601        let mut data_gen = BatchGenerator::new()
2602            .col(Box::new(
2603                IncrementingInt32::new().named("filter_me".to_owned()),
2604            ))
2605            .col(Box::new(RandomVector::new().named("indexable".to_owned())));
2606
2607        fixture
2608            .create_with_data(data_gen.batch(row_count))
2609            .await
2610            .unwrap();
2611        fixture
2612            .append_data(data_gen.batch(row_count))
2613            .await
2614            .unwrap();
2615        fixture.create_some_index().await.unwrap();
2616        fixture.delete_data("filter_me < 20").await.unwrap();
2617        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2618        fixture
2619            .overwrite_data(data_gen.batch(row_count))
2620            .await
2621            .unwrap();
2622        fixture.delete_data("filter_me >= 40").await.unwrap();
2623
2624        let before_count = fixture.count_files().await.unwrap();
2625        let removed = fixture
2626            .run_cleanup(utc_now() - TimeDelta::try_days(8).unwrap())
2627            .await
2628            .unwrap();
2629        let after_count = fixture.count_files().await.unwrap();
2630
2631        let data_files_removed = (before_count.num_data_files - after_count.num_data_files) as u64;
2632        let transaction_files_removed =
2633            (before_count.num_tx_files - after_count.num_tx_files) as u64;
2634        let index_files_removed =
2635            (before_count.num_index_files - after_count.num_index_files) as u64;
2636        let deletion_files_removed =
2637            (before_count.num_delete_files - after_count.num_delete_files) as u64;
2638
2639        assert_eq!(removed.data_files_removed, data_files_removed);
2640        assert_eq!(removed.transaction_files_removed, transaction_files_removed);
2641        assert_eq!(removed.index_files_removed, index_files_removed);
2642        assert_eq!(removed.deletion_files_removed, deletion_files_removed);
2643        assert_gt!(removed.data_files_removed, 0);
2644        assert_gt!(removed.transaction_files_removed, 0);
2645        assert_gt!(removed.index_files_removed, 0);
2646        assert_gt!(removed.deletion_files_removed, 0);
2647    }
2648
2649    #[tokio::test]
2650    async fn dont_clean_index_data_files() {
2651        // Indexes have .lance files in them that are not referenced
2652        // by any fragment.  We need to make sure the cleanup routine
2653        // doesn't over-zealously delete these
2654        let fixture = MockDatasetFixture::try_new().unwrap();
2655        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2656        fixture.create_some_data().await.unwrap();
2657        fixture.create_some_index().await.unwrap();
2658
2659        let before_count = fixture.count_files().await.unwrap();
2660        let before = utc_now() - TimeDelta::try_days(8).unwrap();
2661        let removed = fixture.run_cleanup(before).await.unwrap();
2662        assert_eq!(removed.old_versions, 0);
2663        assert_eq!(removed.bytes_removed, 0);
2664
2665        let after_count = fixture.count_files().await.unwrap();
2666
2667        assert_eq!(before_count, after_count);
2668    }
2669
2670    #[tokio::test]
2671    async fn cleanup_old_replaced_segment_keeps_still_referenced_segments() {
2672        let fixture = MockDatasetFixture::try_new().unwrap();
2673        fixture.create_some_data().await.unwrap();
2674
2675        let mut dataset = fixture.open().await.unwrap();
2676        let field_id = dataset.schema().field("indexable").unwrap().id;
2677
2678        let seg_a = Uuid::new_v4();
2679        let seg_b = Uuid::new_v4();
2680        write_dummy_index_artifact(&dataset, seg_a).await.unwrap();
2681        write_dummy_index_artifact(&dataset, seg_b).await.unwrap();
2682
2683        let index_a = dummy_index_metadata(&dataset, field_id, seg_a, [0_u32]);
2684        let index_b = dummy_index_metadata(&dataset, field_id, seg_b, [1_u32]);
2685        let initial_tx = Transaction::new(
2686            dataset.manifest.version,
2687            Operation::CreateIndex {
2688                new_indices: vec![index_a.clone(), index_b.clone()],
2689                removed_indices: vec![],
2690            },
2691            None,
2692        );
2693        dataset
2694            .apply_commit(initial_tx, &Default::default(), &Default::default())
2695            .await
2696            .unwrap();
2697
2698        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2699
2700        let seg_c = Uuid::new_v4();
2701        write_dummy_index_artifact(&dataset, seg_c).await.unwrap();
2702        let index_c = dummy_index_metadata(&dataset, field_id, seg_c, [2_u32]);
2703        let replace_tx = Transaction::new(
2704            dataset.manifest.version,
2705            Operation::CreateIndex {
2706                new_indices: vec![index_c.clone()],
2707                removed_indices: vec![index_a.clone()],
2708            },
2709            None,
2710        );
2711        dataset
2712            .apply_commit(replace_tx, &Default::default(), &Default::default())
2713            .await
2714            .unwrap();
2715
2716        let removed = fixture
2717            .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2718            .await
2719            .unwrap();
2720
2721        assert_eq!(removed.index_files_removed, 2);
2722        assert!(
2723            !dataset
2724                .object_store
2725                .as_ref()
2726                .exists(
2727                    &dataset
2728                        .indices_dir()
2729                        .clone()
2730                        .join(seg_a.to_string())
2731                        .join("index.idx")
2732                )
2733                .await
2734                .unwrap()
2735        );
2736        assert!(
2737            dataset
2738                .object_store
2739                .as_ref()
2740                .exists(
2741                    &dataset
2742                        .indices_dir()
2743                        .clone()
2744                        .join(seg_b.to_string())
2745                        .join("index.idx")
2746                )
2747                .await
2748                .unwrap()
2749        );
2750        assert!(
2751            dataset
2752                .object_store
2753                .as_ref()
2754                .exists(
2755                    &dataset
2756                        .indices_dir()
2757                        .clone()
2758                        .join(seg_c.to_string())
2759                        .join("index.idx")
2760                )
2761                .await
2762                .unwrap()
2763        );
2764    }
2765
2766    #[tokio::test]
2767    async fn cleanup_old_uncommitted_index_artifacts() {
2768        let fixture = MockDatasetFixture::try_new().unwrap();
2769        fixture.create_some_data().await.unwrap();
2770
2771        let dataset = fixture.open().await.unwrap();
2772        let staging_uuid = Uuid::new_v4();
2773        let shard_uuid = Uuid::new_v4();
2774        let built_segment_uuid = Uuid::new_v4();
2775
2776        write_dummy_staging_partial(&dataset, staging_uuid, shard_uuid)
2777            .await
2778            .unwrap();
2779        write_dummy_index_artifact(&dataset, built_segment_uuid)
2780            .await
2781            .unwrap();
2782
2783        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2784
2785        let removed = fixture
2786            .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2787            .await
2788            .unwrap();
2789
2790        assert_eq!(removed.old_versions, 0);
2791        assert_eq!(removed.index_files_removed, 4);
2792        assert!(
2793            !dataset
2794                .object_store
2795                .as_ref()
2796                .exists(
2797                    &dataset
2798                        .indices_dir()
2799                        .clone()
2800                        .join(staging_uuid.to_string())
2801                        .join(format!("partial_{}", shard_uuid))
2802                        .join("index.idx"),
2803                )
2804                .await
2805                .unwrap()
2806        );
2807        assert!(
2808            !dataset
2809                .object_store
2810                .as_ref()
2811                .exists(
2812                    &dataset
2813                        .indices_dir()
2814                        .clone()
2815                        .join(built_segment_uuid.to_string())
2816                        .join("index.idx"),
2817                )
2818                .await
2819                .unwrap()
2820        );
2821    }
2822
2823    #[tokio::test]
2824    async fn cleanup_failed_commit_data_file() {
2825        // We should clean up data files that are written but the commit failed
2826        // for whatever reason
2827
2828        let mut fixture = MockDatasetFixture::try_new().unwrap();
2829        fixture.create_some_data().await.unwrap();
2830        fixture.block_commits();
2831        assert!(fixture.append_some_data().await.is_err());
2832        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2833
2834        let before_count = fixture.count_files().await.unwrap();
2835        // This append will fail since the commit is blocked but it should have
2836        // deposited a data file
2837        assert_eq!(before_count.num_data_files, 2);
2838        assert_eq!(before_count.num_manifest_files, 1);
2839        // Only 1 txn file: the failed commit's txn file was already cleaned up.
2840        assert_eq!(before_count.num_tx_files, 1);
2841
2842        // All of our manifests are newer than the threshold but temp files
2843        // should still be deleted.
2844        let removed = fixture
2845            .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2846            .await
2847            .unwrap();
2848
2849        let after_count = fixture.count_files().await.unwrap();
2850        assert_eq!(removed.old_versions, 0);
2851        assert_eq!(removed.data_files_removed, 1);
2852        assert_eq!(
2853            removed.bytes_removed,
2854            before_count.num_bytes - after_count.num_bytes
2855        );
2856
2857        assert_eq!(after_count.num_data_files, 1);
2858        assert_eq!(after_count.num_manifest_files, 1);
2859        assert_eq!(after_count.num_tx_files, 1);
2860    }
2861
2862    #[tokio::test]
2863    async fn dont_cleanup_in_progress_write() {
2864        // We should not cleanup data files newer than our threshold as they might
2865        // belong to in-progress writes
2866
2867        // For testing purposes we actually create these files with a failed write
2868        // but the cleanup routine has no way of detecting this.  They should look
2869        // just like an in-progress write.
2870        let mut fixture = MockDatasetFixture::try_new().unwrap();
2871        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2872        fixture.create_some_data().await.unwrap();
2873        fixture.block_commits();
2874        assert!(fixture.append_some_data().await.is_err());
2875
2876        let before_count = fixture.count_files().await.unwrap();
2877
2878        let removed = fixture
2879            .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2880            .await
2881            .unwrap();
2882
2883        assert_eq!(removed.old_versions, 0);
2884        assert_eq!(removed.bytes_removed, 0);
2885        assert_eq!(removed.data_files_removed, 0);
2886
2887        let after_count = fixture.count_files().await.unwrap();
2888        assert_eq!(before_count, after_count);
2889    }
2890
2891    #[tokio::test]
2892    async fn can_recover_delete_failure() {
2893        // We want to make sure that an I/O error during the cleanup process doesn't
2894        // prevent us from running cleanup again later.
2895        let mut fixture = MockDatasetFixture::try_new().unwrap();
2896        fixture.create_some_data().await.unwrap();
2897        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
2898        fixture.overwrite_some_data().await.unwrap();
2899
2900        // The delete operation should delete the first version and its
2901        // data file.  However, we will block the manifest file from getting
2902        // cleaned up by simulating an I/O error.
2903        fixture.block_delete_manifest();
2904
2905        let before_count = fixture.count_files().await.unwrap();
2906        assert_eq!(before_count.num_data_files, 2);
2907        assert_eq!(before_count.num_manifest_files, 2);
2908
2909        assert!(
2910            fixture
2911                .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2912                .await
2913                .is_err()
2914        );
2915
2916        // This test currently relies on us sending in manifest files after
2917        // data files.  Also, the delete process is run in parallel.  However,
2918        // it seems stable to stably delete the data file even though the manifest delete fails.
2919        // My guess is that it is not possible to interrupt a task in flight and so it still
2920        // has to finish the buffered tasks even if they are ignored.
2921        let mid_count = fixture.count_files().await.unwrap();
2922        assert_eq!(mid_count.num_data_files, 1);
2923        assert_eq!(mid_count.num_manifest_files, 2);
2924
2925        fixture.unblock_delete_manifest();
2926
2927        let removed = fixture
2928            .run_cleanup(utc_now() - TimeDelta::try_days(7).unwrap())
2929            .await
2930            .unwrap();
2931
2932        let after_count = fixture.count_files().await.unwrap();
2933        assert_eq!(removed.old_versions, 1);
2934        assert_eq!(
2935            removed.bytes_removed,
2936            mid_count.num_bytes - after_count.num_bytes
2937        );
2938
2939        assert_eq!(after_count.num_data_files, 1);
2940        assert_eq!(after_count.num_manifest_files, 1);
2941    }
2942
2943    #[tokio::test]
2944    async fn cleanup_and_retain_3_recent_versions() {
2945        let fixture = MockDatasetFixture::try_new().unwrap();
2946        fixture.create_some_data().await.unwrap();
2947        let mut time = 10i64;
2948        for _ in 0..4 {
2949            MockClock::set_system_time(TimeDelta::try_seconds(time).unwrap().to_std().unwrap());
2950            time += 10i64;
2951            fixture.overwrite_some_data().await.unwrap();
2952        }
2953
2954        let before_count = fixture.count_files().await.unwrap();
2955        assert_eq!(before_count.num_data_files, 5);
2956        assert_eq!(before_count.num_manifest_files, 5);
2957
2958        // Retain 3 recent versions
2959        let policy = CleanupPolicyBuilder::default()
2960            .retain_n_versions(&fixture.open().await.unwrap(), 3)
2961            .await
2962            .unwrap()
2963            .build();
2964        let removed = fixture.run_cleanup_with_policy(policy).await.unwrap();
2965
2966        let after_count = fixture.count_files().await.unwrap();
2967        assert_eq!(removed.old_versions, 2);
2968        assert_eq!(
2969            removed.bytes_removed,
2970            before_count.num_bytes - after_count.num_bytes
2971        );
2972
2973        assert_eq!(after_count.num_data_files, 3);
2974        assert_eq!(after_count.num_manifest_files, 3);
2975    }
2976
2977    #[tokio::test]
2978    async fn cleanup_before_ts_and_retain_n_recent_versions() {
2979        let fixture = MockDatasetFixture::try_new().unwrap();
2980        fixture.create_some_data().await.unwrap();
2981        for time in (1i64..).take(4) {
2982            MockClock::set_system_time(TimeDelta::try_days(time).unwrap().to_std().unwrap());
2983            fixture.overwrite_some_data().await.unwrap();
2984        }
2985
2986        let before_count = fixture.count_files().await.unwrap();
2987        assert_eq!(before_count.num_data_files, 5);
2988        assert_eq!(before_count.num_manifest_files, 5);
2989
2990        // Retain 3 recent versions before timestamp now - 6days
2991        let policy = CleanupPolicyBuilder::default()
2992            .before_timestamp(utc_now() - TimeDelta::try_days(6).unwrap())
2993            .retain_n_versions(&fixture.open().await.unwrap(), 3)
2994            .await
2995            .unwrap()
2996            .build();
2997        let removed = fixture.run_cleanup_with_policy(policy).await.unwrap();
2998        assert_eq!(removed.old_versions, 0);
2999
3000        // Retain 10 recent versions before timestamp now
3001        let policy = CleanupPolicyBuilder::default()
3002            .before_timestamp(utc_now())
3003            .retain_n_versions(&fixture.open().await.unwrap(), 10)
3004            .await
3005            .unwrap()
3006            .build();
3007        let removed = fixture.run_cleanup_with_policy(policy).await.unwrap();
3008        assert_eq!(removed.old_versions, 0);
3009
3010        // Retain 3 recent versions before timestamp now - 1days
3011        let policy = CleanupPolicyBuilder::default()
3012            .before_timestamp(utc_now() - TimeDelta::try_days(2).unwrap())
3013            .retain_n_versions(&fixture.open().await.unwrap(), 3)
3014            .await
3015            .unwrap()
3016            .build();
3017        let removed = fixture.run_cleanup_with_policy(policy).await.unwrap();
3018
3019        let after_count = fixture.count_files().await.unwrap();
3020        assert_eq!(removed.old_versions, 2);
3021        assert_eq!(
3022            removed.bytes_removed,
3023            before_count.num_bytes - after_count.num_bytes
3024        );
3025        assert_eq!(after_count.num_data_files, 3);
3026        assert_eq!(after_count.num_manifest_files, 3);
3027    }
3028
3029    #[tokio::test]
3030    async fn cleanup_preserves_unmanaged_dirs_and_files() {
3031        // Ensure cleanup does not delete unmanaged directories/files under the dataset root
3032        // Uses MockDatasetFixture and run_cleanup_with_override to match other tests' style
3033        let fixture = MockDatasetFixture::try_new().unwrap();
3034        fixture.create_some_data().await.unwrap();
3035
3036        let registry = Arc::new(ObjectStoreRegistry::default());
3037        let (os, base) =
3038            ObjectStore::from_uri_and_params(registry, &fixture.dataset_path, &fixture.os_params())
3039                .await
3040                .unwrap();
3041
3042        // Create unmanaged directories/files under dataset root
3043        let img = base.clone().join("images").join("clip.mp4");
3044        let misc = base.clone().join("misc").join("notes.txt");
3045        let branch_file = base.clone().join("tree").join("branchA").join("data.bin");
3046        os.put(&img, b"video").await.unwrap();
3047        os.put(&misc, b"notes").await.unwrap();
3048        os.put(&branch_file, b"branch").await.unwrap();
3049
3050        // Create a temporary manifest file that should be cleaned
3051        let tmp_manifest = base.clone().join("_versions").join(".tmp").join("orphan");
3052        os.put(&tmp_manifest, b"tmp").await.unwrap();
3053        // Delete the _transactions directory so that we can test that if not_found err will be swallowed
3054        os.remove_dir_all(base.clone().join(TRANSACTIONS_DIR))
3055            .await
3056            .unwrap();
3057
3058        fixture
3059            .run_cleanup_with_override(utc_now(), Some(true), Some(false))
3060            .await
3061            .unwrap();
3062
3063        // Temp manifest file is managed by Lance and should be removed
3064        assert!(!os.exists(&tmp_manifest).await.unwrap());
3065        // Unrelated files must remain
3066        assert!(os.exists(&img).await.unwrap());
3067        assert!(os.exists(&misc).await.unwrap());
3068        assert!(os.exists(&branch_file).await.unwrap());
3069    }
3070
3071    // Lineage overview with annotated base versions:
3072    // - branch1 is created from main@v1
3073    // - branch4 is created from main@v2 (after main receives a second write)
3074    // - dev/branch2 is created from branch1@latest
3075    // - feature/nathan/branch3 is created from dev/branch2@latest
3076    //
3077    // ASCII lineage with versions:
3078    //    main:v1 ──▶ branch1:v1 ──▶ dev/branch2:v2 ──▶ feature/nathan/branch3:v3
3079    //        │
3080    //    (main:v2) ──▶ branch4:v2
3081    //
3082    // Cleanup policy focus (unless explicitly overridden in a test):
3083    // - retain_n_versions = 1: keep the latest manifest per branch
3084    // - referenced branches: when enabled, protect parent files referenced by descendants
3085    // - file counts reported per branch:
3086    //   manifest: number of manifest files under _versions
3087    //   data: .lance files under data directory
3088    //   tx: .txn files count under _transactions
3089    //   delete: deletion files count under _deletions
3090    //   index: index files count under _indices
3091    //
3092    // Note: branch2 is stored as "dev/branch2"; comments may refer to it as branch2 for brevity.
3093    // Important: auto_cleanup_hook uses policy derived from manifest config; it does not flip
3094    // clean_referenced_branches unless tests call cleanup_old_versions with a custom policy.
3095    struct LineageSetup {
3096        main: BranchDatasetFixture,
3097        branch1: BranchDatasetFixture,
3098        branch2: BranchDatasetFixture,
3099        branch3: BranchDatasetFixture,
3100        branch4: BranchDatasetFixture,
3101    }
3102
3103    impl LineageSetup {
3104        /// Assert all branches and main are unchanged since last refresh.
3105        pub async fn assert_all_unchanged(&mut self) {
3106            self.main.assert_not_changed().await.unwrap();
3107            self.branch1.assert_not_changed().await.unwrap();
3108            self.branch2.assert_not_changed().await.unwrap();
3109            self.branch3.assert_not_changed().await.unwrap();
3110            self.branch4.assert_not_changed().await.unwrap();
3111        }
3112
3113        /// Assert specified branches are unchanged.
3114        pub async fn assert_unchanged(&mut self, branches: &[&str]) {
3115            for &b in branches {
3116                match b {
3117                    "main" => self.main.assert_not_changed().await.unwrap(),
3118                    "branch1" => self.branch1.assert_not_changed().await.unwrap(),
3119                    "branch2" => self.branch2.assert_not_changed().await.unwrap(),
3120                    "branch3" => self.branch3.assert_not_changed().await.unwrap(),
3121                    "branch4" => self.branch4.assert_not_changed().await.unwrap(),
3122                    _ => panic!("unknown branch: {}", b),
3123                }
3124            }
3125        }
3126
3127        pub async fn enable_auto_cleanup(&mut self) -> Result<()> {
3128            let updates = [
3129                ("lance.auto_cleanup.interval", "1"),
3130                ("lance.auto_cleanup.retain_versions", "1"),
3131                ("lance.auto_cleanup.referenced_branch", "true"),
3132            ];
3133            self.main.dataset.update_config(updates).await?;
3134            self.branch1.dataset.update_config(updates).await?;
3135            self.branch2.dataset.update_config(updates).await?;
3136            self.branch3.dataset.update_config(updates).await?;
3137            self.branch4.dataset.update_config(updates).await?;
3138            self.main.refresh().await?;
3139            self.branch1.refresh().await?;
3140            self.branch2.refresh().await?;
3141            self.branch3.refresh().await?;
3142            self.branch4.refresh().await?;
3143            Ok(())
3144        }
3145
3146        pub async fn disable_auto_cleanup(&mut self) -> Result<()> {
3147            let updates = [
3148                ("lance.auto_cleanup.interval", None),
3149                ("lance.auto_cleanup.retain_versions", None),
3150                ("lance.auto_cleanup.older_than", None),
3151            ];
3152            self.main.dataset.update_config(updates).await?;
3153            self.branch1.dataset.update_config(updates).await?;
3154            self.branch2.dataset.update_config(updates).await?;
3155            self.branch3.dataset.update_config(updates).await?;
3156            self.branch4.dataset.update_config(updates).await?;
3157            self.main.refresh().await?;
3158            self.branch1.refresh().await?;
3159            self.branch2.refresh().await?;
3160            self.branch3.refresh().await?;
3161            self.branch4.refresh().await?;
3162            Ok(())
3163        }
3164    }
3165
3166    // Build the lineage and configure per-branch auto-cleanup to retain latest version.
3167    async fn build_lineage_datasets() -> Result<LineageSetup> {
3168        let fixture = Arc::new(MockDatasetFixture::try_new()?);
3169
3170        MockClock::set_system_time(TimeDelta::try_seconds(1).unwrap().to_std().unwrap());
3171
3172        // Create main (initial write) with id and text columns for inverted index
3173        use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, StringArray};
3174        use arrow_schema::{DataType, Field};
3175        let ids = Int32Array::from_iter_values(0..50i32);
3176        let texts = StringArray::from_iter_values((0..50i32).map(|i| format!("text_{}", i)));
3177        let schema = Arc::new(arrow_schema::Schema::new(vec![
3178            Field::new("id", DataType::Int32, false),
3179            Field::new("text", DataType::Utf8, false),
3180        ]));
3181        let batch =
3182            RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(texts)]).unwrap();
3183        let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema);
3184
3185        Dataset::write(
3186            reader,
3187            &fixture.dataset_path,
3188            Some(WriteParams {
3189                mode: WriteMode::Create,
3190                store_params: Some(fixture.os_params()),
3191                ..Default::default()
3192            }),
3193        )
3194        .await?;
3195        let mut main = BranchDatasetFixture::new(fixture.clone(), fixture.load().await?);
3196        // Initial index creation and refresh counts
3197        main.create_text_index().await?;
3198        main.write_data().await?;
3199
3200        // Create branch1 from main@v1, then do an initial append + deterministic delete
3201        let mut branch1 = BranchDatasetFixture::new(
3202            fixture.clone(),
3203            fixture
3204                .create_branch_and_load(&mut main.dataset, "branch1", (None, None))
3205                .await?,
3206        );
3207        branch1.write_data().await?;
3208
3209        // Create branch2 from branch1@latest
3210        let mut branch2 = BranchDatasetFixture::new(
3211            fixture.clone(),
3212            fixture
3213                .create_branch_and_load(&mut branch1.dataset, "dev/branch2", ("branch1", None))
3214                .await?,
3215        );
3216        branch2.write_data().await?;
3217
3218        // Create branch3 from branch2@latest, initial append + delete
3219        let mut branch3 = BranchDatasetFixture::new(
3220            fixture.clone(),
3221            fixture
3222                .create_branch_and_load(
3223                    &mut branch2.dataset,
3224                    "feature/nathan/branch3",
3225                    ("dev/branch2", None),
3226                )
3227                .await?,
3228        );
3229        branch3.write_data().await?;
3230
3231        // Create branch4 from a new version in main
3232        main.write_data().await?;
3233        let mut branch4 = BranchDatasetFixture::new(
3234            fixture.clone(),
3235            fixture
3236                .create_branch_and_load(&mut main.dataset, "branch4", (None, None))
3237                .await?,
3238        );
3239        branch4.write_data().await?;
3240
3241        let mut lineage = LineageSetup {
3242            main,
3243            branch1,
3244            branch2,
3245            branch3,
3246            branch4,
3247        };
3248
3249        lineage.disable_auto_cleanup().await?;
3250        Ok(lineage)
3251    }
3252
3253    // BranchDatasetFixture combines dataset with branch-specific state and file counting.
3254    // It provides:
3255    // - Shared fixture for temporary directory and mock store
3256    // - Dataset holding for stateful operations (checkout, write, etc.)
3257    // - File counting for cleanup verification
3258    struct BranchDatasetFixture {
3259        fixture: Arc<MockDatasetFixture>,
3260        dataset: Dataset,
3261        counts: FileCounts,
3262    }
3263
3264    impl BranchDatasetFixture {
3265        fn new(fixture: Arc<MockDatasetFixture>, dataset: Dataset) -> Self {
3266            Self {
3267                fixture,
3268                dataset,
3269                counts: FileCounts {
3270                    num_manifest_files: 0,
3271                    num_data_files: 0,
3272                    num_tx_files: 0,
3273                    num_delete_files: 0,
3274                    num_index_files: 0,
3275                    num_bytes: 0,
3276                },
3277            }
3278        }
3279
3280        // Create a full-text index (Inverted) on the "text" column once.
3281        // We only create this on main during dataset creation. Branches inherit the index configuration.
3282        async fn create_text_index(&mut self) -> Result<()> {
3283            use crate::index::DatasetIndexExt;
3284            use lance_index::IndexType;
3285            use lance_index::scalar::InvertedIndexParams;
3286            let params = InvertedIndexParams::default();
3287            self.dataset
3288                .create_index(&["text"], IndexType::Inverted, None, &params, true)
3289                .await?;
3290            Ok(())
3291        }
3292
3293        // Append a batch, then read exactly one row and delete that row; finally optimize indices.
3294        async fn append_delete_and_optimize_index(&mut self) -> Result<()> {
3295            // Append a small batch with id and text columns
3296            self.write_batch(5).await?;
3297            // Delete the last row to create a deletion file
3298            self.delete_last_row().await?;
3299            // Optimize indices after write and delete
3300            use lance_index::optimize::OptimizeOptions;
3301            self.dataset
3302                .optimize_indices(&OptimizeOptions::merge(1))
3303                .await?;
3304            Ok(())
3305        }
3306
3307        // Append a batch with id and text columns.
3308        async fn write_batch(&mut self, rows: i32) -> Result<()> {
3309            use crate::dataset::WriteParams;
3310            use arrow_array::{Int32Array, RecordBatch, RecordBatchIterator, StringArray};
3311            use arrow_schema::{DataType, Field};
3312
3313            let ids = Int32Array::from_iter_values(0..rows);
3314            let texts = StringArray::from_iter_values((0..rows).map(|i| format!("text_{}", i)));
3315            let schema = Arc::new(arrow_schema::Schema::new(vec![
3316                Field::new("id", DataType::Int32, false),
3317                Field::new("text", DataType::Utf8, false),
3318            ]));
3319            let batch =
3320                RecordBatch::try_new(schema.clone(), vec![Arc::new(ids), Arc::new(texts)]).unwrap();
3321            let reader = RecordBatchIterator::new(vec![batch].into_iter().map(Ok), schema);
3322
3323            self.dataset
3324                .append(
3325                    reader,
3326                    Some(WriteParams {
3327                        mode: WriteMode::Append,
3328                        store_params: Some(self.fixture.os_params()),
3329                        ..Default::default()
3330                    }),
3331                )
3332                .await?;
3333            self.dataset.checkout_latest().await?;
3334            Ok(())
3335        }
3336
3337        // Delete the last row to generate a deletion file.
3338        async fn delete_last_row(&mut self) -> Result<()> {
3339            let batch = self.dataset.scan().with_row_id().try_into_batch().await?;
3340            if batch.num_rows() > 0 {
3341                let row_id_col = batch.column_by_name(lance_core::ROW_ID).unwrap();
3342                let uint64_array = row_id_col.as_any().downcast_ref::<UInt64Array>().unwrap();
3343                let max_row_id = compute::max(uint64_array).unwrap_or(0);
3344                self.dataset
3345                    .delete(&format!("_rowid = {}", max_row_id))
3346                    .await?;
3347            }
3348            Ok(())
3349        }
3350
3351        // Update counters by listing authoritative branch directories instead of reading the latest manifest.
3352        async fn refresh(&mut self) -> Result<()> {
3353            use futures::TryStreamExt;
3354            let branch_path = self.dataset.base.clone();
3355
3356            // Count files in a directory, filtering by optional extension(s).
3357            async fn count_dir(
3358                os: &ObjectStore,
3359                dir: &Path,
3360                exts: Option<&[&str]>,
3361            ) -> Result<usize> {
3362                let mut count = 0usize;
3363                let mut s = os.read_dir_all(dir, None);
3364                while let Some(meta) = s.try_next().await? {
3365                    match exts {
3366                        Some(exts) => {
3367                            if let Some(e) = meta.location.extension()
3368                                && exts.contains(&e)
3369                            {
3370                                count += 1;
3371                            }
3372                        }
3373                        None => count += 1,
3374                    }
3375                }
3376                Ok(count)
3377            }
3378
3379            let manifest_dir = branch_path.clone().join("_versions");
3380            self.counts.num_manifest_files = count_dir(
3381                &self.dataset.object_store,
3382                &manifest_dir,
3383                Some(&["manifest"]),
3384            )
3385            .await
3386            .unwrap_or(0);
3387
3388            // Transactions: count files under _transactions (extension .txn)
3389            let txn_dir = branch_path.clone().join("_transactions");
3390            self.counts.num_tx_files =
3391                count_dir(&self.dataset.object_store, &txn_dir, Some(&["txn"]))
3392                    .await
3393                    .unwrap_or(0);
3394
3395            // Indices: count files under _indices
3396            let idx_dir = branch_path.clone().join(crate::dataset::INDICES_DIR);
3397            self.counts.num_index_files = count_dir(&self.dataset.object_store, &idx_dir, None)
3398                .await
3399                .unwrap_or(0);
3400
3401            // Deletions: count files under _deletions (extensions .arrow / .bin)
3402            let del_dir = branch_path.clone().join("_deletions");
3403            self.counts.num_delete_files = count_dir(
3404                &self.dataset.object_store,
3405                &del_dir,
3406                Some(&["arrow", "bin"]),
3407            )
3408            .await
3409            .unwrap_or(0);
3410
3411            // Data files: count .lance files under data/
3412            let data_dir = branch_path.clone().join(crate::dataset::DATA_DIR);
3413            self.counts.num_data_files =
3414                count_dir(&self.dataset.object_store, &data_dir, Some(&["lance"]))
3415                    .await
3416                    .unwrap_or(0);
3417
3418            Ok(())
3419        }
3420
3421        async fn count_data(&self) -> Result<usize> {
3422            use futures::TryStreamExt;
3423            let mut count = 0usize;
3424            let mut s = self.dataset.scan().try_into_stream().await?;
3425            while let Some(_batch) = s.try_next().await? {
3426                count += 1;
3427            }
3428            Ok(count)
3429        }
3430
3431        // Strict equality assertion for all counters.
3432        async fn assert_not_changed(&mut self) -> Result<()> {
3433            let pre_counts = self.counts;
3434            let pre_data_count = self.count_data().await?;
3435
3436            self.refresh().await?;
3437            assert_eq!(
3438                self.counts.num_manifest_files,
3439                pre_counts.num_manifest_files
3440            );
3441            assert_eq!(self.counts.num_data_files, pre_counts.num_data_files);
3442            assert_eq!(self.counts.num_tx_files, pre_counts.num_tx_files);
3443            assert_eq!(self.counts.num_delete_files, pre_counts.num_delete_files);
3444            assert_eq!(self.counts.num_index_files, pre_counts.num_index_files);
3445            assert_eq!(self.count_data().await?, pre_data_count);
3446            Ok(())
3447        }
3448
3449        // Append, delete top row, and optimize indices.
3450        async fn write_data(&mut self) -> Result<()> {
3451            self.append_delete_and_optimize_index().await?;
3452            self.refresh().await
3453        }
3454
3455        // Compact files for a given branch and optimize indices to stabilize index files.
3456        async fn compact(&mut self) -> Result<()> {
3457            use crate::dataset::optimize::{CompactionOptions, compact_files};
3458            compact_files(&mut self.dataset, CompactionOptions::default(), None).await?;
3459            self.refresh().await
3460        }
3461
3462        async fn run_cleanup(&mut self) -> Result<RemovalStats> {
3463            let policy = CleanupPolicyBuilder::default()
3464                .error_if_tagged_old_versions(false)
3465                .retain_n_versions(&self.dataset, 1)
3466                .await?
3467                .build();
3468            self.run_cleanup_inner(policy).await
3469        }
3470
3471        async fn run_cleanup_with_referenced_branches(&mut self) -> Result<RemovalStats> {
3472            let policy = CleanupPolicyBuilder::default()
3473                .error_if_tagged_old_versions(false)
3474                .clean_referenced_branches(true)
3475                .retain_n_versions(&self.dataset, 1)
3476                .await?
3477                .build();
3478            self.run_cleanup_inner(policy).await
3479        }
3480
3481        async fn explain_cleanup_with_referenced_branches(&mut self) -> Result<CleanupExplanation> {
3482            let policy = CleanupPolicyBuilder::default()
3483                .error_if_tagged_old_versions(false)
3484                .clean_referenced_branches(true)
3485                .retain_n_versions(&self.dataset, 1)
3486                .await?
3487                .build();
3488            self.dataset.checkout_latest().await?;
3489            self.dataset.cleanup(policy).explain().await
3490        }
3491
3492        async fn run_cleanup_inner(&mut self, policy: CleanupPolicy) -> Result<RemovalStats> {
3493            let pre_count = self.count_data().await?;
3494            self.dataset.checkout_latest().await?;
3495            let stats = cleanup_old_versions(&self.dataset, policy).await;
3496            self.refresh().await?;
3497            // Assert data could be read again and did't change
3498            assert_eq!(self.count_data().await?, pre_count);
3499            stats
3500        }
3501    }
3502
3503    // ===================== Tests =====================
3504    #[tokio::test]
3505    async fn cleanup_lineage_branch1() {
3506        let mut setup = build_lineage_datasets().await.unwrap();
3507
3508        setup.branch1.write_data().await.unwrap();
3509        setup.branch1.run_cleanup().await.unwrap();
3510        // Branch2 and branch3 hold references from branch1:
3511        // - 1 manifest file
3512        // - 1 data file
3513        // - 1 deletion file
3514        // - 4 index files
3515        // The left is the counts for the latest version of appending
3516        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
3517        assert_eq!(setup.branch1.counts.num_data_files, 2);
3518        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3519        assert_eq!(setup.branch1.counts.num_delete_files, 2);
3520        assert_eq!(setup.branch1.counts.num_index_files, 14);
3521        setup.assert_all_unchanged().await;
3522
3523        setup.branch1.compact().await.unwrap();
3524        setup.branch1.run_cleanup().await.unwrap();
3525        // Branch2 and branch3 hold references from branch1:
3526        // - 1 manifest file
3527        // - 1 data file
3528        // - 1 deletion file
3529        // - 4 index files
3530        // The left (1, 1, 1, 0, 4) is the counts for the latest version of compaction
3531        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
3532        assert_eq!(setup.branch1.counts.num_data_files, 2);
3533        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3534        assert_eq!(setup.branch1.counts.num_delete_files, 1);
3535        assert_eq!(setup.branch1.counts.num_index_files, 14);
3536        setup.assert_all_unchanged().await;
3537
3538        // Now we clean the referenced files of branch1 by branch2 and branch3
3539        setup.branch2.compact().await.unwrap();
3540        setup.branch3.compact().await.unwrap();
3541        setup.branch3.run_cleanup().await.unwrap();
3542        setup.branch2.run_cleanup().await.unwrap();
3543        // Only the latest manifest is retained.
3544        // (1, 1, 1, 0, 4) is the counts for the latest version of compaction
3545        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
3546        assert_eq!(setup.branch2.counts.num_data_files, 1);
3547        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3548        assert_eq!(setup.branch2.counts.num_delete_files, 0);
3549        assert_eq!(setup.branch2.counts.num_index_files, 7);
3550        // Only the latest manifest is retained.
3551        // (1, 1, 1, 0, 4) is the counts for the latest version of compaction
3552        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3553        assert_eq!(setup.branch3.counts.num_data_files, 1);
3554        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3555        assert_eq!(setup.branch3.counts.num_delete_files, 0);
3556        assert_eq!(setup.branch3.counts.num_index_files, 7);
3557        setup.branch1.run_cleanup().await.unwrap();
3558
3559        // Only the latest manifest is retained.
3560        // (1, 1, 1, 0, 4) is the counts for the latest version of compaction
3561        assert_eq!(setup.branch1.counts.num_manifest_files, 1);
3562        assert_eq!(setup.branch1.counts.num_data_files, 1);
3563        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3564        assert_eq!(setup.branch1.counts.num_delete_files, 0);
3565        assert_eq!(setup.branch1.counts.num_index_files, 7);
3566        setup.assert_all_unchanged().await;
3567    }
3568
3569    #[tokio::test]
3570    async fn cleanup_lineage_branch3() {
3571        let mut setup = build_lineage_datasets().await.unwrap();
3572
3573        setup.branch3.write_data().await.unwrap();
3574        setup.branch3.run_cleanup().await.unwrap();
3575        // Two writes produced:
3576        // - 2 data files
3577        // - 2 deletion files
3578        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3579        assert_eq!(setup.branch3.counts.num_data_files, 2);
3580        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3581        assert_eq!(setup.branch3.counts.num_delete_files, 2);
3582        assert_eq!(setup.branch3.counts.num_index_files, 7);
3583        setup
3584            .assert_unchanged(&["branch1", "branch2", "branch4", "main"])
3585            .await;
3586
3587        setup.branch2.compact().await.unwrap();
3588        setup.branch2.run_cleanup().await.unwrap();
3589        // Branch3 hold references from branch2:
3590        // - 1 manifest file
3591        // - 1 data file
3592        // - 1 deletion file
3593        // The left is the counts for the latest version of compaction
3594        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
3595        assert_eq!(setup.branch2.counts.num_data_files, 2);
3596        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3597        assert_eq!(setup.branch2.counts.num_delete_files, 1);
3598        assert_eq!(setup.branch2.counts.num_index_files, 7);
3599
3600        setup.branch3.compact().await.unwrap();
3601        setup.branch3.run_cleanup().await.unwrap();
3602        // Only the latest manifest is retained.
3603        // (1, 1, 1, 0, 4) is the counts for the latest version
3604        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3605        assert_eq!(setup.branch3.counts.num_data_files, 1);
3606        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3607        assert_eq!(setup.branch3.counts.num_delete_files, 0);
3608        assert_eq!(setup.branch3.counts.num_index_files, 7);
3609        setup
3610            .assert_unchanged(&["branch1", "branch2", "branch4", "main"])
3611            .await;
3612
3613        setup.branch2.compact().await.unwrap();
3614        setup.branch2.run_cleanup().await.unwrap();
3615        // Only the latest manifest is retained.
3616        // (1, 1, 1, 0, 4) is the counts for the latest version
3617        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
3618        assert_eq!(setup.branch2.counts.num_data_files, 1);
3619        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3620        assert_eq!(setup.branch2.counts.num_delete_files, 0);
3621        assert_eq!(setup.branch2.counts.num_index_files, 7);
3622    }
3623
3624    #[tokio::test]
3625    async fn cleanup_lineage_branch4() {
3626        // Setup shared lineage and per-branch auto-clean config
3627        let mut setup = build_lineage_datasets().await.unwrap();
3628
3629        setup.branch4.write_data().await.unwrap();
3630        setup.branch4.run_cleanup().await.unwrap();
3631        // Two writes produced:
3632        // - 2 data files
3633        // - 2 deletion files
3634        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
3635        assert_eq!(setup.branch4.counts.num_data_files, 2);
3636        assert_eq!(setup.branch4.counts.num_tx_files, 1);
3637        assert_eq!(setup.branch4.counts.num_delete_files, 2);
3638        assert_eq!(setup.branch4.counts.num_index_files, 7);
3639        setup.assert_all_unchanged().await;
3640
3641        setup.main.compact().await.unwrap();
3642        setup.main.run_cleanup().await.unwrap();
3643        // Branch1-branch2 hold references from main:
3644        // - 1 manifest file
3645        // - 2 data files
3646        // - 1 deletion file
3647        // - 4 index files
3648        // Branch4 holds references from main:
3649        // - 1 manifest file
3650        // - 3 data files
3651        // - 1 deletion file
3652        // - 4 index files
3653        // The left(1, 1, 1, 0, 0) is the counts for the latest version of compaction
3654        assert_eq!(setup.main.counts.num_manifest_files, 3);
3655        assert_eq!(setup.main.counts.num_data_files, 4);
3656        assert_eq!(setup.main.counts.num_tx_files, 1);
3657        assert_eq!(setup.main.counts.num_delete_files, 2);
3658        assert_eq!(setup.main.counts.num_index_files, 14);
3659
3660        setup.branch4.compact().await.unwrap();
3661        setup.branch4.run_cleanup().await.unwrap();
3662        // Only the latest manifest is retained.
3663        // (1, 1, 1, 0, 4) is the counts of one version
3664        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
3665        assert_eq!(setup.branch4.counts.num_data_files, 1);
3666        assert_eq!(setup.branch4.counts.num_tx_files, 1);
3667        assert_eq!(setup.branch4.counts.num_delete_files, 0);
3668        assert_eq!(setup.branch4.counts.num_index_files, 7);
3669        setup.assert_all_unchanged().await;
3670
3671        setup.main.run_cleanup().await.unwrap();
3672        // Branch1-branch2 hold references from main:
3673        // - 1 manifest file
3674        // - 2 data files
3675        // - 1 deletion file
3676        // - 4 index files
3677        // The left(1, 1, 1, 0, 4) is the counts for the latest version of compaction
3678        assert_eq!(setup.main.counts.num_manifest_files, 2);
3679        assert_eq!(setup.main.counts.num_data_files, 3);
3680        assert_eq!(setup.main.counts.num_tx_files, 1);
3681        assert_eq!(setup.main.counts.num_delete_files, 1);
3682        assert_eq!(setup.main.counts.num_index_files, 14);
3683    }
3684
3685    #[tokio::test]
3686    async fn cleanup_lineage_main() {
3687        // Setup shared lineage and per-branch auto-clean config
3688        let mut setup = build_lineage_datasets().await.unwrap();
3689
3690        setup.main.write_data().await.unwrap();
3691        setup.main.run_cleanup().await.unwrap();
3692        // Branch1-branch2 hold references from main:
3693        // - 1 manifest file
3694        // - 2 data files
3695        // - 1 deletion file
3696        // - 4 index files(only for branch1)
3697        // Branch4 holds references from main:
3698        // - 1 manifest file
3699        // - 3 data files
3700        // - 1 deletion file
3701        // - 4 index files
3702        // The left(1, 1, 1, 1, 4) is the counts for the latest version of compaction
3703        assert_eq!(setup.main.counts.num_manifest_files, 3);
3704        assert_eq!(setup.main.counts.num_data_files, 4);
3705        assert_eq!(setup.main.counts.num_tx_files, 1);
3706        assert_eq!(setup.main.counts.num_delete_files, 3);
3707        assert_eq!(setup.main.counts.num_index_files, 21);
3708        setup.assert_all_unchanged().await;
3709
3710        setup.main.compact().await.unwrap();
3711        setup.main.run_cleanup().await.unwrap();
3712        // Cleanup the deletion file
3713        // Produce 1 datafile and cleanup 1
3714        assert_eq!(setup.main.counts.num_manifest_files, 3);
3715        assert_eq!(setup.main.counts.num_data_files, 4);
3716        assert_eq!(setup.main.counts.num_tx_files, 1);
3717        assert_eq!(setup.main.counts.num_delete_files, 2);
3718        assert_eq!(setup.main.counts.num_index_files, 21);
3719        setup.assert_all_unchanged().await;
3720
3721        setup.branch1.write_data().await.unwrap();
3722        setup.branch1.compact().await.unwrap();
3723        setup.branch2.write_data().await.unwrap();
3724        setup.branch2.compact().await.unwrap();
3725        setup.branch2.run_cleanup().await.unwrap();
3726        // Branch3 holds references from branch2:
3727        // - 1 manifest file
3728        // - 1 data files
3729        // - 1 deletion file
3730        // Branch3 holds reference from branch1:
3731        // - 1 manifest file
3732        // - 1 data files
3733        // - 2 deletion files
3734        // - 4 index files
3735        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
3736        assert_eq!(setup.branch2.counts.num_data_files, 2);
3737        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3738        assert_eq!(setup.branch2.counts.num_delete_files, 1);
3739        assert_eq!(setup.branch2.counts.num_index_files, 14);
3740        setup.branch1.run_cleanup().await.unwrap();
3741        // Cleanup 4 index files referenced from branch2
3742        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
3743        assert_eq!(setup.branch1.counts.num_data_files, 2);
3744        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3745        assert_eq!(setup.branch1.counts.num_delete_files, 1);
3746        assert_eq!(setup.branch1.counts.num_index_files, 7);
3747
3748        setup.main.run_cleanup().await.unwrap();
3749        // Branch3 holds references from main:
3750        // - 1 manifest file
3751        // - 1 data files
3752        // - 1 deletion file
3753        // Branch4 holds references from main:
3754        // - 1 manifest file
3755        // - 3 data files
3756        // - 2 deletion files
3757        // - 4 index files
3758        assert_eq!(setup.main.counts.num_manifest_files, 3);
3759        assert_eq!(setup.main.counts.num_data_files, 4);
3760        assert_eq!(setup.main.counts.num_tx_files, 1);
3761        assert_eq!(setup.main.counts.num_delete_files, 2);
3762        assert_eq!(setup.main.counts.num_index_files, 14);
3763
3764        setup.branch3.write_data().await.unwrap();
3765        setup.branch3.compact().await.unwrap();
3766        setup.branch3.run_cleanup().await.unwrap();
3767        // Only the counts for the latest version
3768        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3769        assert_eq!(setup.branch3.counts.num_data_files, 1);
3770        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3771        assert_eq!(setup.branch3.counts.num_delete_files, 0);
3772        assert_eq!(setup.branch3.counts.num_index_files, 7);
3773
3774        setup.main.run_cleanup().await.unwrap();
3775        // Cleanup doesn't take effects if we don't clean branch2 and branch1 first
3776        assert_eq!(setup.main.counts.num_manifest_files, 3);
3777        assert_eq!(setup.main.counts.num_data_files, 4);
3778        assert_eq!(setup.main.counts.num_tx_files, 1);
3779        assert_eq!(setup.main.counts.num_delete_files, 2);
3780        assert_eq!(setup.main.counts.num_index_files, 14);
3781
3782        // Cleanup doesn't take effect if we don't clean branch2 first
3783        setup.branch1.run_cleanup().await.unwrap();
3784        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
3785        assert_eq!(setup.branch1.counts.num_data_files, 2);
3786        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3787        assert_eq!(setup.branch1.counts.num_delete_files, 1);
3788        assert_eq!(setup.branch1.counts.num_index_files, 7);
3789
3790        setup.branch2.run_cleanup().await.unwrap();
3791        // Only the latest manifest is retained.
3792        // (1, 1, 1, 0, 4) is the counts for the latest version
3793        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
3794        assert_eq!(setup.branch2.counts.num_data_files, 1);
3795        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3796        assert_eq!(setup.branch2.counts.num_delete_files, 0);
3797        assert_eq!(setup.branch2.counts.num_index_files, 7);
3798
3799        setup.branch1.run_cleanup().await.unwrap();
3800        // Only the latest manifest is retained.
3801        // (1, 1, 1, 0, 4) is the counts for the latest version
3802        assert_eq!(setup.branch1.counts.num_manifest_files, 1);
3803        assert_eq!(setup.branch1.counts.num_data_files, 1);
3804        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3805        assert_eq!(setup.branch1.counts.num_delete_files, 0);
3806        assert_eq!(setup.branch1.counts.num_index_files, 7);
3807
3808        setup.main.run_cleanup().await.unwrap();
3809        // Branch4 holds references from main:
3810        // - 1 manifest file
3811        // - 3 data files
3812        // - 2 deletion files
3813        // - 4 index files
3814        assert_eq!(setup.main.counts.num_manifest_files, 2);
3815        assert_eq!(setup.main.counts.num_data_files, 4);
3816        assert_eq!(setup.main.counts.num_tx_files, 1);
3817        assert_eq!(setup.main.counts.num_delete_files, 2);
3818        assert_eq!(setup.main.counts.num_index_files, 14);
3819
3820        setup.branch4.write_data().await.unwrap();
3821        setup.branch4.compact().await.unwrap();
3822        setup.branch4.run_cleanup().await.unwrap();
3823        // Only the latest manifest is retained.
3824        // (1, 1, 1, 0, 4) is the counts for the latest version
3825        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
3826        assert_eq!(setup.branch4.counts.num_data_files, 1);
3827        assert_eq!(setup.branch4.counts.num_tx_files, 1);
3828        assert_eq!(setup.branch4.counts.num_delete_files, 0);
3829        assert_eq!(setup.branch4.counts.num_index_files, 7);
3830
3831        setup.main.run_cleanup().await.unwrap();
3832        // Only the latest manifest is retained.
3833        // (1, 1, 1, 0, 4) is the counts for the latest version
3834        assert_eq!(setup.main.counts.num_manifest_files, 1);
3835        assert_eq!(setup.main.counts.num_data_files, 1);
3836        assert_eq!(setup.main.counts.num_tx_files, 1);
3837        assert_eq!(setup.main.counts.num_delete_files, 0);
3838        assert_eq!(setup.main.counts.num_index_files, 7);
3839    }
3840
3841    #[tokio::test]
3842    async fn auto_clean_referenced_branches_from_branch2() {
3843        // Setup shared lineage and per-branch auto-clean config
3844        let mut setup = build_lineage_datasets().await.unwrap();
3845
3846        setup.branch3.write_data().await.unwrap();
3847        setup.enable_auto_cleanup().await.unwrap();
3848        setup
3849            .branch2
3850            .run_cleanup_with_referenced_branches()
3851            .await
3852            .unwrap();
3853        setup.branch3.refresh().await.unwrap();
3854        // Branch3 holds references from branch2:
3855        // - 1 manifest file
3856        // - 1 data file
3857        // - 1 deletion file
3858        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
3859        assert_eq!(setup.branch2.counts.num_data_files, 1);
3860        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3861        assert_eq!(setup.branch2.counts.num_delete_files, 1);
3862        assert_eq!(setup.branch2.counts.num_index_files, 7);
3863        // After auto-clean: branch3
3864        // 2 appends produced 2 data files
3865        // 2 deletes produced 2 deletion files
3866        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3867        assert_eq!(setup.branch3.counts.num_data_files, 2);
3868        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3869        assert_eq!(setup.branch3.counts.num_delete_files, 2);
3870        assert_eq!(setup.branch3.counts.num_index_files, 7);
3871        setup
3872            .assert_unchanged(&["branch1", "branch4", "main"])
3873            .await;
3874
3875        setup.disable_auto_cleanup().await.unwrap();
3876        setup.branch2.write_data().await.unwrap();
3877        setup.branch2.compact().await.unwrap();
3878        setup.branch3.compact().await.unwrap();
3879        setup.enable_auto_cleanup().await.unwrap();
3880        setup
3881            .branch2
3882            .run_cleanup_with_referenced_branches()
3883            .await
3884            .unwrap();
3885        setup.branch3.refresh().await.unwrap();
3886        // Only the latest manifest is retained.
3887        // (1, 1, 1, 0, 4) is the counts of one version
3888        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
3889        assert_eq!(setup.branch2.counts.num_data_files, 1);
3890        assert_eq!(setup.branch2.counts.num_tx_files, 1);
3891        assert_eq!(setup.branch2.counts.num_delete_files, 0);
3892        assert_eq!(setup.branch2.counts.num_index_files, 7);
3893        // Only the latest manifest is retained.
3894        // (1, 1, 1, 0, 4) is the counts of one version
3895        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
3896        assert_eq!(setup.branch3.counts.num_data_files, 1);
3897        assert_eq!(setup.branch3.counts.num_tx_files, 1);
3898        assert_eq!(setup.branch3.counts.num_delete_files, 0);
3899        assert_eq!(setup.branch3.counts.num_index_files, 7);
3900        setup
3901            .assert_unchanged(&["branch1", "branch4", "main"])
3902            .await;
3903    }
3904
3905    #[tokio::test]
3906    async fn auto_clean_referenced_branches_from_main() {
3907        let mut setup = build_lineage_datasets().await.unwrap();
3908
3909        setup.enable_auto_cleanup().await.unwrap();
3910        setup.main.write_data().await.unwrap();
3911        setup
3912            .main
3913            .run_cleanup_with_referenced_branches()
3914            .await
3915            .unwrap();
3916        // Branch3, branch2 and branch1 hold references from main:
3917        // - 1 manifest file
3918        // - 2 data files
3919        // - 1 deletion file
3920        // Branch4 holds references from main:
3921        // - 1 manifest file
3922        // - 3 data files
3923        // - 1 deletion file
3924        // - 4 index files
3925        assert_eq!(setup.main.counts.num_manifest_files, 3);
3926        assert_eq!(setup.main.counts.num_data_files, 4);
3927        assert_eq!(setup.main.counts.num_tx_files, 1);
3928        assert_eq!(setup.main.counts.num_delete_files, 3);
3929        assert_eq!(setup.main.counts.num_index_files, 7);
3930
3931        setup.main.compact().await.unwrap();
3932        setup
3933            .main
3934            .run_cleanup_with_referenced_branches()
3935            .await
3936            .unwrap();
3937        // Branch3, branch2 and branch1 hold references from main:
3938        // - 1 manifest file
3939        // - 2 data files
3940        // - 1 deletion file
3941        // Branch4 holds references from main:
3942        // - 1 manifest file
3943        // - 3 data files
3944        // - 1 deletion file
3945        assert_eq!(setup.main.counts.num_manifest_files, 3);
3946        assert_eq!(setup.main.counts.num_data_files, 4);
3947        assert_eq!(setup.main.counts.num_tx_files, 1);
3948        assert_eq!(setup.main.counts.num_delete_files, 2);
3949        assert_eq!(setup.main.counts.num_index_files, 7);
3950
3951        setup.branch4.compact().await.unwrap();
3952        setup
3953            .main
3954            .run_cleanup_with_referenced_branches()
3955            .await
3956            .unwrap();
3957        setup.branch4.refresh().await.unwrap();
3958        // Branch3, branch2 and branch1 hold references from main:
3959        // - 1 manifest file
3960        // - 2 data files
3961        // - 1 deletion file
3962        assert_eq!(setup.main.counts.num_manifest_files, 2);
3963        assert_eq!(setup.main.counts.num_data_files, 3);
3964        assert_eq!(setup.main.counts.num_tx_files, 1);
3965        assert_eq!(setup.main.counts.num_delete_files, 1);
3966        assert_eq!(setup.main.counts.num_index_files, 7);
3967        // (1, 1, 1, 0, 4) is the counts of one version
3968        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
3969        assert_eq!(setup.branch4.counts.num_data_files, 1);
3970        assert_eq!(setup.branch4.counts.num_tx_files, 1);
3971        assert_eq!(setup.branch4.counts.num_delete_files, 0);
3972        assert_eq!(setup.branch4.counts.num_index_files, 7);
3973
3974        setup.branch1.write_data().await.unwrap();
3975        setup.branch1.compact().await.unwrap();
3976        setup
3977            .main
3978            .run_cleanup_with_referenced_branches()
3979            .await
3980            .unwrap();
3981        setup.branch1.refresh().await.unwrap();
3982        // Branch3 and branch2 still hold references from main:
3983        // - 1 manifest file
3984        // - 2 data files
3985        // - 1 deletion file
3986        assert_eq!(setup.main.counts.num_manifest_files, 2);
3987        assert_eq!(setup.main.counts.num_data_files, 3);
3988        assert_eq!(setup.main.counts.num_tx_files, 1);
3989        assert_eq!(setup.main.counts.num_delete_files, 1);
3990        assert_eq!(setup.main.counts.num_index_files, 7);
3991        // Branch3 and branch2 still hold references from branch1:
3992        // - 1 manifest file
3993        // - 1 data files
3994        // - 1 deletion file
3995        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
3996        assert_eq!(setup.branch1.counts.num_data_files, 2);
3997        assert_eq!(setup.branch1.counts.num_tx_files, 1);
3998        assert_eq!(setup.branch1.counts.num_delete_files, 1);
3999        assert_eq!(setup.branch1.counts.num_index_files, 7);
4000
4001        setup.branch2.write_data().await.unwrap();
4002        setup.branch2.compact().await.unwrap();
4003        setup
4004            .main
4005            .run_cleanup_with_referenced_branches()
4006            .await
4007            .unwrap();
4008        setup.branch2.refresh().await.unwrap();
4009        // Branch3 still holds references from main:
4010        // - 1 manifest file
4011        // - 2 data files
4012        // - 1 deletion file
4013        assert_eq!(setup.main.counts.num_manifest_files, 2);
4014        assert_eq!(setup.main.counts.num_data_files, 3);
4015        assert_eq!(setup.main.counts.num_tx_files, 1);
4016        assert_eq!(setup.main.counts.num_delete_files, 1);
4017        assert_eq!(setup.main.counts.num_index_files, 7);
4018        // Branch3 still holds references from branch1:
4019        // - 1 manifest file
4020        // - 1 data files
4021        // - 1 deletion file
4022        assert_eq!(setup.branch1.counts.num_manifest_files, 2);
4023        assert_eq!(setup.branch1.counts.num_data_files, 2);
4024        assert_eq!(setup.branch1.counts.num_tx_files, 1);
4025        assert_eq!(setup.branch1.counts.num_delete_files, 1);
4026        assert_eq!(setup.branch1.counts.num_index_files, 7);
4027        // Branch3 still holds references from branch2:
4028        // - 1 manifest file
4029        // - 1 data files
4030        // - 1 deletion file
4031        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
4032        assert_eq!(setup.branch2.counts.num_data_files, 2);
4033        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4034        assert_eq!(setup.branch2.counts.num_delete_files, 1);
4035        assert_eq!(setup.branch2.counts.num_index_files, 7);
4036
4037        setup.branch3.write_data().await.unwrap();
4038        setup.branch3.compact().await.unwrap();
4039        setup
4040            .main
4041            .run_cleanup_with_referenced_branches()
4042            .await
4043            .unwrap();
4044        setup.branch1.refresh().await.unwrap();
4045        setup.branch2.refresh().await.unwrap();
4046        setup.branch3.refresh().await.unwrap();
4047        // For all branches, only the latest manifest is retained.
4048        // (1, 1, 1, 0, 4) is the counts of one version
4049        assert_eq!(setup.main.counts.num_manifest_files, 1);
4050        assert_eq!(setup.main.counts.num_data_files, 1);
4051        assert_eq!(setup.main.counts.num_tx_files, 1);
4052        assert_eq!(setup.main.counts.num_delete_files, 0);
4053        assert_eq!(setup.main.counts.num_index_files, 7);
4054        assert_eq!(setup.branch1.counts.num_manifest_files, 1);
4055        assert_eq!(setup.branch1.counts.num_data_files, 1);
4056        assert_eq!(setup.branch1.counts.num_tx_files, 1);
4057        assert_eq!(setup.branch1.counts.num_delete_files, 0);
4058        assert_eq!(setup.branch1.counts.num_index_files, 7);
4059        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
4060        assert_eq!(setup.branch2.counts.num_data_files, 1);
4061        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4062        assert_eq!(setup.branch2.counts.num_delete_files, 0);
4063        assert_eq!(setup.branch2.counts.num_index_files, 7);
4064        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
4065        assert_eq!(setup.branch3.counts.num_data_files, 1);
4066        assert_eq!(setup.branch3.counts.num_tx_files, 1);
4067        assert_eq!(setup.branch3.counts.num_delete_files, 0);
4068        assert_eq!(setup.branch3.counts.num_index_files, 7);
4069        setup.assert_unchanged(&["branch4"]).await;
4070    }
4071
4072    #[tokio::test]
4073    async fn explain_cleanup_with_referenced_branches_matches_cleanup() {
4074        let mut setup = build_lineage_datasets().await.unwrap();
4075
4076        setup.enable_auto_cleanup().await.unwrap();
4077        setup.main.write_data().await.unwrap();
4078        setup.main.compact().await.unwrap();
4079        setup.branch4.compact().await.unwrap();
4080        setup.branch1.write_data().await.unwrap();
4081        setup.branch1.compact().await.unwrap();
4082        setup.branch2.write_data().await.unwrap();
4083        setup.branch2.compact().await.unwrap();
4084        setup.branch3.write_data().await.unwrap();
4085        setup.branch3.compact().await.unwrap();
4086
4087        setup.main.refresh().await.unwrap();
4088        setup.branch1.refresh().await.unwrap();
4089        setup.branch2.refresh().await.unwrap();
4090        setup.branch3.refresh().await.unwrap();
4091        setup.branch4.refresh().await.unwrap();
4092        let main_counts_before = setup.main.counts;
4093        let branch1_counts_before = setup.branch1.counts;
4094        let branch2_counts_before = setup.branch2.counts;
4095        let branch3_counts_before = setup.branch3.counts;
4096        let branch4_counts_before = setup.branch4.counts;
4097
4098        let explanation = setup
4099            .main
4100            .explain_cleanup_with_referenced_branches()
4101            .await
4102            .unwrap();
4103
4104        setup.main.refresh().await.unwrap();
4105        setup.branch1.refresh().await.unwrap();
4106        setup.branch2.refresh().await.unwrap();
4107        setup.branch3.refresh().await.unwrap();
4108        setup.branch4.refresh().await.unwrap();
4109        assert_eq!(setup.main.counts, main_counts_before);
4110        assert_eq!(setup.branch1.counts, branch1_counts_before);
4111        assert_eq!(setup.branch2.counts, branch2_counts_before);
4112        assert_eq!(setup.branch3.counts, branch3_counts_before);
4113        assert_eq!(setup.branch4.counts, branch4_counts_before);
4114
4115        let removed = setup
4116            .main
4117            .run_cleanup_with_referenced_branches()
4118            .await
4119            .unwrap();
4120
4121        assert!(!explanation.referenced_branches.is_empty());
4122        assert!(
4123            explanation
4124                .referenced_branches
4125                .iter()
4126                .any(|branch| branch.cleanup_candidate)
4127        );
4128        assert_eq!(explanation.stats, removed);
4129        setup.branch1.refresh().await.unwrap();
4130        setup.branch2.refresh().await.unwrap();
4131        setup.branch3.refresh().await.unwrap();
4132        setup.branch4.refresh().await.unwrap();
4133        assert_eq!(setup.main.counts.num_manifest_files, 1);
4134        assert_eq!(setup.branch1.counts.num_manifest_files, 1);
4135        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
4136        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
4137        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
4138    }
4139
4140    #[tokio::test]
4141    async fn auto_clean_referenced_branches_with_tags() {
4142        let mut setup = build_lineage_datasets().await.unwrap();
4143
4144        setup
4145            .branch3
4146            .dataset
4147            .tags()
4148            .create("branch3-tag", setup.branch3.dataset.version().version)
4149            .await
4150            .unwrap();
4151        setup
4152            .main
4153            .dataset
4154            .tags()
4155            .create("main-tag", setup.main.dataset.version().version)
4156            .await
4157            .unwrap();
4158
4159        setup.branch1.compact().await.unwrap();
4160        setup.branch2.compact().await.unwrap();
4161        setup.branch3.compact().await.unwrap();
4162        setup.branch4.compact().await.unwrap();
4163        setup.main.compact().await.unwrap();
4164        setup.enable_auto_cleanup().await.unwrap();
4165        setup
4166            .main
4167            .run_cleanup_with_referenced_branches()
4168            .await
4169            .unwrap();
4170        setup.branch1.refresh().await.unwrap();
4171        setup.branch2.refresh().await.unwrap();
4172        setup.branch3.refresh().await.unwrap();
4173        setup.branch4.refresh().await.unwrap();
4174        // Two tags hold two manifest references
4175        // Main tag holds 1 tx file, 3 data files, 2 deletion files and 4 index files
4176        assert_eq!(setup.main.counts.num_manifest_files, 3);
4177        assert_eq!(setup.main.counts.num_data_files, 4);
4178        assert_eq!(setup.main.counts.num_tx_files, 2);
4179        assert_eq!(setup.main.counts.num_delete_files, 2);
4180        assert_eq!(setup.main.counts.num_index_files, 14);
4181        // Branch3 tag holds branch1 with 1 tx file, 1 data files, 1 deletion files and 4 index files
4182        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
4183        assert_eq!(setup.branch2.counts.num_data_files, 2);
4184        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4185        assert_eq!(setup.branch2.counts.num_delete_files, 1);
4186        assert_eq!(setup.branch2.counts.num_index_files, 7);
4187        // Branch3 tag holds branch2 with 1 tx file, 1 data files, 1 deletion files and 4 index files
4188        assert_eq!(setup.branch2.counts.num_manifest_files, 2);
4189        assert_eq!(setup.branch2.counts.num_data_files, 2);
4190        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4191        assert_eq!(setup.branch2.counts.num_delete_files, 1);
4192        assert_eq!(setup.branch2.counts.num_index_files, 7);
4193        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
4194        assert_eq!(setup.branch4.counts.num_data_files, 1);
4195        assert_eq!(setup.branch4.counts.num_tx_files, 1);
4196        assert_eq!(setup.branch4.counts.num_delete_files, 0);
4197        assert_eq!(setup.branch4.counts.num_index_files, 7);
4198
4199        setup
4200            .branch3
4201            .dataset
4202            .tags()
4203            .delete("branch3-tag")
4204            .await
4205            .unwrap();
4206        setup
4207            .main
4208            .run_cleanup_with_referenced_branches()
4209            .await
4210            .unwrap();
4211        setup.branch1.refresh().await.unwrap();
4212        setup.branch2.refresh().await.unwrap();
4213        setup.branch3.refresh().await.unwrap();
4214        setup.branch4.refresh().await.unwrap();
4215        // 1 manifest file referenced by branch3-tag is cleaned
4216        assert_eq!(setup.main.counts.num_manifest_files, 2);
4217        assert_eq!(setup.main.counts.num_data_files, 4);
4218        assert_eq!(setup.main.counts.num_tx_files, 2);
4219        assert_eq!(setup.main.counts.num_delete_files, 2);
4220        assert_eq!(setup.main.counts.num_index_files, 14);
4221        assert_eq!(setup.branch1.counts.num_manifest_files, 1);
4222        assert_eq!(setup.branch1.counts.num_data_files, 1);
4223        assert_eq!(setup.branch1.counts.num_tx_files, 1);
4224        assert_eq!(setup.branch1.counts.num_delete_files, 0);
4225        assert_eq!(setup.branch1.counts.num_index_files, 7);
4226        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
4227        assert_eq!(setup.branch2.counts.num_data_files, 1);
4228        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4229        assert_eq!(setup.branch2.counts.num_delete_files, 0);
4230        assert_eq!(setup.branch2.counts.num_index_files, 7);
4231        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
4232        assert_eq!(setup.branch3.counts.num_data_files, 1);
4233        assert_eq!(setup.branch3.counts.num_tx_files, 1);
4234        assert_eq!(setup.branch3.counts.num_delete_files, 0);
4235        assert_eq!(setup.branch3.counts.num_index_files, 7);
4236        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
4237        assert_eq!(setup.branch4.counts.num_data_files, 1);
4238        assert_eq!(setup.branch4.counts.num_tx_files, 1);
4239        assert_eq!(setup.branch4.counts.num_delete_files, 0);
4240        assert_eq!(setup.branch4.counts.num_index_files, 7);
4241
4242        setup.main.dataset.tags().delete("main-tag").await.unwrap();
4243        setup
4244            .main
4245            .run_cleanup_with_referenced_branches()
4246            .await
4247            .unwrap();
4248        setup.branch2.refresh().await.unwrap();
4249        setup.branch3.refresh().await.unwrap();
4250        setup.branch4.refresh().await.unwrap();
4251        // All cleaned up
4252        assert_eq!(setup.main.counts.num_manifest_files, 1);
4253        assert_eq!(setup.main.counts.num_data_files, 1);
4254        assert_eq!(setup.main.counts.num_tx_files, 1);
4255        assert_eq!(setup.main.counts.num_delete_files, 0);
4256        assert_eq!(setup.main.counts.num_index_files, 7);
4257        assert_eq!(setup.branch2.counts.num_manifest_files, 1);
4258        assert_eq!(setup.branch2.counts.num_data_files, 1);
4259        assert_eq!(setup.branch2.counts.num_tx_files, 1);
4260        assert_eq!(setup.branch2.counts.num_delete_files, 0);
4261        assert_eq!(setup.branch2.counts.num_index_files, 7);
4262        assert_eq!(setup.branch3.counts.num_manifest_files, 1);
4263        assert_eq!(setup.branch3.counts.num_data_files, 1);
4264        assert_eq!(setup.branch3.counts.num_tx_files, 1);
4265        assert_eq!(setup.branch3.counts.num_delete_files, 0);
4266        assert_eq!(setup.branch3.counts.num_index_files, 7);
4267        assert_eq!(setup.branch4.counts.num_manifest_files, 1);
4268        assert_eq!(setup.branch4.counts.num_data_files, 1);
4269        assert_eq!(setup.branch4.counts.num_tx_files, 1);
4270        assert_eq!(setup.branch4.counts.num_delete_files, 0);
4271        assert_eq!(setup.branch4.counts.num_index_files, 7);
4272    }
4273
4274    #[test]
4275    fn test_calculate_duration_s3() {
4276        // Normal case: duration is computed from S3 batch size and configured rate.
4277        let normal_rate = 100;
4278        let expected_duration_ns =
4279            1_000_000_000u64.div_ceil(normal_rate * S3_DELETE_STREAM_BATCH_SIZE);
4280        assert_eq!(
4281            calculate_duration("s3".to_string(), normal_rate),
4282            Duration::from_nanos(expected_duration_ns)
4283        );
4284
4285        // Edge case: rate too small should be clamped to 1.
4286        let min_rate_duration = calculate_duration("s3".to_string(), 1);
4287        assert_eq!(calculate_duration("s3".to_string(), 0), min_rate_duration);
4288
4289        // Edge case: computed duration_ns too small should be clamped to at least 1ns.
4290        let very_large_rate = 2_000_000;
4291        assert_eq!(
4292            calculate_duration("s3".to_string(), very_large_rate),
4293            Duration::from_nanos(1)
4294        );
4295    }
4296
4297    #[tokio::test]
4298    async fn test_cleanup_with_rate_limit() {
4299        // Create multiple versions with data files that will be deleted.
4300        let fixture = MockDatasetFixture::try_new().unwrap();
4301        fixture.create_some_data().await.unwrap();
4302        // Create several old versions
4303        for _ in 0..4 {
4304            fixture.overwrite_some_data().await.unwrap();
4305        }
4306
4307        MockClock::set_system_time(TimeDelta::try_days(10).unwrap().to_std().unwrap());
4308
4309        // Set rate limit to 1 ops/second so cleanup of several files must take at least ~1s
4310        let policy = CleanupPolicyBuilder::default()
4311            .before_timestamp(utc_now() - TimeDelta::try_days(8).unwrap())
4312            .delete_rate_limit(1)
4313            .unwrap()
4314            .build();
4315
4316        let start = std::time::Instant::now();
4317        let db = fixture.open().await.unwrap();
4318        let stats = cleanup_old_versions(&db, policy).await.unwrap();
4319        let elapsed = start.elapsed();
4320
4321        // We deleted old versions, so there should be removed files
4322        assert!(
4323            stats.old_versions > 0,
4324            "expected some old versions to be removed"
4325        );
4326        // With rate=1 and multiple files, it must take at least 2s
4327        // (even just 2 deletions at 1/s means ≥2s)
4328        assert!(
4329            elapsed.as_millis() >= 2000,
4330            "expected cleanup to be rate-limited (elapsed: {:?})",
4331            elapsed
4332        );
4333    }
4334}