Skip to main content

ethrex_storage/
layering.rs

1use ethrex_common::H256;
2use fastbloom::AtomicBloomFilter;
3use rayon::prelude::*;
4use rustc_hash::{FxBuildHasher, FxHashMap};
5use std::{
6    fmt,
7    sync::{Arc, RwLock},
8};
9
10use ethrex_trie::{Nibbles, TrieDB, TrieError};
11
12const BLOOM_SIZE: usize = 1_000_000;
13const FALSE_POSITIVE_RATE: f64 = 0.02;
14
15#[derive(Debug, Clone)]
16struct TrieLayer {
17    nodes: FxHashMap<Vec<u8>, Vec<u8>>,
18    parent: H256,
19    id: usize,
20}
21
22/// In-memory cache of trie diff-layers, one per block (or batch in full sync), forming a
23/// newest->oldest chain via `parent` down to the on-disk state.
24///
25/// Disk commits are gated on a canonical safe-commit root (`safe_commit_root`): a layer is
26/// flushed only when that root is canonical and deep enough that it lands on the ancestor
27/// walk. `H256::zero()` means "no safe commit point yet", so nothing is flushed and the
28/// on-disk genesis state is never pruned. A global bloom filter short-circuits lookups for
29/// keys absent from every layer.
30#[derive(Clone)]
31pub struct TrieLayerCache {
32    /// Monotonically increasing ID for layers, starting at 1.
33    /// TODO: this implementation panics on overflow
34    last_id: usize,
35    /// Number of layers after which we should commit to the database.
36    pub(crate) commit_threshold: usize,
37    layers: FxHashMap<H256, Arc<TrieLayer>>,
38    /// Global bloom filter that tracks all keys across all layers.
39    ///
40    /// Used to avoid looking up all layers when the given path doesn't exist in any
41    /// layer, thus going directly to the database.
42    bloom: AtomicBloomFilter<FxBuildHasher>,
43    /// The canonical safe-commit state root, computed by the Store after each forkchoice update.
44    ///
45    /// `H256::zero()` means "no safe commit point yet". Read by
46    /// [`get_commitable`](Self::get_commitable) to gate disk commits.
47    pub(crate) safe_commit_root: Arc<RwLock<H256>>,
48}
49
50impl fmt::Debug for TrieLayerCache {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        let safe_commit = match self.safe_commit_root.read() {
53            Ok(guard) => format!("{:?}", *guard),
54            Err(_) => "<poisoned>".to_string(),
55        };
56        f.debug_struct("TrieLayerCache")
57            .field("last_id", &self.last_id)
58            .field("commit_threshold", &self.commit_threshold)
59            .field("layers", &self.layers)
60            .field("bloom", &"AtomicBloomFilter")
61            .field("safe_commit_root", &safe_commit)
62            .finish()
63    }
64}
65
66impl Default for TrieLayerCache {
67    fn default() -> Self {
68        Self {
69            bloom: Self::create_filter(BLOOM_SIZE),
70            last_id: 0,
71            layers: Default::default(),
72            // TODO (issue #6345): this is coupled with DB_COMMIT_THRESHOLD in store.rs — unify them.
73            commit_threshold: 128,
74            safe_commit_root: Arc::new(RwLock::new(H256::zero())),
75        }
76    }
77}
78
79impl TrieLayerCache {
80    /// Creates a new cache with the given commit threshold and a shared safe-commit-root cell.
81    ///
82    /// The `safe_commit_root` Arc is shared with [`Store`](crate::Store) so that the Store
83    /// can update the cell without replacing the cache Arc.
84    /// `H256::zero()` in the cell means "no safe commit point yet".
85    pub fn new_with_safe_commit(
86        commit_threshold: usize,
87        safe_commit_root: Arc<RwLock<H256>>,
88    ) -> Self {
89        Self {
90            bloom: Self::create_filter(BLOOM_SIZE),
91            last_id: 0,
92            layers: Default::default(),
93            commit_threshold,
94            safe_commit_root,
95        }
96    }
97
98    fn create_filter(expected_items: usize) -> AtomicBloomFilter<FxBuildHasher> {
99        AtomicBloomFilter::with_false_pos(FALSE_POSITIVE_RATE)
100            .hasher(FxBuildHasher)
101            .expected_items(expected_items.max(BLOOM_SIZE))
102    }
103
104    /// Looks up a trie node `key` starting from the layer identified by `state_root`,
105    /// walking the parent chain toward older layers.
106    ///
107    /// Returns `Some(value)` from the first (newest) layer that contains the key, or `None`
108    /// if no layer has it. A bloom filter is checked first to skip the walk entirely when the
109    /// key is guaranteed absent from all layers (callers then fall through to the on-disk trie).
110    pub fn get(&self, state_root: H256, key: &[u8]) -> Option<Vec<u8>> {
111        // Fast check to know if any layer may contain the given key.
112        // We can only be certain it doesn't exist, but if it returns true it may or may not exist (false positive).
113        if !self.bloom.contains(key) {
114            // TrieWrapper goes to db when returning None.
115            return None;
116        }
117
118        let mut current_state_root = state_root;
119
120        while let Some(layer) = self.layers.get(&current_state_root) {
121            if let Some(value) = layer.nodes.get(key) {
122                return Some(value.clone());
123            }
124            current_state_root = layer.parent;
125            if current_state_root == state_root {
126                // TODO: check if this is possible in practice
127                // This can't happen in L1, due to system contracts irreversibly modifying state
128                // at each block.
129                // On L2, if no transactions are included in a block, the state root remains the same,
130                // but we handle that case in put_batch. It may happen, however, if someone modifies
131                // state with a privileged tx and later reverts it (since it doesn't update nonce).
132                panic!("State cycle found");
133            }
134        }
135        None
136    }
137
138    /// Determines whether a disk commit should happen by checking whether the safe-commit root
139    /// appears on the ancestor chain starting from `parent_state_root`.
140    ///
141    /// Returns `Some(safe_commit_root)` when the root is found on the ancestor walk; `None`
142    /// when the cell is zero, poisoned, or the root is not on the walk. The bounded-walk
143    /// cycle guard caps the walk at `layers.len()` steps to ensure termination.
144    pub fn get_commitable(&self, parent_state_root: H256) -> Option<H256> {
145        // (a) Read the safe-commit root; a poisoned lock is treated as "not ready".
146        let safe_root = *self.safe_commit_root.read().ok()?;
147        // (b) Zero means no safe commit point yet; commit nothing.
148        if safe_root.is_zero() {
149            return None;
150        }
151        // (c) The executed parent IS the safe-commit root; commit immediately.
152        if parent_state_root == safe_root {
153            return Some(safe_root);
154        }
155        // (d) Walk the layer parent-chain from parent_state_root looking for safe_root.
156        let mut current = parent_state_root;
157        let mut steps = 0usize;
158        let max_steps = self.layers.len();
159        while let Some(layer) = self.layers.get(&current) {
160            if current == safe_root {
161                return Some(safe_root);
162            }
163            let next = layer.parent;
164            // Cycle guard: if walking would return to the walk start, stop.
165            if next == parent_state_root {
166                return None;
167            }
168            steps += 1;
169            // Bounded-walk safeguard: a mid-chain cycle (e.g. B→C→B) would not be
170            // caught by the start-of-walk guard above. Capping at layers.len() steps
171            // ensures the loop always terminates.
172            if steps > max_steps {
173                return None;
174            }
175            current = next;
176        }
177        // (e) Reached chain bottom (root not in layers / already on disk) without matching safe_root.
178        None
179    }
180
181    /// Depth-only commit gate for batch execution (full sync / block import).
182    ///
183    /// Walks the parent chain from `state_root`, counting layers, and returns the state root of
184    /// the layer that is `threshold` layers deep — committing purely by depth, ignoring the
185    /// canonical [`safe_commit_root`](Self::safe_commit_root) cell.
186    ///
187    /// Used only in batch mode, where the node extends a single canonical chain (full sync and
188    /// `import` never execute competing forks), so the non-canonical-commit hazard that the
189    /// canonical gate guards against cannot occur. The canonical gate keys on the `head - 128`
190    /// safe-commit root, which never lands on a batch layer boundary (~1024 blocks apart), so it
191    /// would never flush during batch execution; this depth gate bounds memory instead.
192    pub(crate) fn get_commitable_by_depth(
193        &self,
194        mut state_root: H256,
195        threshold: usize,
196    ) -> Option<H256> {
197        let mut counter = 0;
198        while let Some(layer) = self.layers.get(&state_root) {
199            counter += 1;
200            if counter >= threshold {
201                return Some(state_root);
202            }
203            state_root = layer.parent;
204        }
205        None
206    }
207
208    /// Inserts a new diff-layer into the cache, keyed by `state_root` and pointing to `parent`.
209    ///
210    /// In regular sync each call adds one block's trie diffs. In full sync (batch mode), each
211    /// call adds diffs for an entire batch of ~1024 blocks.
212    ///
213    /// No-ops if `parent == state_root` (empty block with no state change), or if `state_root`
214    /// is already present (duplicate insertion guard).
215    pub fn put_batch(
216        &mut self,
217        parent: H256,
218        state_root: H256,
219        key_values: Vec<(Nibbles, Vec<u8>)>,
220    ) {
221        if parent == state_root && key_values.is_empty() {
222            return;
223        } else if parent == state_root {
224            // L1 always changes the state root (system contracts run even on empty blocks), so
225            // this should not happen there. L2 can legitimately keep the same root on empty blocks
226            // because it has no system contract calls.
227            tracing::trace!("parent == state_root but key_values not empty");
228            return;
229        }
230        if self.layers.contains_key(&state_root) {
231            tracing::warn!("tried to insert a state_root that's already inserted");
232            return;
233        }
234
235        // Add keys to the global bloom filter
236        for (p, _) in &key_values {
237            self.bloom.insert(p.as_ref());
238        }
239
240        let nodes: FxHashMap<Vec<u8>, Vec<u8>> = key_values
241            .into_iter()
242            .map(|(path, value)| (path.into_vec(), value))
243            .collect();
244
245        self.last_id += 1;
246        let entry = TrieLayer {
247            nodes,
248            parent,
249            id: self.last_id,
250        };
251        self.layers.insert(state_root, Arc::new(entry));
252    }
253
254    /// Rebuilds the global bloom filter from scratch using all keys across all remaining layers.
255    ///
256    /// Called after [`commit`](Self::commit) removes layers, since the old filter may contain
257    /// keys from the removed layers (producing unnecessary false positives).
258    pub fn rebuild_bloom(&mut self) {
259        // Pre-compute total keys for optimal filter sizing
260        let total_keys: usize = self.layers.values().map(|layer| layer.nodes.len()).sum();
261
262        let filter = Self::create_filter(total_keys.max(BLOOM_SIZE));
263
264        // Parallel insertion - AtomicBloomFilter allows concurrent insert via &self
265        self.layers.par_iter().for_each(|(_, layer)| {
266            for path in layer.nodes.keys() {
267                filter.insert(path);
268            }
269        });
270
271        self.bloom = filter;
272    }
273
274    /// Removes the layer at `state_root` (a key returned by [`get_commitable`](Self::get_commitable))
275    /// and all older ancestors, returning their merged diffs oldest-first for sequential disk write.
276    /// Returns `None` if `state_root` is not a layer.
277    ///
278    /// The `retain(id > top_layer_id)` step keeps every layer newer than `state_root` in memory, so
279    /// speculative not-yet-canonical state is never dropped; the bloom filter is then rebuilt.
280    pub fn commit(&mut self, state_root: H256) -> Option<Vec<(Vec<u8>, Vec<u8>)>> {
281        let mut layers_to_commit = vec![];
282        let mut current_state_root = state_root;
283        while let Some(layer) = self.layers.remove(&current_state_root) {
284            let layer = Arc::unwrap_or_clone(layer);
285            current_state_root = layer.parent;
286            layers_to_commit.push(layer);
287        }
288        let top_layer_id = layers_to_commit.first()?.id;
289        // older layers are useless
290        self.layers.retain(|_, item| item.id > top_layer_id);
291        self.rebuild_bloom(); // layers removed, rebuild global bloom filter.
292        let nodes_to_commit = layers_to_commit
293            .into_iter()
294            .rev()
295            .flat_map(|layer| layer.nodes)
296            .collect();
297        Some(nodes_to_commit)
298    }
299}
300
301/// [`TrieDB`] adapter that checks in-memory diff-layers ([`TrieLayerCache`]) first,
302/// falling back to the on-disk trie only for keys not found in any layer.
303///
304/// Used by the EVM during block execution: reads see the latest uncommitted state without
305/// waiting for a disk flush.
306pub struct TrieWrapper {
307    pub state_root: H256,
308    pub inner: Arc<TrieLayerCache>,
309    pub db: Box<dyn TrieDB>,
310    /// Pre-computed prefix nibbles for storage tries.
311    /// For state tries this is None; for storage tries this is
312    /// `Nibbles::from_bytes(address.as_bytes()).append_new(17)`.
313    prefix_nibbles: Option<Nibbles>,
314}
315
316impl TrieWrapper {
317    pub fn new(
318        state_root: H256,
319        inner: Arc<TrieLayerCache>,
320        db: Box<dyn TrieDB>,
321        prefix: Option<H256>,
322    ) -> Self {
323        let prefix_nibbles = prefix.map(|p| Nibbles::from_bytes(p.as_bytes()).append_new(17));
324        Self {
325            state_root,
326            inner,
327            db,
328            prefix_nibbles,
329        }
330    }
331}
332
333/// Prepends an account address prefix (with an invalid nibble `17` as separator) to a
334/// trie path, distinguishing storage trie entries from state trie entries in the flat
335/// key-value namespace. Returns the path unchanged if `prefix` is `None` (state trie).
336pub fn apply_prefix(prefix: Option<H256>, path: Nibbles) -> Nibbles {
337    match prefix {
338        Some(prefix) => Nibbles::from_bytes(prefix.as_bytes())
339            .append_new(17)
340            .concat(&path),
341        None => path,
342    }
343}
344
345impl TrieDB for TrieWrapper {
346    fn flatkeyvalue_computed(&self, key: Nibbles) -> bool {
347        // NOTE: we apply the prefix here, since the underlying TrieDB should
348        // always be for the state trie.
349        let key = match &self.prefix_nibbles {
350            Some(prefix) => prefix.concat(&key),
351            None => key,
352        };
353        self.db.flatkeyvalue_computed(key)
354    }
355
356    fn get(&self, key: Nibbles) -> Result<Option<Vec<u8>>, TrieError> {
357        let key = match &self.prefix_nibbles {
358            Some(prefix) => prefix.concat(&key),
359            None => key,
360        };
361        if let Some(value) = self.inner.get(self.state_root, key.as_ref()) {
362            return Ok(Some(value));
363        }
364        self.db.get(key)
365    }
366
367    fn put_batch(&self, _key_values: Vec<(Nibbles, Vec<u8>)>) -> Result<(), TrieError> {
368        // TODO: Get rid of this.
369        unimplemented!("This function should not be called");
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use std::collections::HashSet;
376    use std::sync::{Arc, RwLock};
377
378    use ethrex_common::H256;
379    use ethrex_trie::Nibbles;
380
381    use super::TrieLayerCache;
382
383    /// Build an `H256` from a single byte, all other bytes zero.
384    fn h256(b: u8) -> H256 {
385        let mut bytes = [0u8; 32];
386        bytes[31] = b;
387        H256(bytes)
388    }
389
390    /// A dummy trie key, distinct per layer so `put_batch`'s empty-block guard does not skip it.
391    fn key(b: u8) -> Nibbles {
392        Nibbles::from_bytes(&[b; 32])
393    }
394
395    /// Build a linear chain of N layers on top of an on-disk floor (`H256::zero()`),
396    /// returning the roots in order `[root_1, ..., root_n]` with `root_{i+1}.parent == root_i`.
397    fn build_chain(cache: &mut TrieLayerCache, n: u8) -> Vec<H256> {
398        let mut roots = Vec::with_capacity(n as usize);
399        let mut parent = H256::zero();
400        for i in 1..=n {
401            let root = h256(i);
402            cache.put_batch(parent, root, vec![(key(i), vec![i])]);
403            roots.push(root);
404            parent = root;
405        }
406        roots
407    }
408
409    fn cache_with_cell(threshold: usize, root: H256) -> (TrieLayerCache, Arc<RwLock<H256>>) {
410        let cell = Arc::new(RwLock::new(root));
411        let cache = TrieLayerCache::new_with_safe_commit(threshold, Arc::clone(&cell));
412        (cache, cell)
413    }
414
415    /// (a) Zero cell means "no safe commit point yet" -> get_commitable returns None.
416    #[test]
417    fn zero_cell_yields_none() {
418        let (mut cache, _cell) = cache_with_cell(4, H256::zero());
419        let roots = build_chain(&mut cache, 5);
420        assert_eq!(cache.get_commitable(*roots.last().unwrap()), None);
421    }
422
423    /// (b) Safe root on the parent walk -> get_commitable returns Some(safe_root).
424    #[test]
425    fn safe_root_on_walk_yields_some() {
426        let safe = h256(2);
427        let (mut cache, _cell) = cache_with_cell(4, safe);
428        let roots = build_chain(&mut cache, 4);
429        // Walk from L4: L4 -> L3 -> L2 (== safe) -> Some(L2).
430        assert_eq!(cache.get_commitable(roots[3]), Some(safe));
431    }
432
433    /// (c) parent_state_root == safe root -> immediate Some, no walk needed.
434    #[test]
435    fn parent_equals_safe_root_yields_some() {
436        let roots = {
437            let (mut cache, _cell) = cache_with_cell(4, H256::zero());
438            build_chain(&mut cache, 3)
439        };
440        let l3 = roots[2];
441        let (mut cache, _cell) = cache_with_cell(4, l3);
442        build_chain(&mut cache, 3);
443        assert_eq!(cache.get_commitable(l3), Some(l3));
444    }
445
446    /// (d) Safe root not an ancestor (never inserted as a layer) -> None, regardless of depth.
447    #[test]
448    fn safe_root_not_ancestor_yields_none() {
449        let (mut cache, _cell) = cache_with_cell(4, h256(99));
450        // 6 layers (> threshold) so an old depth-only path would have fired.
451        let roots = build_chain(&mut cache, 6);
452        assert_eq!(cache.get_commitable(*roots.last().unwrap()), None);
453    }
454
455    /// (e) Cycle guard: a B -> C -> B link must terminate and return None.
456    #[test]
457    fn cycle_guard_terminates() {
458        let b = h256(20);
459        let c = h256(21);
460        let (mut cache, _cell) = cache_with_cell(4, h256(99));
461        // Insert C (parent B) then B (parent C); neither key pre-exists, so both insert,
462        // forming the cycle B <-> C. The safe root (h256(99)) is absent on purpose.
463        cache.put_batch(b, c, vec![(key(21), vec![21])]);
464        cache.put_batch(c, b, vec![(key(20), vec![20])]);
465        // Walking from C must terminate (start-of-walk + bounded-walk guards) and yield None.
466        assert_eq!(cache.get_commitable(c), None);
467    }
468
469    /// (f) commit(safe_root) removes the safe layer and all older ones, retaining layers above it.
470    #[test]
471    fn commit_retains_layers_above_safe_root() {
472        let safe = h256(2);
473        let (mut cache, _cell) = cache_with_cell(4, safe);
474        let roots = build_chain(&mut cache, 4);
475        let (l3, l4) = (roots[2], roots[3]);
476        assert_eq!(cache.get_commitable(roots[3]), Some(safe));
477
478        cache.commit(safe);
479        let remaining: HashSet<H256> = cache.layers.keys().copied().collect();
480        let expected: HashSet<H256> = [l3, l4].into_iter().collect();
481        assert_eq!(
482            remaining, expected,
483            "commit(safe) must retain only the layers above it"
484        );
485    }
486
487    /// (g) Memory bound: after building > threshold layers and committing at a safe root that
488    /// keeps the chain bounded, the retained layer count stays <= commit_threshold + 1.
489    #[test]
490    fn memory_bound_after_commit() {
491        let threshold = 4usize;
492        // Build threshold + 3 = 7 layers; set the safe root `threshold` below the tip.
493        let n = (threshold + 3) as u8;
494        let safe = h256(n - threshold as u8); // h256(3): leaves layers 4..=7 above it
495        let (mut cache, _cell) = cache_with_cell(threshold, safe);
496        let roots = build_chain(&mut cache, n);
497        let tip = *roots.last().unwrap();
498
499        let commitable = cache.get_commitable(tip).expect("safe root on the walk");
500        cache.commit(commitable);
501        assert!(
502            cache.layers.len() <= threshold + 1,
503            "retained layers ({}) must stay within commit_threshold + 1 ({})",
504            cache.layers.len(),
505            threshold + 1
506        );
507    }
508
509    /// Why live block-by-block execution must NOT use the depth gate: with nothing canonicalized
510    /// (safe_commit cell ZERO), the depth gate would flush a non-canonical layer and prune genesis
511    /// -> the "post-state for block 0 absent" wedge. The canonical gate commits nothing instead.
512    ///
513    /// Wedge simulation: non-canonical newPayload layers pile up but nothing is canonicalized,
514    /// so the safe_commit cell stays ZERO and never advances.
515    #[test]
516    fn live_canonical_gate_holds_while_depth_gate_would_commit() {
517        // threshold = 4, safe_commit cell = ZERO (nothing canonicalized).
518        let (mut cache, _cell) = cache_with_cell(4, H256::zero());
519        // Linear chain L1 <- L2 <- L3 <- L4 <- L5 (distinct keys so guards pass).
520        let roots = build_chain(&mut cache, 5);
521        let l5 = *roots.last().unwrap();
522
523        // Depth gate: commits a layer at depth 4 -> would prune genesis on the path-keyed disk
524        // root if used in live mode. This is why live mode uses the canonical gate below.
525        assert!(
526            cache.get_commitable_by_depth(l5, 4).is_some(),
527            "depth-only gate commits at depth 4 regardless of canonicality"
528        );
529
530        // Canonical gate (live mode): safe_commit cell is zero, so nothing is committed and
531        // genesis is preserved.
532        assert_eq!(
533            cache.get_commitable(l5),
534            None,
535            "canonical gate must commit nothing while safe_commit is zero (the wedge fix)"
536        );
537    }
538
539    /// Batch execution (full sync / import) must still flush even when no FCU has advanced the
540    /// safe-commit cell. The canonical gate stays parked at zero (import does not FCU until the
541    /// end; full sync's `head - 128` root never lands on a ~1024-block batch boundary), so batch
542    /// mode commits by depth instead -> memory stays bounded and state is durable across restart.
543    #[test]
544    fn batch_depth_gate_flushes_without_safe_commit() {
545        // safe_commit cell = ZERO, as during bulk import before the terminal FCU.
546        let (mut cache, _cell) = cache_with_cell(4, H256::zero());
547        // Five batch layers stacked (each stands in for ~1024 blocks in real batch mode).
548        let roots = build_chain(&mut cache, 5);
549        let tip = *roots.last().unwrap();
550
551        // Canonical gate would never flush here -> unbounded memory (the regression iovoid flagged).
552        assert_eq!(
553            cache.get_commitable(tip),
554            None,
555            "canonical gate never flushes batch layers while safe_commit is zero"
556        );
557
558        // Depth gate (batch mode) flushes the layer BATCH_COMMIT_THRESHOLD deep: root_2 sits 4
559        // layers below the tip (tip=root_5).
560        assert_eq!(
561            cache.get_commitable_by_depth(tip, 4),
562            Some(roots[1]),
563            "batch depth gate must flush the layer 4 deep, bounding memory"
564        );
565    }
566}