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;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use chrono::Utc;
23
24use crate::spec::{
25    MAIN_BRANCH, SnapshotReference, SnapshotRetention, TableMetadata, TableProperties,
26};
27use crate::table::Table;
28use crate::transaction::action::{ActionCommit, TransactionAction};
29use crate::{Error, ErrorKind, Result, TableRequirement, TableUpdate};
30
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.
54pub struct ExpireSnapshotsAction {
55    explicit_ids_to_remove: Vec<i64>,
56    older_than_ms: Option<i64>,
57    retain_last: Option<usize>,
58}
59
60impl ExpireSnapshotsAction {
61    pub(crate) fn new() -> Self {
62        Self {
63            explicit_ids_to_remove: vec![],
64            older_than_ms: None,
65            retain_last: None,
66        }
67    }
68
69    /// Expire these snapshot ids in addition to any age-based selection.
70    ///
71    /// Age-based expiry runs by default (see the type-level docs), so a call that only names ids
72    /// still expires snapshots older than `history.expire.max-snapshot-age-ms`. Pin
73    /// [`expire_older_than_ms`](Self::expire_older_than_ms) to a very old timestamp to expire by id
74    /// alone.
75    ///
76    /// Ids accumulate across calls (like [`add_data_files`](crate::transaction::Transaction::fast_append)).
77    /// An id that is still referenced by a branch or tag cannot be expired and causes commits to fail.
78    pub fn expire_snapshot_ids(mut self, snapshot_ids: impl IntoIterator<Item = i64>) -> Self {
79        self.explicit_ids_to_remove.extend(snapshot_ids);
80        self
81    }
82
83    /// Expire snapshots whose timestamp is strictly older than `older_than_ms`.
84    pub fn expire_older_than_ms(mut self, older_than_ms: i64) -> Self {
85        self.older_than_ms = Some(older_than_ms);
86        self
87    }
88
89    /// Keep at least the `retain_last` most recent snapshots of each branch when expiring by age
90    /// (defaults to the table's `history.expire.min-snapshots-to-keep`, must be at least 1).
91    ///
92    /// This only bounds the age cutoff; it does not protect snapshots named via
93    /// [`expire_snapshot_ids`](Self::expire_snapshot_ids). Setting it to 0 makes commit fail.
94    pub fn retain_last(mut self, retain_last: usize) -> Self {
95        self.retain_last = Some(retain_last);
96        self
97    }
98
99    /// Resolves the snapshots and refs to remove, following Java `RemoveSnapshots.internalApply`.
100    fn plan(&self, table: &Table, properties: &TableProperties<'_>) -> Result<ExpirePlan> {
101        // Matches Java `RemoveSnapshots.retainLast`, which requires at least one snapshot.
102        if self.retain_last == Some(0) {
103            return Err(Error::new(
104                ErrorKind::DataInvalid,
105                "Number of snapshots to retain must be at least 1",
106            ));
107        }
108
109        let metadata = table.metadata();
110        let now = Utc::now().timestamp_millis();
111        // 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 Error::new(ErrorKind::DataInvalid, "Cannot expire the current snapshot");
281        }
282        let ref_names: Vec<&str> = metadata
283            .refs
284            .iter()
285            .filter(|(_, snapshot_ref)| snapshot_ref.snapshot_id == snapshot_id)
286            .map(|(ref_name, _)| ref_name.as_str())
287            .collect();
288        Error::new(
289            ErrorKind::DataInvalid,
290            format!("Cannot expire snapshot {snapshot_id}: still referenced by {ref_names:?}"),
291        )
292    }
293}
294
295/// Snapshots and refs an [`ExpireSnapshotsAction`] resolves to remove.
296struct ExpirePlan {
297    ids_to_remove: Vec<i64>,
298    refs_to_remove: Vec<String>,
299}
300
301#[async_trait]
302impl TransactionAction for ExpireSnapshotsAction {
303    async fn commit(self: Arc<Self>, table: &Table) -> Result<ActionCommit> {
304        let metadata = table.metadata();
305        let properties = metadata.table_properties();
306
307        // Expiring metadata defeats a user's explicit decision to disable GC (Java refuses too).
308        if !properties.gc_enabled()? {
309            return Err(Error::new(
310                ErrorKind::DataInvalid,
311                "Cannot expire snapshots: gc.enabled is false",
312            ));
313        }
314
315        let plan = self.plan(table, &properties)?;
316
317        if plan.ids_to_remove.is_empty() && plan.refs_to_remove.is_empty() {
318            return Ok(ActionCommit::new(vec![], vec![]));
319        }
320
321        // Drop aged-out refs first, then the snapshots no ref retains anymore.
322        let mut updates: Vec<TableUpdate> = plan
323            .refs_to_remove
324            .into_iter()
325            .map(|ref_name| TableUpdate::RemoveSnapshotRef { ref_name })
326            .collect();
327
328        // Drop statistics metadata for expired snapshots.
329        // This only updates metadata; puffin files are cleaned up separately.
330        let mut stats_updates: Vec<TableUpdate> = vec![];
331        for &snapshot_id in &plan.ids_to_remove {
332            stats_updates.extend(
333                metadata
334                    .statistics_for_snapshot(snapshot_id)
335                    .is_some()
336                    .then_some(TableUpdate::RemoveStatistics { snapshot_id }),
337            );
338            stats_updates.extend(
339                metadata
340                    .partition_statistics_for_snapshot(snapshot_id)
341                    .is_some()
342                    .then_some(TableUpdate::RemovePartitionStatistics { snapshot_id }),
343            );
344        }
345
346        if !plan.ids_to_remove.is_empty() {
347            updates.push(TableUpdate::RemoveSnapshots {
348                snapshot_ids: plan.ids_to_remove,
349            });
350        }
351        updates.extend(stats_updates);
352
353        // The ref assertion closes the race where a concurrent writer advances `main` between
354        // selection and commit, which could orphan a snapshot whose parent we are about to remove.
355        Ok(ActionCommit::new(updates, vec![
356            TableRequirement::UuidMatch {
357                uuid: metadata.uuid(),
358            },
359            TableRequirement::RefSnapshotIdMatch {
360                r#ref: MAIN_BRANCH.to_string(),
361                snapshot_id: metadata.current_snapshot_id(),
362            },
363        ]))
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use std::collections::HashMap;
370    use std::sync::Arc;
371
372    use chrono::Utc;
373
374    use crate::spec::{
375        MAIN_BRANCH, Operation, PartitionStatisticsFile, Snapshot, SnapshotReference,
376        SnapshotRetention, StatisticsFile, Summary,
377    };
378    use crate::table::Table;
379    use crate::transaction::Transaction;
380    use crate::transaction::action::{ApplyTransactionAction, TransactionAction};
381    use crate::transaction::expire_snapshots::ExpireSnapshotsAction;
382    use crate::transaction::tests::{make_v2_minimal_table, make_v2_table};
383    use crate::{TableRequirement, TableUpdate};
384
385    // `make_v2_table` carries an older snapshot (ts 1515100955770) and a current
386    // snapshot (ts 1555100955770).
387    const OLD_SNAPSHOT: i64 = 3051729675574597004;
388    const CURRENT_SNAPSHOT: i64 = 3055729675574597004;
389    // Well after the minimal table's last-updated-ms, so synthetic snapshots pass timestamp checks.
390    const TS: i64 = 1_700_000_000_000;
391    // A cutoff at the epoch makes age-based expiry a no-op (every snapshot is newer), isolating the
392    // explicit-id behavior under test now that the default cutoff (`now - 5 days`) always runs.
393    const NO_AGE_EXPIRY: i64 = 0;
394
395    fn action() -> ExpireSnapshotsAction {
396        ExpireSnapshotsAction::new()
397    }
398
399    async fn removed_ids(action: ExpireSnapshotsAction) -> Vec<i64> {
400        expired(&make_v2_table(), action).await
401    }
402
403    async fn updates_of(table: &Table, action: ExpireSnapshotsAction) -> Vec<TableUpdate> {
404        Arc::new(action).commit(table).await.unwrap().take_updates()
405    }
406
407    async fn expired(table: &Table, action: ExpireSnapshotsAction) -> Vec<i64> {
408        updates_of(table, action)
409            .await
410            .into_iter()
411            .find_map(|update| match update {
412                TableUpdate::RemoveSnapshots { snapshot_ids } => Some(snapshot_ids),
413                _ => None,
414            })
415            .unwrap_or_default()
416    }
417
418    fn removed_refs(updates: &[TableUpdate]) -> Vec<String> {
419        let mut refs: Vec<String> = updates
420            .iter()
421            .filter_map(|update| match update {
422                TableUpdate::RemoveSnapshotRef { ref_name } => Some(ref_name.clone()),
423                _ => None,
424            })
425            .collect();
426        refs.sort();
427        refs
428    }
429
430    fn snapshot(id: i64, parent: Option<i64>, sequence_number: i64, timestamp_ms: i64) -> Snapshot {
431        Snapshot::builder()
432            .with_snapshot_id(id)
433            .with_parent_snapshot_id(parent)
434            .with_sequence_number(sequence_number)
435            .with_timestamp_ms(timestamp_ms)
436            .with_schema_id(0)
437            .with_manifest_list(format!("/snap-{id}.avro"))
438            .with_summary(Summary {
439                operation: Operation::Append,
440                additional_properties: HashMap::new(),
441            })
442            .build()
443    }
444
445    fn branch(snapshot_id: i64, min_snapshots_to_keep: Option<i32>) -> SnapshotReference {
446        branch_with(snapshot_id, min_snapshots_to_keep, None, None)
447    }
448
449    fn branch_with(
450        snapshot_id: i64,
451        min_snapshots_to_keep: Option<i32>,
452        max_snapshot_age_ms: Option<i64>,
453        max_ref_age_ms: Option<i64>,
454    ) -> SnapshotReference {
455        SnapshotReference {
456            snapshot_id,
457            retention: SnapshotRetention::Branch {
458                min_snapshots_to_keep,
459                max_snapshot_age_ms,
460                max_ref_age_ms,
461            },
462        }
463    }
464
465    fn tag(snapshot_id: i64, max_ref_age_ms: Option<i64>) -> SnapshotReference {
466        SnapshotReference {
467            snapshot_id,
468            retention: SnapshotRetention::Tag { max_ref_age_ms },
469        }
470    }
471
472    /// 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!(Arc::new(action).commit(&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!(Arc::new(action).commit(&table).await.is_err());
615    }
616
617    #[tokio::test]
618    async fn test_age_expiry_skips_tagged_snapshot() {
619        let table = table_with_tag_on_old();
620        let mut commit = Arc::new(action().expire_older_than_ms(i64::MAX))
621            .commit(&table)
622            .await
623            .unwrap();
624        // Both snapshots are referenced (current + tag), so nothing is expired.
625        assert!(commit.take_updates().is_empty());
626    }
627
628    #[tokio::test]
629    async fn test_retain_last_default_expires_older_non_current() {
630        assert_eq!(
631            removed_ids(action().expire_older_than_ms(i64::MAX)).await,
632            vec![OLD_SNAPSHOT]
633        );
634    }
635
636    #[tokio::test]
637    async fn test_retain_last_noop_when_enough_retained() {
638        assert!(removed_ids(action().retain_last(5)).await.is_empty());
639    }
640
641    #[tokio::test]
642    async fn test_older_than_excludes_newer_snapshots() {
643        // Threshold older than every snapshot -> nothing qualifies.
644        assert!(
645            removed_ids(action().expire_older_than_ms(1))
646                .await
647                .is_empty()
648        );
649    }
650
651    #[tokio::test]
652    async fn test_apply_registers_action() {
653        let table = make_v2_table();
654        let tx = Transaction::new(&table);
655        let tx = tx
656            .expire_snapshots()
657            .expire_snapshot_ids(vec![OLD_SNAPSHOT])
658            .apply(tx)
659            .unwrap();
660        assert_eq!(tx.actions.len(), 1);
661    }
662
663    #[tokio::test]
664    async fn test_per_branch_retention_protects_shared_ancestor() {
665        // main: 1 -> 2 -> 3 ; branch `b`: 1 -> 2 -> 4. Snapshot 2 is a shared ancestor of both
666        // branches but is not a ref head.
667        let table = table_with(
668            vec![
669                snapshot(1, None, 35, TS + 1),
670                snapshot(2, Some(1), 36, TS + 2),
671                snapshot(3, Some(2), 37, TS + 3),
672                snapshot(4, Some(2), 38, TS + 4),
673            ],
674            vec![(MAIN_BRANCH, branch(3, None)), ("b", branch(4, None))],
675        );
676
677        // A global "newest 2" would expire 2 and orphan branch `b`; per-branch retention keeps it.
678        let removed = expired(
679            &table,
680            action().retain_last(2).expire_older_than_ms(i64::MAX),
681        )
682        .await;
683        assert_eq!(removed, vec![1]);
684    }
685
686    #[tokio::test]
687    async fn test_per_ref_min_snapshots_to_keep_overrides_retain_last() {
688        let table = table_with(
689            vec![
690                snapshot(1, None, 35, TS + 1),
691                snapshot(2, Some(1), 36, TS + 2),
692                snapshot(3, Some(2), 37, TS + 3),
693            ],
694            vec![(MAIN_BRANCH, branch(3, Some(3)))],
695        );
696
697        // The branch's own min_snapshots_to_keep=3 wins over the action's retain_last(1).
698        let removed = expired(
699            &table,
700            action().retain_last(1).expire_older_than_ms(i64::MAX),
701        )
702        .await;
703        assert!(removed.is_empty());
704    }
705
706    #[tokio::test]
707    async fn test_explicit_and_age_combine() {
708        let table = table_with(
709            vec![
710                snapshot(1, None, 35, TS + 1),
711                snapshot(2, Some(1), 36, TS + 2),
712                snapshot(3, Some(2), 37, TS + 3),
713                snapshot(4, Some(3), 38, TS + 4),
714            ],
715            vec![(MAIN_BRANCH, branch(4, None))],
716        );
717
718        // Age expires 1 and 2 (older than the cutoff, beyond retain_last). 3 is newer than the
719        // cutoff so age keeps it, but it is named explicitly, so all three are expired.
720        let removed = expired(
721            &table,
722            action()
723                .retain_last(1)
724                .expire_older_than_ms(TS + 3)
725                .expire_snapshot_ids(vec![3]),
726        )
727        .await;
728        assert_eq!(removed, vec![1, 2, 3]);
729    }
730
731    #[tokio::test]
732    async fn test_expire_snapshot_ids_accumulates() {
733        let table = table_with(
734            vec![
735                snapshot(1, None, 35, TS + 1),
736                snapshot(2, Some(1), 36, TS + 2),
737                snapshot(3, Some(2), 37, TS + 3),
738            ],
739            vec![(MAIN_BRANCH, branch(3, None))],
740        );
741
742        // Two separate calls both take effect (age expiry pinned off to isolate accumulation).
743        let removed = expired(
744            &table,
745            action()
746                .expire_snapshot_ids(vec![1])
747                .expire_snapshot_ids(vec![2])
748                .expire_older_than_ms(NO_AGE_EXPIRY),
749        )
750        .await;
751        assert_eq!(removed, vec![1, 2]);
752    }
753
754    #[tokio::test]
755    async fn test_gc_disabled_errors() {
756        let table = make_v2_table();
757        let metadata = table
758            .metadata()
759            .clone()
760            .into_builder(None)
761            .set_properties(HashMap::from([(
762                "gc.enabled".to_string(),
763                "false".to_string(),
764            )]))
765            .unwrap()
766            .build()
767            .unwrap()
768            .metadata;
769        let table = table.with_metadata(Arc::new(metadata));
770
771        let action = action().expire_snapshot_ids(vec![OLD_SNAPSHOT]);
772        assert!(Arc::new(action).commit(&table).await.is_err());
773    }
774
775    #[tokio::test]
776    async fn test_commit_asserts_main_ref() {
777        let table = make_v2_table();
778        let mut commit = Arc::new(action().expire_snapshot_ids(vec![OLD_SNAPSHOT]))
779            .commit(&table)
780            .await
781            .unwrap();
782        assert!(
783            commit
784                .take_requirements()
785                .iter()
786                .any(|requirement| matches!(
787                    requirement,
788                    TableRequirement::RefSnapshotIdMatch { r#ref, snapshot_id }
789                        if r#ref == MAIN_BRANCH && *snapshot_id == Some(CURRENT_SNAPSHOT)
790                ))
791        );
792    }
793
794    #[tokio::test]
795    async fn test_ref_aging_drops_old_tag_and_expires_its_snapshot() {
796        let now = Utc::now().timestamp_millis();
797        let day_ms = 24 * 60 * 60 * 1000;
798        // main: 2 (recent). Isolated snapshot 1 (old) is only kept alive by `old-tag`, whose own
799        // age (10 days) exceeds its max-ref-age of 1 day.
800        let table = table_with(
801            vec![
802                snapshot(1, None, 35, now - 10 * day_ms),
803                snapshot(2, None, 36, now - 1000),
804            ],
805            // Set the tag before main so the builder's last-updated bookkeeping stays monotonic.
806            vec![
807                ("old-tag", tag(1, Some(day_ms))),
808                (MAIN_BRANCH, branch(2, None)),
809            ],
810        );
811
812        // An explicit cutoff lets the freed snapshot 1 actually expire once the tag is gone.
813        let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
814        // The tag is dropped, and snapshot 1 (now unreferenced and old) is expired with it.
815        assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
816        assert!(updates.iter().any(
817            |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
818        ));
819    }
820
821    #[tokio::test]
822    async fn test_ref_aging_keeps_recent_tag() {
823        let now = Utc::now().timestamp_millis();
824        let day_ms = 24 * 60 * 60 * 1000;
825        let table = table_with(
826            vec![
827                snapshot(1, None, 35, now - 1000),
828                snapshot(2, None, 36, now - 500),
829            ],
830            vec![
831                ("fresh-tag", tag(1, Some(day_ms))),
832                (MAIN_BRANCH, branch(2, None)),
833            ],
834        );
835
836        let updates = updates_of(&table, action()).await;
837        // The tag is younger than its max-ref-age, so neither it nor its snapshot is removed.
838        assert!(removed_refs(&updates).is_empty());
839        assert_eq!(expired(&table, action()).await, Vec::<i64>::new());
840    }
841
842    #[tokio::test]
843    async fn test_per_ref_max_snapshot_age_overrides_default() {
844        let now = Utc::now().timestamp_millis();
845        let day_ms = 24 * 60 * 60 * 1000;
846        // main: 1 (3 days old) -> 2 (1 day old) -> 3 (recent), with a 2-day per-ref window.
847        let table = table_with(
848            vec![
849                snapshot(1, None, 35, now - 3 * day_ms),
850                snapshot(2, Some(1), 36, now - day_ms),
851                snapshot(3, Some(2), 37, now - 1000),
852            ],
853            vec![(MAIN_BRANCH, branch_with(3, Some(1), Some(2 * day_ms), None))],
854        );
855
856        // With no explicit cutoff nothing would expire, but main's 2-day window expires snapshot 1.
857        let removed = expired(&table, action()).await;
858        assert_eq!(removed, vec![1]);
859    }
860
861    #[tokio::test]
862    async fn test_ref_aging_drops_old_branch() {
863        let now = Utc::now().timestamp_millis();
864        let day_ms = 24 * 60 * 60 * 1000;
865        // A stale non-main branch (head 1, 10 days old) past its 1-day max-ref-age.
866        let table = table_with(
867            vec![
868                snapshot(1, None, 35, now - 10 * day_ms),
869                snapshot(2, None, 36, now - 1000),
870            ],
871            vec![
872                ("stale", branch_with(1, None, None, Some(day_ms))),
873                (MAIN_BRANCH, branch(2, None)),
874            ],
875        );
876
877        let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
878        // The stale branch is dropped and snapshot 1 (now unreferenced and old) is expired.
879        assert_eq!(removed_refs(&updates), vec!["stale".to_string()]);
880        assert!(updates.iter().any(
881            |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
882        ));
883    }
884
885    #[tokio::test]
886    async fn test_unreferenced_snapshots_retained_only_while_young() {
887        let now = Utc::now().timestamp_millis();
888        let day_ms = 24 * 60 * 60 * 1000;
889        // Snapshot 1 is the main head; 2 and 3 are orphans (no ref, not ancestors). Add oldest
890        // first so the builder's timestamp bookkeeping stays monotonic.
891        let table = table_with(
892            vec![
893                snapshot(3, None, 35, now - 10 * day_ms), // old orphan
894                snapshot(1, None, 36, now - 1000),        // main head
895                snapshot(2, None, 37, now - 1000),        // young orphan
896            ],
897            vec![(MAIN_BRANCH, branch(1, None))],
898        );
899
900        // The young orphan (2) is kept; only the old orphan (3) is expired.
901        let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
902        assert_eq!(removed, vec![3]);
903    }
904
905    #[tokio::test]
906    async fn test_retain_last_zero_errors() {
907        let table = make_v2_table();
908        assert!(
909            Arc::new(action().retain_last(0))
910                .commit(&table)
911                .await
912                .is_err()
913        );
914    }
915
916    #[tokio::test]
917    async fn test_cannot_expire_branch_head_explicitly() {
918        let table = table_with(
919            vec![snapshot(1, None, 35, TS + 1), snapshot(2, None, 36, TS + 2)],
920            vec![(MAIN_BRANCH, branch(1, None)), ("branch", branch(2, None))],
921        );
922        // 2 is the head of a non-main branch, so it cannot be expired explicitly.
923        let action = action().expire_snapshot_ids(vec![2]);
924        assert!(Arc::new(action).commit(&table).await.is_err());
925    }
926
927    #[tokio::test]
928    async fn test_tag_does_not_protect_its_ancestry() {
929        let now = Utc::now().timestamp_millis();
930        let day_ms = 24 * 60 * 60 * 1000;
931        // Chain 1 -> 2 -> 3 with main rewound to 1 and a tag on 3. Snapshot 2 (the tag target's
932        // parent) is reachable from no ref's retained set, so it is expired.
933        let table = table_with(
934            vec![
935                snapshot(1, None, 35, now - 10 * day_ms),
936                snapshot(2, Some(1), 36, now - 8 * day_ms),
937                snapshot(3, Some(2), 37, now - 1000),
938            ],
939            vec![(MAIN_BRANCH, branch(1, None)), ("tag", tag(3, None))],
940        );
941
942        let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
943        assert_eq!(removed, vec![2]);
944    }
945
946    #[tokio::test]
947    async fn test_branch_protects_its_ancestry() {
948        let now = Utc::now().timestamp_millis();
949        let day_ms = 24 * 60 * 60 * 1000;
950        // Same topology as the tag case, but a branch (keeping its whole history) replaces the tag.
951        // The branch's parent snapshot 2 is now reachable history and is not expired.
952        let table = table_with(
953            vec![
954                snapshot(1, None, 35, now - 10 * day_ms),
955                snapshot(2, Some(1), 36, now - 8 * day_ms),
956                snapshot(3, Some(2), 37, now - 1000),
957            ],
958            vec![
959                (MAIN_BRANCH, branch(1, None)),
960                ("branch", branch_with(3, None, Some(i64::MAX), None)),
961            ],
962        );
963
964        let removed = expired(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
965        assert!(removed.is_empty());
966    }
967
968    #[tokio::test]
969    async fn test_per_branch_max_snapshot_age_differs_across_branches() {
970        let now = Utc::now().timestamp_millis();
971        let day_ms = 24 * 60 * 60 * 1000;
972        // main (1 -> 2) keeps 5 days; branch `keep` (3 -> 4) keeps 60 days. Snapshots 1 and 3 are
973        // both 30 days old, but only main's short window expires its ancestor.
974        let table = table_with(
975            vec![
976                snapshot(1, None, 35, now - 30 * day_ms),
977                snapshot(3, None, 36, now - 30 * day_ms),
978                snapshot(2, Some(1), 37, now - 2 * day_ms),
979                snapshot(4, Some(3), 38, now - 2 * day_ms),
980            ],
981            vec![
982                (MAIN_BRANCH, branch_with(2, None, Some(5 * day_ms), None)),
983                ("keep", branch_with(4, None, Some(60 * day_ms), None)),
984            ],
985        );
986
987        // main's 5-day window expires its old ancestor 1; keep's 60-day window retains its old
988        // ancestor 3.
989        let removed = expired(&table, action()).await;
990        assert_eq!(removed, vec![1]);
991    }
992
993    #[tokio::test]
994    async fn test_default_cutoff_expires_snapshots_older_than_max_age() {
995        let now = Utc::now().timestamp_millis();
996        let day_ms = 24 * 60 * 60 * 1000;
997        let table = table_with(
998            vec![
999                snapshot(1, None, 35, now - 10 * day_ms), // older than the default 5-day cutoff
1000                snapshot(2, Some(1), 36, now - 1000),     // recent
1001            ],
1002            vec![(MAIN_BRANCH, branch(2, None))],
1003        );
1004
1005        // No explicit cutoff: defaults to now - history.expire.max-snapshot-age-ms (5 days).
1006        let removed = expired(&table, action()).await;
1007        assert_eq!(removed, vec![1]);
1008    }
1009
1010    #[tokio::test]
1011    async fn test_min_snapshots_to_keep_property_is_the_default_floor() {
1012        let table = table_with_props(
1013            vec![
1014                snapshot(1, None, 35, TS + 1),
1015                snapshot(2, Some(1), 36, TS + 2),
1016                snapshot(3, Some(2), 37, TS + 3),
1017            ],
1018            vec![(MAIN_BRANCH, branch(3, None))],
1019            HashMap::from([(
1020                "history.expire.min-snapshots-to-keep".to_string(),
1021                "3".to_string(),
1022            )]),
1023        );
1024
1025        // The snapshots predate the default cutoff, but the table's min-snapshots-to-keep=3 keeps
1026        // the whole chain.
1027        let removed = expired(&table, action()).await;
1028        assert!(removed.is_empty());
1029    }
1030
1031    #[tokio::test]
1032    async fn test_max_snapshot_age_ms_property_sets_the_cutoff() {
1033        let now = Utc::now().timestamp_millis();
1034        let day_ms = 24 * 60 * 60 * 1000;
1035        // Snapshot 1 is 2 days old: the built-in 5-day default would keep it, but the table's
1036        // 1-day history.expire.max-snapshot-age-ms expires it. This pins the cutoff to the property
1037        // value rather than the hardcoded default.
1038        let table = table_with_props(
1039            vec![
1040                snapshot(1, None, 35, now - 2 * day_ms),
1041                snapshot(2, Some(1), 36, now - 1000),
1042            ],
1043            vec![(MAIN_BRANCH, branch(2, None))],
1044            HashMap::from([(
1045                "history.expire.max-snapshot-age-ms".to_string(),
1046                day_ms.to_string(),
1047            )]),
1048        );
1049
1050        let removed = expired(&table, action()).await;
1051        assert_eq!(removed, vec![1]);
1052    }
1053
1054    #[tokio::test]
1055    async fn test_max_ref_age_ms_property_ages_out_ref_without_its_own_window() {
1056        let now = Utc::now().timestamp_millis();
1057        let day_ms = 24 * 60 * 60 * 1000;
1058        // `old-tag` sets no max_ref_age_ms of its own, so it ages against the table's
1059        // history.expire.max-ref-age-ms (1 day); its head is 10 days old, so the tag is dropped.
1060        // Set the tag before main so the builder's last-updated bookkeeping stays monotonic.
1061        let table = table_with_props(
1062            vec![
1063                snapshot(1, None, 35, now - 10 * day_ms),
1064                snapshot(2, None, 36, now - 1000),
1065            ],
1066            vec![("old-tag", tag(1, None)), (MAIN_BRANCH, branch(2, None))],
1067            HashMap::from([(
1068                "history.expire.max-ref-age-ms".to_string(),
1069                day_ms.to_string(),
1070            )]),
1071        );
1072
1073        let updates = updates_of(&table, action()).await;
1074        assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1075        // Once the tag is gone, snapshot 1 (unreferenced and well past the default cutoff) expires.
1076        assert!(updates.iter().any(
1077            |u| matches!(u, TableUpdate::RemoveSnapshots { snapshot_ids } if snapshot_ids == &[1])
1078        ));
1079    }
1080
1081    #[tokio::test]
1082    async fn test_expiring_snapshot_drops_its_statistics() {
1083        // main: 1 -> 2, both carrying statistics. retain_last(1) keeps only the head (2).
1084        let table = table_with_stats(
1085            vec![
1086                snapshot(1, None, 35, TS + 1),
1087                snapshot(2, Some(1), 36, TS + 2),
1088            ],
1089            vec![(MAIN_BRANCH, branch(2, None))],
1090            vec![stats_file(1), stats_file(2)],
1091            vec![partition_stats_file(1), partition_stats_file(2)],
1092        );
1093
1094        let updates = updates_of(
1095            &table,
1096            action().retain_last(1).expire_older_than_ms(i64::MAX),
1097        )
1098        .await;
1099
1100        // Snapshot 1 expires, so its stats entries are dropped; the retained head 2 keeps its stats.
1101        assert_eq!(removed_statistics(&updates), vec![1]);
1102        assert_eq!(removed_partition_statistics(&updates), vec![1]);
1103    }
1104
1105    #[tokio::test]
1106    async fn test_expiring_snapshot_without_statistics_emits_no_removal() {
1107        // Same expiry, but no statistics attached to any snapshot.
1108        let table = table_with(
1109            vec![
1110                snapshot(1, None, 35, TS + 1),
1111                snapshot(2, Some(1), 36, TS + 2),
1112            ],
1113            vec![(MAIN_BRANCH, branch(2, None))],
1114        );
1115
1116        let updates = updates_of(
1117            &table,
1118            action().retain_last(1).expire_older_than_ms(i64::MAX),
1119        )
1120        .await;
1121
1122        assert!(removed_statistics(&updates).is_empty());
1123        assert!(removed_partition_statistics(&updates).is_empty());
1124    }
1125
1126    #[tokio::test]
1127    async fn test_only_present_statistics_variant_is_removed() {
1128        // Snapshot 1 has statistics but no partition statistics.
1129        let table = table_with_stats(
1130            vec![
1131                snapshot(1, None, 35, TS + 1),
1132                snapshot(2, Some(1), 36, TS + 2),
1133            ],
1134            vec![(MAIN_BRANCH, branch(2, None))],
1135            vec![stats_file(1)],
1136            vec![],
1137        );
1138
1139        let updates = updates_of(
1140            &table,
1141            action().retain_last(1).expire_older_than_ms(i64::MAX),
1142        )
1143        .await;
1144
1145        assert_eq!(removed_statistics(&updates), vec![1]);
1146        assert!(removed_partition_statistics(&updates).is_empty());
1147    }
1148
1149    #[tokio::test]
1150    async fn test_ref_aging_expiry_drops_statistics() {
1151        let now = Utc::now().timestamp_millis();
1152        let day_ms = 24 * 60 * 60 * 1000;
1153        // Snapshot 1 is kept alive only by `old-tag`; once the tag ages out (1-day max-ref-age vs a
1154        // 10-day-old head) snapshot 1 becomes expirable and its statistics go with it. This reaches
1155        // the stats loop through the ref-aging branch of `plan()`, distinct from the retain/age path.
1156        let table = table_with_stats(
1157            vec![
1158                snapshot(1, None, 35, now - 10 * day_ms),
1159                snapshot(2, None, 36, now - 1000),
1160            ],
1161            vec![
1162                ("old-tag", tag(1, Some(day_ms))),
1163                (MAIN_BRANCH, branch(2, None)),
1164            ],
1165            vec![stats_file(1)],
1166            vec![partition_stats_file(1)],
1167        );
1168
1169        let updates = updates_of(&table, action().expire_older_than_ms(now - 5 * day_ms)).await;
1170        assert_eq!(removed_refs(&updates), vec!["old-tag".to_string()]);
1171        assert_eq!(removed_statistics(&updates), vec![1]);
1172        assert_eq!(removed_partition_statistics(&updates), vec![1]);
1173    }
1174
1175    #[tokio::test]
1176    async fn test_multiple_expired_snapshots_drop_their_statistics() {
1177        // Chain 1 -> 2 -> 3 on main; retain_last(1) keeps only head 3, expiring 1 and 2, both
1178        // carrying statistics.
1179        let table = table_with_stats(
1180            vec![
1181                snapshot(1, None, 35, TS + 1),
1182                snapshot(2, Some(1), 36, TS + 2),
1183                snapshot(3, Some(2), 37, TS + 3),
1184            ],
1185            vec![(MAIN_BRANCH, branch(3, None))],
1186            vec![stats_file(1), stats_file(2)],
1187            vec![partition_stats_file(1), partition_stats_file(2)],
1188        );
1189
1190        let updates = updates_of(
1191            &table,
1192            action().retain_last(1).expire_older_than_ms(i64::MAX),
1193        )
1194        .await;
1195
1196        assert_eq!(removed_statistics(&updates), vec![1, 2]);
1197        assert_eq!(removed_partition_statistics(&updates), vec![1, 2]);
1198    }
1199}