Skip to main content

amalgam/
recovery.rs

1//! Auto-recovery: retry L2 / backplane operations that failed transiently.
2//!
3//! When a write to L2 or a backplane publish fails, the operation is queued here
4//! (deduplicated by key — latest wins) and replayed by a background task once the
5//! dependency recovers. This mirrors FusionCache's auto-recovery: a bounded
6//! queue, a retry budget, and a barrier delay after reconnection so the whole
7//! cluster settles before replay.
8
9use std::sync::{Arc, Weak};
10use std::time::Duration;
11
12use async_trait::async_trait;
13use dashmap::DashMap;
14
15use crate::error::Result;
16use crate::time::{Clock, Timestamp};
17
18/// The kind of operation to replay.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum RecoveryAction {
21    /// Re-write the entry to L2 and re-publish a `Set` notification.
22    Set,
23    /// Re-remove the key from L2 and re-publish a `Remove` notification.
24    Remove,
25    /// Re-publish an `Expire` notification.
26    Expire,
27}
28
29/// A queued operation awaiting replay.
30#[derive(Debug, Clone)]
31pub struct RecoveryItem {
32    /// The (prefixed) cache key.
33    pub key: Arc<str>,
34    /// What to replay.
35    pub action: RecoveryAction,
36    /// When the original operation happened (newer wins on dedup).
37    pub timestamp: Timestamp,
38    /// When this item should be abandoned (drop if `now >= expires_at`).
39    pub expires_at: Timestamp,
40    /// How many replay attempts remain (`None` ⇒ unbounded).
41    pub remaining_retries: Option<u32>,
42}
43
44/// Configuration for the auto-recovery service.
45#[derive(Debug, Clone)]
46pub struct RecoveryConfig {
47    /// Master switch.
48    pub enabled: bool,
49    /// Delay between drain passes (and the post-reconnect barrier). Mirrors
50    /// FusionCache's `AutoRecoveryDelay` (default 2s).
51    pub delay: Duration,
52    /// Maximum queued items (`None` ⇒ unbounded).
53    pub max_items: Option<usize>,
54    /// Maximum replay attempts per item (`None` ⇒ unbounded).
55    pub max_retries: Option<u32>,
56}
57
58impl Default for RecoveryConfig {
59    fn default() -> Self {
60        Self {
61            enabled: true,
62            delay: Duration::from_secs(2),
63            max_items: None,
64            max_retries: None,
65        }
66    }
67}
68
69/// Replays a recovery item. Implemented by the cache (it re-does the L2 write /
70/// backplane publish the item describes).
71#[async_trait]
72pub trait RecoveryExecutor: Send + Sync {
73    /// Replays one item.
74    ///
75    /// # Errors
76    /// Returns an error if the replay fails (the item is kept and retried).
77    async fn replay(&self, item: &RecoveryItem) -> Result<()>;
78}
79
80/// The auto-recovery queue + background drain loop.
81pub struct AutoRecoveryService {
82    config: RecoveryConfig,
83    clock: Arc<dyn Clock>,
84    queue: DashMap<Arc<str>, RecoveryItem>,
85    executor: std::sync::OnceLock<Weak<dyn RecoveryExecutor>>,
86}
87
88impl AutoRecoveryService {
89    /// Creates a service. Call [`set_executor`](Self::set_executor) then
90    /// [`spawn`](Self::spawn) to start draining.
91    #[must_use]
92    pub fn new(config: RecoveryConfig, clock: Arc<dyn Clock>) -> Arc<Self> {
93        Arc::new(Self {
94            config,
95            clock,
96            queue: DashMap::new(),
97            executor: std::sync::OnceLock::new(),
98        })
99    }
100
101    /// Wires the executor that replays items (the cache). Held as a `Weak` so the
102    /// service never keeps the cache alive.
103    pub fn set_executor(&self, executor: Weak<dyn RecoveryExecutor>) {
104        let _ = self.executor.set(executor);
105    }
106
107    /// Number of queued items.
108    #[must_use]
109    pub fn len(&self) -> usize {
110        self.queue.len()
111    }
112
113    /// `true` if nothing is queued.
114    #[must_use]
115    pub fn is_empty(&self) -> bool {
116        self.queue.is_empty()
117    }
118
119    /// Queues an operation for replay (deduplicated by key — the newest
120    /// `timestamp` wins). Respects `max_items` by dropping the soonest-expiring
121    /// item when full.
122    pub fn enqueue(&self, item: RecoveryItem) {
123        if !self.config.enabled {
124            return;
125        }
126        // Latest-wins dedup.
127        match self.queue.get(&item.key) {
128            Some(existing) if existing.timestamp >= item.timestamp => return,
129            _ => {}
130        }
131        if let Some(max) = self.config.max_items
132            && self.queue.len() >= max
133            && !self.queue.contains_key(&item.key)
134        {
135            self.evict_one_before(item.expires_at);
136            if self.queue.len() >= max {
137                return; // still full ⇒ reject the new item
138            }
139        }
140        let mut item = item;
141        if item.remaining_retries.is_none() {
142            item.remaining_retries = self.config.max_retries;
143        }
144        self.queue.insert(Arc::clone(&item.key), item);
145    }
146
147    /// Drops the queued item that expires soonest, if it expires before `bound`.
148    fn evict_one_before(&self, bound: Timestamp) {
149        let victim = self
150            .queue
151            .iter()
152            .min_by_key(|e| e.value().expires_at.ticks())
153            .map(|e| (e.key().clone(), e.value().expires_at));
154        if let Some((key, expires_at)) = victim
155            && expires_at < bound
156        {
157            self.queue.remove(&key);
158        }
159    }
160
161    /// Starts the background drain loop. Idempotent-safe to call once after
162    /// [`set_executor`](Self::set_executor). Requires a tokio runtime.
163    pub fn spawn(self: &Arc<Self>) {
164        if !self.config.enabled {
165            return;
166        }
167        let service = Arc::clone(self);
168        let delay = self.config.delay;
169        tokio::spawn(async move {
170            let mut ticker = tokio::time::interval(delay.max(Duration::from_millis(50)));
171            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
172            loop {
173                ticker.tick().await;
174                // Exit when the cache (executor) is gone.
175                let Some(executor) = service.executor.get().and_then(Weak::upgrade) else {
176                    // No executor yet (or cache dropped). If the slot was never
177                    // set we keep waiting; if it was set and is now dead, stop.
178                    if service.executor.get().is_some() {
179                        break;
180                    }
181                    continue;
182                };
183                service.drain_once(executor.as_ref()).await;
184            }
185        });
186    }
187
188    /// Replays every currently-queued item once. Public for deterministic tests.
189    pub async fn drain_once(&self, executor: &dyn RecoveryExecutor) {
190        let now = self.clock.now();
191        let keys: Vec<Arc<str>> = self.queue.iter().map(|e| e.key().clone()).collect();
192        for key in keys {
193            let Some(item) = self.queue.get(&key).map(|e| e.value().clone()) else {
194                continue;
195            };
196            if now >= item.expires_at {
197                self.queue.remove(&key);
198                continue;
199            }
200            match executor.replay(&item).await {
201                Ok(()) => {
202                    self.queue.remove(&key);
203                }
204                Err(_) => self.record_failure(&key),
205            }
206        }
207    }
208
209    /// Decrements an item's retry budget, dropping it when exhausted.
210    fn record_failure(&self, key: &Arc<str>) {
211        let mut drop_it = false;
212        if let Some(mut entry) = self.queue.get_mut(key) {
213            match entry.remaining_retries {
214                Some(0) => drop_it = true,
215                Some(n) => entry.remaining_retries = Some(n - 1),
216                None => {}
217            }
218        }
219        if drop_it {
220            self.queue.remove(key);
221        }
222    }
223}