1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum VacuumMode {
22 DryRun,
24 Apply,
26}
27
28impl VacuumMode {
29 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum VacuumArtifactDisposition {
42 Retained,
44 Removable,
46 Deleted,
48 AlreadyAbsent,
50}
51
52impl VacuumArtifactDisposition {
53 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#[derive(Debug, Clone, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum VacuumArtifactReason {
68 ReferencedByCommit {
70 version: u64,
72 },
73 WithinRetention,
75 ChangedSincePlanning,
77 UnrecognizedArtifact,
79 Unreferenced,
81 InvalidOrUnreadableParquet,
83}
84
85impl VacuumArtifactReason {
86 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#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VacuumArtifact {
102 pub path: String,
104 pub size_bytes: u64,
106 pub modified_at: DateTime<Utc>,
108 pub disposition: VacuumArtifactDisposition,
110 pub reason: VacuumArtifactReason,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct VacuumReport {
117 pub table_version: u64,
119 pub older_than: DateTime<Utc>,
121 pub mode: VacuumMode,
123 pub artifacts: Vec<VacuumArtifact>,
125 pub considered_files: usize,
127 pub retained_files: usize,
129 pub removable_files: usize,
131 pub deleted_files: usize,
133 pub already_absent_files: usize,
135 pub considered_bytes: u128,
137 pub retained_bytes: u128,
139 pub removable_bytes: u128,
141 pub deleted_bytes: u128,
143 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#[derive(Debug, Snafu)]
207#[snafu(module, visibility(pub(crate)))]
208#[non_exhaustive]
209pub enum VacuumError {
210 #[snafu(display("Vacuum cutoff {older_than} is in the future"))]
212 FutureCutoff {
213 older_than: DateTime<Utc>,
215 },
216
217 #[snafu(context(false), display("Table protocol error: {source}"))]
219 Protocol {
220 #[snafu(source)]
222 source: TableProtocolError,
223 backtrace: Backtrace,
225 },
226
227 #[snafu(display("Latest table kind is {kind:?}, expected a time-series table"))]
229 NotTimeSeries {
230 kind: TableKind,
232 },
233
234 #[snafu(context(false), display("Vacuum transaction-log error: {source}"))]
236 Commit {
237 #[snafu(source, backtrace)]
239 source: CommitError,
240 },
241
242 #[snafu(context(false), display("Vacuum storage error: {source}"))]
244 Storage {
245 #[snafu(source, backtrace)]
247 source: StorageError,
248 },
249
250 #[snafu(display("Failed to delete vacuum candidate {path}: {source}"))]
252 Delete {
253 path: String,
255 #[snafu(source, backtrace)]
257 source: StorageError,
258 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 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 #[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}