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}