Skip to main content

donadb_x/
lib.rs

1//! # DonaDbX — Lock-Free Storage Engine for Blockchain Validator State
2//!
3//! DonaDbX is a high-performance, append-only key-value store built for
4//! blockchain validator state. It combines a lock-free write path, atomic
5//! N-shard double-buffering, parallel BLAKE3 Merkle folding, and crash-safe
6//! segment rotation into a single coherent API surface.
7//!
8//! ## Architecture
9//!
10//! The engine is composed of five cooperating subsystems:
11//!
12//! ```text
13//! ┌──────────────────────────────────────────────────────────────┐
14//! │                        DonaDbX API                           │
15//! └───────────┬──────────────────────────────────┬───────────────┘
16//!             │                                  │
17//!   ┌─────────▼──────────┐            ┌──────────▼──────────┐
18//!   │  1. ExecutionFrame │            │  3. DualBufferEngine │
19//!   │  Isolated write    │            │  N-shard ArcSwap;    │
20//!   │  arena; rollback   │            │  atomic buffer swap  │
21//!   └─────────┬──────────┘            └──────┬───────────────┘
22//!             │                             │
23//!             │                  ┌──────────┼──────────────┐
24//!             │                  │          │              │
25//!   ┌─────────▼──────────┐  ┌────▼───────┐  ┌▼────────────┐
26//!   │  2. CommutativeLog │  │ 4. Rayon   │  │ 5. Segment  │
27//!   │  Lock-free mmap    │  │    fold    │  │    Manager  │
28//!   │  fetch_add +       │  │  Parallel  │  │  Rotation,  │
29//!   │  memcpy append     │  │  BLAKE3 +  │  │  manifest,  │
30//!   │  posix_fallocate   │  │  XOR root  │  │  recovery   │
31//!   └────────────────────┘  └────────────┘  └─────────────┘
32//! ```
33//!
34//! | # | Subsystem | Role |
35//! |---|-----------|------|
36//! | 1 | [`ExecutionFrame`] | Isolated write arena with zero-copy rollback. Writes are visible via [`DonaDbX::get`] immediately; visible via the index after [`ExecutionFrame::commit`]. |
37//! | 2 | [`commutative::CommutativeLog`] | Memory-mapped append log. One `fetch_add` reserves space; one `memcpy` fills it. `posix_fallocate` pre-allocates physical blocks so disk-full surfaces at segment creation, not mid-write. |
38//! | 3 | `DualBufferEngine` | N shards (one per logical CPU), each with monotonically-growing logs. [`DonaDbX::commit`] snapshots all shards and launches parallel fold without blocking writers. |
39//! | 4 | Rayon fold pool | After each commit, runs parallel `BLAKE3(value)` hashing across all shards simultaneously. Maintains a commutative XOR accumulator `key XOR BLAKE3(value)` for the state root. |
40//! | 5 | `SegmentManager` | Rotates the active log to a sealed segment at the fill threshold. Writes a crash-safe JSON manifest via atomic `write-tmp → rename`. Replays on reopen. |
41//!
42//! ## Key design decisions
43//!
44//! **Lock-free writes.** [`DonaDbX::put`] performs a single `fetch_add` on the
45//! shard's `write_offset` atomic to reserve a slot, then copies the payload in
46//! with `memcpy`. No mutex is acquired on the hot write path.
47//!
48//! **Parallel fold.** [`DonaDbX::commit`] snapshots all shard write frontiers
49//! and dispatches them to a Rayon thread pool for parallel processing. Each
50//! shard is folded independently, maximizing CPU utilization.
51//!
52//! **Commutative Merkle root.** The state root is maintained as an XOR
53//! accumulator over `key XOR BLAKE3(value)` for every key. Incremental updates
54//! are O(1): XOR out the old contribution, XOR in the new one. The final root
55//! is `BLAKE3(accumulator)`.
56//!
57//! **Immediate read visibility.** [`DonaDbX::get`] checks three sources in
58//! order: (1) the shard index (O(1), covers folded writes), (2) an active-log
59//! scan over the current unflushed batch (covers writes since the last fold),
60//! (3) sealed segments (covers rotated history). In-batch reads are visible
61//! immediately without waiting for the next `commit()`.
62//!
63//! **Crash recovery.** The first 8 bytes of `seg_active.log` store
64//! `committed_offset` as a little-endian `u64`. On reopen, the engine scans
65//! only the range `[8, committed_offset)`, rebuilding the index and XOR
66//! accumulator from scratch. Partial writes above `committed_offset` are
67//! silently discarded. No separate write-ahead log is needed.
68//!
69//! **Disk-full protection.** `posix_fallocate(2)` is called at segment
70//! creation time. If the filesystem cannot allocate the required space,
71//! [`DbError::Io`]`(ENOSPC)` is returned immediately — never as a `SIGBUS`
72//! mid-write inside a validation loop.
73//!
74//! ## Quick start
75//!
76//! ```no_run
77//! use donadb_x::{DonaDbX, Config};
78//!
79//! let db = DonaDbX::open("./state", Config::default()).unwrap();
80//!
81//! let key = [1u8; 32];
82//! db.put(key, b"hello").unwrap();
83//!
84//! // commit() swaps the active buffer and launches an async fold.
85//! let ack = db.commit(1).unwrap();
86//!
87//! // wait() blocks until the fold completes and returns the 32-byte state root.
88//! let root = ack.wait();
89//!
90//! assert_eq!(db.get(&key).unwrap(), b"hello");
91//! println!("state root: {}", hex::encode(root));
92//! ```
93//!
94//! ## Multi-threaded writes with [`BlockWriter`]
95//!
96//! For high-throughput ingestion, acquire one [`BlockWriter`] per thread per
97//! block. Each writer holds a direct `Arc<CommutativeLog>`, so writers on
98//! different shards contend only on their own shard's `write_offset` atomic —
99//! never on each other.
100//!
101//! ```no_run
102//! use donadb_x::{DonaDbX, Config};
103//! use std::sync::Arc;
104//!
105//! let db = Arc::new(DonaDbX::open("./state", Config::default()).unwrap());
106//!
107//! let handles: Vec<_> = (0..4).map(|shard_id| {
108//!     let db = Arc::clone(&db);
109//!     std::thread::spawn(move || {
110//!         let writer = db.writer(shard_id);
111//!         for i in 0u8..64 {
112//!             let mut key = [0u8; 32];
113//!             key[0] = shard_id as u8;
114//!             key[1] = i;
115//!             writer.put(key, &[i; 8]).unwrap();
116//!         }
117//!     })
118//! }).collect();
119//!
120//! for h in handles { h.join().unwrap(); }
121//! let root = db.commit(2).unwrap().wait();
122//! println!("block 2 state root: {}", hex::encode(root));
123//! ```
124//!
125//! ## Complexity summary
126//!
127//! | Operation | Complexity | Notes |
128//! |-----------|------------|-------|
129//! | [`put()`][DonaDbX::put] | O(1) | One `fetch_add` + one `memcpy`; no locks |
130//! | [`get()`][DonaDbX::get] | O(1) + O(u) | O(1) index hit; O(u) unflushed scan on miss, where u = records in current batch |
131//! | [`get_at()`][DonaDbX::get_at] | O(v) | MVCC chain walk over v versions of the key |
132//! | [`commit()`][DonaDbX::commit] | O(1) enqueue | Swap is instantaneous; fold is async |
133//! | [`state_root()`][DonaDbX::state_root] | O(1) | Reads the pre-maintained XOR accumulator |
134//! | Segment rotation | O(n) amortised | Runs once at fill threshold per segment |
135//! | Crash recovery | O(r) | Replays r committed records from the active log |
136
137use thiserror::Error;
138
139// ── Error type ────────────────────────────────────────────────────────────────
140
141/// The unified error type for all DonaDbX operations.
142///
143/// All public methods return `DbResult<T>`, which is an alias for
144/// `Result<T, DbError>`.
145#[derive(Debug, Error)]
146pub enum DbError {
147    /// An underlying OS I/O error (file open, mmap, read, write, rename, …).
148    #[error("I/O: {0}")]
149    Io(#[from] std::io::Error),
150
151    /// The active log has no remaining capacity for the requested write.
152    ///
153    /// This should not occur under normal operation because the segment
154    /// manager rotates the log before it reaches capacity. If it does occur,
155    /// increase `Config::buffer_size` or lower `Config::rotate_threshold`.
156    #[error("Log full")]
157    LogFull,
158
159    /// A log record's magic number or length fields are invalid.
160    ///
161    /// This may indicate file corruption or an incomplete write from a
162    /// previous crash. The crash-recovery path stops scanning at the first
163    /// corrupt record, so partial trailing writes are safe.
164    #[error("Corrupt")]
165    Corrupt,
166
167    /// The requested key does not exist in any committed log or sealed segment.
168    #[error("Not found")]
169    NotFound,
170
171    /// A requested feature or configuration value is not supported.
172    ///
173    /// The attached `&'static str` identifies which feature was requested.
174    #[error("Unsupported: {0}")]
175    Unsupported(&'static str),
176}
177
178/// Convenience alias for `Result<T, DbError>`.
179pub type DbResult<T> = Result<T, DbError>;
180
181// ── Modules ───────────────────────────────────────────────────────────────────
182
183/// Public index API: sharded hash-map for key → log-offset lookups.
184pub mod index;
185
186/// Public commutative-log API: lock-free, memory-mapped append log.
187pub mod commutative;
188
189/// In-process value cache — bounded DashMap populated by the fold thread.
190pub(crate) mod value_cache;
191
192/// String prefix index — secondary BTreeMap for lexicographic prefix scans.
193pub mod string_index;
194
195/// Segment management: rotation, manifest persistence, sealed-segment reads.
196pub(crate) mod segment;
197
198/// Double-buffering engine: atomic buffer swap and async Rayon fold pool.
199pub(crate) mod dual_buffer;
200
201/// Top-level engine: `DonaDbX`, `Config`, `CommitAck`, `ExecutionFrame`.
202mod engine;
203
204// ── Public re-exports ─────────────────────────────────────────────────────────
205
206pub use engine::{DonaDbX, Config, CommitAck, ExecutionFrame, BlockWriter};
207pub use commutative::MmapRef;
208pub use string_index::StringIndex;
209
210// ── Tests ─────────────────────────────────────────────────────────────────────
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use tempfile::tempdir;
216
217    /// Construct a 32-byte key from a single discriminator byte.
218    fn k(n: u8) -> [u8; 32] {
219        let mut b = [0u8; 32];
220        b[0] = n;
221        b
222    }
223
224    /// Small config suitable for unit tests: 4 MiB segments avoid exhausting
225    /// disk space now that posix_fallocate pre-allocates physical blocks.
226    fn test_cfg() -> Config {
227        Config { buffer_size: 4 << 20, ..Default::default() }
228    }
229
230    /// Smoke test: write two keys, commit, then read both back.
231    #[test]
232    fn basic() {
233        let d = tempdir().unwrap();
234        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
235        db.put(k(1), b"hello").unwrap();
236        db.put(k(2), b"world").unwrap();
237        db.commit(1).unwrap().wait();
238        assert_eq!(db.get(&k(1)).unwrap(), b"hello");
239        assert_eq!(db.get(&k(2)).unwrap(), b"world");
240    }
241
242    /// Writing the same key twice in one block should leave only the latest value.
243    #[test]
244    fn overwrite() {
245        let d = tempdir().unwrap();
246        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
247        db.put(k(1), b"old").unwrap();
248        db.put(k(1), b"new").unwrap();
249        db.commit(1).unwrap().wait();
250        assert_eq!(db.get(&k(1)).unwrap(), b"new");
251    }
252
253    /// Committed data must survive a process restart (simulated by dropping
254    /// and reopening the `DonaDbX` instance against the same directory).
255    #[test]
256    fn crash_recovery() {
257        let d = tempdir().unwrap();
258        {
259            let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
260            db.put(k(1), b"ok").unwrap();
261            db.commit(1).unwrap().wait();
262        }
263        let db2 = DonaDbX::open(d.path(), test_cfg()).unwrap();
264        assert_eq!(db2.get(&k(1)).unwrap(), b"ok");
265    }
266
267    /// Writes inside an `ExecutionFrame` are visible to `get()` immediately via
268    /// the active-log scan, and remain visible after `frame.commit()` once the
269    /// fold has updated the index.
270    #[test]
271    fn frame_isolation() {
272        let d = tempdir().unwrap();
273        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
274        let frame = db.begin_frame();
275        frame.put(k(1), b"secret").unwrap();
276        // Active-log scan: the write is in the mmap and readable immediately.
277        assert_eq!(db.get(&k(1)).unwrap(), b"secret");
278        frame.commit(1).unwrap().wait();
279        // Still readable via the index after the fold.
280        assert_eq!(db.get(&k(1)).unwrap(), b"secret");
281    }
282
283    /// Aborting a frame must roll back its writes and leave previously
284    /// committed data intact.
285    #[test]
286    fn frame_abort() {
287        let d = tempdir().unwrap();
288        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
289        db.put(k(1), b"committed").unwrap();
290        db.commit(1).unwrap().wait();
291        let frame = db.begin_frame();
292        frame.put(k(1), b"aborted").unwrap();
293        frame.abort();
294        assert_eq!(db.get(&k(1)).unwrap(), b"committed");
295    }
296
297    /// A committed delete makes the key invisible to get().
298    #[test]
299    fn delete_committed() {
300        let d = tempdir().unwrap();
301        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
302        db.put(k(1), b"alive").unwrap();
303        db.commit(1).unwrap().wait();
304        assert_eq!(db.get(&k(1)).unwrap(), b"alive");
305
306        db.delete(k(1)).unwrap();
307        db.commit(2).unwrap().wait();
308        assert!(db.get(&k(1)).is_err());
309    }
310
311    /// A delete followed by a put in the next block resurrects the key.
312    #[test]
313    fn delete_then_resurrect() {
314        let d = tempdir().unwrap();
315        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
316        db.put(k(1), b"v1").unwrap();
317        db.commit(1).unwrap().wait();
318        db.delete(k(1)).unwrap();
319        db.commit(2).unwrap().wait();
320        db.put(k(1), b"v2").unwrap();
321        db.commit(3).unwrap().wait();
322        assert_eq!(db.get(&k(1)).unwrap(), b"v2");
323    }
324
325    /// state_root changes on put, returns to prior value after delete,
326    /// confirming the XOR accumulator correctly reverses the contribution.
327    #[test]
328    fn state_root_delete_reversal() {
329        let d = tempdir().unwrap();
330        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
331        let root_empty = db.state_root();
332
333        db.put(k(1), b"hello").unwrap();
334        db.commit(1).unwrap().wait();
335        let root_with = db.state_root();
336        assert_ne!(root_empty, root_with);
337
338        db.delete(k(1)).unwrap();
339        db.commit(2).unwrap().wait();
340        let root_after_delete = db.state_root();
341        // After deleting the only key, the accumulator must return to empty.
342        assert_eq!(root_empty, root_after_delete,
343            "state root must be identical to empty after deleting the only key");
344    }
345
346    /// scan_prefix returns only matching, non-deleted keys in sorted order.
347    #[test]
348    fn scan_prefix_basic() {
349        let d = tempdir().unwrap();
350        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
351        let mut k1 = [0u8; 32]; k1[0] = 0x01; k1[1] = 0x00;
352        let mut k2 = [0u8; 32]; k2[0] = 0x01; k2[1] = 0x01;
353        let mut k3 = [0u8; 32]; k3[0] = 0x02;
354        db.put(k1, b"a").unwrap();
355        db.put(k2, b"b").unwrap();
356        db.put(k3, b"c").unwrap();
357        db.commit(1).unwrap().wait();
358
359        let results = db.scan_prefix(&[0x01]).unwrap();
360        assert_eq!(results.len(), 2);
361        assert_eq!(results[0].0, k1);
362        assert_eq!(results[1].0, k2);
363
364        // Delete k1, scan should return only k2.
365        db.delete(k1).unwrap();
366        db.commit(2).unwrap().wait();
367        let results = db.scan_prefix(&[0x01]).unwrap();
368        assert_eq!(results.len(), 1);
369        assert_eq!(results[0].0, k2);
370    }
371
372    /// scan_from_reverse returns keys ≤ start in descending order.
373    #[test]
374    fn scan_from_reverse_basic() {
375        let d = tempdir().unwrap();
376        let db = DonaDbX::open(d.path(), test_cfg()).unwrap();
377        let mut ka = [0u8; 32]; ka[0] = 0x10;
378        let mut kb = [0u8; 32]; kb[0] = 0x20;
379        let mut kc = [0u8; 32]; kc[0] = 0x30;
380        db.put(ka, b"a").unwrap();
381        db.put(kb, b"b").unwrap();
382        db.put(kc, b"c").unwrap();
383        db.commit(1).unwrap().wait();
384
385        let results = db.scan_from_reverse(&kb).unwrap();
386        assert_eq!(results.len(), 2);
387        // Descending: kb first, then ka.
388        assert_eq!(results[0].0, kb);
389        assert_eq!(results[1].0, ka);
390    }
391}