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