donadb_x/engine.rs
1//! Engine layer — the public-facing orchestrator of all five subsystems.
2//!
3//! This module owns the [`DonaDbX`] struct and wires together:
4//!
5//! - **Subsystem 2** ([`crate::commutative::CommutativeLog`]): lock-free mmap append log.
6//! - **Subsystem 3** ([`crate::dual_buffer::DualBufferEngine`]): parallel fold coordinator.
7//! - **Subsystem 4** (Rayon fold pool inside `DualBufferEngine`): parallel BLAKE3 + XOR
8//! state-root computation, running asynchronously after each `commit()`.
9//! - **Subsystem 5** ([`crate::segment`]): segment rotation, JSON manifest, crash recovery.
10//! - **Subsystem 1** ([`ExecutionFrame`]): isolated write arena with rollback support.
11
12#![allow(unsafe_code)]
13
14use crate::commutative::{CommutativeLog, CMAG, CTOMB, CHDR};
15use crate::dual_buffer::DualBufferEngine;
16use crate::index::ShardIndex;
17use crate::segment::{Manifest, SegmentMeta, SealedSegment};
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicU64, Ordering};
21
22/// Re-export so callers can use [`BlockWriter`] without importing `dual_buffer`.
23pub use crate::dual_buffer::BlockWriter;
24
25// ── Config ────────────────────────────────────────────────────────────────────
26
27/// Runtime configuration for a [`DonaDbX`] instance.
28///
29/// All fields have sensible defaults via [`Config::default()`]; most deployments
30/// only need to override `buffer_size` to match available RAM.
31///
32/// # Example
33/// ```
34/// use donadb_x::Config;
35///
36/// let cfg = Config {
37/// buffer_size: 512 << 20, // 512 MiB segments
38/// rotate_threshold: 0.75,
39/// fold_threads: Some(4),
40/// };
41/// ```
42#[derive(Clone, Debug)]
43pub struct Config {
44 /// Maximum size of a single log segment in bytes.
45 ///
46 /// The active log is memory-mapped at this size on creation. When the fill
47 /// ratio reaches [`rotate_threshold`][Config::rotate_threshold], the segment
48 /// is sealed and a fresh one is opened.
49 ///
50 /// Default: `2 GiB` (`2 << 30`).
51 pub buffer_size: u64,
52
53 /// Fraction of [`buffer_size`][Config::buffer_size] at which segment rotation
54 /// is triggered, expressed as a value in `(0.0, 1.0]`.
55 ///
56 /// Rotation is checked after every `commit()`. Once
57 /// `write_offset / buffer_size >= rotate_threshold`, the current segment is
58 /// sealed atomically and a new active log is created.
59 ///
60 /// Default: `0.80` (rotate at 80 % full).
61 pub rotate_threshold: f64,
62
63 /// Number of threads used by the Rayon fold pool for post-commit hashing.
64 ///
65 /// `None` lets the engine choose automatically (currently half of the
66 /// logical CPU count). Set an explicit value to bound CPU usage on
67 /// shared hosts.
68 ///
69 /// Default: `None` (auto-detect).
70 pub fold_threads: Option<usize>,
71}
72
73impl Default for Config {
74 /// Returns a configuration suitable for most validator deployments:
75 /// 2 GiB segments, 80 % rotation threshold, auto-detected fold threads.
76 fn default() -> Self {
77 Self {
78 buffer_size: 2 << 30,
79 rotate_threshold: 0.80,
80 fold_threads: None,
81 }
82 }
83}
84
85// ── DonaDbX ───────────────────────────────────────────────────────────────────
86
87/// The top-level storage engine handle.
88///
89/// `DonaDbX` is a single struct that encapsulates all five subsystems of the
90/// DonaDbX storage engine. It is `Send + Sync` and is typically shared across
91/// threads via an `Arc<DonaDbX>`.
92///
93/// Writes are lock-free on the hot path: [`put()`][DonaDbX::put] performs a
94/// single `fetch_add` to reserve space in the active log, then copies the
95/// payload in. [`commit()`][DonaDbX::commit] snapshots all shards and launches
96/// a parallel fold; the returned [`CommitAck`] lets callers wait for or poll
97/// the resulting state root.
98pub struct DonaDbX {
99 dbe: DualBufferEngine,
100 gen: AtomicU64,
101 sealed: parking_lot::RwLock<Vec<SealedSegment>>,
102 manifest: parking_lot::Mutex<Manifest>,
103 manifest_path: PathBuf,
104 dir: PathBuf,
105 cfg: Config,
106 current_height: AtomicU64,
107 /// Direct Arc to the current segment's CommutativeLog.
108 /// Updated only on segment rotation. Zero atomic overhead on the read
109 /// hot path — no ArcSwap load, no refcount bump per get().
110 segment_log: arc_swap::ArcSwap<CommutativeLog>,
111}
112
113// ── CommitAck ─────────────────────────────────────────────────────────────────
114
115/// Handle returned by [`DonaDbX::commit()`].
116///
117/// After a `commit()` call, the active log is atomically swapped and the Rayon
118/// fold pool begins computing the new state root in the background. `CommitAck`
119/// lets callers synchronise with that computation.
120///
121/// - Call [`wait()`][CommitAck::wait] to **block** until the fold completes and
122/// obtain the 32-byte state root. Use this at the end of a block when you need
123/// the root before producing a block header.
124/// - Call [`try_poll()`][CommitAck::try_poll] to **check without blocking**
125/// whether the fold has finished. Useful in async runtimes or polling loops
126/// where blocking is undesirable.
127///
128/// The `#[must_use]` attribute ensures that accidentally discarding an ack
129/// (and therefore never waiting for the fold) produces a compiler warning.
130#[must_use = "call .wait() to obtain the state root once the fold completes"]
131pub struct CommitAck {
132 rx: std::sync::mpsc::Receiver<[u8; 32]>,
133}
134
135impl CommitAck {
136 /// Block until the async fold completes and return the 32-byte state root.
137 ///
138 /// If the fold thread panicked or the channel was dropped, returns an
139 /// all-zero sentinel value rather than propagating the error.
140 pub fn wait(self) -> [u8; 32] {
141 self.rx.recv().unwrap_or([0u8; 32])
142 }
143
144 /// Non-blocking poll. Returns `Some(root)` if the fold has finished,
145 /// `None` if it is still running.
146 pub fn try_poll(&self) -> Option<[u8; 32]> {
147 self.rx.try_recv().ok()
148 }
149}
150
151// ── DonaDbX impl ──────────────────────────────────────────────────────────────
152
153impl DonaDbX {
154 // ── Subsystem 5: open + crash recovery ────────────────────────────────────
155
156 /// Open or create a DonaDbX database at `dir`.
157 ///
158 /// # First call (new database)
159 /// The directory is created if it does not exist. A fresh `seg_active.log`
160 /// is memory-mapped at `cfg.buffer_size` bytes, and a `manifest.json` is
161 /// written listing it as the sole active segment.
162 ///
163 /// # Reopen (existing database)
164 /// The manifest is read to discover all sealed segments, which are
165 /// memory-mapped read-only. The active log is then replayed: every record
166 /// whose offset falls below the persisted committed-offset is re-inserted
167 /// into the in-memory index and XOR Merkle accumulator. This restores full
168 /// query capability without a separate write-ahead log.
169 ///
170 /// # Crash recovery
171 /// If the process crashed mid-write, the active log may contain a partial
172 /// record beyond the committed-offset. The replay scan stops at the first
173 /// corrupt or incomplete record, leaving only fully written data visible.
174 /// No manual repair step is required.
175 ///
176 /// # Errors
177 /// Returns [`crate::DbError::Io`] if the directory cannot be created, any
178 /// segment file cannot be opened, or the manifest cannot be read or written.
179 /// Returns [`crate::DbError::Corrupt`] if a mandatory segment file is
180 /// structurally invalid.
181 pub fn open(dir: impl AsRef<Path>, cfg: Config) -> crate::DbResult<Self> {
182 let dir = dir.as_ref();
183 std::fs::create_dir_all(dir)?;
184
185 let manifest_path = dir.join("manifest.json");
186 let mut manifest = Manifest::load(&manifest_path)?;
187
188 // Memory-map every sealed segment listed in the manifest.
189 let mut sealed_vec: Vec<SealedSegment> = Vec::new();
190 for meta in manifest.segments.iter().filter(|m| m.sealed) {
191 sealed_vec.push(SealedSegment::open(meta.clone())?);
192 }
193
194 // Open the durable active log file (seg_active.log).
195 let active_path = dir.join("seg_active.log");
196 let active_log = Arc::new(CommutativeLog::open(&active_path, cfg.buffer_size)?);
197
198 // Construct the DualBufferEngine, passing the active log as its initial buffer.
199 let dbe = DualBufferEngine::open(dir, cfg.buffer_size, Arc::clone(&active_log))?;
200
201 // Replay committed records from the active log to rebuild the index and
202 // Merkle accumulator after a restart or crash.
203 {
204 Self::replay_into(&active_log, 0, &dbe.index, &dbe.merkle)?;
205 }
206 // The local active_log Arc is dropped here; DualBufferEngine holds the
207 // sole remaining reference via its internal ArcSwap.
208
209 // Ensure the manifest always has an entry for the active segment.
210 if !manifest.segments.iter().any(|m| !m.sealed) {
211 manifest.segments.push(SegmentMeta {
212 id: manifest.next_id,
213 path: dir.join("seg_active.log").to_string_lossy().into(),
214 sealed: false,
215 used: 0,
216 min_height: 0,
217 max_height: u64::MAX,
218 });
219 manifest.next_id += 1;
220 manifest.save(&manifest_path)?;
221 }
222
223 Ok(Self {
224 dbe,
225 gen: AtomicU64::new(0),
226 sealed: parking_lot::RwLock::new(sealed_vec),
227 manifest: parking_lot::Mutex::new(manifest),
228 manifest_path,
229 dir: dir.to_owned(),
230 cfg,
231 current_height: AtomicU64::new(0),
232 segment_log: arc_swap::ArcSwap::from(Arc::clone(&active_log)),
233 })
234 }
235
236 // ── Subsystem 2: lock-free write ──────────────────────────────────────────
237
238 /// Append a key-value pair to the active log.
239 ///
240 /// This method is **truly lock-free**: it performs a single atomic
241 /// `fetch_add` to reserve a slot in the memory-mapped log, then copies
242 /// the key and value into that slot with `memcpy`. No mutex or spin-lock
243 /// is acquired on this path.
244 ///
245 /// The `prev_offset` field in the record header is written as `0` here.
246 /// The fold pass, which runs after [`commit()`][Self::commit], patches it
247 /// to the previous offset for the same key using the index it already holds.
248 /// This keeps the write path free of any index reads or shard locks.
249 ///
250 /// # Parameters
251 /// - `key`: the 32-byte key to write.
252 /// - `value`: arbitrary byte slice to associate with the key.
253 ///
254 /// # Returns
255 /// The byte offset in the log at which this record was written. Offsets are
256 /// stable for the lifetime of the segment and can be used with
257 /// [`get_at()`][Self::get_at] for direct MVCC access.
258 ///
259 /// # Errors
260 /// Returns [`crate::DbError::LogFull`] if the active segment has no room
261 /// for the record.
262 #[inline(always)]
263 pub fn put(&self, key: [u8; 32], value: &[u8]) -> crate::DbResult<u64> {
264 let height = self.current_height.load(Ordering::Acquire);
265 self.dbe.active.load().put_versioned(key, value, 0, height)
266 }
267
268 // ── Subsystem 3 + 4: commit — snapshot + async parallel fold ─────────────
269
270 /// Commit all pending writes for block `height` and launch the async fold.
271 ///
272 /// Snapshots the write frontier across all shards and dispatches a parallel
273 /// fold operation:
274 ///
275 /// 1. **Snapshot frontiers** — capture `write_offset` for each shard
276 /// 2. **Parallel fold** — the Rayon thread pool processes all shards
277 /// simultaneously, computing `BLAKE3(value)` for every record, updating
278 /// the index with correct `prev_offset` MVCC chain pointers, and
279 /// XOR-folding results into the commutative state root
280 ///
281 /// The fold runs on a dedicated thread pool without blocking the caller.
282 ///
283 /// # Returns
284 /// A [`CommitAck`] whose [`wait()`][CommitAck::wait] method blocks until
285 /// the fold finishes and returns the new 32-byte state root.
286 ///
287 /// # Errors
288 /// Returns [`crate::DbError::Io`] if segment rotation fails.
289 pub fn commit(&self, height: u64) -> crate::DbResult<CommitAck> {
290 self.current_height.store(height, Ordering::Release);
291 let cur_gen = self.gen.load(Ordering::Acquire);
292 let sealed_used = self.dbe.active.load().write_offset();
293
294 // Snapshot all shards and dispatch parallel fold
295 let fold_ack = self.dbe.swap(cur_gen)?;
296
297 // After the swap, check whether the sealed buffer has crossed the
298 // rotation threshold and rotate if so.
299 if sealed_used as f64 / self.cfg.buffer_size as f64 >= self.cfg.rotate_threshold {
300 self.rotate(height, sealed_used)?;
301 }
302
303 Ok(CommitAck { rx: fold_ack.into_rx() })
304 }
305
306 // ── Reads ─────────────────────────────────────────────────────────────────
307
308 /// Look up the latest committed value for `key`.
309 ///
310 /// Search order (optimized for minimal latency):
311 /// 0. **Value cache** — O(1) lock-free CLOCK cache, zero mmap access
312 /// 1. **Index** — O(1) lock-free DashMap lookup per shard
313 /// 2. **Active log unflushed region** — linear scan for writes since last fold
314 /// 3. **Sealed segments** — historical data from rotated segments
315 ///
316 /// The index entry's `log_id` is compared against the current generation
317 /// counter before dereferencing the offset. If they differ, the entry
318 /// belongs to a segment that has since been rotated.
319 ///
320 /// # Errors
321 /// Returns [`crate::DbError::NotFound`] if the key has never been written.
322 /// Returns [`crate::DbError::Corrupt`] if the record at the stored offset
323 /// fails its magic-number check.
324 pub fn get(&self, key: &[u8; 32]) -> crate::DbResult<Vec<u8>> {
325 // Step 0: value cache — O(1), zero mmap access, zero page faults.
326 // Populated by the fold thread on each commit(). A cache hit returns
327 // immediately without consulting the index or touching the mmap.
328 if let Some(entry) = self.dbe.index.get_entry(key) {
329 if entry.deleted { return Err(crate::DbError::NotFound); }
330 if let Some(cached) = self.dbe.vcache.get(key) {
331 return Ok(cached.to_vec());
332 }
333 }
334
335 // Step 1: index — O(1), covers all folded writes.
336 if let Some(entry) = self.dbe.index.get_entry(key) {
337 if entry.deleted { return Err(crate::DbError::NotFound); }
338 if entry.log_id == self.gen.load(Ordering::Acquire) {
339 let log = self.segment_log.load();
340 if let Ok(v) = self.read_from_log(&log, entry.offset as usize) {
341 return Ok(v);
342 }
343 }
344 }
345
346 // Step 2: unflushed active-log scan — writes since last fold.
347 {
348 let active = self.dbe.active.load();
349 if let Some(v) = active.scan_unflushed(key) {
350 return Ok(v);
351 }
352 }
353
354 // Step 3: sealed segments — rotated history.
355 for seg in self.sealed.read().iter().rev() {
356 if let Some(v) = seg.get_at(key, seg.meta.max_height) {
357 return Ok(v);
358 }
359 }
360 Err(crate::DbError::NotFound)
361 }
362
363 /// Zero-copy read — return a `MmapRef` pointing directly into the mmap.
364 ///
365 /// Same lookup order as [`get`][Self::get] but avoids heap allocation
366 /// on the happy path by returning a reference into the mmap.
367 ///
368 /// Falls back to a `Vec`-backed allocation (wrapped in `MmapRef`) only
369 /// for values that live in sealed segments (sealed segment reads already
370 /// return `Vec<u8>`).
371 ///
372 /// # Errors
373 /// Returns [`crate::DbError::NotFound`] if the key has never been written
374 /// or was deleted.
375 pub fn get_slice(&self, key: &[u8; 32]) -> crate::DbResult<crate::commutative::MmapRef> {
376 if let Some(entry) = self.dbe.index.get_entry(key) {
377 if entry.deleted { return Err(crate::DbError::NotFound); }
378 if entry.log_id == self.gen.load(Ordering::Acquire) {
379 let committed = self.segment_log.load();
380 if let Some(r) = committed.value_ref(entry.offset as usize) {
381 return Ok(r);
382 }
383 }
384 }
385 // Unflushed active-log scan
386 {
387 let active = self.dbe.active.load();
388 if let Some(v) = active.scan_unflushed(key) {
389 return Ok(crate::commutative::MmapRef::from_vec(v));
390 }
391 }
392 // Sealed segments
393 for seg in self.sealed.read().iter().rev() {
394 if let Some(v) = seg.get_at(key, seg.meta.max_height) {
395 return Ok(crate::commutative::MmapRef::from_vec(v));
396 }
397 }
398 Err(crate::DbError::NotFound)
399 }
400
401 /// Look up the value of `key` as it was at block `height`.
402 ///
403 /// Performs an **MVCC chain walk**: each log record stores a `prev_offset`
404 /// pointer to the preceding version of the same key. This method follows
405 /// the chain from the most recent entry backwards until it finds a record
406 /// whose block height is ≤ `height`.
407 ///
408 /// `height` semantics: a record written during `commit(h)` has height `h`.
409 /// Requesting `get_at(key, h)` returns the value that was current at the
410 /// end of block `h` — i.e., the most recent write with `record.height <= h`.
411 ///
412 /// The search tries the active log first (if its `min_height <= height`),
413 /// then walks sealed segments in reverse chronological order.
414 ///
415 /// # Errors
416 /// Returns [`crate::DbError::NotFound`] if no version of the key exists at
417 /// or before `height`.
418 pub fn get_at(&self, key: &[u8; 32], height: u64) -> crate::DbResult<Vec<u8>> {
419 let active_min = self.manifest.lock().segments.iter()
420 .find(|m| !m.sealed)
421 .map(|m| m.min_height)
422 .unwrap_or(0);
423
424 if active_min <= height {
425 if let Some(entry) = self.dbe.index.get_entry(key) {
426 if entry.log_id == self.gen.load(Ordering::Acquire) {
427 let log = self.segment_log.load();
428 if let Ok(v) = self.chain_walk(&log, entry.offset as usize, height) {
429 return Ok(v);
430 }
431 }
432 }
433 }
434
435 for seg in self.sealed.read().iter().rev() {
436 if seg.meta.min_height > height {
437 continue;
438 }
439 if let Some(v) = seg.get_at(key, height) {
440 return Ok(v);
441 }
442 }
443 Err(crate::DbError::NotFound)
444 }
445
446 /// Mark `key` as deleted in the active log.
447 ///
448 /// Appends a tombstone record (`CTOMB` magic, `vlen = 0`) to the active log.
449 /// After `commit()` + `ack.wait()`:
450 /// - [`get(key)`][Self::get] returns `NotFound`.
451 /// - The key's `key XOR BLAKE3(value)` contribution is XORed out of the
452 /// state root accumulator, keeping the root consistent.
453 /// - The key is excluded from [`scan_prefix`][Self::scan_prefix] results.
454 ///
455 /// Deletion is reversible: a subsequent `put(key, new_value)` in a later
456 /// block resurrects the key normally.
457 ///
458 /// # Errors
459 /// Returns [`crate::DbError::LogFull`] if the active segment has no room.
460 #[inline(always)]
461 pub fn delete(&self, key: [u8; 32]) -> crate::DbResult<u64> {
462 let height = self.current_height.load(Ordering::Acquire);
463 // Remove from the value cache immediately so concurrent get() calls
464 // see NotFound without waiting for the next fold.
465 self.dbe.vcache.remove(&key);
466 self.dbe.active.load().del_versioned(key, 0, height)
467 }
468
469 /// Return all committed key-value pairs whose key starts with `prefix`.
470 ///
471 /// Results are collected from the in-memory index (folded writes) and the
472 /// active-log unflushed region (writes since the last `commit()`), then
473 /// sorted by key in ascending order.
474 ///
475 /// Keys that were deleted (tombstoned) are excluded. The prefix is matched
476 /// byte-for-byte against the full 32-byte key.
477 ///
478 /// # Performance
479 /// O(n) over the number of live index entries — the index is unordered
480 /// (shard hash maps), so a full scan is required. For workloads that call
481 /// `scan_prefix` frequently consider keeping a sorted secondary structure;
482 /// for consensus storage (one scan per block boundary) this is fine.
483 pub fn scan_prefix(&self, prefix: &[u8]) -> crate::DbResult<Vec<([u8; 32], Vec<u8>)>> {
484 let mut results: Vec<([u8; 32], Vec<u8>)> = Vec::new();
485
486 // Step 1: collect all non-deleted index entries whose key matches prefix.
487 // Use get() for value retrieval — it handles committed log, active-log
488 // unflushed region, and sealed segments correctly across any number of
489 // prior commits.
490 for entry in self.dbe.index.all_entries() {
491 if entry.deleted { continue; }
492 if !entry.key.starts_with(prefix) { continue; }
493 if let Ok(v) = self.get(&entry.key) {
494 results.push((entry.key, v));
495 }
496 }
497
498 // Step 2: overlay unflushed active-log writes — covers keys written in
499 // the current uncommitted batch that are not yet in the index.
500 let active = self.dbe.active.load();
501 for (key, val) in active.scan_all_unflushed() {
502 if !key.starts_with(prefix) { continue; }
503 results.retain(|(k, _)| k != &key);
504 if !val.is_empty() {
505 results.push((key, val));
506 }
507 }
508
509 results.sort_unstable_by_key(|(k, _)| *k);
510 Ok(results)
511 }
512
513 pub fn scan_from_reverse(&self, start: &[u8; 32]) -> crate::DbResult<Vec<([u8; 32], Vec<u8>)>> {
514 let mut results: Vec<([u8; 32], Vec<u8>)> = Vec::new();
515
516 for entry in self.dbe.index.all_entries() {
517 if entry.deleted { continue; }
518 if entry.key.as_ref() > start.as_ref() { continue; }
519 if let Ok(v) = self.get(&entry.key) {
520 results.push((entry.key, v));
521 }
522 }
523
524 results.sort_unstable_by(|(a, _), (b, _)| b.cmp(a));
525 Ok(results)
526 }
527
528 /// Return the current 32-byte state root.
529 ///
530 /// The state root is a commutative XOR accumulator over `key XOR BLAKE3(value)`
531 /// for every key currently in the index. It is updated incrementally by the
532 /// fold pass after each [`commit()`][Self::commit], so this call is **O(1)** —
533 /// it simply reads the accumulator value from memory.
534 ///
535 /// The root covers exactly the set of keys visible to [`get()`][Self::get]:
536 /// all committed writes across the active log and all sealed segments.
537 pub fn state_root(&self) -> [u8; 32] {
538 self.dbe.state_root()
539 }
540
541 /// Return the number of distinct keys in the index.
542 ///
543 /// This counts unique keys across all shards of the in-memory [`ShardIndex`].
544 /// It reflects the state after the last completed fold, not pending writes
545 /// in the active buffer.
546 pub fn len(&self) -> usize {
547 self.dbe.index.len()
548 }
549
550 /// Returns `true` if the index contains no keys.
551 ///
552 /// This checks whether the in-memory index is empty after the last
553 /// completed fold, not including pending writes in the active buffer.
554 pub fn is_empty(&self) -> bool {
555 self.dbe.index.is_empty()
556 }
557
558 /// Return the number of index shards (informational).
559 pub fn num_shards(&self) -> usize {
560 self.dbe.num_shards()
561 }
562
563 /// Acquire a per-thread write handle for shard `shard_id`.
564 ///
565 /// `BlockWriter` holds a direct `Arc<CommutativeLog>`, bypassing the
566 /// `ArcSwap` load on every write. This makes it suitable for tight loops
567 /// where the overhead of an atomic pointer load per `put()` would be
568 /// measurable.
569 ///
570 /// **Shard semantics**: `shard_id` is purely advisory — it is used by the
571 /// `DualBufferEngine` to partition keys across index shards for reduced
572 /// lock contention. A thread should pick a consistent shard for the
573 /// duration of a block. The number of valid shard IDs is
574 /// [`num_shards()`][Self::num_shards]; values outside that range are
575 /// wrapped modulo the shard count.
576 ///
577 /// **One per thread per block**: do not share a `BlockWriter` between
578 /// threads. Obtain a new writer at the start of each block, as the
579 /// underlying `Arc<CommutativeLog>` is replaced on every
580 /// [`commit()`][Self::commit].
581 pub fn writer(&self, shard_id: usize) -> BlockWriter {
582 let height = self.current_height.load(Ordering::Acquire);
583 self.dbe.writer(shard_id, height)
584 }
585
586 // ── Subsystem 1: ExecutionFrame factory ───────────────────────────────────
587
588 /// Begin an isolated write frame.
589 ///
590 /// An `ExecutionFrame` provides **read isolation**: writes made through the
591 /// frame are appended to the active log (and are therefore durable once
592 /// [`commit()`][ExecutionFrame::commit] is called), but they are not
593 /// inserted into the index until the fold pass runs after the commit.
594 /// Concurrent [`get()`][Self::get] calls on the parent `DonaDbX` will not
595 /// observe the frame's writes until after the commit and fold complete.
596 ///
597 /// If the frame is no longer needed, call [`abort()`][ExecutionFrame::abort]
598 /// to zero out the reserved log region and roll back the write offset as if
599 /// the writes never happened.
600 pub fn begin_frame(&self) -> ExecutionFrame<'_> {
601 ExecutionFrame {
602 db: self,
603 start: self.dbe.active.load().write_offset(),
604 log_id: self.gen.load(Ordering::Acquire),
605 }
606 }
607
608 // ── Private helpers ───────────────────────────────────────────────────────
609
610 /// Read the value from the committed log at byte offset `off`.
611 ///
612 /// Validates the record magic number and length fields before copying the
613 /// value bytes into a new `Vec`. Returns `Corrupt` on any structural error.
614 #[cfg(test)]
615 fn read_committed(&self, off: usize) -> crate::DbResult<Vec<u8>> {
616 self.read_from_log(&self.segment_log.load(), off)
617 }
618
619 /// Read the value at byte offset `off` from an arbitrary `CommutativeLog`.
620 fn read_from_log(&self, log: &CommutativeLog, off: usize) -> crate::DbResult<Vec<u8>> {
621 let cap = log.capacity as usize;
622 if off + CHDR + 32 > cap {
623 return Err(crate::DbError::Corrupt);
624 }
625 let ptr = log.mmap_ptr();
626 let magic = u32::from_le_bytes(unsafe {
627 std::slice::from_raw_parts(ptr.add(off), 4)
628 }.try_into().unwrap());
629 if magic != CMAG {
630 return Err(crate::DbError::Corrupt);
631 }
632 let vlen = u32::from_le_bytes(unsafe {
633 std::slice::from_raw_parts(ptr.add(off + 4), 4)
634 }.try_into().unwrap()) as usize;
635 let vs = off + CHDR + 32;
636 if vs + vlen > cap {
637 return Err(crate::DbError::Corrupt);
638 }
639 Ok(unsafe { std::slice::from_raw_parts(ptr.add(vs), vlen).to_vec() })
640 }
641
642 /// Walk the MVCC `prev_offset` chain to find the value at or before `target` height.
643 ///
644 /// Each record in the log stores a `prev_offset` pointer (8 bytes at header
645 /// offset 8) to the previous write for the same key, and a block height (8
646 /// bytes at header offset 16). This method follows the chain until it finds
647 /// a record whose height ≤ `target` and returns its value, or returns
648 /// `NotFound` if no such record exists.
649 fn chain_walk(
650 &self,
651 log: &CommutativeLog,
652 start: usize,
653 target: u64,
654 ) -> crate::DbResult<Vec<u8>> {
655 let cap = log.capacity as usize;
656 let ptr = log.mmap_ptr();
657 let mut off = start;
658 loop {
659 if off + CHDR + 32 > cap {
660 return Err(crate::DbError::Corrupt);
661 }
662 let magic = u32::from_le_bytes(unsafe {
663 std::slice::from_raw_parts(ptr.add(off), 4)
664 }.try_into().unwrap());
665 if magic != CMAG {
666 return Err(crate::DbError::Corrupt);
667 }
668 let vlen = u32::from_le_bytes(unsafe {
669 std::slice::from_raw_parts(ptr.add(off + 4), 4)
670 }.try_into().unwrap()) as usize;
671 let prev = u64::from_le_bytes(unsafe {
672 std::slice::from_raw_parts(ptr.add(off + 8), 8)
673 }.try_into().unwrap());
674 let rec_h = u64::from_le_bytes(unsafe {
675 std::slice::from_raw_parts(ptr.add(off + 16), 8)
676 }.try_into().unwrap());
677 if rec_h <= target {
678 let vs = off + CHDR + 32;
679 if vs + vlen > cap {
680 return Err(crate::DbError::Corrupt);
681 }
682 return Ok(unsafe {
683 std::slice::from_raw_parts(ptr.add(vs), vlen).to_vec()
684 });
685 }
686 if prev == 0 {
687 return Err(crate::DbError::NotFound);
688 }
689 off = prev as usize;
690 }
691 }
692
693 /// Subsystem 5: seal the active log, open a fresh active log, and update
694 /// the manifest and in-memory sealed segment list.
695 ///
696 /// The rename from `seg_active.log` to the new sealed path is a POSIX
697 /// atomic operation; the existing mmap mapping in the `DualBufferEngine`
698 /// remains valid through the rename. The manifest is written twice: once
699 /// with the sealed entry and once with the new active entry, so a crash
700 /// between the two writes leaves a recoverable state.
701 fn rotate(&self, height: u64, actual: u64) -> crate::DbResult<()> {
702 let next_id = { self.manifest.lock().next_id };
703 let seal_path = self.dir.join(format!("seg_{:04}.log", next_id));
704 let seal_min = self.manifest.lock().segments.iter()
705 .find(|m| !m.sealed)
706 .map(|m| m.min_height)
707 .unwrap_or(0);
708
709 // Atomically rename the active file; the existing mmap stays valid.
710 std::fs::rename(self.dir.join("seg_active.log"), &seal_path)
711 .map_err(crate::DbError::Io)?;
712
713 // Write the manifest with the sealed entry first, then with the new
714 // active entry. Crashing between the two saves leaves a recoverable
715 // manifest (the new active segment simply won't exist yet and will be
716 // created as empty on the next open).
717 {
718 let mut m = self.manifest.lock();
719 m.segments.retain(|s| s.sealed);
720 m.segments.push(SegmentMeta {
721 id: next_id,
722 path: seal_path.to_string_lossy().into(),
723 sealed: true,
724 used: actual,
725 min_height: seal_min,
726 max_height: height,
727 });
728 m.next_id += 1;
729 m.save(&self.manifest_path)?;
730 let new_id = m.next_id;
731 m.segments.push(SegmentMeta {
732 id: new_id,
733 path: self.dir.join("seg_active.log").to_string_lossy().into(),
734 sealed: false,
735 used: 0,
736 min_height: height + 1,
737 max_height: u64::MAX,
738 });
739 m.next_id += 1;
740 m.save(&self.manifest_path)?;
741 }
742
743 // Bump the generation counter so that any index entries written to the
744 // old log are recognised as belonging to a sealed segment on the next
745 // get() call.
746 self.gen.fetch_add(1, Ordering::AcqRel);
747 let new_log = Arc::new(CommutativeLog::open(
748 &self.dir.join("seg_active.log"),
749 self.cfg.buffer_size,
750 )?);
751 // Install the new log into shard 0 (updates active/committed aliases)
752 // and into segment_log so get() reads from the new segment immediately.
753 self.dbe.replace_shard_log(0, Arc::clone(&new_log));
754 self.segment_log.store(new_log);
755 self.dbe.vcache.clear();
756
757 // Memory-map the newly sealed segment and append it to the sealed list.
758 let new_meta = self.manifest.lock().segments.iter()
759 .find(|s| s.id == next_id)
760 .ok_or_else(|| crate::DbError::Io(
761 std::io::Error::new(std::io::ErrorKind::NotFound, "sealed meta missing"),
762 ))?
763 .clone();
764 self.sealed.write().push(SealedSegment::open(new_meta)?);
765 Ok(())
766 }
767
768 /// Replay committed records from `log` into `index` and `merkle`.
769 ///
770 /// Called during [`open()`][Self::open] to restore in-memory state from the
771 /// durable active log after a restart or crash. The scan starts at byte
772 /// offset 8 (after the log header) and stops at the first corrupt or
773 /// incomplete record. Only records below the persisted `committed_offset`
774 /// are processed.
775 ///
776 /// For each record, the accumulator is updated by XORing out the old
777 /// `key XOR BLAKE3(old_value)` contribution (if the key was already seen
778 /// earlier in the replay) and XORing in the new `key XOR BLAKE3(value)`.
779 fn replay_into(
780 log: &Arc<CommutativeLog>,
781 log_id: u64,
782 index: &Arc<ShardIndex>,
783 merkle: &Arc<parking_lot::Mutex<[u8; 32]>>,
784 ) -> crate::DbResult<()> {
785 let committed = log.committed_offset() as usize;
786 if committed <= 8 {
787 return Ok(());
788 }
789 let ptr = log.mmap_ptr();
790 let mut cur = 8usize;
791 let mut acc = [0u8; 32];
792
793 while cur + CHDR + 32 <= committed {
794 let magic = u32::from_le_bytes(unsafe {
795 std::slice::from_raw_parts(ptr.add(cur), 4)
796 }.try_into().unwrap());
797
798 if magic == CTOMB {
799 let key: [u8; 32] = unsafe {
800 std::slice::from_raw_parts(ptr.add(cur + CHDR), 32)
801 }.try_into().unwrap();
802 // XOR out using cached value_hash — no mmap re-read needed.
803 if let Some(entry) = index.get_entry(&key) {
804 if !entry.deleted {
805 for i in 0..32 { acc[i] ^= key[i] ^ entry.value_hash[i]; }
806 }
807 }
808 let wc = index.count(&key) + 1;
809 index.upsert_with_deleted(key, cur as u64, wc, log_id, true);
810 cur += CHDR + 32;
811 continue;
812 }
813
814 if magic != CMAG {
815 break;
816 }
817 let vlen = u32::from_le_bytes(unsafe {
818 std::slice::from_raw_parts(ptr.add(cur + 4), 4)
819 }.try_into().unwrap()) as usize;
820 let total = CHDR + 32 + vlen;
821 if cur + total > committed || vlen > committed {
822 break;
823 }
824 let key: [u8; 32] = unsafe {
825 std::slice::from_raw_parts(ptr.add(cur + CHDR), 32)
826 }.try_into().unwrap();
827 let val_end = cur + CHDR + 32 + vlen;
828 if val_end > committed {
829 break;
830 }
831 let val = unsafe { std::slice::from_raw_parts(ptr.add(cur + CHDR + 32), vlen) };
832 let vh = *blake3::hash(val).as_bytes();
833
834 // Remove the previous contribution of this key from the accumulator.
835 if let Some(entry) = index.get_entry(&key) {
836 if !entry.deleted {
837 // Use cached value_hash — never re-read from mmap.
838 for i in 0..32 { acc[i] ^= key[i] ^ entry.value_hash[i]; }
839 }
840 }
841 for i in 0..32 {
842 acc[i] ^= key[i] ^ vh[i];
843 }
844 let wc = index.count(&key) + 1;
845 index.upsert_full(key, cur as u64, wc, log_id, false, vh);
846 cur += total;
847 }
848 log.set_committed(cur as u64);
849 *merkle.lock() = acc;
850 Ok(())
851 }
852}
853
854// ── Subsystem 1: ExecutionFrame ───────────────────────────────────────────────
855
856/// An isolated write arena scoped to a single block.
857///
858/// Writes appended through an `ExecutionFrame` go into the active log immediately
859/// (making them durable on fsync) but are not visible to [`DonaDbX::get()`]
860/// until the frame is committed and the subsequent fold pass updates the index.
861///
862/// Use [`abort()`][ExecutionFrame::abort] to roll back all writes made since the
863/// frame was created, as if they never happened. Use
864/// [`commit()`][ExecutionFrame::commit] to promote them into the committed log.
865pub struct ExecutionFrame<'a> {
866 db: &'a DonaDbX,
867 /// Write offset in the active log at the time the frame was opened.
868 /// Used as the rollback point on abort.
869 start: u64,
870 /// Log generation at frame creation. If this differs from the current
871 /// generation at abort time, a rotation has already sealed the frame;
872 /// no rollback is needed.
873 log_id: u64,
874}
875
876impl<'a> ExecutionFrame<'a> {
877 /// Append a key-value pair within this frame.
878 ///
879 /// Identical semantics to [`DonaDbX::put()`]: lock-free, one `fetch_add`,
880 /// one `memcpy`. The write is not reflected in [`DonaDbX::get()`] until
881 /// this frame is committed and the fold completes.
882 #[inline(always)]
883 pub fn put(&self, key: [u8; 32], value: &[u8]) -> crate::DbResult<u64> {
884 self.db.put(key, value)
885 }
886
887 /// Commit all writes in this frame for block `height`.
888 ///
889 /// Equivalent to calling [`DonaDbX::commit()`] directly. The frame is
890 /// consumed and its writes become visible to readers after the fold
891 /// completes.
892 pub fn commit(self, height: u64) -> crate::DbResult<CommitAck> {
893 self.db.commit(height)
894 }
895
896 /// Abort this frame, rolling back all writes made since it was created.
897 ///
898 /// Zero-fills the log region from `start` to the current write offset,
899 /// then resets the write offset to `start`. This makes the region
900 /// available for reuse and leaves no trace in the log.
901 ///
902 /// If a segment rotation occurred after this frame was opened (detected
903 /// by comparing the stored `log_id` with the current generation), the
904 /// frame has already been sealed into a segment. In that case the abort
905 /// is a no-op: the rotation's committed-offset boundary already excludes
906 /// any partial writes from this frame.
907 ///
908 /// The method waits for all in-flight lock-free writes (those that reserved
909 /// space but have not yet finished copying) to complete before zeroing, to
910 /// avoid a data race with concurrent writers in the same shard.
911 pub fn abort(self) {
912 let log_arc = self.db.dbe.active.load_full();
913 let cur_gen = self.db.gen.load(Ordering::Acquire);
914 // If the log was rotated after this frame was created, the frame's
915 // writes are already sealed and there is nothing to roll back.
916 if cur_gen != self.log_id {
917 return;
918 }
919 let _guard = log_arc.commit_lock.lock();
920 // Wait for any concurrent lock-free writers that have reserved space
921 // but have not yet finished their memcpy.
922 while log_arc.inflight.load(Ordering::Acquire) > 0 {
923 std::hint::spin_loop();
924 }
925 let current = log_arc.write_offset() as usize;
926 let start = self.start as usize;
927 if current > start {
928 unsafe {
929 std::ptr::write_bytes(
930 log_arc.mmap_ptr().add(start) as *mut u8,
931 0,
932 current - start,
933 );
934 }
935 }
936 log_arc.write_offset.store(self.start, Ordering::Release);
937 }
938}
939
940#[cfg(test)]
941mod engine_tests {
942 use super::*;
943 use tempfile::tempdir;
944
945 fn cfg() -> Config { Config { buffer_size: 256 << 20, ..Default::default() } }
946
947 #[test]
948 fn scan_prefix_debug() {
949 let d = tempdir().unwrap();
950 let db = DonaDbX::open(d.path(), cfg()).unwrap();
951 let mut k1 = [0u8; 32]; k1[0] = 0x01; k1[1] = 0x00;
952 let mut k2 = [0u8; 32]; k2[0] = 0x01; k2[1] = 0x01;
953 let mut k3 = [0u8; 32]; k3[0] = 0x02;
954 db.put(k1, b"a").unwrap();
955 db.put(k2, b"b").unwrap();
956 db.put(k3, b"c").unwrap();
957 db.commit(1).unwrap().wait();
958
959 let cur_gen = db.gen.load(Ordering::Acquire);
960 eprintln!("cur_gen = {cur_gen}");
961 for e in db.dbe.index.all_entries() {
962 let rc = db.read_committed(e.offset as usize);
963 let sw = e.key.starts_with(&[0x01u8]);
964 eprintln!(" key[0]={:#04x} log_id={} offset={} deleted={} read_committed={:?} starts_with_01={}",
965 e.key[0], e.log_id, e.offset, e.deleted, rc.as_ref().map(|v| v.len()), sw);
966 }
967 let result = db.scan_prefix(&[0x01u8]).unwrap();
968 eprintln!("scan_prefix results: {}", result.len());
969 assert_eq!(result.len(), 2);
970 }
971
972 #[test]
973 fn cache_populated_and_hit() {
974 let d = tempdir().unwrap();
975 let db = DonaDbX::open(d.path(), cfg()).unwrap();
976 let mut k = [0u8; 32]; k[0] = 0x42;
977 db.put(k, b"cached_value").unwrap();
978 db.commit(1).unwrap().wait();
979
980 let cache_len = db.dbe.vcache.len();
981 eprintln!("vcache entries after commit: {}", cache_len);
982 assert!(cache_len > 0, "fold thread must populate cache");
983
984 // Direct cache hit
985 let hit = db.dbe.vcache.get(&k);
986 assert!(hit.is_some(), "key must be in cache after fold");
987 assert_eq!(hit.unwrap().as_ref(), b"cached_value");
988
989 // get() must return the cached value
990 assert_eq!(db.get(&k).unwrap(), b"cached_value");
991 }
992
993 #[test]
994 fn optimization_benchmark() {
995 use std::time::Instant;
996 use std::sync::Arc;
997
998 let d = tempdir().unwrap();
999 let db = Arc::new(DonaDbX::open(d.path(), cfg()).unwrap());
1000
1001 eprintln!("\n╔════════════════════════════════════════════════════════════╗");
1002 eprintln!("║ DonaDbX Optimization Benchmark ║");
1003 eprintln!("╚════════════════════════════════════════════════════════════╝");
1004
1005 // Test 1: Write throughput
1006 let n = 50_000u64;
1007 let val = vec![42u8; 128];
1008
1009 eprintln!("\n[1] Write {} keys (128 bytes each)", n);
1010 let write_start = Instant::now();
1011 for i in 0..n {
1012 let mut key = [0u8; 32];
1013 key[..8].copy_from_slice(&i.to_le_bytes());
1014 db.put(key, &val).unwrap();
1015 }
1016 let write_time = write_start.elapsed();
1017 eprintln!(" Write time: {:?} ({:.0} ops/s)",
1018 write_time, n as f64 / write_time.as_secs_f64());
1019
1020 // Test 2: Commit + parallel fold
1021 eprintln!("\n[2] Commit + parallel fold");
1022 let commit_start = Instant::now();
1023 let root = db.commit(1).unwrap().wait();
1024 let commit_time = commit_start.elapsed();
1025 eprintln!(" Commit time: {:?}", commit_time);
1026 eprintln!(" State root: {}", hex::encode(&root[..8]));
1027
1028 // Test 3: Cache effectiveness (CLOCK eviction)
1029 eprintln!("\n[3] Read performance (cache effectiveness)");
1030 let cache_len = db.dbe.vcache.len();
1031 eprintln!(" Cache entries: {}", cache_len);
1032
1033 let read_start = Instant::now();
1034 let mut hits = 0;
1035 for i in 0..10_000u64 {
1036 let mut key = [0u8; 32];
1037 key[..8].copy_from_slice(&(i * 5).to_le_bytes());
1038 if db.get(&key).is_ok() {
1039 hits += 1;
1040 }
1041 }
1042 let read_time = read_start.elapsed();
1043 eprintln!(" 10K reads: {:?} ({:.0} reads/s)",
1044 read_time, 10_000.0 / read_time.as_secs_f64());
1045 eprintln!(" Hit rate: {:.1}%", hits as f64 / 100.0);
1046
1047 // Test 4: Multi-shard concurrent writes
1048 eprintln!("\n[4] Concurrent writes (multi-shard)");
1049 let threads = 4;
1050 let per_thread = 10_000u64;
1051 let val_arc = Arc::new(val);
1052
1053 let concurrent_start = Instant::now();
1054 let handles: Vec<_> = (0..threads).map(|shard_id| {
1055 let db = Arc::clone(&db);
1056 let val = Arc::clone(&val_arc);
1057 std::thread::spawn(move || {
1058 let writer = db.writer(shard_id);
1059 for i in 0..per_thread {
1060 let mut key = [0u8; 32];
1061 key[0] = shard_id as u8;
1062 key[8..16].copy_from_slice(&i.to_le_bytes());
1063 writer.put(key, &val).unwrap();
1064 }
1065 })
1066 }).collect();
1067
1068 for h in handles { h.join().unwrap(); }
1069 let concurrent_time = concurrent_start.elapsed();
1070 let total_ops = threads as u64 * per_thread;
1071 eprintln!(" {} threads × {} ops: {:?} ({:.0} ops/s)",
1072 threads, per_thread, concurrent_time,
1073 total_ops as f64 / concurrent_time.as_secs_f64());
1074
1075 let root2 = db.commit(2).unwrap().wait();
1076 eprintln!(" Final index size: {} keys", db.len());
1077 eprintln!(" State root: {}", hex::encode(&root2[..8]));
1078
1079 eprintln!("\n✓ All optimization benchmarks passed");
1080 }
1081}