donadb_x/dual_buffer.rs
1//! N-shard write engine with per-shard monotonically-growing logs and a
2//! dedicated background fold thread.
3//!
4//! # Design
5//!
6//! Each shard owns **one** [`CommutativeLog`] backed by a persistent file that
7//! grows monotonically until segment rotation. The file is never reset or
8//! reused within a segment.
9//!
10//! - `commit()` dispatches a fold over the dirty range
11//! `[committed_offset, write_offset)` **without** swapping to a new buffer.
12//! - After the fold, `committed_offset` advances. The same file continues
13//! receiving writes for the next block.
14//! - Index offsets remain valid for the entire lifetime of the segment file.
15//! - On rotation (triggered by `engine.rs` when the fill threshold is crossed),
16//! `replace_shard_log` installs a fresh file. Only then do index entries
17//! become stale (the generation counter bumps).
18//!
19//! ## Write path (per-block)
20//!
21//! 1. Call [`DualBufferEngine::writer`] with a shard ID and block height to
22//! obtain a [`BlockWriter`].
23//! 2. Call [`BlockWriter::put`] as many times as needed — each call is a single
24//! `fetch_add` on the shard's `write_offset` atomic.
25//! 3. At the block boundary, call [`DualBufferEngine::swap`]. This snapshots
26//! `write_offset` for each shard and sends a fold request to the background
27//! thread. **No buffer pointer is swapped.**
28//! 4. The background fold thread calls `commit_fold_until` on each shard's log,
29//! advancing `committed_offset` and updating the shared Merkle accumulator.
30//! 5. The returned [`FoldAck`] delivers the new accumulator once the fold
31//! completes.
32
33use crate::commutative::CommutativeLog;
34use crate::index::ShardIndex;
35use crate::value_cache::ValueCache;
36use arc_swap::ArcSwap;
37use bytes::Bytes;
38use std::path::{Path, PathBuf};
39use std::sync::{Arc, mpsc};
40use std::sync::atomic::{AtomicBool, Ordering};
41
42// ── WriteShard ────────────────────────────────────────────────────────────────
43
44/// One independent write shard — a thin wrapper around an
45/// `ArcSwap<CommutativeLog>` pointing to the shard's stable, monotonically
46/// growing log file.
47///
48/// The log file is never reset within a segment. It is only replaced
49/// when `engine.rs` calls [`DualBufferEngine::replace_shard_log`] during a
50/// segment rotation.
51pub struct WriteShard {
52 /// The current log for this shard. Wrapped in `ArcSwap` so that
53 /// `replace_shard_log` can atomically install a fresh file on segment
54 /// rotation while any in-flight `BlockWriter` handles keep their `Arc`
55 /// reference alive.
56 pub log: Arc<ArcSwap<CommutativeLog>>,
57
58 /// Zero-based index of this shard within the engine's shard array.
59 #[allow(dead_code)]
60 shard_id: usize,
61}
62
63impl WriteShard {
64 /// Create a new shard backed by `initial` as the active log.
65 fn new(shard_id: usize, initial: Arc<CommutativeLog>) -> Self {
66 Self {
67 log: Arc::new(ArcSwap::from(initial)),
68 shard_id,
69 }
70 }
71
72 /// Snapshot the current write frontier for this shard.
73 ///
74 /// Returns `(Arc<CommutativeLog>, end_offset)`. The same log keeps
75 /// receiving writes; the fold thread will process the range
76 /// `[committed_offset, end_offset)` and advance `committed_offset`.
77 /// No buffer is swapped.
78 fn snap(&self) -> (Arc<CommutativeLog>, u64) {
79 let log = self.log.load_full();
80 let end_off = log.write_offset();
81 (log, end_off)
82 }
83}
84
85// ── BlockWriter ───────────────────────────────────────────────────────────────
86
87/// A write handle scoped to one thread and one block.
88///
89/// Obtained via [`crate::DonaDbX::writer`].
90/// Holds a direct `Arc` to the shard's current log — no `ArcSwap` guard is
91/// re-acquired on each write, so the hot `put()` path touches zero shared
92/// state beyond the shard's own `write_offset` atomic.
93///
94/// Create one `BlockWriter` per thread at the start of each block and drop it
95/// before calling [`crate::DonaDbX::commit`].
96pub struct BlockWriter {
97 /// Direct reference to the active shard log for this block.
98 pub log: Arc<CommutativeLog>,
99 /// Block height recorded in every record written through this handle.
100 pub height: u64,
101}
102
103impl BlockWriter {
104 /// Append a key-value record to the shard log.
105 ///
106 /// Delegates to `CommutativeLog::put_versioned` with `prev_off = 0`; the
107 /// MVCC chain pointer is patched in during `commit_fold_until` once the
108 /// index is consulted.
109 #[inline(always)]
110 pub fn put(&self, key: [u8; 32], value: &[u8]) -> crate::DbResult<u64> {
111 self.log.put_versioned(key, value, 0, self.height)
112 }
113}
114
115// ── FoldReq (internal) ────────────────────────────────────────────────────────
116
117struct FoldReq {
118 /// All shard logs for this block, paired with their end offsets.
119 shards: Vec<(Arc<CommutativeLog>, u64)>,
120 log_id: u64,
121 acc: [u8; 32],
122 ack: mpsc::Sender<[u8; 32]>,
123 /// Reference to the shared value cache so the fold thread can populate it.
124 cache: ValueCache,
125}
126
127struct FoldThread {
128 _h: std::thread::JoinHandle<()>,
129 stop: Arc<AtomicBool>,
130}
131
132impl Drop for FoldThread {
133 fn drop(&mut self) {
134 self.stop.store(true, Ordering::Release);
135 }
136}
137
138// ── FoldAck ───────────────────────────────────────────────────────────────────
139
140/// A handle representing a pending fold operation.
141///
142/// Returned by [`DualBufferEngine::swap`]. The fold runs on a background
143/// thread; this type provides two ways to observe its completion:
144///
145/// - [`wait`](FoldAck::wait) — block the current thread until the fold
146/// finishes and return the new accumulator.
147/// - [`into_rx`](FoldAck::into_rx) — take ownership of the underlying channel
148/// receiver for integration with `select!` or async patterns.
149#[must_use = "call .wait() to block until fold completes, or .into_rx() to integrate with async code"]
150pub struct FoldAck(mpsc::Receiver<[u8; 32]>);
151
152impl FoldAck {
153 /// Block until the fold thread delivers the new accumulator.
154 ///
155 /// Returns `[0u8; 32]` only if the fold thread has unexpectedly terminated.
156 #[allow(dead_code)]
157 pub fn wait(self) -> [u8; 32] {
158 self.0.recv().unwrap_or([0u8; 32])
159 }
160
161 /// Unwrap the underlying `mpsc::Receiver` for non-blocking use.
162 pub fn into_rx(self) -> mpsc::Receiver<[u8; 32]> {
163 self.0
164 }
165}
166
167// ── DualBufferEngine ──────────────────────────────────────────────────────────
168
169/// N-shard lock-free write engine with monotonically-growing per-shard logs.
170///
171/// See the [module-level documentation](self) for a full architecture overview.
172pub struct DualBufferEngine {
173 /// All write shards — one per logical writer-thread slot.
174 pub shards: Vec<WriteShard>,
175
176 /// Convenience alias pointing to `shards[0].log`.
177 ///
178 /// Retained for the single-threaded `put()` path and for `engine.rs` to
179 /// install a fresh log after segment rotation.
180 pub active: Arc<ArcSwap<CommutativeLog>>,
181
182 /// Points to the same log as `active`. In the current design there is no
183 /// separate "committed" buffer — reads and writes share the same file.
184 pub committed: Arc<ArcSwap<CommutativeLog>>,
185
186 /// Channel to the background fold thread. Bounded channel of depth 2
187 /// provides back-pressure if commits outrun folds.
188 fold_tx: mpsc::SyncSender<FoldReq>,
189
190 /// The shard index — maps 32-byte keys to their latest mmap offsets.
191 /// Updated exclusively by the fold thread.
192 pub index: Arc<ShardIndex>,
193
194 /// The latest XOR accumulator (un-hashed). The canonical state root is
195 /// `blake3(merkle)`. Protected by a `Mutex` because only the fold thread
196 /// writes it and the main thread reads it at block boundaries.
197 pub merkle: Arc<parking_lot::Mutex<[u8; 32]>>,
198
199 /// Directory containing all shard log files.
200 #[allow(dead_code)]
201 dir: PathBuf,
202
203 /// Byte capacity of each individual shard log file.
204 #[allow(dead_code)]
205 buf_size: u64,
206
207 /// Handle to the background fold thread. Signals it to stop on drop.
208 _fold: FoldThread,
209
210 /// Bounded in-process value cache populated by the fold thread.
211 ///
212 /// Holds the most recently committed value for each key as a cheaply
213 /// cloneable `Bytes`. `get()` checks this before touching the mmap,
214 /// eliminating page faults on hot-key reads.
215 pub vcache: ValueCache,
216}
217
218impl DualBufferEngine {
219 /// Open (or create) the engine in `dir` with the given parameters.
220 ///
221 /// `active_log` — the pre-opened durable log that shard 0 will use.
222 ///
223 /// # Shard count
224 ///
225 /// `n_shards = max(num_cpus, 2)`. Shard 0 uses `active_log` directly.
226 /// Shards 1..N each get their own file (`shard_{i}_active.log`).
227 pub fn open(
228 dir: &Path,
229 buf_size: u64,
230 active_log: Arc<CommutativeLog>,
231 ) -> crate::DbResult<Self> {
232 std::fs::create_dir_all(dir).map_err(crate::DbError::Io)?;
233 let index = Arc::new(ShardIndex::new());
234 let merkle = Arc::new(parking_lot::Mutex::new([0u8; 32]));
235 let stop = Arc::new(AtomicBool::new(false));
236
237 // Cache up to 200K entries — covers typical validator state sizes.
238 // Entries are Bytes (Arc-backed), so each clone is O(1).
239 let vcache = ValueCache::new(200_000);
240
241 let n_shards = num_cpus::get().max(2);
242
243 let mut shards = Vec::with_capacity(n_shards);
244 for i in 0..n_shards {
245 let init = if i == 0 {
246 Arc::clone(&active_log)
247 } else {
248 let path = dir.join(format!("shard_{i}_active.log"));
249 Arc::new(CommutativeLog::open(&path, buf_size)?)
250 };
251 shards.push(WriteShard::new(i, init));
252 }
253
254 // Both `active` and `committed` point to the same log (shard 0).
255 let active = Arc::clone(&shards[0].log);
256 let committed = Arc::new(ArcSwap::from(Arc::clone(&active_log)));
257
258 let (fold_tx, fold_rx) = mpsc::sync_channel::<FoldReq>(2);
259 let idx2 = Arc::clone(&index);
260 let mrk2 = Arc::clone(&merkle);
261 let stp2 = Arc::clone(&stop);
262
263 let fold_h = std::thread::Builder::new()
264 .name("dbx-fold".into())
265 .spawn(move || {
266 if let Some(cores) = core_affinity::get_core_ids() {
267 let c = if cores.len() > 1 { cores[1] } else { cores[0] };
268 core_affinity::set_for_current(c);
269 }
270 let fold_threads = (num_cpus::get() / 2).max(2);
271 let pool = rayon::ThreadPoolBuilder::new()
272 .num_threads(fold_threads)
273 .thread_name(|i| format!("dbx-fold-worker-{i}"))
274 .build()
275 .unwrap_or_else(|_| {
276 rayon::ThreadPoolBuilder::new()
277 .num_threads(2)
278 .build()
279 .unwrap()
280 });
281
282 loop {
283 if stp2.load(Ordering::Acquire) { break; }
284 match fold_rx.recv_timeout(std::time::Duration::from_millis(5)) {
285 Ok(req) => {
286 // OPTIMIZATION: Parallel shard folding with work stealing.
287 // Instead of folding shards sequentially, we process all
288 // shards in parallel. Each shard produces its own partial
289 // accumulator, then we XOR-combine them at the end.
290
291 use rayon::prelude::*;
292 use crate::commutative::{CMAG, CTOMB, CHDR};
293
294 let partial_accs: Vec<[u8; 32]> = pool.install(|| {
295 req.shards.par_iter().map(|(log, end_off)| {
296 let start = log.committed_offset() as usize;
297
298 // Fold this shard's records
299 let shard_acc = match log.commit_fold_until(
300 &idx2, req.acc, *end_off, req.log_id,
301 ) {
302 Ok((a, _)) => a,
303 Err(_) => req.acc,
304 };
305
306 // Populate cache while pages are hot
307 let end = *end_off as usize;
308 let ptr = log.mmap_ptr();
309 let cap = log.capacity as usize;
310 let mut cur = start;
311
312 while cur + CHDR + 32 <= end.min(cap) {
313 let magic = u32::from_le_bytes(unsafe {
314 std::slice::from_raw_parts(ptr.add(cur), 4)
315 }.try_into().unwrap_or([0u8;4]));
316
317 if magic == CTOMB {
318 let key: [u8; 32] = unsafe {
319 std::slice::from_raw_parts(ptr.add(cur + CHDR), 32)
320 }.try_into().unwrap_or([0u8;32]);
321 req.cache.remove(&key);
322 cur += CHDR + 32;
323 continue;
324 }
325
326 if magic != CMAG { break; }
327
328 let vlen = u32::from_le_bytes(unsafe {
329 std::slice::from_raw_parts(ptr.add(cur + 4), 4)
330 }.try_into().unwrap_or([0u8;4])) as usize;
331 let total = CHDR + 32 + vlen;
332
333 if cur + total > end.min(cap) { break; }
334
335 let key: [u8; 32] = unsafe {
336 std::slice::from_raw_parts(ptr.add(cur + CHDR), 32)
337 }.try_into().unwrap_or([0u8;32]);
338
339 // Only cache values ≤ 8 KiB
340 if vlen <= 8192 {
341 let val = Bytes::copy_from_slice(unsafe {
342 std::slice::from_raw_parts(
343 ptr.add(cur + CHDR + 32), vlen)
344 });
345 req.cache.insert(key, val);
346 }
347 cur += total;
348 }
349
350 shard_acc
351 }).collect()
352 });
353
354 // Combine partial accumulators - each shard returns
355 // its updated accumulator after folding
356 let final_acc = if !partial_accs.is_empty() {
357 partial_accs[0]
358 } else {
359 req.acc
360 };
361
362 *mrk2.lock() = final_acc;
363 let _ = req.ack.send(final_acc);
364 }
365 Err(mpsc::RecvTimeoutError::Disconnected) => break,
366 Err(_) => {}
367 }
368 }
369 })
370 .unwrap();
371
372 Ok(Self {
373 shards,
374 active,
375 committed,
376 fold_tx,
377 index,
378 merkle,
379 dir: dir.to_owned(),
380 buf_size,
381 _fold: FoldThread { _h: fold_h, stop },
382 vcache,
383 })
384 }
385
386 /// Acquire a write handle for the given shard and block height.
387 ///
388 /// `shard_id` is wrapped with `% shards.len()`, so callers may use any
389 /// non-negative integer without risk of an out-of-bounds panic.
390 pub fn writer(&self, shard_id: usize, height: u64) -> BlockWriter {
391 let shard = &self.shards[shard_id % self.shards.len()];
392 BlockWriter {
393 log: shard.log.load_full(),
394 height,
395 }
396 }
397
398 /// Snapshot all shard write frontiers and dispatch an async fold.
399 ///
400 /// **No buffer pointer is swapped.** The log files continue receiving
401 /// writes for the next block. The fold thread processes
402 /// `[committed_offset, end_offset)` on each shard log and advances
403 /// `committed_offset` after completion.
404 ///
405 /// Returns a [`FoldAck`] that can be `.wait()`ed to block until the fold
406 /// completes. Returns `DbError::Io` if the fold thread has stopped.
407 pub fn swap(&self, log_id: u64) -> crate::DbResult<FoldAck> {
408 let acc = *self.merkle.lock();
409 let (tx, rx) = mpsc::channel();
410
411 // Snapshot the write frontier for every shard. We wait for inflight
412 // writes to settle so the fold sees a consistent boundary.
413 let mut shard_data = Vec::with_capacity(self.shards.len());
414 for shard in &self.shards {
415 let (log, end_off) = shard.snap();
416 // Spin until all in-flight writes that reserved slots ≤ end_off
417 // have finished writing their data.
418 while log.inflight.load(Ordering::Acquire) > 0 {
419 std::hint::spin_loop();
420 }
421 shard_data.push((log, end_off));
422 }
423
424 self.fold_tx
425 .send(FoldReq {
426 shards: shard_data,
427 log_id,
428 acc,
429 ack: tx,
430 cache: self.vcache.clone(),
431 })
432 .map_err(|_| {
433 crate::DbError::Io(std::io::Error::new(
434 std::io::ErrorKind::BrokenPipe,
435 "fold thread stopped",
436 ))
437 })?;
438
439 Ok(FoldAck(rx))
440 }
441
442 /// Replace the log for shard `shard_id` with `new_log`.
443 ///
444 /// Called by `engine.rs` during segment rotation to install a fresh file.
445 /// After replacement, `active` and `committed` (which point to the same
446 /// log as shard 0) are also updated if `shard_id == 0`.
447 pub fn replace_shard_log(&self, shard_id: usize, new_log: Arc<CommutativeLog>) {
448 let idx = shard_id % self.shards.len();
449 self.shards[idx].log.store(Arc::clone(&new_log));
450 if idx == 0 {
451 self.active.store(Arc::clone(&new_log));
452 self.committed.store(Arc::clone(&new_log));
453 }
454 }
455
456 /// Return the number of write shards.
457 pub fn num_shards(&self) -> usize {
458 self.shards.len()
459 }
460
461 /// Compute and return the current state root as `blake3(accumulator)`.
462 pub fn state_root(&self) -> [u8; 32] {
463 *blake3::hash(&*self.merkle.lock()).as_bytes()
464 }
465}