Skip to main content

iceberg/transaction/
expire_snapshots.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// A transaction action that removes snapshots from table metadata.
32///
33/// This only rewrites metadata; the now-unreferenced data and metadata files are left untouched.
34/// Physical file cleanup is the responsibility of a higher-level maintenance operation built on
35/// top of this action.
36///
37/// Selection follows Java `RemoveSnapshots`:
38/// - Explicit ids ([`expire_snapshot_ids`](Self::expire_snapshot_ids)) and age-based expiry are
39///   combined: a snapshot is expired if it is named explicitly *or* selected by age.
40/// - Age-based expiry always runs. The cutoff is [`expire_older_than_ms`](Self::expire_older_than_ms)
41///   when set, a per-branch `max_snapshot_age_ms` for that branch, otherwise
42///   `now - history.expire.max-snapshot-age-ms` (default 5 days), matching Java's constructor default.
43/// - Expiry is computed per branch along each branch's ancestry: each branch keeps its most recent
44///   [`retain_last`](Self::retain_last) snapshots — defaulting to `history.expire.min-snapshots-to-keep`,
45///   with a per-ref `min_snapshots_to_keep` overriding both — plus any ancestor newer than the cutoff,
46///   so a shared ancestor reachable from a retained branch is never expired.
47/// - Refs are aged out first: a non-`main` branch or tag whose head is older than its
48///   `max_ref_age_ms` (defaulting to `history.expire.max-ref-age-ms`) is removed, and snapshots only
49///   that ref retained then become expirable.
50/// - Heads of retained refs (including the current snapshot) are never expired, and naming one
51///   explicitly is an error, since
52///   [`remove_snapshots`](crate::spec::TableMetadataBuilder::remove_snapshots) would otherwise
53///   drop the ref silently.
54#[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    /// Expire these snapshot ids in addition to any age-based selection.
71    ///
72    /// Age-based expiry runs by default (see the type-level docs), so a call that only names ids
73    /// still expires snapshots older than `history.expire.max-snapshot-age-ms`. Pin
74    /// [`expire_older_than_ms`](Self::expire_older_than_ms) to a very old timestamp to expire by id
75    /// alone.
76    ///
77    /// Ids accumulate across calls (like [`add_data_files`](crate::transaction::Transaction::fast_append)).
78    /// An id that is still referenced by a branch or tag cannot be expired and causes commits to fail.
79    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    /// Expire snapshots whose timestamp is strictly older than `older_than_ms`.
85    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    /// Keep at least the `retain_last` most recent snapshots of each branch when expiring by age
91    /// (defaults to the table's `history.expire.min-snapshots-to-keep`, must be at least 1).
92    ///
93    /// This only bounds the age cutoff; it does not protect snapshots named via
94    /// [`expire_snapshot_ids`](Self::expire_snapshot_ids). Setting it to 0 makes commit fail.
95    pub fn retain_last(mut self, retain_last: usize) -> Self {
96        self.retain_last = Some(retain_last);
97        self
98    }
99
100    /// Resolves the snapshots and refs to remove, following Java `RemoveSnapshots.internalApply`.
101    fn plan(&self, table: &Table, properties: &TableProperties<'_>) -> Result<ExpirePlan> {
102        // Matches Java `RemoveSnapshots.retainLast`, which requires at least one snapshot.
103        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        // When a knob is not set explicitly, fall back to the table's `history.expire.*` properties,
112        // matching Java `RemoveSnapshots`' constructor. With the default `max-snapshot-age-ms` (5
113        // days) the age path always runs, so even an explicit-id-only call applies the default cutoff.
114        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        // Ref aging: `main` is always kept; any other ref whose head is older than its
124        // `max_ref_age_ms` (defaulting to `history.expire.max-ref-age-ms`) is dropped, like Java's
125        // `computeRetainedRefs`.
126        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        // Heads of retained refs (plus the current snapshot) are never expired; naming one
140        // explicitly is an error, since `remove_snapshots` would otherwise drop the ref silently.
141        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        // Per-branch retention: keep each branch's most recent `min_to_keep` ancestors plus any
158        // newer than the branch cutoff. The current snapshot is treated as a branch (default policy)
159        // so its lineage is protected even when there is no explicit `main` ref.
160        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        // Unreferenced snapshots newer than the default cutoff are kept (Java's
200        // `unreferencedSnapshotsToRetain`); everything else not retained is expired.
201        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    /// Whether a non-main ref should be dropped because its head is older than its `max_ref_age_ms`,
223    /// defaulting to `default_max_ref_age_ms` (`history.expire.max-ref-age-ms`) when the ref sets no
224    /// window of its own. The default `i64::MAX` effectively never ages a ref out.
225    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    /// Walks a branch's ancestry (Java's `computeBranchSnapshotsToRetain`), retaining each ancestor
243    /// while fewer than `min` are kept or it is newer than `cutoff`, and recording every ancestor as
244    /// referenced. Ancestry timestamps decrease monotonically, so this needs no early break.
245    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    /// Iterates a snapshot and its ancestors, newest first, following `parent_snapshot_id`.
267    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
292/// Snapshots and refs an [`ExpireSnapshotsAction`] resolves to remove.
293struct 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        // Expiring metadata defeats a user's explicit decision to disable GC (Java refuses too).
305        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        // Drop aged-out refs first, then the snapshots no ref retains anymore.
318        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        // Drop statistics metadata for expired snapshots.
325        // This only updates metadata; puffin files are cleaned up separately.
326        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        // The ref assertion closes the race where a concurrent writer advances `main` between
350        // selection and commit, which could orphan a snapshot whose parent we are about to remove.
351        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    // `make_v2_table` carries an older snapshot (ts 1515100955770) and a current
383    // snapshot (ts 1555100955770).
384    const OLD_SNAPSHOT: i64 = 3051729675574597004;
385    const CURRENT_SNAPSHOT: i64 = 3055729675574597004;
386    // Well after the minimal table's last-updated-ms, so synthetic snapshots pass timestamp checks.
387    const TS: i64 = 1_700_000_000_000;
388    // A cutoff at the epoch makes age-based expiry a no-op (every snapshot is newer), isolating the
389    // explicit-id behavior under test now that the default cutoff (`now - 5 days`) always runs.
390    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    /// Builds a table from synthetic snapshots and refs on top of an empty base.
473    fn table_with(snapshots: Vec<Snapshot>, refs: Vec<(&str, SnapshotReference)>) -> Table {
474        table_with_props(snapshots, refs, HashMap::new())
475    }
476
477    /// Like [`table_with`], but also seeds table properties (e.g. `history.expire.*` defaults).
478    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    /// Like [`table_with`], but also attaches statistics and partition-statistics files. Reuses
500    /// [`table_with`] for the snapshot/ref wiring so the two can't drift, then layers stats on top.
501    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    /// `make_v2_table` with a tag pointing at the older snapshot.
591    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        // Both snapshots are referenced (current + tag), so nothing is expired.
624        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        // Threshold older than every snapshot -> nothing qualifies.
643        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        // main: 1 -> 2 -> 3 ; branch `b`: 1 -> 2 -> 4. Snapshot 2 is a shared ancestor of both
665        // branches but is not a ref head.
666        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        // A global "newest 2" would expire 2 and orphan branch `b`; per-branch retention keeps it.
677        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        // The branch's own min_snapshots_to_keep=3 wins over the action's retain_last(1).
697        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        // Age expires 1 and 2 (older than the cutoff, beyond retain_last). 3 is newer than the
718        // cutoff so age keeps it, but it is named explicitly, so all three are expired.
719        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        // Two separate calls both take effect (age expiry pinned off to isolate accumulation).
742        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        // main: 2 (recent). Isolated snapshot 1 (old) is only kept alive by `old-tag`, whose own
798        // age (10 days) exceeds its max-ref-age of 1 day.
799        let table = table_with(
800            vec![
801                snapshot(1, None, 35, now - 10 * day_ms),
802                snapshot(2, None, 36, now - 1000),
803            ],
804            // Set the tag before main so the builder's last-updated bookkeeping stays monotonic.
805            vec![
806                ("old-tag", tag(1, Some(day_ms))),
807                (MAIN_BRANCH, branch(2, None)),
808            ],
809        );
810
811        // An explicit cutoff lets the freed snapshot 1 actually expire once the tag is gone.
812        let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
813        // The tag is dropped, and snapshot 1 (now unreferenced and old) is expired with it.
814        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        // The tag is younger than its max-ref-age, so neither it nor its snapshot is removed.
837        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        // main: 1 (3 days old) -> 2 (1 day old) -> 3 (recent), with a 2-day per-ref window.
846        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        // With no explicit cutoff nothing would expire, but main's 2-day window expires snapshot 1.
856        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        // A stale non-main branch (head 1, 10 days old) past its 1-day max-ref-age.
865        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        // The stale branch is dropped and snapshot 1 (now unreferenced and old) is expired.
878        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        // Snapshot 1 is the main head; 2 and 3 are orphans (no ref, not ancestors). Add oldest
889        // first so the builder's timestamp bookkeeping stays monotonic.
890        let table = table_with(
891            vec![
892                snapshot(3, None, 35, now - 10 * day_ms), // old orphan
893                snapshot(1, None, 36, now - 1000),        // main head
894                snapshot(2, None, 37, now - 1000),        // young orphan
895            ],
896            vec![(MAIN_BRANCH, branch(1, None))],
897        );
898
899        // The young orphan (2) is kept; only the old orphan (3) is expired.
900        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        // 2 is the head of a non-main branch, so it cannot be expired explicitly.
921        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        // Chain 1 -> 2 -> 3 with main rewound to 1 and a tag on 3. Snapshot 2 (the tag target's
930        // parent) is reachable from no ref's retained set, so it is expired.
931        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        // Same topology as the tag case, but a branch (keeping its whole history) replaces the tag.
949        // The branch's parent snapshot 2 is now reachable history and is not expired.
950        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        // main (1 -> 2) keeps 5 days; branch `keep` (3 -> 4) keeps 60 days. Snapshots 1 and 3 are
971        // both 30 days old, but only main's short window expires its ancestor.
972        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        // main's 5-day window expires its old ancestor 1; keep's 60-day window retains its old
986        // ancestor 3.
987        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), // older than the default 5-day cutoff
998                snapshot(2, Some(1), 36, now - 1000),     // recent
999            ],
1000            vec![(MAIN_BRANCH, branch(2, None))],
1001        );
1002
1003        // No explicit cutoff: defaults to now - history.expire.max-snapshot-age-ms (5 days).
1004        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        // The snapshots predate the default cutoff, but the table's min-snapshots-to-keep=3 keeps
1024        // the whole chain.
1025        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        // Snapshot 1 is 2 days old: the built-in 5-day default would keep it, but the table's
1034        // 1-day history.expire.max-snapshot-age-ms expires it. This pins the cutoff to the property
1035        // value rather than the hardcoded default.
1036        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        // `old-tag` sets no max_ref_age_ms of its own, so it ages against the table's
1057        // history.expire.max-ref-age-ms (1 day); its head is 10 days old, so the tag is dropped.
1058        // Set the tag before main so the builder's last-updated bookkeeping stays monotonic.
1059        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        // Once the tag is gone, snapshot 1 (unreferenced and well past the default cutoff) expires.
1074        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        // main: 1 -> 2, both carrying statistics. retain_last(1) keeps only the head (2).
1082        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        // Snapshot 1 expires, so its stats entries are dropped; the retained head 2 keeps its stats.
1099        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        // Same expiry, but no statistics attached to any snapshot.
1106        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        // Snapshot 1 has statistics but no partition statistics.
1127        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        // Snapshot 1 is kept alive only by `old-tag`; once the tag ages out (1-day max-ref-age vs a
1152        // 10-day-old head) snapshot 1 becomes expirable and its statistics go with it. This reaches
1153        // the stats loop through the ref-aging branch of `plan()`, distinct from the retain/age path.
1154        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        // Chain 1 -> 2 -> 3 on main; retain_last(1) keeps only head 3, expiring 1 and 2, both
1176        // carrying statistics.
1177        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}