use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::Utc;
use crate::spec::{
MAIN_BRANCH, SnapshotReference, SnapshotRetention, TableMetadata, TableProperties,
};
use crate::table::Table;
use crate::transaction::action::{ActionCommit, TransactionAction};
use crate::{Error, ErrorKind, Result, TableRequirement, TableUpdate};
pub struct ExpireSnapshotsAction {
explicit_ids_to_remove: Vec<i64>,
older_than_ms: Option<i64>,
retain_last: Option<usize>,
}
impl ExpireSnapshotsAction {
pub(crate) fn new() -> Self {
Self {
explicit_ids_to_remove: vec![],
older_than_ms: None,
retain_last: None,
}
}
pub fn expire_snapshot_ids(mut self, snapshot_ids: impl IntoIterator<Item = i64>) -> Self {
self.explicit_ids_to_remove.extend(snapshot_ids);
self
}
pub fn expire_older_than_ms(mut self, older_than_ms: i64) -> Self {
self.older_than_ms = Some(older_than_ms);
self
}
pub fn retain_last(mut self, retain_last: usize) -> Self {
self.retain_last = Some(retain_last);
self
}
fn plan(&self, table: &Table, properties: &TableProperties) -> Result<ExpirePlan> {
if self.retain_last == Some(0) {
return Err(Error::new(
ErrorKind::DataInvalid,
"Number of snapshots to retain must be at least 1",
));
}
let metadata = table.metadata();
let now = Utc::now().timestamp_millis();
let default_cutoff = self
.older_than_ms
.unwrap_or_else(|| now.saturating_sub(properties.max_snapshot_age_ms));
let default_min_to_keep = self.retain_last.unwrap_or(properties.min_snapshots_to_keep);
let mut removed_ref_names: Vec<String> = vec![];
let mut retained_refs: Vec<&SnapshotReference> = vec![];
for (ref_name, snapshot_ref) in &metadata.refs {
if ref_name == MAIN_BRANCH
|| !Self::ref_aged_out(metadata, snapshot_ref, now, properties.max_ref_age_ms)
{
retained_refs.push(snapshot_ref);
} else {
removed_ref_names.push(ref_name.clone());
}
}
let mut ref_head_ids: HashSet<i64> = retained_refs.iter().map(|r| r.snapshot_id).collect();
if let Some(current_id) = metadata.current_snapshot_id() {
ref_head_ids.insert(current_id);
}
let existing_ids: HashSet<i64> = metadata.snapshots().map(|s| s.snapshot_id()).collect();
let mut expiring_ids: HashSet<i64> = HashSet::new();
for id in &self.explicit_ids_to_remove {
if ref_head_ids.contains(id) {
return Err(Self::reference_error(metadata, *id));
}
if existing_ids.contains(id) {
expiring_ids.insert(*id);
}
}
let mut retained_ids = ref_head_ids.clone();
let mut referenced_ids = ref_head_ids.clone();
let mut branches: Vec<(i64, usize, i64)> = vec![];
for snapshot_ref in &retained_refs {
match &snapshot_ref.retention {
SnapshotRetention::Branch {
min_snapshots_to_keep,
max_snapshot_age_ms,
..
} => {
let min_to_keep =
min_snapshots_to_keep.map_or(default_min_to_keep, |m| m as usize);
let cutoff =
max_snapshot_age_ms.map_or(default_cutoff, |age| now.saturating_sub(age));
branches.push((snapshot_ref.snapshot_id, min_to_keep, cutoff));
}
SnapshotRetention::Tag { .. } => {
referenced_ids.insert(snapshot_ref.snapshot_id);
}
}
}
if let Some(current_id) = metadata.current_snapshot_id()
&& !branches
.iter()
.any(|(head_id, _, _)| *head_id == current_id)
{
branches.push((current_id, default_min_to_keep, default_cutoff));
}
for (head_id, min_to_keep, cutoff) in branches {
Self::retain_branch(
metadata,
head_id,
min_to_keep,
cutoff,
&mut retained_ids,
&mut referenced_ids,
);
}
for snapshot in metadata.snapshots() {
let id = snapshot.snapshot_id();
if !referenced_ids.contains(&id) && snapshot.timestamp_ms() >= default_cutoff {
retained_ids.insert(id);
}
}
for snapshot in metadata.snapshots() {
if !retained_ids.contains(&snapshot.snapshot_id()) {
expiring_ids.insert(snapshot.snapshot_id());
}
}
let mut ids_to_remove: Vec<i64> = expiring_ids.into_iter().collect();
ids_to_remove.sort_unstable();
removed_ref_names.sort();
Ok(ExpirePlan {
ids_to_remove,
refs_to_remove: removed_ref_names,
})
}
fn ref_aged_out(
metadata: &TableMetadata,
snapshot_ref: &SnapshotReference,
now: i64,
default_max_ref_age_ms: i64,
) -> bool {
let max_ref_age_ms = match snapshot_ref.retention {
SnapshotRetention::Branch { max_ref_age_ms, .. }
| SnapshotRetention::Tag { max_ref_age_ms } => max_ref_age_ms,
}
.unwrap_or(default_max_ref_age_ms);
match metadata.snapshot_by_id(snapshot_ref.snapshot_id) {
Some(snapshot) => now.saturating_sub(snapshot.timestamp_ms()) > max_ref_age_ms,
None => false,
}
}
fn retain_branch(
metadata: &TableMetadata,
head_id: i64,
min_to_keep: usize,
cutoff: i64,
retained_ids: &mut HashSet<i64>,
referenced_ids: &mut HashSet<i64>,
) {
let mut kept_count = 0usize;
for ancestor_id in Self::ancestors(metadata, head_id) {
referenced_ids.insert(ancestor_id);
let timestamp = metadata
.snapshot_by_id(ancestor_id)
.map_or(i64::MIN, |snapshot| snapshot.timestamp_ms());
if kept_count < min_to_keep || timestamp >= cutoff {
retained_ids.insert(ancestor_id);
kept_count += 1;
}
}
}
fn ancestors(metadata: &TableMetadata, head_id: i64) -> impl Iterator<Item = i64> + '_ {
let mut next_id = Some(head_id);
std::iter::from_fn(move || {
let id = next_id?;
next_id = metadata
.snapshot_by_id(id)
.and_then(|snapshot| snapshot.parent_snapshot_id());
Some(id)
})
}
fn reference_error(metadata: &TableMetadata, snapshot_id: i64) -> Error {
if metadata.current_snapshot_id() == Some(snapshot_id) {
return Error::new(ErrorKind::DataInvalid, "Cannot expire the current snapshot");
}
let ref_names: Vec<&str> = metadata
.refs
.iter()
.filter(|(_, snapshot_ref)| snapshot_ref.snapshot_id == snapshot_id)
.map(|(ref_name, _)| ref_name.as_str())
.collect();
Error::new(
ErrorKind::DataInvalid,
format!("Cannot expire snapshot {snapshot_id}: still referenced by {ref_names:?}"),
)
}
}
struct ExpirePlan {
ids_to_remove: Vec<i64>,
refs_to_remove: Vec<String>,
}
#[async_trait]
impl TransactionAction for ExpireSnapshotsAction {
async fn commit(self: Arc<Self>, table: &Table) -> Result<ActionCommit> {
let metadata = table.metadata();
let properties = metadata.table_properties()?;
if !properties.gc_enabled {
return Err(Error::new(
ErrorKind::DataInvalid,
"Cannot expire snapshots: gc.enabled is false",
));
}
let plan = self.plan(table, &properties)?;
if plan.ids_to_remove.is_empty() && plan.refs_to_remove.is_empty() {
return Ok(ActionCommit::new(vec![], vec![]));
}
let mut updates: Vec<TableUpdate> = plan
.refs_to_remove
.into_iter()
.map(|ref_name| TableUpdate::RemoveSnapshotRef { ref_name })
.collect();
let mut stats_updates: Vec<TableUpdate> = vec![];
for &snapshot_id in &plan.ids_to_remove {
stats_updates.extend(
metadata
.statistics_for_snapshot(snapshot_id)
.is_some()
.then_some(TableUpdate::RemoveStatistics { snapshot_id }),
);
stats_updates.extend(
metadata
.partition_statistics_for_snapshot(snapshot_id)
.is_some()
.then_some(TableUpdate::RemovePartitionStatistics { snapshot_id }),
);
}
if !plan.ids_to_remove.is_empty() {
updates.push(TableUpdate::RemoveSnapshots {
snapshot_ids: plan.ids_to_remove,
});
}
updates.extend(stats_updates);
Ok(ActionCommit::new(updates, vec![
TableRequirement::UuidMatch {
uuid: metadata.uuid(),
},
TableRequirement::RefSnapshotIdMatch {
r#ref: MAIN_BRANCH.to_string(),
snapshot_id: metadata.current_snapshot_id(),
},
]))
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use chrono::Utc;
use crate::spec::{
MAIN_BRANCH, Operation, PartitionStatisticsFile, Snapshot, SnapshotReference,
SnapshotRetention, StatisticsFile, Summary,
};
use crate::table::Table;
use crate::transaction::Transaction;
use crate::transaction::action::{ApplyTransactionAction, TransactionAction};
use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
use crate::transaction::tests::{make_v2_minimal_table, make_v2_table};
use crate::{TableRequirement, TableUpdate};
const OLD_SNAPSHOT: i64 = 3051729675574597004;
const CURRENT_SNAPSHOT: i64 = 3055729675574597004;
const TS: i64 = 1_700_000_000_000;
const NO_AGE_EXPIRY: i64 = 0;
fn action() -> ExpireSnapshotsAction {
ExpireSnapshotsAction::new()
}
async fn removed_ids(action: ExpireSnapshotsAction) -> Vec<i64> {
expired(&make_v2_table(), action).await
}
async fn updates_of(table: &Table, action: ExpireSnapshotsAction) -> Vec<TableUpdate> {
Arc::new(action).commit(table).await.unwrap().take_updates()
}
async fn expired(table: &Table, action: ExpireSnapshotsAction) -> Vec<i64> {
updates_of(table, action)
.await
.into_iter()
.find_map(|update| match update {
TableUpdate::RemoveSnapshots { snapshot_ids } => Some(snapshot_ids),
_ => None,
})
.unwrap_or_default()
}
fn removed_refs(updates: &[TableUpdate]) -> Vec<String> {
let mut refs: Vec<String> = updates
.iter()
.filter_map(|update| match update {
TableUpdate::RemoveSnapshotRef { ref_name } => Some(ref_name.clone()),
_ => None,
})
.collect();
refs.sort();
refs
}
fn snapshot(id: i64, parent: Option<i64>, sequence_number: i64, timestamp_ms: i64) -> Snapshot {
Snapshot::builder()
.with_snapshot_id(id)
.with_parent_snapshot_id(parent)
.with_sequence_number(sequence_number)
.with_timestamp_ms(timestamp_ms)
.with_schema_id(0)
.with_manifest_list(format!("/snap-{id}.avro"))
.with_summary(Summary {
operation: Operation::Append,
additional_properties: HashMap::new(),
})
.build()
}
fn branch(snapshot_id: i64, min_snapshots_to_keep: Option<i32>) -> SnapshotReference {
branch_with(snapshot_id, min_snapshots_to_keep, None, None)
}
fn branch_with(
snapshot_id: i64,
min_snapshots_to_keep: Option<i32>,
max_snapshot_age_ms: Option<i64>,
max_ref_age_ms: Option<i64>,
) -> SnapshotReference {
SnapshotReference {
snapshot_id,
retention: SnapshotRetention::Branch {
min_snapshots_to_keep,
max_snapshot_age_ms,
max_ref_age_ms,
},
}
}
fn tag(snapshot_id: i64, max_ref_age_ms: Option<i64>) -> SnapshotReference {
SnapshotReference {
snapshot_id,
retention: SnapshotRetention::Tag { max_ref_age_ms },
}
}
fn table_with(snapshots: Vec<Snapshot>, refs: Vec<(&str, SnapshotReference)>) -> Table {
table_with_props(snapshots, refs, HashMap::new())
}
fn table_with_props(
snapshots: Vec<Snapshot>,
refs: Vec<(&str, SnapshotReference)>,
properties: HashMap<String, String>,
) -> Table {
let base = make_v2_minimal_table();
let mut builder = base
.metadata()
.clone()
.into_builder(None)
.set_properties(properties)
.unwrap();
for snapshot in snapshots {
builder = builder.add_snapshot(snapshot).unwrap();
}
for (name, reference) in refs {
builder = builder.set_ref(name, reference).unwrap();
}
base.with_metadata(Arc::new(builder.build().unwrap().metadata))
}
fn table_with_stats(
snapshots: Vec<Snapshot>,
refs: Vec<(&str, SnapshotReference)>,
statistics: Vec<StatisticsFile>,
partition_statistics: Vec<PartitionStatisticsFile>,
) -> Table {
let table = table_with(snapshots, refs);
let mut builder = table.metadata().clone().into_builder(None);
for stats in statistics {
builder = builder.set_statistics(stats);
}
for stats in partition_statistics {
builder = builder.set_partition_statistics(stats);
}
table.with_metadata(Arc::new(builder.build().unwrap().metadata))
}
fn stats_file(snapshot_id: i64) -> StatisticsFile {
StatisticsFile {
snapshot_id,
statistics_path: format!("/stats-{snapshot_id}.puffin"),
file_size_in_bytes: 1,
file_footer_size_in_bytes: 1,
key_metadata: None,
blob_metadata: vec![],
}
}
fn partition_stats_file(snapshot_id: i64) -> PartitionStatisticsFile {
PartitionStatisticsFile {
snapshot_id,
statistics_path: format!("/partition-stats-{snapshot_id}.puffin"),
file_size_in_bytes: 1,
}
}
fn removed_statistics(updates: &[TableUpdate]) -> Vec<i64> {
updates
.iter()
.filter_map(|update| match update {
TableUpdate::RemoveStatistics { snapshot_id } => Some(*snapshot_id),
_ => None,
})
.collect()
}
fn removed_partition_statistics(updates: &[TableUpdate]) -> Vec<i64> {
updates
.iter()
.filter_map(|update| match update {
TableUpdate::RemovePartitionStatistics { snapshot_id } => Some(*snapshot_id),
_ => None,
})
.collect()
}
#[tokio::test]
async fn test_expire_explicit_snapshot_id() {
assert_eq!(
removed_ids(
action()
.expire_snapshot_ids(vec![OLD_SNAPSHOT])
.expire_older_than_ms(NO_AGE_EXPIRY)
)
.await,
vec![OLD_SNAPSHOT]
);
}
#[tokio::test]
async fn test_explicit_unknown_id_is_ignored() {
assert!(
removed_ids(
action()
.expire_snapshot_ids(vec![42])
.expire_older_than_ms(NO_AGE_EXPIRY)
)
.await
.is_empty()
);
}
#[tokio::test]
async fn test_cannot_expire_current_snapshot() {
let table = make_v2_table();
let action = action().expire_snapshot_ids(vec![CURRENT_SNAPSHOT]);
assert!(Arc::new(action).commit(&table).await.is_err());
}
fn table_with_tag_on_old() -> Table {
let table = make_v2_table();
let metadata = table
.metadata()
.clone()
.into_builder(None)
.set_ref("history-tag", SnapshotReference {
snapshot_id: OLD_SNAPSHOT,
retention: SnapshotRetention::Tag {
max_ref_age_ms: None,
},
})
.unwrap()
.build()
.unwrap()
.metadata;
table.with_metadata(Arc::new(metadata))
}
#[tokio::test]
async fn test_cannot_expire_tagged_snapshot_explicitly() {
let table = table_with_tag_on_old();
let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
assert!(Arc::new(action).commit(&table).await.is_err());
}
#[tokio::test]
async fn test_age_expiry_skips_tagged_snapshot() {
let table = table_with_tag_on_old();
let mut commit = Arc::new(action().expire_older_than_ms(i64::MAX))
.commit(&table)
.await
.unwrap();
assert!(commit.take_updates().is_empty());
}
#[tokio::test]
async fn test_retain_last_default_expires_older_non_current() {
assert_eq!(
removed_ids(action().expire_older_than_ms(i64::MAX)).await,
vec![OLD_SNAPSHOT]
);
}
#[tokio::test]
async fn test_retain_last_noop_when_enough_retained() {
assert!(removed_ids(action().retain_last(5)).await.is_empty());
}
#[tokio::test]
async fn test_older_than_excludes_newer_snapshots() {
assert!(
removed_ids(action().expire_older_than_ms(1))
.await
.is_empty()
);
}
#[tokio::test]
async fn test_apply_registers_action() {
let table = make_v2_table();
let tx = Transaction::new(&table);
let tx = tx
.expire_snapshots()
.expire_snapshot_ids(vec![OLD_SNAPSHOT])
.apply(tx)
.unwrap();
assert_eq!(tx.actions.len(), 1);
}
#[tokio::test]
async fn test_per_branch_retention_protects_shared_ancestor() {
let table = table_with(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
snapshot(4, Some(2), 38, TS + 4),
],
vec![(MAIN_BRANCH, branch(3, None)), ("b", branch(4, None))],
);
let removed = expired(
&table,
action().retain_last(2).expire_older_than_ms(i64::MAX),
)
.await;
assert_eq!(removed, vec![1]);
}
#[tokio::test]
async fn test_per_ref_min_snapshots_to_keep_overrides_retain_last() {
let table = table_with(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
],
vec![(MAIN_BRANCH, branch(3, Some(3)))],
);
let removed = expired(
&table,
action().retain_last(1).expire_older_than_ms(i64::MAX),
)
.await;
assert!(removed.is_empty());
}
#[tokio::test]
async fn test_explicit_and_age_combine() {
let table = table_with(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
snapshot(4, Some(3), 38, TS + 4),
],
vec![(MAIN_BRANCH, branch(4, None))],
);
let removed = expired(
&table,
action()
.retain_last(1)
.expire_older_than_ms(TS + 3)
.expire_snapshot_ids(vec![3]),
)
.await;
assert_eq!(removed, vec![1, 2, 3]);
}
#[tokio::test]
async fn test_expire_snapshot_ids_accumulates() {
let table = table_with(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
],
vec![(MAIN_BRANCH, branch(3, None))],
);
let removed = expired(
&table,
action()
.expire_snapshot_ids(vec![1])
.expire_snapshot_ids(vec![2])
.expire_older_than_ms(NO_AGE_EXPIRY),
)
.await;
assert_eq!(removed, vec![1, 2]);
}
#[tokio::test]
async fn test_gc_disabled_errors() {
let table = make_v2_table();
let metadata = table
.metadata()
.clone()
.into_builder(None)
.set_properties(HashMap::from([(
"gc.enabled".to_string(),
"false".to_string(),
)]))
.unwrap()
.build()
.unwrap()
.metadata;
let table = table.with_metadata(Arc::new(metadata));
let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
assert!(Arc::new(action).commit(&table).await.is_err());
}
#[tokio::test]
async fn test_commit_asserts_main_ref() {
let table = make_v2_table();
let mut commit = Arc::new(action().expire_snapshot_ids(vec![OLD_SNAPSHOT]))
.commit(&table)
.await
.unwrap();
assert!(
commit
.take_requirements()
.iter()
.any(|requirement| matches!(
requirement,
TableRequirement::RefSnapshotIdMatch { r#ref, snapshot_id }
if r#ref == MAIN_BRANCH && *snapshot_id == Some(CURRENT_SNAPSHOT)
))
);
}
#[tokio::test]
async fn test_ref_aging_drops_old_tag_and_expires_its_snapshot() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, None, 36, now - 1000),
],
vec![
("old-tag", tag(1, Some(day_ms))),
(MAIN_BRANCH, branch(2, None)),
],
);
let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
assert!(updates.iter().any(
|u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
));
}
#[tokio::test]
async fn test_ref_aging_keeps_recent_tag() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 1000),
snapshot(2, None, 36, now - 500),
],
vec![
("fresh-tag", tag(1, Some(day_ms))),
(MAIN_BRANCH, branch(2, None)),
],
);
let updates = updates_of(&table, action()).await;
assert!(removed_refs(&updates).is_empty());
assert_eq!(expired(&table, action()).await, Vec::<i64>::new());
}
#[tokio::test]
async fn test_per_ref_max_snapshot_age_overrides_default() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 3 * day_ms),
snapshot(2, Some(1), 36, now - day_ms),
snapshot(3, Some(2), 37, now - 1000),
],
vec![(MAIN_BRANCH, branch_with(3, Some(1), Some(2 * day_ms), None))],
);
let removed = expired(&table, action()).await;
assert_eq!(removed, vec![1]);
}
#[tokio::test]
async fn test_ref_aging_drops_old_branch() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, None, 36, now - 1000),
],
vec![
("stale", branch_with(1, None, None, Some(day_ms))),
(MAIN_BRANCH, branch(2, None)),
],
);
let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert_eq!(removed_refs(&updates), vec!["stale".to_string()]);
assert!(updates.iter().any(
|u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
));
}
#[tokio::test]
async fn test_unreferenced_snapshots_retained_only_while_young() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(3, None, 35, now - 10 * day_ms), snapshot(1, None, 36, now - 1000), snapshot(2, None, 37, now - 1000), ],
vec![(MAIN_BRANCH, branch(1, None))],
);
let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert_eq!(removed, vec![3]);
}
#[tokio::test]
async fn test_retain_last_zero_errors() {
let table = make_v2_table();
assert!(
Arc::new(action().retain_last(0))
.commit(&table)
.await
.is_err()
);
}
#[tokio::test]
async fn test_cannot_expire_branch_head_explicitly() {
let table = table_with(
vec![snapshot(1, None, 35, TS + 1), snapshot(2, None, 36, TS + 2)],
vec![(MAIN_BRANCH, branch(1, None)), ("branch", branch(2, None))],
);
let action = action().expire_snapshot_ids(vec![2]);
assert!(Arc::new(action).commit(&table).await.is_err());
}
#[tokio::test]
async fn test_tag_does_not_protect_its_ancestry() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, Some(1), 36, now - 8 * day_ms),
snapshot(3, Some(2), 37, now - 1000),
],
vec![(MAIN_BRANCH, branch(1, None)), ("tag", tag(3, None))],
);
let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert_eq!(removed, vec![2]);
}
#[tokio::test]
async fn test_branch_protects_its_ancestry() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, Some(1), 36, now - 8 * day_ms),
snapshot(3, Some(2), 37, now - 1000),
],
vec![
(MAIN_BRANCH, branch(1, None)),
("branch", branch_with(3, None, Some(i64::MAX), None)),
],
);
let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert!(removed.is_empty());
}
#[tokio::test]
async fn test_per_branch_max_snapshot_age_differs_across_branches() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 30 * day_ms),
snapshot(3, None, 36, now - 30 * day_ms),
snapshot(2, Some(1), 37, now - 2 * day_ms),
snapshot(4, Some(3), 38, now - 2 * day_ms),
],
vec![
(MAIN_BRANCH, branch_with(2, None, Some(5 * day_ms), None)),
("keep", branch_with(4, None, Some(60 * day_ms), None)),
],
);
let removed = expired(&table, action()).await;
assert_eq!(removed, vec![1]);
}
#[tokio::test]
async fn test_default_cutoff_expires_snapshots_older_than_max_age() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with(
vec![
snapshot(1, None, 35, now - 10 * day_ms), snapshot(2, Some(1), 36, now - 1000), ],
vec![(MAIN_BRANCH, branch(2, None))],
);
let removed = expired(&table, action()).await;
assert_eq!(removed, vec![1]);
}
#[tokio::test]
async fn test_min_snapshots_to_keep_property_is_the_default_floor() {
let table = table_with_props(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
],
vec![(MAIN_BRANCH, branch(3, None))],
HashMap::from([(
"history.expire.min-snapshots-to-keep".to_string(),
"3".to_string(),
)]),
);
let removed = expired(&table, action()).await;
assert!(removed.is_empty());
}
#[tokio::test]
async fn test_max_snapshot_age_ms_property_sets_the_cutoff() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with_props(
vec![
snapshot(1, None, 35, now - 2 * day_ms),
snapshot(2, Some(1), 36, now - 1000),
],
vec![(MAIN_BRANCH, branch(2, None))],
HashMap::from([(
"history.expire.max-snapshot-age-ms".to_string(),
day_ms.to_string(),
)]),
);
let removed = expired(&table, action()).await;
assert_eq!(removed, vec![1]);
}
#[tokio::test]
async fn test_max_ref_age_ms_property_ages_out_ref_without_its_own_window() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with_props(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, None, 36, now - 1000),
],
vec![("old-tag", tag(1, None)), (MAIN_BRANCH, branch(2, None))],
HashMap::from([(
"history.expire.max-ref-age-ms".to_string(),
day_ms.to_string(),
)]),
);
let updates = updates_of(&table, action()).await;
assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
assert!(updates.iter().any(
|u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
));
}
#[tokio::test]
async fn test_expiring_snapshot_drops_its_statistics() {
let table = table_with_stats(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
],
vec![(MAIN_BRANCH, branch(2, None))],
vec![stats_file(1), stats_file(2)],
vec![partition_stats_file(1), partition_stats_file(2)],
);
let updates = updates_of(
&table,
action().retain_last(1).expire_older_than_ms(i64::MAX),
)
.await;
assert_eq!(removed_statistics(&updates), vec![1]);
assert_eq!(removed_partition_statistics(&updates), vec![1]);
}
#[tokio::test]
async fn test_expiring_snapshot_without_statistics_emits_no_removal() {
let table = table_with(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
],
vec![(MAIN_BRANCH, branch(2, None))],
);
let updates = updates_of(
&table,
action().retain_last(1).expire_older_than_ms(i64::MAX),
)
.await;
assert!(removed_statistics(&updates).is_empty());
assert!(removed_partition_statistics(&updates).is_empty());
}
#[tokio::test]
async fn test_only_present_statistics_variant_is_removed() {
let table = table_with_stats(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
],
vec![(MAIN_BRANCH, branch(2, None))],
vec![stats_file(1)],
vec![],
);
let updates = updates_of(
&table,
action().retain_last(1).expire_older_than_ms(i64::MAX),
)
.await;
assert_eq!(removed_statistics(&updates), vec![1]);
assert!(removed_partition_statistics(&updates).is_empty());
}
#[tokio::test]
async fn test_ref_aging_expiry_drops_statistics() {
let now = Utc::now().timestamp_millis();
let day_ms = 24 * 60 * 60 * 1000;
let table = table_with_stats(
vec![
snapshot(1, None, 35, now - 10 * day_ms),
snapshot(2, None, 36, now - 1000),
],
vec![
("old-tag", tag(1, Some(day_ms))),
(MAIN_BRANCH, branch(2, None)),
],
vec![stats_file(1)],
vec![partition_stats_file(1)],
);
let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
assert_eq!(removed_statistics(&updates), vec![1]);
assert_eq!(removed_partition_statistics(&updates), vec![1]);
}
#[tokio::test]
async fn test_multiple_expired_snapshots_drop_their_statistics() {
let table = table_with_stats(
vec![
snapshot(1, None, 35, TS + 1),
snapshot(2, Some(1), 36, TS + 2),
snapshot(3, Some(2), 37, TS + 3),
],
vec![(MAIN_BRANCH, branch(3, None))],
vec![stats_file(1), stats_file(2)],
vec![partition_stats_file(1), partition_stats_file(2)],
);
let updates = updates_of(
&table,
action().retain_last(1).expire_older_than_ms(i64::MAX),
)
.await;
assert_eq!(removed_statistics(&updates), vec![1, 2]);
assert_eq!(removed_partition_statistics(&updates), vec![1, 2]);
}
}