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