Skip to main content

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}