rushdb_storage/lib.rs
1//! Ordered KV substrate shared by every node: buffered transactions over a
2//! committed B-tree view, atomic redb publication of expanded op sets, a
3//! serialized command log, multi-version snapshot reads, and
4//! serializable-optimistic dependency validation.
5
6// The storage boundary deliberately exposes byte-oriented iterator and callback
7// signatures. Naming each nested trait-object form would obscure that API.
8#![allow(clippy::type_complexity)]
9
10mod adapter_budgets;
11pub mod backend;
12pub mod cache;
13mod health;
14mod id_alloc;
15pub use id_alloc::IdAllocator;
16pub mod read_accounting;
17mod transaction_view;
18pub use transaction_view::Txn;
19use transaction_view::TxnBase;
20mod commit;
21mod copy_on_write;
22pub use copy_on_write::CopyOnWrite;
23mod conflict_window;
24mod derive;
25mod fair_gate;
26pub mod history_v2;
27mod layout;
28mod log_export;
29pub mod log_store;
30mod maintenance;
31mod memory_backend;
32mod observability;
33mod overlay_view;
34pub mod owner_sets;
35mod persist;
36pub mod planner_stats;
37mod publish;
38mod read_view;
39mod receipt;
40mod redb_backend;
41pub mod resources;
42mod temp_path;
43pub mod tenant_dir;
44mod usage;
45pub use temp_path::TempPath;
46pub use usage::is_usage_bytes_key;
47pub mod wal;
48mod wal_replay;
49use backend::StorageBackend;
50pub use derive::{DeriveRead, Deriver};
51pub use maintenance::CompactOutcome;
52pub use observability::{
53 BackendLockTimes, CommitObserver, CommitPhase, WriterHolder, WriterHolds, WRITER_HOLD_BOUNDS,
54};
55pub use overlay_view::OverlayView;
56pub use planner_stats::PlannerStats;
57pub use read_view::{
58 ReadCancellation, ReadLimits, ReadPin, ReadView, ScanBatch, ScanBounds, ScanCursor,
59 ScanProjection, ScanRow,
60};
61
62#[cfg(test)]
63mod derive_tests;
64#[cfg(test)]
65mod read_view_tests;
66#[cfg(test)]
67mod receipt_tests;
68#[cfg(test)]
69mod regression_tests;
70#[cfg(test)]
71mod sequencer_tests;
72#[cfg(test)]
73mod writer_tests;
74
75/// Remove the materialization file at `path`. The WAL directory and layout
76/// manifest are left alone.
77///
78/// # Errors
79/// Returns the I/O error of the removal.
80pub fn remove_store(path: &std::path::Path) -> std::io::Result<()> {
81 std::fs::remove_file(path)
82}
83
84use rushdb_types::command::CommitBatch;
85use rushdb_types::ids::TenantId;
86use serde::de::DeserializeOwned;
87use serde::Serialize;
88use std::cell::RefCell;
89use std::collections::{BTreeMap, BTreeSet};
90use std::ops::Bound::{Included, Unbounded};
91use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
92use std::sync::mpsc;
93use std::sync::{Arc, Condvar, Mutex, RwLock};
94use thiserror::Error;
95use uuid::Uuid;
96
97/// Per-tenant outer admission shards, retained for replication call ordering.
98/// Local expansion additionally takes the single logical writer mutex.
99const N_SHARDS: usize = 64;
100
101/// A fully-admitted op-set awaiting batched durable publication, keyed by
102/// its reserved commit index. The publisher drains the contiguous prefix from
103/// the published watermark in one redb write transaction.
104struct PendingCommit {
105 ci: u64,
106 /// Physical shard this commit belongs to (tenant routing). The publisher
107 /// fans out durability across shard backends so different tenants persist
108 /// in parallel once `shard_count > 1`.
109 shard: usize,
110 ops: Vec<Op>,
111 prepared: PreparedCommand,
112 /// The originating `CommitBatch`, pre-serialized: paired with
113 /// `ops` in the WAL record so the command journal is derivable from the
114 /// WAL alone, without a separate `__log__` redb row.
115 command_payload: Vec<u8>,
116 ack: mpsc::Sender<Result<(), StorageError>>,
117}
118
119/// Pending-overlay state for the commit sequencer.
120///
121/// A committed `base` plus the net key effect of every assigned-but-unpublished
122/// commit, so an expansion can read through `Txn`'s overlay instead of waiting
123/// for the predecessor to be published. It is guarded by the writer
124/// `admission_lock`, so every writer that can assign a commit index observes
125/// one consistent overlay.
126///
127/// `buffer` is this state's *own* copy (never the publisher's `pending` map), so
128/// it cannot race the publisher removing a drained entry. Rebase only needs the
129/// generation (commit index) at which each key was last written, so the buffer
130/// stays O(distinct in-flight keys), not O(in-flight ops).
131#[derive(Default)]
132struct SequencerState {
133 /// `key -> (commit index that last wrote it, value or tombstone)` for
134 /// assigned-but-unpublished commits. The committed base is opened fresh
135 /// (and dropped) per critical section, so this state holds no read pin
136 /// between commits and cannot stall GC.
137 buffer: BTreeMap<Vec<u8>, (u64, Option<Vec<u8>>)>,
138}
139
140impl std::fmt::Debug for SequencerState {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("SequencerState")
143 .field("in_flight_keys", &self.buffer.len())
144 .finish()
145 }
146}
147
148impl SequencerState {
149 /// Fold an assigned commit's expanded ops into the pending buffer.
150 fn record(&mut self, ci: u64, ops: &[Op]) {
151 for op in ops {
152 match op {
153 Op::Put(key, value) | Op::Set(key, value) => {
154 self.buffer.insert(key.clone(), (ci, Some(value.clone())));
155 }
156 Op::Del(key) | Op::Purge(key) => {
157 self.buffer.insert(key.clone(), (ci, None));
158 }
159 }
160 }
161 }
162}
163
164pub(crate) const LOG_PREFIX: &[u8] = b"__log__/";
165const LAST_APPLIED_KEY: &[u8] = b"meta/last-applied";
166/// Durable watermark: the highest LSN retention has retired.
167/// Bounds `Store::truncate_log`, so the retention floor (hard caps and
168/// consumer protection) decides which WAL segments may go.
169pub(crate) const RETENTION_CURSOR_KEY: &[u8] = b"meta/retention-cursor";
170
171/// Durable byte accounting by key family, for growth diagnosis and operator
172/// metrics. All values are exact sums of stored key+value lengths for the
173/// family, taken from one read view; they exclude redb's copy-on-write slack
174/// (compare against [`Store::storage_file_bytes`]).
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct StorageStatistics {
177 /// Canonical data rows (records, occurrences, postings, catalogs, …).
178 pub data_keys: u64,
179 /// Total key+value bytes of canonical data rows.
180 pub data_bytes: u64,
181 /// Retained MVCC history rows.
182 pub history_keys: u64,
183 /// Total key+encoded-history bytes.
184 pub history_bytes: u64,
185 /// Retained command-log rows (`__log__/<lsn>`).
186 pub log_keys: u64,
187 /// Total command-log bytes.
188 pub log_bytes: u64,
189 /// Retry-receipt rows (receipts plus their expiry index).
190 pub receipt_keys: u64,
191 /// Total retry-receipt bytes.
192 pub receipt_bytes: u64,
193 /// Number of bounded admission-lock waits (including timed-out waits).
194 pub admission_waits: u64,
195 /// Milliseconds spent waiting for admission-lock ownership.
196 pub admission_wait_ms: u64,
197 /// Number of explicit durability checkpoints.
198 pub checkpoints: u64,
199 /// Milliseconds spent in explicit durability checkpoints.
200 pub checkpoint_wait_ms: u64,
201 /// Bytes appended to the WAL (framed records), cumulative since open.
202 pub wal_bytes_appended: u64,
203 /// Encoded bytes of every op, base and derived, handed to the backends,
204 /// cumulative since open. With `wal_bytes_appended`, the two write
205 /// streams a commit produces; compare against `rushdb.redb` growth and
206 /// the process's block writes to attribute write amplification.
207 pub applied_bytes: u64,
208}
209
210/// Errors returned by the storage layer: failed preconditions, corrupt or
211/// unsupported durable state, resource exhaustion, and invalid read requests.
212#[derive(Debug, Clone, Error)]
213pub enum StorageError {
214 /// A poisoned storage lock fenced the store; reopen is required.
215 #[error("storage unhealthy after lock poisoning; reopen required")]
216 Unhealthy,
217 /// A transaction precondition was violated; `path` is the offending key.
218 #[error("precondition failed on {path}")]
219 Precondition {
220 /// Key whose precondition failed.
221 path: String,
222 },
223 /// The deterministic expander failed while building a commit.
224 #[error("builder error: {0}")]
225 Builder(String),
226 /// A durable record could not be decoded or verified.
227 #[error("corrupt log entry")]
228 CorruptLog,
229 /// A log record carries a command version this build does not support.
230 #[error("unsupported command version {0}")]
231 UnsupportedCommandVersion(u32),
232 /// The stored outcome payload is missing or cannot be deserialized.
233 #[error("committed outcome payload is unavailable or incompatible")]
234 OutcomePayload,
235 /// The physical backend failed while applying or reading durable state.
236 #[error("durable storage failure: {0}")]
237 Durability(String),
238 /// The requested snapshot predates the oldest retained commit.
239 #[error("snapshot {requested} expired; oldest supported commit is {oldest}")]
240 SnapshotExpired {
241 /// Snapshot commit index that was requested.
242 requested: u64,
243 /// Oldest commit index still readable.
244 oldest: u64,
245 },
246 /// The requested snapshot is newer than the visible commit index.
247 #[error("snapshot {requested} is newer than visible commit {visible}")]
248 FutureSnapshot {
249 /// Snapshot commit index that was requested.
250 requested: u64,
251 /// Commit index currently visible.
252 visible: u64,
253 },
254 /// A managed byte budget refused the request.
255 #[error("storage resource exhausted: {0}")]
256 ResourceExhausted(String),
257 /// The supplied storage configuration is not valid.
258 #[error("invalid storage configuration: {0}")]
259 InvalidConfiguration(String),
260 /// A pinned read was cancelled by its caller.
261 #[error("storage read cancelled")]
262 Cancelled,
263 /// Scan bounds or batch limits are invalid.
264 #[error("invalid scan bounds or limits: {0}")]
265 InvalidRead(String),
266 /// Physical compaction was requested while protected read views are open.
267 #[error("physical compaction requires all protected read views to close")]
268 ReadersActive,
269 /// No conflict summary is retained for a commit that must be probed.
270 #[error(
271 "actual write summary unavailable for commit {commit_index}; retry with a fresh snapshot"
272 )]
273 ConflictHistoryUnavailable {
274 /// Commit index whose conflict summary is missing.
275 commit_index: u64,
276 },
277}
278
279/// Operation emitted into the atomic publish set.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum Op {
282 /// Insert or overwrite a key, retaining the superseded value as history.
283 Put(Vec<u8>, Vec<u8>),
284 /// Remove a key, retaining the removed value as history.
285 Del(Vec<u8>),
286 /// Overwrite a key **without** retaining a superseded version (and drop
287 /// any history already accumulated for it). Used for internal bookkeeping
288 /// keys whose values are not part of user-visible time travel: counters,
289 /// allocation watermarks, degree counters, the label catalog and the
290 /// commit watermark. Without this, every overwrite of a hot counter
291 /// rewrites its whole history vector, which is quadratic in the number of
292 /// writes.
293 Set(Vec<u8>, Vec<u8>),
294 /// Erase a key together with its current-value MVCC metadata and retained
295 /// history, without appending a new version. Destructive by design: used
296 /// by tenant purge, where the values are intentionally destroyed and
297 /// keeping versions for them would bloat storage without bound.
298 Purge(Vec<u8>),
299}
300
301impl Op {
302 /// Approximate encoded size (key + value bytes) for group-commit byte
303 /// accounting (P04 bounded writer).
304 #[must_use]
305 pub fn encoded_len(&self) -> usize {
306 match self {
307 Op::Put(k, v) | Op::Set(k, v) => k.len() + v.len(),
308 Op::Del(k) | Op::Purge(k) => k.len(),
309 }
310 }
311}
312
313/// Reason prefix of a [`CommitOutcome::Conflicted`] caused by a read
314/// dependency that a later commit wrote (first-committer-wins).
315pub const SERIALIZABLE_CONFLICT: &str = "serializable_conflict";
316
317/// Serializable-mode read dependencies recorded against a transaction.
318/// Zero-width entries are point reads; otherwise half-open [start,end).
319#[derive(Default, Clone, Debug)]
320pub struct DependencySet {
321 /// Recorded read ranges as `(start, end)` pairs; a zero-width pair is a
322 /// point dependency.
323 pub ranges: Vec<(Vec<u8>, Vec<u8>)>,
324 whole_keyspace: bool,
325}
326
327impl DependencySet {
328 /// Whether no point, range, or whole-keyspace dependency was recorded.
329 #[must_use]
330 pub fn is_empty(&self) -> bool {
331 self.ranges.is_empty() && !self.whole_keyspace
332 }
333
334 /// Record a half-open `[start, end)` range dependency.
335 pub fn record(&mut self, start: Vec<u8>, end: Vec<u8>) {
336 self.ranges.push((start, end));
337 }
338
339 /// Prefix tracking is explicit so an empty prefix (all keys) is distinct
340 /// from a point dependency on the empty key.
341 pub fn record_prefix(&mut self, prefix: &[u8]) {
342 if prefix.is_empty() {
343 self.whole_keyspace = true;
344 } else {
345 let (start, end) = prefix_range(prefix);
346 self.record(start, end);
347 }
348 }
349
350 /// Merge dependencies captured by another preparation phase.
351 pub fn extend(&mut self, other: Self) {
352 self.ranges.extend(other.ranges);
353 self.whole_keyspace |= other.whole_keyspace;
354 }
355
356 fn intersects(&self, key: &[u8]) -> bool {
357 self.whole_keyspace
358 || self.ranges.iter().any(|(s, e)| {
359 if s == e {
360 key == s.as_slice()
361 } else {
362 key >= s.as_slice() && (e.is_empty() || key < e.as_slice())
363 }
364 })
365 }
366
367 /// Whether `key` falls in any recorded read range (serializable conflict
368 /// probe for transaction commit).
369 #[must_use]
370 pub fn intersects_key(&self, key: &[u8]) -> bool {
371 self.intersects(key)
372 }
373}
374
375/// Prepared metadata for one admitted command. The publisher keeps this
376/// alongside expanded operations so an admission overlay can validate
377/// read/write conflicts without re-expanding the command.
378#[derive(Clone, Debug, Default)]
379pub struct PreparedCommand {
380 /// Reserved commit index, preserving command-log order.
381 pub commit_index: u64,
382 /// Canonical keys written by the expanded operation set.
383 pub writes: BTreeSet<Vec<u8>>,
384 /// Keys/ranges read while preparing the command.
385 pub reads: DependencySet,
386 /// Expanded under writer admission against the sequencer overlay, so its
387 /// reads already observed every lower-index commit (published or
388 /// pending). Such a command can share a publish group with the commits
389 /// it read from: the group applies in commit-index order.
390 pub sequenced: bool,
391}
392
393impl PreparedCommand {
394 /// Build prepared metadata from an expanded operation set.
395 #[must_use]
396 pub fn from_ops(commit_index: u64, ops: &[Op]) -> Self {
397 let writes = ops
398 .iter()
399 .map(|op| match op {
400 Op::Put(key, _) | Op::Set(key, _) | Op::Del(key) | Op::Purge(key) => key,
401 })
402 .cloned()
403 .collect();
404 Self {
405 commit_index,
406 writes,
407 reads: DependencySet::default(),
408 sequenced: false,
409 }
410 }
411
412 /// Whether this command's reads overlap writes from `other`, or vice
413 /// versa. The check is symmetric and suitable for optimistic admission.
414 ///
415 /// The write-write check (large-transaction support, Part F) uses
416 /// `writes` being a `BTreeSet` directly — `other.writes.contains(key)`
417 /// is O(log n), not the nested linear scan this used to be. Filtering
418 /// only `self`'s side is still exactly equivalent to filtering both:
419 /// `is_coordination_write` is a pure function of the key's own bytes, so
420 /// if `key` already passed the filter, an identical byte sequence in
421 /// `other.writes` has the same classification — there is no case where
422 /// filtering the other side too would change the answer.
423 ///
424 /// Read sets are probed **against** the ordered write set — one range
425 /// query per recorded read — rather than testing every written key
426 /// against a linear list of read ranges. The old form was
427 /// O(writes × reads) per pair and, applied pairwise inside every publish
428 /// group, starved bulk ingest for minutes (cloud-capacity F4 follow-up).
429 #[must_use]
430 pub fn conflicts_with(&self, other: &Self) -> bool {
431 reads_hit_writes(&other.reads, &self.writes)
432 || reads_hit_writes(&self.reads, &other.writes)
433 || {
434 let (small, large) = if self.writes.len() <= other.writes.len() {
435 (&self.writes, &other.writes)
436 } else {
437 (&other.writes, &self.writes)
438 };
439 small
440 .iter()
441 .filter(|key| !is_coordination_write(key))
442 .any(|key| large.contains(key))
443 }
444 }
445}
446
447/// Whether any recorded read (point, range, or whole keyspace) covers a
448/// non-coordination key of `writes`. O(reads × log writes).
449fn reads_hit_writes(reads: &DependencySet, writes: &BTreeSet<Vec<u8>>) -> bool {
450 use std::ops::Bound::Excluded;
451 let application = |key: &&Vec<u8>| !is_coordination_write(key);
452 if reads.whole_keyspace {
453 return writes.iter().any(|key| application(&key));
454 }
455 reads.ranges.iter().any(|(start, end)| {
456 if start == end {
457 writes.contains(start) && !is_coordination_write(start)
458 } else if end.is_empty() {
459 writes
460 .range::<[u8], _>((Included(start.as_slice()), Unbounded))
461 .any(|key| application(&key))
462 } else if start < end {
463 writes
464 .range::<[u8], _>((Included(start.as_slice()), Excluded(end.as_slice())))
465 .any(|key| application(&key))
466 } else {
467 false
468 }
469 })
470}
471
472/// Ordered publication owns these metadata rows; they are rewritten by every
473/// commit as part of the command log and do not represent application-level
474/// read/write overlap for group admission.
475fn is_coordination_write(key: &[u8]) -> bool {
476 key == LAST_APPLIED_KEY
477 || key.starts_with(LOG_PREFIX)
478 || key.starts_with(receipt::RECEIPT_PREFIX)
479 || key.starts_with(receipt::RECEIPT_EXP_PREFIX)
480}
481
482/// Inclusive start / exclusive end range covering all keys under a prefix.
483/// An empty end bound means unbounded above.
484#[must_use]
485pub fn prefix_range(prefix: &[u8]) -> (Vec<u8>, Vec<u8>) {
486 let mut end = prefix.to_vec();
487 loop {
488 match end.last_mut() {
489 Some(last) if *last != 0xFF => {
490 *last += 1;
491 return (prefix.to_vec(), end);
492 }
493 Some(_) => {
494 end.pop();
495 }
496 None => return (prefix.to_vec(), Vec::new()),
497 }
498 }
499}
500
501fn log_key(seq: u64) -> Vec<u8> {
502 let mut k = LOG_PREFIX.to_vec();
503 k.extend_from_slice(format!("{seq:020}").as_bytes());
504 k
505}
506
507/// Terminal status of a commit key, as stored durably.
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum OutcomeKind {
510 /// The commit succeeded at the given commit index.
511 Committed(u64),
512 /// The commit was terminally aborted and must not be retried under the
513 /// same commit key.
514 Aborted,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518enum OutcomeStatus {
519 Committed,
520 Aborted,
521}
522
523/// Result of a commit attempt: either the durable commit index plus the
524/// caller payload, or a conflict reason.
525#[derive(Debug)]
526pub enum CommitOutcome<Payload> {
527 /// The commit was published at `commit_index` with the caller's `payload`.
528 Committed {
529 /// Durable commit index assigned to this commit.
530 commit_index: u64,
531 /// Caller-supplied response payload persisted with the outcome.
532 payload: Payload,
533 },
534 /// The commit was rejected; the string explains the conflict.
535 Conflicted(String),
536}
537
538/// Deterministic state-machine expansion callback: derives every canonical
539/// and derived key implied by one committed batch (document 07).
540pub type ExpandFn = Box<dyn Fn(u64, &CommitBatch, &mut Txn) -> Result<(), String> + Send + Sync>;
541
542/// Accumulated state of one in-flight cross-service transaction. The buffer is
543/// the transaction's uncommitted write overlay (read-buffer-first like `Txn`);
544/// `deps` records the public reads performed inside the transaction;
545/// `mutations`/`preconditions` replay the writes atomically at commit.
546#[derive(Clone)]
547pub struct TxAccumulator {
548 /// Commit index the transaction's snapshot reads are based on.
549 pub base_ci: u64,
550 /// Tenant that owns the transaction.
551 pub tenant: TenantId,
552 /// Uncommitted write overlay, keyed by storage key.
553 pub buffer: BTreeMap<Vec<u8>, Option<Vec<u8>>>,
554 /// Keys of `buffer` that are monotone counters ([`Txn::set_watermark`]).
555 /// A concurrent commit that advanced one is not a write conflict.
556 pub watermarks: BTreeSet<Vec<u8>>,
557 /// Public read dependencies recorded inside the transaction.
558 pub deps: DependencySet,
559 /// Mutations accumulated across statements, replayed at commit.
560 pub mutations: Vec<rushdb_types::command::Mutation>,
561 /// Preconditions accumulated across statements, checked at commit.
562 pub preconditions: Vec<rushdb_types::command::Precondition>,
563 /// Pinned snapshot all transaction reads resolve at. Cross-request
564 /// coordinators set this at BEGIN so statements and expansion observe the
565 /// transaction's snapshot rather than current committed state. `None`
566 /// keeps current-state reads for embedded/short-lived callers.
567 pub base_view: Option<std::sync::Arc<ReadView>>,
568}
569
570impl TxAccumulator {
571 /// Create an empty accumulator for `tenant` whose snapshot is `base_ci`.
572 #[must_use]
573 pub fn new(tenant: TenantId, base_ci: u64) -> Self {
574 TxAccumulator {
575 base_ci,
576 tenant,
577 buffer: BTreeMap::new(),
578 watermarks: BTreeSet::new(),
579 deps: DependencySet::default(),
580 mutations: Vec::new(),
581 preconditions: Vec::new(),
582 base_view: None,
583 }
584 }
585}
586
587/// Replication seam: when installed on a `Store`, local commits build the
588/// canonical `CommitBatch` and route it through the leader. The leader
589/// replicates to the quorum and every node (leader included) applies it
590/// through `Store::apply_replicated_batch`, which returns after the batch is
591/// durably applied locally. Serialized `payload` is stored with the outcome so
592/// idempotent retries can reproduce the caller's response.
593pub trait Replicator: Send + Sync {
594 /// Replicate `batch` with its serialized `payload` to the quorum.
595 ///
596 /// # Errors
597 /// Returns a human-readable reason if replication fails.
598 fn replicate(&self, batch: &CommitBatch, payload: &[u8]) -> Result<(), String>;
599
600 /// Highest commit index durably acknowledged by the replication quorum.
601 ///
602 /// Implementations that cannot expose a watermark return `None`; the
603 /// retention worker then leaves the replica consumer unregistered rather
604 /// than guessing that a lagging replica is caught up.
605 fn acknowledged_commit_index(&self) -> Option<u64> {
606 None
607 }
608}
609
610/// One superseded value of a key, covering the half-open range [from,to).
611#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
612pub struct VersionEntry {
613 /// Commit index at which this version became current.
614 pub from_ci: u64,
615 /// Commit index that superseded this version; `0` means still current.
616 pub to_ci: u64,
617 /// Stored value; `None` represents a deletion.
618 pub value: Option<Vec<u8>>,
619}
620
621/// Ordered key/value store shared by every node: admission, deterministic
622/// expansion, group-commit publication, multi-version reads, and retention.
623pub struct Store {
624 health: Arc<health::StoreHealth>,
625 resources: resources::ByteBudget,
626 transaction_resources: resources::ByteBudget,
627 /// Persistent shared budget for retained logical caches.
628 logical_cache_resources: Option<resources::ByteBudget>,
629 /// Shared reservation pool for in-flight query/adapter phases.
630 phase_resources: Option<resources::ByteBudget>,
631 /// Per-adapter attribution children of the two budgets above.
632 adapter_budgets: adapter_budgets::AdapterBudgets,
633 planner_stats: Arc<planner_stats::PlannerStats>,
634 owner_sets: Arc<owner_sets::OwnerSets>,
635 resource_config: resources::StorageResources,
636 data: Arc<RwLock<BTreeMap<Vec<u8>, Vec<u8>>>>,
637 /// creation commit index per live key (multi-version reads).
638 created: Arc<RwLock<BTreeMap<Vec<u8>, u64>>>,
639 /// superseded values per key for time-travel reads.
640 history: Arc<RwLock<BTreeMap<Vec<u8>, Vec<VersionEntry>>>>,
641 /// lowest commit index that must remain readable (GC watermark).
642 gc_watermark: AtomicU64,
643 /// Logical retention leases (held under `publish_lock` so a successful
644 /// view cannot race GC). Each entry maps a pinned CI to its [`ReadPin`]
645 /// (count, last-lease-ms, generation) so an abandoned reader can be
646 /// expired by age (P06 safe cancellation-before-release).
647 read_pins: Arc<Mutex<BTreeMap<u64, ReadPin>>>,
648 /// Cancellation handles corresponding to each retained read lease. GC
649 /// cancels a lease before removing its retention pin, so an expired view
650 /// cannot continue reading after history is reclaimed.
651 read_cancellations: Arc<Mutex<BTreeMap<u64, Vec<ReadCancellation>>>>,
652 /// Transitional single logical writer. Planning and expansion see the
653 /// fully published predecessor, including global metadata shared by tenants.
654 /// Also owns the sequencer's pending overlay (`SequencerState`).
655 admission_lock: Mutex<SequencerState>,
656 /// A failed fsync can have an uncertain durable outcome. Fence all later
657 /// writes until reopen; never retry publication under another CI in-process.
658 publisher_failure: Mutex<Option<StorageError>>,
659 #[cfg(test)]
660 fail_next_persist: std::sync::atomic::AtomicBool,
661 /// Per-tenant outer admission shards (replication planning order).
662 shards: Vec<Mutex<()>>,
663 /// Serializes durable persist + in-memory publish in commit-index order.
664 publish_lock: Mutex<()>,
665 /// FIFO gate in front of `admission_lock` (see `fair_gate`).
666 admission_queue: fair_gate::FairGate,
667 /// Write sets of recently published commits for optimistic conflict
668 /// validation (in memory only; see `conflict_window`).
669 conflicts: Mutex<conflict_window::ConflictWindow>,
670 /// Fully-admitted op-sets awaiting batched publication, keyed by their
671 /// reserved commit index (drained from `published+1`).
672 pending: Mutex<BTreeMap<u64, PendingCommit>>,
673 pending_cv: Condvar,
674 /// Commit-index reservation counter (1-based). Reservation happens at
675 /// admission; only successful commits consume a slot so the log stays
676 /// dense and replay stays position-indexed.
677 next_commit_index: AtomicU64,
678 /// Last durably persisted + applied index. `committed_version()` reads
679 /// this watermark (a reservation may run ahead of publication).
680 published: AtomicU64,
681 expander: RwLock<Option<Arc<ExpandFn>>>,
682 /// Called with each durable commit's latency in seconds (metrics hook).
683 commit_observer: RwLock<Option<Arc<CommitObserver>>>,
684 /// Apply-time deriver; `None` applies base ops unchanged.
685 deriver: RwLock<Option<Arc<dyn Deriver>>>,
686 /// Bounded tenant-directory resolution cache.
687 tenant_cache: Mutex<tenant_dir::TenantCache>,
688 /// Ids reserved by planning, ahead of their commits (`id_alloc`).
689 pub(crate) ids: IdAllocator,
690 /// Set when WAL recovery applied records with no deriver installed, so a
691 /// later `set_deriver` fences the store instead of serving missing
692 /// derived rows.
693 underived_replay: std::sync::atomic::AtomicBool,
694 /// One durable backend per physical shard. `shard_count == 1` (default)
695 /// keeps a single element, preserving today's single-file layout. A future
696 /// per-shard commit-index split (W4 phase 2b) routes each tenant's commits
697 /// to its shard's backend for parallel writers.
698 durable: RwLock<Box<[Option<Arc<dyn StorageBackend>>]>>,
699 /// Log-first segmented WAL: when present, the durable truth — a
700 /// commit acks after its op-set is appended+fsynced here, and redb is a
701 /// checkpointed materialization replayed from it. `None` for in-memory.
702 log: Mutex<Option<log_store::LogStore>>,
703 /// Optional replication seam (`OpenRaft` leader): when set, local commits
704 /// route through the leader instead of publishing locally; every node
705 /// (including this one) applies the result via `apply_replicated_batch`.
706 replicator: RwLock<Option<Arc<dyn Replicator>>>,
707 /// Resumable GC cursor: the last history key the background worker
708 /// processed. `None` means no sweep is in progress.
709 gc_cursor: Mutex<Option<Vec<u8>>>,
710 /// Primary durable file path (shard 0), set by `attach_redb`. Used only
711 /// for operator-facing size metrics.
712 data_path: Mutex<Option<std::path::PathBuf>>,
713 /// Copy-on-write state of the data files, recorded at open.
714 copy_on_write: Mutex<Option<CopyOnWrite>>,
715 /// Set at shutdown so long maintenance scans stop promptly and the store
716 /// closes cleanly instead of waiting for a full keyspace scan.
717 maintenance_abort: Arc<AtomicBool>,
718 /// Operator counters for retention/compaction sweeps.
719 log_rows_pruned: AtomicU64,
720 receipts_swept: AtomicU64,
721 /// Clock of retry receipts (unix ms); injectable so tests can age them.
722 receipt_clock: RwLock<Arc<dyn Fn() -> u64 + Send + Sync>>,
723 compactions: AtomicU64,
724 /// Number of group-commit redb write transactions persisted (one fsync
725 /// each under `Local`); the coalescing metric for the linger knob.
726 group_commits: AtomicU64,
727 admission_waits: AtomicU64,
728 admission_wait_ms: AtomicU64,
729 checkpoints: AtomicU64,
730 checkpoint_wait_ms: AtomicU64,
731 /// Wall-clock ms of the last published commit; compaction quiescence gate.
732 last_commit_ms: AtomicU64,
733 /// File bytes immediately after the last compaction; growth-ratio gate.
734 last_compacted_file_bytes: AtomicU64,
735 /// Keys erased by purges (tenant purge) since the last compaction: a
736 /// purge frees pages without growing the file, so it triggers idle
737 /// compaction on its own.
738 purged_since_compaction: AtomicU64,
739 /// Bytes appended to the WAL, cumulative (see `StorageStatistics`).
740 wal_bytes_appended: AtomicU64,
741 /// Encoded op bytes handed to the backends, cumulative.
742 applied_bytes: AtomicU64,
743 /// Whether a physical compaction holds the database: every read and
744 /// commit waits on it, so readiness reports it.
745 compacting: AtomicBool,
746}
747
748impl Default for Store {
749 fn default() -> Self {
750 Self::new_in_memory()
751 }
752}
753
754impl Store {
755 /// Create an in-memory store with default resource budgets.
756 ///
757 /// # Panics
758 /// Panics if the default storage resources are invalid, which is a
759 /// compile-time constant and cannot happen at runtime.
760 #[must_use]
761 pub fn new_in_memory() -> Self {
762 Self::new_with_resources(resources::StorageResources::default())
763 .expect("valid default storage resources")
764 }
765
766 /// Create an in-memory store with an explicit resource configuration.
767 ///
768 /// # Errors
769 /// Returns [`StorageError::InvalidConfiguration`] when `config` fails
770 /// validation, or [`StorageError::ResourceExhausted`] when a byte budget
771 /// cannot be created.
772 #[expect(
773 clippy::too_many_lines,
774 reason = "the store's constructor initializes every counter and lock in one place"
775 )]
776 pub fn new_with_resources(config: resources::StorageResources) -> Result<Self, StorageError> {
777 config
778 .validate()
779 .map_err(|e| StorageError::InvalidConfiguration(e.to_string()))?;
780 let resources = resources::ByteBudget::new("store", config.managed_limit_bytes)
781 .map_err(|e| StorageError::ResourceExhausted(e.to_string()))?;
782 let transaction_resources = resources
783 .child("transaction-staging", config.transaction_pool_bytes)
784 .map_err(|e| StorageError::InvalidConfiguration(e.to_string()))?;
785 // A zero-sized pool is disabled; otherwise it is capped by the root.
786 let optional_child = |name: &'static str, bytes: usize| {
787 (bytes > 0)
788 .then(|| resources.child(name, bytes.min(config.managed_limit_bytes)))
789 .transpose()
790 .map_err(|e| StorageError::InvalidConfiguration(e.to_string()))
791 };
792 let phase_resources = optional_child("query-phases", config.phase_budget_bytes)?;
793 let logical_cache_resources = optional_child("logical-cache", config.logical_cache_bytes)?;
794 let adapter_budgets = adapter_budgets::AdapterBudgets::new(
795 optional_child("vector-cache", config.vector_cache_bytes)?,
796 phase_resources.as_ref(),
797 )?;
798 let owner_sets = Arc::new(owner_sets::OwnerSets::new(
799 logical_cache_resources.as_ref(),
800 )?);
801 let planner_stats = Arc::new(planner_stats::PlannerStats::new(
802 &config,
803 logical_cache_resources.as_ref(),
804 Arc::new(std::time::Instant::now),
805 )?);
806 // The in-memory store is a real `StorageBackend`. `Store` keeps
807 // shared handles to the same maps for diagnostics/replay; the backend
808 // owns the read view and the write path.
809 let health = Arc::new(health::StoreHealth::default());
810 let maintenance_abort = Arc::new(AtomicBool::new(false));
811 let maps = memory_backend::MemoryMaps::new();
812 let memory_backends: Box<[Option<Arc<dyn StorageBackend>>]> = (0..config.shard_count)
813 .map(|_| {
814 Some(Arc::new(memory_backend::MemoryBackend::new(
815 Arc::clone(&health),
816 memory_backend::MemoryMaps {
817 data: Arc::clone(&maps.data),
818 created: Arc::clone(&maps.created),
819 history: Arc::clone(&maps.history),
820 },
821 Arc::clone(&maintenance_abort),
822 )) as Arc<dyn StorageBackend>)
823 })
824 .collect();
825 let conflicts = conflict_window::ConflictWindow::charged_to(&resources);
826 Ok(Store {
827 resources,
828 transaction_resources,
829 logical_cache_resources,
830 phase_resources,
831 adapter_budgets,
832 planner_stats,
833 owner_sets,
834 resource_config: config,
835 data: Arc::clone(&maps.data),
836 created: Arc::clone(&maps.created),
837 history: Arc::clone(&maps.history),
838 gc_watermark: AtomicU64::new(0),
839 read_pins: Arc::new(Mutex::new(BTreeMap::new())),
840 read_cancellations: Arc::new(Mutex::new(BTreeMap::new())),
841 admission_lock: Mutex::new(SequencerState::default()),
842 health,
843 publisher_failure: Mutex::new(None),
844 #[cfg(test)]
845 fail_next_persist: std::sync::atomic::AtomicBool::new(false),
846 shards: (0..N_SHARDS).map(|_| Mutex::new(())).collect(),
847 publish_lock: Mutex::new(()),
848 admission_queue: fair_gate::FairGate::default(),
849 conflicts: Mutex::new(conflicts),
850 pending: Mutex::new(BTreeMap::new()),
851 pending_cv: Condvar::new(),
852 next_commit_index: AtomicU64::new(1),
853 published: AtomicU64::new(0),
854 expander: RwLock::new(None),
855 commit_observer: RwLock::new(None),
856 deriver: RwLock::new(None),
857 tenant_cache: Mutex::new(tenant_dir::TenantCache::default()),
858 ids: IdAllocator::default(),
859 underived_replay: std::sync::atomic::AtomicBool::new(false),
860 durable: RwLock::new(memory_backends),
861 log: Mutex::new(None),
862 replicator: RwLock::new(None),
863 gc_cursor: Mutex::new(None),
864 data_path: Mutex::new(None),
865 copy_on_write: Mutex::new(None),
866 maintenance_abort,
867 log_rows_pruned: AtomicU64::new(0),
868 receipts_swept: AtomicU64::new(0),
869 receipt_clock: RwLock::new(Arc::new(wal::now_ms)),
870 compactions: AtomicU64::new(0),
871 group_commits: AtomicU64::new(0),
872 admission_waits: AtomicU64::new(0),
873 admission_wait_ms: AtomicU64::new(0),
874 checkpoints: AtomicU64::new(0),
875 checkpoint_wait_ms: AtomicU64::new(0),
876 last_commit_ms: AtomicU64::new(0),
877 last_compacted_file_bytes: AtomicU64::new(0),
878 purged_since_compaction: AtomicU64::new(0),
879 wal_bytes_appended: AtomicU64::new(0),
880 applied_bytes: AtomicU64::new(0),
881 compacting: AtomicBool::new(false),
882 })
883 }
884
885 /// Whether a physical compaction currently holds the database. Reads
886 /// and commits wait for it; `/ready` reports it so an orchestrator does
887 /// not route traffic to a node that cannot answer.
888 #[must_use]
889 pub fn compacting(&self) -> bool {
890 self.compacting.load(Ordering::Relaxed)
891 }
892
893 /// Mark a physical compaction as holding (or releasing) the database.
894 /// Set by compaction itself; public for tests of what observes it.
895 #[doc(hidden)]
896 pub fn mark_compacting(&self, on: bool) {
897 self.compacting.store(on, Ordering::Relaxed);
898 }
899
900 /// `(WAL bytes appended, op bytes applied)` since the store opened: the
901 /// two write streams a commit produces.
902 #[must_use]
903 pub fn write_bytes(&self) -> (u64, u64) {
904 (
905 self.wal_bytes_appended.load(Ordering::Relaxed),
906 self.applied_bytes.load(Ordering::Relaxed),
907 )
908 }
909
910 /// The store's managed-byte budget.
911 pub fn resource_budget(&self) -> resources::ByteBudget {
912 self.resources.clone()
913 }
914
915 /// The configuration this store was opened with.
916 pub fn resource_config(&self) -> resources::StorageResources {
917 self.resource_config
918 }
919
920 /// The byte budget charged for transaction staging.
921 pub fn transaction_budget(&self) -> resources::ByteBudget {
922 self.transaction_resources.clone()
923 }
924
925 /// Approximate bytes resident in the writer's current-value and history
926 /// maps. This is the transitional RAM mirror P04 removes; the baseline
927 /// harness records it so the "no full RAM mirror" milestone is
928 /// measurable and comparable across revisions.
929 ///
930 /// Lock poisoning fences the store until reopen.
931 pub fn approximate_map_bytes(&self) -> (u64, u64) {
932 let data = self.health.cleanup(self.data.read());
933 let current: u64 = data.iter().map(|(k, v)| (k.len() + v.len()) as u64).sum();
934 drop(data);
935 let history = self.health.cleanup(self.history.read());
936 let hist: u64 = history
937 .iter()
938 .map(|(k, v)| {
939 k.len() as u64
940 + v.iter()
941 .map(|e| e.value.as_ref().map_or(0, std::vec::Vec::len) as u64)
942 .sum::<u64>()
943 })
944 .sum();
945 (current, hist)
946 }
947
948 /// Shard index for a tenant; commits without a tenant use shard 0.
949 #[expect(
950 clippy::cast_possible_truncation,
951 reason = "the hash is immediately reduced modulo N_SHARDS, so truncating its high bits cannot change routing correctness"
952 )]
953 fn shard_for(tenant: Option<&TenantId>) -> usize {
954 match tenant {
955 Some(t) => {
956 let mut h = 2_166_136_261u64;
957 for b in &t.to_be_bytes() {
958 h ^= u64::from(*b);
959 h = h.wrapping_mul(16_777_619);
960 }
961 (h as usize) % N_SHARDS
962 }
963 None => 0,
964 }
965 }
966
967 /// Open a crash-safe redb-backed store. The materialized keyspace and
968 /// MVCC metadata are restored before the store is returned.
969 ///
970 /// # Errors
971 /// Returns [`StorageError`] if the resource configuration is invalid, the
972 /// backend cannot be opened, or its durable watermark cannot be read.
973 pub fn open(path: &std::path::Path) -> Result<Self, StorageError> {
974 Self::open_with_resources(path, resources::StorageResources::default())
975 }
976
977 /// Open a redb-backed store with an explicit resource configuration.
978 ///
979 /// # Errors
980 /// Returns [`StorageError`] if `config` is invalid, the shard backends
981 /// cannot be opened, or their durable watermarks cannot be read.
982 pub fn open_with_resources(
983 path: &std::path::Path,
984 config: resources::StorageResources,
985 ) -> Result<Self, StorageError> {
986 Self::open_with_deriver(path, config, None)
987 }
988
989 /// Open a redb-backed store with the apply-time deriver installed
990 /// *before* WAL recovery, so replayed commits derive the same rows live
991 /// publication would have.
992 ///
993 /// # Errors
994 /// Returns [`StorageError`] if `config` is invalid, the shard backends
995 /// cannot be opened, their watermarks cannot be read, or replay fails.
996 pub fn open_with_deriver(
997 path: &std::path::Path,
998 config: resources::StorageResources,
999 deriver: Option<Arc<dyn Deriver>>,
1000 ) -> Result<Self, StorageError> {
1001 let store = Self::new_with_resources(config)?;
1002 if let Some(deriver) = deriver {
1003 store.set_deriver(deriver);
1004 }
1005 store.attach_redb(path)?;
1006 Ok(store)
1007 }
1008
1009 fn attach_redb(&self, path: &std::path::Path) -> Result<(), StorageError> {
1010 self.check_health()?;
1011 let shard_count = self.resource_config.shard_count;
1012 let layout_info =
1013 layout::validate_layout(path, shard_count).map_err(StorageError::Durability)?;
1014 // Before any file is created, so the shard files and the WAL inherit
1015 // it (SCALE-2: checkpoint fsyncs on copy-on-write stall every tenant).
1016 if let Some(parent) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
1017 std::fs::create_dir_all(parent)
1018 .map_err(|error| StorageError::Durability(error.to_string()))?;
1019 }
1020 copy_on_write::opt_out(path);
1021 let cache_pool = self
1022 .resources
1023 .child("redb-cache", self.resource_config.page_cache_bytes)
1024 .map_err(|e| StorageError::ResourceExhausted(e.to_string()))?;
1025 let mut backends: Vec<Option<Arc<dyn StorageBackend>>> = Vec::with_capacity(shard_count);
1026 for shard in 0..shard_count {
1027 // Shard 0 keeps the historical single-file name; shard N uses a
1028 // suffixed file. With shard_count == 1 the layout is unchanged.
1029 let shard_path = if shard == 0 {
1030 path.to_path_buf()
1031 } else {
1032 let mut os = path.as_os_str().to_os_string();
1033 os.push(format!("-{shard}"));
1034 std::path::PathBuf::from(os)
1035 };
1036 // Split the configured page cache across physical shards.
1037 let shard_cache_bytes = self.resource_config.page_cache_bytes / shard_count;
1038 let cache = cache_pool
1039 .child("shard", shard_cache_bytes)
1040 .and_then(|pool| pool.try_reserve(shard_cache_bytes))
1041 .map_err(|e| StorageError::ResourceExhausted(e.to_string()))?;
1042 let backend =
1043 backend::open_backend(&shard_path, cache).map_err(StorageError::Durability)?;
1044 backends.push(Some(backend));
1045 }
1046 // Each shard file holds its tenants' data plus the global
1047 // log/outcome/last-applied rows for commits routed to it. The durable
1048 // LSN and GC floor are read for the watermark; the data/created/history
1049 // maps are NOT materialized: a redb-backed store reads natively
1050 // and stays memory-bounded regardless of dataset size.
1051 let mut gc_watermark = 0u64;
1052 let mut commit_index = 0u64;
1053 let mut materialization: Vec<u64> = Vec::with_capacity(shard_count);
1054 for backend in backends.iter().flatten() {
1055 let (lsn, floor) = backend.load_watermark().map_err(StorageError::Durability)?;
1056 materialization.push(lsn);
1057 gc_watermark = gc_watermark.max(floor);
1058 commit_index = commit_index.max(lsn);
1059 }
1060 // Log-first: open the segmented WAL (the durable truth) and replay any
1061 // tail beyond each shard's materialization.
1062 let (log, recovered) = self.open_log_and_replay(path, &backends, &materialization)?;
1063 commit_index = commit_index.max(recovered);
1064 self.gc_watermark.store(gc_watermark, Ordering::SeqCst);
1065 self.next_commit_index
1066 .store(commit_index + 1, Ordering::SeqCst);
1067 self.published.store(commit_index, Ordering::SeqCst);
1068 *self.health.guard(self.log.lock())? = Some(log);
1069 *self.health.guard(self.durable.write())? = backends.into_boxed_slice();
1070 *self.health.guard(self.data_path.lock())? = Some(path.to_path_buf());
1071 *self.health.guard(self.copy_on_write.lock())? = Some(copy_on_write::opt_out(path));
1072 // Idle compaction measures growth from the file as opened, not from
1073 // zero: with a zero baseline every boot compacted the whole file
1074 // under the publish lock (27 min at 10M records, SCALE-1).
1075 self.last_compacted_file_bytes
1076 .store(self.storage_file_bytes().unwrap_or(0), Ordering::Relaxed);
1077 if layout_info.is_none() {
1078 layout::create_manifest(path, shard_count).map_err(StorageError::Durability)?;
1079 }
1080 Ok(())
1081 }
1082
1083 /// The durable backend for a physical shard, if this store has one.
1084 fn backend(&self, shard: usize) -> Option<Arc<dyn StorageBackend>> {
1085 self.health
1086 .cleanup(self.durable.read())
1087 .get(shard)
1088 .and_then(std::clone::Clone::clone)
1089 }
1090
1091 /// Shard 0 backend (the global single-file layout).
1092 fn primary_backend(&self) -> Option<Arc<dyn StorageBackend>> {
1093 self.backend(0)
1094 }
1095
1096 /// The planner-statistics cache: idle eviction and metrics.
1097 #[must_use]
1098 pub fn planner_stats(&self) -> &PlannerStats {
1099 &self.planner_stats
1100 }
1101
1102 /// The persistent shared byte budget for logical caches, or `None`
1103 /// when `resources.logical-cache-bytes` is 0. Every retained cache/cursor
1104 /// reservation from this store contends on this same child.
1105 #[must_use]
1106 pub fn cache_budget(&self) -> Option<resources::ByteBudget> {
1107 self.logical_cache_resources.clone()
1108 }
1109
1110 /// Shared byte budget for active query/adapter phases: one persistent
1111 /// child of the resource tree, so concurrent requests share one cap.
1112 #[must_use]
1113 pub fn phase_budget(&self) -> Option<resources::ByteBudget> {
1114 self.phase_resources.clone()
1115 }
1116
1117 /// All open shard backends, in shard order.
1118 fn all_backends(&self) -> Vec<Arc<dyn StorageBackend>> {
1119 self.health
1120 .cleanup(self.durable.read())
1121 .iter()
1122 .flatten()
1123 .cloned()
1124 .collect()
1125 }
1126
1127 /// Run `op` on every shard backend and collect the results in shard order,
1128 /// with the first error (in shard order) winning. Shards are independent
1129 /// files, so with more than one shard the work runs concurrently — this is
1130 /// what makes multi-shard GC/compaction scale instead of serializing.
1131 fn for_each_backend_parallel<T, F>(&self, op: F) -> Result<Vec<T>, StorageError>
1132 where
1133 T: Send,
1134 F: Fn(&dyn StorageBackend) -> Result<T, String> + Sync,
1135 {
1136 self.check_health()?;
1137 let backends = self.all_backends();
1138 if backends.len() <= 1 {
1139 let mut out = Vec::with_capacity(backends.len());
1140 for backend in &backends {
1141 out.push(op(backend.as_ref()).map_err(StorageError::Durability)?);
1142 }
1143 return Ok(out);
1144 }
1145 std::thread::scope(|scope| {
1146 let handles: Vec<_> = backends
1147 .iter()
1148 .map(|backend| scope.spawn(|| op(backend.as_ref())))
1149 .collect();
1150 let mut out = Vec::with_capacity(handles.len());
1151 let mut first_err: Option<String> = None;
1152 for handle in handles {
1153 match handle.join() {
1154 Ok(Ok(value)) => out.push(value),
1155 Ok(Err(error)) => {
1156 if first_err.is_none() {
1157 first_err = Some(error);
1158 }
1159 }
1160 Err(_) => {
1161 if first_err.is_none() {
1162 first_err = Some("backend worker panicked".to_string());
1163 }
1164 }
1165 }
1166 }
1167 match first_err {
1168 Some(error) => Err(StorageError::Durability(error)),
1169 None => Ok(out),
1170 }
1171 })
1172 }
1173
1174 /// Acquire the admission lock, honoring the configured acquisition timeout.
1175 /// A bounded wait turns unbounded writer queueing into a retryable
1176 /// `resource exhausted` instead of an unbounded stall (config #4).
1177 fn acquire_admission(&self) -> Result<std::sync::MutexGuard<'_, SequencerState>, StorageError> {
1178 self.check_health()?;
1179 let timeout_ms = self.resource_config().lock_acquisition_timeout_ms;
1180 if timeout_ms == 0 {
1181 // FIFO: the turn is held until the mutex is acquired, so waiting
1182 // writers acquire admission in arrival order (no starvation).
1183 let _turn = self.admission_queue.enter();
1184 return self.health.guard(self.admission_lock.lock());
1185 }
1186 let started = std::time::Instant::now();
1187 let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
1188 loop {
1189 match self.admission_lock.try_lock() {
1190 Ok(guard) => {
1191 let waited = started.elapsed();
1192 if waited > std::time::Duration::ZERO {
1193 self.admission_waits.fetch_add(1, Ordering::Relaxed);
1194 self.admission_wait_ms.fetch_add(
1195 u64::try_from(waited.as_millis()).unwrap_or(u64::MAX),
1196 Ordering::Relaxed,
1197 );
1198 }
1199 return Ok(guard);
1200 }
1201 Err(std::sync::TryLockError::Poisoned(poisoned)) => {
1202 drop(poisoned);
1203 self.health.fence();
1204 return Err(StorageError::Unhealthy);
1205 }
1206 Err(std::sync::TryLockError::WouldBlock) => {
1207 if std::time::Instant::now() >= deadline {
1208 let waited = started.elapsed();
1209 self.admission_waits.fetch_add(1, Ordering::Relaxed);
1210 self.admission_wait_ms.fetch_add(
1211 u64::try_from(waited.as_millis()).unwrap_or(u64::MAX),
1212 Ordering::Relaxed,
1213 );
1214 return Err(StorageError::ResourceExhausted(
1215 "admission lock acquisition timed out".into(),
1216 ));
1217 }
1218 std::thread::sleep(std::time::Duration::from_millis(1));
1219 }
1220 }
1221 }
1222 }
1223
1224 /// Drop the in-flight prefix the published base now subsumes. Called at the
1225 /// top of every writer's admission critical section. It reads `published`
1226 /// once; if the publisher advances in between, the overlay just carries an
1227 /// already-published op redundantly until the next trim, which is harmless
1228 /// (the base holds the same value).
1229 fn trim_sequencer(&self, state: &mut SequencerState) {
1230 let published = self.published.load(Ordering::SeqCst);
1231 state.buffer.retain(|_, (ci, _)| *ci > published);
1232 }
1233
1234 /// Assemble the pending overlay (current committed base + in-flight writes)
1235 /// used to expand a command without waiting for its predecessors to
1236 /// publish. The base is the live committed view, opened here and dropped
1237 /// with the overlay, so no read pin outlives the commit. The caller must
1238 /// hold the admission guard that owns `state`.
1239 fn sequencer_overlay(&self, state: &SequencerState) -> Result<Arc<OverlayView>, StorageError> {
1240 let base = Arc::new(self.read_view()?);
1241 let mut buffer: BTreeMap<Vec<u8>, Option<Vec<u8>>> = BTreeMap::new();
1242 for (key, (_, value)) in &state.buffer {
1243 buffer.insert(key.clone(), value.clone());
1244 }
1245 Ok(Arc::new(OverlayView::with_buffer(base, buffer)))
1246 }
1247
1248 fn check_health(&self) -> Result<(), StorageError> {
1249 if self.data.is_poisoned()
1250 || self.created.is_poisoned()
1251 || self.history.is_poisoned()
1252 || self.publish_lock.is_poisoned()
1253 || self.admission_lock.is_poisoned()
1254 || self.pending.is_poisoned()
1255 || self.publisher_failure.is_poisoned()
1256 || self.read_pins.is_poisoned()
1257 || self.read_cancellations.is_poisoned()
1258 || self.expander.is_poisoned()
1259 || self.durable.is_poisoned()
1260 || self.log.is_poisoned()
1261 || self.replicator.is_poisoned()
1262 || self.gc_cursor.is_poisoned()
1263 || self.data_path.is_poisoned()
1264 || self.shards.iter().any(Mutex::is_poisoned)
1265 {
1266 self.health.fence();
1267 }
1268 self.health.check()
1269 }
1270
1271 fn check_publisher(&self) -> Result<(), StorageError> {
1272 self.check_health()?;
1273 match self.health.guard(self.publisher_failure.lock())?.clone() {
1274 Some(error) => Err(error),
1275 None => Ok(()),
1276 }
1277 }
1278
1279 /// Return the terminal publisher failure, if durable publication has been
1280 /// fenced. Readiness callers use this to avoid routing traffic to a store
1281 /// whose later commits cannot become durable.
1282 ///
1283 /// Lock poisoning fences the store until reopen.
1284 #[must_use]
1285 pub fn publisher_failure(&self) -> Option<String> {
1286 if let Err(error) = self.check_health() {
1287 return Some(error.to_string());
1288 }
1289 self.health
1290 .cleanup(self.publisher_failure.lock())
1291 .as_ref()
1292 .map(ToString::to_string)
1293 }
1294
1295 fn native_transaction_view(&self) -> Result<Option<redb_backend::MergedViews>, StorageError> {
1296 self.check_health()?;
1297 let backends = self.all_backends();
1298 if backends.is_empty() {
1299 return Ok(None);
1300 }
1301 let mut views: Vec<Box<dyn crate::backend::StorageReadView>> =
1302 Vec::with_capacity(backends.len());
1303 for backend in &backends {
1304 views.push(backend.read_view()?);
1305 }
1306 Ok(Some(redb_backend::MergedViews::new(views)))
1307 }
1308
1309 /// Install the deterministic expander used to derive canonical and
1310 /// derived keys for every committed batch.
1311 ///
1312 /// Lock poisoning fences the store until reopen.
1313 pub fn set_expander(&self, expander: Arc<ExpandFn>) {
1314 *self.health.cleanup(self.expander.write()) = Some(expander);
1315 }
1316
1317 /// Whether this store persists to a durable backend. A construction
1318 /// property (was a file opened?), not a property of the map contents.
1319 #[must_use]
1320 pub fn is_durable(&self) -> bool {
1321 self.health.cleanup(self.data_path.lock()).is_some()
1322 }
1323
1324 /// Install a replication seam (e.g. an `OpenRaft` leader). When set, local
1325 /// commits route through the leader instead of publishing locally.
1326 ///
1327 /// Lock poisoning fences the store until reopen.
1328 pub fn set_replicator(&self, replicator: Option<Arc<dyn Replicator>>) {
1329 *self.health.cleanup(self.replicator.write()) = replicator;
1330 }
1331
1332 fn replicator(&self) -> Option<Arc<dyn Replicator>> {
1333 self.health.cleanup(self.replicator.read()).clone()
1334 }
1335
1336 /// Return the replication watermark when the configured replicator can
1337 /// expose one. A missing watermark is deliberately fail-closed for
1338 /// retention: history is retained until coverage is observable.
1339 #[must_use]
1340 pub fn replication_acknowledged_commit(&self) -> Option<u64> {
1341 self.replicator()
1342 .and_then(|replicator| replicator.acknowledged_commit_index())
1343 }
1344
1345 /// Last durably persisted and applied commit index.
1346 pub fn committed_version(&self) -> u64 {
1347 self.published.load(Ordering::SeqCst)
1348 }
1349
1350 /// Public idempotency probe: durable commit index for a commit key.
1351 pub fn committed_index_for(&self, commit_key: Uuid) -> Option<u64> {
1352 self.lookup_outcome(commit_key)
1353 .filter(|(st, _)| matches!(st, OutcomeStatus::Committed))
1354 .map(|(_, ci)| ci)
1355 }
1356
1357 /// Public outcome probe for transaction coordinators: distinguishes a
1358 /// durable committed index from a durable aborted outcome.
1359 pub fn outcome_for(&self, commit_key: Uuid) -> Option<OutcomeKind> {
1360 self.lookup_outcome(commit_key).map(|(st, ci)| match st {
1361 OutcomeStatus::Committed => OutcomeKind::Committed(ci),
1362 OutcomeStatus::Aborted => OutcomeKind::Aborted,
1363 })
1364 }
1365
1366 /// Durably record an aborted outcome under a commit key (used by
1367 /// rollback so a later commit attempt discovers `aborted`, never false
1368 /// success). No data is published.
1369 ///
1370 /// # Errors
1371 /// Returns [`StorageError`] if the durable outcome write fails.
1372 ///
1373 /// Lock poisoning fences the store until reopen.
1374 pub fn record_aborted_outcome(&self, commit_key: Uuid) -> Result<(), StorageError> {
1375 self.check_health()?;
1376 let _admission = self.acquire_admission()?;
1377 self.record_aborted_outcome_admitted(commit_key)
1378 }
1379
1380 fn record_aborted_outcome_admitted(&self, commit_key: Uuid) -> Result<(), StorageError> {
1381 self.check_health()?;
1382 let _guard = self.health.guard(self.publish_lock.lock())?;
1383 if self.lookup_outcome_checked(commit_key)?.is_some() {
1384 return Ok(());
1385 }
1386 // An aborted tx consumes no commit index: the terminal aborted outcome
1387 // is keyed by the commit key and does not occupy a sequence slot, so
1388 // the contiguous publisher never has to fill a hole for it.
1389 self.record_outcome_locked(commit_key, OutcomeStatus::Aborted, 0)?;
1390 Ok(())
1391 }
1392
1393 /// Fallible latest-state point probe. Multi-read consumers must instead
1394 /// hold a `ReadView`. Write admission uses this under the logical writer.
1395 ///
1396 /// # Errors
1397 /// Returns [`StorageError`] if the publisher has failed or the native
1398 /// backend read fails.
1399 ///
1400 /// Lock poisoning fences the store until reopen.
1401 pub fn try_get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
1402 self.check_health()?;
1403 self.check_publisher()?;
1404 let native = self
1405 .native_transaction_view()?
1406 .ok_or(StorageError::Unhealthy)?;
1407 native.get_current(key, ReadLimits::default(), ScanProjection::KeyValue)
1408 }
1409
1410 /// Fallible outcome probe that distinguishes committed from aborted and
1411 /// surfaces corrupt durable state.
1412 ///
1413 /// # Errors
1414 /// Returns [`StorageError::CorruptLog`] if the stored outcome row is
1415 /// malformed, or a backend error if the read fails.
1416 pub fn try_outcome_for(&self, commit_key: Uuid) -> Result<Option<OutcomeKind>, StorageError> {
1417 self.check_health()?;
1418 Ok(self
1419 .lookup_outcome_checked(commit_key)?
1420 .map(|(status, ci)| match status {
1421 OutcomeStatus::Committed => OutcomeKind::Committed(ci),
1422 OutcomeStatus::Aborted => OutcomeKind::Aborted,
1423 }))
1424 }
1425
1426 /// Fallible exact-width `u64` read for engine callers.
1427 ///
1428 /// # Errors
1429 /// Returns [`StorageError::CorruptLog`] when a present value is not exactly
1430 /// eight bytes, or when the durable read fails.
1431 ///
1432 /// Lock poisoning fences the store until reopen.
1433 pub fn try_get_u64(&self, k: &[u8]) -> Result<Option<u64>, StorageError> {
1434 self.check_health()?;
1435 let bytes = self
1436 .native_transaction_view()?
1437 .ok_or(StorageError::Unhealthy)?
1438 .get_current(k, ReadLimits::default(), ScanProjection::KeyValue)?;
1439 let Some(bytes) = bytes else {
1440 return Ok(None);
1441 };
1442 let raw: [u8; 8] = bytes
1443 .as_slice()
1444 .try_into()
1445 .map_err(|_| StorageError::CorruptLog)?;
1446 Ok(Some(u64::from_be_bytes(raw)))
1447 }
1448
1449 /// Fallible historical prefix scan for engine callers.
1450 ///
1451 /// # Errors
1452 /// Returns backend or read-limit failures.
1453 ///
1454 /// Lock poisoning fences the store until reopen.
1455 pub fn try_range_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> {
1456 self.check_health()?;
1457 let view = self
1458 .native_transaction_view()?
1459 .ok_or(StorageError::Unhealthy)?;
1460 let (s, e) = prefix_range(prefix);
1461 let rows: Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> = view
1462 .current_range(&s, &e, ReadLimits::default(), ScanProjection::KeyValue)?
1463 .collect();
1464 rows
1465 }
1466
1467 /// Fallible historical point read for engine callers.
1468 ///
1469 /// # Errors
1470 /// Returns backend or read-limit failures.
1471 ///
1472 /// Lock poisoning fences the store until reopen.
1473 pub fn try_get_at(&self, k: &[u8], snap: u64) -> Result<Option<Vec<u8>>, StorageError> {
1474 self.check_health()?;
1475 self.native_transaction_view()?
1476 .ok_or(StorageError::Unhealthy)?
1477 .get_at(k, snap, ReadLimits::default(), ScanProjection::KeyValue)
1478 }
1479
1480 /// Fallible historical prefix scan for engine callers.
1481 ///
1482 /// # Errors
1483 /// Returns backend or read-limit failures.
1484 ///
1485 /// Lock poisoning fences the store until reopen.
1486 pub fn try_range_prefix_at(
1487 &self,
1488 prefix: &[u8],
1489 snap: u64,
1490 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> {
1491 self.check_health()?;
1492 // Read directly through the backend view at `snap` (like `try_get_at`):
1493 // this is a fallible legacy probe, not the pinned `ReadView` path. It
1494 // therefore does not require the store's published watermark to have
1495 // caught up, which raw-apply test/recovery callers rely on.
1496 let view = self
1497 .native_transaction_view()?
1498 .ok_or(StorageError::Unhealthy)?;
1499 let limits = ReadLimits::default();
1500 let (s, e) = prefix_range(prefix);
1501 let mut lower = Included(s);
1502 let upper = if e.is_empty() {
1503 Unbounded
1504 } else {
1505 std::ops::Bound::Excluded(e)
1506 };
1507 let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
1508 let mut bytes = 0usize;
1509 while let Some(key) = view.next_key(&lower, &upper, limits, true)? {
1510 if let Some(value) = view.get_at(&key, snap, limits, ScanProjection::KeyValue)? {
1511 bytes = bytes.saturating_add(key.len() + value.len());
1512 if bytes > 64 * 1024 * 1024 {
1513 return Err(StorageError::ResourceExhausted(
1514 "materialized read exceeds configured limit; use a cursor".into(),
1515 ));
1516 }
1517 out.push((key.clone(), value));
1518 }
1519 lower = std::ops::Bound::Excluded(key);
1520 }
1521 Ok(out)
1522 }
1523
1524 /// Advances retention: superseded versions covered before the watermark
1525 /// are reclaimed; versions still needed by any newer snapshot stay.
1526 ///
1527 /// # Errors
1528 /// Returns [`StorageError`] if the durable reclamation fails.
1529 ///
1530 /// Lock poisoning fences the store until reopen.
1531 pub fn retire_before(&self, watermark: u64) -> Result<usize, StorageError> {
1532 self.check_health()?;
1533 let _publisher = self.health.guard(self.publish_lock.lock())?;
1534 self.retire_before_locked(watermark)
1535 }
1536
1537 /// Bounded, resumable GC: reclaim history superseded before `watermark`
1538 /// for at most `max_keys` per shard in this call. Returns the next key to
1539 /// resume from (or `None` when every shard finished the walk). A
1540 /// background driver calls this repeatedly; it never performs one
1541 /// monolithic redb txn.
1542 ///
1543 /// # Errors
1544 /// Returns [`StorageError`] if the publisher has failed or a shard's
1545 /// durable reclamation fails.
1546 ///
1547 /// # Panics
1548 /// Panics if the read-pin mutex or history `RwLock` is poisoned by a
1549 /// previous panic.
1550 #[expect(
1551 clippy::needless_pass_by_value,
1552 reason = "public signature is a compatibility contract; the cursor is cloned per shard so taking it by value cannot change behavior"
1553 )]
1554 pub fn retire_before_batch(
1555 &self,
1556 watermark: u64,
1557 cursor: Option<Vec<u8>>,
1558 max_keys: usize,
1559 ) -> Result<Option<Vec<u8>>, StorageError> {
1560 self.check_health()?;
1561 self.check_publisher()?;
1562 // Clamp identically to the monolithic path: never reclaim a version a
1563 // pinned read may still need, never advance the floor past the
1564 // committed version, and never move the floor backwards. The clamp is
1565 // taken under `read_pins`, the same lock reads use for their floor
1566 // check and pin insert, so no read can admit a snapshot below the new
1567 // floor.
1568 let watermark = {
1569 let pins = self.health.guard(self.read_pins.lock())?;
1570 let watermark = watermark.min(self.committed_version());
1571 let watermark = pins
1572 .first_key_value()
1573 .map_or(watermark, |(ci, _)| watermark.min(*ci));
1574 let watermark = watermark.max(self.gc_watermark());
1575 // Publish the floor *while holding* `read_pins`. A reader that
1576 // acquires the lock after this sees the new floor and is rejected;
1577 // a reader that acquired it before this is already reflected in
1578 // `pins`, so the clamp cannot be below its snapshot. Storing after
1579 // unlocking would let a reader admit a below-floor snapshot that
1580 // GC is about to reclaim.
1581 self.gc_watermark
1582 .store(watermark, std::sync::atomic::Ordering::SeqCst);
1583 watermark
1584 };
1585 // Reclaim holds no read lock, so readers are never blocked behind a GC
1586 // write transaction.
1587 // A single cursor is shared across shards. Resume from the smallest
1588 // per-shard cursor so no shard skips keys, and treat the sweep as
1589 // complete only when every shard reports no further work.
1590 let mut last: Option<Vec<u8>> = None;
1591 let mut all_done = true;
1592 for backend in self.all_backends() {
1593 if let Some(k) = backend
1594 .retire_before_batch(watermark, cursor.clone(), max_keys)
1595 .map_err(StorageError::Durability)?
1596 {
1597 all_done = false;
1598 last = Some(match last {
1599 Some(prev) if prev <= k => prev,
1600 _ => k,
1601 });
1602 }
1603 }
1604 if all_done {
1605 // Full walk completed on every shard: apply the same in-memory
1606 // retention the monolithic path applies after durable reclamation.
1607 let mut hist = self.health.guard(self.history.write())?;
1608 for entries in hist.values_mut() {
1609 entries.retain(|e| e.to_ci > watermark || e.to_ci == 0);
1610 }
1611 hist.retain(|_, v| !v.is_empty());
1612 }
1613 Ok(last)
1614 }
1615
1616 /// One tick of the background GC loop: reclaim a bounded batch toward
1617 /// the current committed watermark. Returns `true` when the whole history
1618 /// table was swept this tick (a full GC pass completed and a checkpoint
1619 /// was published per shard), `false` when the sweep is mid-walk and the
1620 /// caller should tick again. Safe to call concurrently: each tick is a
1621 /// small redb write transaction.
1622 ///
1623 /// # Errors
1624 /// Returns [`StorageError`] if the publisher has failed or a shard's
1625 /// durable reclamation fails.
1626 ///
1627 /// Lock poisoning fences the store until reopen.
1628 pub fn gc_tick(&self, max_keys: usize) -> Result<bool, StorageError> {
1629 self.check_health()?;
1630 let watermark = self.committed_version();
1631 let cursor = self.health.guard(self.gc_cursor.lock())?.clone();
1632 let next = self.retire_before_batch(watermark, cursor, max_keys)?;
1633 if let Some(k) = next {
1634 *self.health.guard(self.gc_cursor.lock())? = Some(k);
1635 Ok(false)
1636 } else {
1637 // Sweep complete: reset the cursor so the next pass starts
1638 // from the beginning (new histories are reclaimed too).
1639 *self.health.guard(self.gc_cursor.lock())? = None;
1640 Ok(true)
1641 }
1642 }
1643
1644 /// [`Self::gc_tick`] in batches of `max_keys` until the history sweep
1645 /// completes or `budget` has elapsed, so reclamation keeps up with the
1646 /// write rate instead of retiring a fixed number of keys per maintenance
1647 /// tick (SOAK-1: 512 keys per 5 s fell behind ~750 rewrites/s and history
1648 /// grew without bound). Returns whether the sweep completed.
1649 ///
1650 /// # Errors
1651 /// See [`Self::gc_tick`].
1652 pub fn gc_tick_for(
1653 &self,
1654 max_keys: usize,
1655 budget: std::time::Duration,
1656 ) -> Result<bool, StorageError> {
1657 let started = std::time::Instant::now();
1658 loop {
1659 if self.gc_tick(max_keys)? {
1660 return Ok(true);
1661 }
1662 if started.elapsed() >= budget {
1663 return Ok(false);
1664 }
1665 }
1666 }
1667
1668 fn retire_before_locked(&self, watermark: u64) -> Result<usize, StorageError> {
1669 self.check_health()?;
1670 self.check_publisher()?;
1671 let watermark = {
1672 let pins = self.health.guard(self.read_pins.lock())?;
1673 let watermark = watermark.min(self.committed_version());
1674 let watermark = pins
1675 .first_key_value()
1676 .map_or(watermark, |(ci, _)| watermark.min(*ci));
1677 let watermark = watermark.max(self.gc_watermark());
1678 // Fence reads below the new floor while still holding `read_pins`
1679 // (see `retire_before_batch`). A failed durable reclamation leaves
1680 // the floor advanced — a conservative, fail-closed state — never
1681 // the reverse.
1682 self.gc_watermark
1683 .store(watermark, std::sync::atomic::Ordering::SeqCst);
1684 watermark
1685 };
1686 // Prune the in-memory maps first so the reclaimed-version count is
1687 // observable (the durable maps are empty; the backend does its own
1688 // reclamation below). `MemoryBackend::retire_before` then re-prunes the
1689 // same maps idempotently.
1690 let mut removed = 0usize;
1691 {
1692 let mut hist = self.health.guard(self.history.write())?;
1693 for entries in hist.values_mut() {
1694 let before = entries.len();
1695 entries.retain(|e| e.to_ci > watermark || e.to_ci == 0);
1696 removed += before - entries.len();
1697 }
1698 hist.retain(|_, v| !v.is_empty());
1699 }
1700 self.for_each_backend_parallel(|backend| backend.retire_before(watermark))?;
1701 Ok(removed)
1702 }
1703
1704 /// Oldest supported logical commit index, inclusive. New historical views
1705 /// below this floor fail, even if an individual key still has older bytes.
1706 pub fn gc_watermark(&self) -> u64 {
1707 self.gc_watermark.load(std::sync::atomic::Ordering::SeqCst)
1708 }
1709
1710 /// Expire retained read leases older than `max_age_ms` (P06: reader age
1711 /// limits with safe cancellation-before-release). An abandoned reader that
1712 /// never drops its view cannot block history advancement forever; the GC
1713 /// worker calls this so the floor can move. Returns the count of leases
1714 /// released (CI entries whose remaining pin count reached zero).
1715 ///
1716 /// Lock poisoning fences the store until reopen.
1717 pub fn expire_read_pins(&self, max_age_ms: u64) -> usize {
1718 if self.check_health().is_err() {
1719 return 0;
1720 }
1721 let now = wal::now_ms();
1722 let mut pins = self.health.cleanup(self.read_pins.lock());
1723 let mut cancellations = self.health.cleanup(self.read_cancellations.lock());
1724 let mut released = 0usize;
1725 pins.retain(|ci, pin| {
1726 if now.saturating_sub(pin.lease_ms) > max_age_ms {
1727 if let Some(handles) = cancellations.remove(ci) {
1728 for handle in handles {
1729 handle.cancel();
1730 }
1731 }
1732 released += 1;
1733 false
1734 } else {
1735 true
1736 }
1737 });
1738 released
1739 }
1740
1741 /// Observability: the oldest active pin (inclusive CI floor) and how
1742 /// many distinct CIs are retained. Lets operators see long-reader retention
1743 /// and confirm it releases after handles close.
1744 ///
1745 /// Lock poisoning fences the store until reopen.
1746 pub fn retention_summary(&self) -> (Option<u64>, usize) {
1747 let pins = self.health.cleanup(self.read_pins.lock());
1748 (pins.first_key_value().map(|(ci, _)| *ci), pins.len())
1749 }
1750
1751 /// Number of keys in the sequencer's assigned-but-unpublished overlay.
1752 /// Observability for the single-writer in-flight window; returns 0
1753 /// when the admission lock is poisoned rather than panicking a scrape.
1754 #[must_use]
1755 pub fn sequencer_inflight_keys(&self) -> usize {
1756 self.admission_lock
1757 .lock()
1758 .map_or(0, |state| state.buffer.len())
1759 }
1760
1761 /// Reclaim versions superseded at or before the requested watermark,
1762 /// clamped by protected read views and the committed CI. Legacy unpinned
1763 /// readers must still coordinate their own retention. Returns reclaimed
1764 /// version count; canonical current data and command logs are unchanged.
1765 ///
1766 /// # Errors
1767 /// Returns [`StorageError`] if the durable reclamation fails.
1768 pub fn gc(&self, watermark: u64) -> Result<usize, StorageError> {
1769 self.check_health()?;
1770 self.retire_before(watermark)
1771 }
1772
1773 fn record_outcome_locked(
1774 &self,
1775 commit_key: Uuid,
1776 status: OutcomeStatus,
1777 ci: u64,
1778 ) -> Result<(), StorageError> {
1779 self.check_health()?;
1780 let ops: Vec<Op> = receipt::rows(commit_key, status, ci, self.receipt_now(), None)
1781 .into_iter()
1782 .map(|(key, value)| Op::Put(key, value))
1783 .collect();
1784 // Aborted-outcome idempotency markers are local metadata, not part of
1785 // the committed log sequence (they carry no commit index), so they are
1786 // written to the materialization directly rather than the WAL.
1787 self.persist_ops_local(&ops, ci)
1788 }
1789
1790 /// Current time of the receipt clock (unix ms).
1791 fn receipt_now(&self) -> u64 {
1792 (self.health.cleanup(self.receipt_clock.read()).clone())()
1793 }
1794
1795 /// Replace the receipt clock (tests age receipts with a mock clock).
1796 #[doc(hidden)]
1797 pub fn set_receipt_clock(&self, clock: Arc<dyn Fn() -> u64 + Send + Sync>) {
1798 *self.health.cleanup(self.receipt_clock.write()) = clock;
1799 }
1800
1801 /// Stage the receipt of `commit_key` in `txn`.
1802 fn put_receipt(
1803 &self,
1804 txn: &mut Txn<'_>,
1805 commit_key: Uuid,
1806 status: OutcomeStatus,
1807 ci: u64,
1808 payload: Option<&[u8]>,
1809 ) {
1810 for (key, value) in receipt::rows(commit_key, status, ci, self.receipt_now(), payload) {
1811 txn.put(key, value);
1812 }
1813 }
1814
1815 /// The live receipt of `commit_key`: `None` when there is none or it is
1816 /// past the idempotency window (a retry then executes as a new commit).
1817 fn live_receipt(&self, commit_key: Uuid) -> Result<Option<receipt::Receipt>, StorageError> {
1818 let Some(raw) = self.try_get(&receipt::receipt_key(commit_key))? else {
1819 return Ok(None);
1820 };
1821 let receipt = receipt::decode(&raw)?;
1822 let window_ms = self
1823 .resource_config
1824 .idempotency_window_seconds
1825 .saturating_mul(1000);
1826 Ok((!receipt.expired(self.receipt_now(), window_ms)).then_some(receipt))
1827 }
1828
1829 /// Apply an op-set to the materialization without a WAL record. Used only
1830 /// for local idempotency metadata that is not part of the commit log.
1831 fn persist_ops_local(&self, ops: &[Op], commit_index: u64) -> Result<(), StorageError> {
1832 self.check_health()?;
1833 self.check_publisher()?;
1834 if let Some(backend) = self.primary_backend() {
1835 backend
1836 .apply(ops, commit_index)
1837 .map_err(StorageError::Durability)?;
1838 }
1839 Ok(())
1840 }
1841
1842 fn lookup_outcome(&self, commit_key: Uuid) -> Option<(OutcomeStatus, u64)> {
1843 self.lookup_outcome_checked(commit_key).ok().flatten()
1844 }
1845
1846 fn lookup_outcome_checked(
1847 &self,
1848 commit_key: Uuid,
1849 ) -> Result<Option<(OutcomeStatus, u64)>, StorageError> {
1850 self.check_health()?;
1851 Ok(self
1852 .live_receipt(commit_key)?
1853 .map(|receipt| (receipt.status, receipt.commit_index)))
1854 }
1855
1856 /// The stored response of a committed `commit_key` (its retry answer).
1857 fn committed_payload<P: serde::de::DeserializeOwned>(
1858 &self,
1859 commit_key: Uuid,
1860 ) -> Result<P, StorageError> {
1861 let payload = self
1862 .live_receipt(commit_key)?
1863 .and_then(|receipt| receipt.payload)
1864 .ok_or(StorageError::OutcomePayload)?;
1865 serde_json::from_slice(&payload).map_err(|_| StorageError::OutcomePayload)
1866 }
1867
1868 /// Rebuilds state by replaying the full serialized command log through
1869 /// the registered deterministic expander.
1870 ///
1871 /// # Errors
1872 /// Returns [`StorageError::CorruptLog`] for malformed input or missing
1873 /// expansion support, and [`StorageError::Builder`] when deterministic
1874 /// expansion rejects an entry. No replayed state is published on decode or
1875 /// expansion failure.
1876 ///
1877 /// # Panics
1878 /// Panics only if an internal lock is poisoned by a previous panic.
1879 pub fn try_replay(&self, log: &[Vec<u8>]) -> Result<(), StorageError> {
1880 self.check_health()?;
1881 // A replay input is a committed command log, so silently dropping a
1882 // malformed entry would renumber every later commit and produce a
1883 // state that looks valid while diverging from the durable history.
1884 // Recovery callers that intentionally trim a torn tail must do so
1885 // before invoking replay (as the WAL reader does).
1886 let entries: Vec<CommitBatch> = log
1887 .iter()
1888 .map(|raw| {
1889 rushdb_types::command_codec::decode(raw).map_err(|_| StorageError::CorruptLog)
1890 })
1891 .collect::<Result<_, _>>()?;
1892 let expander = self.health.guard(self.expander.read())?.clone();
1893 let mut prepared = Vec::with_capacity(entries.len());
1894 // Expansion of a later command may legitimately depend on canonical
1895 // state produced by an earlier command (for example, a text
1896 // watermark refers to a profile created in the preceding commit).
1897 // Build a private shadow map while preparing the complete replay so
1898 // those dependencies are visible without publishing partial state.
1899 let mut shadow = self.health.guard(self.data.read())?.clone();
1900
1901 for (i, batch) in entries.iter().enumerate() {
1902 let ci = i as u64 + 1;
1903 let Some(expand) = expander.as_ref() else {
1904 return Err(StorageError::CorruptLog);
1905 };
1906 let ops;
1907 {
1908 // Full command replay still builds the reference state, so
1909 // the base is always the live shadow map, never native.
1910 let mut txn = Txn {
1911 base: TxnBase::Live(&shadow),
1912 buffer: BTreeMap::new(),
1913 purge_keys: BTreeSet::default(),
1914 set_keys: BTreeSet::default(),
1915 watermark_keys: BTreeSet::default(),
1916 ids: &self.ids,
1917 deps: None,
1918 };
1919 expand(ci, batch, &mut txn).map_err(StorageError::Builder)?;
1920 self.put_receipt(
1921 &mut txn,
1922 batch.commit_key,
1923 OutcomeStatus::Committed,
1924 ci,
1925 None,
1926 );
1927 txn.set(LAST_APPLIED_KEY.to_vec(), wal::encode_last_applied(0, ci));
1928 ops = txn.drain();
1929 }
1930 // Command replay expands against a base-only shadow; fold the
1931 // derived rows in too, so an expander that reads derived counters
1932 // (DeleteFamily) sees what a live expansion would.
1933 let derived =
1934 Self::derive_ops(self.deriver().as_ref(), ci, &ops, &derive::MapRead(&shadow))?;
1935 for op in derived.as_deref().unwrap_or(&ops) {
1936 match op {
1937 Op::Put(key, value) | Op::Set(key, value) => {
1938 shadow.insert(key.clone(), value.clone());
1939 }
1940 Op::Del(key) | Op::Purge(key) => {
1941 shadow.remove(key);
1942 }
1943 }
1944 }
1945 prepared.push((ci, ops, rushdb_types::command_codec::encode(batch)));
1946 }
1947 for (ci, ops, command_payload) in prepared {
1948 // Apply through the backend: one write path for memory and
1949 // durable replay; `persist_ops` derives the same derived rows the
1950 // live publisher would, never shipped over the wire.
1951 self.persist_ops(&ops, ci, Some(&command_payload))?;
1952 self.health.guard(self.conflicts.lock())?.record(ci, &ops);
1953 }
1954 {
1955 let applied = entries.len() as u64;
1956 self.next_commit_index.store(applied + 1, Ordering::SeqCst);
1957 self.published.store(applied, Ordering::SeqCst);
1958 self.last_commit_ms.store(wal::now_ms(), Ordering::Relaxed);
1959 self.persist_ops(
1960 &[Op::Set(
1961 LAST_APPLIED_KEY.to_vec(),
1962 wal::encode_last_applied(0, applied),
1963 )],
1964 applied,
1965 None,
1966 )?;
1967 }
1968 Ok(())
1969 }
1970
1971 /// Apply a command batch already committed by `OpenRaft`. This bypasses
1972 /// admission-time planning but uses exactly the same deterministic
1973 /// expander and durable publication path as a local commit.
1974 ///
1975 /// # Errors
1976 /// Returns [`StorageError`] if the publisher has failed, the batch has a
1977 /// terminal aborted outcome, or expansion/publication fails.
1978 pub fn apply_replicated_batch(&self, batch: &CommitBatch) -> Result<u64, StorageError> {
1979 self.check_health()?;
1980 self.apply_replicated_batch_with_payload(batch, None)
1981 }
1982
1983 /// Apply a replicated batch and durably store its serialized payload with
1984 /// the outcome so idempotent retries on the leader reproduce the original
1985 /// caller response.
1986 ///
1987 /// # Errors
1988 /// Returns [`StorageError`] if the publisher has failed, no expander is
1989 /// installed, the batch has a terminal aborted outcome, or publication
1990 /// fails.
1991 ///
1992 /// # Panics
1993 /// Panics only if `batch` fails to serialize (never, for a well-formed
1994 /// `CommitBatch`). Lock poisoning fences the store until reopen.
1995 pub fn apply_replicated_batch_with_payload(
1996 &self,
1997 batch: &CommitBatch,
1998 payload: Option<&[u8]>,
1999 ) -> Result<u64, StorageError> {
2000 self.check_health()?;
2001 let _admission = self.acquire_admission()?;
2002 self.check_publisher()?;
2003 if let Some((OutcomeStatus::Committed, commit_index)) =
2004 self.lookup_outcome_checked(batch.commit_key)?
2005 {
2006 return Ok(commit_index);
2007 }
2008
2009 if matches!(
2010 self.try_outcome_for(batch.commit_key)?,
2011 Some(OutcomeKind::Aborted)
2012 ) {
2013 return Err(StorageError::Builder(
2014 "commit key has a terminal aborted outcome".into(),
2015 ));
2016 }
2017 let commit_index = self.next_commit_index.load(Ordering::SeqCst);
2018 let ops = {
2019 let guard = self.health.guard(self.data.read())?;
2020 let native = self.native_transaction_view()?;
2021 let base = TxnBase::resolve(None, None, native, Some(&guard));
2022 let mut txn = Txn {
2023 base,
2024 buffer: BTreeMap::new(),
2025 purge_keys: BTreeSet::default(),
2026 set_keys: BTreeSet::default(),
2027 watermark_keys: BTreeSet::default(),
2028 ids: &self.ids,
2029 deps: None,
2030 };
2031 let expander = self.health.guard(self.expander.read())?.clone();
2032 let Some(expand) = expander.as_ref() else {
2033 return Err(StorageError::CorruptLog);
2034 };
2035 expand(commit_index, batch, &mut txn).map_err(StorageError::Builder)?;
2036 self.put_receipt(
2037 &mut txn,
2038 batch.commit_key,
2039 OutcomeStatus::Committed,
2040 commit_index,
2041 payload,
2042 );
2043 txn.set(
2044 LAST_APPLIED_KEY.to_vec(),
2045 wal::encode_last_applied(0, commit_index),
2046 );
2047 txn.drain()
2048 };
2049 // Large transactions: the same ceiling `commit_sharded`
2050 // enforces before its own CAS reservation (see `commit.rs`) applies
2051 // here too, before `next_commit_index` advances — this path has its
2052 // own, separate reservation (no CAS loop; a replicated batch's index
2053 // is assigned directly from the leader's already-ordered stream).
2054 // Operationally this means a follower configured with a *smaller*
2055 // ceiling than the leader will stall (loudly, retried forever by
2056 // `replication.rs`'s follower loop) on a commit the leader already
2057 // accepted, rather than silently diverging or truncating it —
2058 // deliberately: this is a config-mismatch to fix, not a case to
2059 // paper over. Chaining itself doesn't need this check to write a
2060 // large commit correctly; it's policy consistency, not correctness.
2061 let command_payload = rushdb_types::command_codec::encode(batch);
2062 let expanded_bytes = wal::op_set_encoded_len(&ops).saturating_add(command_payload.len());
2063 if expanded_bytes > self.resource_config.max_expanded_commit_bytes {
2064 return Err(StorageError::ResourceExhausted(format!(
2065 "expanded mutation batch exceeds {} bytes (got {expanded_bytes})",
2066 self.resource_config.max_expanded_commit_bytes
2067 )));
2068 }
2069 self.next_commit_index
2070 .store(commit_index + 1, Ordering::SeqCst);
2071 let shard =
2072 resources::physical_shard_for(Some(&batch.tenant), self.resource_config.shard_count);
2073 self.enqueue_and_publish(commit_index, shard, ops, command_payload)?;
2074 Ok(commit_index)
2075 }
2076}