Skip to main content

rushdb_storage/
maintenance.rs

1//! Retention, checkpoints, compaction, and storage diagnostics.
2//! All fallible operations consult store health before accessing shared state.
3
4use super::{
5    receipt, wal, Op, Ordering, ReadLimits, ScanProjection, StorageError, StorageStatistics, Store,
6    RETENTION_CURSOR_KEY,
7};
8
9/// Keys purged since the last compaction that make idle compaction worth
10/// it even though the file did not grow (one small tenant's purge).
11const PURGED_KEYS_TO_COMPACT: u64 = 10_000;
12
13/// How long [`Store::compact_if_needed`] keeps retrying while a reader is
14/// open — a pinned view or a backend read transaction
15/// ([`crate::backend::COMPACT_BUSY`]) — and the pause between tries. The
16/// compaction period is a multiple of the 5 s maintenance tick and both start
17/// at boot, so a compaction tick lands while the tick's index-build step or
18/// the text applier holds a short-lived view; one try per tick lost every
19/// tick for 10 minutes after a purge (envelope `purge_half`, 2026-10-10).
20const COMPACT_BUSY_RETRY: std::time::Duration = std::time::Duration::from_secs(2);
21const COMPACT_BUSY_PAUSE: std::time::Duration = std::time::Duration::from_millis(20);
22
23/// What [`Store::compact_if_needed`] did, so a deferral is never silent.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum CompactOutcome {
26    /// Compacted at this commit index.
27    Compacted(u64),
28    /// A commit landed within the quiescence window.
29    CommitsRecent,
30    /// Neither the growth nor the purged-keys threshold is met.
31    BelowThreshold,
32    /// A pinned reader (transaction, cursor, backup) is open.
33    ReadersPinned,
34    /// The store has no file (in-memory).
35    NotDurable,
36}
37
38impl Store {
39    /// Reclaim historical versions and ask redb to compact its copy-on-write
40    /// pages. In-memory stores only perform logical MVCC reclamation.
41    ///
42    /// # Errors
43    /// Returns [`StorageError::ReadersActive`] when protected read views are
44    /// open, or another [`StorageError`] if reclamation or compaction fails.
45    pub fn compact(&self) -> Result<u64, StorageError> {
46        self.check_health()?;
47        match self.compact_if_idle()? {
48            Some(ci) => Ok(ci),
49            None => Err(StorageError::ReadersActive),
50        }
51    }
52
53    /// Signal a shutdown: in-flight maintenance scans stop within a batch so
54    /// the store can be dropped (and redb closed cleanly) promptly.
55    pub fn begin_shutdown(&self) {
56        self.maintenance_abort.store(true, Ordering::Relaxed);
57        for backend in self.all_backends() {
58            backend.begin_shutdown();
59        }
60    }
61
62    /// Whether [`Self::begin_shutdown`] has been called.
63    #[must_use]
64    pub fn maintenance_aborted(&self) -> bool {
65        self.maintenance_abort.load(Ordering::Relaxed)
66    }
67
68    /// Make every shard's materialization durable (one redb `Immediate`
69    /// commit with quick-repair state) without changing logical state. Under
70    /// log-first, per-commit durability is the WAL fsync; redb commits are
71    /// `Durability::None` until this checkpoint, which is also what lets WAL
72    /// segments at or below the checkpointed LSN be truncated. A no-op for
73    /// in-memory stores.
74    ///
75    /// # Errors
76    /// Returns [`StorageError`] if a shard's checkpoint fails.
77    pub fn checkpoint(&self) -> Result<(), StorageError> {
78        self.check_health()?;
79        let started = std::time::Instant::now();
80        let durable_lsn = self.committed_version();
81        self.for_each_backend_parallel(|backend| backend.checkpoint(durable_lsn))?;
82        self.truncate_log(durable_lsn)?;
83        self.checkpoints.fetch_add(1, Ordering::Relaxed);
84        self.checkpoint_wait_ms.fetch_add(
85            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
86            Ordering::Relaxed,
87        );
88        Ok(())
89    }
90
91    /// Run `copy` with every store file quiescent at a durable commit:
92    /// publication is held, every published commit is checkpointed durably,
93    /// and no backend commits (maintenance included) until `copy` returns.
94    /// Readers continue; commits wait. `copy` receives that commit index,
95    /// exactly what the copied files hold (a plain [`Self::checkpoint`]
96    /// records `committed_version`, which can run ahead of what was applied).
97    ///
98    /// # Errors
99    /// Returns [`StorageError`] if the checkpoint or a backend pause fails;
100    /// `copy`'s own error is returned inside the `Ok`.
101    pub fn with_quiescent_files<T>(
102        &self,
103        copy: impl FnOnce(u64) -> Result<T, String>,
104    ) -> Result<Result<T, String>, StorageError> {
105        fn paused(
106            backends: &[std::sync::Arc<dyn crate::StorageBackend>],
107            run: &mut dyn FnMut(),
108        ) -> Result<(), String> {
109            match backends.split_first() {
110                None => {
111                    run();
112                    Ok(())
113                }
114                Some((first, rest)) => {
115                    let mut inner = Ok(());
116                    first.while_writes_paused(&mut || inner = paused(rest, run))?;
117                    inner
118                }
119            }
120        }
121        self.check_health()?;
122        let _guard = self.health.guard(self.publish_lock.lock())?;
123        self.drain_and_publish();
124        let durable_lsn = self.committed_version();
125        self.for_each_backend_parallel(|backend| backend.checkpoint(durable_lsn))?;
126        self.checkpoints.fetch_add(1, Ordering::Relaxed);
127        let mut copy = Some(copy);
128        let mut result = None;
129        paused(&self.all_backends(), &mut || {
130            if let Some(copy) = copy.take() {
131                result = Some(copy(durable_lsn));
132            }
133        })
134        .map_err(StorageError::Durability)?;
135        Ok(result.unwrap_or_else(|| Err("store file copy did not run".into())))
136    }
137
138    /// Drop WAL segments fully covered by the durable materialization at
139    /// `durable_lsn`, additionally clamped to the durable retention cursor
140    ///: outcome-payload retention derives each old commit's
141    /// payload key from the WAL's embedded command, so a segment must not be
142    /// reclaimed before that sweep has consumed it, or the payload key
143    /// becomes unrecoverable. A sealed segment whose last record is at or
144    /// below `min(durable_lsn, retention_cursor)` is both recoverable from
145    /// redb and already retention-consumed, so it can be reclaimed; the
146    /// active segment is always retained.
147    fn truncate_log(&self, durable_lsn: u64) -> Result<(), StorageError> {
148        let cursor = self.retention_cursor()?;
149        let target = durable_lsn.min(cursor);
150        let mut guard = self.health.guard(self.log.lock())?;
151        if let Some(log) = guard.as_mut() {
152            // Make appended-but-unsynced records durable before reclaiming any
153            // segment (`Async` durability relies on this checkpoint fsync).
154            log.sync()
155                .map_err(|error| StorageError::Durability(format!("WAL sync: {error}")))?;
156            log.truncate_before(target.saturating_add(1))
157                .map_err(|error| StorageError::Durability(format!("truncate WAL: {error}")))?;
158        }
159        Ok(())
160    }
161
162    /// The durable watermark up to which outcome-payload retention has
163    /// consumed the WAL. `0` when retention has never run (or this
164    /// store is non-durable), meaning no segment may yet be reclaimed on that
165    /// basis — [`Self::truncate_log`]'s clamp.
166    fn retention_cursor(&self) -> Result<u64, StorageError> {
167        let Some(backend) = self.primary_backend() else {
168            return Ok(0);
169        };
170        let view = backend.read_view()?;
171        match view.read_value(
172            RETENTION_CURSOR_KEY,
173            0,
174            true,
175            ReadLimits::default(),
176            ScanProjection::KeyValue,
177        )? {
178            Some(raw) => wal::decode_last_applied(&raw).map_err(|_| StorageError::CorruptLog),
179            None => Ok(0),
180        }
181    }
182
183    /// Durably advance the retention cursor to `lsn`. Applied directly
184    /// against the primary backend (commit index `0`, like other
185    /// out-of-band maintenance writes) — this is housekeeping, not a logical
186    /// commit, so it never touches admission/publication.
187    fn advance_retention_cursor(&self, lsn: u64) -> Result<(), StorageError> {
188        let Some(backend) = self.primary_backend() else {
189            return Ok(());
190        };
191        backend
192            .apply(
193                &[Op::Set(
194                    RETENTION_CURSOR_KEY.to_vec(),
195                    wal::encode_last_applied(0, lsn),
196                )],
197                0,
198            )
199            .map_err(StorageError::Durability)
200    }
201
202    /// Like [`Store::compact`], but a store with protected read views returns
203    /// `Ok(None)` ("not now") instead of an error, so a background compactor
204    /// can simply retry on the next tick. redb itself refuses to compact while
205    /// any read transaction is live (`TransactionInProgress`); such a refusal
206    /// surfaces as `Err` and is equally retryable by the caller.
207    ///
208    /// # Errors
209    /// Returns [`StorageError`] if reclamation or compaction fails.
210    ///
211    /// Lock poisoning fences the store until reopen.
212    pub fn compact_if_idle(&self) -> Result<Option<u64>, StorageError> {
213        self.check_health()?;
214        let _guard = self.health.guard(self.publish_lock.lock())?;
215        if !self.health.guard(self.read_pins.lock())?.is_empty() {
216            return Ok(None);
217        }
218        self.drain_and_publish();
219        let commit_index = self.committed_version();
220        self.retire_before_locked(commit_index)?;
221        // Compact every shard file concurrently (independent files).
222        self.for_each_backend_parallel(|backend| backend.compact())?;
223        self.compactions.fetch_add(1, Ordering::Relaxed);
224        Ok(Some(commit_index))
225    }
226
227    /// Idle compaction gated by quiescence and a growth ratio. This is what
228    /// the background worker uses; its ticker is the rate limit (a cooldown
229    /// here, stamped when a compaction finished, refused every other tick:
230    /// effective period 2× the interval, measured 2026-10-10). It never
231    /// rewrites the file while commits are flowing (quiescence), and only
232    /// once the file has grown by `growth_percent` since the last
233    /// compaction or at least [`PURGED_KEYS_TO_COMPACT`] keys were purged
234    /// (a tenant purge frees space without growing the file). Returns why it
235    /// deferred, or the commit index it compacted at.
236    ///
237    /// # Errors
238    /// Returns [`StorageError`] if reclamation or compaction fails.
239    ///
240    /// Lock poisoning fences the store until reopen.
241    pub fn compact_if_needed(
242        &self,
243        quiesce: std::time::Duration,
244        growth_percent: u32,
245    ) -> Result<CompactOutcome, StorageError> {
246        self.check_health()?;
247        let now = wal::now_ms();
248        let quiesce_ms = u64::try_from(quiesce.as_millis()).unwrap_or(u64::MAX);
249        let last_commit = self.last_commit_ms.load(Ordering::Relaxed);
250        if last_commit != 0 && now.saturating_sub(last_commit) < quiesce_ms {
251            return Ok(CompactOutcome::CommitsRecent);
252        }
253        let Some(file) = self.storage_file_bytes() else {
254            return Ok(CompactOutcome::NotDurable);
255        };
256        let last_file = self.last_compacted_file_bytes.load(Ordering::Relaxed);
257        let grown = last_file == 0
258            || file.saturating_mul(100) >= last_file.saturating_mul(u64::from(growth_percent));
259        // A purge frees pages without growing the file.
260        let purged = self.purged_since_compaction.load(Ordering::Relaxed) >= PURGED_KEYS_TO_COMPACT;
261        if !grown && !purged {
262            return Ok(CompactOutcome::BelowThreshold);
263        }
264        let deadline = std::time::Instant::now() + COMPACT_BUSY_RETRY;
265        let compacted = loop {
266            let attempt = self.compact_if_idle();
267            let reader_open = match &attempt {
268                Ok(None) => true,
269                Err(StorageError::Durability(error)) => error == crate::backend::COMPACT_BUSY,
270                _ => false,
271            };
272            if reader_open && std::time::Instant::now() < deadline {
273                // Retry with every lock released: waiting under the publish
274                // or database lock would stall the reader we wait for.
275                std::thread::sleep(COMPACT_BUSY_PAUSE);
276                continue;
277            }
278            break attempt?;
279        };
280        match compacted {
281            Some(ci) => {
282                self.purged_since_compaction.store(0, Ordering::Relaxed);
283                self.last_compacted_file_bytes
284                    .store(self.storage_file_bytes().unwrap_or(file), Ordering::Relaxed);
285                Ok(CompactOutcome::Compacted(ci))
286            }
287            None => Ok(CompactOutcome::ReadersPinned),
288        }
289    }
290
291    /// Apparent size of the primary durable file, if this store is
292    /// disk-backed. Operator metric only; not a durability or capacity input.
293    ///
294    /// Lock poisoning fences the store until reopen.
295    pub fn storage_file_bytes(&self) -> Option<u64> {
296        let path = self.health.cleanup(self.data_path.lock()).clone()?;
297        std::fs::metadata(path).ok().map(|meta| meta.len())
298    }
299
300    /// Durable byte accounting by key family (see [`StorageStatistics`]).
301    /// Aggregates every shard. Scans the key space once, so callers should
302    /// sample it on a maintenance schedule, not per request.
303    ///
304    /// # Errors
305    /// Returns [`StorageError::Durability`] if a shard cannot be read.
306    ///
307    /// Lock poisoning fences the store until reopen.
308    pub fn storage_stats(&self) -> Result<StorageStatistics, StorageError> {
309        self.check_health()?;
310        // Every store aggregates its backend(s); the in-memory backend computes
311        // the same accounting from its maps.
312        let mut total = StorageStatistics::default();
313        for backend in self.all_backends() {
314            if self.maintenance_aborted() {
315                return Err(StorageError::Cancelled);
316            }
317            let s = match backend.storage_stats() {
318                Ok(stats) => stats,
319                Err(error) if error == "maintenance cancelled" => {
320                    return Err(StorageError::Cancelled);
321                }
322                Err(error) => return Err(StorageError::Durability(error)),
323            };
324            total.data_keys += s.data_keys;
325            total.data_bytes += s.data_bytes;
326            total.history_keys += s.history_keys;
327            total.history_bytes += s.history_bytes;
328            total.log_keys += s.log_keys;
329            total.log_bytes += s.log_bytes;
330            total.receipt_keys += s.receipt_keys;
331            total.receipt_bytes += s.receipt_bytes;
332        }
333        total.admission_waits = self.admission_waits.load(Ordering::Relaxed);
334        total.admission_wait_ms = self.admission_wait_ms.load(Ordering::Relaxed);
335        total.checkpoints = self.checkpoints.load(Ordering::Relaxed);
336        total.checkpoint_wait_ms = self.checkpoint_wait_ms.load(Ordering::Relaxed);
337        Ok(total)
338    }
339
340    /// Lowest LSN that a byte-target retention policy must retire (via
341    /// [`Self::retire_commit_metadata_before`], which advances the retention
342    /// cursor) so the WAL's own retained bytes can shrink to `max_bytes` once
343    /// [`Self::truncate_log`] next runs. `0` means "nothing to do" (disabled,
344    /// non-durable, or already within budget).
345    ///
346    /// Segment-granular: a whole sealed WAL segment is dropped at a
347    /// time (the log's actual reclamation unit), never a row — the WAL has no
348    /// row-level accounting to scan. The always-open active segment is never
349    /// counted as droppable.
350    ///
351    /// # Errors
352    /// Returns [`StorageError`] if the WAL cannot be read.
353    pub fn log_byte_floor(&self, max_bytes: u64) -> Result<u64, StorageError> {
354        self.check_health()?;
355        if !self.is_durable() || max_bytes == 0 {
356            return Ok(0);
357        }
358        let total = self.retained_log_bytes()?;
359        if total == 0 || total <= max_bytes {
360            return Ok(0);
361        }
362        let excess = total - max_bytes;
363        let guard = self.health.guard(self.log.lock())?;
364        let Some(log) = guard.as_ref() else {
365            return Ok(0);
366        };
367        let segments = log.segments();
368        if segments.len() <= 1 {
369            // Only the active segment exists; nothing sealed to drop.
370            return Ok(0);
371        }
372        let mut removed = 0u64;
373        let mut floor = 0u64;
374        for segment in &segments[..segments.len() - 1] {
375            removed = removed.saturating_add(segment.bytes);
376            floor = segment.last_lsn.saturating_add(1);
377            if removed >= excess {
378                break;
379            }
380        }
381        if removed == 0 {
382            return Ok(0);
383        }
384        Ok(floor)
385    }
386
387    /// Total bytes retained by the segmented WAL: the sum of every
388    /// segment's valid bytes, including the active segment. `0` for a
389    /// non-durable store (no WAL file).
390    ///
391    /// # Errors
392    /// Returns [`StorageError`] if a storage lock is poisoned.
393    pub fn retained_log_bytes(&self) -> Result<u64, StorageError> {
394        self.check_health()?;
395        let guard = self.health.guard(self.log.lock())?;
396        Ok(guard
397            .as_ref()
398            .map_or(0, |log| log.segments().iter().map(|s| s.bytes).sum()))
399    }
400
401    /// Retention counters: (commits retired, receipts swept, compactions).
402    pub fn maintenance_counters(&self) -> (u64, u64, u64) {
403        (
404            self.log_rows_pruned.load(Ordering::Relaxed),
405            self.receipts_swept.load(Ordering::Relaxed),
406            self.compactions.load(Ordering::Relaxed),
407        )
408    }
409
410    /// Number of group-commit redb write transactions persisted (one fsync each
411    /// under `Local`). With a linger configured, concurrent commits coalesce
412    /// into fewer transactions.
413    #[must_use]
414    pub fn group_commit_count(&self) -> u64 {
415        self.group_commits.load(Ordering::Relaxed)
416    }
417
418    /// Bounded, resumable retention step: advance the durable retention
419    /// cursor (which clamps WAL truncation at the next checkpoint) by at most
420    /// `max_keys` commits towards `committed - keep_commits`. Per-commit
421    /// response bodies are no longer kept here: they live in retry receipts,
422    /// which expire by time ([`Self::sweep_receipts`]).
423    ///
424    /// No-op for in-memory stores (no WAL). Returns the number of commits
425    /// retired this call; callers loop until it returns 0.
426    ///
427    /// # Errors
428    /// Returns [`StorageError`] if the publisher has failed or the cursor
429    /// cannot be read or written.
430    pub fn retire_commit_metadata(
431        &self,
432        keep_commits: u64,
433        max_keys: usize,
434    ) -> Result<usize, StorageError> {
435        self.check_health()?;
436        let floor = self.committed_version().saturating_sub(keep_commits);
437        self.retire_commit_metadata_before(floor, max_keys)
438    }
439
440    /// [`Self::retire_commit_metadata`] with an explicit floor LSN, so a
441    /// retention policy (`keep_all` / `keep_none` / `N commits`) can drive it.
442    ///
443    /// # Errors
444    /// Returns [`StorageError`] if the publisher has failed or the cursor
445    /// cannot be read or written.
446    ///
447    /// Lock poisoning fences the store until reopen.
448    pub fn retire_commit_metadata_before(
449        &self,
450        floor: u64,
451        max_keys: usize,
452    ) -> Result<usize, StorageError> {
453        self.check_health()?;
454        self.check_publisher()?;
455        if !self.is_durable() || max_keys == 0 || floor == 0 {
456            return Ok(0);
457        }
458        let cursor = self.retention_cursor()?;
459        // Commits below `floor` are retired: the cursor is the last one.
460        let target = (floor - 1).min(cursor.saturating_add(max_keys as u64));
461        if target <= cursor {
462            return Ok(0);
463        }
464        {
465            let _guard = self.health.guard(self.publish_lock.lock())?;
466            self.advance_retention_cursor(target)?;
467        }
468        let retired = target - cursor;
469        self.log_rows_pruned.fetch_add(retired, Ordering::Relaxed);
470        Ok(usize::try_from(retired).unwrap_or(usize::MAX))
471    }
472
473    /// Delete expired retry receipts in batches of `batch` until none are
474    /// left or `budget` has elapsed, so the sweep keeps up with any commit
475    /// rate instead of removing a fixed number per maintenance tick. The
476    /// publish lock is released between batches, so commits interleave.
477    /// Returns the number of receipts removed.
478    ///
479    /// # Errors
480    /// See [`Self::sweep_receipts`].
481    pub fn sweep_expired_receipts(
482        &self,
483        batch: usize,
484        budget: std::time::Duration,
485    ) -> Result<usize, StorageError> {
486        let started = std::time::Instant::now();
487        let mut removed = 0;
488        loop {
489            let swept = self.sweep_receipts(batch)?;
490            removed += swept;
491            if swept < batch || started.elapsed() >= budget {
492                return Ok(removed);
493            }
494        }
495    }
496
497    /// Delete at most `max_keys` retry receipts older than the idempotency
498    /// window, oldest first through the expiry index. Each call is
499    /// one bounded write; callers loop (or wait for the next tick) until it
500    /// returns 0. Returns the number of receipts removed.
501    ///
502    /// # Errors
503    /// Returns [`StorageError`] if the store is unhealthy, the index cannot be
504    /// read, a receipt row is malformed, or the erase fails.
505    ///
506    /// Lock poisoning fences the store until reopen.
507    pub fn sweep_receipts(&self, max_keys: usize) -> Result<usize, StorageError> {
508        self.check_health()?;
509        self.check_publisher()?;
510        if max_keys == 0 {
511            return Ok(0);
512        }
513        let window_ms = self
514            .resource_config
515            .idempotency_window_seconds
516            .saturating_mul(1000);
517        let cutoff = self.receipt_now().saturating_sub(window_ms);
518        let view = self.read_view()?;
519        let mut cursor = view.scan(
520            crate::read_view::ScanBounds {
521                start: std::ops::Bound::Included(receipt::RECEIPT_EXP_PREFIX.to_vec()),
522                // Everything created strictly before the cutoff.
523                end: std::ops::Bound::Excluded(receipt::expiry_key(cutoff, uuid::Uuid::nil())),
524            },
525            ScanProjection::Keys,
526        )?;
527        let batch = cursor.next_batch(max_keys, usize::MAX)?;
528        let mut ops = Vec::with_capacity(batch.rows.len() * 2);
529        for row in &batch.rows {
530            let (created_ms, commit_key) = receipt::expiry_commit_key(&row.key)?;
531            let receipt_key = receipt::receipt_key(commit_key);
532            // A commit key reused after expiry has a newer receipt; only the
533            // receipt this index row describes goes.
534            if let Some(raw) = view.get(&receipt_key)? {
535                if receipt::decode(&raw)?.created_ms == created_ms {
536                    ops.push(Op::Purge(receipt_key));
537                }
538            }
539            ops.push(Op::Purge(row.key.clone()));
540        }
541        drop(cursor);
542        drop(view);
543        if batch.rows.is_empty() {
544            return Ok(0);
545        }
546        {
547            let _guard = self.health.guard(self.publish_lock.lock())?;
548            for backend in self.all_backends() {
549                backend.apply(&ops, 0).map_err(StorageError::Durability)?;
550            }
551        }
552        self.receipts_swept
553            .fetch_add(batch.rows.len() as u64, Ordering::Relaxed);
554        Ok(batch.rows.len())
555    }
556}