Skip to main content

objectstore_service/backend/
changelog.rs

1//! Change lifecycle tracking and durable write-ahead log.
2//!
3//! When a storage mutation spans both the high-volume (HV) and long-term (LT)
4//! backends, several non-atomic steps must happen in sequence: uploading to LT,
5//! committing a tombstone in HV via compare-and-swap, and cleaning up
6//! unreferenced blobs. A crash at any point can leave orphaned LT blobs.
7//!
8//! This module provides two layers of protection:
9//!
10//! 1. **In-process tracking** — [`ChangeGuard`] is an RAII guard that tracks
11//!    the current [`ChangePhase`] of an operation. When dropped, it spawns a
12//!    background task to clean up whichever blob is unreferenced based on the
13//!    phase reached before the drop. This handles normal errors and early
14//!    returns within a running process.
15//!
16//! 2. **Durable write-ahead log** — The [`ChangeLog`] trait records a
17//!    [`Change`] to durable storage *before* any LT side effects begin. If the
18//!    process crashes, a recovery scan reads outstanding entries and cleans up
19//!    orphaned blobs. Recovery is garbage collection — it never replays CAS
20//!    mutations or finishes incomplete operations.
21
22use std::collections::HashMap;
23use std::fmt;
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27use objectstore_types::time::Timestamp;
28use sentry::{Hub, SentryFutureExt};
29use tokio_util::task::TaskTracker;
30use tokio_util::task::task_tracker::TaskTrackerToken;
31
32use crate::backend::common::{Backend, HighVolumeBackend, TieredMetadata};
33use crate::error::Result;
34use crate::id::ObjectId;
35
36/// Initial delay for exponential backoff retries in background cleanup tasks.
37const INITIAL_BACKOFF: Duration = Duration::from_millis(100);
38/// Maximum delay for exponential backoff retries in background cleanup tasks.
39const MAX_BACKOFF: Duration = Duration::from_secs(30);
40
41/// Unique identifier for a change log entry.
42///
43/// Generated per-operation as a UUIDv7. In durable storage, scoped to the
44/// owning service instance (e.g., `~oplog/{instance_id}/{change_id}`).
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub struct ChangeId(uuid::Uuid);
47
48impl ChangeId {
49    /// Generates a new unique change ID.
50    #[allow(clippy::new_without_default)]
51    pub fn new() -> Self {
52        Self(uuid::Uuid::now_v7())
53    }
54}
55
56impl fmt::Display for ChangeId {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        self.0.fmt(f)
59    }
60}
61
62/// Describes the LT blobs involved in a multi-step storage change.
63///
64/// Every mutating flow maps to: "I may have written a `new` LT blob and I
65/// may be replacing an `old` LT blob." Recovery uses these fields to determine
66/// which blobs are orphaned by reading the current HV state.
67#[derive(Debug, Clone)]
68pub struct Change {
69    /// The logical object being mutated.
70    ///
71    /// Used by cleanup to query HV and determine which blob is currently referenced.
72    pub id: ObjectId,
73    /// The new LT blob written by this operation.
74    ///
75    /// Needs cleanup on failure (the CAS did not commit).
76    pub new: Option<ObjectId>,
77    /// The old LT blob being replaced.
78    ///
79    /// Needs cleanup on success (the CAS committed and the old blob is unreferenced).
80    pub old: Option<ObjectId>,
81}
82
83/// Manager for multi-step storage changes, including backends and durable log.
84///
85/// Encapsulates the state and logic for recording changes, advancing their phases,
86/// and performing cleanup on drop. The `TieredStorage` backend holds an instance
87/// of this manager to use it for its multi-step operations.
88#[derive(Debug)]
89pub struct ChangeManager {
90    /// The backend for small objects (≤ 1 MiB).
91    pub(crate) high_volume: Box<dyn HighVolumeBackend>,
92    /// The backend for large objects (> 1 MiB).
93    pub(crate) long_term: Box<dyn Backend>,
94    /// Durable write-ahead log for multi-step changes.
95    pub(crate) changelog: Box<dyn ChangeLog>,
96    /// Tracks outstanding background cleanup operations for graceful shutdown.
97    pub(crate) tracker: TaskTracker,
98}
99
100impl ChangeManager {
101    /// Creates a new `ChangeManager` with the given backends and changelog.
102    pub fn new(
103        high_volume: Box<dyn HighVolumeBackend>,
104        long_term: Box<dyn Backend>,
105        changelog: Box<dyn ChangeLog>,
106    ) -> Arc<Self> {
107        Arc::new(Self {
108            high_volume,
109            long_term,
110            changelog,
111            tracker: TaskTracker::new(),
112        })
113    }
114
115    /// Records the change to the log and returns a guard.
116    ///
117    /// Generates a unique [`ChangeId`] and writes a durable log entry before
118    /// returning. The caller may proceed with LT side effects immediately after.
119    ///
120    /// When the [`ChangeGuard`] is dropped, a background process is spawned to
121    /// clean up any unreferenced objects in LT storage.
122    #[tracing::instrument(level = "debug", fields(id = ?change.id, new = ?change.new, old = ?change.old), skip_all)]
123    pub async fn record(self: Arc<Self>, change: Change) -> Result<ChangeGuard> {
124        let token = self.tracker.token();
125
126        let id = ChangeId::new();
127        self.changelog.record(&id, &change).await?;
128
129        let state = ChangeState {
130            id,
131            change,
132            phase: ChangePhase::Recorded,
133            manager: self,
134            _token: token,
135        };
136
137        Ok(ChangeGuard {
138            state: Some(state),
139            hub: Hub::current(),
140        })
141    }
142
143    /// Scans the changelog for outstanding entries and runs cleanup for each.
144    ///
145    /// Spawn this into a background task at startup to recover from any orphaned objects after a
146    /// crash. During normal operation, this should return an empty list and have no effect.
147    #[tracing::instrument(level = "debug", skip_all)]
148    pub async fn recover(self: Arc<Self>) -> Result<()> {
149        // Hold one token for the duration of recovery to prevent premature shutdown.
150        let _token = self.tracker.token();
151
152        let entries =
153            self.changelog.scan().await.inspect_err(|e| {
154                objectstore_log::error!(!!e, "Failed to run changelog recovery")
155            })?;
156
157        // NB: Intentionally clean up sequentially to reduce load on the system.
158        for (id, change) in entries {
159            let state = ChangeState {
160                id,
161                change,
162                phase: ChangePhase::Recovered,
163                manager: self.clone(),
164                _token: self.tracker.token(),
165            };
166
167            state.cleanup().await;
168        }
169
170        Ok(())
171    }
172}
173
174/// Durable write-ahead log for multi-step storage changes.
175///
176/// Records in-progress changes that span both HV and LT backends so that
177/// recovery can identify and clean up orphaned LT blobs after crashes.
178/// The log is stored independently from the data backend (though it may
179/// share infrastructure) and is scoped per service instance.
180///
181/// Recovery is garbage collection — it reads HV state to determine which
182/// blobs are unreferenced and deletes them. It never replays CAS mutations
183/// or finishes incomplete operations.
184///
185/// Implementations handle instance identity, heartbeats, and key prefixing
186/// internally — callers interact only with entries.
187#[async_trait::async_trait]
188pub trait ChangeLog: fmt::Debug + Send + Sync {
189    /// Records a change before any side effects begin (write-ahead).
190    ///
191    /// Must be durable before returning — the caller will proceed with
192    /// LT writes immediately after.
193    async fn record(&self, id: &ChangeId, change: &Change) -> Result<()>;
194
195    /// Removes a completed change from the log.
196    ///
197    /// Called after all cleanup (LT blob deletion) is finished. Removing
198    /// a nonexistent entry is not an error (idempotent).
199    async fn remove(&self, id: &ChangeId) -> Result<()>;
200
201    /// Returns all outstanding changes eligible for recovery.
202    ///
203    /// During normal operation this returns only the calling instance's
204    /// entries. During recovery of a dead instance, the implementation
205    /// may return that instance's entries after the caller has claimed
206    /// ownership (via heartbeat CAS).
207    ///
208    /// The returned entries are unordered.
209    async fn scan(&self) -> Result<Vec<(ChangeId, Change)>>;
210}
211
212/// In-memory [`ChangeLog`] for tests and deployments without durable logging.
213///
214/// Stores entries in a `HashMap`. [`Clone`]-able so tests can hold a handle
215/// for direct inspection while the service owns a boxed copy.
216#[derive(Debug, Clone, Default)]
217pub struct InMemoryChangeLog {
218    entries: Arc<Mutex<HashMap<ChangeId, Change>>>,
219}
220
221#[async_trait::async_trait]
222impl ChangeLog for InMemoryChangeLog {
223    async fn record(&self, id: &ChangeId, change: &Change) -> Result<()> {
224        let mut entries = self.entries.lock().expect("lock poisoned");
225        entries.insert(id.clone(), change.clone());
226        Ok(())
227    }
228
229    async fn remove(&self, id: &ChangeId) -> Result<()> {
230        let mut entries = self.entries.lock().expect("lock poisoned");
231        entries.remove(id);
232        Ok(())
233    }
234
235    async fn scan(&self) -> Result<Vec<(ChangeId, Change)>> {
236        let entries = self.entries.lock().expect("lock poisoned");
237        let result = entries
238            .iter()
239            .map(|(id, change)| (id.clone(), change.clone()))
240            .collect();
241        Ok(result)
242    }
243}
244
245/// [`ChangeLog`] implementation that discards all entries.
246///
247/// Used as the default when no durable log is configured. Provides no
248/// crash-recovery guarantees — orphan cleanup relies entirely on in-process
249/// [`ChangeGuard`] drop logic.
250#[derive(Debug, Default)]
251pub struct NoopChangeLog;
252
253#[async_trait::async_trait]
254impl ChangeLog for NoopChangeLog {
255    async fn record(&self, _id: &ChangeId, _change: &Change) -> Result<()> {
256        Ok(())
257    }
258
259    async fn remove(&self, _id: &ChangeId) -> Result<()> {
260        Ok(())
261    }
262
263    async fn scan(&self) -> Result<Vec<(ChangeId, Change)>> {
264        Ok(Vec::new())
265    }
266}
267
268/// Phase of a multi-step storage change.
269#[derive(Debug, PartialEq, Eq)]
270pub enum ChangePhase {
271    /// The change was recovered from changelog and the phase is unknown.
272    Recovered,
273    /// The change is recorded in the log and LT upload has started.
274    Recorded,
275    /// LT upload has succeeded and the tombstone is being updated.
276    Written,
277    /// The tombstone update failed due to a conflict.
278    Lost,
279    /// The tombstone update succeeded.
280    Updated,
281    /// Cleanup complete.
282    Completed,
283}
284
285impl ChangePhase {
286    /// Returns the phase corresponding to the outcome of a compare-and-write operation.
287    pub fn compare_and_write(succeeded: bool) -> Self {
288        if succeeded { Self::Updated } else { Self::Lost }
289    }
290}
291
292/// Internal state for a [`ChangeGuard`].
293///
294/// Logs an error if dropped in any phase other than `Completed`.
295#[derive(Debug)]
296struct ChangeState {
297    id: ChangeId,
298    change: Change,
299    phase: ChangePhase,
300    manager: Arc<ChangeManager>,
301    _token: TaskTrackerToken,
302}
303
304impl ChangeState {
305    /// Marks the operation as completed, preventing any cleanup on drop.
306    fn mark_completed(mut self) {
307        self.phase = ChangePhase::Completed;
308    }
309
310    /// Determines tombstone state and runs cleanup for unreferenced objects.
311    #[tracing::instrument(level = "debug", skip_all, fields(phase= ?self.phase, new = ?self.change.new, old = ?self.change.old))]
312    async fn cleanup(self) {
313        let current = match self.phase {
314            // For `Recovered`, we must first check the state of the tombstone.
315            ChangePhase::Recovered => self.read_tombstone().await,
316            ChangePhase::Recorded => self.change.old.clone(),
317            // For `Written`, the CAS outcome is unknown — read HV to determine it.
318            ChangePhase::Written => self.read_tombstone().await,
319            ChangePhase::Lost => self.change.old.clone(),
320            ChangePhase::Updated => self.change.new.clone(),
321            ChangePhase::Completed => return, // unreachable
322        };
323
324        if current != self.change.old
325            && let Some(ref old) = self.change.old
326        {
327            self.cleanup_lt(old).await;
328        }
329
330        if current != self.change.new
331            && let Some(ref new) = self.change.new
332        {
333            self.cleanup_lt(new).await;
334        }
335
336        self.cleanup_log().await;
337        self.mark_completed();
338    }
339
340    /// Reads the tombstone target for `id` from HV, retrying with exponential backoff on error.
341    ///
342    /// Returns `None` if the entry holds an inline object or is absent.
343    async fn read_tombstone(&self) -> Option<ObjectId> {
344        let mut delay = INITIAL_BACKOFF;
345        loop {
346            match self
347                .manager
348                .high_volume
349                .get_tiered_metadata(&self.change.id, Timestamp::now())
350                .await
351            {
352                Ok(TieredMetadata::Tombstone(t)) => return Some(t.target),
353                Ok(TieredMetadata::Object(_)) => return None,
354                Ok(TieredMetadata::NotFound) => return None,
355                Err(_) => {
356                    tokio::time::sleep(delay).await;
357                    delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
358                }
359            }
360        }
361    }
362
363    /// Deletes `target` from `lt`, retrying with exponential backoff until success.
364    async fn cleanup_lt(&self, target: &ObjectId) {
365        let mut delay = INITIAL_BACKOFF;
366        while self
367            .manager
368            .long_term
369            .delete_object(target, Timestamp::now())
370            .await
371            .is_err()
372        {
373            tokio::time::sleep(delay).await;
374            delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
375        }
376    }
377
378    /// Removes this change's log entry, retrying with exponential backoff until success.
379    async fn cleanup_log(&self) {
380        let mut delay = INITIAL_BACKOFF;
381        while self.manager.changelog.remove(&self.id).await.is_err() {
382            tokio::time::sleep(delay).await;
383            delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
384        }
385    }
386}
387
388impl Drop for ChangeState {
389    fn drop(&mut self) {
390        if self.phase != ChangePhase::Completed {
391            objectstore_log::error!(
392                change = ?self.change,
393                phase = ?self.phase,
394                "Operation dropped without completing cleanup"
395            );
396        }
397    }
398}
399
400/// RAII guard that tracks cleanup state for a multi-step storage change.
401///
402/// When dropped in a non-`Completed` phase, determines the LT blob to clean up
403/// and spawns a background task to delete it. If no tokio runtime is available
404/// (e.g., during shutdown), the drop logs an error instead of panicking.
405pub struct ChangeGuard {
406    state: Option<ChangeState>,
407    hub: Arc<Hub>,
408}
409
410impl ChangeGuard {
411    /// Advances the operation to the given phase. Zero-cost, no I/O.
412    pub(crate) fn advance(&mut self, phase: ChangePhase) {
413        if let Some(ref mut state) = self.state {
414            state.phase = phase;
415        }
416    }
417}
418
419impl fmt::Debug for ChangeGuard {
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        f.debug_struct("ChangeGuard")
422            .field("state", &self.state)
423            // hide `hub` intentionally
424            .finish()
425    }
426}
427
428impl Drop for ChangeGuard {
429    fn drop(&mut self) {
430        if let Some(state) = self.state.take()
431            && state.phase != ChangePhase::Completed
432            && let Ok(handle) = tokio::runtime::Handle::try_current()
433        {
434            let hub = Hub::new_from_top(&self.hub);
435            handle.spawn(state.cleanup().bind_hub(hub));
436        }
437
438        // NB: Drop of `ChangeState` logs an error if cleanup is not scheduled.
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use objectstore_types::scope::{Scope, Scopes};
445
446    use super::*;
447    use crate::id::ObjectContext;
448
449    fn make_id(key: &str) -> ObjectId {
450        ObjectId::new(
451            ObjectContext {
452                usecase: "testing".parse().unwrap(),
453                scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
454            },
455            key.into(),
456        )
457    }
458
459    #[tokio::test]
460    async fn record_then_scan_returns_entry() {
461        let log = InMemoryChangeLog::default();
462        let id = ChangeId::new();
463        let change = Change {
464            id: make_id("object-key"),
465            new: Some(make_id("object-key/rev1")),
466            old: None,
467        };
468
469        log.record(&id, &change).await.unwrap();
470
471        let entries = log.scan().await.unwrap();
472        assert_eq!(entries.len(), 1);
473        assert_eq!(entries[0].0, id);
474    }
475
476    #[tokio::test]
477    async fn remove_then_scan_does_not_return_entry() {
478        let log = InMemoryChangeLog::default();
479        let id = ChangeId::new();
480        let change = Change {
481            id: make_id("object-key"),
482            new: None,
483            old: Some(make_id("object-key/rev1")),
484        };
485
486        log.record(&id, &change).await.unwrap();
487        log.remove(&id).await.unwrap();
488
489        let entries = log.scan().await.unwrap();
490        assert!(entries.is_empty());
491    }
492
493    #[tokio::test]
494    async fn remove_nonexistent_entry_is_not_an_error() {
495        let log = InMemoryChangeLog::default();
496        let id = ChangeId::new();
497
498        log.remove(&id).await.unwrap();
499    }
500
501    /// When the tokio runtime is dropped while an operation is in flight, the `ChangeGuard`
502    /// drops outside any runtime and cannot schedule cleanup. The log entry must persist
503    /// so that a future recovery pass can identify and clean up orphaned blobs.
504    #[test]
505    fn runtime_drop_while_pending_preserves_log_entry() {
506        use crate::backend::in_memory::InMemoryBackend;
507
508        let log = InMemoryChangeLog::default();
509        let manager = ChangeManager::new(
510            Box::new(InMemoryBackend::new("hv")),
511            Box::new(InMemoryBackend::new("lt")),
512            Box::new(log.clone()),
513        );
514
515        let guard = {
516            let rt = tokio::runtime::Runtime::new().unwrap();
517            // Simulate a mid-flight operation that recorded its change but did not complete.
518            rt.block_on(manager.record(Change {
519                id: make_id("crash-test"),
520                new: Some(make_id("crash-test/rev")),
521                old: None,
522            }))
523            .unwrap()
524            // Runtime drops here while `guard` is still alive outside it.
525        };
526
527        // Guard drops with no runtime active: cleanup cannot be scheduled.
528        drop(guard);
529
530        // Log entry must survive so recovery can clean up the orphaned blob.
531        let rt = tokio::runtime::Runtime::new().unwrap();
532        let entries = rt.block_on(log.scan()).unwrap();
533        assert_eq!(entries.len(), 1, "log entry must persist");
534    }
535}