Skip to main content

timeseries_table_format/table/operations/
vacuum.rs

1//! Retention-aware removal of unreferenced table-managed artifacts.
2
3use std::{collections::HashMap, path::Path};
4
5use chrono::{DateTime, Utc};
6use parquet::arrow::async_reader::AsyncFileReader;
7use snafu::{Backtrace, ResultExt, Snafu};
8use uuid::Uuid;
9
10use crate::{
11    coverage::layout::{COVERAGE_EXT, SEGMENT_COVERAGE_DIR, TABLE_SNAPSHOT_DIR},
12    metadata::protocol::TableProtocolError,
13    storage::{self, StorageError, StorageFileMetadata},
14    table::{TableError, TimeSeriesTable},
15    transaction_log::{CommitError, LogAction, TableKind},
16};
17
18/// Whether vacuum reports candidates or removes them.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum VacuumMode {
22    /// Inspect the table without deleting files.
23    DryRun,
24    /// Delete expired, unreferenced table-managed files.
25    Apply,
26}
27
28impl VacuumMode {
29    /// Return the stable snake-case mode name.
30    pub const fn as_str(&self) -> &'static str {
31        match self {
32            Self::DryRun => "dry_run",
33            Self::Apply => "apply",
34        }
35    }
36}
37
38/// The action vacuum took or would take for one considered file.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum VacuumArtifactDisposition {
42    /// Vacuum preserved the file.
43    Retained,
44    /// Dry-run identified the file as removable.
45    Removable,
46    /// Apply mode removed the file.
47    Deleted,
48    /// Apply mode found the file already absent.
49    AlreadyAbsent,
50}
51
52impl VacuumArtifactDisposition {
53    /// Return the stable snake-case disposition name.
54    pub const fn as_str(&self) -> &'static str {
55        match self {
56            Self::Retained => "retained",
57            Self::Removable => "removable",
58            Self::Deleted => "deleted",
59            Self::AlreadyAbsent => "already_absent",
60        }
61    }
62}
63
64/// Why vacuum retained or selected one file.
65#[derive(Debug, Clone, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum VacuumArtifactReason {
68    /// A retained commit references this path.
69    ReferencedByCommit {
70        /// Earliest retained commit that references the path.
71        version: u64,
72    },
73    /// The file modification time is at or after the required cutoff.
74    WithinRetention,
75    /// The file's size or modification time differed from the planned metadata.
76    ChangedSincePlanning,
77    /// The file is below a scanned directory but does not have a reserved managed shape.
78    UnrecognizedArtifact,
79    /// The file is expired and no retained commit references it.
80    Unreferenced,
81    /// The expired, unreferenced Parquet file has no readable valid footer.
82    InvalidOrUnreadableParquet,
83}
84
85impl VacuumArtifactReason {
86    /// Return the stable snake-case reason name.
87    pub const fn as_str(&self) -> &'static str {
88        match self {
89            Self::ReferencedByCommit { .. } => "referenced_by_commit",
90            Self::WithinRetention => "within_retention",
91            Self::ChangedSincePlanning => "changed_since_planning",
92            Self::UnrecognizedArtifact => "unrecognized_artifact",
93            Self::Unreferenced => "unreferenced",
94            Self::InvalidOrUnreadableParquet => "invalid_or_unreadable_parquet",
95        }
96    }
97}
98
99/// Vacuum classification for one file below a scanned directory.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VacuumArtifact {
102    /// Canonical table-relative path.
103    pub path: String,
104    /// Latest file size observed by this invocation.
105    pub size_bytes: u64,
106    /// Latest modification time observed by this invocation.
107    pub modified_at: DateTime<Utc>,
108    /// Action taken or proposed by this invocation.
109    pub disposition: VacuumArtifactDisposition,
110    /// Reason for the disposition.
111    pub reason: VacuumArtifactReason,
112}
113
114/// Structured result of one vacuum invocation.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct VacuumReport {
117    /// Latest validated transaction-log version used for deletion safety.
118    pub table_version: u64,
119    /// Required exclusive upper bound on removable file modification times.
120    pub older_than: DateTime<Utc>,
121    /// Requested vacuum behavior.
122    pub mode: VacuumMode,
123    /// Every regular file considered below `data/` and `_coverage/`.
124    pub artifacts: Vec<VacuumArtifact>,
125    /// Number of files considered by this invocation.
126    pub considered_files: usize,
127    /// Number of files retained by this invocation.
128    pub retained_files: usize,
129    /// Number of files reported as removable by dry-run.
130    pub removable_files: usize,
131    /// Number of files removed by apply mode.
132    pub deleted_files: usize,
133    /// Number of candidates already absent when apply checked or removed them.
134    pub already_absent_files: usize,
135    /// Bytes across every considered file.
136    pub considered_bytes: u128,
137    /// Bytes retained by this invocation.
138    pub retained_bytes: u128,
139    /// Bytes reported as removable by dry-run.
140    pub removable_bytes: u128,
141    /// Bytes removed by apply mode.
142    pub deleted_bytes: u128,
143    /// Bytes last observed for candidates already absent during apply.
144    pub already_absent_bytes: u128,
145}
146
147impl VacuumReport {
148    fn new(
149        table_version: u64,
150        older_than: DateTime<Utc>,
151        mode: VacuumMode,
152        artifacts: Vec<VacuumArtifact>,
153    ) -> Self {
154        let considered_files = artifacts.len();
155        let mut retained_files = 0usize;
156        let mut removable_files = 0usize;
157        let mut deleted_files = 0usize;
158        let mut already_absent_files = 0usize;
159        let mut considered_bytes = 0u128;
160        let mut retained_bytes = 0u128;
161        let mut removable_bytes = 0u128;
162        let mut deleted_bytes = 0u128;
163        let mut already_absent_bytes = 0u128;
164        for artifact in &artifacts {
165            let bytes = u128::from(artifact.size_bytes);
166            considered_bytes += bytes;
167            match artifact.disposition {
168                VacuumArtifactDisposition::Retained => {
169                    retained_files += 1;
170                    retained_bytes += bytes;
171                }
172                VacuumArtifactDisposition::Removable => {
173                    removable_files += 1;
174                    removable_bytes += bytes;
175                }
176                VacuumArtifactDisposition::Deleted => {
177                    deleted_files += 1;
178                    deleted_bytes += bytes;
179                }
180                VacuumArtifactDisposition::AlreadyAbsent => {
181                    already_absent_files += 1;
182                    already_absent_bytes += bytes;
183                }
184            }
185        }
186        Self {
187            table_version,
188            older_than,
189            mode,
190            artifacts,
191            considered_files,
192            retained_files,
193            removable_files,
194            deleted_files,
195            already_absent_files,
196            considered_bytes,
197            retained_bytes,
198            removable_bytes,
199            deleted_bytes,
200            already_absent_bytes,
201        }
202    }
203}
204
205/// Errors owned by a vacuum operation.
206#[derive(Debug, Snafu)]
207#[snafu(module, visibility(pub(crate)))]
208#[non_exhaustive]
209pub enum VacuumError {
210    /// The retention cutoff is later than the current time.
211    #[snafu(display("Vacuum cutoff {older_than} is in the future"))]
212    FutureCutoff {
213        /// Rejected exclusive retention cutoff.
214        older_than: DateTime<Utc>,
215    },
216
217    /// The latest table protocol does not permit this client to vacuum.
218    #[snafu(context(false), display("Table protocol error: {source}"))]
219    Protocol {
220        /// Complete table protocol failure.
221        #[snafu(source)]
222        source: TableProtocolError,
223        /// Backtrace captured at the vacuum boundary.
224        backtrace: Backtrace,
225    },
226
227    /// The latest metadata no longer describes a time-series table.
228    #[snafu(display("Latest table kind is {kind:?}, expected a time-series table"))]
229    NotTimeSeries {
230        /// Rejected table kind.
231        kind: TableKind,
232    },
233
234    /// Reading or validating retained transaction-log history failed.
235    #[snafu(context(false), display("Vacuum transaction-log error: {source}"))]
236    Commit {
237        /// Complete transaction-log failure.
238        #[snafu(source, backtrace)]
239        source: CommitError,
240    },
241
242    /// Listing or inspecting table-managed storage failed.
243    #[snafu(context(false), display("Vacuum storage error: {source}"))]
244    Storage {
245        /// Complete storage failure.
246        #[snafu(source, backtrace)]
247        source: StorageError,
248    },
249
250    /// Removing one selected file failed.
251    #[snafu(display("Failed to delete vacuum candidate {path}: {source}"))]
252    Delete {
253        /// Canonical table-relative path selected for deletion.
254        path: String,
255        /// Complete storage failure.
256        #[snafu(source, backtrace)]
257        source: StorageError,
258        /// Report state after every deletion completed before this failure.
259        partial_report: Box<VacuumReport>,
260    },
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264enum ArtifactKind {
265    Parquet,
266    Coverage,
267    Unrecognized,
268}
269
270fn is_canonical_uuid(value: &str) -> bool {
271    Uuid::parse_str(value).is_ok_and(|uuid| uuid.hyphenated().to_string() == value)
272}
273
274fn is_managed_parquet_path(path: &str) -> bool {
275    let Some(path) = path.strip_suffix(".parquet") else {
276        return false;
277    };
278
279    if let Some(id) = path
280        .strip_prefix(storage::layout::APPEND_DATA_DIR)
281        .and_then(|path| path.strip_prefix('/'))
282    {
283        return !id.contains('/') && is_canonical_uuid(id);
284    }
285
286    let Some(path) = path
287        .strip_prefix(storage::layout::ENTITY_REWRITE_DATA_DIR)
288        .and_then(|path| path.strip_prefix('/'))
289    else {
290        return false;
291    };
292    let Some((attempt_id, ordinal)) = path.split_once('/') else {
293        return false;
294    };
295    !ordinal.contains('/')
296        && is_canonical_uuid(attempt_id)
297        && ordinal
298            .parse::<usize>()
299            .is_ok_and(|value| format!("{value:010}") == ordinal)
300}
301
302fn artifact_kind(path: &str) -> ArtifactKind {
303    if is_managed_parquet_path(path) {
304        return ArtifactKind::Parquet;
305    }
306    let path = Path::new(path);
307    let extension = path.extension().and_then(|extension| extension.to_str());
308    if (path.starts_with(SEGMENT_COVERAGE_DIR) || path.starts_with(TABLE_SNAPSHOT_DIR))
309        && extension == Some(COVERAGE_EXT)
310    {
311        ArtifactKind::Coverage
312    } else {
313        ArtifactKind::Unrecognized
314    }
315}
316
317async fn parquet_footer_is_valid(table: &TimeSeriesTable, path: &str) -> bool {
318    let Ok(mut file) =
319        storage::open_parquet_reader(table.location().as_ref(), Path::new(path)).await
320    else {
321        return false;
322    };
323    file.get_metadata(None).await.is_ok()
324}
325
326async fn retained_paths(
327    table: &TimeSeriesTable,
328) -> Result<(u64, HashMap<String, u64>), VacuumError> {
329    let mut paths = HashMap::new();
330    let state = table
331        .log
332        .replay_table_state(|version, action| match action {
333            LogAction::AddSegment(segment) => {
334                paths.entry(segment.path.clone()).or_insert(version);
335                if let Some(path) = &segment.coverage_path {
336                    paths.entry(path.clone()).or_insert(version);
337                }
338            }
339            LogAction::RemoveSegment { path } => {
340                paths.entry(path.clone()).or_insert(version);
341            }
342            LogAction::UpdateTableCoverage { coverage_path, .. } => {
343                paths.entry(coverage_path.clone()).or_insert(version);
344            }
345            LogAction::UpdateTableMeta(_) => {}
346        })
347        .await
348        .map_err(VacuumError::from)?;
349    state
350        .table_meta
351        .ensure_write_compatible()
352        .map_err(VacuumError::from)?;
353    if !matches!(state.table_meta.kind, TableKind::TimeSeries(_)) {
354        return Err(VacuumError::NotTimeSeries {
355            kind: state.table_meta.kind,
356        });
357    }
358
359    Ok((state.version, paths))
360}
361
362async fn classify_artifact(
363    table: &TimeSeriesTable,
364    file: StorageFileMetadata,
365    older_than: DateTime<Utc>,
366    retained: &HashMap<String, u64>,
367) -> VacuumArtifact {
368    let modified_at = DateTime::<Utc>::from(file.modified_at);
369    let kind = artifact_kind(&file.path);
370    let (disposition, reason) = if let Some(version) = retained.get(&file.path) {
371        (
372            VacuumArtifactDisposition::Retained,
373            VacuumArtifactReason::ReferencedByCommit { version: *version },
374        )
375    } else if kind == ArtifactKind::Unrecognized {
376        (
377            VacuumArtifactDisposition::Retained,
378            VacuumArtifactReason::UnrecognizedArtifact,
379        )
380    } else if modified_at >= older_than {
381        (
382            VacuumArtifactDisposition::Retained,
383            VacuumArtifactReason::WithinRetention,
384        )
385    } else {
386        match kind {
387            ArtifactKind::Unrecognized => (
388                VacuumArtifactDisposition::Retained,
389                VacuumArtifactReason::UnrecognizedArtifact,
390            ),
391            ArtifactKind::Parquet if !parquet_footer_is_valid(table, &file.path).await => (
392                VacuumArtifactDisposition::Removable,
393                VacuumArtifactReason::InvalidOrUnreadableParquet,
394            ),
395            ArtifactKind::Parquet | ArtifactKind::Coverage => (
396                VacuumArtifactDisposition::Removable,
397                VacuumArtifactReason::Unreferenced,
398            ),
399        }
400    };
401    VacuumArtifact {
402        path: file.path,
403        size_bytes: file.size_bytes,
404        modified_at,
405        disposition,
406        reason,
407    }
408}
409
410async fn prepare_candidate_for_delete(
411    table: &TimeSeriesTable,
412    artifact: &mut VacuumArtifact,
413) -> Result<bool, StorageError> {
414    let fresh =
415        match storage::regular_file_metadata(table.location().as_ref(), Path::new(&artifact.path))
416            .await
417        {
418            Ok(fresh) => fresh,
419            Err(StorageError::NotFound { .. }) => {
420                artifact.disposition = VacuumArtifactDisposition::AlreadyAbsent;
421                return Ok(false);
422            }
423            Err(source) => return Err(source),
424        };
425    let modified_at = DateTime::<Utc>::from(fresh.modified_at);
426    // ponytail: size and mtime are the portable identity available today; use backend
427    // generation tokens when the storage abstraction exposes them.
428    if fresh.size_bytes != artifact.size_bytes || modified_at != artifact.modified_at {
429        artifact.size_bytes = fresh.size_bytes;
430        artifact.modified_at = modified_at;
431        artifact.disposition = VacuumArtifactDisposition::Retained;
432        artifact.reason = VacuumArtifactReason::ChangedSincePlanning;
433        return Ok(false);
434    }
435    Ok(true)
436}
437
438impl TimeSeriesTable {
439    /// Inspect or delete expired files unreachable from retained table history.
440    ///
441    /// Vacuum considers regular files below `data/` and `_coverage/`. It never
442    /// deletes transaction-log files, expires snapshots, or rewrites history.
443    /// `older_than` is required and exclusive: files modified at or after the
444    /// cutoff are retained, and a future cutoff is rejected. Choose a cutoff
445    /// older than the longest expected writer duration so active writers remain
446    /// inside the retention window.
447    ///
448    /// Apply rechecks each candidate's size and modification time immediately
449    /// before deletion. The retention cutoff remains the safety boundary because
450    /// that check and deletion are not atomic.
451    ///
452    /// Apply mode may delete earlier candidates before a later deletion error.
453    /// [`VacuumError::Delete`] includes the partial report from that attempt.
454    #[tracing::instrument(
455        name = "table.vacuum",
456        target = "timeseries_table_format::table::vacuum",
457        level = "debug",
458        skip_all,
459        fields(
460            mode = mode.as_str(),
461            table_version = tracing::field::Empty,
462            outcome = tracing::field::Empty
463        )
464    )]
465    pub async fn vacuum(
466        &self,
467        older_than: DateTime<Utc>,
468        mode: VacuumMode,
469    ) -> Result<VacuumReport, TableError> {
470        let result: Result<VacuumReport, VacuumError> = async {
471            if older_than > Utc::now() {
472                return Err(VacuumError::FutureCutoff { older_than });
473            }
474            let (mut table_version, mut retained) = retained_paths(self).await?;
475            let mut files =
476                storage::list_files(self.location().as_ref(), Path::new("data")).await?;
477            files.extend(
478                storage::list_files(self.location().as_ref(), Path::new("_coverage")).await?,
479            );
480            files.sort_by(|left, right| left.path.cmp(&right.path));
481
482            let mut artifacts = Vec::with_capacity(files.len());
483            for file in files {
484                artifacts.push(classify_artifact(self, file, older_than, &retained).await);
485            }
486
487            if mode == VacuumMode::Apply {
488                (table_version, retained) = retained_paths(self).await?;
489                let mut delete_failure = None;
490                for artifact in &mut artifacts {
491                    if artifact.disposition != VacuumArtifactDisposition::Removable {
492                        continue;
493                    }
494                    if let Some(version) = retained.get(&artifact.path) {
495                        artifact.disposition = VacuumArtifactDisposition::Retained;
496                        artifact.reason =
497                            VacuumArtifactReason::ReferencedByCommit { version: *version };
498                        continue;
499                    }
500                    match prepare_candidate_for_delete(self, artifact).await {
501                        Ok(true) => {}
502                        Ok(false) => continue,
503                        Err(source) => {
504                            delete_failure = Some((artifact.path.clone(), source));
505                            break;
506                        }
507                    }
508                    match storage::remove_file(self.location().as_ref(), Path::new(&artifact.path))
509                        .await
510                    {
511                        Ok(()) => {
512                            artifact.disposition = VacuumArtifactDisposition::Deleted;
513                        }
514                        Err(StorageError::NotFound { .. }) => {
515                            artifact.disposition = VacuumArtifactDisposition::AlreadyAbsent;
516                        }
517                        Err(source) => {
518                            delete_failure = Some((artifact.path.clone(), source));
519                            break;
520                        }
521                    }
522                }
523                if let Some((path, source)) = delete_failure {
524                    return Err(VacuumError::Delete {
525                        path,
526                        source,
527                        partial_report: Box::new(VacuumReport::new(
528                            table_version,
529                            older_than,
530                            mode,
531                            artifacts,
532                        )),
533                    });
534                }
535            }
536
537            Ok(VacuumReport::new(
538                table_version,
539                older_than,
540                mode,
541                artifacts,
542            ))
543        }
544        .await;
545
546        let span = tracing::Span::current();
547        match &result {
548            Ok(report) => {
549                span.record("table_version", report.table_version);
550                span.record(
551                    "outcome",
552                    match mode {
553                        VacuumMode::DryRun => "dry_run",
554                        VacuumMode::Apply => "applied",
555                    },
556                );
557            }
558            Err(_) => {
559                span.record("outcome", "failed");
560            }
561        }
562        result.context(crate::table::error::VacuumSnafu)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use std::{
569        fs::{self, FileTimes},
570        io::Write as _,
571        time::{Duration as StdDuration, SystemTime},
572    };
573
574    use chrono::Duration;
575    use tempfile::TempDir;
576
577    use super::*;
578    use crate::{
579        coverage::EntityIdentity,
580        storage::{TableLocation, inject_cleanup_failure, layout, open_new_output_sink, write_new},
581        table::test_util::{
582            TestResult, TestRow, make_basic_table_meta, utc_datetime, write_test_parquet,
583        },
584        transaction_log::{
585            FileFormat, IndexValue, SegmentEntityLayout, SegmentMeta, TransactionLogStore,
586        },
587    };
588
589    fn artifact<'a>(report: &'a VacuumReport, path: &str) -> &'a VacuumArtifact {
590        report
591            .artifacts
592            .iter()
593            .find(|artifact| artifact.path == path)
594            .unwrap_or_else(|| panic!("missing vacuum artifact {path}"))
595    }
596
597    fn mark_expired(path: &Path) -> std::io::Result<()> {
598        fs::OpenOptions::new()
599            .write(true)
600            .open(path)?
601            .set_times(FileTimes::new().set_modified(SystemTime::UNIX_EPOCH))
602    }
603
604    fn expired_cutoff() -> DateTime<Utc> {
605        DateTime::from(SystemTime::UNIX_EPOCH + StdDuration::from_secs(1))
606    }
607
608    fn referenced_segment(
609        path: &str,
610        coverage_path: &str,
611    ) -> Result<SegmentMeta, Box<dyn std::error::Error>> {
612        Ok(SegmentMeta {
613            path: path.to_string(),
614            format: FileFormat::Parquet,
615            entity_layout: SegmentEntityLayout::Single(EntityIdentity::try_new(vec!["A".into()])?),
616            index_min: IndexValue::Timestamp(utc_datetime(2025, 1, 1, 0, 0, 0)),
617            index_max: IndexValue::Timestamp(utc_datetime(2025, 1, 1, 0, 1, 0)),
618            row_count: 2,
619            file_size: None,
620            coverage_path: Some(coverage_path.to_string()),
621        })
622    }
623
624    #[tokio::test]
625    async fn vacuum_dry_run_and_apply_preserve_retained_history_and_logs() -> TestResult {
626        let temp = TempDir::new()?;
627        let location = TableLocation::local(temp.path());
628        let table = TimeSeriesTable::create(location.clone(), make_basic_table_meta()).await?;
629        let historical_data = "data/historical.parquet";
630        let historical_coverage = "_coverage/segments/historical.roar";
631        let historical_snapshot = "_coverage/table/2-historical.roar";
632        for (path, bytes) in [
633            (historical_data, b"historical data".as_slice()),
634            (historical_coverage, b"historical coverage".as_slice()),
635            (historical_snapshot, b"historical snapshot".as_slice()),
636        ] {
637            write_new(location.as_ref(), Path::new(path), bytes).await?;
638        }
639
640        let log = TransactionLogStore::new(location.clone());
641        log.commit_with_expected_version(
642            1,
643            vec![
644                LogAction::AddSegment(referenced_segment(historical_data, historical_coverage)?),
645                LogAction::UpdateTableCoverage {
646                    index_kind: table.index_spec().kind.clone(),
647                    coverage_path: historical_snapshot.to_string(),
648                },
649            ],
650        )
651        .await?;
652        log.commit_with_expected_version(
653            2,
654            vec![LogAction::RemoveSegment {
655                path: historical_data.to_string(),
656            }],
657        )
658        .await?;
659
660        let invalid_orphan = "data/_managed/append/00000000-0000-0000-0000-000000000001.parquet";
661        let valid_orphan =
662            "data/_staged/entity-rewrite/00000000-0000-0000-0000-000000000002/0000000000.parquet";
663        let coverage_orphan = "_coverage/segments/orphan.roar";
664        let unrecognized = "data/keep.txt";
665        let external_source = "data/00000000-0000-0000-0000-000000000009.parquet";
666        write_new(location.as_ref(), Path::new(invalid_orphan), b"incomplete").await?;
667        write_test_parquet(
668            &temp.path().join(valid_orphan),
669            true,
670            false,
671            &[TestRow {
672                ts_millis: 0,
673                symbol: "A",
674                price: 1.0,
675            }],
676        )?;
677        write_new(
678            location.as_ref(),
679            Path::new(coverage_orphan),
680            b"orphan coverage",
681        )
682        .await?;
683        write_new(location.as_ref(), Path::new(unrecognized), b"keep").await?;
684        write_test_parquet(
685            &temp.path().join(external_source),
686            true,
687            false,
688            &[TestRow {
689                ts_millis: 60_000,
690                symbol: "A",
691                price: 2.0,
692            }],
693        )?;
694        for path in [
695            invalid_orphan,
696            valid_orphan,
697            coverage_orphan,
698            unrecognized,
699            external_source,
700        ] {
701            mark_expired(&temp.path().join(path))?;
702        }
703
704        let log_paths = [
705            layout::current_rel_path(),
706            layout::commit_rel_path(1),
707            layout::commit_rel_path(2),
708            layout::commit_rel_path(3),
709        ];
710        let log_before = log_paths
711            .iter()
712            .map(|path| fs::read(temp.path().join(path)))
713            .collect::<Result<Vec<_>, _>>()?;
714        let older_than = expired_cutoff();
715
716        let dry_run = table.vacuum(older_than, VacuumMode::DryRun).await?;
717
718        assert_eq!(dry_run.table_version, 3);
719        assert_eq!(dry_run.mode, VacuumMode::DryRun);
720        assert_eq!(dry_run.considered_files, 8);
721        assert_eq!(dry_run.retained_files, 5);
722        assert_eq!(dry_run.removable_files, 3);
723        assert_eq!(dry_run.deleted_files, 0);
724        assert_eq!(dry_run.deleted_bytes, 0);
725        let historical = artifact(&dry_run, historical_data);
726        assert_eq!(historical.size_bytes, b"historical data".len() as u64);
727        assert_eq!(historical.disposition, VacuumArtifactDisposition::Retained);
728        assert_eq!(
729            historical.reason,
730            VacuumArtifactReason::ReferencedByCommit { version: 2 }
731        );
732        assert_eq!(
733            artifact(&dry_run, invalid_orphan).disposition,
734            VacuumArtifactDisposition::Removable
735        );
736        assert_eq!(
737            artifact(&dry_run, invalid_orphan).reason,
738            VacuumArtifactReason::InvalidOrUnreadableParquet
739        );
740        assert_eq!(
741            artifact(&dry_run, valid_orphan).reason,
742            VacuumArtifactReason::Unreferenced
743        );
744        assert_eq!(
745            artifact(&dry_run, coverage_orphan).disposition,
746            VacuumArtifactDisposition::Removable
747        );
748        assert_eq!(
749            artifact(&dry_run, unrecognized).reason,
750            VacuumArtifactReason::UnrecognizedArtifact
751        );
752        assert_eq!(
753            artifact(&dry_run, external_source).reason,
754            VacuumArtifactReason::UnrecognizedArtifact
755        );
756        let removable_bytes = u128::from(b"incomplete".len() as u64)
757            + u128::from(fs::metadata(temp.path().join(valid_orphan))?.len())
758            + u128::from(b"orphan coverage".len() as u64);
759        assert_eq!(dry_run.removable_bytes, removable_bytes);
760        for path in [invalid_orphan, valid_orphan, coverage_orphan] {
761            assert!(temp.path().join(path).exists());
762        }
763
764        let applied = table.vacuum(older_than, VacuumMode::Apply).await?;
765
766        assert_eq!(applied.table_version, 3);
767        assert_eq!(applied.considered_files, 8);
768        assert_eq!(applied.retained_files, 5);
769        assert_eq!(applied.removable_files, 0);
770        assert_eq!(applied.deleted_files, 3);
771        assert_eq!(applied.removable_bytes, 0);
772        assert_eq!(applied.deleted_bytes, removable_bytes);
773        for path in [invalid_orphan, valid_orphan, coverage_orphan] {
774            assert_eq!(
775                artifact(&applied, path).disposition,
776                VacuumArtifactDisposition::Deleted
777            );
778            assert!(!temp.path().join(path).exists());
779        }
780        for path in [
781            historical_data,
782            historical_coverage,
783            historical_snapshot,
784            unrecognized,
785            external_source,
786        ] {
787            assert!(temp.path().join(path).exists(), "vacuum removed {path}");
788        }
789        let log_after = log_paths
790            .iter()
791            .map(|path| fs::read(temp.path().join(path)))
792            .collect::<Result<Vec<_>, _>>()?;
793        assert_eq!(log_after, log_before);
794        assert_eq!(table.current_version().await?, 3);
795        Ok(())
796    }
797
798    #[tokio::test]
799    async fn vacuum_preserves_a_recent_reserved_writer_path() -> TestResult {
800        let temp = TempDir::new()?;
801        let location = TableLocation::local(temp.path());
802        let table = TimeSeriesTable::create(location.clone(), make_basic_table_meta()).await?;
803        let path = "data/_managed/append/00000000-0000-0000-0000-000000000003.parquet";
804        let unrecognized = "data/keep.txt";
805        let mut sink = open_new_output_sink(location.as_ref(), Path::new(path)).await?;
806        sink.write_all(b"incomplete")?;
807        sink.flush()?;
808        write_new(location.as_ref(), Path::new(unrecognized), b"keep").await?;
809
810        let report = table
811            .vacuum(Utc::now() - Duration::hours(1), VacuumMode::Apply)
812            .await?;
813
814        assert_eq!(
815            artifact(&report, path).disposition,
816            VacuumArtifactDisposition::Retained
817        );
818        assert_eq!(
819            artifact(&report, path).reason,
820            VacuumArtifactReason::WithinRetention
821        );
822        assert_eq!(
823            artifact(&report, unrecognized).reason,
824            VacuumArtifactReason::UnrecognizedArtifact
825        );
826        assert!(temp.path().join(path).exists());
827        assert!(temp.path().join(unrecognized).exists());
828        drop(sink);
829        assert!(!temp.path().join(path).exists());
830        Ok(())
831    }
832
833    #[tokio::test]
834    async fn vacuum_fails_closed_when_retained_history_is_corrupt() -> TestResult {
835        let temp = TempDir::new()?;
836        let location = TableLocation::local(temp.path());
837        let table = TimeSeriesTable::create(location.clone(), make_basic_table_meta()).await?;
838        let orphan = "data/_managed/append/00000000-0000-0000-0000-000000000004.parquet";
839        write_new(location.as_ref(), Path::new(orphan), b"incomplete").await?;
840        fs::write(temp.path().join(layout::commit_rel_path(1)), b"{invalid")?;
841
842        let error = table
843            .vacuum(expired_cutoff(), VacuumMode::Apply)
844            .await
845            .expect_err("corrupt retained history must stop vacuum");
846
847        assert!(matches!(
848            error,
849            TableError::Vacuum {
850                source: VacuumError::Commit {
851                    source: CommitError::CommitDeserialization { version: 1, .. }
852                }
853            }
854        ));
855        assert!(temp.path().join(orphan).exists());
856        Ok(())
857    }
858
859    #[tokio::test]
860    async fn apply_preserves_changed_and_reports_missing_candidates() -> TestResult {
861        let temp = TempDir::new()?;
862        let location = TableLocation::local(temp.path());
863        let table = TimeSeriesTable::create(location.clone(), make_basic_table_meta()).await?;
864        let path = "data/_managed/append/00000000-0000-0000-0000-000000000005.parquet";
865        write_new(location.as_ref(), Path::new(path), b"old").await?;
866        mark_expired(&temp.path().join(path))?;
867        let file = storage::list_files(location.as_ref(), Path::new("data"))
868            .await?
869            .pop()
870            .ok_or("missing planned file")?;
871        let mut candidate =
872            classify_artifact(&table, file, expired_cutoff(), &HashMap::new()).await;
873        fs::write(temp.path().join(path), b"new contents")?;
874
875        assert!(!prepare_candidate_for_delete(&table, &mut candidate).await?);
876        assert_eq!(candidate.disposition, VacuumArtifactDisposition::Retained);
877        assert_eq!(candidate.reason, VacuumArtifactReason::ChangedSincePlanning);
878        assert_eq!(candidate.size_bytes, b"new contents".len() as u64);
879        assert!(temp.path().join(path).exists());
880
881        fs::remove_file(temp.path().join(path))?;
882        assert!(!prepare_candidate_for_delete(&table, &mut candidate).await?);
883        assert_eq!(
884            candidate.disposition,
885            VacuumArtifactDisposition::AlreadyAbsent
886        );
887        let report = VacuumReport::new(1, expired_cutoff(), VacuumMode::Apply, vec![candidate]);
888        assert_eq!(report.deleted_files, 0);
889        assert_eq!(report.deleted_bytes, 0);
890        assert_eq!(report.already_absent_files, 1);
891        assert_eq!(report.already_absent_bytes, b"new contents".len() as u128);
892        Ok(())
893    }
894
895    #[tokio::test]
896    async fn apply_error_includes_deletions_completed_before_the_failure() -> TestResult {
897        let temp = TempDir::new()?;
898        let location = TableLocation::local(temp.path());
899        let table = TimeSeriesTable::create(location.clone(), make_basic_table_meta()).await?;
900        let first = "data/_managed/append/00000000-0000-0000-0000-000000000006.parquet";
901        let failed = "data/_managed/append/00000000-0000-0000-0000-000000000007.parquet";
902        for path in [first, failed] {
903            write_new(location.as_ref(), Path::new(path), b"incomplete").await?;
904            mark_expired(&temp.path().join(path))?;
905        }
906        inject_cleanup_failure(temp.path().join(failed));
907
908        let error = table
909            .vacuum(expired_cutoff(), VacuumMode::Apply)
910            .await
911            .expect_err("injected deletion failure must fail apply");
912
913        let TableError::Vacuum {
914            source:
915                VacuumError::Delete {
916                    path,
917                    partial_report,
918                    ..
919                },
920        } = error
921        else {
922            panic!("unexpected error: {error:?}");
923        };
924        assert_eq!(path, failed);
925        assert_eq!(
926            artifact(&partial_report, first).disposition,
927            VacuumArtifactDisposition::Deleted
928        );
929        assert_eq!(
930            artifact(&partial_report, failed).disposition,
931            VacuumArtifactDisposition::Removable
932        );
933        assert!(!temp.path().join(first).exists());
934        assert!(temp.path().join(failed).exists());
935        Ok(())
936    }
937
938    #[tokio::test]
939    async fn vacuum_rejects_a_future_cutoff_before_inspecting_storage() -> TestResult {
940        let temp = TempDir::new()?;
941        let location = TableLocation::local(temp.path());
942        let table = TimeSeriesTable::create(location, make_basic_table_meta()).await?;
943        let older_than = Utc::now() + Duration::hours(1);
944
945        let error = table
946            .vacuum(older_than, VacuumMode::DryRun)
947            .await
948            .expect_err("a future retention cutoff must fail");
949
950        assert!(matches!(
951            error,
952            TableError::Vacuum {
953                source: VacuumError::FutureCutoff {
954                    older_than: rejected
955                }
956            } if rejected == older_than
957        ));
958        Ok(())
959    }
960}