Skip to main content

ipfrs_storage/
compaction.rs

1//! Sled compaction scheduling with lock-free atomics.
2//!
3//! [`CompactionScheduler`] tracks write activity and decides when to trigger a
4//! Sled WAL flush / compaction.  All state is held in atomics so the scheduler
5//! is cheaply shareable across threads without any mutex.
6//!
7//! # Decision logic
8//!
9//! A compaction is recommended when **any** of the following hold and no
10//! compaction is already in progress:
11//!
12//! * The store has been idle for at least [`CompactionConfig::idle_threshold`]
13//!   **and** at least [`CompactionConfig::min_interval`] has elapsed since the
14//!   last compaction.
15//! * Bytes written since the last compaction exceed
16//!   [`CompactionConfig::max_bytes_since_compact`].
17
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::Arc;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22// ---------------------------------------------------------------------------
23// CompactionConfig
24// ---------------------------------------------------------------------------
25
26/// Configuration for automatic Sled compaction scheduling.
27#[derive(Debug, Clone)]
28pub struct CompactionConfig {
29    /// Minimum idle duration before triggering compaction.
30    ///
31    /// Default: 5 minutes.
32    pub idle_threshold: Duration,
33
34    /// Minimum interval between consecutive compactions.
35    ///
36    /// Default: 30 minutes.
37    pub min_interval: Duration,
38
39    /// Maximum bytes written since last compaction before forcing one regardless
40    /// of the idle/interval constraints.
41    ///
42    /// Default: 100 MiB.
43    pub max_bytes_since_compact: u64,
44}
45
46impl Default for CompactionConfig {
47    fn default() -> Self {
48        Self {
49            idle_threshold: Duration::from_secs(5 * 60),
50            min_interval: Duration::from_secs(30 * 60),
51            max_bytes_since_compact: 100 * 1024 * 1024,
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// CompactionScheduler
58// ---------------------------------------------------------------------------
59
60/// Lock-free compaction scheduler for Sled block stores.
61///
62/// All mutable state is held in atomics so `Arc<CompactionScheduler>` is
63/// safely shared across threads and async tasks without a mutex.
64pub struct CompactionScheduler {
65    config: CompactionConfig,
66    /// Unix-epoch milliseconds of the last put/delete operation.
67    last_operation_ms: AtomicU64,
68    /// Unix-epoch milliseconds of the last successful compaction.
69    last_compaction_ms: AtomicU64,
70    /// Bytes written to the store since the last compaction.
71    bytes_since_compaction: AtomicU64,
72    /// Total compaction count since creation.
73    compaction_count: AtomicU64,
74    /// Guard flag: true while a compaction is in-flight.
75    is_compacting: AtomicBool,
76}
77
78impl std::fmt::Debug for CompactionScheduler {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("CompactionScheduler")
81            .field("config", &self.config)
82            .field(
83                "last_operation_ms",
84                &self.last_operation_ms.load(Ordering::Relaxed),
85            )
86            .field(
87                "last_compaction_ms",
88                &self.last_compaction_ms.load(Ordering::Relaxed),
89            )
90            .field(
91                "bytes_since_compaction",
92                &self.bytes_since_compaction.load(Ordering::Relaxed),
93            )
94            .field(
95                "compaction_count",
96                &self.compaction_count.load(Ordering::Relaxed),
97            )
98            .field("is_compacting", &self.is_compacting.load(Ordering::Relaxed))
99            .finish()
100    }
101}
102
103/// Returns the current Unix time in milliseconds.
104fn now_ms() -> u64 {
105    SystemTime::now()
106        .duration_since(UNIX_EPOCH)
107        .unwrap_or(Duration::ZERO)
108        .as_millis() as u64
109}
110
111impl CompactionScheduler {
112    /// Create a new scheduler wrapped in an `Arc`.
113    pub fn new(config: CompactionConfig) -> Arc<Self> {
114        let now = now_ms();
115        Arc::new(Self {
116            config,
117            // Treat creation time as the epoch for both the last operation and
118            // the last compaction so that an idle store does not fire immediately.
119            last_operation_ms: AtomicU64::new(now),
120            last_compaction_ms: AtomicU64::new(now),
121            bytes_since_compaction: AtomicU64::new(0),
122            compaction_count: AtomicU64::new(0),
123            is_compacting: AtomicBool::new(false),
124        })
125    }
126
127    /// Record a write of `bytes` bytes.
128    ///
129    /// Updates the "last operation" timestamp and the byte counter that feeds
130    /// the bytes-threshold trigger.
131    pub fn record_write(&self, bytes: usize) {
132        self.last_operation_ms.store(now_ms(), Ordering::Relaxed);
133        self.bytes_since_compaction
134            .fetch_add(bytes as u64, Ordering::Relaxed);
135    }
136
137    /// Returns `true` when the scheduler recommends a compaction be triggered.
138    ///
139    /// Specifically it returns `true` when **all** of the following hold:
140    ///
141    /// * No compaction is already in-flight (`!is_compacting`).
142    /// * The bytes threshold is exceeded **or** (the store has been idle long
143    ///   enough **and** the minimum inter-compaction interval has been met).
144    pub fn should_compact(&self) -> bool {
145        if self.is_compacting.load(Ordering::Acquire) {
146            return false;
147        }
148
149        let bytes = self.bytes_since_compaction.load(Ordering::Relaxed);
150        if bytes >= self.config.max_bytes_since_compact {
151            return true;
152        }
153
154        self.idle_duration() >= self.config.idle_threshold
155            && self.time_since_last_compaction() >= self.config.min_interval
156    }
157
158    /// Attempt to claim the compaction lock.
159    ///
160    /// Uses a compare-and-swap to atomically transition `is_compacting` from
161    /// `false` to `true`.  Returns `true` when the caller won the race (they
162    /// should proceed with the compaction); returns `false` if another goroutine
163    /// is already compacting.
164    pub fn mark_compaction_started(&self) -> bool {
165        self.is_compacting
166            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
167            .is_ok()
168    }
169
170    /// Release the compaction lock and update post-compaction bookkeeping.
171    ///
172    /// Resets the bytes counter, updates the last-compaction timestamp, and
173    /// increments the compaction count before clearing the in-flight flag.
174    pub fn mark_compaction_done(&self) {
175        self.bytes_since_compaction.store(0, Ordering::Relaxed);
176        self.last_compaction_ms.store(now_ms(), Ordering::Relaxed);
177        self.compaction_count.fetch_add(1, Ordering::Relaxed);
178        self.is_compacting.store(false, Ordering::Release);
179    }
180
181    /// Total number of completed compactions.
182    pub fn compaction_count(&self) -> u64 {
183        self.compaction_count.load(Ordering::Relaxed)
184    }
185
186    /// Bytes written to the store since the last compaction completed.
187    pub fn bytes_since_last_compaction(&self) -> u64 {
188        self.bytes_since_compaction.load(Ordering::Relaxed)
189    }
190
191    /// Duration since the last recorded write operation.
192    pub fn idle_duration(&self) -> Duration {
193        let last_ms = self.last_operation_ms.load(Ordering::Relaxed);
194        let now = now_ms();
195        Duration::from_millis(now.saturating_sub(last_ms))
196    }
197
198    /// Duration since the last compaction completed.
199    pub fn time_since_last_compaction(&self) -> Duration {
200        let last_ms = self.last_compaction_ms.load(Ordering::Relaxed);
201        let now = now_ms();
202        Duration::from_millis(now.saturating_sub(last_ms))
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Tests
208// ---------------------------------------------------------------------------
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::time::Duration;
214
215    fn make_scheduler_with_config(
216        idle_threshold_secs: u64,
217        min_interval_secs: u64,
218        max_bytes: u64,
219    ) -> Arc<CompactionScheduler> {
220        CompactionScheduler::new(CompactionConfig {
221            idle_threshold: Duration::from_secs(idle_threshold_secs),
222            min_interval: Duration::from_secs(min_interval_secs),
223            max_bytes_since_compact: max_bytes,
224        })
225    }
226
227    #[test]
228    fn test_default_config() {
229        let cfg = CompactionConfig::default();
230        assert_eq!(cfg.idle_threshold, Duration::from_secs(5 * 60));
231        assert_eq!(cfg.min_interval, Duration::from_secs(30 * 60));
232        assert_eq!(cfg.max_bytes_since_compact, 100 * 1024 * 1024);
233    }
234
235    #[test]
236    fn test_record_write_updates_bytes() {
237        let sched = make_scheduler_with_config(300, 1800, 100 * 1024 * 1024);
238        assert_eq!(sched.bytes_since_last_compaction(), 0);
239
240        sched.record_write(1024);
241        assert_eq!(sched.bytes_since_last_compaction(), 1024);
242
243        sched.record_write(512);
244        assert_eq!(sched.bytes_since_last_compaction(), 1536);
245    }
246
247    #[test]
248    fn test_should_compact_by_bytes() {
249        // Set max_bytes very low so that a single write crosses the threshold.
250        let sched = make_scheduler_with_config(300, 1800, 100);
251
252        // Write 101 bytes — exceeds the 100-byte threshold.
253        sched.record_write(101);
254        assert!(
255            sched.should_compact(),
256            "should compact once bytes threshold is exceeded"
257        );
258    }
259
260    #[test]
261    fn test_should_compact_needs_min_interval() {
262        // Zero idle threshold but very long min_interval.
263        let sched = make_scheduler_with_config(0, 86400, 100 * 1024 * 1024);
264
265        // Even though idle_threshold is 0, min_interval (24 h) has not elapsed.
266        assert!(
267            !sched.should_compact(),
268            "should NOT compact when min_interval has not elapsed"
269        );
270    }
271
272    #[test]
273    fn test_mark_compaction_lifecycle() {
274        let sched = make_scheduler_with_config(300, 1800, 100);
275
276        // Record writes to cross the bytes threshold.
277        sched.record_write(200);
278        assert!(sched.should_compact());
279
280        // Claim the lock.
281        let won = sched.mark_compaction_started();
282        assert!(won, "first caller must win the CAS");
283
284        // While in-flight, should_compact must return false.
285        assert!(
286            !sched.should_compact(),
287            "in-flight guard must block re-entry"
288        );
289
290        // Finish the compaction.
291        sched.mark_compaction_done();
292        assert_eq!(
293            sched.bytes_since_last_compaction(),
294            0,
295            "bytes counter must be reset after compaction"
296        );
297        assert_eq!(sched.compaction_count(), 1);
298        // is_compacting must be false again.
299        assert!(!sched.is_compacting.load(Ordering::Relaxed));
300    }
301
302    #[test]
303    fn test_concurrent_compaction_prevention() {
304        let sched = make_scheduler_with_config(300, 1800, 100 * 1024 * 1024);
305
306        // First caller wins.
307        let first = sched.mark_compaction_started();
308        assert!(first, "first caller must win");
309
310        // Second caller must lose.
311        let second = sched.mark_compaction_started();
312        assert!(
313            !second,
314            "second caller must be rejected while compaction is in-flight"
315        );
316
317        // Clean up.
318        sched.mark_compaction_done();
319    }
320
321    #[test]
322    fn test_compaction_count_increments() {
323        let sched = make_scheduler_with_config(300, 1800, 100 * 1024 * 1024);
324
325        assert_eq!(sched.compaction_count(), 0);
326
327        for expected in 1..=5u64 {
328            let won = sched.mark_compaction_started();
329            assert!(won);
330            sched.mark_compaction_done();
331            assert_eq!(sched.compaction_count(), expected);
332        }
333    }
334}