1use std::collections::HashSet;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use chrono::Utc;
23
24use crate::spec::{
25 MAIN_BRANCH, SnapshotReference, SnapshotRetention, TableMetadata, TableProperties,
26};
27use crate::table::Table;
28use crate::transaction::action::{ActionCommit, TransactionAction};
29use crate::{Error, ErrorKind, Result, TableRequirement, TableUpdate};
30
31pub struct ExpireSnapshotsAction {
55 explicit_ids_to_remove: Vec<i64>,
56 older_than_ms: Option<i64>,
57 retain_last: Option<usize>,
58}
59
60impl ExpireSnapshotsAction {
61 pub(crate) fn new() -> Self {
62 Self {
63 explicit_ids_to_remove: vec![],
64 older_than_ms: None,
65 retain_last: None,
66 }
67 }
68
69 pub fn expire_snapshot_ids(mut self, snapshot_ids: impl IntoIterator<Item = i64>) -> Self {
79 self.explicit_ids_to_remove.extend(snapshot_ids);
80 self
81 }
82
83 pub fn expire_older_than_ms(mut self, older_than_ms: i64) -> Self {
85 self.older_than_ms = Some(older_than_ms);
86 self
87 }
88
89 pub fn retain_last(mut self, retain_last: usize) -> Self {
95 self.retain_last = Some(retain_last);
96 self
97 }
98
99 fn plan(&self, table: &Table, properties: &TableProperties<'_>) -> Result<ExpirePlan> {
101 if self.retain_last == Some(0) {
103 return Err(Error::new(
104 ErrorKind::DataInvalid,
105 "Number of snapshots to retain must be at least 1",
106 ));
107 }
108
109 let metadata = table.metadata();
110 let now = Utc::now().timestamp_millis();
111 let default_cutoff = match self.older_than_ms {
115 Some(older_than_ms) => older_than_ms,
116 None => now.saturating_sub(properties.max_snapshot_age_ms()?),
117 };
118 let default_min_to_keep = match self.retain_last {
119 Some(retain_last) => retain_last,
120 None => properties.min_snapshots_to_keep()?,
121 };
122
123 let default_max_ref_age_ms = properties.max_ref_age_ms()?;
127 let mut removed_ref_names: Vec<String> = vec![];
128 let mut retained_refs: Vec<&SnapshotReference> = vec![];
129 for (ref_name, snapshot_ref) in &metadata.refs {
130 if ref_name == MAIN_BRANCH
131 || !Self::ref_aged_out(metadata, snapshot_ref, now, default_max_ref_age_ms)
132 {
133 retained_refs.push(snapshot_ref);
134 } else {
135 removed_ref_names.push(ref_name.clone());
136 }
137 }
138
139 let mut ref_head_ids: HashSet<i64> = retained_refs.iter().map(|r| r.snapshot_id).collect();
142 if let Some(current_id) = metadata.current_snapshot_id() {
143 ref_head_ids.insert(current_id);
144 }
145
146 let existing_ids: HashSet<i64> = metadata.snapshots().map(|s| s.snapshot_id()).collect();
147 let mut expiring_ids: HashSet<i64> = HashSet::new();
148 for id in &self.explicit_ids_to_remove {
149 if ref_head_ids.contains(id) {
150 return Err(Self::reference_error(metadata, *id));
151 }
152 if existing_ids.contains(id) {
153 expiring_ids.insert(*id);
154 }
155 }
156
157 let mut retained_ids = ref_head_ids.clone();
161 let mut referenced_ids = ref_head_ids.clone();
162 let mut branches: Vec<(i64, usize, i64)> = vec![];
163 for snapshot_ref in &retained_refs {
164 match &snapshot_ref.retention {
165 SnapshotRetention::Branch {
166 min_snapshots_to_keep,
167 max_snapshot_age_ms,
168 ..
169 } => {
170 let min_to_keep =
171 min_snapshots_to_keep.map_or(default_min_to_keep, |m| m as usize);
172 let cutoff =
173 max_snapshot_age_ms.map_or(default_cutoff, |age| now.saturating_sub(age));
174 branches.push((snapshot_ref.snapshot_id, min_to_keep, cutoff));
175 }
176 SnapshotRetention::Tag { .. } => {
177 referenced_ids.insert(snapshot_ref.snapshot_id);
178 }
179 }
180 }
181 if let Some(current_id) = metadata.current_snapshot_id()
182 && !branches
183 .iter()
184 .any(|(head_id, _, _)| *head_id == current_id)
185 {
186 branches.push((current_id, default_min_to_keep, default_cutoff));
187 }
188 for (head_id, min_to_keep, cutoff) in branches {
189 Self::retain_branch(
190 metadata,
191 head_id,
192 min_to_keep,
193 cutoff,
194 &mut retained_ids,
195 &mut referenced_ids,
196 );
197 }
198
199 for snapshot in metadata.snapshots() {
202 let id = snapshot.snapshot_id();
203 if !referenced_ids.contains(&id) && snapshot.timestamp_ms() >= default_cutoff {
204 retained_ids.insert(id);
205 }
206 }
207 for snapshot in metadata.snapshots() {
208 if !retained_ids.contains(&snapshot.snapshot_id()) {
209 expiring_ids.insert(snapshot.snapshot_id());
210 }
211 }
212
213 let mut ids_to_remove: Vec<i64> = expiring_ids.into_iter().collect();
214 ids_to_remove.sort_unstable();
215 removed_ref_names.sort();
216 Ok(ExpirePlan {
217 ids_to_remove,
218 refs_to_remove: removed_ref_names,
219 })
220 }
221
222 fn ref_aged_out(
226 metadata: &TableMetadata,
227 snapshot_ref: &SnapshotReference,
228 now: i64,
229 default_max_ref_age_ms: i64,
230 ) -> bool {
231 let max_ref_age_ms = match snapshot_ref.retention {
232 SnapshotRetention::Branch { max_ref_age_ms, .. }
233 | SnapshotRetention::Tag { max_ref_age_ms } => max_ref_age_ms,
234 }
235 .unwrap_or(default_max_ref_age_ms);
236 match metadata.snapshot_by_id(snapshot_ref.snapshot_id) {
237 Some(snapshot) => now.saturating_sub(snapshot.timestamp_ms()) > max_ref_age_ms,
238 None => false,
239 }
240 }
241
242 fn retain_branch(
246 metadata: &TableMetadata,
247 head_id: i64,
248 min_to_keep: usize,
249 cutoff: i64,
250 retained_ids: &mut HashSet<i64>,
251 referenced_ids: &mut HashSet<i64>,
252 ) {
253 let mut kept_count = 0usize;
254 for ancestor_id in Self::ancestors(metadata, head_id) {
255 referenced_ids.insert(ancestor_id);
256 let timestamp = metadata
257 .snapshot_by_id(ancestor_id)
258 .map_or(i64::MIN, |snapshot| snapshot.timestamp_ms());
259 if kept_count < min_to_keep || timestamp >= cutoff {
260 retained_ids.insert(ancestor_id);
261 kept_count += 1;
262 }
263 }
264 }
265
266 fn ancestors(metadata: &TableMetadata, head_id: i64) -> impl Iterator<Item = i64> + '_ {
268 let mut next_id = Some(head_id);
269 std::iter::from_fn(move || {
270 let id = next_id?;
271 next_id = metadata
272 .snapshot_by_id(id)
273 .and_then(|snapshot| snapshot.parent_snapshot_id());
274 Some(id)
275 })
276 }
277
278 fn reference_error(metadata: &TableMetadata, snapshot_id: i64) -> Error {
279 if metadata.current_snapshot_id() == Some(snapshot_id) {
280 return Error::new(ErrorKind::DataInvalid, "Cannot expire the current snapshot");
281 }
282 let ref_names: Vec<&str> = metadata
283 .refs
284 .iter()
285 .filter(|(_, snapshot_ref)| snapshot_ref.snapshot_id == snapshot_id)
286 .map(|(ref_name, _)| ref_name.as_str())
287 .collect();
288 Error::new(
289 ErrorKind::DataInvalid,
290 format!("Cannot expire snapshot {snapshot_id}: still referenced by {ref_names:?}"),
291 )
292 }
293}
294
295struct ExpirePlan {
297 ids_to_remove: Vec<i64>,
298 refs_to_remove: Vec<String>,
299}
300
301#[async_trait]
302impl TransactionAction for ExpireSnapshotsAction {
303 async fn commit(self: Arc<Self>, table: &Table) -> Result<ActionCommit> {
304 let metadata = table.metadata();
305 let properties = metadata.table_properties();
306
307 if !properties.gc_enabled()? {
309 return Err(Error::new(
310 ErrorKind::DataInvalid,
311 "Cannot expire snapshots: gc.enabled is false",
312 ));
313 }
314
315 let plan = self.plan(table, &properties)?;
316
317 if plan.ids_to_remove.is_empty() && plan.refs_to_remove.is_empty() {
318 return Ok(ActionCommit::new(vec![], vec![]));
319 }
320
321 let mut updates: Vec<TableUpdate> = plan
323 .refs_to_remove
324 .into_iter()
325 .map(|ref_name| TableUpdate::RemoveSnapshotRef { ref_name })
326 .collect();
327
328 let mut stats_updates: Vec<TableUpdate> = vec![];
331 for &snapshot_id in &plan.ids_to_remove {
332 stats_updates.extend(
333 metadata
334 .statistics_for_snapshot(snapshot_id)
335 .is_some()
336 .then_some(TableUpdate::RemoveStatistics { snapshot_id }),
337 );
338 stats_updates.extend(
339 metadata
340 .partition_statistics_for_snapshot(snapshot_id)
341 .is_some()
342 .then_some(TableUpdate::RemovePartitionStatistics { snapshot_id }),
343 );
344 }
345
346 if !plan.ids_to_remove.is_empty() {
347 updates.push(TableUpdate::RemoveSnapshots {
348 snapshot_ids: plan.ids_to_remove,
349 });
350 }
351 updates.extend(stats_updates);
352
353 Ok(ActionCommit::new(updates, vec![
356 TableRequirement::UuidMatch {
357 uuid: metadata.uuid(),
358 },
359 TableRequirement::RefSnapshotIdMatch {
360 r#ref: MAIN_BRANCH.to_string(),
361 snapshot_id: metadata.current_snapshot_id(),
362 },
363 ]))
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use std::collections::HashMap;
370 use std::sync::Arc;
371
372 use chrono::Utc;
373
374 use crate::spec::{
375 MAIN_BRANCH, Operation, PartitionStatisticsFile, Snapshot, SnapshotReference,
376 SnapshotRetention, StatisticsFile, Summary,
377 };
378 use crate::table::Table;
379 use crate::transaction::Transaction;
380 use crate::transaction::action::{ApplyTransactionAction, TransactionAction};
381 use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
382 use crate::transaction::tests::{make_v2_minimal_table, make_v2_table};
383 use crate::{TableRequirement, TableUpdate};
384
385 const OLD_SNAPSHOT: i64 = 3051729675574597004;
388 const CURRENT_SNAPSHOT: i64 = 3055729675574597004;
389 const TS: i64 = 1_700_000_000_000;
391 const NO_AGE_EXPIRY: i64 = 0;
394
395 fn action() -> ExpireSnapshotsAction {
396 ExpireSnapshotsAction::new()
397 }
398
399 async fn removed_ids(action: ExpireSnapshotsAction) -> Vec<i64> {
400 expired(&make_v2_table(), action).await
401 }
402
403 async fn updates_of(table: &Table, action: ExpireSnapshotsAction) -> Vec<TableUpdate> {
404 Arc::new(action).commit(table).await.unwrap().take_updates()
405 }
406
407 async fn expired(table: &Table, action: ExpireSnapshotsAction) -> Vec<i64> {
408 updates_of(table, action)
409 .await
410 .into_iter()
411 .find_map(|update| match update {
412 TableUpdate::RemoveSnapshots { snapshot_ids } => Some(snapshot_ids),
413 _ => None,
414 })
415 .unwrap_or_default()
416 }
417
418 fn removed_refs(updates: &[TableUpdate]) -> Vec<String> {
419 let mut refs: Vec<String> = updates
420 .iter()
421 .filter_map(|update| match update {
422 TableUpdate::RemoveSnapshotRef { ref_name } => Some(ref_name.clone()),
423 _ => None,
424 })
425 .collect();
426 refs.sort();
427 refs
428 }
429
430 fn snapshot(id: i64, parent: Option<i64>, sequence_number: i64, timestamp_ms: i64) -> Snapshot {
431 Snapshot::builder()
432 .with_snapshot_id(id)
433 .with_parent_snapshot_id(parent)
434 .with_sequence_number(sequence_number)
435 .with_timestamp_ms(timestamp_ms)
436 .with_schema_id(0)
437 .with_manifest_list(format!("/snap-{id}.avro"))
438 .with_summary(Summary {
439 operation: Operation::Append,
440 additional_properties: HashMap::new(),
441 })
442 .build()
443 }
444
445 fn branch(snapshot_id: i64, min_snapshots_to_keep: Option<i32>) -> SnapshotReference {
446 branch_with(snapshot_id, min_snapshots_to_keep, None, None)
447 }
448
449 fn branch_with(
450 snapshot_id: i64,
451 min_snapshots_to_keep: Option<i32>,
452 max_snapshot_age_ms: Option<i64>,
453 max_ref_age_ms: Option<i64>,
454 ) -> SnapshotReference {
455 SnapshotReference {
456 snapshot_id,
457 retention: SnapshotRetention::Branch {
458 min_snapshots_to_keep,
459 max_snapshot_age_ms,
460 max_ref_age_ms,
461 },
462 }
463 }
464
465 fn tag(snapshot_id: i64, max_ref_age_ms: Option<i64>) -> SnapshotReference {
466 SnapshotReference {
467 snapshot_id,
468 retention: SnapshotRetention::Tag { max_ref_age_ms },
469 }
470 }
471
472 fn table_with(snapshots: Vec<Snapshot>, refs: Vec<(&str, SnapshotReference)>) -> Table {
474 table_with_props(snapshots, refs, HashMap::new())
475 }
476
477 fn table_with_props(
479 snapshots: Vec<Snapshot>,
480 refs: Vec<(&str, SnapshotReference)>,
481 properties: HashMap<String, String>,
482 ) -> Table {
483 let base = make_v2_minimal_table();
484 let mut builder = base
485 .metadata()
486 .clone()
487 .into_builder(None)
488 .set_properties(properties)
489 .unwrap();
490 for snapshot in snapshots {
491 builder = builder.add_snapshot(snapshot).unwrap();
492 }
493 for (name, reference) in refs {
494 builder = builder.set_ref(name, reference).unwrap();
495 }
496 base.with_metadata(Arc::new(builder.build().unwrap().metadata))
497 }
498
499 fn table_with_stats(
502 snapshots: Vec<Snapshot>,
503 refs: Vec<(&str, SnapshotReference)>,
504 statistics: Vec<StatisticsFile>,
505 partition_statistics: Vec<PartitionStatisticsFile>,
506 ) -> Table {
507 let table = table_with(snapshots, refs);
508 let mut builder = table.metadata().clone().into_builder(None);
509 for stats in statistics {
510 builder = builder.set_statistics(stats);
511 }
512 for stats in partition_statistics {
513 builder = builder.set_partition_statistics(stats);
514 }
515 table.with_metadata(Arc::new(builder.build().unwrap().metadata))
516 }
517
518 fn stats_file(snapshot_id: i64) -> StatisticsFile {
519 StatisticsFile {
520 snapshot_id,
521 statistics_path: format!("/stats-{snapshot_id}.puffin"),
522 file_size_in_bytes: 1,
523 file_footer_size_in_bytes: 1,
524 key_metadata: None,
525 blob_metadata: vec![],
526 }
527 }
528
529 fn partition_stats_file(snapshot_id: i64) -> PartitionStatisticsFile {
530 PartitionStatisticsFile {
531 snapshot_id,
532 statistics_path: format!("/partition-stats-{snapshot_id}.puffin"),
533 file_size_in_bytes: 1,
534 }
535 }
536
537 fn removed_statistics(updates: &[TableUpdate]) -> Vec<i64> {
538 updates
539 .iter()
540 .filter_map(|update| match update {
541 TableUpdate::RemoveStatistics { snapshot_id } => Some(*snapshot_id),
542 _ => None,
543 })
544 .collect()
545 }
546
547 fn removed_partition_statistics(updates: &[TableUpdate]) -> Vec<i64> {
548 updates
549 .iter()
550 .filter_map(|update| match update {
551 TableUpdate::RemovePartitionStatistics { snapshot_id } => Some(*snapshot_id),
552 _ => None,
553 })
554 .collect()
555 }
556
557 #[tokio::test]
558 async fn test_expire_explicit_snapshot_id() {
559 assert_eq!(
560 removed_ids(
561 action()
562 .expire_snapshot_ids(vec![OLD_SNAPSHOT])
563 .expire_older_than_ms(NO_AGE_EXPIRY)
564 )
565 .await,
566 vec![OLD_SNAPSHOT]
567 );
568 }
569
570 #[tokio::test]
571 async fn test_explicit_unknown_id_is_ignored() {
572 assert!(
573 removed_ids(
574 action()
575 .expire_snapshot_ids(vec![42])
576 .expire_older_than_ms(NO_AGE_EXPIRY)
577 )
578 .await
579 .is_empty()
580 );
581 }
582
583 #[tokio::test]
584 async fn test_cannot_expire_current_snapshot() {
585 let table = make_v2_table();
586 let action = action().expire_snapshot_ids(vec![CURRENT_SNAPSHOT]);
587 assert!(Arc::new(action).commit(&table).await.is_err());
588 }
589
590 fn table_with_tag_on_old() -> Table {
592 let table = make_v2_table();
593 let metadata = table
594 .metadata()
595 .clone()
596 .into_builder(None)
597 .set_ref("history-tag", SnapshotReference {
598 snapshot_id: OLD_SNAPSHOT,
599 retention: SnapshotRetention::Tag {
600 max_ref_age_ms: None,
601 },
602 })
603 .unwrap()
604 .build()
605 .unwrap()
606 .metadata;
607 table.with_metadata(Arc::new(metadata))
608 }
609
610 #[tokio::test]
611 async fn test_cannot_expire_tagged_snapshot_explicitly() {
612 let table = table_with_tag_on_old();
613 let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
614 assert!(Arc::new(action).commit(&table).await.is_err());
615 }
616
617 #[tokio::test]
618 async fn test_age_expiry_skips_tagged_snapshot() {
619 let table = table_with_tag_on_old();
620 let mut commit = Arc::new(action().expire_older_than_ms(i64::MAX))
621 .commit(&table)
622 .await
623 .unwrap();
624 assert!(commit.take_updates().is_empty());
626 }
627
628 #[tokio::test]
629 async fn test_retain_last_default_expires_older_non_current() {
630 assert_eq!(
631 removed_ids(action().expire_older_than_ms(i64::MAX)).await,
632 vec![OLD_SNAPSHOT]
633 );
634 }
635
636 #[tokio::test]
637 async fn test_retain_last_noop_when_enough_retained() {
638 assert!(removed_ids(action().retain_last(5)).await.is_empty());
639 }
640
641 #[tokio::test]
642 async fn test_older_than_excludes_newer_snapshots() {
643 assert!(
645 removed_ids(action().expire_older_than_ms(1))
646 .await
647 .is_empty()
648 );
649 }
650
651 #[tokio::test]
652 async fn test_apply_registers_action() {
653 let table = make_v2_table();
654 let tx = Transaction::new(&table);
655 let tx = tx
656 .expire_snapshots()
657 .expire_snapshot_ids(vec![OLD_SNAPSHOT])
658 .apply(tx)
659 .unwrap();
660 assert_eq!(tx.actions.len(), 1);
661 }
662
663 #[tokio::test]
664 async fn test_per_branch_retention_protects_shared_ancestor() {
665 let table = table_with(
668 vec![
669 snapshot(1, None, 35, TS + 1),
670 snapshot(2, Some(1), 36, TS + 2),
671 snapshot(3, Some(2), 37, TS + 3),
672 snapshot(4, Some(2), 38, TS + 4),
673 ],
674 vec![(MAIN_BRANCH, branch(3, None)), ("b", branch(4, None))],
675 );
676
677 let removed = expired(
679 &table,
680 action().retain_last(2).expire_older_than_ms(i64::MAX),
681 )
682 .await;
683 assert_eq!(removed, vec![1]);
684 }
685
686 #[tokio::test]
687 async fn test_per_ref_min_snapshots_to_keep_overrides_retain_last() {
688 let table = table_with(
689 vec![
690 snapshot(1, None, 35, TS + 1),
691 snapshot(2, Some(1), 36, TS + 2),
692 snapshot(3, Some(2), 37, TS + 3),
693 ],
694 vec![(MAIN_BRANCH, branch(3, Some(3)))],
695 );
696
697 let removed = expired(
699 &table,
700 action().retain_last(1).expire_older_than_ms(i64::MAX),
701 )
702 .await;
703 assert!(removed.is_empty());
704 }
705
706 #[tokio::test]
707 async fn test_explicit_and_age_combine() {
708 let table = table_with(
709 vec![
710 snapshot(1, None, 35, TS + 1),
711 snapshot(2, Some(1), 36, TS + 2),
712 snapshot(3, Some(2), 37, TS + 3),
713 snapshot(4, Some(3), 38, TS + 4),
714 ],
715 vec![(MAIN_BRANCH, branch(4, None))],
716 );
717
718 let removed = expired(
721 &table,
722 action()
723 .retain_last(1)
724 .expire_older_than_ms(TS + 3)
725 .expire_snapshot_ids(vec![3]),
726 )
727 .await;
728 assert_eq!(removed, vec![1, 2, 3]);
729 }
730
731 #[tokio::test]
732 async fn test_expire_snapshot_ids_accumulates() {
733 let table = table_with(
734 vec![
735 snapshot(1, None, 35, TS + 1),
736 snapshot(2, Some(1), 36, TS + 2),
737 snapshot(3, Some(2), 37, TS + 3),
738 ],
739 vec![(MAIN_BRANCH, branch(3, None))],
740 );
741
742 let removed = expired(
744 &table,
745 action()
746 .expire_snapshot_ids(vec![1])
747 .expire_snapshot_ids(vec![2])
748 .expire_older_than_ms(NO_AGE_EXPIRY),
749 )
750 .await;
751 assert_eq!(removed, vec![1, 2]);
752 }
753
754 #[tokio::test]
755 async fn test_gc_disabled_errors() {
756 let table = make_v2_table();
757 let metadata = table
758 .metadata()
759 .clone()
760 .into_builder(None)
761 .set_properties(HashMap::from([(
762 "gc.enabled".to_string(),
763 "false".to_string(),
764 )]))
765 .unwrap()
766 .build()
767 .unwrap()
768 .metadata;
769 let table = table.with_metadata(Arc::new(metadata));
770
771 let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
772 assert!(Arc::new(action).commit(&table).await.is_err());
773 }
774
775 #[tokio::test]
776 async fn test_commit_asserts_main_ref() {
777 let table = make_v2_table();
778 let mut commit = Arc::new(action().expire_snapshot_ids(vec![OLD_SNAPSHOT]))
779 .commit(&table)
780 .await
781 .unwrap();
782 assert!(
783 commit
784 .take_requirements()
785 .iter()
786 .any(|requirement| matches!(
787 requirement,
788 TableRequirement::RefSnapshotIdMatch { r#ref, snapshot_id }
789 if r#ref == MAIN_BRANCH && *snapshot_id == Some(CURRENT_SNAPSHOT)
790 ))
791 );
792 }
793
794 #[tokio::test]
795 async fn test_ref_aging_drops_old_tag_and_expires_its_snapshot() {
796 let now = Utc::now().timestamp_millis();
797 let day_ms = 24 * 60 * 60 * 1000;
798 let table = table_with(
801 vec![
802 snapshot(1, None, 35, now - 10 * day_ms),
803 snapshot(2, None, 36, now - 1000),
804 ],
805 vec![
807 ("old-tag", tag(1, Some(day_ms))),
808 (MAIN_BRANCH, branch(2, None)),
809 ],
810 );
811
812 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
814 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
816 assert!(updates.iter().any(
817 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
818 ));
819 }
820
821 #[tokio::test]
822 async fn test_ref_aging_keeps_recent_tag() {
823 let now = Utc::now().timestamp_millis();
824 let day_ms = 24 * 60 * 60 * 1000;
825 let table = table_with(
826 vec![
827 snapshot(1, None, 35, now - 1000),
828 snapshot(2, None, 36, now - 500),
829 ],
830 vec![
831 ("fresh-tag", tag(1, Some(day_ms))),
832 (MAIN_BRANCH, branch(2, None)),
833 ],
834 );
835
836 let updates = updates_of(&table, action()).await;
837 assert!(removed_refs(&updates).is_empty());
839 assert_eq!(expired(&table, action()).await, Vec::<i64>::new());
840 }
841
842 #[tokio::test]
843 async fn test_per_ref_max_snapshot_age_overrides_default() {
844 let now = Utc::now().timestamp_millis();
845 let day_ms = 24 * 60 * 60 * 1000;
846 let table = table_with(
848 vec![
849 snapshot(1, None, 35, now - 3 * day_ms),
850 snapshot(2, Some(1), 36, now - day_ms),
851 snapshot(3, Some(2), 37, now - 1000),
852 ],
853 vec![(MAIN_BRANCH, branch_with(3, Some(1), Some(2 * day_ms), None))],
854 );
855
856 let removed = expired(&table, action()).await;
858 assert_eq!(removed, vec![1]);
859 }
860
861 #[tokio::test]
862 async fn test_ref_aging_drops_old_branch() {
863 let now = Utc::now().timestamp_millis();
864 let day_ms = 24 * 60 * 60 * 1000;
865 let table = table_with(
867 vec![
868 snapshot(1, None, 35, now - 10 * day_ms),
869 snapshot(2, None, 36, now - 1000),
870 ],
871 vec![
872 ("stale", branch_with(1, None, None, Some(day_ms))),
873 (MAIN_BRANCH, branch(2, None)),
874 ],
875 );
876
877 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
878 assert_eq!(removed_refs(&updates), vec!["stale".to_string()]);
880 assert!(updates.iter().any(
881 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
882 ));
883 }
884
885 #[tokio::test]
886 async fn test_unreferenced_snapshots_retained_only_while_young() {
887 let now = Utc::now().timestamp_millis();
888 let day_ms = 24 * 60 * 60 * 1000;
889 let table = table_with(
892 vec![
893 snapshot(3, None, 35, now - 10 * day_ms), snapshot(1, None, 36, now - 1000), snapshot(2, None, 37, now - 1000), ],
897 vec![(MAIN_BRANCH, branch(1, None))],
898 );
899
900 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
902 assert_eq!(removed, vec![3]);
903 }
904
905 #[tokio::test]
906 async fn test_retain_last_zero_errors() {
907 let table = make_v2_table();
908 assert!(
909 Arc::new(action().retain_last(0))
910 .commit(&table)
911 .await
912 .is_err()
913 );
914 }
915
916 #[tokio::test]
917 async fn test_cannot_expire_branch_head_explicitly() {
918 let table = table_with(
919 vec![snapshot(1, None, 35, TS + 1), snapshot(2, None, 36, TS + 2)],
920 vec![(MAIN_BRANCH, branch(1, None)), ("branch", branch(2, None))],
921 );
922 let action = action().expire_snapshot_ids(vec![2]);
924 assert!(Arc::new(action).commit(&table).await.is_err());
925 }
926
927 #[tokio::test]
928 async fn test_tag_does_not_protect_its_ancestry() {
929 let now = Utc::now().timestamp_millis();
930 let day_ms = 24 * 60 * 60 * 1000;
931 let table = table_with(
934 vec![
935 snapshot(1, None, 35, now - 10 * day_ms),
936 snapshot(2, Some(1), 36, now - 8 * day_ms),
937 snapshot(3, Some(2), 37, now - 1000),
938 ],
939 vec![(MAIN_BRANCH, branch(1, None)), ("tag", tag(3, None))],
940 );
941
942 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
943 assert_eq!(removed, vec![2]);
944 }
945
946 #[tokio::test]
947 async fn test_branch_protects_its_ancestry() {
948 let now = Utc::now().timestamp_millis();
949 let day_ms = 24 * 60 * 60 * 1000;
950 let table = table_with(
953 vec![
954 snapshot(1, None, 35, now - 10 * day_ms),
955 snapshot(2, Some(1), 36, now - 8 * day_ms),
956 snapshot(3, Some(2), 37, now - 1000),
957 ],
958 vec![
959 (MAIN_BRANCH, branch(1, None)),
960 ("branch", branch_with(3, None, Some(i64::MAX), None)),
961 ],
962 );
963
964 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
965 assert!(removed.is_empty());
966 }
967
968 #[tokio::test]
969 async fn test_per_branch_max_snapshot_age_differs_across_branches() {
970 let now = Utc::now().timestamp_millis();
971 let day_ms = 24 * 60 * 60 * 1000;
972 let table = table_with(
975 vec![
976 snapshot(1, None, 35, now - 30 * day_ms),
977 snapshot(3, None, 36, now - 30 * day_ms),
978 snapshot(2, Some(1), 37, now - 2 * day_ms),
979 snapshot(4, Some(3), 38, now - 2 * day_ms),
980 ],
981 vec![
982 (MAIN_BRANCH, branch_with(2, None, Some(5 * day_ms), None)),
983 ("keep", branch_with(4, None, Some(60 * day_ms), None)),
984 ],
985 );
986
987 let removed = expired(&table, action()).await;
990 assert_eq!(removed, vec![1]);
991 }
992
993 #[tokio::test]
994 async fn test_default_cutoff_expires_snapshots_older_than_max_age() {
995 let now = Utc::now().timestamp_millis();
996 let day_ms = 24 * 60 * 60 * 1000;
997 let table = table_with(
998 vec![
999 snapshot(1, None, 35, now - 10 * day_ms), snapshot(2, Some(1), 36, now - 1000), ],
1002 vec![(MAIN_BRANCH, branch(2, None))],
1003 );
1004
1005 let removed = expired(&table, action()).await;
1007 assert_eq!(removed, vec![1]);
1008 }
1009
1010 #[tokio::test]
1011 async fn test_min_snapshots_to_keep_property_is_the_default_floor() {
1012 let table = table_with_props(
1013 vec![
1014 snapshot(1, None, 35, TS + 1),
1015 snapshot(2, Some(1), 36, TS + 2),
1016 snapshot(3, Some(2), 37, TS + 3),
1017 ],
1018 vec![(MAIN_BRANCH, branch(3, None))],
1019 HashMap::from([(
1020 "history.expire.min-snapshots-to-keep".to_string(),
1021 "3".to_string(),
1022 )]),
1023 );
1024
1025 let removed = expired(&table, action()).await;
1028 assert!(removed.is_empty());
1029 }
1030
1031 #[tokio::test]
1032 async fn test_max_snapshot_age_ms_property_sets_the_cutoff() {
1033 let now = Utc::now().timestamp_millis();
1034 let day_ms = 24 * 60 * 60 * 1000;
1035 let table = table_with_props(
1039 vec![
1040 snapshot(1, None, 35, now - 2 * day_ms),
1041 snapshot(2, Some(1), 36, now - 1000),
1042 ],
1043 vec![(MAIN_BRANCH, branch(2, None))],
1044 HashMap::from([(
1045 "history.expire.max-snapshot-age-ms".to_string(),
1046 day_ms.to_string(),
1047 )]),
1048 );
1049
1050 let removed = expired(&table, action()).await;
1051 assert_eq!(removed, vec![1]);
1052 }
1053
1054 #[tokio::test]
1055 async fn test_max_ref_age_ms_property_ages_out_ref_without_its_own_window() {
1056 let now = Utc::now().timestamp_millis();
1057 let day_ms = 24 * 60 * 60 * 1000;
1058 let table = table_with_props(
1062 vec![
1063 snapshot(1, None, 35, now - 10 * day_ms),
1064 snapshot(2, None, 36, now - 1000),
1065 ],
1066 vec![("old-tag", tag(1, None)), (MAIN_BRANCH, branch(2, None))],
1067 HashMap::from([(
1068 "history.expire.max-ref-age-ms".to_string(),
1069 day_ms.to_string(),
1070 )]),
1071 );
1072
1073 let updates = updates_of(&table, action()).await;
1074 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1075 assert!(updates.iter().any(
1077 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
1078 ));
1079 }
1080
1081 #[tokio::test]
1082 async fn test_expiring_snapshot_drops_its_statistics() {
1083 let table = table_with_stats(
1085 vec![
1086 snapshot(1, None, 35, TS + 1),
1087 snapshot(2, Some(1), 36, TS + 2),
1088 ],
1089 vec![(MAIN_BRANCH, branch(2, None))],
1090 vec![stats_file(1), stats_file(2)],
1091 vec![partition_stats_file(1), partition_stats_file(2)],
1092 );
1093
1094 let updates = updates_of(
1095 &table,
1096 action().retain_last(1).expire_older_than_ms(i64::MAX),
1097 )
1098 .await;
1099
1100 assert_eq!(removed_statistics(&updates), vec![1]);
1102 assert_eq!(removed_partition_statistics(&updates), vec![1]);
1103 }
1104
1105 #[tokio::test]
1106 async fn test_expiring_snapshot_without_statistics_emits_no_removal() {
1107 let table = table_with(
1109 vec![
1110 snapshot(1, None, 35, TS + 1),
1111 snapshot(2, Some(1), 36, TS + 2),
1112 ],
1113 vec![(MAIN_BRANCH, branch(2, None))],
1114 );
1115
1116 let updates = updates_of(
1117 &table,
1118 action().retain_last(1).expire_older_than_ms(i64::MAX),
1119 )
1120 .await;
1121
1122 assert!(removed_statistics(&updates).is_empty());
1123 assert!(removed_partition_statistics(&updates).is_empty());
1124 }
1125
1126 #[tokio::test]
1127 async fn test_only_present_statistics_variant_is_removed() {
1128 let table = table_with_stats(
1130 vec![
1131 snapshot(1, None, 35, TS + 1),
1132 snapshot(2, Some(1), 36, TS + 2),
1133 ],
1134 vec![(MAIN_BRANCH, branch(2, None))],
1135 vec![stats_file(1)],
1136 vec![],
1137 );
1138
1139 let updates = updates_of(
1140 &table,
1141 action().retain_last(1).expire_older_than_ms(i64::MAX),
1142 )
1143 .await;
1144
1145 assert_eq!(removed_statistics(&updates), vec![1]);
1146 assert!(removed_partition_statistics(&updates).is_empty());
1147 }
1148
1149 #[tokio::test]
1150 async fn test_ref_aging_expiry_drops_statistics() {
1151 let now = Utc::now().timestamp_millis();
1152 let day_ms = 24 * 60 * 60 * 1000;
1153 let table = table_with_stats(
1157 vec![
1158 snapshot(1, None, 35, now - 10 * day_ms),
1159 snapshot(2, None, 36, now - 1000),
1160 ],
1161 vec![
1162 ("old-tag", tag(1, Some(day_ms))),
1163 (MAIN_BRANCH, branch(2, None)),
1164 ],
1165 vec![stats_file(1)],
1166 vec![partition_stats_file(1)],
1167 );
1168
1169 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
1170 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1171 assert_eq!(removed_statistics(&updates), vec![1]);
1172 assert_eq!(removed_partition_statistics(&updates), vec![1]);
1173 }
1174
1175 #[tokio::test]
1176 async fn test_multiple_expired_snapshots_drop_their_statistics() {
1177 let table = table_with_stats(
1180 vec![
1181 snapshot(1, None, 35, TS + 1),
1182 snapshot(2, Some(1), 36, TS + 2),
1183 snapshot(3, Some(2), 37, TS + 3),
1184 ],
1185 vec![(MAIN_BRANCH, branch(3, None))],
1186 vec![stats_file(1), stats_file(2)],
1187 vec![partition_stats_file(1), partition_stats_file(2)],
1188 );
1189
1190 let updates = updates_of(
1191 &table,
1192 action().retain_last(1).expire_older_than_ms(i64::MAX),
1193 )
1194 .await;
1195
1196 assert_eq!(removed_statistics(&updates), vec![1, 2]);
1197 assert_eq!(removed_partition_statistics(&updates), vec![1, 2]);
1198 }
1199}