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(¤t_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(¤t) {
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(¤t_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}