sochdb_storage/durable_storage.rs
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! Durable Storage Layer
19//!
20//! Wires together the live storage components into a durable engine:
21//!
22//! - WAL (txn_wal.rs) for crash-consistent durability + recovery
23//! - Group Commit for throughput
24//! - MVCC for isolation
25//! - LSCS for columnar efficiency
26//!
27//! Truth-in-capabilities: the live path provides crash-consistent WAL recovery,
28//! but NOT at-rest encryption, point-in-time recovery, ARIES checkpointing, or
29//! WAL fencing — those modules exist but are quarantined behind the empty,
30//! non-default `experimental` feature and are unwired. Query
31//! [`crate::durability_capabilities`] rather than relying on prose like
32//! "production-grade".
33//!
34//! ## Architecture
35//!
36//! ```text
37//! ┌─────────────────────────────────────────────────────────────────┐
38//! │ DurableStorage │
39//! ├─────────────────────────────────────────────────────────────────┤
40//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
41//! │ │ MvccManager │ │ GroupCommit │───▶│ TxnWal (fsync) │ │
42//! │ │ │ │ │ └─────────────────────┘ │
43//! │ │ ┌─────────┐ │ └─────────────┘ │
44//! │ │ │Snapshots│ │ │
45//! │ │ └─────────┘ │ ┌─────────────────────────────────────────┐│
46//! │ │ ┌─────────┐ │ │ MemTable ││
47//! │ │ │ Txn Map │ │ │ (key → (value, txn_id, version)) ││
48//! │ │ └─────────┘ │ └─────────────────────────────────────────┘│
49//! │ └─────────────┘ │
50//! │ ┌─────────────────────────────────────────┐│
51//! │ │ LSCS (SST) ││
52//! │ │ Immutable columnar segments ││
53//! │ └─────────────────────────────────────────┘│
54//! └─────────────────────────────────────────────────────────────────┘
55//! ```
56//!
57//! ## Concurrency
58//!
59//! - Writers: Serialize through WAL, use MVCC for conflict detection
60//! - Readers: Lock-free reads at snapshot timestamp
61//! - Commits: Batched through GroupCommit for throughput
62//!
63//! ## Isolation Contract
64//!
65//! The live write path (`MvccManager`, used by `DurableStorage`) provides
66//! **Serializable Snapshot Isolation (SSI)**, which is strictly stronger than
67//! plain Snapshot Isolation (SI):
68//!
69//! - **Snapshot reads.** Every transaction reads from a consistent snapshot
70//! fixed at `begin_transaction()` (its `snapshot_ts`). Concurrent commits are
71//! invisible to it — see `test_snapshot_isolation`. This alone is SI and is
72//! vulnerable to **write skew** (two transactions each read a set the other
73//! writes, both commit, and no serial order reproduces the result).
74//! - **Write-skew prevention.** On commit, `MvccManager::validate_ssi` inspects
75//! recently-committed concurrent transactions for rw-antidependency edges:
76//! an inbound edge (another txn wrote a key we read, `T_other →rw→ T_me`) and
77//! an outbound edge (we wrote a key another txn read, `T_me →rw→ T_other`).
78//! When a transaction sits on **both** an inbound and an outbound rw-edge it
79//! is the pivot of a potential dependency cycle (Cahill/Fekete "dangerous
80//! structure"), so it is aborted. This is the conservative safe subset of the
81//! SSI test: it may abort some serializable schedules (false positives) but
82//! **never admits a non-serializable one** (no false negatives), so the
83//! externally observable isolation level is Serializable.
84//! - **Read-only transactions** (`begin_read_only`) skip read-set tracking and
85//! never participate in validation; they always observe a serializable
86//! snapshot and never abort.
87//!
88//! ### MVCC garbage collection is snapshot-safe
89//!
90//! Old versions are pruned by `DurableStorage::gc()`, which feeds the
91//! **low-water-mark** `MvccManager::min_active_snapshot()` — the minimum
92//! `snapshot_ts` across all still-active transactions — into
93//! `MvccMemTable::gc()` and then `BinarySearchChain::gc_by_ts()`. The chain
94//! retains every version with `commit_ts > min_active_ts` **plus one anchor
95//! version at or below the watermark**, so any in-flight reader can still
96//! resolve the correct version for its snapshot. A version is only freed once
97//! **no active snapshot can observe it**. The watermark is recomputed on every
98//! `begin`/`commit`/`abort`, so it is monotonic with respect to the oldest live
99//! reader. See `test_gc_preserves_versions_for_active_snapshot`.
100
101use std::collections::HashSet;
102use std::path::{Path, PathBuf};
103use std::sync::Arc;
104use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
105
106use dashmap::DashMap;
107use smallvec::SmallVec;
108
109use crossbeam_skiplist::SkipMap;
110
111use crate::DurabilityCapabilities;
112use crate::deferred_index::{DeferredIndexConfig, DeferredSortedIndex};
113use crate::encryption::EncryptionKey;
114use crate::group_commit::EventDrivenGroupCommit;
115use crate::keyring;
116use crate::txn_wal::{TxnWal, TxnWalBuffer, TxnWalEntry};
117use sochdb_core::version_chain::{
118 BinarySearchChain, ChainEntry, MvccVersionChain, MvccVersionChainMut, Timestamp, TxnId,
119 VisibilityContext, WriteConflictDetection,
120};
121use sochdb_core::{Result, SochDBError};
122
123// =============================================================================
124// SSI Bloom Filter - Fast Conflict Pre-Filtering
125// =============================================================================
126
127/// Space-efficient Bloom filter for SSI conflict detection
128///
129/// Used to quickly determine if two transactions MIGHT have conflicting keys.
130/// False positives are acceptable (leads to unnecessary exact checks),
131/// but false negatives are not allowed.
132///
133/// ## Configuration
134///
135/// For 1000 keys with 1% false positive rate:
136/// - m = ~9600 bits ≈ 1.2 KB per transaction
137/// - k = 7 hash functions
138///
139/// ## Lazy Initialization
140///
141/// The bit vector is lazily initialized on first insert to avoid
142/// allocation overhead for read-only transactions.
143#[derive(Clone, Debug)]
144pub struct SsiBloomFilter {
145 /// Bit vector (each u64 holds 64 bits) - lazily initialized
146 bits: Option<Vec<u64>>,
147 /// Expected capacity (used for lazy init sizing)
148 expected_capacity: usize,
149 /// Number of hash functions to use
150 num_hashes: u32,
151}
152
153impl SsiBloomFilter {
154 /// Optimal number of bits per item for 1% false positive rate
155 /// m/n = -ln(p) / (ln(2))² ≈ 9.6 for p = 0.01
156 const BITS_PER_ITEM: f64 = 9.6;
157
158 /// Optimal number of hash functions for 1% false positive rate
159 /// k = (m/n) × ln(2) ≈ 7
160 const DEFAULT_NUM_HASHES: u32 = 7;
161
162 /// Minimum capacity to avoid tiny filters
163 const MIN_CAPACITY: usize = 64;
164
165 /// Create a new bloom filter for expected item count (lazy allocation)
166 ///
167 /// Configured for ~1% false positive rate.
168 /// The bit vector is not allocated until first insert.
169 #[inline]
170 pub fn new(expected_items: usize) -> Self {
171 Self {
172 bits: None,
173 expected_capacity: expected_items.max(Self::MIN_CAPACITY),
174 num_hashes: Self::DEFAULT_NUM_HASHES,
175 }
176 }
177
178 /// Create with specific capacity in words (for memory-constrained scenarios)
179 pub fn with_word_capacity(words: usize) -> Self {
180 Self {
181 bits: None,
182 expected_capacity: words.max(1) * 64 / 10, // Approx items from words
183 num_hashes: Self::DEFAULT_NUM_HASHES,
184 }
185 }
186
187 /// Ensure bits are allocated (lazy initialization)
188 #[inline]
189 fn ensure_allocated(&mut self) {
190 if self.bits.is_none() {
191 let num_bits = ((self.expected_capacity as f64) * Self::BITS_PER_ITEM).ceil() as usize;
192 let num_words = num_bits.div_ceil(64);
193 self.bits = Some(vec![0u64; num_words]);
194 }
195 }
196
197 /// Add a key to the filter - O(k) where k = num_hashes
198 #[inline]
199 pub fn insert(&mut self, key: &[u8]) {
200 self.ensure_allocated();
201 let bits = self.bits.as_mut().unwrap();
202 let num_bits = bits.len() * 64;
203 if num_bits == 0 {
204 return;
205 }
206
207 // Use two hash functions to simulate k hash functions
208 // h(i) = h1 + i * h2 (double hashing technique)
209 let h1 = Self::hash1(key);
210 let h2 = Self::hash2(key);
211
212 for i in 0..self.num_hashes {
213 let h = h1.wrapping_add((i as u64).wrapping_mul(h2));
214 let bit_idx = (h as usize) % num_bits;
215 let word_idx = bit_idx / 64;
216 let bit_pos = bit_idx % 64;
217 bits[word_idx] |= 1 << bit_pos;
218 }
219 }
220
221 /// Check if a key might be present - O(k)
222 ///
223 /// Returns:
224 /// - false: Key is definitely NOT in the set (or filter not initialized)
225 /// - true: Key MIGHT be in the set (needs exact check)
226 #[inline]
227 pub fn may_contain(&self, key: &[u8]) -> bool {
228 let bits = match &self.bits {
229 Some(b) => b,
230 None => return false, // Uninitialized = empty
231 };
232 let num_bits = bits.len() * 64;
233 if num_bits == 0 {
234 return false;
235 }
236
237 let h1 = Self::hash1(key);
238 let h2 = Self::hash2(key);
239
240 for i in 0..self.num_hashes {
241 let h = h1.wrapping_add((i as u64).wrapping_mul(h2));
242 let bit_idx = (h as usize) % num_bits;
243 let word_idx = bit_idx / 64;
244 let bit_pos = bit_idx % 64;
245 if bits[word_idx] & (1 << bit_pos) == 0 {
246 return false; // Definitely not present
247 }
248 }
249 true // Might be present
250 }
251
252 /// Check if this filter might intersect with another
253 ///
254 /// Fast O(m/64) check using bitwise AND of all words.
255 /// If no bits are shared, sets are definitely disjoint.
256 #[inline]
257 pub fn may_intersect(&self, other: &SsiBloomFilter) -> bool {
258 let (self_bits, other_bits) = match (&self.bits, &other.bits) {
259 (Some(s), Some(o)) => (s, o),
260 _ => return false, // Either uninitialized = no intersection
261 };
262 let min_len = self_bits.len().min(other_bits.len());
263 for i in 0..min_len {
264 if self_bits[i] & other_bits[i] != 0 {
265 return true; // Might intersect
266 }
267 }
268 false // Definitely disjoint
269 }
270
271 /// First hash function (using built-in hasher)
272 #[inline]
273 fn hash1(key: &[u8]) -> u64 {
274 use std::collections::hash_map::DefaultHasher;
275 use std::hash::{Hash, Hasher};
276 let mut hasher = DefaultHasher::new();
277 key.hash(&mut hasher);
278 hasher.finish()
279 }
280
281 /// Second hash function (using twox-hash for independence)
282 #[inline]
283 fn hash2(key: &[u8]) -> u64 {
284 twox_hash::xxh3::hash64(key)
285 }
286
287 /// Get the memory size in bytes
288 pub fn size_bytes(&self) -> usize {
289 self.bits.as_ref().map(|b| b.len() * 8).unwrap_or(0) + std::mem::size_of::<Self>()
290 }
291
292 /// Check if the filter is empty
293 pub fn is_empty(&self) -> bool {
294 match &self.bits {
295 Some(bits) => bits.iter().all(|&w| w == 0),
296 None => true,
297 }
298 }
299}
300
301/// Type alias for inline key storage - keys up to 32 bytes stored on stack
302/// This eliminates heap allocation for typical keys like "users/12345" (12 bytes)
303pub type InlineKey = SmallVec<[u8; 32]>;
304
305/// Version of a key-value pair
306#[derive(Debug, Clone)]
307pub struct Version {
308 /// The value (None = tombstone)
309 pub value: Option<Vec<u8>>,
310 /// Transaction that created this version
311 pub txn_id: u64,
312 /// Commit timestamp (0 = uncommitted)
313 pub commit_ts: u64,
314}
315
316// Rec 11: Implement ChainEntry so BinarySearchChain<Version> works
317impl ChainEntry for Version {
318 #[inline]
319 fn commit_ts(&self) -> u64 {
320 self.commit_ts
321 }
322 #[inline]
323 fn txn_id(&self) -> u64 {
324 self.txn_id
325 }
326 #[inline]
327 fn set_commit_ts(&mut self, ts: u64) {
328 self.commit_ts = ts;
329 }
330}
331
332// ============================================================================
333// Optimized VersionChain with Binary Search (Task 1: mm.md)
334// ============================================================================
335
336/// Multi-version data for a single key with O(log v) read complexity
337///
338/// ## Optimization: Binary Search with Sorted Commit Ordering
339///
340/// Separates committed versions (sorted descending by commit_ts) from
341/// uncommitted version (single optional slot per transaction).
342///
343/// **Before:** O(v) linear scan + O(v) max computation = O(v)
344/// **After:** O(1) uncommitted check + O(log v) binary search = O(log v)
345///
346/// For v=10 versions: 3.3x speedup
347/// For v=100 versions: 7x speedup
348///
349/// ## Rec 11: Consolidated
350///
351/// Delegates binary-search logic to `BinarySearchChain<Version>` from sochdb-core,
352/// eliminating duplication with `mvcc_concurrent::VersionChain`.
353#[derive(Debug, Default)]
354pub struct VersionChain {
355 /// Consolidated binary-search chain (Rec 11)
356 inner: BinarySearchChain<Version>,
357}
358
359impl VersionChain {
360 /// Create a new empty version chain
361 #[inline]
362 pub fn new() -> Self {
363 Self {
364 inner: BinarySearchChain::new(),
365 }
366 }
367
368 /// Add a new uncommitted version
369 /// If there's already an uncommitted version from this txn, update it in place
370 ///
371 /// O(1) - just updates the uncommitted slot
372 #[inline]
373 pub fn add_uncommitted(&mut self, value: Option<Vec<u8>>, txn_id: u64) {
374 match self.inner.uncommitted_mut() {
375 Some(v) if v.txn_id == txn_id => {
376 // Update in place - O(1)
377 v.value = value;
378 }
379 _ => {
380 // New or different txn — set the slot
381 self.inner.set_uncommitted(Version {
382 value,
383 txn_id,
384 commit_ts: 0,
385 });
386 }
387 }
388 }
389
390 /// Commit a version - moves from uncommitted slot to sorted committed list
391 ///
392 /// O(log v) - inserts into sorted position using binary search
393 #[inline]
394 pub fn commit(&mut self, txn_id: u64, commit_ts: u64) -> bool {
395 self.inner.commit(txn_id, commit_ts)
396 }
397
398 /// Abort a version (remove uncommitted version for txn)
399 ///
400 /// O(1) - just clears the uncommitted slot if it matches
401 #[inline]
402 pub fn abort(&mut self, txn_id: u64) {
403 self.inner.abort(txn_id);
404 }
405
406 /// Read at a snapshot timestamp, optionally seeing own uncommitted writes
407 ///
408 /// ## Complexity: O(1) + O(log v) = O(log v)
409 #[inline]
410 pub fn read_at(&self, snapshot_ts: u64, current_txn_id: Option<u64>) -> Option<&Version> {
411 self.inner.read_at(snapshot_ts, current_txn_id)
412 }
413
414 /// Check if there's an uncommitted version by another transaction
415 ///
416 /// O(1) - just checks the uncommitted slot
417 #[inline]
418 pub fn has_write_conflict(&self, my_txn_id: u64) -> bool {
419 self.inner.has_write_conflict(my_txn_id)
420 }
421
422 /// Garbage collect old versions
423 pub fn gc(&mut self, min_active_ts: u64) {
424 self.inner.gc_by_ts(min_active_ts);
425 }
426
427 /// Get total version count (committed + uncommitted)
428 #[inline]
429 pub fn version_count(&self) -> usize {
430 self.inner.version_count()
431 }
432
433 // Legacy compatibility: get versions vec (for tests)
434 #[cfg(test)]
435 pub fn versions(&self) -> Vec<Version> {
436 let mut result = self.inner.committed_versions().to_vec();
437 if let Some(v) = self.inner.uncommitted() {
438 result.push(v.clone());
439 }
440 result
441 }
442}
443
444// =============================================================================
445// Rec 6: Unified Version Chain Trait Implementations
446// =============================================================================
447
448impl MvccVersionChain for VersionChain {
449 type Value = Option<Vec<u8>>;
450
451 fn get_visible(&self, ctx: &VisibilityContext) -> Option<&Self::Value> {
452 // Delegate to BinarySearchChain, then project to value field
453 self.inner
454 .read_at(ctx.snapshot_ts, Some(ctx.reader_txn_id))
455 .map(|v| &v.value)
456 }
457
458 fn get_latest(&self) -> Option<&Self::Value> {
459 self.inner.latest().map(|v| &v.value)
460 }
461
462 fn version_count(&self) -> usize {
463 self.inner.version_count()
464 }
465}
466
467impl MvccVersionChainMut for VersionChain {
468 fn add_uncommitted(&mut self, value: Self::Value, txn_id: TxnId) {
469 self.add_uncommitted(value, txn_id);
470 }
471
472 fn commit_version(&mut self, txn_id: TxnId, commit_ts: Timestamp) -> bool {
473 self.inner.commit(txn_id, commit_ts)
474 }
475
476 fn delete_version(&mut self, txn_id: TxnId, _delete_ts: Timestamp) -> bool {
477 // Insert a tombstone (None value) as uncommitted
478 self.add_uncommitted(None, txn_id);
479 true
480 }
481
482 fn gc(&mut self, min_visible_ts: Timestamp) -> (usize, usize) {
483 let before = self.inner.committed_count();
484 self.inner.gc_by_ts(min_visible_ts);
485 let removed = before - self.inner.committed_count();
486 (removed, removed * std::mem::size_of::<Version>())
487 }
488}
489
490impl WriteConflictDetection for VersionChain {
491 fn has_write_conflict(&self, txn_id: TxnId) -> bool {
492 self.has_write_conflict(txn_id)
493 }
494}
495
496// =============================================================================
497// Pre-sizing Constants to Avoid HashSet Resize Overhead
498// =============================================================================
499
500/// Default capacity for write_set HashSet
501/// Sized for typical OLTP transactions (10-50 keys)
502/// Avoids resize overhead that caused +11% regression
503const WRITE_SET_INITIAL_CAPACITY: usize = 32;
504
505/// Default capacity for read_set HashSet
506/// Typically larger than write_set due to read-heavy patterns
507const READ_SET_INITIAL_CAPACITY: usize = 64;
508
509/// Transaction state for MVCC
510#[derive(Debug, Clone)]
511pub struct MvccTransaction {
512 /// Transaction ID
513 pub txn_id: u64,
514 /// Snapshot timestamp (reads see commits before this)
515 pub snapshot_ts: u64,
516 /// Keys written by this transaction - uses SmallVec for inline storage
517 /// Pre-sized to WRITE_SET_INITIAL_CAPACITY to avoid resize overhead
518 pub write_set: HashSet<InlineKey>,
519 /// Keys read by this transaction (for SSI validation) - uses SmallVec for inline storage
520 /// Pre-sized to READ_SET_INITIAL_CAPACITY to avoid resize overhead
521 pub read_set: HashSet<InlineKey>,
522 /// Bloom filter for write set - fast SSI pre-filtering
523 pub write_bloom: SsiBloomFilter,
524 /// Bloom filter for read set - fast SSI pre-filtering
525 pub read_bloom: SsiBloomFilter,
526 /// Transaction state
527 pub state: TxnState,
528 /// Transaction mode for SSI optimization (Recommendation 9)
529 /// ReadOnly/WriteOnly modes skip SSI tracking for 2.6x improvement
530 pub mode: TransactionMode,
531}
532
533impl MvccTransaction {
534 /// Create a new transaction with pre-sized collections
535 ///
536 /// This avoids HashSet resize overhead during the transaction
537 /// which was causing +11% regression on write_set.insert().
538 #[inline]
539 pub fn new(txn_id: u64, snapshot_ts: u64) -> Self {
540 Self::with_mode(txn_id, snapshot_ts, TransactionMode::ReadWrite)
541 }
542
543 /// Create a read-only transaction (SSI bypass - 2.6x faster)
544 ///
545 /// Read-only transactions skip all SSI tracking:
546 /// - No read_set allocation
547 /// - No read_bloom allocation
548 /// - No commit validation
549 ///
550 /// ## Performance
551 ///
552 /// For N=100 reads: 8350ns → 3230ns (2.6× improvement)
553 #[inline]
554 pub fn read_only(txn_id: u64, snapshot_ts: u64) -> Self {
555 Self::with_mode(txn_id, snapshot_ts, TransactionMode::ReadOnly)
556 }
557
558 /// Create a write-only transaction (partial SSI bypass)
559 ///
560 /// Write-only transactions skip read tracking:
561 /// - No read_set tracking
562 /// - No read_bloom inserts
563 /// - Still needs write_set for commit
564 #[inline]
565 pub fn write_only(txn_id: u64, snapshot_ts: u64) -> Self {
566 Self::with_mode(txn_id, snapshot_ts, TransactionMode::WriteOnly)
567 }
568
569 /// Create transaction with specific mode
570 #[inline]
571 pub fn with_mode(txn_id: u64, snapshot_ts: u64, mode: TransactionMode) -> Self {
572 // Optimize allocation based on mode
573 let (write_capacity, read_capacity) = match mode {
574 TransactionMode::ReadOnly => (0, 0), // No tracking needed
575 TransactionMode::WriteOnly => (WRITE_SET_INITIAL_CAPACITY, 0),
576 TransactionMode::ReadWrite => (WRITE_SET_INITIAL_CAPACITY, READ_SET_INITIAL_CAPACITY),
577 };
578 Self::with_capacity(txn_id, snapshot_ts, write_capacity, read_capacity, mode)
579 }
580
581 /// Create with custom capacities for expected workload
582 ///
583 /// Use this when you know the transaction will write many keys
584 /// to avoid resize overhead entirely.
585 #[inline]
586 pub fn with_capacity(
587 txn_id: u64,
588 snapshot_ts: u64,
589 write_capacity: usize,
590 read_capacity: usize,
591 mode: TransactionMode,
592 ) -> Self {
593 Self {
594 txn_id,
595 snapshot_ts,
596 write_set: HashSet::with_capacity(write_capacity),
597 read_set: HashSet::with_capacity(read_capacity),
598 write_bloom: SsiBloomFilter::new(write_capacity.max(1)),
599 read_bloom: SsiBloomFilter::new(read_capacity.max(1)),
600 state: TxnState::Active,
601 mode,
602 }
603 }
604
605 /// Check if this is a read-only transaction
606 #[inline]
607 pub fn is_read_only(&self) -> bool {
608 self.write_set.is_empty()
609 }
610
611 /// Check if this is a single-key write transaction
612 #[inline]
613 pub fn is_single_key_write(&self) -> bool {
614 self.write_set.len() == 1 && self.read_set.len() <= 1
615 }
616}
617
618/// Transaction state
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum TxnState {
621 Active,
622 Committed,
623 Aborted,
624}
625
626// =============================================================================
627// Transaction Mode for SSI Bypass (Recommendation 9)
628// =============================================================================
629
630/// Transaction mode for SSI optimization
631///
632/// By classifying transactions at begin time, we can skip expensive SSI
633/// tracking for the majority of transactions:
634///
635/// | Mode | SSI Read Tracking | SSI Write Tracking | Commit Overhead |
636/// |-----------|-------------------|--------------------|-----------------|
637/// | ReadOnly | None | None | ~10 ns |
638/// | WriteOnly | None | Full | ~30 ns |
639/// | ReadWrite | Full | Full | ~50 ns |
640///
641/// ## Performance Analysis
642///
643/// For read-only transactions (typically 90% of workload):
644/// ```text
645/// Current: T_txn = T_begin + N × (T_read + T_record) + T_commit
646/// = 100ns + N × (32ns + 50ns) + 50ns = 150ns + 82ns × N
647///
648/// ReadOnly: T_txn = T_begin_ro + N × T_read + T_commit_ro
649/// = 20ns + N × 32ns + 10ns = 30ns + 32ns × N
650///
651/// For N=100 reads: 8350ns → 3230ns (2.6× faster)
652/// ```
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
654pub enum TransactionMode {
655 /// Read-only transaction - skips ALL SSI tracking
656 /// Cannot form rw-antidependency cycles (no writes to create outgoing edges)
657 /// Safe to skip read_set, read_bloom, and commit validation entirely
658 ReadOnly,
659
660 /// Write-only transaction - skips read tracking
661 /// Cannot form incoming rw-edges (no reads from concurrent writers)
662 /// Only needs write_set and write_bloom tracking
663 WriteOnly,
664
665 /// Full read-write transaction (default) - complete SSI tracking
666 /// May form both incoming and outgoing rw-edges
667 /// Requires full validation at commit time
668 #[default]
669 ReadWrite,
670}
671
672impl TransactionMode {
673 /// Check if this mode requires read tracking
674 #[inline]
675 pub fn tracks_reads(&self) -> bool {
676 matches!(self, TransactionMode::ReadWrite)
677 }
678
679 /// Check if this mode requires write tracking
680 #[inline]
681 pub fn tracks_writes(&self) -> bool {
682 matches!(
683 self,
684 TransactionMode::WriteOnly | TransactionMode::ReadWrite
685 )
686 }
687
688 /// Check if commit needs SSI validation
689 #[inline]
690 pub fn needs_ssi_validation(&self) -> bool {
691 matches!(self, TransactionMode::ReadWrite)
692 }
693}
694
695/// SSI conflict edge type
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum ConflictType {
698 /// Read-write conflict: T1 reads X, then T2 writes X
699 ReadWrite,
700 /// Write-read conflict: T1 writes X, then T2 reads X
701 WriteRead,
702}
703
704/// SSI conflict edge for dangerous structure detection
705#[derive(Debug, Clone)]
706pub struct ConflictEdge {
707 /// Source transaction
708 pub from_txn: u64,
709 /// Target transaction
710 pub to_txn: u64,
711 /// Type of conflict
712 pub conflict_type: ConflictType,
713}
714
715/// MVCC Manager with SSI support
716///
717/// Uses DashMap for lock-free per-transaction access.
718/// Implements Serializable Snapshot Isolation (SSI) with
719/// dangerous structure detection for rw-antidependency cycles.
720#[allow(clippy::type_complexity)]
721pub struct MvccManager {
722 /// Active transactions (sharded for concurrent access)
723 active_txns: DashMap<u64, MvccTransaction>,
724 /// Current timestamp counter
725 ts_counter: AtomicU64,
726 /// Minimum active snapshot timestamp (for GC)
727 min_active_ts: AtomicU64,
728 /// Refcounted multiset of active snapshot timestamps, ordered.
729 ///
730 /// Maintained incrementally on txn begin/end so the GC watermark
731 /// (`min_active_ts`) is O(log N) per begin/commit instead of an O(active_txns)
732 /// full `active_txns` DashMap scan (which locked every shard on every
733 /// begin AND commit — the dominant on-CPU cost under concurrent writes).
734 /// A multiset (count per ts) is required because `begin` reads ts_counter
735 /// without incrementing, so concurrent txns can share a snapshot_ts.
736 active_snapshots: parking_lot::Mutex<std::collections::BTreeMap<u64, u32>>,
737 /// Recently committed transactions for SSI validation
738 /// Maps txn_id -> (commit_ts, read_bloom, write_bloom, read_set, write_set)
739 /// Bloom filters enable fast O(m/64) pre-filtering before O(n) exact checks
740 recent_commits: DashMap<
741 u64,
742 (
743 u64,
744 SsiBloomFilter,
745 SsiBloomFilter,
746 HashSet<InlineKey>,
747 HashSet<InlineKey>,
748 ),
749 >,
750 /// Max recent commits to track
751 max_recent_commits: usize,
752}
753
754impl Default for MvccManager {
755 fn default() -> Self {
756 Self::new()
757 }
758}
759
760impl MvccManager {
761 pub fn new() -> Self {
762 Self {
763 active_txns: DashMap::new(),
764 ts_counter: AtomicU64::new(1),
765 min_active_ts: AtomicU64::new(0),
766 active_snapshots: parking_lot::Mutex::new(std::collections::BTreeMap::new()),
767 recent_commits: DashMap::new(),
768 max_recent_commits: 1000, // Track last 1000 commits for SSI
769 }
770 }
771
772 /// Begin a new transaction with snapshot isolation
773 ///
774 /// Uses pre-sized HashSets to avoid resize overhead (+11% regression fix)
775 pub fn begin(&self, txn_id: u64) -> MvccTransaction {
776 self.begin_with_mode(txn_id, TransactionMode::ReadWrite)
777 }
778
779 /// Begin a read-only transaction (SSI bypass - 2.6x faster)
780 ///
781 /// Read-only transactions skip all SSI tracking, reducing
782 /// per-read overhead from ~82ns to ~32ns.
783 ///
784 /// ## Safety
785 ///
786 /// Caller must ensure no writes are performed. Attempting to
787 /// write in a read-only transaction will still succeed but
788 /// won't be tracked for SSI validation.
789 #[inline]
790 pub fn begin_read_only(&self, txn_id: u64) -> MvccTransaction {
791 self.begin_with_mode(txn_id, TransactionMode::ReadOnly)
792 }
793
794 /// Begin a write-only transaction (partial SSI bypass)
795 ///
796 /// Write-only transactions skip read tracking, reducing overhead
797 /// for insert-heavy workloads.
798 #[inline]
799 pub fn begin_write_only(&self, txn_id: u64) -> MvccTransaction {
800 self.begin_with_mode(txn_id, TransactionMode::WriteOnly)
801 }
802
803 /// Begin a transaction with specific mode
804 ///
805 /// This is the core transaction creation method that all other
806 /// begin_* methods delegate to.
807 pub fn begin_with_mode(&self, txn_id: u64, mode: TransactionMode) -> MvccTransaction {
808 let snapshot_ts = self.ts_counter.load(Ordering::SeqCst);
809
810 // Create transaction with mode-optimized allocations
811 let txn = MvccTransaction::with_mode(txn_id, snapshot_ts, mode);
812
813 self.active_txns.insert(txn_id, txn.clone());
814 self.track_active_begin(snapshot_ts);
815
816 txn
817 }
818
819 /// Get transaction if active (clones - use get_snapshot_ts for hot path)
820 pub fn get(&self, txn_id: u64) -> Option<MvccTransaction> {
821 self.active_txns.get(&txn_id).map(|t| t.clone())
822 }
823
824 /// Fast path: get just the snapshot timestamp without cloning
825 /// This is the hot path for reads - avoids cloning bloom filters
826 #[inline]
827 pub fn get_snapshot_ts(&self, txn_id: u64) -> Option<u64> {
828 self.active_txns.get(&txn_id).map(|t| t.snapshot_ts)
829 }
830
831 /// Record a read (for SSI) - uses inline key storage + bloom filter
832 ///
833 /// ## SSI Bypass (Recommendation 9)
834 ///
835 /// For ReadOnly mode transactions, this is a no-op (instant return).
836 /// For WriteOnly mode transactions, this is a no-op.
837 /// Only ReadWrite mode transactions track reads for SSI.
838 ///
839 /// This reduces per-read overhead from ~50ns to ~0ns for read-only txns.
840 #[inline]
841 pub fn record_read(&self, txn_id: u64, key: &[u8]) {
842 if let Some(mut txn) = self.active_txns.get_mut(&txn_id) {
843 // SSI Bypass: Skip tracking for read-only and write-only modes
844 if !txn.mode.tracks_reads() {
845 return;
846 }
847
848 // Only track reads if within reasonable bounds
849 if txn.read_set.len() < 10000 {
850 txn.read_set.insert(SmallVec::from_slice(key));
851 txn.read_bloom.insert(key);
852 }
853 }
854 }
855
856 /// Record a write - uses inline key storage + bloom filter
857 ///
858 /// Note: Even ReadOnly transactions can record writes (mode is a hint).
859 /// The mode only affects SSI tracking, not write capability.
860 pub fn record_write(&self, txn_id: u64, key: &[u8]) {
861 if let Some(mut txn) = self.active_txns.get_mut(&txn_id) {
862 txn.write_set.insert(SmallVec::from_slice(key));
863 txn.write_bloom.insert(key);
864 }
865 }
866
867 /// Allocate commit timestamp
868 pub fn alloc_commit_ts(&self) -> u64 {
869 self.ts_counter.fetch_add(1, Ordering::SeqCst)
870 }
871
872 /// Commit transaction with SSI validation
873 /// Returns (commit_ts, write_set) so the memtable can be updated efficiently
874 /// Returns None if SSI validation fails (dangerous structure detected)
875 ///
876 /// ## SSI Bypass (Recommendation 9)
877 ///
878 /// For ReadOnly mode: Skip validation entirely (~10ns commit)
879 /// For WriteOnly mode: Skip read-based validation (~30ns commit)
880 /// For ReadWrite mode: Full validation (~50ns commit)
881 pub fn commit(&self, txn_id: u64) -> Option<(u64, HashSet<InlineKey>)> {
882 // Get transaction before removing
883 let txn = self.active_txns.get(&txn_id)?.clone();
884
885 // SSI Bypass: Skip validation for ReadOnly transactions
886 // ReadOnly can never form rw-antidependency cycles
887 if txn.mode != TransactionMode::ReadWrite || !self.validate_ssi(&txn) {
888 // For ReadOnly/WriteOnly: always valid (mode check short-circuits)
889 // For ReadWrite: check SSI validation result
890 if txn.mode == TransactionMode::ReadWrite && !self.validate_ssi(&txn) {
891 // Abort on SSI violation
892 if self.active_txns.remove(&txn_id).is_some() {
893 self.track_active_end(txn.snapshot_ts);
894 }
895 return None;
896 }
897 }
898
899 let commit_ts = self.alloc_commit_ts();
900
901 // Extract write_set and remove transaction - takes ownership
902 let (_, removed_txn) = self.active_txns.remove(&txn_id)?;
903 // Capture before `removed_txn` is partially moved below; snapshot_ts is
904 // the multiset key for the matching begin.
905 let snap = removed_txn.snapshot_ts;
906
907 // OPTIMIZATION: Only track ReadWrite transactions for SSI
908 // ReadOnly/WriteOnly can't form complete rw-antidependency cycles
909 let needs_ssi_tracking = removed_txn.mode == TransactionMode::ReadWrite
910 && !removed_txn.read_set.is_empty()
911 && !removed_txn.write_set.is_empty();
912
913 if needs_ssi_tracking {
914 // Need to clone write_set since we return it AND track it
915 let write_set_for_return = removed_txn.write_set.clone();
916
917 self.track_commit_owned(
918 txn_id,
919 commit_ts,
920 removed_txn.read_bloom,
921 removed_txn.write_bloom,
922 removed_txn.read_set,
923 removed_txn.write_set,
924 );
925
926 self.track_active_end(snap);
927 Some((commit_ts, write_set_for_return))
928 } else {
929 // Fast path: no SSI tracking needed, avoid clone entirely
930 self.track_active_end(snap);
931 Some((commit_ts, removed_txn.write_set))
932 }
933 }
934
935 /// Validate SSI constraints for a committing transaction
936 ///
937 /// ## Transaction Classification (Task 3: Optimistic MVCC)
938 ///
939 /// Transactions are classified and routed through appropriate fast paths:
940 ///
941 /// | Class | Criteria | Validation Cost |
942 /// |------------|-------------------------------|-----------------|
943 /// | ReadOnly | write_set.is_empty() | 0 ns |
944 /// | SingleKey | write_set.len() == 1 | 0 ns |
945 /// | Disjoint | bloom filters don't intersect | ~10 ns |
946 /// | General | full SSI check | ~50 ns |
947 ///
948 /// Expected distribution: ~60% read-only, ~25% single-key, ~10% disjoint, ~5% general
949 /// Weighted average: ~8 ns vs 50 ns baseline (6x improvement)
950 ///
951 /// Detects "dangerous structures" - rw-antidependency cycles:
952 /// - T1 reads X (snapshot sees old value)
953 /// - T2 writes X (concurrent write)
954 /// - T2 reads Y (snapshot sees old value)
955 /// - T1 writes Y (concurrent write)
956 ///
957 /// If T1 → rw → T2 → rw → T1 exists, abort T1
958 #[inline]
959 fn validate_ssi(&self, txn: &MvccTransaction) -> bool {
960 // =================================================================
961 // Fast Path 1: Read-only transactions (0 ns)
962 // =================================================================
963 // Read-only transactions can never form rw-antidependency cycles
964 // because they have no writes to create outgoing rw-edges
965 if txn.write_set.is_empty() {
966 return true;
967 }
968
969 // =================================================================
970 // Fast Path 2: No recent commits to check (0 ns)
971 // =================================================================
972 if self.recent_commits.is_empty() {
973 return true;
974 }
975
976 // =================================================================
977 // Fast Path 3: Single-key write transactions (0 ns)
978 // =================================================================
979 // A single-key write transaction cannot form a dangerous cycle:
980 // - For a cycle T1 →rw→ T2 →rw→ T1, we need T1 to read what T2 wrote
981 // AND T2 to read what T1 wrote
982 // - With only one key in write_set, the same key would need to be
983 // in both read_set AND write_set of both transactions
984 // - This is already prevented by our conflict detection (write-write)
985 if txn.write_set.len() == 1 && txn.read_set.len() <= 1 {
986 return true;
987 }
988
989 let my_snapshot = txn.snapshot_ts;
990
991 // =================================================================
992 // Fast Path 4: Disjoint transactions using Bloom filters (~10 ns)
993 // =================================================================
994 // Pre-filter using bloom filters: if our write_bloom doesn't intersect
995 // with any concurrent transaction's read_bloom AND vice versa,
996 // there can be no rw-antidependency
997 let mut any_may_intersect = false;
998 for entry in self.recent_commits.iter() {
999 let (_, (other_commit_ts, other_read_bloom, other_write_bloom, _, _)) = entry.pair();
1000
1001 // Only check concurrent transactions.
1002 //
1003 // A committed transaction is *concurrent* with us iff its writes are
1004 // invisible to our snapshot. Read visibility is strict
1005 // (`commit_ts < snapshot_ts`), so a transaction with
1006 // `commit_ts >= my_snapshot` is invisible and must be validated
1007 // against. Using `<` here (not `<=`) keeps this window consistent
1008 // with `read_at`; a `<=` would skip a boundary transaction whose
1009 // `commit_ts == my_snapshot` and miss a genuine write-skew.
1010 if *other_commit_ts < my_snapshot {
1011 continue;
1012 }
1013
1014 // Check bloom filter intersection (O(m/64) per filter)
1015 // If our writes may intersect their reads OR their writes may intersect our reads
1016 if txn.write_bloom.may_intersect(other_read_bloom)
1017 || other_write_bloom.may_intersect(&txn.read_bloom)
1018 {
1019 any_may_intersect = true;
1020 break;
1021 }
1022 }
1023
1024 // No bloom intersection means definitely disjoint - no SSI conflict possible
1025 if !any_may_intersect {
1026 return true;
1027 }
1028
1029 // =================================================================
1030 // Full SSI Validation (~50 ns)
1031 // =================================================================
1032 // Check for rw-conflicts with recently committed transactions
1033 // An rw-conflict exists if:
1034 // - T_other wrote to a key that T_me read (T_other →rw→ T_me)
1035 // - T_me wrote to a key that T_other read (T_me →rw→ T_other)
1036
1037 let mut in_conflict_with: Vec<u64> = Vec::new();
1038 let mut out_conflict_to: Vec<u64> = Vec::new();
1039
1040 for entry in self.recent_commits.iter() {
1041 let (
1042 other_txn_id,
1043 (
1044 other_commit_ts,
1045 _other_read_bloom,
1046 other_write_bloom,
1047 other_read_set,
1048 other_write_set,
1049 ),
1050 ) = entry.pair();
1051
1052 // Only consider transactions concurrent with us: those whose writes
1053 // are invisible to our snapshot. Read visibility is strict
1054 // (`commit_ts < snapshot_ts`), so `commit_ts >= my_snapshot` means
1055 // concurrent. `<` (not `<=`) keeps this consistent with `read_at`.
1056 if *other_commit_ts < my_snapshot {
1057 continue;
1058 }
1059
1060 // Check: other wrote → we read (other →rw→ me)
1061 // T_other wrote a key that T_me read (rw-dependency inbound)
1062 //
1063 // Bloom-accelerated: First check bloom filter for fast rejection (O(m/64))
1064 // Only do expensive HashSet intersection if bloom says "maybe conflict"
1065 let mut has_in_conflict = false;
1066 for key in txn.read_set.iter() {
1067 if other_write_bloom.may_contain(key) {
1068 // Bloom says maybe - do exact check
1069 if other_write_set.contains(key) {
1070 has_in_conflict = true;
1071 break;
1072 }
1073 }
1074 }
1075 if has_in_conflict {
1076 in_conflict_with.push(*other_txn_id);
1077 }
1078
1079 // Check: we wrote → other read (me →rw→ other)
1080 // T_me wrote a key that T_other read (rw-dependency outbound)
1081 //
1082 // Bloom-accelerated: Use our write_bloom against their read_set
1083 let mut has_out_conflict = false;
1084 for key in other_read_set.iter() {
1085 if txn.write_bloom.may_contain(key) {
1086 // Bloom says maybe - do exact check
1087 if txn.write_set.contains(key) {
1088 has_out_conflict = true;
1089 break;
1090 }
1091 }
1092 }
1093 if has_out_conflict {
1094 out_conflict_to.push(*other_txn_id);
1095 }
1096 }
1097
1098 // Dangerous structure: we have both incoming AND outgoing rw-edges
1099 // This creates a potential cycle: T1 →rw→ T_me →rw→ T2
1100 //
1101 // Conservative check: if both exist, abort
1102 // A more precise check would verify the cycle path, but this is safe
1103 if !in_conflict_with.is_empty() && !out_conflict_to.is_empty() {
1104 return false; // SSI violation - abort
1105 }
1106
1107 true
1108 }
1109
1110 /// Track a committed transaction for future SSI validation
1111 ///
1112 /// Only tracks transactions that have both reads AND writes, since SSI
1113 /// only detects rw-antidependency cycles. Pure read or pure write
1114 /// transactions can't form cycles.
1115 ///
1116 /// ## Optimization: Zero-Copy Transfer
1117 ///
1118 /// Takes ownership of sets instead of cloning to avoid the +15% commit
1119 /// phase regression. The caller should use mem::take() to transfer ownership.
1120 fn track_commit_owned(
1121 &self,
1122 txn_id: u64,
1123 commit_ts: u64,
1124 read_bloom: SsiBloomFilter,
1125 write_bloom: SsiBloomFilter,
1126 read_set: HashSet<InlineKey>,
1127 write_set: HashSet<InlineKey>,
1128 ) {
1129 // Optimization: Only track mixed read-write transactions
1130 // Pure reads can't create outgoing rw-edges
1131 // Pure writes can't create incoming rw-edges
1132 if read_set.is_empty() || write_set.is_empty() {
1133 return; // Skip tracking - can't form SSI cycle
1134 }
1135
1136 // Add to recent commits with bloom filters for fast SSI pre-filtering
1137 // No cloning needed - we take ownership
1138 self.recent_commits.insert(
1139 txn_id,
1140 (commit_ts, read_bloom, write_bloom, read_set, write_set),
1141 );
1142
1143 // Lazy pruning: only prune when we're significantly over capacity
1144 // Avoids pruning overhead on every commit
1145 if self.recent_commits.len() > self.max_recent_commits * 2 {
1146 // Remove entries with lowest commit_ts
1147 let min_active = self.min_active_ts.load(Ordering::Relaxed);
1148 self.recent_commits
1149 .retain(|_, (ts, _, _, _, _)| *ts >= min_active);
1150 }
1151 }
1152
1153 /// Legacy track_commit that clones - kept for compatibility
1154 #[allow(dead_code)]
1155 fn track_commit(
1156 &self,
1157 txn_id: u64,
1158 commit_ts: u64,
1159 read_bloom: SsiBloomFilter,
1160 write_bloom: SsiBloomFilter,
1161 read_set: &HashSet<InlineKey>,
1162 write_set: &HashSet<InlineKey>,
1163 ) {
1164 if read_set.is_empty() || write_set.is_empty() {
1165 return;
1166 }
1167 self.recent_commits.insert(
1168 txn_id,
1169 (
1170 commit_ts,
1171 read_bloom,
1172 write_bloom,
1173 read_set.clone(),
1174 write_set.clone(),
1175 ),
1176 );
1177 }
1178
1179 /// Abort transaction
1180 pub fn abort(&self, txn_id: u64) {
1181 if let Some((_, t)) = self.active_txns.remove(&txn_id) {
1182 self.track_active_end(t.snapshot_ts);
1183 }
1184 }
1185
1186 /// Get minimum active snapshot timestamp
1187 pub fn min_active_snapshot(&self) -> u64 {
1188 self.min_active_ts.load(Ordering::SeqCst)
1189 }
1190
1191 /// Get count of active transactions
1192 pub fn active_transaction_count(&self) -> usize {
1193 self.active_txns.len()
1194 }
1195
1196 /// Record a transaction entering the active set (its snapshot_ts) and
1197 /// refresh `min_active_ts`. O(log N) — replaces the old O(active_txns) scan.
1198 fn track_active_begin(&self, snapshot_ts: u64) {
1199 let mut snaps = self.active_snapshots.lock();
1200 *snaps.entry(snapshot_ts).or_insert(0) += 1;
1201 // The set is non-empty (we just inserted); the smallest key is the min.
1202 let min = *snaps.keys().next().expect("non-empty after insert");
1203 self.min_active_ts.store(min, Ordering::SeqCst);
1204 }
1205
1206 /// Record a transaction leaving the active set and refresh `min_active_ts`.
1207 /// O(log N). `snapshot_ts` MUST be the value used at the matching begin.
1208 fn track_active_end(&self, snapshot_ts: u64) {
1209 let mut snaps = self.active_snapshots.lock();
1210 if let Some(c) = snaps.get_mut(&snapshot_ts) {
1211 *c -= 1;
1212 if *c == 0 {
1213 snaps.remove(&snapshot_ts);
1214 }
1215 }
1216 // When no txn is active the watermark is the current ts_counter, exactly
1217 // as the old full-scan did via `unwrap_or_else`.
1218 let min = snaps
1219 .keys()
1220 .next()
1221 .copied()
1222 .unwrap_or_else(|| self.ts_counter.load(Ordering::SeqCst));
1223 self.min_active_ts.store(min, Ordering::SeqCst);
1224 }
1225
1226 /// Test-only SOUND consistency check: with no concurrent mutation in flight,
1227 /// the active-snapshot multiset must exactly mirror `active_txns` and yield
1228 /// the same watermark as a full scan. Must be called from a quiescent (e.g.
1229 /// single-threaded) point — it reads both structures and is not atomic.
1230 #[cfg(test)]
1231 pub(crate) fn assert_active_snapshots_consistent(&self) {
1232 use std::collections::BTreeMap;
1233 let mut from_txns: BTreeMap<u64, u32> = BTreeMap::new();
1234 for e in self.active_txns.iter() {
1235 *from_txns.entry(e.value().snapshot_ts).or_insert(0) += 1;
1236 }
1237 let snaps = self.active_snapshots.lock();
1238 assert_eq!(
1239 *snaps, from_txns,
1240 "active_snapshots multiset drifted from active_txns"
1241 );
1242 let watermark = self.min_active_ts.load(Ordering::SeqCst);
1243 match from_txns.keys().next().copied() {
1244 // Non-empty: watermark must equal the oldest active snapshot.
1245 Some(min) => assert_eq!(watermark, min, "min_active_ts watermark wrong"),
1246 // Empty: any watermark <= ts_counter is SAFE (GC just stays
1247 // conservative); the initial state is 0, post-drain it is ts_counter.
1248 None => assert!(
1249 watermark <= self.ts_counter.load(Ordering::SeqCst),
1250 "empty-state watermark {} exceeds ts_counter",
1251 watermark
1252 ),
1253 }
1254 }
1255
1256 /// Recompute `min_active_ts` from the active-snapshot multiset (O(log N)).
1257 /// Retained for any caller that needs a watermark refresh without a
1258 /// corresponding begin/end (e.g. recovery).
1259 #[allow(dead_code)]
1260 fn update_min_active_ts(&self) {
1261 let snaps = self.active_snapshots.lock();
1262 let min = snaps
1263 .keys()
1264 .next()
1265 .copied()
1266 .unwrap_or_else(|| self.ts_counter.load(Ordering::SeqCst));
1267 self.min_active_ts.store(min, Ordering::SeqCst);
1268 }
1269}
1270
1271/// Epoch-based dirty list for O(expired) GC instead of O(n)
1272///
1273/// Instead of scanning ALL version chains, we track which keys have versions
1274/// created in each epoch. GC only needs to visit keys from old epochs.
1275struct EpochDirtyList {
1276 /// Ring buffer of epoch -> dirty keys
1277 /// Index = epoch % EPOCH_RING_SIZE
1278 epochs: [parking_lot::Mutex<Vec<Vec<u8>>>; 4],
1279 /// Current epoch
1280 current_epoch: AtomicU64,
1281}
1282
1283const EPOCH_RING_SIZE: usize = 4;
1284
1285impl EpochDirtyList {
1286 fn new() -> Self {
1287 Self {
1288 epochs: [
1289 parking_lot::Mutex::new(Vec::new()),
1290 parking_lot::Mutex::new(Vec::new()),
1291 parking_lot::Mutex::new(Vec::new()),
1292 parking_lot::Mutex::new(Vec::new()),
1293 ],
1294 current_epoch: AtomicU64::new(0),
1295 }
1296 }
1297
1298 /// Record a version created in the current epoch
1299 #[inline]
1300 fn record_version(&self, key: Vec<u8>) {
1301 let epoch = self.current_epoch.load(Ordering::Relaxed);
1302 let idx = (epoch as usize) % EPOCH_RING_SIZE;
1303 self.epochs[idx].lock().push(key);
1304 }
1305
1306 /// Record multiple versions in a single lock acquisition (Rec 3: MVCC Batching)
1307 ///
1308 /// Performance: Single lock acquire vs N lock acquires for batch of N writes.
1309 /// For 100 writes: ~100x fewer mutex operations.
1310 #[inline]
1311 fn record_versions_batch(&self, keys: impl IntoIterator<Item = Vec<u8>>) {
1312 let epoch = self.current_epoch.load(Ordering::Relaxed);
1313 let idx = (epoch as usize) % EPOCH_RING_SIZE;
1314 let mut guard = self.epochs[idx].lock();
1315 guard.extend(keys);
1316 }
1317
1318 /// Advance to next epoch, returning old epoch's dirty keys
1319 fn advance_epoch(&self) -> (u64, Vec<Vec<u8>>) {
1320 let old_epoch = self.current_epoch.fetch_add(1, Ordering::SeqCst);
1321 let old_idx = (old_epoch as usize) % EPOCH_RING_SIZE;
1322
1323 // Drain the old epoch's dirty list
1324 let mut guard = self.epochs[old_idx].lock();
1325 let keys = std::mem::take(&mut *guard);
1326 (old_epoch, keys)
1327 }
1328
1329 /// Get current epoch
1330 #[allow(dead_code)]
1331 fn current(&self) -> u64 {
1332 self.current_epoch.load(Ordering::Relaxed)
1333 }
1334}
1335
1336// ============================================================================
1337// Streaming Scan Iterator
1338// ============================================================================
1339
1340/// Streaming iterator for range scans
1341///
1342/// Yields results one at a time without materializing the full result set.
1343/// This enables processing of very large result sets with O(1) memory per
1344/// iteration instead of O(N) for the entire result set.
1345struct ScanRangeIterator<'a> {
1346 memtable: &'a MvccMemTable,
1347 start: Vec<u8>,
1348 end: Vec<u8>,
1349 snapshot_ts: u64,
1350 current_txn_id: Option<u64>,
1351 use_ordered: bool,
1352 // We use Option to defer initialization
1353 ordered_iter: Option<Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a>>,
1354 unordered_iter: Option<Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a>>,
1355 initialized: bool,
1356}
1357
1358impl<'a> Iterator for ScanRangeIterator<'a> {
1359 type Item = (Vec<u8>, Vec<u8>);
1360
1361 fn next(&mut self) -> Option<Self::Item> {
1362 // Lazy initialization on first call
1363 if !self.initialized {
1364 self.initialized = true;
1365
1366 if self.use_ordered {
1367 // Try deferred index first (after compaction, it uses a SkipMap internally)
1368 if let Some(ref def_idx) = self.memtable.deferred_index {
1369 let start = self.start.clone();
1370 let end = self.end.clone();
1371 let snapshot_ts = self.snapshot_ts;
1372 let current_txn_id = self.current_txn_id;
1373 let data = &self.memtable.data;
1374
1375 // Collect keys from deferred index (already sorted after compact)
1376 let keys: Vec<Vec<u8>> = if end.is_empty() {
1377 def_idx.range_from(&start).collect()
1378 } else {
1379 def_idx.range(&start, &end).collect()
1380 };
1381
1382 let iter: Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a> =
1383 Box::new(keys.into_iter().filter_map(move |key| {
1384 if let Some(chain) = data.get(&key)
1385 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1386 && let Some(value) = &v.value
1387 {
1388 Some((key, value.clone()))
1389 } else {
1390 None
1391 }
1392 }));
1393 self.ordered_iter = Some(iter);
1394 } else if let Some(ref idx) = self.memtable.ordered_index {
1395 let start = self.start.clone();
1396 let end = self.end.clone();
1397 let snapshot_ts = self.snapshot_ts;
1398 let current_txn_id = self.current_txn_id;
1399 let data = &self.memtable.data;
1400
1401 let iter: Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a> = if end.is_empty()
1402 {
1403 Box::new(idx.range(start..).filter_map(move |entry| {
1404 let key = entry.key();
1405 if let Some(chain) = data.get(key)
1406 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1407 && let Some(value) = &v.value
1408 {
1409 Some((key.clone(), value.clone()))
1410 } else {
1411 None
1412 }
1413 }))
1414 } else {
1415 Box::new(idx.range(start..end).filter_map(move |entry| {
1416 let key = entry.key();
1417 if let Some(chain) = data.get(key)
1418 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1419 && let Some(value) = &v.value
1420 {
1421 Some((key.clone(), value.clone()))
1422 } else {
1423 None
1424 }
1425 }))
1426 };
1427 self.ordered_iter = Some(iter);
1428 }
1429 } else {
1430 // Unordered full scan
1431 let start = self.start.clone();
1432 let end = self.end.clone();
1433 let snapshot_ts = self.snapshot_ts;
1434 let current_txn_id = self.current_txn_id;
1435
1436 let iter: Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a> =
1437 Box::new(self.memtable.data.iter().filter_map(move |entry| {
1438 let key = entry.key();
1439
1440 if key.as_slice() < start.as_slice() {
1441 return None;
1442 }
1443 if !end.is_empty() && key.as_slice() >= end.as_slice() {
1444 return None;
1445 }
1446
1447 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
1448 && let Some(value) = &v.value
1449 {
1450 Some((key.clone(), value.clone()))
1451 } else {
1452 None
1453 }
1454 }));
1455 self.unordered_iter = Some(iter);
1456 }
1457 }
1458
1459 // Get next from appropriate iterator
1460 if let Some(ref mut iter) = self.ordered_iter {
1461 iter.next()
1462 } else if let Some(ref mut iter) = self.unordered_iter {
1463 iter.next()
1464 } else {
1465 None
1466 }
1467 }
1468}
1469
1470/// MemTable with MVCC support
1471///
1472/// Uses DashMap for lock-free concurrent access per key.
1473/// This eliminates the global write lock bottleneck.
1474///
1475/// Uses epoch-based dirty tracking for O(expired) GC instead of O(n) full scan.
1476/// Maintains a deferred sorted index for efficient scans:
1477/// - Writes: O(1) append to hot buffer
1478/// - Scans: O(N log N) sort-on-demand (amortized across many writes)
1479pub struct MvccMemTable {
1480 /// Key -> VersionChain (sharded for concurrent access)
1481 data: DashMap<Vec<u8>, VersionChain>,
1482 /// Deferred sorted index for efficient prefix/range scans (optional)
1483 /// O(1) insert to hot buffer, O(N log N) sort on first scan
1484 /// When None, scan_prefix will fall back to O(N) DashMap iteration
1485 deferred_index: Option<DeferredSortedIndex>,
1486 /// Legacy SkipMap for compatibility (used when deferred=false)
1487 ordered_index: Option<SkipMap<Vec<u8>, ()>>,
1488 /// Whether to use deferred sorting (true) or immediate SkipMap (false)
1489 #[allow(dead_code)]
1490 use_deferred: bool,
1491 /// Approximate size in bytes
1492 size_bytes: AtomicU64,
1493 /// Epoch-based dirty list for efficient GC
1494 dirty_list: EpochDirtyList,
1495}
1496
1497impl Default for MvccMemTable {
1498 fn default() -> Self {
1499 Self::new()
1500 }
1501}
1502
1503impl MvccMemTable {
1504 pub fn new() -> Self {
1505 Self::with_ordered_index(true)
1506 }
1507
1508 /// Create memtable with optional ordered index
1509 ///
1510 /// When `enable_ordered_index` is false, saves ~134 ns/op on writes
1511 /// but scan_prefix becomes O(N) instead of O(log N + K)
1512 ///
1513 /// Uses deferred sorting by default for better write performance:
1514 /// - Writes: O(1) append to hot buffer
1515 /// - Scans: O(N log N) sort-on-demand
1516 pub fn with_ordered_index(enable_ordered_index: bool) -> Self {
1517 Self::with_index_mode(enable_ordered_index, true)
1518 }
1519
1520 /// Create memtable with fine-grained control over indexing
1521 ///
1522 /// # Arguments
1523 /// * `enable_ordered_index` - Whether to maintain an ordered index
1524 /// * `use_deferred` - If true, use deferred sorting (O(1) writes, sort-on-scan)
1525 /// If false, use SkipMap (O(log N) writes)
1526 pub fn with_index_mode(enable_ordered_index: bool, use_deferred: bool) -> Self {
1527 Self {
1528 data: DashMap::new(),
1529 deferred_index: if enable_ordered_index && use_deferred {
1530 Some(DeferredSortedIndex::with_config(DeferredIndexConfig {
1531 max_unsorted_entries: 10_000, // Compact every 10K writes
1532 enabled: true,
1533 }))
1534 } else {
1535 None
1536 },
1537 ordered_index: if enable_ordered_index && !use_deferred {
1538 Some(SkipMap::new())
1539 } else {
1540 None
1541 },
1542 use_deferred,
1543 size_bytes: AtomicU64::new(0),
1544 dirty_list: EpochDirtyList::new(),
1545 }
1546 }
1547
1548 /// Write a key-value pair (uncommitted)
1549 pub fn write(&self, key: Vec<u8>, value: Option<Vec<u8>>, txn_id: u64) -> Result<()> {
1550 let value_size = value.as_ref().map(|v| v.len()).unwrap_or(0);
1551 let key_len = key.len();
1552
1553 // Track this key in the current epoch's dirty list for GC
1554 self.dirty_list.record_version(key.clone());
1555
1556 // Insert into ordered index for prefix scans (if enabled)
1557 // Deferred: O(1) append to hot buffer
1558 // SkipMap: O(log N) insert
1559 if let Some(ref idx) = self.deferred_index {
1560 idx.insert(key.clone());
1561 } else if let Some(ref idx) = self.ordered_index {
1562 idx.insert(key.clone(), ());
1563 }
1564
1565 // Use entry API for atomic get-or-insert
1566 let mut entry = self.data.entry(key).or_default();
1567
1568 // Check for write-write conflict
1569 if entry.has_write_conflict(txn_id) {
1570 return Err(SochDBError::Internal(
1571 "Write-write conflict detected".into(),
1572 ));
1573 }
1574 entry.add_uncommitted(value, txn_id);
1575 self.size_bytes
1576 .fetch_add((key_len + value_size) as u64, Ordering::Relaxed);
1577
1578 Ok(())
1579 }
1580
1581 /// Write multiple key-value pairs (uncommitted) - more efficient than individual writes
1582 ///
1583 /// Optimizations applied (Rec 3: MVCC Batching):
1584 /// - Batched dirty list tracking: single lock acquire for all keys
1585 /// - Deferred index: O(1) append per key
1586 pub fn write_batch(&self, writes: &[(Vec<u8>, Option<Vec<u8>>)], txn_id: u64) -> Result<()> {
1587 let mut total_size = 0u64;
1588
1589 // Rec 3: Batch MVCC tracking - single lock acquire for all keys
1590 self.dirty_list
1591 .record_versions_batch(writes.iter().map(|(k, _)| k.clone()));
1592
1593 for (key, value) in writes {
1594 // Insert into ordered index (if enabled)
1595 // Deferred: O(1) append, SkipMap: O(log N)
1596 if let Some(ref idx) = self.deferred_index {
1597 idx.insert(key.clone());
1598 } else if let Some(ref idx) = self.ordered_index {
1599 idx.insert(key.clone(), ());
1600 }
1601
1602 let mut entry = self.data.entry(key.clone()).or_default();
1603
1604 if entry.has_write_conflict(txn_id) {
1605 return Err(SochDBError::Internal(
1606 "Write-write conflict detected".into(),
1607 ));
1608 }
1609
1610 let value_size = value.as_ref().map(|v| v.len()).unwrap_or(0);
1611 entry.add_uncommitted(value.clone(), txn_id);
1612 total_size += (key.len() + value_size) as u64;
1613 }
1614
1615 self.size_bytes.fetch_add(total_size, Ordering::Relaxed);
1616 Ok(())
1617 }
1618
1619 /// Read at snapshot timestamp, with optional current txn to see own writes
1620 pub fn read(
1621 &self,
1622 key: &[u8],
1623 snapshot_ts: u64,
1624 current_txn_id: Option<u64>,
1625 ) -> Option<Vec<u8>> {
1626 self.data.get(key).and_then(|chain| {
1627 chain
1628 .read_at(snapshot_ts, current_txn_id)
1629 .and_then(|v| v.value.clone())
1630 })
1631 }
1632
1633 /// Commit all versions for a transaction
1634 ///
1635 /// Only updates the keys that were written by this transaction (tracked in write_set).
1636 /// Accepts InlineKey for zero-allocation MVCC tracking.
1637 pub fn commit(&self, txn_id: u64, commit_ts: u64, write_set: &HashSet<InlineKey>) {
1638 // Only iterate over keys we know were written - O(k) instead of O(n)
1639 for key in write_set {
1640 if let Some(mut chain) = self.data.get_mut(key.as_slice()) {
1641 chain.commit(txn_id, commit_ts);
1642 }
1643 }
1644 }
1645
1646 /// Legacy commit method (iterates all keys) - kept for backward compatibility
1647 #[allow(dead_code)]
1648 pub fn commit_all(&self, txn_id: u64, commit_ts: u64) {
1649 for mut entry in self.data.iter_mut() {
1650 entry.value_mut().commit(txn_id, commit_ts);
1651 }
1652 }
1653
1654 /// Abort all versions for a transaction
1655 pub fn abort(&self, txn_id: u64) {
1656 for mut entry in self.data.iter_mut() {
1657 entry.value_mut().abort(txn_id);
1658 }
1659 }
1660
1661 /// Scan keys with prefix at snapshot (without seeing uncommitted from other txns)
1662 ///
1663 /// ## Performance
1664 ///
1665 /// When ordered_index is enabled: O(log N + K) complexity
1666 /// - O(log N) to seek to the first key with prefix
1667 /// - O(K) to iterate matching keys
1668 ///
1669 /// When ordered_index is disabled: O(N) full DashMap scan (fallback)
1670 ///
1671 /// ## Optimizations Applied
1672 ///
1673 /// - Pre-allocates result vector based on expected output size
1674 /// - Uses batch-friendly iteration patterns
1675 /// - Minimizes allocations during iteration
1676 /// - Deferred index: compacts hot buffer on first scan for sorted iteration
1677 pub fn scan_prefix(
1678 &self,
1679 prefix: &[u8],
1680 snapshot_ts: u64,
1681 current_txn_id: Option<u64>,
1682 ) -> Vec<(Vec<u8>, Vec<u8>)> {
1683 // Estimate result size for pre-allocation (use 10% of total as heuristic)
1684 let estimated_size = (self.data.len() / 10).max(64);
1685 let mut results = Vec::with_capacity(estimated_size);
1686
1687 if let Some(ref idx) = self.deferred_index {
1688 // Deferred index path: sort-on-scan (compacts hot buffer if needed)
1689 for key in idx.range_from(prefix) {
1690 // Stop when we've passed the prefix range
1691 if !key.starts_with(prefix) {
1692 break;
1693 }
1694
1695 // O(1) lookup in DashMap for version chain
1696 if let Some(chain) = self.data.get(&key)
1697 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1698 && let Some(value) = &v.value
1699 {
1700 results.push((key, value.clone()));
1701 }
1702 }
1703 } else if let Some(ref idx) = self.ordered_index {
1704 // Fast path: O(log N) seek to first key >= prefix
1705 for entry in idx.range(prefix.to_vec()..) {
1706 let key = entry.key();
1707
1708 // Stop when we've passed the prefix range
1709 if !key.starts_with(prefix) {
1710 break;
1711 }
1712
1713 // O(1) lookup in DashMap for version chain
1714 if let Some(chain) = self.data.get(key)
1715 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1716 && let Some(value) = &v.value
1717 {
1718 results.push((key.clone(), value.clone()));
1719 }
1720 }
1721 } else {
1722 // Fallback: O(N) full DashMap scan when ordered_index is disabled
1723 // Optimized with batch-friendly iteration
1724 for entry in self.data.iter() {
1725 let key = entry.key();
1726 if !key.starts_with(prefix) {
1727 continue;
1728 }
1729 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
1730 && let Some(value) = &v.value
1731 {
1732 results.push((key.clone(), value.clone()));
1733 }
1734 }
1735 }
1736
1737 results
1738 }
1739
1740 /// Optimized full scan with batch allocation
1741 ///
1742 /// For use when scanning entire tables/namespaces.
1743 /// Pre-allocates based on actual data size.
1744 pub fn scan_all(
1745 &self,
1746 snapshot_ts: u64,
1747 current_txn_id: Option<u64>,
1748 ) -> Vec<(Vec<u8>, Vec<u8>)> {
1749 let mut results = Vec::with_capacity(self.data.len());
1750
1751 for entry in self.data.iter() {
1752 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
1753 && let Some(value) = &v.value
1754 {
1755 results.push((entry.key().clone(), value.clone()));
1756 }
1757 }
1758
1759 results
1760 }
1761
1762 /// Streaming scan iterator for very large datasets
1763 ///
1764 /// Returns an iterator that yields (key, value) pairs without
1765 /// materializing the entire result set in memory.
1766 pub fn scan_prefix_iter<'a>(
1767 &'a self,
1768 prefix: &'a [u8],
1769 snapshot_ts: u64,
1770 current_txn_id: Option<u64>,
1771 ) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a {
1772 self.data.iter().filter_map(move |entry| {
1773 let key = entry.key();
1774 if !key.starts_with(prefix) {
1775 return None;
1776 }
1777 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
1778 && let Some(value) = &v.value
1779 {
1780 Some((key.clone(), value.clone()))
1781 } else {
1782 None
1783 }
1784 })
1785 }
1786
1787 /// Scan range
1788 pub fn scan_range(
1789 &self,
1790 start: &[u8],
1791 end: &[u8],
1792 snapshot_ts: u64,
1793 current_txn_id: Option<u64>,
1794 ) -> Vec<(Vec<u8>, Vec<u8>)> {
1795 let mut results = Vec::new();
1796
1797 if let Some(ref idx) = self.deferred_index {
1798 // Deferred index path: sort-on-scan
1799 if end.is_empty() {
1800 for key in idx.range_from(start) {
1801 if let Some(chain) = self.data.get(&key)
1802 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1803 && let Some(value) = &v.value
1804 {
1805 results.push((key, value.clone()));
1806 }
1807 }
1808 } else {
1809 for key in idx.range(start, end) {
1810 if let Some(chain) = self.data.get(&key)
1811 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1812 && let Some(value) = &v.value
1813 {
1814 results.push((key, value.clone()));
1815 }
1816 }
1817 }
1818 } else if let Some(ref idx) = self.ordered_index {
1819 // Use range scan on SkipMap
1820 if end.is_empty() {
1821 // Unbounded end
1822 for entry in idx.range(start.to_vec()..) {
1823 let key = entry.key();
1824 if let Some(chain) = self.data.get(key)
1825 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1826 && let Some(value) = &v.value
1827 {
1828 results.push((key.clone(), value.clone()));
1829 }
1830 }
1831 } else {
1832 for entry in idx.range(start.to_vec()..end.to_vec()) {
1833 let key = entry.key();
1834 if let Some(chain) = self.data.get(key)
1835 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
1836 && let Some(value) = &v.value
1837 {
1838 results.push((key.clone(), value.clone()));
1839 }
1840 }
1841 }
1842 } else {
1843 // Fallback to full scan if no ordered index
1844 for entry in self.data.iter() {
1845 let key = entry.key();
1846
1847 if key.as_slice() < start {
1848 continue;
1849 }
1850 if !end.is_empty() && key.as_slice() >= end {
1851 continue;
1852 }
1853
1854 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
1855 && let Some(value) = &v.value
1856 {
1857 results.push((key.clone(), value.clone()));
1858 }
1859 }
1860 }
1861
1862 results
1863 }
1864
1865 /// Streaming range scan iterator for very large datasets
1866 ///
1867 /// Returns an iterator that yields (key, value) pairs without
1868 /// materializing the entire result set in memory. Uses the ordered
1869 /// index when available for O(log N + K) complexity.
1870 ///
1871 /// ## Zero-Allocation Design
1872 ///
1873 /// While the iterator itself cannot avoid allocations for returned
1874 /// values (since the caller needs ownership), it avoids:
1875 /// - Pre-materializing all results
1876 /// - Intermediate buffers
1877 /// - Repeated key comparisons for already-visited entries
1878 ///
1879 /// ## Usage
1880 ///
1881 /// ```ignore
1882 /// for (key, value) in memtable.scan_range_iter(b"start", b"end", ts, txn) {
1883 /// // Process each result as it arrives
1884 /// // Memory usage is O(1) per iteration, not O(N) total
1885 /// }
1886 /// ```
1887 pub fn scan_range_iter<'a>(
1888 &'a self,
1889 start: &'a [u8],
1890 end: &'a [u8],
1891 snapshot_ts: u64,
1892 current_txn_id: Option<u64>,
1893 ) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a {
1894 // Compact deferred index before scanning if needed
1895 if let Some(ref idx) = self.deferred_index {
1896 idx.compact();
1897 }
1898
1899 // Use either ordered index or full scan
1900 let use_ordered = self.ordered_index.is_some() || self.deferred_index.is_some();
1901
1902 // Create iterator based on availability of ordered index
1903 ScanRangeIterator {
1904 memtable: self,
1905 start: start.to_vec(),
1906 end: end.to_vec(),
1907 snapshot_ts,
1908 current_txn_id,
1909 use_ordered,
1910 ordered_iter: None,
1911 unordered_iter: None,
1912 initialized: false,
1913 }
1914 }
1915
1916 /// Get approximate size
1917 pub fn size(&self) -> u64 {
1918 self.size_bytes.load(Ordering::Relaxed)
1919 }
1920
1921 /// Garbage collect old versions using epoch-based dirty list
1922 ///
1923 /// O(expired_versions) instead of O(all_versions)
1924 /// Only visits keys that had versions created in the old epoch.
1925 pub fn gc(&self, min_active_ts: u64) -> usize {
1926 // Advance epoch and get the dirty keys from the old epoch
1927 let (_old_epoch, dirty_keys) = self.dirty_list.advance_epoch();
1928
1929 if dirty_keys.is_empty() {
1930 return 0;
1931 }
1932
1933 let mut gc_count = 0;
1934
1935 // Only visit keys that were modified in the old epoch
1936 // Use a HashSet to deduplicate keys that were written multiple times
1937 let unique_keys: std::collections::HashSet<_> = dirty_keys.into_iter().collect();
1938
1939 for key in unique_keys {
1940 if let Some(mut entry) = self.data.get_mut(&key) {
1941 let before = entry.value().version_count();
1942 entry.value_mut().gc(min_active_ts);
1943 gc_count += before.saturating_sub(entry.value().version_count());
1944 }
1945 }
1946
1947 gc_count
1948 }
1949
1950 /// Legacy full-scan GC (for testing or when epoch-based tracking isn't available)
1951 #[allow(dead_code)]
1952 pub fn gc_full_scan(&self, min_active_ts: u64) -> usize {
1953 let mut gc_count = 0;
1954
1955 for mut entry in self.data.iter_mut() {
1956 let before = entry.value().version_count();
1957 entry.value_mut().gc(min_active_ts);
1958 gc_count += before.saturating_sub(entry.value().version_count());
1959 }
1960
1961 gc_count
1962 }
1963}
1964
1965// =============================================================================
1966// Rec 11: Unified MvccStore Implementation for MvccMemTable
1967// =============================================================================
1968
1969impl sochdb_core::version_chain::MvccStore for MvccMemTable {
1970 fn mvcc_get(&self, key: &[u8], snapshot_ts: u64, txn_id: Option<u64>) -> Option<Vec<u8>> {
1971 self.read(key, snapshot_ts, txn_id)
1972 }
1973
1974 fn mvcc_put(
1975 &self,
1976 key: &[u8],
1977 value: Option<Vec<u8>>,
1978 txn_id: u64,
1979 ) -> std::result::Result<(), sochdb_core::version_chain::MvccStoreError> {
1980 let mut entry = self.data.entry(key.to_vec()).or_default();
1981 if entry.has_write_conflict(txn_id) {
1982 return Err(sochdb_core::version_chain::MvccStoreError::WriteConflict);
1983 }
1984 entry.add_uncommitted(value, txn_id);
1985 Ok(())
1986 }
1987
1988 fn mvcc_commit_key(&self, key: &[u8], txn_id: u64, commit_ts: u64) -> bool {
1989 if let Some(mut chain) = self.data.get_mut(key) {
1990 return chain.commit(txn_id, commit_ts);
1991 }
1992 false
1993 }
1994
1995 fn mvcc_abort_key(&self, key: &[u8], txn_id: u64) {
1996 if let Some(mut chain) = self.data.get_mut(key) {
1997 chain.abort(txn_id);
1998 }
1999 }
2000
2001 fn mvcc_has_conflict(&self, key: &[u8], txn_id: u64) -> bool {
2002 self.data
2003 .get(key)
2004 .map(|chain| chain.has_write_conflict(txn_id))
2005 .unwrap_or(false)
2006 }
2007
2008 fn mvcc_gc(&self, min_ts: u64) -> sochdb_core::version_chain::MvccGcStats {
2009 let mut stats = sochdb_core::version_chain::MvccGcStats::default();
2010 for mut entry in self.data.iter_mut() {
2011 stats.keys_scanned += 1;
2012 let before = entry.value().version_count();
2013 entry.value_mut().gc(min_ts);
2014 stats.versions_removed += before.saturating_sub(entry.value().version_count());
2015 }
2016 stats
2017 }
2018
2019 fn mvcc_key_count(&self) -> usize {
2020 self.data.len()
2021 }
2022}
2023
2024// ============================================================================
2025// ArenaMvccMemTable - Arena-Backed MVCC MemTable with Reduced Allocations
2026// ============================================================================
2027
2028use crate::key_buffer::ArenaKeyHandle;
2029
2030/// Epoch-based dirty list using ArenaKeyHandle for reduced allocations
2031struct ArenaEpochDirtyList {
2032 epochs: [parking_lot::Mutex<Vec<ArenaKeyHandle>>; 4],
2033 current_epoch: AtomicU64,
2034}
2035
2036impl ArenaEpochDirtyList {
2037 fn new() -> Self {
2038 Self {
2039 epochs: [
2040 parking_lot::Mutex::new(Vec::new()),
2041 parking_lot::Mutex::new(Vec::new()),
2042 parking_lot::Mutex::new(Vec::new()),
2043 parking_lot::Mutex::new(Vec::new()),
2044 ],
2045 current_epoch: AtomicU64::new(0),
2046 }
2047 }
2048
2049 #[inline]
2050 fn record_version(&self, key: ArenaKeyHandle) {
2051 let epoch = self.current_epoch.load(Ordering::Relaxed);
2052 let idx = (epoch as usize) % EPOCH_RING_SIZE;
2053 self.epochs[idx].lock().push(key);
2054 }
2055
2056 fn advance_epoch(&self) -> (u64, Vec<ArenaKeyHandle>) {
2057 let old_epoch = self.current_epoch.fetch_add(1, Ordering::SeqCst);
2058 let old_idx = (old_epoch as usize) % EPOCH_RING_SIZE;
2059 let mut guard = self.epochs[old_idx].lock();
2060 let keys = std::mem::take(&mut *guard);
2061 (old_epoch, keys)
2062 }
2063}
2064
2065/// Arena-backed MVCC MemTable with optimized key storage
2066///
2067/// This version uses `ArenaKeyHandle` instead of `Vec<u8>` for keys,
2068/// reducing per-write allocations from 3 to 1:
2069///
2070/// - Before: 3 × Vec<u8> clones per write (dirty_list, ordered_index, data)
2071/// - After: 1 × ArenaKeyHandle creation, 3 × O(1) copies (16 bytes each)
2072///
2073/// ## Performance
2074///
2075/// Expected improvement: 20-30% throughput increase on write-heavy workloads
2076/// by reducing:
2077/// - Heap allocations: 3 → 1 per write
2078/// - Bytes copied: 3L → L + 48 bytes (where L = key length)
2079pub struct ArenaMvccMemTable {
2080 /// Key -> VersionChain (uses ArenaKeyHandle for O(1) hash)
2081 data: DashMap<ArenaKeyHandle, VersionChain>,
2082 /// Ordered index for prefix scans
2083 ordered_index: Option<SkipMap<ArenaKeyHandle, ()>>,
2084 /// Approximate size in bytes
2085 size_bytes: AtomicU64,
2086 /// Epoch-based dirty list (arena-backed)
2087 dirty_list: ArenaEpochDirtyList,
2088}
2089
2090impl ArenaMvccMemTable {
2091 pub fn new() -> Self {
2092 Self::with_ordered_index(true)
2093 }
2094
2095 pub fn with_ordered_index(enable_ordered_index: bool) -> Self {
2096 Self {
2097 data: DashMap::new(),
2098 ordered_index: if enable_ordered_index {
2099 Some(SkipMap::new())
2100 } else {
2101 None
2102 },
2103 size_bytes: AtomicU64::new(0),
2104 dirty_list: ArenaEpochDirtyList::new(),
2105 }
2106 }
2107
2108 /// Write a key-value pair using arena key handle
2109 ///
2110 /// Only creates ONE ArenaKeyHandle, then copies it (16 bytes) to each location.
2111 /// This is much cheaper than cloning Vec<u8> which requires heap allocation.
2112 pub fn write(&self, key: &[u8], value: Option<Vec<u8>>, txn_id: u64) -> Result<()> {
2113 let value_size = value.as_ref().map(|v| v.len()).unwrap_or(0);
2114 let key_len = key.len();
2115
2116 // Create ONE ArenaKeyHandle - this is the only allocation for the key
2117 let key_handle = ArenaKeyHandle::new(key);
2118
2119 // Track in dirty list (O(1) copy of 16-byte handle)
2120 self.dirty_list.record_version(key_handle.clone());
2121
2122 // Insert into ordered index (O(1) copy of 16-byte handle)
2123 if let Some(ref idx) = self.ordered_index {
2124 idx.insert(key_handle.clone(), ());
2125 }
2126
2127 // Use entry API with the handle
2128 let mut entry = self.data.entry(key_handle).or_default();
2129
2130 if entry.has_write_conflict(txn_id) {
2131 return Err(SochDBError::Internal(
2132 "Write-write conflict detected".into(),
2133 ));
2134 }
2135 entry.add_uncommitted(value, txn_id);
2136 self.size_bytes
2137 .fetch_add((key_len + value_size) as u64, Ordering::Relaxed);
2138
2139 Ok(())
2140 }
2141
2142 /// Write batch using arena key handles
2143 pub fn write_batch(&self, writes: &[(&[u8], Option<Vec<u8>>)], txn_id: u64) -> Result<()> {
2144 let mut total_size = 0u64;
2145
2146 for (key, value) in writes {
2147 let key_handle = ArenaKeyHandle::new(key);
2148
2149 self.dirty_list.record_version(key_handle.clone());
2150
2151 if let Some(ref idx) = self.ordered_index {
2152 idx.insert(key_handle.clone(), ());
2153 }
2154
2155 let mut entry = self.data.entry(key_handle).or_default();
2156
2157 if entry.has_write_conflict(txn_id) {
2158 return Err(SochDBError::Internal(
2159 "Write-write conflict detected".into(),
2160 ));
2161 }
2162
2163 let value_size = value.as_ref().map(|v| v.len()).unwrap_or(0);
2164 entry.add_uncommitted(value.clone(), txn_id);
2165 total_size += (key.len() + value_size) as u64;
2166 }
2167
2168 self.size_bytes.fetch_add(total_size, Ordering::Relaxed);
2169 Ok(())
2170 }
2171
2172 /// Read at snapshot timestamp
2173 pub fn read(
2174 &self,
2175 key: &[u8],
2176 snapshot_ts: u64,
2177 current_txn_id: Option<u64>,
2178 ) -> Option<Vec<u8>> {
2179 // Create temporary handle for lookup (uses pre-computed hash for O(1) lookup)
2180 let key_handle = ArenaKeyHandle::new(key);
2181 self.data.get(&key_handle).and_then(|chain| {
2182 chain
2183 .read_at(snapshot_ts, current_txn_id)
2184 .and_then(|v| v.value.clone())
2185 })
2186 }
2187
2188 /// Commit transaction
2189 pub fn commit(&self, txn_id: u64, commit_ts: u64, write_set: &HashSet<InlineKey>) {
2190 for key in write_set {
2191 let key_handle = ArenaKeyHandle::new(key.as_slice());
2192 if let Some(mut chain) = self.data.get_mut(&key_handle) {
2193 chain.commit(txn_id, commit_ts);
2194 }
2195 }
2196 }
2197
2198 /// Abort transaction
2199 pub fn abort(&self, txn_id: u64) {
2200 for mut entry in self.data.iter_mut() {
2201 entry.value_mut().abort(txn_id);
2202 }
2203 }
2204
2205 /// Scan prefix
2206 pub fn scan_prefix(
2207 &self,
2208 prefix: &[u8],
2209 snapshot_ts: u64,
2210 current_txn_id: Option<u64>,
2211 ) -> Vec<(Vec<u8>, Vec<u8>)> {
2212 let mut results = Vec::new();
2213 let prefix_handle = ArenaKeyHandle::new(prefix);
2214
2215 if let Some(ref idx) = self.ordered_index {
2216 for entry in idx.range(prefix_handle..) {
2217 let key = entry.key();
2218
2219 if !key.as_bytes().starts_with(prefix) {
2220 break;
2221 }
2222
2223 if let Some(chain) = self.data.get(key)
2224 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
2225 && let Some(value) = &v.value
2226 {
2227 results.push((key.as_bytes().to_vec(), value.clone()));
2228 }
2229 }
2230 } else {
2231 for entry in self.data.iter() {
2232 let key = entry.key();
2233 if !key.as_bytes().starts_with(prefix) {
2234 continue;
2235 }
2236 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
2237 && let Some(value) = &v.value
2238 {
2239 results.push((key.as_bytes().to_vec(), value.clone()));
2240 }
2241 }
2242 }
2243
2244 results
2245 }
2246
2247 /// Get approximate size
2248 pub fn size(&self) -> u64 {
2249 self.size_bytes.load(Ordering::Relaxed)
2250 }
2251
2252 /// Garbage collect old versions
2253 pub fn gc(&self, min_active_ts: u64) -> usize {
2254 let (_old_epoch, dirty_keys) = self.dirty_list.advance_epoch();
2255
2256 if dirty_keys.is_empty() {
2257 return 0;
2258 }
2259
2260 let mut gc_count = 0;
2261 let unique_keys: std::collections::HashSet<_> = dirty_keys.into_iter().collect();
2262
2263 for key in unique_keys {
2264 if let Some(mut entry) = self.data.get_mut(&key) {
2265 let before = entry.value().version_count();
2266 entry.value_mut().gc(min_active_ts);
2267 gc_count += before.saturating_sub(entry.value().version_count());
2268 }
2269 }
2270
2271 gc_count
2272 }
2273}
2274
2275impl Default for ArenaMvccMemTable {
2276 fn default() -> Self {
2277 Self::new()
2278 }
2279}
2280
2281// ============================================================================
2282// MemTableKind - Unified MemTable Abstraction (Principal Engineer Pattern)
2283// ============================================================================
2284
2285/// Configuration for memtable type selection
2286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2287pub enum MemTableType {
2288 /// Standard MVCC memtable with deferred sorting
2289 /// Best for: general workloads, balanced read/write
2290 Standard,
2291 /// Arena-backed memtable with reduced allocations
2292 /// Best for: write-heavy workloads, large keys
2293 Arena,
2294}
2295
2296impl Default for MemTableType {
2297 fn default() -> Self {
2298 // Default to Standard which now has deferred sorting
2299 MemTableType::Standard
2300 }
2301}
2302
2303/// Unified memtable abstraction using enum dispatch
2304///
2305/// This pattern provides:
2306/// - Zero-cost abstraction (no vtable, no dynamic dispatch)
2307/// - Type-safe switching between implementations
2308/// - Easy extensibility for future memtable types
2309///
2310/// ## Why Enum over Trait Object?
2311///
2312/// - Hot path performance: enum match is a single branch vs vtable indirection
2313/// - Cache friendliness: no pointer chasing
2314/// - Inlining: compiler can inline through enum dispatch
2315pub enum MemTableKind {
2316 Standard(MvccMemTable),
2317 Arena(ArenaMvccMemTable),
2318}
2319
2320impl MemTableKind {
2321 /// Create a new memtable of the specified type
2322 pub fn new(kind: MemTableType, enable_ordered_index: bool) -> Self {
2323 match kind {
2324 MemTableType::Standard => {
2325 MemTableKind::Standard(MvccMemTable::with_ordered_index(enable_ordered_index))
2326 }
2327 MemTableType::Arena => {
2328 MemTableKind::Arena(ArenaMvccMemTable::with_ordered_index(enable_ordered_index))
2329 }
2330 }
2331 }
2332
2333 /// Write a key-value pair
2334 #[inline]
2335 pub fn write(&self, key: Vec<u8>, value: Option<Vec<u8>>, txn_id: u64) -> Result<()> {
2336 match self {
2337 MemTableKind::Standard(m) => m.write(key, value, txn_id),
2338 MemTableKind::Arena(m) => m.write(&key, value, txn_id),
2339 }
2340 }
2341
2342 /// Write batch of key-value pairs
2343 #[inline]
2344 pub fn write_batch(&self, writes: &[(Vec<u8>, Option<Vec<u8>>)], txn_id: u64) -> Result<()> {
2345 match self {
2346 MemTableKind::Standard(m) => m.write_batch(writes, txn_id),
2347 MemTableKind::Arena(m) => {
2348 // Convert to arena-compatible format
2349 let arena_writes: Vec<(&[u8], Option<Vec<u8>>)> = writes
2350 .iter()
2351 .map(|(k, v)| (k.as_slice(), v.clone()))
2352 .collect();
2353 m.write_batch(&arena_writes, txn_id)
2354 }
2355 }
2356 }
2357
2358 /// Read at snapshot timestamp
2359 #[inline]
2360 pub fn read(
2361 &self,
2362 key: &[u8],
2363 snapshot_ts: u64,
2364 current_txn_id: Option<u64>,
2365 ) -> Option<Vec<u8>> {
2366 match self {
2367 MemTableKind::Standard(m) => m.read(key, snapshot_ts, current_txn_id),
2368 MemTableKind::Arena(m) => m.read(key, snapshot_ts, current_txn_id),
2369 }
2370 }
2371
2372 /// Commit transaction
2373 #[inline]
2374 pub fn commit(&self, txn_id: u64, commit_ts: u64, write_set: &HashSet<InlineKey>) {
2375 match self {
2376 MemTableKind::Standard(m) => m.commit(txn_id, commit_ts, write_set),
2377 MemTableKind::Arena(m) => m.commit(txn_id, commit_ts, write_set),
2378 }
2379 }
2380
2381 /// Abort transaction
2382 #[inline]
2383 pub fn abort(&self, txn_id: u64) {
2384 match self {
2385 MemTableKind::Standard(m) => m.abort(txn_id),
2386 MemTableKind::Arena(m) => m.abort(txn_id),
2387 }
2388 }
2389
2390 /// Scan prefix
2391 #[inline]
2392 pub fn scan_prefix(
2393 &self,
2394 prefix: &[u8],
2395 snapshot_ts: u64,
2396 current_txn_id: Option<u64>,
2397 ) -> Vec<(Vec<u8>, Vec<u8>)> {
2398 match self {
2399 MemTableKind::Standard(m) => m.scan_prefix(prefix, snapshot_ts, current_txn_id),
2400 MemTableKind::Arena(m) => m.scan_prefix(prefix, snapshot_ts, current_txn_id),
2401 }
2402 }
2403
2404 /// Scan range
2405 #[inline]
2406 pub fn scan_range(
2407 &self,
2408 start: &[u8],
2409 end: &[u8],
2410 snapshot_ts: u64,
2411 current_txn_id: Option<u64>,
2412 ) -> Vec<(Vec<u8>, Vec<u8>)> {
2413 match self {
2414 MemTableKind::Standard(m) => m.scan_range(start, end, snapshot_ts, current_txn_id),
2415 MemTableKind::Arena(m) => {
2416 // ArenaMvccMemTable doesn't have scan_range, use scan_prefix fallback
2417 let mut results = Vec::new();
2418 if let Some(ref idx) = m.ordered_index {
2419 let start_handle = ArenaKeyHandle::new(start);
2420 let end_handle = ArenaKeyHandle::new(end);
2421
2422 if end.is_empty() {
2423 for entry in idx.range(start_handle..) {
2424 let key = entry.key();
2425 if let Some(chain) = m.data.get(key)
2426 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
2427 && let Some(value) = &v.value
2428 {
2429 results.push((key.as_bytes().to_vec(), value.clone()));
2430 }
2431 }
2432 } else {
2433 for entry in idx.range(start_handle..end_handle) {
2434 let key = entry.key();
2435 if let Some(chain) = m.data.get(key)
2436 && let Some(v) = chain.read_at(snapshot_ts, current_txn_id)
2437 && let Some(value) = &v.value
2438 {
2439 results.push((key.as_bytes().to_vec(), value.clone()));
2440 }
2441 }
2442 }
2443 } else {
2444 for entry in m.data.iter() {
2445 let key = entry.key();
2446 let key_bytes = key.as_bytes();
2447 if key_bytes < start {
2448 continue;
2449 }
2450 if !end.is_empty() && key_bytes >= end {
2451 continue;
2452 }
2453 if let Some(v) = entry.value().read_at(snapshot_ts, current_txn_id)
2454 && let Some(value) = &v.value
2455 {
2456 results.push((key_bytes.to_vec(), value.clone()));
2457 }
2458 }
2459 }
2460 results
2461 }
2462 }
2463 }
2464
2465 /// Scan range iterator (returns collected results for now)
2466 #[inline]
2467 pub fn scan_range_iter<'a>(
2468 &'a self,
2469 start: &'a [u8],
2470 end: &'a [u8],
2471 snapshot_ts: u64,
2472 current_txn_id: Option<u64>,
2473 ) -> Box<dyn Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a> {
2474 match self {
2475 MemTableKind::Standard(m) => {
2476 Box::new(m.scan_range_iter(start, end, snapshot_ts, current_txn_id))
2477 }
2478 MemTableKind::Arena(_) => {
2479 // Arena version returns collected results as iterator
2480 let results = self.scan_range(start, end, snapshot_ts, current_txn_id);
2481 Box::new(results.into_iter())
2482 }
2483 }
2484 }
2485
2486 /// Get approximate size
2487 #[inline]
2488 pub fn size(&self) -> u64 {
2489 match self {
2490 MemTableKind::Standard(m) => m.size(),
2491 MemTableKind::Arena(m) => m.size(),
2492 }
2493 }
2494
2495 /// Garbage collect old versions
2496 #[inline]
2497 pub fn gc(&self, min_active_ts: u64) -> usize {
2498 match self {
2499 MemTableKind::Standard(m) => m.gc(min_active_ts),
2500 MemTableKind::Arena(m) => m.gc(min_active_ts),
2501 }
2502 }
2503
2504 /// Get the kind of memtable
2505 pub fn kind(&self) -> MemTableType {
2506 match self {
2507 MemTableKind::Standard(_) => MemTableType::Standard,
2508 MemTableKind::Arena(_) => MemTableType::Arena,
2509 }
2510 }
2511}
2512
2513/// Durable storage engine with full ACID support
2514pub struct DurableStorage {
2515 /// Path to storage directory
2516 path: PathBuf,
2517 /// Write-ahead log
2518 wal: Arc<TxnWal>,
2519 /// MVCC manager
2520 mvcc: Arc<MvccManager>,
2521 /// In-memory data (unified abstraction over Standard/Arena)
2522 memtable: Arc<MemTableKind>,
2523 /// Per-transaction WAL buffers for batched writes
2524 /// Key: txn_id, Value: TxnWalBuffer that accumulates writes in memory
2525 /// At commit, buffer is flushed to WAL with single lock acquisition
2526 txn_write_buffers: DashMap<u64, TxnWalBuffer>,
2527 /// Group commit buffer (optional)
2528 group_commit: Option<Arc<EventDrivenGroupCommit>>,
2529 /// Recovery state
2530 needs_recovery: AtomicU64, // 1 = needs recovery
2531 /// Last checkpoint LSN
2532 last_checkpoint_lsn: AtomicU64,
2533 /// Synchronous mode (like SQLite's PRAGMA synchronous)
2534 /// 0 = OFF, 1 = NORMAL (periodic sync), 2 = FULL (sync every commit)
2535 sync_mode: AtomicU64,
2536 /// Commits since last sync (for NORMAL mode)
2537 commits_since_sync: AtomicU64,
2538 /// Adaptive batch sizing for NORMAL mode (Little's Law)
2539 /// Arrival rate in requests/sec × 1000 for precision
2540 arrival_rate_ema: AtomicU64,
2541 /// Last commit timestamp in microseconds
2542 last_commit_us: AtomicU64,
2543 /// Estimated fsync latency in microseconds
2544 fsync_latency_us: AtomicU64,
2545 /// Database lock for exclusive access (None = no locking)
2546 #[allow(dead_code)]
2547 db_lock: Option<crate::lock::DatabaseLock>,
2548 /// Whether at-rest encryption is active for this instance (drives the live
2549 /// per-instance durability matrix). Set from the resolved keyring at open.
2550 at_rest_encrypted: bool,
2551 /// Whether Point-in-Time Recovery is enabled for this database (Task 3B PITR
2552 /// phase 1). Derived from the presence of `wal.manifest` at open (the manifest
2553 /// is the single source of truth), or set by `enable_point_in_time_recovery`.
2554 /// When enabled, the destructive `truncate_wal()` is forbidden (segment
2555 /// sealing is the PITR-safe replacement, landing in a later phase) so the WAL
2556 /// record ordinal stays a stable, durable monotonic LSN across restarts.
2557 pitr_enabled: AtomicBool,
2558}
2559
2560/// Encryption configuration for opening a [`DurableStorage`].
2561///
2562/// `disabled()` (the default for the legacy open variants) keeps the database
2563/// plaintext and byte-compatible with pre-encryption binaries. `with_kek()`
2564/// supplies the Key-Encryption-Key — the operator secret that *wraps* a
2565/// per-database data key; it is never used verbatim as the cipher key (see
2566/// [`crate::keyring`]). A wrong/missing KEK for an encrypted database fails
2567/// closed at open (the DB will refuse to open, never silently read as plaintext).
2568pub struct StorageEncryption {
2569 /// The KEK. `None` ⇒ plaintext database.
2570 pub kek: Option<EncryptionKey>,
2571 /// Human-readable identifier for the key source (e.g. "env:SOCHDB_ENCRYPTION_KEY",
2572 /// "embedded", "kms:..."). Bound into the keyring for provenance.
2573 pub source_id: String,
2574}
2575
2576impl StorageEncryption {
2577 /// Plaintext (no encryption) — the default.
2578 pub fn disabled() -> Self {
2579 Self {
2580 kek: None,
2581 source_id: "none".to_string(),
2582 }
2583 }
2584
2585 /// Encrypt at rest under the given KEK.
2586 pub fn with_kek(kek: EncryptionKey, source_id: impl Into<String>) -> Self {
2587 Self {
2588 kek: Some(kek),
2589 source_id: source_id.into(),
2590 }
2591 }
2592
2593 /// Whether a key is configured (i.e. encryption is requested).
2594 pub fn is_enabled(&self) -> bool {
2595 self.kek.is_some()
2596 }
2597}
2598
2599impl DurableStorage {
2600 /// Open or create durable storage at path
2601 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
2602 Self::open_with_config(path, true)
2603 }
2604
2605 /// Open with configurable ordered index
2606 ///
2607 /// When `enable_ordered_index` is false, saves ~134 ns/op on writes
2608 /// but scan_prefix becomes O(N) instead of O(log N + K)
2609 pub fn open_with_config<P: AsRef<Path>>(path: P, enable_ordered_index: bool) -> Result<Self> {
2610 Self::open_with_full_config(path, enable_ordered_index, MemTableType::Standard)
2611 }
2612
2613 /// Open with arena-backed memtable for write-heavy workloads
2614 ///
2615 /// Uses ArenaMvccMemTable which reduces per-write allocations from 3 to 1.
2616 /// Best for workloads with:
2617 /// - High write throughput
2618 /// - Large keys (reduces allocation overhead)
2619 /// - Minimal concurrent reads during writes
2620 pub fn open_with_arena<P: AsRef<Path>>(path: P) -> Result<Self> {
2621 Self::open_with_full_config(path, true, MemTableType::Arena)
2622 }
2623
2624 /// Open with full configuration options
2625 ///
2626 /// # Arguments
2627 /// * `path` - Storage directory path
2628 /// * `enable_ordered_index` - Enable ordered index for O(log N) scans
2629 /// * `memtable_type` - Type of memtable to use (Standard or Arena)
2630 ///
2631 /// # Locking
2632 ///
2633 /// Acquires an exclusive advisory lock on the database directory.
2634 /// This prevents concurrent multi-process access which would corrupt data.
2635 /// If another process has the database open, returns `Err(DatabaseLocked)`.
2636 pub fn open_with_full_config<P: AsRef<Path>>(
2637 path: P,
2638 enable_ordered_index: bool,
2639 memtable_type: MemTableType,
2640 ) -> Result<Self> {
2641 Self::open_with_full_config_internal(
2642 path,
2643 enable_ordered_index,
2644 memtable_type,
2645 true,
2646 StorageEncryption::disabled(),
2647 )
2648 }
2649
2650 /// Open with at-rest encryption configured via [`StorageEncryption`].
2651 ///
2652 /// With `StorageEncryption::disabled()` this is identical to
2653 /// [`Self::open_with_full_config`]. With a KEK, the database is opened (or
2654 /// created) encrypted: a per-DB data key is generated and wrapped into the
2655 /// keyring on first open, and a wrong/missing key on a subsequent open fails
2656 /// closed. Reads are unaffected (the live read path is in-memory); only the
2657 /// WAL write/recovery path pays the AEAD cost.
2658 pub fn open_with_encryption<P: AsRef<Path>>(
2659 path: P,
2660 enable_ordered_index: bool,
2661 memtable_type: MemTableType,
2662 encryption: StorageEncryption,
2663 ) -> Result<Self> {
2664 Self::open_with_full_config_internal(
2665 path,
2666 enable_ordered_index,
2667 memtable_type,
2668 true,
2669 encryption,
2670 )
2671 }
2672
2673 /// The live, per-instance durability capabilities of THIS database.
2674 ///
2675 /// Unlike the build-level [`crate::durability_capabilities`] (which reports
2676 /// defaults), this reflects the actual resolved state — notably whether
2677 /// at-rest encryption is active for this opened instance.
2678 pub fn durability_capabilities(&self) -> DurabilityCapabilities {
2679 DurabilityCapabilities {
2680 crash_recovery: true,
2681 at_rest_encryption: self.at_rest_encrypted,
2682 // Point-in-time recovery is live (recover_to) when PITR is enabled:
2683 // the WAL is fully retained and can be replayed to an LSN/timestamp.
2684 point_in_time_recovery: self.pitr_enabled.load(Ordering::SeqCst),
2685 // ARIES / fencing remain unwired on the live path.
2686 aries_checkpoint: false,
2687 wal_fencing: false,
2688 }
2689 }
2690
2691 /// Whether at-rest encryption is active for this instance.
2692 pub fn is_encrypted(&self) -> bool {
2693 self.at_rest_encrypted
2694 }
2695
2696 /// Open without locking (for testing crash recovery scenarios)
2697 ///
2698 /// # Safety
2699 /// This should ONLY be used in tests that simulate crashes by forgetting
2700 /// the storage instance. In production, always use `open_with_full_config`.
2701 #[cfg(test)]
2702 pub fn open_without_lock<P: AsRef<Path>>(path: P) -> Result<Self> {
2703 Self::open_with_full_config_internal(
2704 path,
2705 true,
2706 MemTableType::Standard,
2707 false,
2708 StorageEncryption::disabled(),
2709 )
2710 }
2711
2712 /// Open an ephemeral (in-memory-like) DurableStorage backed by a temp directory.
2713 ///
2714 /// Uses the full DurableStorage engine (WAL, MVCC, SSI) but writes to a
2715 /// temporary directory that is automatically cleaned up when the
2716 /// `EphemeralHandle` is dropped. This ensures test and production code paths
2717 /// are identical — bugs found in tests are guaranteed to reproduce in production.
2718 ///
2719 /// # Returns
2720 /// An `EphemeralHandle` that owns both the storage and the temp directory.
2721 /// Access the storage via `handle.storage()` or `Deref` coercion.
2722 ///
2723 /// # Example
2724 /// ```ignore
2725 /// let handle = DurableStorage::open_ephemeral()?;
2726 /// let txn = handle.begin_transaction()?;
2727 /// handle.write(txn, b"key".to_vec(), b"value".to_vec())?;
2728 /// handle.commit(txn)?;
2729 /// // temp directory cleaned up when `handle` drops
2730 /// ```
2731 pub fn open_ephemeral() -> Result<EphemeralHandle> {
2732 let tmp = tempfile::tempdir().map_err(|e| SochDBError::Io(e))?;
2733 let storage = Self::open_with_full_config_internal(
2734 tmp.path(),
2735 true,
2736 MemTableType::Standard,
2737 false, // No lock needed for ephemeral
2738 StorageEncryption::disabled(),
2739 )?;
2740 Ok(EphemeralHandle {
2741 storage,
2742 _tmpdir: tmp,
2743 })
2744 }
2745
2746 /// Open an ephemeral DurableStorage with group commit enabled.
2747 ///
2748 /// Same as `open_ephemeral()` but with group commit for higher throughput.
2749 pub fn open_ephemeral_with_group_commit() -> Result<EphemeralHandle> {
2750 let tmp = tempfile::tempdir().map_err(|e| SochDBError::Io(e))?;
2751 let mut storage = Self::open_with_full_config_internal(
2752 tmp.path(),
2753 true,
2754 MemTableType::Standard,
2755 false,
2756 StorageEncryption::disabled(),
2757 )?;
2758
2759 let wal = storage.wal.clone();
2760 let gc = EventDrivenGroupCommit::new(move |txn_ids: &[u64]| {
2761 for &txn_id in txn_ids {
2762 let entry = TxnWalEntry::txn_commit(txn_id);
2763 wal.append_no_flush(&entry).map_err(|e| e.to_string())?;
2764 }
2765 wal.flush().map_err(|e| e.to_string())?;
2766 wal.sync().map_err(|e| e.to_string())?;
2767 Ok(std::time::SystemTime::now()
2768 .duration_since(std::time::UNIX_EPOCH)
2769 .unwrap()
2770 .as_micros() as u64)
2771 });
2772 storage.group_commit = Some(Arc::new(gc));
2773
2774 Ok(EphemeralHandle {
2775 storage,
2776 _tmpdir: tmp,
2777 })
2778 }
2779
2780 fn open_with_full_config_internal<P: AsRef<Path>>(
2781 path: P,
2782 enable_ordered_index: bool,
2783 memtable_type: MemTableType,
2784 acquire_lock: bool,
2785 encryption: StorageEncryption,
2786 ) -> Result<Self> {
2787 let path = path.as_ref().to_path_buf();
2788 std::fs::create_dir_all(&path)?;
2789
2790 // Acquire exclusive lock on database directory (unless disabled for testing)
2791 let db_lock = if acquire_lock {
2792 Some(
2793 crate::lock::DatabaseLock::acquire(&path)
2794 .map_err(|e| SochDBError::LockError(e.to_string()))?,
2795 )
2796 } else {
2797 None
2798 };
2799
2800 let wal_path = path.join("wal.log");
2801
2802 // Resolve at-rest encryption from the keyring BEFORE touching the WAL.
2803 // - A fresh DB (no wal.log yet) with a KEK ⇒ create an encrypted keyring.
2804 // - An existing plaintext DB with a KEK ⇒ refused (must migrate explicitly).
2805 // - An encrypted DB with a wrong/missing KEK ⇒ refused fail-closed.
2806 let is_new_db = !wal_path.exists();
2807 let enc_state = keyring::load_or_init(
2808 &path,
2809 encryption.kek.as_ref(),
2810 &encryption.source_id,
2811 is_new_db,
2812 )?;
2813 let at_rest_encrypted = enc_state.is_encrypted();
2814 let wal = Arc::new(TxnWal::new_with_encryption(
2815 &wal_path,
2816 enc_state.engine(),
2817 enc_state.db_uuid(),
2818 enc_state.key_epoch(),
2819 )?);
2820
2821 // PITR anchor: the presence of wal.manifest is the single source of truth
2822 // that this DB is PITR-enabled. If present, seed last_checkpoint_lsn from
2823 // it (it is otherwise in-memory and lost on restart).
2824 let (pitr_enabled, initial_checkpoint_lsn) =
2825 if crate::wal_manifest::WalManifest::exists(&path) {
2826 let m = crate::wal_manifest::WalManifest::load(&path)?;
2827 (true, m.last_checkpoint_lsn)
2828 } else {
2829 (false, 0)
2830 };
2831
2832 let storage = Self {
2833 path,
2834 wal: wal.clone(),
2835 mvcc: Arc::new(MvccManager::new()),
2836 memtable: Arc::new(MemTableKind::new(memtable_type, enable_ordered_index)),
2837 txn_write_buffers: DashMap::new(),
2838 group_commit: None,
2839 needs_recovery: AtomicU64::new(0),
2840 last_checkpoint_lsn: AtomicU64::new(initial_checkpoint_lsn),
2841 sync_mode: AtomicU64::new(1), // Default: NORMAL (like SQLite)
2842 commits_since_sync: AtomicU64::new(0),
2843 // Adaptive batch sizing (Little's Law)
2844 arrival_rate_ema: AtomicU64::new(1_000_000), // 1000 req/s × 1000 initial
2845 last_commit_us: AtomicU64::new(0),
2846 fsync_latency_us: AtomicU64::new(5000), // 5ms default
2847 db_lock,
2848 at_rest_encrypted,
2849 pitr_enabled: AtomicBool::new(pitr_enabled),
2850 };
2851
2852 // Check if recovery needed
2853 if storage.check_recovery_needed()? {
2854 storage.needs_recovery.store(1, Ordering::SeqCst);
2855 }
2856
2857 Ok(storage)
2858 }
2859
2860 /// Open with group commit enabled
2861 pub fn open_with_group_commit<P: AsRef<Path>>(path: P) -> Result<Self> {
2862 Self::open_with_group_commit_and_config(path, true)
2863 }
2864
2865 /// Open with group commit and configurable ordered index
2866 pub fn open_with_group_commit_and_config<P: AsRef<Path>>(
2867 path: P,
2868 enable_ordered_index: bool,
2869 ) -> Result<Self> {
2870 let mut storage = Self::open_with_config(path, enable_ordered_index)?;
2871
2872 let wal = storage.wal.clone();
2873 let gc = EventDrivenGroupCommit::new(move |txn_ids: &[u64]| {
2874 // Write all commit records WITHOUT flushing (batch them)
2875 for &txn_id in txn_ids {
2876 let entry = TxnWalEntry::txn_commit(txn_id);
2877 wal.append_no_flush(&entry).map_err(|e| e.to_string())?;
2878 }
2879
2880 // Then do a SINGLE flush + fsync for the entire batch
2881 wal.flush().map_err(|e| e.to_string())?;
2882 wal.sync().map_err(|e| e.to_string())?;
2883
2884 // Return commit timestamp
2885 Ok(std::time::SystemTime::now()
2886 .duration_since(std::time::UNIX_EPOCH)
2887 .unwrap()
2888 .as_micros() as u64)
2889 });
2890
2891 storage.group_commit = Some(Arc::new(gc));
2892 Ok(storage)
2893 }
2894
2895 /// Open with IndexPolicy for automatic memtable/index configuration
2896 ///
2897 /// This is the recommended constructor for new code. The policy determines:
2898 /// - Whether to use ordered index (ScanOptimized only)
2899 /// - Whether to use arena-backed memtable (WriteOptimized, AppendOnly)
2900 /// - Default settings optimized for the workload pattern
2901 ///
2902 /// # Arguments
2903 /// * `path` - Storage directory path
2904 /// * `policy` - Index policy determining write/scan tradeoffs
2905 /// * `group_commit` - Whether to enable group commit for throughput
2906 pub fn open_with_policy<P: AsRef<Path>>(
2907 path: P,
2908 policy: crate::index_policy::IndexPolicy,
2909 group_commit: bool,
2910 ) -> Result<Self> {
2911 Self::open_with_policy_encrypted(path, policy, group_commit, StorageEncryption::disabled())
2912 }
2913
2914 /// Policy-based open with at-rest encryption configured.
2915 ///
2916 /// Identical to [`Self::open_with_policy`] but threads a [`StorageEncryption`]
2917 /// down to the keyring/WAL so the embedded `Database` kernel can open (or
2918 /// create) an encrypted database. `StorageEncryption::disabled()` is exactly
2919 /// the plaintext behaviour.
2920 pub fn open_with_policy_encrypted<P: AsRef<Path>>(
2921 path: P,
2922 policy: crate::index_policy::IndexPolicy,
2923 group_commit: bool,
2924 encryption: StorageEncryption,
2925 ) -> Result<Self> {
2926 use crate::index_policy::IndexPolicy;
2927
2928 // Derive configuration from policy
2929 let (enable_ordered_index, memtable_type) = match policy {
2930 IndexPolicy::WriteOptimized | IndexPolicy::AppendOnly => {
2931 // Write-heavy: no ordered index, use arena for reduced allocations
2932 (false, MemTableType::Arena)
2933 }
2934 IndexPolicy::Balanced => {
2935 // Mixed OLTP: deferred sorting (already implemented in Standard)
2936 (true, MemTableType::Standard)
2937 }
2938 IndexPolicy::ScanOptimized => {
2939 // Scan-heavy: maintain ordered index
2940 (true, MemTableType::Standard)
2941 }
2942 };
2943
2944 if group_commit {
2945 let mut storage =
2946 Self::open_with_encryption(path, enable_ordered_index, memtable_type, encryption)?;
2947
2948 let wal = storage.wal.clone();
2949 let gc = EventDrivenGroupCommit::new(move |txn_ids: &[u64]| {
2950 for &txn_id in txn_ids {
2951 let entry = TxnWalEntry::txn_commit(txn_id);
2952 wal.append_no_flush(&entry).map_err(|e| e.to_string())?;
2953 }
2954 wal.flush().map_err(|e| e.to_string())?;
2955 wal.sync().map_err(|e| e.to_string())?;
2956 Ok(std::time::SystemTime::now()
2957 .duration_since(std::time::UNIX_EPOCH)
2958 .unwrap()
2959 .as_micros() as u64)
2960 });
2961 storage.group_commit = Some(Arc::new(gc));
2962 Ok(storage)
2963 } else {
2964 Self::open_with_encryption(path, enable_ordered_index, memtable_type, encryption)
2965 }
2966 }
2967
2968 /// Open storage for concurrent mode (multi-reader, single-writer)
2969 ///
2970 /// This method opens the storage WITHOUT acquiring the exclusive file lock.
2971 /// Coordination is handled by the concurrent MVCC layer instead.
2972 ///
2973 /// # Safety
2974 ///
2975 /// This must ONLY be called from `Database::open_concurrent()` which
2976 /// manages the concurrent MVCC coordination. Direct use will cause
2977 /// data corruption.
2978 pub fn open_for_concurrent<P: AsRef<Path>>(
2979 path: P,
2980 policy: crate::index_policy::IndexPolicy,
2981 ) -> Result<Self> {
2982 Self::open_for_concurrent_encrypted(path, policy, StorageEncryption::disabled())
2983 }
2984
2985 /// Concurrent-mode open with at-rest encryption configured.
2986 ///
2987 /// Identical to [`Self::open_for_concurrent`] but threads a
2988 /// [`StorageEncryption`] through, so an encrypted database can also be opened
2989 /// in concurrent (multi-reader) mode rather than failing closed for lack of a
2990 /// key channel.
2991 pub fn open_for_concurrent_encrypted<P: AsRef<Path>>(
2992 path: P,
2993 policy: crate::index_policy::IndexPolicy,
2994 encryption: StorageEncryption,
2995 ) -> Result<Self> {
2996 use crate::index_policy::IndexPolicy;
2997
2998 let (enable_ordered_index, memtable_type) = match policy {
2999 IndexPolicy::WriteOptimized | IndexPolicy::AppendOnly => (false, MemTableType::Arena),
3000 IndexPolicy::Balanced => (true, MemTableType::Standard),
3001 IndexPolicy::ScanOptimized => (true, MemTableType::Standard),
3002 };
3003
3004 // Open WITHOUT exclusive file lock (concurrent MVCC handles coordination)
3005 Self::open_with_full_config_internal(
3006 path,
3007 enable_ordered_index,
3008 memtable_type,
3009 false,
3010 encryption,
3011 )
3012 }
3013
3014 /// Get the memtable type being used
3015 pub fn memtable_type(&self) -> MemTableType {
3016 self.memtable.kind()
3017 }
3018
3019 /// Check if recovery is needed (dirty shutdown detection)
3020 ///
3021 /// Note: Recovery must ALWAYS run to rebuild the in-memory memtable from WAL.
3022 /// The clean_shutdown marker only tells us if there might be uncommitted transactions,
3023 /// but committed data still needs to be loaded from WAL into the memtable.
3024 fn check_recovery_needed(&self) -> Result<bool> {
3025 let marker_path = self.path.join(".clean_shutdown");
3026 if marker_path.exists() {
3027 // Clean shutdown - remove marker
3028 std::fs::remove_file(&marker_path)?;
3029 }
3030 // ALWAYS need recovery to rebuild memtable from WAL
3031 // The memtable is in-memory only and needs to be restored on every startup
3032 Ok(true)
3033 }
3034
3035 /// Perform crash recovery
3036 pub fn recover(&self) -> Result<RecoveryStats> {
3037 if self.needs_recovery.load(Ordering::SeqCst) == 0 {
3038 return Ok(RecoveryStats::default());
3039 }
3040
3041 let (writes, txn_count) = self.wal.replay_for_recovery()?;
3042
3043 // Apply committed writes to memtable
3044 let recovery_txn_id = self.wal.alloc_txn_id();
3045 let commit_ts = self.mvcc.alloc_commit_ts();
3046
3047 // Collect keys being written for efficient commit
3048 let mut write_set: HashSet<InlineKey> = HashSet::new();
3049 for (key, value) in &writes {
3050 write_set.insert(SmallVec::from_slice(key));
3051 self.memtable
3052 .write(key.clone(), Some(value.clone()), recovery_txn_id)?;
3053 }
3054 self.memtable.commit(recovery_txn_id, commit_ts, &write_set);
3055
3056 self.needs_recovery.store(0, Ordering::SeqCst);
3057
3058 Ok(RecoveryStats {
3059 transactions_recovered: txn_count,
3060 writes_recovered: writes.len(),
3061 commit_ts,
3062 })
3063 }
3064
3065 /// Point-in-Time Recovery: rebuild the in-memory state as of `target`.
3066 ///
3067 /// This is the PITR analogue of [`Self::recover`] — call it on a FRESH open
3068 /// INSTEAD of `recover()` (not in addition to it), to materialize the
3069 /// database as it existed at a chosen LSN or commit timestamp. Because PITR
3070 /// mode never truncates the WAL, the full history is retained and replayed up
3071 /// to (and stopping at) the target, with transaction atomicity preserved
3072 /// (a transaction is applied only if its commit is within the target).
3073 ///
3074 /// Requires PITR to be enabled (the WAL must be fully retained); returns an
3075 /// error otherwise, since a truncated WAL cannot honor an arbitrary target.
3076 pub fn recover_to(&self, target: crate::txn_wal::RecoveryTarget) -> Result<RecoveryStats> {
3077 if !self.pitr_enabled.load(Ordering::SeqCst) {
3078 return Err(SochDBError::InvalidArgument(
3079 "recover_to requires Point-in-Time Recovery to be enabled \
3080 (the full WAL must be retained); call enable_point_in_time_recovery first"
3081 .to_string(),
3082 ));
3083 }
3084
3085 // Single-shot recovery on a fresh open: refuse if state was already
3086 // rebuilt (by recover() or a prior recover_to()). Re-applying would layer
3087 // a stale set over the point-in-time state under a newer commit_ts and
3088 // silently corrupt it (recover()/recover_to() both clear needs_recovery).
3089 if self.needs_recovery.load(Ordering::SeqCst) == 0 {
3090 return Err(SochDBError::InvalidArgument(
3091 "recover_to must be the sole recovery on a fresh open, but state \
3092 was already recovered; reopen the database and call recover_to first"
3093 .to_string(),
3094 ));
3095 }
3096
3097 // Make the on-disk WAL match current_lsn() before replaying. Under the
3098 // default NORMAL sync mode the commit record(s) may still sit in the
3099 // BufWriter, while current_lsn() counts the in-memory sequence — so
3100 // without this flush+fsync a captured-LSN cut would silently drop
3101 // committed-but-unflushed records (replay reads a fresh on-disk handle).
3102 self.wal.flush()?;
3103 self.wal.sync()?;
3104
3105 let (writes, txn_count) = self.wal.replay_to_target(target)?;
3106
3107 // Apply the bounded set of committed writes to the (fresh) memtable,
3108 // mirroring recover().
3109 let recovery_txn_id = self.wal.alloc_txn_id();
3110 let commit_ts = self.mvcc.alloc_commit_ts();
3111 let mut write_set: HashSet<InlineKey> = HashSet::new();
3112 for (key, value) in &writes {
3113 write_set.insert(SmallVec::from_slice(key));
3114 self.memtable
3115 .write(key.clone(), Some(value.clone()), recovery_txn_id)?;
3116 }
3117 self.memtable.commit(recovery_txn_id, commit_ts, &write_set);
3118
3119 self.needs_recovery.store(0, Ordering::SeqCst);
3120
3121 Ok(RecoveryStats {
3122 transactions_recovered: txn_count,
3123 writes_recovered: writes.len(),
3124 commit_ts,
3125 })
3126 }
3127
3128 /// Begin a new transaction
3129 pub fn begin_transaction(&self) -> Result<u64> {
3130 let txn_id = self.wal.begin_transaction()?;
3131 self.mvcc.begin(txn_id);
3132 Ok(txn_id)
3133 }
3134
3135 /// Begin a transaction with a specific mode (ReadOnly/WriteOnly/ReadWrite)
3136 ///
3137 /// This enables mode-aware optimizations:
3138 /// - ReadOnly: Skip SSI tracking, 2.6x faster reads
3139 /// - WriteOnly: Skip read tracking, faster bulk inserts
3140 /// - ReadWrite: Full SSI for serializable isolation
3141 pub fn begin_with_mode(&self, mode: TransactionMode) -> Result<u64> {
3142 let txn_id = self.wal.begin_transaction()?;
3143 self.mvcc.begin_with_mode(txn_id, mode);
3144 Ok(txn_id)
3145 }
3146
3147 /// Begin a read-only transaction without any WAL records.
3148 ///
3149 /// This is a performance-critical optimization that eliminates two WAL
3150 /// mutex acquisitions per read (TxnBegin + TxnAbort). Since read-only
3151 /// transactions have no state to recover, WAL records are unnecessary.
3152 ///
3153 /// Callers MUST use `abort_read_only_fast()` to clean up.
3154 #[inline]
3155 pub fn begin_read_only_fast(&self) -> u64 {
3156 let txn_id = self.wal.alloc_txn_id();
3157 self.mvcc.begin_read_only(txn_id);
3158 txn_id
3159 }
3160
3161 /// Abort a fast read-only transaction.
3162 ///
3163 /// O(1) cleanup: only removes MVCC state. No WAL write, no memtable scan.
3164 #[inline]
3165 pub fn abort_read_only_fast(&self, txn_id: u64) {
3166 self.mvcc.abort(txn_id);
3167 }
3168
3169 /// Read a key WITHOUT any MVCC transaction tracking.
3170 ///
3171 /// Uses the current global timestamp to see all committed writes.
3172 /// Bypasses: begin/abort, active_txns DashMap, record_read, stats.
3173 /// Only safe for single-threaded access (no concurrent writes).
3174 #[inline]
3175 pub fn read_latest(&self, key: &[u8]) -> Option<Vec<u8>> {
3176 let snapshot_ts = self
3177 .mvcc
3178 .ts_counter
3179 .load(std::sync::atomic::Ordering::Relaxed);
3180 self.memtable.read(key, snapshot_ts, None)
3181 }
3182
3183 /// Scan keys with a prefix WITHOUT any MVCC transaction tracking.
3184 ///
3185 /// Uses the current global timestamp. Only safe for single-threaded access.
3186 #[inline]
3187 pub fn scan_latest(&self, prefix: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
3188 let snapshot_ts = self
3189 .mvcc
3190 .ts_counter
3191 .load(std::sync::atomic::Ordering::Relaxed);
3192 self.memtable.scan_prefix(prefix, snapshot_ts, None)
3193 }
3194
3195 /// Read a key within a transaction
3196 #[inline]
3197 pub fn read(&self, txn_id: u64, key: &[u8]) -> Result<Option<Vec<u8>>> {
3198 // Fast path: get just snapshot_ts without cloning whole transaction
3199 let snapshot_ts = self
3200 .mvcc
3201 .get_snapshot_ts(txn_id)
3202 .ok_or_else(|| SochDBError::Internal("Transaction not found".into()))?;
3203
3204 // Record read for SSI (skipped for read-only transactions)
3205 self.mvcc.record_read(txn_id, key);
3206
3207 // Read at snapshot timestamp, seeing own uncommitted writes
3208 Ok(self.memtable.read(key, snapshot_ts, Some(txn_id)))
3209 }
3210
3211 /// Write a key-value pair within a transaction
3212 ///
3213 /// Writes are buffered and only flushed to disk on commit.
3214 /// This provides ~10× better throughput for batched inserts.
3215 pub fn write(&self, txn_id: u64, key: Vec<u8>, value: Vec<u8>) -> Result<()> {
3216 // Use the zero-allocation path internally
3217 self.write_refs(txn_id, &key, &value)?;
3218
3219 Ok(())
3220 }
3221
3222 /// Write from references - zero allocation hot path
3223 ///
3224 /// Avoids cloning key/value by writing to WAL from refs directly,
3225 /// then only allocating once for memtable storage.
3226 #[inline]
3227 pub fn write_refs(&self, txn_id: u64, key: &[u8], value: &[u8]) -> Result<()> {
3228 // Record write for MVCC (uses InlineKey - zero allocation for small keys)
3229 self.mvcc.record_write(txn_id, key);
3230
3231 // Buffer writes in memory using TxnWalBuffer - NO WAL lock taken!
3232 // This reduces lock contention from O(writes) to O(1) per transaction
3233 self.txn_write_buffers
3234 .entry(txn_id)
3235 .or_insert_with(|| TxnWalBuffer::new(txn_id))
3236 .append(key, value);
3237
3238 // Write to memtable (needs owned key/value for storage)
3239 self.memtable
3240 .write(key.to_vec(), Some(value.to_vec()), txn_id)?;
3241
3242 Ok(())
3243 }
3244
3245 /// Delete a key within a transaction
3246 pub fn delete(&self, txn_id: u64, key: Vec<u8>) -> Result<()> {
3247 // Record write (uses InlineKey - zero allocation for small keys)
3248 self.mvcc.record_write(txn_id, &key);
3249
3250 // Buffer tombstone in memory - NO WAL lock taken!
3251 self.txn_write_buffers
3252 .entry(txn_id)
3253 .or_insert_with(|| TxnWalBuffer::new(txn_id))
3254 .append(&key, &[]); // Empty value = tombstone
3255
3256 // Write tombstone to memtable
3257 self.memtable.write(key, None, txn_id)?;
3258
3259 Ok(())
3260 }
3261
3262 /// Batch write multiple key-value pairs with reduced overhead
3263 ///
3264 /// This API amortizes fixed costs over the batch:
3265 /// - Single DashMap entry lookup for TxnWalBuffer
3266 /// - Single MVCC write set update
3267 /// - Batch memtable operations
3268 ///
3269 /// Performance: ~2-3x faster than individual write_refs calls
3270 /// for batches of 100+ entries.
3271 ///
3272 /// # Arguments
3273 /// * `txn_id` - Transaction ID
3274 /// * `writes` - Slice of (key, value) pairs
3275 #[inline]
3276 pub fn write_batch_refs(&self, txn_id: u64, writes: &[(&[u8], &[u8])]) -> Result<()> {
3277 if writes.is_empty() {
3278 return Ok(());
3279 }
3280
3281 // Single DashMap access for entire batch
3282 let mut buffer_entry = self
3283 .txn_write_buffers
3284 .entry(txn_id)
3285 .or_insert_with(|| TxnWalBuffer::new(txn_id));
3286
3287 // Batch operations with reduced per-row overhead
3288 for (key, value) in writes {
3289 // Record write for MVCC
3290 self.mvcc.record_write(txn_id, key);
3291
3292 // Append to WAL buffer
3293 buffer_entry.append(key, value);
3294 }
3295 drop(buffer_entry);
3296
3297 // Batch write to memtable
3298 let owned_writes: Vec<(Vec<u8>, Option<Vec<u8>>)> = writes
3299 .iter()
3300 .map(|(k, v)| (k.to_vec(), Some(v.to_vec())))
3301 .collect();
3302 self.memtable.write_batch(&owned_writes, txn_id)?;
3303
3304 Ok(())
3305 }
3306
3307 /// Commit a transaction
3308 ///
3309 /// With sync_mode:
3310 /// - 0 (OFF): No sync, risk of data loss
3311 /// - 1 (NORMAL): Adaptive sync using Little's Law: W* = √(τ/λ)
3312 /// - 2 (FULL): Sync every commit (safest, slowest)
3313 pub fn commit(&self, txn_id: u64) -> Result<u64> {
3314 // First, flush all buffered DATA writes to the WAL with a SINGLE lock
3315 // acquisition (O(1) lock instead of O(writes) locks). Flushing data
3316 // records before validation is safe: ARIES recovery only treats them as
3317 // winners if a *durable commit record* for this transaction also exists,
3318 // and that record is written below — strictly after validation succeeds.
3319 if let Some((_, buffer)) = self.txn_write_buffers.remove(&txn_id)
3320 && !buffer.is_empty()
3321 {
3322 // Flush entire buffer to WAL with one lock
3323 self.wal.flush_buffer(&buffer)?;
3324 }
3325
3326 // ====================================================================
3327 // Task 1 — Linearizable commit invariant:
3328 //
3329 // A commit record must NEVER become durable unless the transaction
3330 // has already passed SSI validation and its write set is final.
3331 //
3332 // We therefore VALIDATE (and freeze the write set) *before* making the
3333 // commit record durable. `mvcc.commit` performs SSI validation and
3334 // returns `None` on a dangerous structure (serialization conflict) or
3335 // if the transaction no longer exists. Writing/fsyncing the commit
3336 // record before this point would let a crash resurrect a transaction
3337 // that the live system rejected — the classic "committed-after-crash,
3338 // aborted-before-crash" non-linearizability bug.
3339 // ====================================================================
3340 let (commit_ts, write_set) = self.mvcc.commit(txn_id).ok_or_else(|| {
3341 SochDBError::Validation(
3342 "transaction aborted: SSI validation failed (serialization conflict) \
3343 or transaction not found"
3344 .into(),
3345 )
3346 })?;
3347
3348 // Validation passed and the write set is frozen. Only now may the
3349 // commit record become durable.
3350 if let Some(gc) = &self.group_commit {
3351 // Submit the *validated* commit intent to group commit and wait.
3352 // This batches multiple validated commits into a single fsync.
3353 gc.submit_and_wait(txn_id).map_err(SochDBError::Internal)?;
3354 } else {
3355 // Direct commit path with adaptive sync (Little's Law)
3356 let sync_mode = self.sync_mode.load(Ordering::Relaxed);
3357 let commits = self.commits_since_sync.fetch_add(1, Ordering::Relaxed);
3358
3359 // Update arrival rate for adaptive batching
3360 self.update_arrival_rate();
3361
3362 // Write commit record (no flush yet - BufWriter will buffer it)
3363 let entry = TxnWalEntry::txn_commit(txn_id);
3364 self.wal.append_no_flush(&entry)?;
3365
3366 // Determine if we should sync/flush based on mode
3367 let should_sync = match sync_mode {
3368 0 => false, // OFF: never sync
3369 1 => commits >= self.adaptive_batch_threshold(), // NORMAL: adaptive
3370 _ => true, // FULL: always sync
3371 };
3372
3373 if should_sync {
3374 // Measure fsync latency for adaptive tuning
3375 let start = std::time::Instant::now();
3376 self.wal.flush()?;
3377 self.wal.sync()?;
3378 let latency_us = start.elapsed().as_micros() as u64;
3379
3380 // Update fsync latency estimate (EMA with α = 0.1)
3381 let old_latency = self.fsync_latency_us.load(Ordering::Relaxed);
3382 let new_latency = (old_latency * 9 + latency_us) / 10;
3383 self.fsync_latency_us.store(new_latency, Ordering::Relaxed);
3384
3385 self.commits_since_sync.store(0, Ordering::Relaxed);
3386 }
3387 }
3388
3389 // Commit record is durable (or buffered per sync mode) for a validated
3390 // transaction — publish the writes to the memtable. (O(k), k = keys.)
3391 self.memtable.commit(txn_id, commit_ts, &write_set);
3392
3393 Ok(commit_ts)
3394 }
3395
3396 /// Update arrival rate using exponential moving average
3397 #[inline]
3398 fn update_arrival_rate(&self) {
3399 let now_us = std::time::SystemTime::now()
3400 .duration_since(std::time::UNIX_EPOCH)
3401 .unwrap()
3402 .as_micros() as u64;
3403
3404 let last = self.last_commit_us.swap(now_us, Ordering::Relaxed);
3405
3406 if last > 0 {
3407 let delta_us = now_us.saturating_sub(last);
3408 if delta_us > 0 && delta_us < 10_000_000 {
3409 // Ignore gaps > 10s
3410 // Rate = 1_000_000 / delta_us (requests/sec)
3411 // Stored as rate × 1000 for precision
3412 let instant_rate = 1_000_000_000 / delta_us;
3413
3414 // EMA with α = 0.1
3415 let old_rate = self.arrival_rate_ema.load(Ordering::Relaxed);
3416 let new_rate = (old_rate * 9 + instant_rate) / 10;
3417 self.arrival_rate_ema.store(new_rate, Ordering::Relaxed);
3418 }
3419 }
3420 }
3421
3422 /// Compute optimal batch threshold using Little's Law
3423 ///
3424 /// W* = √(τ / λ) where τ = fsync latency, λ = arrival rate
3425 /// Returns the number of commits to batch before fsync
3426 #[inline]
3427 fn adaptive_batch_threshold(&self) -> u64 {
3428 let lambda = self.arrival_rate_ema.load(Ordering::Relaxed) as f64 / 1000.0; // req/s
3429 let tau = self.fsync_latency_us.load(Ordering::Relaxed) as f64 / 1_000_000.0; // seconds
3430
3431 if lambda <= 0.0 || tau <= 0.0 {
3432 return 100; // Fallback to fixed threshold
3433 }
3434
3435 // Little's Law: W* = sqrt(2 × τ × λ)
3436 // This minimizes total time = wait_time + fsync_overhead
3437 let n_opt = (2.0 * tau * lambda).sqrt();
3438
3439 // Clamp between 1 and 1000
3440 (n_opt as u64).clamp(1, 1000)
3441 }
3442
3443 /// Set synchronous mode
3444 ///
3445 /// - 0: OFF - No fsync (risk of data loss)
3446 /// - 1: NORMAL - Periodic fsync (balanced)
3447 /// - 2: FULL - Fsync every commit (safest)
3448 pub fn set_sync_mode(&self, mode: u64) {
3449 self.sync_mode.store(mode.min(2), Ordering::Relaxed);
3450 }
3451
3452 /// Force a group commit flush (useful for benchmarking or testing)
3453 pub fn flush_group_commit(&self) {
3454 if let Some(gc) = &self.group_commit {
3455 gc.flush_batch();
3456 }
3457 }
3458
3459 /// Abort a transaction
3460 ///
3461 /// Performance: O(1) for read-only transactions (no writes to clean up).
3462 /// For write transactions, O(N) memtable scan is required to remove
3463 /// uncommitted versions.
3464 pub fn abort(&self, txn_id: u64) -> Result<()> {
3465 // Check if transaction had any buffered writes.
3466 // Read-only transactions never populate txn_write_buffers,
3467 // so this returns None — allowing us to skip the O(N) memtable scan.
3468 let had_writes = self.txn_write_buffers.remove(&txn_id).is_some();
3469
3470 if had_writes {
3471 // Write abort record to WAL (only needed if data was written)
3472 self.wal.abort_transaction(txn_id)?;
3473 // Clean up uncommitted memtable entries
3474 self.memtable.abort(txn_id);
3475 }
3476
3477 // MVCC cleanup is always O(1) — just removes from active_txns DashMap
3478 self.mvcc.abort(txn_id);
3479
3480 Ok(())
3481 }
3482
3483 /// Scan keys with prefix
3484 #[inline]
3485 pub fn scan(&self, txn_id: u64, prefix: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
3486 // Fast path: get just snapshot_ts without cloning whole transaction
3487 let snapshot_ts = self
3488 .mvcc
3489 .get_snapshot_ts(txn_id)
3490 .ok_or_else(|| SochDBError::Internal("Transaction not found".into()))?;
3491
3492 // Note: Scan doesn't record individual key reads for SSI (too expensive)
3493 // SSI conflicts are tracked at the prefix level if needed
3494 Ok(self.memtable.scan_prefix(prefix, snapshot_ts, Some(txn_id)))
3495 }
3496
3497 /// Scan keys in range
3498 #[inline]
3499 pub fn scan_range(
3500 &self,
3501 txn_id: u64,
3502 start: &[u8],
3503 end: &[u8],
3504 ) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
3505 let snapshot_ts = self
3506 .mvcc
3507 .get_snapshot_ts(txn_id)
3508 .ok_or_else(|| SochDBError::Internal("Transaction not found".into()))?;
3509
3510 Ok(self
3511 .memtable
3512 .scan_range(start, end, snapshot_ts, Some(txn_id)))
3513 }
3514
3515 /// Streaming scan for very large result sets
3516 ///
3517 /// Returns an iterator that yields (key, value) pairs without
3518 /// materializing the entire result set in memory.
3519 #[inline]
3520 pub fn scan_range_iter<'a>(
3521 &'a self,
3522 txn_id: u64,
3523 start: &'a [u8],
3524 end: &'a [u8],
3525 ) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> + 'a {
3526 let snapshot_ts = self.mvcc.get_snapshot_ts(txn_id).unwrap_or(0);
3527 self.memtable
3528 .scan_range_iter(start, end, snapshot_ts, Some(txn_id))
3529 }
3530
3531 /// Force fsync to disk
3532 /// Flush the WAL's in-memory buffer to the OS
3533 ///
3534 /// This ensures all buffered writes are pushed from the BufWriter
3535 /// into the OS page cache. Call this before `fsync()` to ensure
3536 /// all data is durable.
3537 pub fn flush_wal(&self) -> Result<()> {
3538 self.wal.flush()
3539 }
3540
3541 /// Force sync the WAL to disk (fsync)
3542 pub fn fsync(&self) -> Result<()> {
3543 self.wal.sync()
3544 }
3545
3546 /// Write checkpoint
3547 pub fn checkpoint(&self) -> Result<u64> {
3548 let txn_id = 0; // System transaction
3549 let entry = TxnWalEntry::checkpoint(txn_id);
3550 let lsn = self.wal.append_sync(&entry)?;
3551 self.last_checkpoint_lsn.store(lsn, Ordering::SeqCst);
3552 // PITR: persist the checkpoint LSN to the durable manifest so it survives
3553 // restart. This piggybacks the fsync that append_sync already performed;
3554 // the manifest write is itself crash-safe (temp + fsync + atomic rename).
3555 // No-op (and no manifest write) when PITR is not enabled.
3556 if self.pitr_enabled.load(Ordering::SeqCst) {
3557 self.persist_pitr_manifest(lsn)?;
3558 }
3559 Ok(lsn)
3560 }
3561
3562 /// The current durable monotonic LSN (the WAL record ordinal). In PITR mode
3563 /// the WAL is never truncated, so this is stable and monotonic across
3564 /// restarts (`recover_state` rebuilds it by re-counting on reopen).
3565 pub fn current_lsn(&self) -> u64 {
3566 self.wal.sequence()
3567 }
3568
3569 /// Whether Point-in-Time Recovery is enabled for this database.
3570 pub fn is_pitr_enabled(&self) -> bool {
3571 self.pitr_enabled.load(Ordering::SeqCst)
3572 }
3573
3574 /// Enable Point-in-Time Recovery for this database (one-way, explicit opt-in).
3575 ///
3576 /// Writes the durable `wal.manifest` whose presence marks the DB PITR-enabled
3577 /// on every future open. Once enabled, the destructive [`Self::truncate_wal`]
3578 /// is forbidden so the WAL record ordinal stays a stable durable LSN (segment
3579 /// sealing — the PITR-safe space-reclaim — lands in a later phase). Idempotent.
3580 pub fn enable_point_in_time_recovery(&self) -> Result<()> {
3581 if self.pitr_enabled.load(Ordering::SeqCst) {
3582 return Ok(()); // already enabled
3583 }
3584 // Persist the durable manifest FIRST; only flip the in-memory flag after
3585 // the durable write succeeds. Otherwise a failed manifest write would
3586 // leave `durability_capabilities()` reporting PITR (and the truncate
3587 // guard active) with no durable anchor on disk.
3588 let lsn = self.last_checkpoint_lsn.load(Ordering::SeqCst);
3589 if crate::wal_manifest::WalManifest::exists(&self.path) {
3590 self.persist_pitr_manifest(lsn)?;
3591 } else {
3592 crate::wal_manifest::WalManifest::new(lsn).write_atomic(&self.path)?;
3593 }
3594 self.pitr_enabled.store(true, Ordering::SeqCst);
3595 Ok(())
3596 }
3597
3598 /// Persist the PITR manifest with the given checkpoint LSN, preserving the
3599 /// existing db identity if a manifest is already present.
3600 fn persist_pitr_manifest(&self, lsn: u64) -> Result<()> {
3601 let manifest = match crate::wal_manifest::WalManifest::load(&self.path) {
3602 Ok(mut m) => {
3603 m.last_checkpoint_lsn = lsn;
3604 m
3605 }
3606 Err(_) => crate::wal_manifest::WalManifest::new(lsn),
3607 };
3608 manifest.write_atomic(&self.path)
3609 }
3610
3611 /// Truncate the WAL file after checkpoint.
3612 ///
3613 /// This physically truncates the WAL file to 0 bytes, reclaiming disk
3614 /// space. The in-memory memtable retains all data for the current
3615 /// session, but a crash after truncation will result in data loss
3616 /// since the WAL is the only persistence mechanism for DurableStorage.
3617 ///
3618 /// Call after `checkpoint()` when WAL durability across restarts is
3619 /// not required (e.g. desktop telemetry viewers, caches).
3620 ///
3621 /// Refused when PITR is enabled: truncation resets the WAL record ordinal,
3622 /// which would break the durable monotonic LSN that PITR anchors on. The
3623 /// PITR-safe way to reclaim space is segment sealing (a later phase).
3624 pub fn truncate_wal(&self) -> Result<()> {
3625 if self.pitr_enabled.load(Ordering::SeqCst) {
3626 return Err(SochDBError::InvalidArgument(
3627 "truncate_wal is disabled while Point-in-Time Recovery is enabled \
3628 (it would reset the durable LSN); use segment sealing to reclaim space"
3629 .to_string(),
3630 ));
3631 }
3632 self.wal.truncate()
3633 }
3634
3635 /// Get storage statistics
3636 pub fn stats(&self) -> StorageStats {
3637 // Get WAL size from the WAL manager
3638 let wal_size = self.wal.size_bytes();
3639
3640 // Get active transaction count from MVCC
3641 let active_txns = self.mvcc.active_transaction_count();
3642
3643 StorageStats {
3644 memtable_size_bytes: self.memtable.size(),
3645 wal_size_bytes: wal_size,
3646 active_transactions: active_txns,
3647 min_active_snapshot: self.mvcc.min_active_snapshot(),
3648 last_checkpoint_lsn: self.last_checkpoint_lsn.load(Ordering::SeqCst),
3649 }
3650 }
3651
3652 /// Garbage collect old versions
3653 pub fn gc(&self) -> usize {
3654 let min_ts = self.mvcc.min_active_snapshot();
3655 self.memtable.gc(min_ts)
3656 }
3657
3658 /// Clean shutdown
3659 pub fn shutdown(&self) -> Result<()> {
3660 // Sync WAL
3661 self.fsync()?;
3662
3663 // Write clean shutdown marker
3664 let marker_path = self.path.join(".clean_shutdown");
3665 std::fs::write(&marker_path, b"clean")?;
3666
3667 Ok(())
3668 }
3669}
3670
3671impl Drop for DurableStorage {
3672 fn drop(&mut self) {
3673 let _ = self.shutdown();
3674 }
3675}
3676
3677// =============================================================================
3678// EphemeralHandle - Temp-directory-backed DurableStorage for testing
3679// =============================================================================
3680
3681/// Owns a `DurableStorage` instance backed by a temporary directory.
3682///
3683/// The temp directory is automatically cleaned up when this handle is dropped.
3684/// Access the underlying storage via `Deref` coercion or `.storage()`.
3685///
3686/// # Why this exists
3687///
3688/// SochDB previously had two storage engines — `LscsStorage` (BTreeMap-backed,
3689/// in-memory WAL, used by tests) and `DurableStorage` (SkipMap-backed, real WAL,
3690/// used in production). This dual-engine architecture meant bugs could surface
3691/// only in production because the test path exercised different code.
3692///
3693/// `EphemeralHandle` eliminates this divergence: tests use the exact same
3694/// `DurableStorage` engine as production, just backed by a temp directory.
3695pub struct EphemeralHandle {
3696 storage: DurableStorage,
3697 _tmpdir: tempfile::TempDir,
3698}
3699
3700impl EphemeralHandle {
3701 /// Get a reference to the underlying storage
3702 pub fn storage(&self) -> &DurableStorage {
3703 &self.storage
3704 }
3705
3706 /// Get a mutable reference to the underlying storage
3707 pub fn storage_mut(&mut self) -> &mut DurableStorage {
3708 &mut self.storage
3709 }
3710
3711 /// Consume the handle and return the storage and temp directory.
3712 ///
3713 /// Useful when you need an `Arc<DurableStorage>` — the caller must keep
3714 /// the `TempDir` alive for the lifetime of the storage.
3715 pub fn into_parts(self) -> (DurableStorage, tempfile::TempDir) {
3716 (self.storage, self._tmpdir)
3717 }
3718}
3719
3720impl std::ops::Deref for EphemeralHandle {
3721 type Target = DurableStorage;
3722 fn deref(&self) -> &DurableStorage {
3723 &self.storage
3724 }
3725}
3726
3727impl std::ops::DerefMut for EphemeralHandle {
3728 fn deref_mut(&mut self) -> &mut DurableStorage {
3729 &mut self.storage
3730 }
3731}
3732
3733/// Recovery statistics
3734#[derive(Debug, Default)]
3735pub struct RecoveryStats {
3736 pub transactions_recovered: usize,
3737 pub writes_recovered: usize,
3738 pub commit_ts: u64,
3739}
3740
3741/// Storage statistics
3742#[derive(Debug, Default)]
3743pub struct StorageStats {
3744 pub memtable_size_bytes: u64,
3745 pub wal_size_bytes: u64,
3746 pub active_transactions: usize,
3747 pub min_active_snapshot: u64,
3748 pub last_checkpoint_lsn: u64,
3749}
3750
3751#[cfg(test)]
3752mod tests {
3753 use super::*;
3754 use tempfile::tempdir;
3755
3756 /// Incremental min-active-ts (multiset) must exactly track the old full-scan
3757 /// semantics across begin / commit / abort, including shared snapshot_ts
3758 /// (refcounting) and the empty -> ts_counter watermark fallback.
3759 #[test]
3760 fn test_incremental_min_active_ts() {
3761 let m = MvccManager::new();
3762 m.assert_active_snapshots_consistent(); // empty
3763
3764 // Two txns share the SAME snapshot_ts (begin reads ts_counter w/o bump).
3765 let a = m.begin(1);
3766 let b = m.begin(2);
3767 assert_eq!(a.snapshot_ts, b.snapshot_ts, "shared snapshot expected");
3768 assert_eq!(m.min_active_snapshot(), a.snapshot_ts);
3769 m.assert_active_snapshots_consistent();
3770
3771 // Advance ts via a commit, then a third txn has a HIGHER snapshot.
3772 m.commit(1); // removes a; b still holds the low snapshot
3773 assert_eq!(m.min_active_snapshot(), b.snapshot_ts);
3774 m.assert_active_snapshots_consistent();
3775
3776 let c = m.begin(3);
3777 assert!(c.snapshot_ts >= b.snapshot_ts);
3778 // min is still b's (the oldest still-active), NOT c's.
3779 assert_eq!(m.min_active_snapshot(), b.snapshot_ts);
3780 m.assert_active_snapshots_consistent();
3781
3782 // Abort the oldest -> watermark advances to c's snapshot.
3783 m.abort(2);
3784 assert_eq!(m.min_active_snapshot(), c.snapshot_ts);
3785 m.assert_active_snapshots_consistent();
3786
3787 // Drain the last one -> empty -> watermark falls back to ts_counter.
3788 m.commit(3);
3789 assert_eq!(m.min_active_snapshot(), m.ts_counter.load(Ordering::SeqCst));
3790 m.assert_active_snapshots_consistent();
3791
3792 // Double-abort / abort of unknown id must be a no-op (no underflow).
3793 m.abort(999);
3794 m.abort(3);
3795 m.assert_active_snapshots_consistent();
3796 }
3797
3798 /// Concurrent stress: many threads begin/commit/abort; after they all join
3799 /// (quiescent), the multiset must exactly mirror active_txns. This catches
3800 /// any refcount leak/underflow under real contention.
3801 #[test]
3802 fn test_incremental_min_active_ts_concurrent() {
3803 use std::sync::Arc;
3804 let m = Arc::new(MvccManager::new());
3805 let mut handles = vec![];
3806 for t in 0..8u64 {
3807 let m = Arc::clone(&m);
3808 handles.push(std::thread::spawn(move || {
3809 for i in 0..500u64 {
3810 let id = t * 100_000 + i;
3811 let _txn = m.begin(id);
3812 if i % 3 == 0 {
3813 m.abort(id);
3814 } else {
3815 m.commit(id);
3816 }
3817 }
3818 }));
3819 }
3820 for h in handles {
3821 h.join().unwrap();
3822 }
3823 // All transactions retired -> multiset empty, mirrors active_txns.
3824 m.assert_active_snapshots_consistent();
3825 assert_eq!(m.active_transaction_count(), 0);
3826 }
3827
3828 #[test]
3829 fn test_basic_transaction() {
3830 let dir = tempdir().unwrap();
3831 let storage = DurableStorage::open(dir.path()).unwrap();
3832
3833 // Begin transaction
3834 let txn_id = storage.begin_transaction().unwrap();
3835
3836 // Write data
3837 storage
3838 .write(txn_id, b"key1".to_vec(), b"value1".to_vec())
3839 .unwrap();
3840 storage
3841 .write(txn_id, b"key2".to_vec(), b"value2".to_vec())
3842 .unwrap();
3843
3844 // Read back (within same transaction)
3845 let v1 = storage.read(txn_id, b"key1").unwrap();
3846 assert_eq!(v1, Some(b"value1".to_vec()));
3847
3848 // Commit
3849 let commit_ts = storage.commit(txn_id).unwrap();
3850 assert!(commit_ts > 0);
3851
3852 // Read in new transaction
3853 let txn2 = storage.begin_transaction().unwrap();
3854 let v1 = storage.read(txn2, b"key1").unwrap();
3855 assert_eq!(v1, Some(b"value1".to_vec()));
3856 storage.abort(txn2).unwrap();
3857 }
3858
3859 #[test]
3860 fn test_snapshot_isolation() {
3861 let dir = tempdir().unwrap();
3862 let storage = DurableStorage::open(dir.path()).unwrap();
3863
3864 // T1: Write initial value
3865 let t1 = storage.begin_transaction().unwrap();
3866 storage.write(t1, b"key".to_vec(), b"v1".to_vec()).unwrap();
3867 storage.commit(t1).unwrap();
3868
3869 // T2: Start reading (snapshot at this point)
3870 let t2 = storage.begin_transaction().unwrap();
3871
3872 // T3: Update the value
3873 let t3 = storage.begin_transaction().unwrap();
3874 storage.write(t3, b"key".to_vec(), b"v2".to_vec()).unwrap();
3875 storage.commit(t3).unwrap();
3876
3877 // T2 should still see v1 (snapshot isolation)
3878 let v = storage.read(t2, b"key").unwrap();
3879 assert_eq!(v, Some(b"v1".to_vec()));
3880
3881 // New transaction should see v2
3882 let t4 = storage.begin_transaction().unwrap();
3883 let v = storage.read(t4, b"key").unwrap();
3884 assert_eq!(v, Some(b"v2".to_vec()));
3885
3886 storage.abort(t2).unwrap();
3887 storage.abort(t4).unwrap();
3888 }
3889
3890 #[test]
3891 fn test_gc_preserves_versions_for_active_snapshot() {
3892 // GC must not free a version that an in-flight reader's snapshot can
3893 // still observe. The low-water-mark (min_active_snapshot) is the oldest
3894 // live reader; any version visible to it must survive GC.
3895 let dir = tempdir().unwrap();
3896 let storage = DurableStorage::open(dir.path()).unwrap();
3897
3898 // Seed an initial committed version v1.
3899 let t1 = storage.begin_transaction().unwrap();
3900 storage.write(t1, b"k".to_vec(), b"v1".to_vec()).unwrap();
3901 storage.commit(t1).unwrap();
3902
3903 // Open a long-lived snapshot reader BEFORE any newer writes. Its
3904 // snapshot_ts pins the GC watermark so v1 cannot be reclaimed.
3905 let reader = storage.begin_transaction().unwrap();
3906 assert_eq!(
3907 storage.read(reader, b"k").unwrap(),
3908 Some(b"v1".to_vec()),
3909 "reader's snapshot must see v1"
3910 );
3911
3912 // Produce several newer committed versions while the reader is active.
3913 for v in ["v2", "v3", "v4"] {
3914 let w = storage.begin_transaction().unwrap();
3915 storage
3916 .write(w, b"k".to_vec(), v.as_bytes().to_vec())
3917 .unwrap();
3918 storage.commit(w).unwrap();
3919 }
3920
3921 // Run GC. Because `reader` is still active, the watermark is pinned at
3922 // its snapshot and the version it needs (v1) must NOT be freed.
3923 let _freed = storage.gc();
3924 assert_eq!(
3925 storage.read(reader, b"k").unwrap(),
3926 Some(b"v1".to_vec()),
3927 "GC must not free a version still visible to an active snapshot"
3928 );
3929
3930 // A fresh transaction sees the latest committed version.
3931 let fresh = storage.begin_transaction().unwrap();
3932 assert_eq!(storage.read(fresh, b"k").unwrap(), Some(b"v4".to_vec()));
3933 storage.abort(fresh).unwrap();
3934
3935 // Release the old reader; the watermark can now advance.
3936 storage.abort(reader).unwrap();
3937
3938 // After the old snapshot is gone, GC may reclaim superseded versions,
3939 // and new readers still resolve the latest value correctly.
3940 let _freed2 = storage.gc();
3941 let after = storage.begin_transaction().unwrap();
3942 assert_eq!(storage.read(after, b"k").unwrap(), Some(b"v4".to_vec()));
3943 storage.abort(after).unwrap();
3944 }
3945
3946 #[test]
3947 fn test_ssi_detects_write_skew() {
3948 // Classic write-skew: two concurrent transactions each read what the
3949 // other writes (disjoint write keys). Under plain SI both would commit,
3950 // violating serializability. Under SSI the pivot transaction (with both
3951 // an inbound and an outbound rw-edge) must be aborted, so the two
3952 // commits cannot both succeed.
3953 let dir = tempdir().unwrap();
3954 let storage = DurableStorage::open(dir.path()).unwrap();
3955
3956 // Seed two keys.
3957 let seed = storage.begin_transaction().unwrap();
3958 storage.write(seed, b"x".to_vec(), b"0".to_vec()).unwrap();
3959 storage.write(seed, b"y".to_vec(), b"0".to_vec()).unwrap();
3960 storage.commit(seed).unwrap();
3961
3962 // T1 reads x and y, then writes x.
3963 let t1 = storage.begin_transaction().unwrap();
3964 let _ = storage.read(t1, b"x").unwrap();
3965 let _ = storage.read(t1, b"y").unwrap();
3966
3967 // T2 reads x and y, then writes y. (Concurrent with T1.)
3968 let t2 = storage.begin_transaction().unwrap();
3969 let _ = storage.read(t2, b"x").unwrap();
3970 let _ = storage.read(t2, b"y").unwrap();
3971
3972 storage.write(t1, b"x".to_vec(), b"1".to_vec()).unwrap();
3973 storage.write(t2, b"y".to_vec(), b"1".to_vec()).unwrap();
3974
3975 // First committer wins.
3976 let c1 = storage.commit(t1);
3977 assert!(c1.is_ok(), "first committer should succeed: {c1:?}");
3978
3979 // The second committer forms a dangerous rw-structure with T1 and must
3980 // be rejected to preserve serializability.
3981 let c2 = storage.commit(t2);
3982 assert!(
3983 c2.is_err(),
3984 "SSI must abort the write-skew pivot, but commit succeeded"
3985 );
3986 }
3987
3988 #[test]
3989 fn test_abort_transaction() {
3990 let dir = tempdir().unwrap();
3991 let storage = DurableStorage::open(dir.path()).unwrap();
3992
3993 // Write initial value
3994 let t1 = storage.begin_transaction().unwrap();
3995 storage.write(t1, b"key".to_vec(), b"v1".to_vec()).unwrap();
3996 storage.commit(t1).unwrap();
3997
3998 // Start transaction that will abort
3999 let t2 = storage.begin_transaction().unwrap();
4000 storage.write(t2, b"key".to_vec(), b"v2".to_vec()).unwrap();
4001 storage.abort(t2).unwrap();
4002
4003 // New transaction should see v1 (aborted changes not visible)
4004 let t3 = storage.begin_transaction().unwrap();
4005 let v = storage.read(t3, b"key").unwrap();
4006 assert_eq!(v, Some(b"v1".to_vec()));
4007 storage.abort(t3).unwrap();
4008 }
4009
4010 #[test]
4011 fn test_crash_recovery() {
4012 let dir = tempdir().unwrap();
4013
4014 // Phase 1: Write data and commit
4015 {
4016 // Use open_without_lock for crash simulation tests
4017 let storage = DurableStorage::open_without_lock(dir.path()).unwrap();
4018
4019 // Set sync mode to FULL to ensure data is synced before "crash"
4020 storage.set_sync_mode(2); // FULL: sync every commit
4021
4022 let txn = storage.begin_transaction().unwrap();
4023 storage
4024 .write(txn, b"persist".to_vec(), b"data".to_vec())
4025 .unwrap();
4026 storage.commit(txn).unwrap();
4027
4028 // Simulate crash (no clean shutdown)
4029 std::mem::forget(storage);
4030 }
4031
4032 // Phase 2: Reopen and recover
4033 {
4034 let storage = DurableStorage::open_without_lock(dir.path()).unwrap();
4035 let stats = storage.recover().unwrap();
4036 assert!(stats.transactions_recovered > 0 || stats.writes_recovered > 0);
4037
4038 // Data should be recovered
4039 let txn = storage.begin_transaction().unwrap();
4040 let v = storage.read(txn, b"persist").unwrap();
4041 assert_eq!(v, Some(b"data".to_vec()));
4042 storage.abort(txn).unwrap();
4043 }
4044 }
4045
4046 /// At-rest encryption end-to-end through the live DurableStorage engine
4047 /// (Task 3B): an encrypted DB round-trips committed data, never leaks
4048 /// plaintext to the WAL file, flips the per-instance durability matrix, and
4049 /// fails CLOSED on a wrong or missing key (never a silent plaintext/empty
4050 /// open).
4051 #[test]
4052 fn test_at_rest_encryption_end_to_end() {
4053 let dir = tempdir().unwrap();
4054 let kek = || EncryptionKey::new([0xABu8; 32]);
4055
4056 // Phase 1: create an encrypted DB, write committed data, clean close.
4057 {
4058 let storage = DurableStorage::open_with_encryption(
4059 dir.path(),
4060 true,
4061 MemTableType::Standard,
4062 StorageEncryption::with_kek(kek(), "test"),
4063 )
4064 .unwrap();
4065 assert!(storage.is_encrypted());
4066 assert!(storage.durability_capabilities().at_rest_encryption);
4067 storage.set_sync_mode(2);
4068 let t = storage.begin_transaction().unwrap();
4069 storage
4070 .write(t, b"secret-key".to_vec(), b"secret-value".to_vec())
4071 .unwrap();
4072 storage.commit(t).unwrap();
4073 } // clean drop releases the file lock
4074
4075 // The keyring exists and the WAL bytes do not leak the plaintext record.
4076 assert!(dir.path().join("keyring.json").exists());
4077 let raw = std::fs::read(dir.path().join("wal.log")).unwrap();
4078 assert!(!contains(&raw, b"secret-value"), "value leaked to WAL");
4079 assert!(!contains(&raw, b"secret-key"), "key leaked to WAL");
4080
4081 // Phase 2: reopen with the CORRECT key, recover, read back.
4082 {
4083 let storage = DurableStorage::open_with_encryption(
4084 dir.path(),
4085 true,
4086 MemTableType::Standard,
4087 StorageEncryption::with_kek(kek(), "test"),
4088 )
4089 .unwrap();
4090 storage.recover().unwrap();
4091 let t = storage.begin_transaction().unwrap();
4092 assert_eq!(
4093 storage.read(t, b"secret-key").unwrap(),
4094 Some(b"secret-value".to_vec()),
4095 "committed encrypted data must round-trip"
4096 );
4097 storage.abort(t).unwrap();
4098 }
4099
4100 // Phase 3: WRONG key must fail closed (keyring canary), not open empty.
4101 {
4102 let wrong = DurableStorage::open_with_encryption(
4103 dir.path(),
4104 true,
4105 MemTableType::Standard,
4106 StorageEncryption::with_kek(EncryptionKey::new([0x00u8; 32]), "test"),
4107 );
4108 assert!(wrong.is_err(), "wrong KEK must fail closed");
4109 }
4110
4111 // Phase 4: opening an encrypted DB with NO key must fail closed.
4112 {
4113 let no_key = DurableStorage::open_with_encryption(
4114 dir.path(),
4115 true,
4116 MemTableType::Standard,
4117 StorageEncryption::disabled(),
4118 );
4119 assert!(
4120 no_key.is_err(),
4121 "encrypted DB opened without key must fail closed"
4122 );
4123 }
4124 }
4125
4126 /// A plaintext DB reports at_rest_encryption=false on the live matrix.
4127 #[test]
4128 fn test_plaintext_db_reports_unencrypted() {
4129 let dir = tempdir().unwrap();
4130 let storage = DurableStorage::open_without_lock(dir.path()).unwrap();
4131 assert!(!storage.is_encrypted());
4132 assert!(!storage.durability_capabilities().at_rest_encryption);
4133 assert!(storage.durability_capabilities().crash_recovery);
4134 assert!(!dir.path().join("keyring.json").exists());
4135 }
4136
4137 fn contains(haystack: &[u8], needle: &[u8]) -> bool {
4138 haystack.windows(needle.len()).any(|w| w == needle)
4139 }
4140
4141 /// PITR phase 1 — durable monotonic LSN anchor.
4142 ///
4143 /// Enabling PITR writes the durable manifest; the WAL record ordinal (LSN)
4144 /// and the last-checkpoint LSN then survive a reopen, and the destructive
4145 /// truncate is refused so the anchor can never reset. A non-PITR DB is
4146 /// completely unaffected (no manifest, truncate works).
4147 #[test]
4148 fn test_pitr_durable_lsn_and_truncate_guard() {
4149 let dir = tempdir().unwrap();
4150
4151 // Default DB: not PITR, no manifest, truncate allowed.
4152 {
4153 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4154 assert!(!s.is_pitr_enabled());
4155 assert!(!s.durability_capabilities().point_in_time_recovery);
4156 assert!(s.truncate_wal().is_ok());
4157 }
4158 assert!(!dir.path().join("wal.manifest").exists());
4159
4160 // Enable PITR, write+checkpoint, capture the LSN.
4161 let lsn_before;
4162 let ckpt_lsn;
4163 {
4164 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4165 s.enable_point_in_time_recovery().unwrap();
4166 assert!(s.is_pitr_enabled());
4167
4168 let t = s.begin_transaction().unwrap();
4169 s.write(t, b"k1".to_vec(), b"v1".to_vec()).unwrap();
4170 s.commit(t).unwrap();
4171 ckpt_lsn = s.checkpoint().unwrap();
4172 lsn_before = s.current_lsn();
4173 assert!(lsn_before > 0);
4174
4175 // truncate is refused while PITR is on.
4176 assert!(
4177 s.truncate_wal().is_err(),
4178 "truncate must be refused in PITR mode"
4179 );
4180 }
4181 assert!(dir.path().join("wal.manifest").exists());
4182
4183 // Reopen: PITR auto-detected from the manifest; LSN did NOT reset.
4184 {
4185 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4186 assert!(
4187 s.is_pitr_enabled(),
4188 "PITR must be auto-detected from manifest"
4189 );
4190 s.recover().unwrap();
4191 assert_eq!(
4192 s.current_lsn(),
4193 lsn_before,
4194 "durable LSN must survive reopen (not reset to 0/record-recount drift)"
4195 );
4196 assert_eq!(
4197 s.stats().last_checkpoint_lsn,
4198 ckpt_lsn,
4199 "last_checkpoint_lsn must be restored from the manifest"
4200 );
4201 // Committed data still readable.
4202 let t = s.begin_transaction().unwrap();
4203 assert_eq!(s.read(t, b"k1").unwrap(), Some(b"v1".to_vec()));
4204 s.abort(t).unwrap();
4205 }
4206 }
4207
4208 /// PITR phase 2 — END-TO-END restore to a point in time.
4209 ///
4210 /// Enable PITR, commit two transactions, then on fresh reopens
4211 /// `recover_to(target)` materializes the exact historical state: an LSN cut
4212 /// between the two transactions sees only the first; the full LSN / a
4213 /// far-future timestamp sees both; timestamp 0 sees nothing. Transaction
4214 /// atomicity is preserved at the cut.
4215 #[test]
4216 fn test_pitr_recover_to_point_in_time() {
4217 use crate::txn_wal::RecoveryTarget;
4218
4219 let dir = tempdir().unwrap();
4220
4221 // Build history: txn1 sets k1=v1; txn2 overwrites k1=v1b and adds k2=v2.
4222 let (lsn_after_txn1, lsn_after_txn2);
4223 {
4224 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4225 s.enable_point_in_time_recovery().unwrap();
4226
4227 let t1 = s.begin_transaction().unwrap();
4228 s.write(t1, b"k1".to_vec(), b"v1".to_vec()).unwrap();
4229 s.commit(t1).unwrap();
4230 lsn_after_txn1 = s.current_lsn();
4231
4232 let t2 = s.begin_transaction().unwrap();
4233 s.write(t2, b"k1".to_vec(), b"v1b".to_vec()).unwrap();
4234 s.write(t2, b"k2".to_vec(), b"v2".to_vec()).unwrap();
4235 s.commit(t2).unwrap();
4236 lsn_after_txn2 = s.current_lsn();
4237
4238 s.checkpoint().unwrap();
4239 }
4240 assert!(lsn_after_txn2 > lsn_after_txn1);
4241
4242 // Restore to the cut between txn1 and txn2: only txn1's effect is visible.
4243 {
4244 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4245 s.recover_to(RecoveryTarget::Lsn(lsn_after_txn1)).unwrap();
4246 let t = s.begin_transaction().unwrap();
4247 assert_eq!(
4248 s.read(t, b"k1").unwrap(),
4249 Some(b"v1".to_vec()),
4250 "txn1 value"
4251 );
4252 assert_eq!(
4253 s.read(t, b"k2").unwrap(),
4254 None,
4255 "txn2 must NOT be present at the cut"
4256 );
4257 s.abort(t).unwrap();
4258 }
4259
4260 // Restore to the full LSN: both transactions visible (txn2 wins on k1).
4261 {
4262 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4263 s.recover_to(RecoveryTarget::Lsn(lsn_after_txn2)).unwrap();
4264 let t = s.begin_transaction().unwrap();
4265 assert_eq!(s.read(t, b"k1").unwrap(), Some(b"v1b".to_vec()));
4266 assert_eq!(s.read(t, b"k2").unwrap(), Some(b"v2".to_vec()));
4267 s.abort(t).unwrap();
4268 }
4269
4270 // Timestamp bounds: MAX => everything; 0 => nothing.
4271 {
4272 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4273 s.recover_to(RecoveryTarget::Timestamp(u64::MAX)).unwrap();
4274 let t = s.begin_transaction().unwrap();
4275 assert_eq!(s.read(t, b"k1").unwrap(), Some(b"v1b".to_vec()));
4276 assert_eq!(s.read(t, b"k2").unwrap(), Some(b"v2".to_vec()));
4277 s.abort(t).unwrap();
4278 }
4279 {
4280 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4281 s.recover_to(RecoveryTarget::Timestamp(0)).unwrap();
4282 let t = s.begin_transaction().unwrap();
4283 assert_eq!(s.read(t, b"k1").unwrap(), None, "no commit is <= ts 0");
4284 s.abort(t).unwrap();
4285 }
4286
4287 // The capability matrix now reports PITR live for this DB.
4288 {
4289 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4290 assert!(s.durability_capabilities().point_in_time_recovery);
4291 }
4292 }
4293
4294 /// recover_to is refused on a non-PITR database (the WAL may be truncated, so
4295 /// an arbitrary target cannot be honored).
4296 #[test]
4297 fn test_recover_to_refused_without_pitr() {
4298 use crate::txn_wal::RecoveryTarget;
4299 let dir = tempdir().unwrap();
4300 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4301 assert!(s.recover_to(RecoveryTarget::Lsn(1)).is_err());
4302 assert!(!s.durability_capabilities().point_in_time_recovery);
4303 }
4304
4305 /// Regression (review HIGH): under the DEFAULT NORMAL sync mode a commit
4306 /// record may sit unflushed in the BufWriter while current_lsn() counts it.
4307 /// recover_to MUST flush+fsync before replaying, or a same-process restore to
4308 /// the captured LSN silently drops the committed-but-unflushed tail.
4309 #[test]
4310 fn test_recover_to_flushes_before_replay() {
4311 use crate::txn_wal::RecoveryTarget;
4312 let dir = tempdir().unwrap();
4313 let s = DurableStorage::open_without_lock(dir.path()).unwrap(); // NORMAL sync
4314 s.enable_point_in_time_recovery().unwrap();
4315 let t = s.begin_transaction().unwrap();
4316 s.write(t, b"k".to_vec(), b"v".to_vec()).unwrap();
4317 s.commit(t).unwrap(); // commit record likely unflushed under NORMAL
4318 let lsn = s.current_lsn();
4319 // No checkpoint, SAME process: replay reads a fresh on-disk handle.
4320 let stats = s.recover_to(RecoveryTarget::Lsn(lsn)).unwrap();
4321 assert!(
4322 stats.writes_recovered >= 1,
4323 "committed-but-unflushed data must be recovered (flush before replay)"
4324 );
4325 }
4326
4327 /// Regression (review MEDIUM): recover_to must be the SOLE recovery on a
4328 /// fresh open. After recover() (or a prior recover_to) it must refuse, rather
4329 /// than silently layer a stale set over the point-in-time state.
4330 #[test]
4331 fn test_recover_to_refuses_after_recovery() {
4332 use crate::txn_wal::RecoveryTarget;
4333 let dir = tempdir().unwrap();
4334 {
4335 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4336 s.enable_point_in_time_recovery().unwrap();
4337 let t = s.begin_transaction().unwrap();
4338 s.write(t, b"k".to_vec(), b"v".to_vec()).unwrap();
4339 s.commit(t).unwrap();
4340 s.checkpoint().unwrap();
4341 }
4342 let s = DurableStorage::open_without_lock(dir.path()).unwrap();
4343 s.recover().unwrap(); // full recovery first
4344 assert!(
4345 s.recover_to(RecoveryTarget::Lsn(1)).is_err(),
4346 "recover_to after recover() must refuse (would double-apply)"
4347 );
4348 // And a second recover_to after a first also refuses.
4349 let s2 = DurableStorage::open_without_lock(dir.path()).unwrap();
4350 s2.recover_to(RecoveryTarget::Lsn(u64::MAX)).unwrap();
4351 assert!(s2.recover_to(RecoveryTarget::Lsn(1)).is_err());
4352 }
4353
4354 /// recover_to over an ENCRYPTED WAL: the bounded replay decrypts correctly
4355 /// (review LOW: the encrypted bounded path was previously untested).
4356 #[test]
4357 fn test_pitr_recover_to_encrypted() {
4358 use crate::encryption::EncryptionKey;
4359 use crate::txn_wal::RecoveryTarget;
4360
4361 let dir = tempdir().unwrap();
4362 let kek = [0x9Fu8; 32];
4363 let mk = || StorageEncryption::with_kek(EncryptionKey::new(kek), "test");
4364
4365 let lsn_after_txn1;
4366 {
4367 let s = DurableStorage::open_with_encryption(
4368 dir.path(),
4369 true,
4370 MemTableType::Standard,
4371 mk(),
4372 )
4373 .unwrap();
4374 s.enable_point_in_time_recovery().unwrap();
4375 let t1 = s.begin_transaction().unwrap();
4376 s.write(t1, b"k1".to_vec(), b"v1".to_vec()).unwrap();
4377 s.commit(t1).unwrap();
4378 lsn_after_txn1 = s.current_lsn();
4379 let t2 = s.begin_transaction().unwrap();
4380 s.write(t2, b"k2".to_vec(), b"v2".to_vec()).unwrap();
4381 s.commit(t2).unwrap();
4382 s.checkpoint().unwrap();
4383 s.shutdown().ok();
4384 }
4385
4386 // Reopen encrypted, restore to the cut between the two txns.
4387 let s =
4388 DurableStorage::open_with_encryption(dir.path(), true, MemTableType::Standard, mk())
4389 .unwrap();
4390 s.recover_to(RecoveryTarget::Lsn(lsn_after_txn1)).unwrap();
4391 let t = s.begin_transaction().unwrap();
4392 assert_eq!(
4393 s.read(t, b"k1").unwrap(),
4394 Some(b"v1".to_vec()),
4395 "encrypted bounded replay must decrypt the in-window record"
4396 );
4397 assert_eq!(s.read(t, b"k2").unwrap(), None, "txn2 is past the cut");
4398 s.abort(t).unwrap();
4399 }
4400
4401 /// PITR composes with at-rest encryption (the manifest anchor is independent
4402 /// of the keyring; an encrypted PITR DB round-trips and stays fail-closed).
4403 #[test]
4404 fn test_pitr_with_encryption() {
4405 use crate::encryption::EncryptionKey;
4406
4407 let dir = tempdir().unwrap();
4408 let kek = [0x2Bu8; 32];
4409
4410 {
4411 let s = DurableStorage::open_with_encryption(
4412 dir.path(),
4413 true,
4414 MemTableType::Standard,
4415 StorageEncryption::with_kek(EncryptionKey::new(kek), "test"),
4416 )
4417 .unwrap();
4418 s.enable_point_in_time_recovery().unwrap();
4419 let t = s.begin_transaction().unwrap();
4420 s.write(t, b"ek".to_vec(), b"ev".to_vec()).unwrap();
4421 s.commit(t).unwrap();
4422 s.checkpoint().unwrap();
4423 s.shutdown().ok();
4424 }
4425 assert!(dir.path().join("keyring.json").exists());
4426 assert!(dir.path().join("wal.manifest").exists());
4427
4428 // Reopen encrypted + PITR.
4429 let s = DurableStorage::open_with_encryption(
4430 dir.path(),
4431 true,
4432 MemTableType::Standard,
4433 StorageEncryption::with_kek(EncryptionKey::new(kek), "test"),
4434 )
4435 .unwrap();
4436 assert!(s.is_pitr_enabled());
4437 assert!(s.is_encrypted());
4438 s.recover().unwrap();
4439 let t = s.begin_transaction().unwrap();
4440 assert_eq!(s.read(t, b"ek").unwrap(), Some(b"ev".to_vec()));
4441 s.abort(t).unwrap();
4442 }
4443
4444 /// Crash-atomicity on an ENCRYPTED database: a simulated crash (forget) on an
4445 /// encrypted WAL must, on reopen with the correct key, replay committed data
4446 /// (decrypted via the crypto-aware recovery path) while NOT resurrecting
4447 /// aborted/in-flight writes — and a wrong key after the crash fails closed.
4448 /// Combines the at-rest-encryption + crash-atomicity guarantees.
4449 #[test]
4450 fn test_encrypted_crash_recovery_atomicity() {
4451 use crate::encryption::EncryptionKey;
4452 let dir = tempdir().unwrap();
4453 let kek = || StorageEncryption::with_kek(EncryptionKey::new([0xC1u8; 32]), "test");
4454 // open encrypted WITHOUT the file lock so a forget()+reopen works in-test.
4455 let open = |enc: StorageEncryption| {
4456 DurableStorage::open_with_full_config_internal(
4457 dir.path(),
4458 true,
4459 MemTableType::Standard,
4460 false,
4461 enc,
4462 )
4463 };
4464
4465 {
4466 let storage = open(kek()).unwrap();
4467 assert!(storage.is_encrypted());
4468 storage.set_sync_mode(2); // FULL: fsync each commit before the "crash"
4469 let t1 = storage.begin_transaction().unwrap();
4470 storage
4471 .write(t1, b"committed".to_vec(), b"durable".to_vec())
4472 .unwrap();
4473 storage.commit(t1).unwrap();
4474 let t2 = storage.begin_transaction().unwrap();
4475 storage
4476 .write(t2, b"aborted".to_vec(), b"x".to_vec())
4477 .unwrap();
4478 storage.abort(t2).unwrap();
4479 let t3 = storage.begin_transaction().unwrap();
4480 storage
4481 .write(t3, b"inflight".to_vec(), b"y".to_vec())
4482 .unwrap();
4483 std::mem::forget(storage); // crash: skip clean shutdown
4484 }
4485
4486 // Reopen with the CORRECT key: committed survives, others do not.
4487 {
4488 let storage = open(kek()).unwrap();
4489 storage.recover().unwrap();
4490 let t = storage.begin_transaction().unwrap();
4491 assert_eq!(
4492 storage.read(t, b"committed").unwrap(),
4493 Some(b"durable".to_vec()),
4494 "committed encrypted write must survive the crash"
4495 );
4496 assert_eq!(storage.read(t, b"aborted").unwrap(), None);
4497 assert_eq!(storage.read(t, b"inflight").unwrap(), None);
4498 storage.abort(t).unwrap();
4499 }
4500
4501 // Wrong key after the crash must fail closed (keyring canary).
4502 assert!(
4503 open(StorageEncryption::with_kek(
4504 EncryptionKey::new([0u8; 32]),
4505 "test"
4506 ))
4507 .is_err(),
4508 "wrong key after crash must fail closed"
4509 );
4510 }
4511
4512 /// Crash-atomicity invariant (Task 4 — completes the Task 1 single-writer
4513 /// contract). `test_crash_recovery` proves committed data survives a crash;
4514 /// this proves the other half: recovery must NOT resurrect aborted or
4515 /// in-flight (never-committed) writes. Together they assert the live
4516 /// single-writer engine's commit is atomic AND durable across a crash.
4517 #[test]
4518 fn test_crash_recovery_atomicity() {
4519 let dir = tempdir().unwrap();
4520
4521 // Phase 1: one committed write, one aborted, one in-flight at crash.
4522 {
4523 let storage = DurableStorage::open_without_lock(dir.path()).unwrap();
4524 storage.set_sync_mode(2); // FULL: fsync every commit before the "crash"
4525
4526 // Committed — must survive.
4527 let t1 = storage.begin_transaction().unwrap();
4528 storage
4529 .write(t1, b"committed".to_vec(), b"durable".to_vec())
4530 .unwrap();
4531 storage.commit(t1).unwrap();
4532
4533 // Aborted — must NOT be resurrected.
4534 let t2 = storage.begin_transaction().unwrap();
4535 storage
4536 .write(t2, b"aborted".to_vec(), b"rolledback".to_vec())
4537 .unwrap();
4538 storage.abort(t2).unwrap();
4539
4540 // In-flight (never committed) at crash time — must NOT be resurrected.
4541 let t3 = storage.begin_transaction().unwrap();
4542 storage
4543 .write(t3, b"inflight".to_vec(), b"lost".to_vec())
4544 .unwrap();
4545
4546 // Simulate a crash: skip Drop / clean shutdown.
4547 std::mem::forget(storage);
4548 }
4549
4550 // Phase 2: reopen + recover; assert atomicity.
4551 {
4552 let storage = DurableStorage::open_without_lock(dir.path()).unwrap();
4553 storage.recover().unwrap();
4554
4555 let t = storage.begin_transaction().unwrap();
4556 assert_eq!(
4557 storage.read(t, b"committed").unwrap(),
4558 Some(b"durable".to_vec()),
4559 "committed write must survive the crash"
4560 );
4561 assert_eq!(
4562 storage.read(t, b"aborted").unwrap(),
4563 None,
4564 "aborted write must not be resurrected by recovery"
4565 );
4566 assert_eq!(
4567 storage.read(t, b"inflight").unwrap(),
4568 None,
4569 "uncommitted in-flight write must not be resurrected by recovery"
4570 );
4571 storage.abort(t).unwrap();
4572 }
4573 }
4574
4575 #[test]
4576 fn test_scan_prefix() {
4577 let dir = tempdir().unwrap();
4578 let storage = DurableStorage::open(dir.path()).unwrap();
4579
4580 let txn = storage.begin_transaction().unwrap();
4581 storage
4582 .write(txn, b"user:1".to_vec(), b"alice".to_vec())
4583 .unwrap();
4584 storage
4585 .write(txn, b"user:2".to_vec(), b"bob".to_vec())
4586 .unwrap();
4587 storage
4588 .write(txn, b"order:1".to_vec(), b"order1".to_vec())
4589 .unwrap();
4590 storage.commit(txn).unwrap();
4591
4592 let txn2 = storage.begin_transaction().unwrap();
4593 let users = storage.scan(txn2, b"user:").unwrap();
4594 assert_eq!(users.len(), 2);
4595 storage.abort(txn2).unwrap();
4596 }
4597
4598 #[test]
4599 fn test_delete() {
4600 let dir = tempdir().unwrap();
4601 let storage = DurableStorage::open(dir.path()).unwrap();
4602
4603 // Insert
4604 let t1 = storage.begin_transaction().unwrap();
4605 storage
4606 .write(t1, b"key".to_vec(), b"value".to_vec())
4607 .unwrap();
4608 storage.commit(t1).unwrap();
4609
4610 // Verify exists
4611 let t2 = storage.begin_transaction().unwrap();
4612 assert!(storage.read(t2, b"key").unwrap().is_some());
4613 storage.abort(t2).unwrap();
4614
4615 // Delete
4616 let t3 = storage.begin_transaction().unwrap();
4617 storage.delete(t3, b"key".to_vec()).unwrap();
4618 storage.commit(t3).unwrap();
4619
4620 // Verify deleted
4621 let t4 = storage.begin_transaction().unwrap();
4622 assert!(storage.read(t4, b"key").unwrap().is_none());
4623 storage.abort(t4).unwrap();
4624 }
4625
4626 #[test]
4627 fn test_gc() {
4628 let dir = tempdir().unwrap();
4629 let storage = DurableStorage::open(dir.path()).unwrap();
4630
4631 // Create multiple versions
4632 for i in 0..10 {
4633 let txn = storage.begin_transaction().unwrap();
4634 storage
4635 .write(txn, b"key".to_vec(), format!("v{}", i).into_bytes())
4636 .unwrap();
4637 storage.commit(txn).unwrap();
4638 }
4639
4640 // GC should reclaim old versions
4641 let gc_count = storage.gc();
4642 // At least some versions should be collected
4643 // (exact count depends on implementation)
4644 let _ = gc_count; // gc_count is usize, always >= 0
4645 }
4646
4647 #[test]
4648 fn test_group_commit() {
4649 use std::sync::Arc;
4650 use std::thread;
4651
4652 let dir = tempdir().unwrap();
4653 let storage = Arc::new(DurableStorage::open_with_group_commit(dir.path()).unwrap());
4654
4655 // Spawn multiple threads to commit concurrently
4656 let mut handles = vec![];
4657 for i in 0..4 {
4658 let storage = Arc::clone(&storage);
4659 handles.push(thread::spawn(move || {
4660 let txn = storage.begin_transaction().unwrap();
4661 storage
4662 .write(
4663 txn,
4664 format!("key{}", i).into_bytes(),
4665 format!("val{}", i).into_bytes(),
4666 )
4667 .unwrap();
4668 storage.commit(txn).unwrap()
4669 }));
4670 }
4671
4672 // Wait for all commits
4673 let mut commit_times = vec![];
4674 for h in handles {
4675 commit_times.push(h.join().unwrap());
4676 }
4677
4678 // All commits should succeed
4679 assert!(commit_times.iter().all(|&ts| ts > 0));
4680
4681 // Verify data persisted
4682 let txn = storage.begin_transaction().unwrap();
4683 for i in 0..4 {
4684 let val = storage.read(txn, format!("key{}", i).as_bytes()).unwrap();
4685 assert_eq!(val, Some(format!("val{}", i).into_bytes()));
4686 }
4687 storage.abort(txn).unwrap();
4688 }
4689
4690 // ==================== ArenaMvccMemTable Tests ====================
4691
4692 #[test]
4693 fn test_arena_memtable_basic_write_read() {
4694 let memtable = ArenaMvccMemTable::new();
4695
4696 // Write some values
4697 memtable
4698 .write(b"key1", Some(b"value1".to_vec()), 1)
4699 .unwrap();
4700 memtable
4701 .write(b"key2", Some(b"value2".to_vec()), 1)
4702 .unwrap();
4703
4704 // Read them back (uncommitted, so need txn_id match)
4705 assert_eq!(memtable.read(b"key1", 0, Some(1)), Some(b"value1".to_vec()));
4706 assert_eq!(memtable.read(b"key2", 0, Some(1)), Some(b"value2".to_vec()));
4707 assert_eq!(memtable.read(b"key3", 0, Some(1)), None);
4708 }
4709
4710 #[test]
4711 fn test_arena_memtable_update() {
4712 let memtable = ArenaMvccMemTable::new();
4713
4714 memtable.write(b"key", Some(b"v1".to_vec()), 1).unwrap();
4715 memtable.write(b"key", Some(b"v2".to_vec()), 1).unwrap();
4716
4717 assert_eq!(memtable.read(b"key", 0, Some(1)), Some(b"v2".to_vec()));
4718 }
4719
4720 #[test]
4721 fn test_arena_memtable_delete() {
4722 let memtable = ArenaMvccMemTable::new();
4723
4724 memtable.write(b"key", Some(b"value".to_vec()), 1).unwrap();
4725 memtable.write(b"key", None, 1).unwrap(); // Delete = None value
4726
4727 assert_eq!(memtable.read(b"key", 0, Some(1)), None);
4728 }
4729
4730 #[test]
4731 fn test_arena_memtable_scan_prefix() {
4732 let memtable = ArenaMvccMemTable::new();
4733
4734 memtable
4735 .write(b"user:1:name", Some(b"Alice".to_vec()), 1)
4736 .unwrap();
4737 memtable
4738 .write(b"user:1:email", Some(b"alice@test.com".to_vec()), 1)
4739 .unwrap();
4740 memtable
4741 .write(b"user:2:name", Some(b"Bob".to_vec()), 1)
4742 .unwrap();
4743 memtable
4744 .write(b"order:1", Some(b"order_data".to_vec()), 1)
4745 .unwrap();
4746
4747 // Create a write set and commit
4748 let mut write_set = HashSet::new();
4749 write_set.insert(InlineKey::from_slice(b"user:1:name"));
4750 write_set.insert(InlineKey::from_slice(b"user:1:email"));
4751 write_set.insert(InlineKey::from_slice(b"user:2:name"));
4752 write_set.insert(InlineKey::from_slice(b"order:1"));
4753 memtable.commit(1, 10, &write_set);
4754
4755 // Scan for user:1:* (snapshot_ts > commit_ts to see committed data)
4756 let results = memtable.scan_prefix(b"user:1:", 11, None);
4757 assert_eq!(results.len(), 2);
4758
4759 // Scan for all users
4760 let results = memtable.scan_prefix(b"user:", 11, None);
4761 assert_eq!(results.len(), 3);
4762 }
4763
4764 #[test]
4765 fn test_arena_memtable_write_batch() {
4766 let memtable = ArenaMvccMemTable::new();
4767
4768 let writes: Vec<(&[u8], Option<Vec<u8>>)> = vec![
4769 (b"k1", Some(b"v1".to_vec())),
4770 (b"k2", Some(b"v2".to_vec())),
4771 (b"k3", Some(b"v3".to_vec())),
4772 ];
4773
4774 memtable.write_batch(&writes, 1).unwrap();
4775
4776 assert_eq!(memtable.read(b"k1", 0, Some(1)), Some(b"v1".to_vec()));
4777 assert_eq!(memtable.read(b"k2", 0, Some(1)), Some(b"v2".to_vec()));
4778 assert_eq!(memtable.read(b"k3", 0, Some(1)), Some(b"v3".to_vec()));
4779 }
4780
4781 #[test]
4782 fn test_arena_memtable_gc() {
4783 let memtable = ArenaMvccMemTable::new();
4784
4785 // Write multiple versions
4786 for i in 0..10 {
4787 memtable
4788 .write(b"key", Some(format!("v{}", i).into_bytes()), i + 1)
4789 .unwrap();
4790
4791 let mut write_set = HashSet::new();
4792 write_set.insert(InlineKey::from_slice(b"key"));
4793 memtable.commit(i + 1, (i + 1) * 10, &write_set);
4794 }
4795
4796 // GC old versions
4797 let gc_count = memtable.gc(90);
4798 let _ = gc_count; // gc_count is usize, always >= 0
4799 }
4800
4801 #[test]
4802 fn test_arena_memtable_size_tracking() {
4803 let memtable = ArenaMvccMemTable::new();
4804
4805 assert_eq!(memtable.size(), 0);
4806
4807 memtable.write(b"key", Some(b"value".to_vec()), 1).unwrap();
4808
4809 assert!(memtable.size() > 0);
4810 }
4811
4812 #[test]
4813 fn test_arena_memtable_abort() {
4814 let memtable = ArenaMvccMemTable::new();
4815
4816 memtable
4817 .write(b"key", Some(b"uncommitted".to_vec()), 1)
4818 .unwrap();
4819
4820 // Visible to same txn
4821 assert_eq!(
4822 memtable.read(b"key", 0, Some(1)),
4823 Some(b"uncommitted".to_vec())
4824 );
4825
4826 // Not visible to other txns
4827 assert_eq!(memtable.read(b"key", 0, Some(2)), None);
4828
4829 // Abort
4830 memtable.abort(1);
4831
4832 // No longer visible
4833 assert_eq!(memtable.read(b"key", 0, Some(1)), None);
4834 }
4835
4836 // ========================================================================
4837 // MemTableKind Tests - Unified Abstraction
4838 // ========================================================================
4839
4840 #[test]
4841 fn test_memtable_kind_standard() {
4842 let memtable = MemTableKind::new(MemTableType::Standard, true);
4843 assert_eq!(memtable.kind(), MemTableType::Standard);
4844
4845 // Write and read
4846 memtable
4847 .write(b"key1".to_vec(), Some(b"value1".to_vec()), 1)
4848 .unwrap();
4849
4850 // Commit transaction at ts=100
4851 let write_set = std::iter::once(InlineKey::from_slice(b"key1")).collect();
4852 memtable.commit(1, 100, &write_set);
4853
4854 // Read after commit - snapshot_ts must be > commit_ts for visibility
4855 let v = memtable.read(b"key1", 101, None);
4856 assert_eq!(v, Some(b"value1".to_vec()));
4857 }
4858
4859 #[test]
4860 fn test_memtable_kind_arena() {
4861 let memtable = MemTableKind::new(MemTableType::Arena, true);
4862 assert_eq!(memtable.kind(), MemTableType::Arena);
4863
4864 // Write and read
4865 memtable
4866 .write(b"key1".to_vec(), Some(b"value1".to_vec()), 1)
4867 .unwrap();
4868
4869 // Commit at ts=100
4870 let write_set = std::iter::once(InlineKey::from_slice(b"key1")).collect();
4871 memtable.commit(1, 100, &write_set);
4872
4873 // Read after commit - snapshot_ts > commit_ts
4874 let v = memtable.read(b"key1", 101, None);
4875 assert_eq!(v, Some(b"value1".to_vec()));
4876 }
4877
4878 #[test]
4879 fn test_memtable_kind_scan_range() {
4880 // Test both implementations have consistent behavior
4881 for kind in [MemTableType::Standard, MemTableType::Arena] {
4882 let memtable = MemTableKind::new(kind, true);
4883
4884 // Write some data
4885 for i in 0..5 {
4886 let key = format!("key{}", i);
4887 let value = format!("value{}", i);
4888 memtable
4889 .write(key.into_bytes(), Some(value.into_bytes()), 1)
4890 .unwrap();
4891 }
4892
4893 // Commit all at ts=100
4894 let write_set: HashSet<InlineKey> = (0..5)
4895 .map(|i| InlineKey::from_slice(format!("key{}", i).as_bytes()))
4896 .collect();
4897 memtable.commit(1, 100, &write_set);
4898
4899 // Scan range with snapshot_ts > commit_ts
4900 let results = memtable.scan_range(b"key1", b"key4", 101, None);
4901 assert_eq!(
4902 results.len(),
4903 3,
4904 "kind={:?} should have 3 results (key1, key2, key3)",
4905 kind
4906 );
4907 }
4908 }
4909
4910 #[test]
4911 fn test_durable_storage_arena() {
4912 let dir = tempdir().unwrap();
4913 let storage = DurableStorage::open_with_arena(dir.path()).unwrap();
4914
4915 assert_eq!(storage.memtable_type(), MemTableType::Arena);
4916
4917 // Basic transaction should work the same
4918 let txn_id = storage.begin_transaction().unwrap();
4919 storage
4920 .write(txn_id, b"key1".to_vec(), b"value1".to_vec())
4921 .unwrap();
4922 storage.commit(txn_id).unwrap();
4923
4924 let txn2 = storage.begin_transaction().unwrap();
4925 let v = storage.read(txn2, b"key1").unwrap();
4926 assert_eq!(v, Some(b"value1".to_vec()));
4927 storage.abort(txn2).unwrap();
4928 }
4929
4930 #[test]
4931 fn test_durable_storage_full_config() {
4932 let dir = tempdir().unwrap();
4933
4934 // Test with Arena and ordered index enabled
4935 let storage =
4936 DurableStorage::open_with_full_config(dir.path(), true, MemTableType::Arena).unwrap();
4937
4938 assert_eq!(storage.memtable_type(), MemTableType::Arena);
4939
4940 // Write multiple keys
4941 let txn = storage.begin_transaction().unwrap();
4942 for i in 0..10 {
4943 let key = format!("key{:02}", i);
4944 let value = format!("value{}", i);
4945 storage
4946 .write(txn, key.into_bytes(), value.into_bytes())
4947 .unwrap();
4948 }
4949 storage.commit(txn).unwrap();
4950
4951 // Scan should work (uses scan method for prefix)
4952 let txn2 = storage.begin_transaction().unwrap();
4953 let results = storage.scan(txn2, b"key0").unwrap();
4954 assert_eq!(results.len(), 10); // key00 through key09
4955 storage.abort(txn2).unwrap();
4956 }
4957}