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