1use std::collections::HashSet;
19
20use async_trait::async_trait;
21use chrono::Utc;
22
23use crate::error::invalid_data;
24use crate::spec::{
25 MAIN_BRANCH, SnapshotReference, SnapshotRetention, TableMetadata, TableProperties,
26};
27use crate::table::Table;
28use crate::transaction::action::{ActionCommit, StatelessAction};
29use crate::{Error, Result, TableRequirement, TableUpdate};
30
31#[derive(Clone)]
55pub struct ExpireSnapshotsAction {
56 explicit_ids_to_remove: Vec<i64>,
57 older_than_ms: Option<i64>,
58 retain_last: Option<usize>,
59}
60
61impl ExpireSnapshotsAction {
62 pub(crate) fn new() -> Self {
63 Self {
64 explicit_ids_to_remove: vec![],
65 older_than_ms: None,
66 retain_last: None,
67 }
68 }
69
70 pub fn expire_snapshot_ids(mut self, snapshot_ids: impl IntoIterator<Item = i64>) -> Self {
80 self.explicit_ids_to_remove.extend(snapshot_ids);
81 self
82 }
83
84 pub fn expire_older_than_ms(mut self, older_than_ms: i64) -> Self {
86 self.older_than_ms = Some(older_than_ms);
87 self
88 }
89
90 pub fn retain_last(mut self, retain_last: usize) -> Self {
96 self.retain_last = Some(retain_last);
97 self
98 }
99
100 fn plan(&self, table: &Table, properties: &TableProperties<'_>) -> Result<ExpirePlan> {
102 if self.retain_last == Some(0) {
104 return Err(invalid_data!(
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 invalid_data!("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 invalid_data!("Cannot expire snapshot {snapshot_id}: still referenced by {ref_names:?}")
289 }
290}
291
292struct ExpirePlan {
294 ids_to_remove: Vec<i64>,
295 refs_to_remove: Vec<String>,
296}
297
298#[async_trait]
299impl StatelessAction for ExpireSnapshotsAction {
300 async fn commit(&self, table: &Table) -> Result<ActionCommit> {
301 let metadata = table.metadata();
302 let properties = metadata.table_properties();
303
304 if !properties.gc_enabled()? {
306 return Err(invalid_data!(
307 "Cannot expire snapshots: gc.enabled is false"
308 ));
309 }
310
311 let plan = self.plan(table, &properties)?;
312
313 if plan.ids_to_remove.is_empty() && plan.refs_to_remove.is_empty() {
314 return Ok(ActionCommit::new(vec![], vec![]));
315 }
316
317 let mut updates: Vec<TableUpdate> = plan
319 .refs_to_remove
320 .into_iter()
321 .map(|ref_name| TableUpdate::RemoveSnapshotRef { ref_name })
322 .collect();
323
324 let mut stats_updates: Vec<TableUpdate> = vec![];
327 for &snapshot_id in &plan.ids_to_remove {
328 stats_updates.extend(
329 metadata
330 .statistics_for_snapshot(snapshot_id)
331 .is_some()
332 .then_some(TableUpdate::RemoveStatistics { snapshot_id }),
333 );
334 stats_updates.extend(
335 metadata
336 .partition_statistics_for_snapshot(snapshot_id)
337 .is_some()
338 .then_some(TableUpdate::RemovePartitionStatistics { snapshot_id }),
339 );
340 }
341
342 if !plan.ids_to_remove.is_empty() {
343 updates.push(TableUpdate::RemoveSnapshots {
344 snapshot_ids: plan.ids_to_remove,
345 });
346 }
347 updates.extend(stats_updates);
348
349 Ok(ActionCommit::new(updates, vec![
352 TableRequirement::UuidMatch {
353 uuid: metadata.uuid(),
354 },
355 TableRequirement::RefSnapshotIdMatch {
356 r#ref: MAIN_BRANCH.to_string(),
357 snapshot_id: metadata.current_snapshot_id(),
358 },
359 ]))
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use std::collections::HashMap;
366 use std::sync::Arc;
367
368 use chrono::Utc;
369
370 use crate::spec::{
371 MAIN_BRANCH, Operation, PartitionStatisticsFile, Snapshot, SnapshotReference,
372 SnapshotRetention, StatisticsFile, Summary,
373 };
374 use crate::table::Table;
375 use crate::test_utils::commit_with_fresh_state;
376 use crate::transaction::Transaction;
377 use crate::transaction::action::ApplyTransactionAction;
378 use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
379 use crate::transaction::tests::{make_v2_minimal_table, make_v2_table};
380 use crate::{TableRequirement, TableUpdate};
381
382 const OLD_SNAPSHOT: i64 = 3051729675574597004;
385 const CURRENT_SNAPSHOT: i64 = 3055729675574597004;
386 const TS: i64 = 1_700_000_000_000;
388 const NO_AGE_EXPIRY: i64 = 0;
391
392 fn action() -> ExpireSnapshotsAction {
393 ExpireSnapshotsAction::new()
394 }
395
396 async fn removed_ids(action: ExpireSnapshotsAction) -> Vec<i64> {
397 expired(&make_v2_table(), action).await
398 }
399
400 async fn updates_of(table: &Table, action: ExpireSnapshotsAction) -> Vec<TableUpdate> {
401 commit_with_fresh_state(action, table)
402 .await
403 .unwrap()
404 .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!(commit_with_fresh_state(action, &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!(commit_with_fresh_state(action, &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 = commit_with_fresh_state(action().expire_older_than_ms(i64::MAX), &table)
621 .await
622 .unwrap();
623 assert!(commit.take_updates().is_empty());
625 }
626
627 #[tokio::test]
628 async fn test_retain_last_default_expires_older_non_current() {
629 assert_eq!(
630 removed_ids(action().expire_older_than_ms(i64::MAX)).await,
631 vec![OLD_SNAPSHOT]
632 );
633 }
634
635 #[tokio::test]
636 async fn test_retain_last_noop_when_enough_retained() {
637 assert!(removed_ids(action().retain_last(5)).await.is_empty());
638 }
639
640 #[tokio::test]
641 async fn test_older_than_excludes_newer_snapshots() {
642 assert!(
644 removed_ids(action().expire_older_than_ms(1))
645 .await
646 .is_empty()
647 );
648 }
649
650 #[tokio::test]
651 async fn test_apply_registers_action() {
652 let table = make_v2_table();
653 let tx = Transaction::new(&table);
654 let tx = tx
655 .expire_snapshots()
656 .expire_snapshot_ids(vec![OLD_SNAPSHOT])
657 .apply(tx)
658 .unwrap();
659 assert_eq!(tx.actions.len(), 1);
660 }
661
662 #[tokio::test]
663 async fn test_per_branch_retention_protects_shared_ancestor() {
664 let table = table_with(
667 vec![
668 snapshot(1, None, 35, TS + 1),
669 snapshot(2, Some(1), 36, TS + 2),
670 snapshot(3, Some(2), 37, TS + 3),
671 snapshot(4, Some(2), 38, TS + 4),
672 ],
673 vec![(MAIN_BRANCH, branch(3, None)), ("b", branch(4, None))],
674 );
675
676 let removed = expired(
678 &table,
679 action().retain_last(2).expire_older_than_ms(i64::MAX),
680 )
681 .await;
682 assert_eq!(removed, vec![1]);
683 }
684
685 #[tokio::test]
686 async fn test_per_ref_min_snapshots_to_keep_overrides_retain_last() {
687 let table = table_with(
688 vec![
689 snapshot(1, None, 35, TS + 1),
690 snapshot(2, Some(1), 36, TS + 2),
691 snapshot(3, Some(2), 37, TS + 3),
692 ],
693 vec![(MAIN_BRANCH, branch(3, Some(3)))],
694 );
695
696 let removed = expired(
698 &table,
699 action().retain_last(1).expire_older_than_ms(i64::MAX),
700 )
701 .await;
702 assert!(removed.is_empty());
703 }
704
705 #[tokio::test]
706 async fn test_explicit_and_age_combine() {
707 let table = table_with(
708 vec![
709 snapshot(1, None, 35, TS + 1),
710 snapshot(2, Some(1), 36, TS + 2),
711 snapshot(3, Some(2), 37, TS + 3),
712 snapshot(4, Some(3), 38, TS + 4),
713 ],
714 vec![(MAIN_BRANCH, branch(4, None))],
715 );
716
717 let removed = expired(
720 &table,
721 action()
722 .retain_last(1)
723 .expire_older_than_ms(TS + 3)
724 .expire_snapshot_ids(vec![3]),
725 )
726 .await;
727 assert_eq!(removed, vec![1, 2, 3]);
728 }
729
730 #[tokio::test]
731 async fn test_expire_snapshot_ids_accumulates() {
732 let table = table_with(
733 vec![
734 snapshot(1, None, 35, TS + 1),
735 snapshot(2, Some(1), 36, TS + 2),
736 snapshot(3, Some(2), 37, TS + 3),
737 ],
738 vec![(MAIN_BRANCH, branch(3, None))],
739 );
740
741 let removed = expired(
743 &table,
744 action()
745 .expire_snapshot_ids(vec![1])
746 .expire_snapshot_ids(vec![2])
747 .expire_older_than_ms(NO_AGE_EXPIRY),
748 )
749 .await;
750 assert_eq!(removed, vec![1, 2]);
751 }
752
753 #[tokio::test]
754 async fn test_gc_disabled_errors() {
755 let table = make_v2_table();
756 let metadata = table
757 .metadata()
758 .clone()
759 .into_builder(None)
760 .set_properties(HashMap::from([(
761 "gc.enabled".to_string(),
762 "false".to_string(),
763 )]))
764 .unwrap()
765 .build()
766 .unwrap()
767 .metadata;
768 let table = table.with_metadata(Arc::new(metadata));
769
770 let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
771 assert!(commit_with_fresh_state(action, &table).await.is_err());
772 }
773
774 #[tokio::test]
775 async fn test_commit_asserts_main_ref() {
776 let table = make_v2_table();
777 let mut commit =
778 commit_with_fresh_state(action().expire_snapshot_ids(vec![OLD_SNAPSHOT]), &table)
779 .await
780 .unwrap();
781 assert!(
782 commit
783 .take_requirements()
784 .iter()
785 .any(|requirement| matches!(
786 requirement,
787 TableRequirement::RefSnapshotIdMatch { r#ref, snapshot_id }
788 if r#ref == MAIN_BRANCH && *snapshot_id == Some(CURRENT_SNAPSHOT)
789 ))
790 );
791 }
792
793 #[tokio::test]
794 async fn test_ref_aging_drops_old_tag_and_expires_its_snapshot() {
795 let now = Utc::now().timestamp_millis();
796 let day_ms = 24 * 60 * 60 * 1000;
797 let table = table_with(
800 vec![
801 snapshot(1, None, 35, now - 10 * day_ms),
802 snapshot(2, None, 36, now - 1000),
803 ],
804 vec![
806 ("old-tag", tag(1, Some(day_ms))),
807 (MAIN_BRANCH, branch(2, None)),
808 ],
809 );
810
811 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
813 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
815 assert!(updates.iter().any(
816 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
817 ));
818 }
819
820 #[tokio::test]
821 async fn test_ref_aging_keeps_recent_tag() {
822 let now = Utc::now().timestamp_millis();
823 let day_ms = 24 * 60 * 60 * 1000;
824 let table = table_with(
825 vec![
826 snapshot(1, None, 35, now - 1000),
827 snapshot(2, None, 36, now - 500),
828 ],
829 vec![
830 ("fresh-tag", tag(1, Some(day_ms))),
831 (MAIN_BRANCH, branch(2, None)),
832 ],
833 );
834
835 let updates = updates_of(&table, action()).await;
836 assert!(removed_refs(&updates).is_empty());
838 assert_eq!(expired(&table, action()).await, Vec::<i64>::new());
839 }
840
841 #[tokio::test]
842 async fn test_per_ref_max_snapshot_age_overrides_default() {
843 let now = Utc::now().timestamp_millis();
844 let day_ms = 24 * 60 * 60 * 1000;
845 let table = table_with(
847 vec![
848 snapshot(1, None, 35, now - 3 * day_ms),
849 snapshot(2, Some(1), 36, now - day_ms),
850 snapshot(3, Some(2), 37, now - 1000),
851 ],
852 vec![(MAIN_BRANCH, branch_with(3, Some(1), Some(2 * day_ms), None))],
853 );
854
855 let removed = expired(&table, action()).await;
857 assert_eq!(removed, vec![1]);
858 }
859
860 #[tokio::test]
861 async fn test_ref_aging_drops_old_branch() {
862 let now = Utc::now().timestamp_millis();
863 let day_ms = 24 * 60 * 60 * 1000;
864 let table = table_with(
866 vec![
867 snapshot(1, None, 35, now - 10 * day_ms),
868 snapshot(2, None, 36, now - 1000),
869 ],
870 vec![
871 ("stale", branch_with(1, None, None, Some(day_ms))),
872 (MAIN_BRANCH, branch(2, None)),
873 ],
874 );
875
876 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
877 assert_eq!(removed_refs(&updates), vec!["stale".to_string()]);
879 assert!(updates.iter().any(
880 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
881 ));
882 }
883
884 #[tokio::test]
885 async fn test_unreferenced_snapshots_retained_only_while_young() {
886 let now = Utc::now().timestamp_millis();
887 let day_ms = 24 * 60 * 60 * 1000;
888 let table = table_with(
891 vec![
892 snapshot(3, None, 35, now - 10 * day_ms), snapshot(1, None, 36, now - 1000), snapshot(2, None, 37, now - 1000), ],
896 vec![(MAIN_BRANCH, branch(1, None))],
897 );
898
899 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
901 assert_eq!(removed, vec![3]);
902 }
903
904 #[tokio::test]
905 async fn test_retain_last_zero_errors() {
906 let table = make_v2_table();
907 assert!(
908 commit_with_fresh_state(action().retain_last(0), &table)
909 .await
910 .is_err()
911 );
912 }
913
914 #[tokio::test]
915 async fn test_cannot_expire_branch_head_explicitly() {
916 let table = table_with(
917 vec![snapshot(1, None, 35, TS + 1), snapshot(2, None, 36, TS + 2)],
918 vec![(MAIN_BRANCH, branch(1, None)), ("branch", branch(2, None))],
919 );
920 let action = action().expire_snapshot_ids(vec![2]);
922 assert!(commit_with_fresh_state(action, &table).await.is_err());
923 }
924
925 #[tokio::test]
926 async fn test_tag_does_not_protect_its_ancestry() {
927 let now = Utc::now().timestamp_millis();
928 let day_ms = 24 * 60 * 60 * 1000;
929 let table = table_with(
932 vec![
933 snapshot(1, None, 35, now - 10 * day_ms),
934 snapshot(2, Some(1), 36, now - 8 * day_ms),
935 snapshot(3, Some(2), 37, now - 1000),
936 ],
937 vec![(MAIN_BRANCH, branch(1, None)), ("tag", tag(3, None))],
938 );
939
940 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
941 assert_eq!(removed, vec![2]);
942 }
943
944 #[tokio::test]
945 async fn test_branch_protects_its_ancestry() {
946 let now = Utc::now().timestamp_millis();
947 let day_ms = 24 * 60 * 60 * 1000;
948 let table = table_with(
951 vec![
952 snapshot(1, None, 35, now - 10 * day_ms),
953 snapshot(2, Some(1), 36, now - 8 * day_ms),
954 snapshot(3, Some(2), 37, now - 1000),
955 ],
956 vec![
957 (MAIN_BRANCH, branch(1, None)),
958 ("branch", branch_with(3, None, Some(i64::MAX), None)),
959 ],
960 );
961
962 let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
963 assert!(removed.is_empty());
964 }
965
966 #[tokio::test]
967 async fn test_per_branch_max_snapshot_age_differs_across_branches() {
968 let now = Utc::now().timestamp_millis();
969 let day_ms = 24 * 60 * 60 * 1000;
970 let table = table_with(
973 vec![
974 snapshot(1, None, 35, now - 30 * day_ms),
975 snapshot(3, None, 36, now - 30 * day_ms),
976 snapshot(2, Some(1), 37, now - 2 * day_ms),
977 snapshot(4, Some(3), 38, now - 2 * day_ms),
978 ],
979 vec![
980 (MAIN_BRANCH, branch_with(2, None, Some(5 * day_ms), None)),
981 ("keep", branch_with(4, None, Some(60 * day_ms), None)),
982 ],
983 );
984
985 let removed = expired(&table, action()).await;
988 assert_eq!(removed, vec![1]);
989 }
990
991 #[tokio::test]
992 async fn test_default_cutoff_expires_snapshots_older_than_max_age() {
993 let now = Utc::now().timestamp_millis();
994 let day_ms = 24 * 60 * 60 * 1000;
995 let table = table_with(
996 vec![
997 snapshot(1, None, 35, now - 10 * day_ms), snapshot(2, Some(1), 36, now - 1000), ],
1000 vec![(MAIN_BRANCH, branch(2, None))],
1001 );
1002
1003 let removed = expired(&table, action()).await;
1005 assert_eq!(removed, vec![1]);
1006 }
1007
1008 #[tokio::test]
1009 async fn test_min_snapshots_to_keep_property_is_the_default_floor() {
1010 let table = table_with_props(
1011 vec![
1012 snapshot(1, None, 35, TS + 1),
1013 snapshot(2, Some(1), 36, TS + 2),
1014 snapshot(3, Some(2), 37, TS + 3),
1015 ],
1016 vec![(MAIN_BRANCH, branch(3, None))],
1017 HashMap::from([(
1018 "history.expire.min-snapshots-to-keep".to_string(),
1019 "3".to_string(),
1020 )]),
1021 );
1022
1023 let removed = expired(&table, action()).await;
1026 assert!(removed.is_empty());
1027 }
1028
1029 #[tokio::test]
1030 async fn test_max_snapshot_age_ms_property_sets_the_cutoff() {
1031 let now = Utc::now().timestamp_millis();
1032 let day_ms = 24 * 60 * 60 * 1000;
1033 let table = table_with_props(
1037 vec![
1038 snapshot(1, None, 35, now - 2 * day_ms),
1039 snapshot(2, Some(1), 36, now - 1000),
1040 ],
1041 vec![(MAIN_BRANCH, branch(2, None))],
1042 HashMap::from([(
1043 "history.expire.max-snapshot-age-ms".to_string(),
1044 day_ms.to_string(),
1045 )]),
1046 );
1047
1048 let removed = expired(&table, action()).await;
1049 assert_eq!(removed, vec![1]);
1050 }
1051
1052 #[tokio::test]
1053 async fn test_max_ref_age_ms_property_ages_out_ref_without_its_own_window() {
1054 let now = Utc::now().timestamp_millis();
1055 let day_ms = 24 * 60 * 60 * 1000;
1056 let table = table_with_props(
1060 vec![
1061 snapshot(1, None, 35, now - 10 * day_ms),
1062 snapshot(2, None, 36, now - 1000),
1063 ],
1064 vec![("old-tag", tag(1, None)), (MAIN_BRANCH, branch(2, None))],
1065 HashMap::from([(
1066 "history.expire.max-ref-age-ms".to_string(),
1067 day_ms.to_string(),
1068 )]),
1069 );
1070
1071 let updates = updates_of(&table, action()).await;
1072 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1073 assert!(updates.iter().any(
1075 |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
1076 ));
1077 }
1078
1079 #[tokio::test]
1080 async fn test_expiring_snapshot_drops_its_statistics() {
1081 let table = table_with_stats(
1083 vec![
1084 snapshot(1, None, 35, TS + 1),
1085 snapshot(2, Some(1), 36, TS + 2),
1086 ],
1087 vec![(MAIN_BRANCH, branch(2, None))],
1088 vec![stats_file(1), stats_file(2)],
1089 vec![partition_stats_file(1), partition_stats_file(2)],
1090 );
1091
1092 let updates = updates_of(
1093 &table,
1094 action().retain_last(1).expire_older_than_ms(i64::MAX),
1095 )
1096 .await;
1097
1098 assert_eq!(removed_statistics(&updates), vec![1]);
1100 assert_eq!(removed_partition_statistics(&updates), vec![1]);
1101 }
1102
1103 #[tokio::test]
1104 async fn test_expiring_snapshot_without_statistics_emits_no_removal() {
1105 let table = table_with(
1107 vec![
1108 snapshot(1, None, 35, TS + 1),
1109 snapshot(2, Some(1), 36, TS + 2),
1110 ],
1111 vec![(MAIN_BRANCH, branch(2, None))],
1112 );
1113
1114 let updates = updates_of(
1115 &table,
1116 action().retain_last(1).expire_older_than_ms(i64::MAX),
1117 )
1118 .await;
1119
1120 assert!(removed_statistics(&updates).is_empty());
1121 assert!(removed_partition_statistics(&updates).is_empty());
1122 }
1123
1124 #[tokio::test]
1125 async fn test_only_present_statistics_variant_is_removed() {
1126 let table = table_with_stats(
1128 vec![
1129 snapshot(1, None, 35, TS + 1),
1130 snapshot(2, Some(1), 36, TS + 2),
1131 ],
1132 vec![(MAIN_BRANCH, branch(2, None))],
1133 vec![stats_file(1)],
1134 vec![],
1135 );
1136
1137 let updates = updates_of(
1138 &table,
1139 action().retain_last(1).expire_older_than_ms(i64::MAX),
1140 )
1141 .await;
1142
1143 assert_eq!(removed_statistics(&updates), vec![1]);
1144 assert!(removed_partition_statistics(&updates).is_empty());
1145 }
1146
1147 #[tokio::test]
1148 async fn test_ref_aging_expiry_drops_statistics() {
1149 let now = Utc::now().timestamp_millis();
1150 let day_ms = 24 * 60 * 60 * 1000;
1151 let table = table_with_stats(
1155 vec![
1156 snapshot(1, None, 35, now - 10 * day_ms),
1157 snapshot(2, None, 36, now - 1000),
1158 ],
1159 vec![
1160 ("old-tag", tag(1, Some(day_ms))),
1161 (MAIN_BRANCH, branch(2, None)),
1162 ],
1163 vec![stats_file(1)],
1164 vec![partition_stats_file(1)],
1165 );
1166
1167 let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
1168 assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1169 assert_eq!(removed_statistics(&updates), vec![1]);
1170 assert_eq!(removed_partition_statistics(&updates), vec![1]);
1171 }
1172
1173 #[tokio::test]
1174 async fn test_multiple_expired_snapshots_drop_their_statistics() {
1175 let table = table_with_stats(
1178 vec![
1179 snapshot(1, None, 35, TS + 1),
1180 snapshot(2, Some(1), 36, TS + 2),
1181 snapshot(3, Some(2), 37, TS + 3),
1182 ],
1183 vec![(MAIN_BRANCH, branch(3, None))],
1184 vec![stats_file(1), stats_file(2)],
1185 vec![partition_stats_file(1), partition_stats_file(2)],
1186 );
1187
1188 let updates = updates_of(
1189 &table,
1190 action().retain_last(1).expire_older_than_ms(i64::MAX),
1191 )
1192 .await;
1193
1194 assert_eq!(removed_statistics(&updates), vec![1, 2]);
1195 assert_eq!(removed_partition_statistics(&updates), vec![1, 2]);
1196 }
1197}