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}