Skip to main content

loonfs_core/gc/
reap.rs

1//! Reaping: age-gated deletion of unreachable objects.
2//!
3//! Immutable families age by provider timestamp — nothing else records when
4//! they were written. Checkpoint records do not: their lifecycle instants
5//! (`created_at_ms`, `expires_at_ms`, `released_at_ms`) live in the record,
6//! so no checkpoint state transition depends on object metadata.
7
8use crate::checkpoint::record::encode_checkpoint_record;
9use crate::context::MutationContext;
10use crate::error::{CoreError, Result};
11use loonfs_api::wire::control::{
12    decode_control_object, CheckpointOwner, CheckpointRecordLifecycle, CheckpointRecordState,
13    ControlObjectKind,
14};
15use loonfs_api::{GeneratedIdValidationError, ManifestObjectId, NamespaceId, RetainedReason};
16use loonfs_objectstore::{ObjectStore, ObjectStoreError};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(super) enum CheckpointSweep {
20    /// The record is released and its release has aged past the grace
21    /// window; the key may be deleted.
22    Delete,
23    /// This pass flipped the record `active -> released`.
24    Released,
25    Retain,
26}
27
28/// True once a record's own lease has passed. Checkpoint aging reads the
29/// record, never the object's provider timestamp: the record carries every
30/// instant its lifecycle depends on.
31pub(super) fn lease_expired(record: &CheckpointRecordState, now_ms: u64) -> bool {
32    record
33        .expires_at_ms
34        .is_some_and(|expires_at_ms| expires_at_ms <= now_ms)
35}
36
37/// Advances one collectable checkpoint record along the only path it has.
38///
39/// An active record whose lease has passed — or one on a terminally deleted
40/// namespace, where nothing can read it again — is released by
41/// compare-and-swap on the exact etag inspected, stamping the release
42/// instant. A released record is deletable once that stamp is a grace window
43/// old. Nothing here reads a provider timestamp: `released_at_ms` and
44/// `created_at_ms` are what the record's own age is measured from, and
45/// `expires_at_ms` is what its lease is measured from.
46///
47/// A failed CAS means the record changed and is retained without retry.
48pub(super) async fn sweep_checkpoint_record<S: ObjectStore + ?Sized>(
49    store: &S,
50    namespace_id: &NamespaceId,
51    key: &str,
52    grace_window_ms: u64,
53    namespace_deleted: bool,
54    context: &MutationContext,
55) -> Result<CheckpointSweep> {
56    let Some(body) = store
57        .get_with_metadata(key)
58        .await
59        .map_err(|error| CoreError::store(key, &error))?
60    else {
61        return Ok(CheckpointSweep::Retain);
62    };
63    let Ok(envelope) = decode_control_object::<CheckpointRecordState>(
64        &body.bytes,
65        ControlObjectKind::CheckpointRecord,
66    ) else {
67        return Ok(CheckpointSweep::Retain);
68    };
69    let record = envelope.state;
70    if record.namespace_id != *namespace_id {
71        return Ok(CheckpointSweep::Retain);
72    }
73    if let CheckpointRecordLifecycle::Released { released_at_ms } = record.state {
74        let aged = context.now_ms.saturating_sub(released_at_ms) >= grace_window_ms;
75        return Ok(if aged {
76            CheckpointSweep::Delete
77        } else {
78            CheckpointSweep::Retain
79        });
80    }
81    // A fork pin is never released here, whatever its lease says: only the
82    // fork arm knows whether the target is still reading through it
83    // (`fork_checkpoints.rs`), and it has already had its say by this point.
84    if matches!(record.owner, CheckpointOwner::Fork { .. }) {
85        return Ok(CheckpointSweep::Retain);
86    }
87    // An unexpired pin on a live namespace is exactly what a checkpoint is
88    // for. On a tombstone every pin is dead weight, but a create still in
89    // flight must not be raced, so that arm waits out the grace window from
90    // the record's own creation stamp.
91    let releasable = lease_expired(&record, context.now_ms)
92        || (namespace_deleted
93            && context.now_ms.saturating_sub(record.created_at_ms) >= grace_window_ms);
94    if !releasable {
95        return Ok(CheckpointSweep::Retain);
96    }
97    let Some(etag) = body.metadata.etag.as_deref() else {
98        return Ok(CheckpointSweep::Retain);
99    };
100    let mut released = record;
101    released.state = CheckpointRecordLifecycle::Released {
102        released_at_ms: context.now_ms,
103    };
104    let bytes = encode_checkpoint_record(&released)?;
105    match store.compare_and_swap(key, etag, bytes).await {
106        Ok(_) => Ok(CheckpointSweep::Released),
107        Err(ObjectStoreError::PreconditionFailed { .. }) => {
108            tracing::debug!(
109                namespace_id = %namespace_id,
110                object_key = key,
111                "checkpoint release lost its inspected etag; retaining"
112            );
113            Ok(CheckpointSweep::Retain)
114        }
115        Err(error) => Err(CoreError::store(key, &error)),
116    }
117}
118
119/// What aging one unreferenced candidate decided.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum AgedSweep {
122    /// The grace window had passed over the object and it was deleted.
123    Deleted,
124    /// The object was already gone; nothing was decided and nothing counts.
125    AlreadyGone,
126    /// Younger than the grace window by its own provider timestamp.
127    RetainedInGraceWindow,
128    /// The provider reported no last-modified time, so the object's age is
129    /// unknown and it is treated as young (rule 1).
130    RetainedWithoutTimestamp,
131}
132
133impl AgedSweep {
134    /// True when the key was deleted.
135    pub fn deleted(self) -> bool {
136        self == Self::Deleted
137    }
138
139    /// The retention reason this outcome is, for a caller reporting why a
140    /// pass kept what it kept. `None` for the two outcomes that retained
141    /// nothing.
142    pub fn retained_reason(self) -> Option<RetainedReason> {
143        match self {
144            Self::Deleted | Self::AlreadyGone => None,
145            Self::RetainedInGraceWindow => Some(RetainedReason::GraceWindow),
146            Self::RetainedWithoutTimestamp => Some(RetainedReason::NoProviderTimestamp),
147        }
148    }
149}
150
151/// Deletes one unreferenced candidate if the grace window has passed over
152/// it, and says what it decided otherwise.
153///
154/// Store failures surface unmapped so each collector keeps its own error
155/// vocabulary; everything about the decision itself — which timestamp, what
156/// an absent one means, what the window is measured against — is the same
157/// wherever objects age out, so it is decided once here.
158pub async fn delete_if_aged<S: ObjectStore + ?Sized>(
159    store: &S,
160    key: &str,
161    grace_window_ms: u64,
162    now_ms: u64,
163) -> std::result::Result<AgedSweep, ObjectStoreError> {
164    let Some(metadata) = store.head(key).await? else {
165        return Ok(AgedSweep::AlreadyGone);
166    };
167    let Some(last_modified_ms) = metadata.last_modified_ms else {
168        return Ok(AgedSweep::RetainedWithoutTimestamp);
169    };
170    if now_ms.saturating_sub(last_modified_ms) < grace_window_ms {
171        return Ok(AgedSweep::RetainedInGraceWindow);
172    }
173    store.delete(key).await?;
174    Ok(AgedSweep::Deleted)
175}
176
177pub(super) fn manifest_object_id_of(
178    key: &str,
179) -> Option<std::result::Result<ManifestObjectId, GeneratedIdValidationError>> {
180    let name = key.rsplit('/').next()?;
181    let object_id = name.strip_suffix(".manifest.json")?;
182    Some(ManifestObjectId::parse(object_id))
183}