objectstore_service/backend/
changelog.rs1use std::collections::HashMap;
23use std::fmt;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, SystemTime};
26
27use tokio_util::task::TaskTracker;
28use tokio_util::task::task_tracker::TaskTrackerToken;
29
30use crate::backend::common::{HighVolumeBackend, MultipartUploadBackend, TieredMetadata};
31use crate::error::Result;
32use crate::id::ObjectId;
33
34const INITIAL_BACKOFF: Duration = Duration::from_millis(100);
36const MAX_BACKOFF: Duration = Duration::from_secs(30);
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44pub struct ChangeId(uuid::Uuid);
45
46impl ChangeId {
47 #[allow(clippy::new_without_default)]
49 pub fn new() -> Self {
50 Self(uuid::Uuid::now_v7())
51 }
52}
53
54impl fmt::Display for ChangeId {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 self.0.fmt(f)
57 }
58}
59
60#[derive(Debug, Clone)]
66pub struct Change {
67 pub id: ObjectId,
71 pub new: Option<ObjectId>,
75 pub old: Option<ObjectId>,
79 pub cleanup_after: Option<SystemTime>,
83}
84
85#[derive(Debug)]
91pub struct ChangeManager {
92 pub(crate) high_volume: Box<dyn HighVolumeBackend>,
94 pub(crate) long_term: Box<dyn MultipartUploadBackend>,
96 pub(crate) changelog: Box<dyn ChangeLog>,
98 pub(crate) tracker: TaskTracker,
100}
101
102impl ChangeManager {
103 pub fn new(
105 high_volume: Box<dyn HighVolumeBackend>,
106 long_term: Box<dyn MultipartUploadBackend>,
107 changelog: Box<dyn ChangeLog>,
108 ) -> Arc<Self> {
109 Arc::new(Self {
110 high_volume,
111 long_term,
112 changelog,
113 tracker: TaskTracker::new(),
114 })
115 }
116
117 #[tracing::instrument(level = "debug", fields(id = ?change.id, new = ?change.new, old = ?change.old), skip_all)]
125 pub async fn record(self: Arc<Self>, change: Change) -> Result<ChangeGuard> {
126 let token = self.tracker.token();
127
128 let id = ChangeId::new();
129 self.changelog.record(&id, &change).await?;
130
131 let state = ChangeState {
132 id,
133 change,
134 phase: ChangePhase::Recorded,
135 manager: self.clone(),
136 _token: token,
137 };
138
139 Ok(ChangeGuard { state: Some(state) })
140 }
141
142 #[tracing::instrument(level = "debug", fields(id = ?change.id, new = ?change.new, old = ?change.old), skip_all)]
148 pub async fn record_assembling(self: Arc<Self>, change: Change) -> Result<ChangeGuard> {
149 let token = self.tracker.token();
150
151 let id = ChangeId::new();
152 self.changelog.record(&id, &change).await?;
153
154 let state = ChangeState {
155 id,
156 change,
157 phase: ChangePhase::Assembling,
158 manager: self.clone(),
159 _token: token,
160 };
161
162 Ok(ChangeGuard { state: Some(state) })
163 }
164
165 pub async fn recover(self: Arc<Self>) -> Result<()> {
170 let _token = self.tracker.token();
172
173 let entries =
174 self.changelog.scan().await.inspect_err(|e| {
175 objectstore_log::error!(!!e, "Failed to run changelog recovery")
176 })?;
177
178 for (id, change) in entries {
180 let state = ChangeState {
181 id,
182 change,
183 phase: ChangePhase::Recovered,
184 manager: self.clone(),
185 _token: self.tracker.token(),
186 };
187
188 state.cleanup().await;
189 }
190
191 Ok(())
192 }
193}
194
195#[async_trait::async_trait]
209pub trait ChangeLog: fmt::Debug + Send + Sync {
210 async fn record(&self, id: &ChangeId, change: &Change) -> Result<()>;
215
216 async fn remove(&self, id: &ChangeId) -> Result<()>;
221
222 async fn scan(&self) -> Result<Vec<(ChangeId, Change)>>;
231}
232
233#[derive(Debug, Clone, Default)]
238pub struct InMemoryChangeLog {
239 entries: Arc<Mutex<HashMap<ChangeId, Change>>>,
240}
241
242#[async_trait::async_trait]
243impl ChangeLog for InMemoryChangeLog {
244 async fn record(&self, id: &ChangeId, change: &Change) -> Result<()> {
245 let mut entries = self.entries.lock().expect("lock poisoned");
246 entries.insert(id.clone(), change.clone());
247 Ok(())
248 }
249
250 async fn remove(&self, id: &ChangeId) -> Result<()> {
251 let mut entries = self.entries.lock().expect("lock poisoned");
252 entries.remove(id);
253 Ok(())
254 }
255
256 async fn scan(&self) -> Result<Vec<(ChangeId, Change)>> {
257 let now = SystemTime::now();
258 let entries = self.entries.lock().expect("lock poisoned");
259 let result = entries
260 .iter()
261 .filter(|(_, change)| match change.cleanup_after {
262 None => true,
263 Some(deadline) => now >= deadline,
264 })
265 .map(|(id, change)| (id.clone(), change.clone()))
266 .collect();
267 Ok(result)
268 }
269}
270
271#[cfg(test)]
272impl InMemoryChangeLog {
273 pub fn expire_all(&self) {
275 let mut entries = self.entries.lock().expect("lock poisoned");
276 for change in entries.values_mut() {
277 change.cleanup_after = Some(SystemTime::UNIX_EPOCH);
278 }
279 }
280}
281
282#[derive(Debug, Default)]
288pub struct NoopChangeLog;
289
290#[async_trait::async_trait]
291impl ChangeLog for NoopChangeLog {
292 async fn record(&self, _id: &ChangeId, _change: &Change) -> Result<()> {
293 Ok(())
294 }
295
296 async fn remove(&self, _id: &ChangeId) -> Result<()> {
297 Ok(())
298 }
299
300 async fn scan(&self) -> Result<Vec<(ChangeId, Change)>> {
301 Ok(Vec::new())
302 }
303}
304
305#[derive(Debug, PartialEq, Eq)]
307pub enum ChangePhase {
308 Recovered,
310 Recorded,
312 Assembling,
318 Written,
320 Lost,
322 Updated,
324 Completed,
326}
327
328impl ChangePhase {
329 pub fn compare_and_write(succeeded: bool) -> Self {
331 if succeeded { Self::Updated } else { Self::Lost }
332 }
333}
334
335#[derive(Debug)]
339struct ChangeState {
340 id: ChangeId,
341 change: Change,
342 phase: ChangePhase,
343 manager: Arc<ChangeManager>,
344 _token: TaskTrackerToken,
345}
346
347impl ChangeState {
348 fn mark_completed(mut self) {
350 self.phase = ChangePhase::Completed;
351 }
352
353 async fn cleanup(self) {
355 let current = match self.phase {
356 ChangePhase::Recovered => self.read_tombstone().await,
358 ChangePhase::Recorded => self.change.old.clone(),
359 ChangePhase::Written => self.read_tombstone().await,
361 ChangePhase::Lost => self.change.old.clone(),
362 ChangePhase::Updated => self.change.new.clone(),
363 ChangePhase::Assembling | ChangePhase::Completed => return, };
365
366 if current != self.change.old
367 && let Some(ref old) = self.change.old
368 {
369 self.cleanup_lt(old).await;
370 }
371
372 if current != self.change.new
373 && let Some(ref new) = self.change.new
374 {
375 self.cleanup_lt(new).await;
376 }
377
378 self.cleanup_log().await;
379 self.mark_completed();
380 }
381
382 async fn read_tombstone(&self) -> Option<ObjectId> {
386 let mut delay = INITIAL_BACKOFF;
387 loop {
388 match self
389 .manager
390 .high_volume
391 .get_tiered_metadata(&self.change.id)
392 .await
393 {
394 Ok(TieredMetadata::Tombstone(t)) => return Some(t.target),
395 Ok(TieredMetadata::Object(_)) => return None,
396 Ok(TieredMetadata::NotFound) => return None,
397 Err(_) => {
398 tokio::time::sleep(delay).await;
399 delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
400 }
401 }
402 }
403 }
404
405 async fn cleanup_lt(&self, target: &ObjectId) {
407 let mut delay = INITIAL_BACKOFF;
408 while self.manager.long_term.delete_object(target).await.is_err() {
409 tokio::time::sleep(delay).await;
410 delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
411 }
412 }
413
414 async fn cleanup_log(&self) {
416 let mut delay = INITIAL_BACKOFF;
417 while self.manager.changelog.remove(&self.id).await.is_err() {
418 tokio::time::sleep(delay).await;
419 delay = (delay.mul_f32(1.5)).min(MAX_BACKOFF);
420 }
421 }
422}
423
424impl Drop for ChangeState {
425 fn drop(&mut self) {
426 match self.phase {
427 ChangePhase::Completed => {}
428 ChangePhase::Assembling => {
429 objectstore_log::warn!(
430 change = ?self.change,
431 "Operation dropped in Assembling state, cleanup deferred to ChangeLog recovery"
432 );
433 }
434 _ => {
435 objectstore_log::error!(
436 change = ?self.change,
437 phase = ?self.phase,
438 "Operation dropped without completing cleanup"
439 );
440 }
441 }
442 }
443}
444
445#[derive(Debug)]
451pub struct ChangeGuard {
452 state: Option<ChangeState>,
453}
454
455impl ChangeGuard {
456 pub(crate) fn advance(&mut self, phase: ChangePhase) {
458 if let Some(ref mut state) = self.state {
459 state.phase = phase;
460 }
461 }
462}
463
464impl Drop for ChangeGuard {
465 fn drop(&mut self) {
466 if let Some(state) = self.state.take()
467 && state.phase != ChangePhase::Assembling
468 && state.phase != ChangePhase::Completed
469 && let Ok(handle) = tokio::runtime::Handle::try_current()
470 {
471 handle.spawn(state.cleanup());
472 }
473
474 }
476}
477
478#[cfg(test)]
479mod tests {
480 use objectstore_types::scope::{Scope, Scopes};
481
482 use super::*;
483 use crate::id::ObjectContext;
484
485 fn make_id(key: &str) -> ObjectId {
486 ObjectId::new(
487 ObjectContext {
488 usecase: "testing".into(),
489 scopes: Scopes::from_iter([Scope::create("testing", "value").unwrap()]),
490 },
491 key.into(),
492 )
493 }
494
495 #[tokio::test]
496 async fn record_then_scan_returns_entry() {
497 let log = InMemoryChangeLog::default();
498 let id = ChangeId::new();
499 let change = Change {
500 id: make_id("object-key"),
501 new: Some(make_id("object-key/rev1")),
502 old: None,
503 cleanup_after: None,
504 };
505
506 log.record(&id, &change).await.unwrap();
507
508 let entries = log.scan().await.unwrap();
509 assert_eq!(entries.len(), 1);
510 assert_eq!(entries[0].0, id);
511 }
512
513 #[tokio::test]
514 async fn remove_then_scan_does_not_return_entry() {
515 let log = InMemoryChangeLog::default();
516 let id = ChangeId::new();
517 let change = Change {
518 id: make_id("object-key"),
519 new: None,
520 old: Some(make_id("object-key/rev1")),
521 cleanup_after: None,
522 };
523
524 log.record(&id, &change).await.unwrap();
525 log.remove(&id).await.unwrap();
526
527 let entries = log.scan().await.unwrap();
528 assert!(entries.is_empty());
529 }
530
531 #[tokio::test]
532 async fn remove_nonexistent_entry_is_not_an_error() {
533 let log = InMemoryChangeLog::default();
534 let id = ChangeId::new();
535
536 log.remove(&id).await.unwrap();
537 }
538
539 #[test]
543 fn runtime_drop_while_pending_preserves_log_entry() {
544 use crate::backend::in_memory::InMemoryBackend;
545
546 let log = InMemoryChangeLog::default();
547 let manager = ChangeManager::new(
548 Box::new(InMemoryBackend::new("hv")),
549 Box::new(InMemoryBackend::new("lt")),
550 Box::new(log.clone()),
551 );
552
553 let guard = {
554 let rt = tokio::runtime::Runtime::new().unwrap();
555 rt.block_on(manager.record(Change {
557 id: make_id("crash-test"),
558 new: Some(make_id("crash-test/rev")),
559 old: None,
560 cleanup_after: None,
561 }))
562 .unwrap()
563 };
565
566 drop(guard);
568
569 let rt = tokio::runtime::Runtime::new().unwrap();
571 let entries = rt.block_on(log.scan()).unwrap();
572 assert_eq!(entries.len(), 1, "log entry must persist");
573 }
574
575 #[tokio::test]
576 async fn scan_filters_by_cleanup_after() {
577 let log = InMemoryChangeLog::default();
578
579 let ready_id = ChangeId::new();
580 log.record(
581 &ready_id,
582 &Change {
583 id: make_id("ready"),
584 new: Some(make_id("ready/rev")),
585 old: None,
586 cleanup_after: None,
587 },
588 )
589 .await
590 .unwrap();
591
592 let expired_id = ChangeId::new();
593 log.record(
594 &expired_id,
595 &Change {
596 id: make_id("expired"),
597 new: Some(make_id("expired/rev")),
598 old: None,
599 cleanup_after: Some(SystemTime::now() - Duration::from_secs(1)),
600 },
601 )
602 .await
603 .unwrap();
604
605 let deferred_id = ChangeId::new();
606 log.record(
607 &deferred_id,
608 &Change {
609 id: make_id("deferred"),
610 new: Some(make_id("deferred/rev")),
611 old: None,
612 cleanup_after: Some(SystemTime::now() + Duration::from_hours(24)),
613 },
614 )
615 .await
616 .unwrap();
617
618 let entries = log.scan().await.unwrap();
619 assert_eq!(entries.len(), 2);
620
621 let ids: Vec<_> = entries.iter().map(|(id, _)| id).collect();
622 assert!(ids.contains(&&ready_id));
623 assert!(ids.contains(&&expired_id));
624 }
625}