Skip to main content

ethrex_levm/db/
mod.rs

1use crate::{errors::DatabaseError, precompiles::PrecompileCache};
2use ethrex_common::{
3    Address, H256, U256,
4    types::{AccountState, ChainConfig, Code, CodeMetadata},
5};
6use rustc_hash::{FxHashMap, FxHashSet};
7use std::sync::{
8    Arc, OnceLock, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard,
9    atomic::{AtomicU64, Ordering},
10};
11
12pub mod gen_db;
13
14/// Distinct-cold-key count above which a prefetch routes to the sorted, sharded
15/// batch read instead of per-key parallel point-gets. Shared by the account and
16/// storage prefetch gates here and by the merkle trie-node prefetch gate in
17/// `ethrex-blockchain`, so tuning it moves all three together. ~16384 cold
18/// accesses is ~34M gas of cold reads: above ordinary cold blocks, below the
19/// large-state blocks these paths target. Tunable.
20pub const BLOATED_BATCH_THRESHOLD: usize = 16_384;
21
22// Type aliases for cache storage maps
23type AccountCache = FxHashMap<Address, AccountState>;
24type StorageCache = FxHashMap<(Address, H256), U256>;
25type CodeCache = FxHashMap<H256, Code>;
26/// Touched-key snapshot returned by [`CachingDatabase::touched_keys_where`].
27pub struct TouchedKeys {
28    /// Touched accounts with their storage roots.
29    pub accounts: Vec<(Address, H256)>,
30    /// Touched storage slots as `(account address, slot key)`.
31    pub slots: Vec<(Address, H256)>,
32}
33
34pub trait Database: Send + Sync {
35    fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError>;
36    fn get_storage_value(&self, address: Address, key: H256) -> Result<U256, DatabaseError>;
37    fn get_block_hash(&self, block_number: u64) -> Result<H256, DatabaseError>;
38    fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError>;
39    fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError>;
40    fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError>;
41    /// Access the precompile cache, if available at this database layer.
42    fn precompile_cache(&self) -> Option<&PrecompileCache> {
43        None
44    }
45    /// Batch lookup. Default: loop. Backends with a batched read path (e.g. rocksdb
46    /// `multi_get_cf` on the flat key-value table) should override this and the
47    /// caching layer above will dispatch to it.
48    fn get_account_states_batch(
49        &self,
50        addresses: &[Address],
51    ) -> Result<Vec<AccountState>, DatabaseError> {
52        addresses
53            .iter()
54            .map(|a| self.get_account_state(*a))
55            .collect()
56    }
57    /// Batch bytecode lookup, aligned to `code_hashes`. `None` means the hash is absent
58    /// from the database. Default: loop. Backends with a batched read path (e.g. rocksdb
59    /// `multi_get_cf` on the account-codes table) should override this; the caching layer
60    /// above dispatches to it from [`Self::prefetch_codes`].
61    fn get_account_codes_batch(
62        &self,
63        code_hashes: &[H256],
64    ) -> Result<Vec<Option<Code>>, DatabaseError> {
65        code_hashes
66            .iter()
67            .map(|h| self.get_account_code(*h).map(Some))
68            .collect()
69    }
70    /// Batch storage-slot lookup. Default: loop. Backends with a batched read
71    /// path (e.g. rocksdb `multi_get_cf` on the storage flat key-value table)
72    /// should override this and the caching layer above will dispatch to it.
73    fn get_storage_values_batch(
74        &self,
75        keys: &[(Address, H256)],
76    ) -> Result<Vec<U256>, DatabaseError> {
77        keys.iter()
78            .map(|&(addr, key)| self.get_storage_value(addr, key))
79            .collect()
80    }
81
82    /// Byte budget for warming bytecode ahead of execution, as measured by [`Code::size`].
83    ///
84    /// Bounds the bytecode a block warm reads speculatively, since a block access list
85    /// does not say which of its addresses were accessed for their code. Backends holding
86    /// a bytecode cache report its capacity, which is how much the node was configured to
87    /// keep resident; the default is the floor for a backend without one.
88    fn code_cache_budget_bytes(&self) -> u64 {
89        64 * 1024 * 1024
90    }
91
92    /// Prefetch a batch of bytecodes into the cache. Default: sequential fallback.
93    ///
94    /// Returns the total [`Code::size`] warmed, so a caller warming a whole block can
95    /// hold itself to [`Self::code_cache_budget_bytes`]. An absent hash is not an error:
96    /// warming has nothing to do for it and the executor reports it if reached.
97    fn prefetch_codes(&self, code_hashes: &[H256]) -> Result<u64, DatabaseError> {
98        let mut warmed = 0u64;
99        for &hash in code_hashes {
100            if let Ok(code) = self.get_account_code(hash) {
101                warmed = warmed.saturating_add(u64::try_from(code.size()).unwrap_or(u64::MAX));
102            }
103        }
104        Ok(warmed)
105    }
106    /// Prefetch a batch of accounts into the cache. Default: sequential fallback.
107    fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> {
108        for &addr in addresses {
109            self.get_account_state(addr)?;
110        }
111        Ok(())
112    }
113    /// Prefetch a batch of storage slots into the cache. Default: sequential fallback.
114    fn prefetch_storage(&self, keys: &[(Address, H256)]) -> Result<(), DatabaseError> {
115        for &(addr, key) in keys {
116            self.get_storage_value(addr, key)?;
117        }
118        Ok(())
119    }
120}
121
122/// A database wrapper that caches state lookups for parallel pre-warming.
123///
124/// This enables parallel warming workers to share cached data, and allows
125/// the sequential execution phase to reuse warmed state. Reduces redundant
126/// database/trie lookups when multiple transactions touch the same accounts.
127///
128/// Thread-safe via RwLock - optimized for read-heavy concurrent access.
129///
130/// This caching database is inspired by reth's overlay/proof worker cache.
131///
132/// Besides the per-block warmer/executor sharing above, the mempool
133/// prewarmer builds one instance per slot and publishes it across the block
134/// boundary: `execute_block_pipeline` seeds the *next* block's execution
135/// with it when the parent state and fork match (see
136/// `ethrex-blockchain::prewarm`).
137///
138/// # Invariant
139///
140/// Because one instance is shared across the block boundary (and the
141/// prewarmer may still be filling it while the next block executes), every
142/// cached entry must be a pure function of the parent state root. A cache
143/// layer whose entries also depend on the executing block (fork, number,
144/// timestamp, ...) needs a matching handoff guard in
145/// `execute_block_pipeline` — see `precompile_cache`, whose fork-dependent
146/// entries are covered by the fork-equality check there.
147pub struct CachingDatabase {
148    inner: Arc<dyn Database>,
149    /// Cached account states (balance, nonce, code_hash, storage_root)
150    accounts: RwLock<AccountCache>,
151    /// Cached storage values
152    storage: RwLock<StorageCache>,
153    /// Cached contract code
154    code: RwLock<CodeCache>,
155    /// Sum of [`Code::size`] over `code`. Nothing is evicted from the map, so a long-lived
156    /// instance (the mempool prewarmer keeps one for a whole slot) uses this to bound
157    /// how much bytecode it keeps.
158    code_bytes: AtomicU64,
159    /// Shared precompile result cache (warmer populates, executor reuses).
160    /// `None` when the cache is disabled via `BlockchainOptions::precompile_cache_enabled = false`.
161    precompile_cache: Option<PrecompileCache>,
162    /// Cached chain config (constant for the lifetime of this database)
163    chain_config: OnceLock<ChainConfig>,
164}
165
166impl CachingDatabase {
167    pub fn new(inner: Arc<dyn Database>, precompile_cache_enabled: bool) -> Self {
168        Self {
169            inner,
170            accounts: RwLock::new(FxHashMap::default()),
171            storage: RwLock::new(FxHashMap::default()),
172            code: RwLock::new(FxHashMap::default()),
173            code_bytes: AtomicU64::new(0),
174            precompile_cache: precompile_cache_enabled.then(PrecompileCache::new),
175            chain_config: OnceLock::new(),
176        }
177    }
178
179    fn read_accounts(&self) -> Result<RwLockReadGuard<'_, AccountCache>, DatabaseError> {
180        self.accounts.read().map_err(poison_error_to_db_error)
181    }
182
183    fn write_accounts(&self) -> Result<RwLockWriteGuard<'_, AccountCache>, DatabaseError> {
184        self.accounts.write().map_err(poison_error_to_db_error)
185    }
186
187    fn read_storage(&self) -> Result<RwLockReadGuard<'_, StorageCache>, DatabaseError> {
188        self.storage.read().map_err(poison_error_to_db_error)
189    }
190
191    fn write_storage(&self) -> Result<RwLockWriteGuard<'_, StorageCache>, DatabaseError> {
192        self.storage.write().map_err(poison_error_to_db_error)
193    }
194
195    /// Total [`Code::size`] of the bytecode this cache holds.
196    pub fn code_bytes(&self) -> u64 {
197        self.code_bytes.load(Ordering::Relaxed)
198    }
199
200    /// Inserts `code` if absent and counts its size once.
201    fn keep_code(&self, cache: &mut CodeCache, code_hash: H256, code: Code) {
202        if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(code_hash) {
203            let size = u64::try_from(code.size()).unwrap_or(u64::MAX);
204            entry.insert(code);
205            self.code_bytes.fetch_add(size, Ordering::Relaxed);
206        }
207    }
208
209    fn read_code(&self) -> Result<RwLockReadGuard<'_, CodeCache>, DatabaseError> {
210        self.code.read().map_err(poison_error_to_db_error)
211    }
212
213    fn write_code(&self) -> Result<RwLockWriteGuard<'_, CodeCache>, DatabaseError> {
214        self.code.write().map_err(poison_error_to_db_error)
215    }
216
217    /// Per-slot parallel point-gets, in `missing` order. Warm-optimal fan-out
218    /// for normal-sized prefetch batches; bloated batches use the sorted batch
219    /// multi_get instead (see `prefetch_storage`).
220    #[cfg(feature = "rayon")]
221    fn point_get_storage_many(
222        &self,
223        missing: &[(Address, H256)],
224    ) -> Result<Vec<U256>, DatabaseError> {
225        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
226        missing
227            .par_iter()
228            .map(|&(addr, key)| self.inner.get_storage_value(addr, key))
229            .collect()
230    }
231
232    #[cfg(not(feature = "rayon"))]
233    fn point_get_storage_many(
234        &self,
235        missing: &[(Address, H256)],
236    ) -> Result<Vec<U256>, DatabaseError> {
237        missing
238            .iter()
239            .map(|&(addr, key)| self.inner.get_storage_value(addr, key))
240            .collect()
241    }
242
243    /// Snapshot of the touched key sets matching the given filters: cached
244    /// accounts (with their storage roots) and cached storage slot keys. The
245    /// filters let a caller that tracks already-processed keys collect only
246    /// the delta, keeping the per-call allocation O(new) while the scan
247    /// stays O(cache).
248    pub fn touched_keys_where(
249        &self,
250        account_filter: &dyn Fn(&Address) -> bool,
251        slot_filter: &dyn Fn(&(Address, H256)) -> bool,
252    ) -> TouchedKeys {
253        let accounts = self
254            .accounts
255            .read()
256            .map(|a| {
257                a.iter()
258                    .filter(|(addr, _)| account_filter(addr))
259                    .map(|(addr, st)| (*addr, st.storage_root))
260                    .collect()
261            })
262            .unwrap_or_default();
263        let storage = self
264            .storage
265            .read()
266            .map(|s| s.keys().filter(|k| slot_filter(k)).copied().collect())
267            .unwrap_or_default();
268        TouchedKeys {
269            accounts,
270            slots: storage,
271        }
272    }
273
274    /// Per-account parallel point-gets, in `missing` order. Warm-optimal fan-out
275    /// for normal-sized prefetch batches; large batches use the sorted sharded
276    /// multi_get instead (see `prefetch_accounts`).
277    #[cfg(feature = "rayon")]
278    fn point_get_accounts_many(
279        &self,
280        missing: &[Address],
281    ) -> Result<Vec<AccountState>, DatabaseError> {
282        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
283        missing
284            .par_iter()
285            .map(|&addr| self.inner.get_account_state(addr))
286            .collect()
287    }
288
289    #[cfg(not(feature = "rayon"))]
290    fn point_get_accounts_many(
291        &self,
292        missing: &[Address],
293    ) -> Result<Vec<AccountState>, DatabaseError> {
294        missing
295            .iter()
296            .map(|&addr| self.inner.get_account_state(addr))
297            .collect()
298    }
299}
300
301fn poison_error_to_db_error<T>(err: PoisonError<T>) -> DatabaseError {
302    DatabaseError::Custom(format!("Cache lock poisoned: {err}"))
303}
304
305impl Database for CachingDatabase {
306    fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
307        // Check cache first
308        if let Some(state) = self.read_accounts()?.get(&address).copied() {
309            return Ok(state);
310        }
311
312        // Cache miss: query underlying database
313        let state = self.inner.get_account_state(address)?;
314
315        // Populate cache (AccountState is Copy, no clone needed)
316        self.write_accounts()?.insert(address, state);
317
318        Ok(state)
319    }
320
321    fn get_storage_value(&self, address: Address, key: H256) -> Result<U256, DatabaseError> {
322        // Check cache first
323        if let Some(value) = self.read_storage()?.get(&(address, key)).copied() {
324            return Ok(value);
325        }
326
327        // Cache miss: query underlying database
328        let value = self.inner.get_storage_value(address, key)?;
329
330        // Populate cache (U256 is Copy, no clone needed)
331        self.write_storage()?.insert((address, key), value);
332
333        Ok(value)
334    }
335
336    fn get_block_hash(&self, block_number: u64) -> Result<H256, DatabaseError> {
337        // Block hashes don't benefit much from caching here
338        // (they're already cached in StoreVmDatabase)
339        self.inner.get_block_hash(block_number)
340    }
341
342    fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
343        if let Some(cfg) = self.chain_config.get() {
344            return Ok(*cfg);
345        }
346        let cfg = self.inner.get_chain_config()?;
347        // Ignore set error: another thread may have raced us; re-read the winner.
348        let _ = self.chain_config.set(cfg);
349        Ok(*self.chain_config.get().unwrap_or(&cfg))
350    }
351
352    fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
353        // Check cache first
354        if let Some(code) = self.read_code()?.get(&code_hash).cloned() {
355            return Ok(code);
356        }
357
358        // Cache miss: query underlying database
359        let code = self.inner.get_account_code(code_hash)?;
360
361        // Populate cache (Code contains Bytes which is ref-counted, clone is cheap)
362        self.keep_code(&mut *self.write_code()?, code_hash, code.clone());
363
364        Ok(code)
365    }
366
367    fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
368        // Answer from resident code when there is any. The BAL warmer loads the block's
369        // bytecode into this cache, so a length read usually has the answer here under a
370        // shared read lock. Falling straight through would instead take the store's
371        // single mutex-guarded metadata cache, serializing every `EXTCODESIZE` across
372        // the parallel executor's threads.
373        if let Some(code) = self.read_code()?.get(&code_hash) {
374            return Ok(CodeMetadata {
375                length: u64::try_from(code.len()).unwrap_or(u64::MAX),
376            });
377        }
378        self.inner.get_code_metadata(code_hash)
379    }
380
381    fn precompile_cache(&self) -> Option<&PrecompileCache> {
382        self.precompile_cache.as_ref()
383    }
384
385    /// Warms every address through [`Self::prefetch_accounts`], then answers from the
386    /// cache under one read lock. Repeated addresses cost a single database read, and a
387    /// caller that wants the states pays one map lookup each rather than a full
388    /// `get_account_state` round trip per address.
389    ///
390    /// The read lock spans the whole assembly, so callers should keep `addresses` to a
391    /// bounded slice: a writer (an executor filling the same cache) waits on it.
392    fn get_account_states_batch(
393        &self,
394        addresses: &[Address],
395    ) -> Result<Vec<AccountState>, DatabaseError> {
396        self.prefetch_accounts(addresses)?;
397        let cache = self.read_accounts()?;
398        addresses
399            .iter()
400            .map(|addr| {
401                cache.get(addr).copied().ok_or_else(|| {
402                    DatabaseError::Custom(format!("account {addr:?} missing after prefetch"))
403                })
404            })
405            .collect()
406    }
407
408    fn code_cache_budget_bytes(&self) -> u64 {
409        self.inner.code_cache_budget_bytes()
410    }
411
412    /// Fetches the uncached bytecodes in one batch and inserts them, reading each
413    /// distinct hash once however many entries share it.
414    ///
415    /// Returns only the warmed byte count, not the bytecodes: the executor reads code
416    /// through [`Self::get_account_code`], which this turns into a cache hit. Assembling
417    /// a result vector here would hold the write lock that guards every executor code
418    /// read for the length of the batch, to build something a warming caller discards.
419    fn prefetch_codes(&self, code_hashes: &[H256]) -> Result<u64, DatabaseError> {
420        let missing: Vec<H256> = {
421            let cache = self.read_code()?;
422            let mut seen: FxHashSet<H256> = FxHashSet::default();
423            code_hashes
424                .iter()
425                .copied()
426                .filter(|h| !cache.contains_key(h) && seen.insert(*h))
427                .collect()
428        };
429
430        if missing.is_empty() {
431            return Ok(0);
432        }
433
434        let codes = self.inner.get_account_codes_batch(&missing)?;
435        let mut warmed = 0u64;
436        let mut cache = self.write_code()?;
437        for (hash, code) in missing.into_iter().zip(codes.into_iter()) {
438            // An absent hash has nothing to warm; the executor reports it if reached.
439            if let Some(code) = code {
440                warmed = warmed.saturating_add(u64::try_from(code.size()).unwrap_or(u64::MAX));
441                self.keep_code(&mut cache, hash, code);
442            }
443        }
444        Ok(warmed)
445    }
446
447    fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> {
448        // Filter out already-cached addresses before issuing the batch read.
449        let missing: Vec<Address> = {
450            let cache = self.read_accounts()?;
451            addresses
452                .iter()
453                .copied()
454                .filter(|a| !cache.contains_key(a))
455                .collect()
456        };
457        if missing.is_empty() {
458            return Ok(());
459        }
460        // Same gate as `prefetch_storage`: a large set of distinct COLD accounts is
461        // queue-depth bound. The inner batch path on the rocksdb-backed
462        // StoreVmDatabase used a single multi_get (queue depth 1, async_io off),
463        // which collapses on cold account-heavy blocks (coldbench: ~13x slower than
464        // the sharded batch). Route large/cold sets to the (now sharded) batch and
465        // small/warm sets to parallel point-gets. The gate counts MISSING (cold)
466        // accounts, so warm blocks stay on the point-get path however many accounts
467        // they touch. See `BLOATED_BATCH_THRESHOLD`.
468        let states = if missing.len() >= BLOATED_BATCH_THRESHOLD {
469            self.inner.get_account_states_batch(&missing)?
470        } else {
471            self.point_get_accounts_many(&missing)?
472        };
473        let mut cache = self.write_accounts()?;
474        for (addr, state) in missing.into_iter().zip(states.into_iter()) {
475            cache.entry(addr).or_insert(state);
476        }
477        Ok(())
478    }
479
480    fn prefetch_storage(&self, keys: &[(Address, H256)]) -> Result<(), DatabaseError> {
481        // Filter out already-cached slots before issuing the batch read.
482        let missing: Vec<(Address, H256)> = {
483            let cache = self.read_storage()?;
484            keys.iter()
485                .copied()
486                .filter(|k| !cache.contains_key(k))
487                .collect()
488        };
489        if missing.is_empty() {
490            return Ok(());
491        }
492        // Warm is the common case: a normal block touches relatively few storage
493        // slots and they are usually cache-resident, where per-slot point-gets
494        // (parallel fan-out) are warm-optimal. A block that instead reads a large
495        // number of distinct COLD slots is queue-depth bound: a per-slot fan-out
496        // is capped at ncpu reads in flight, and a single serial multi_get runs
497        // at queue depth 1 (async_io is off in our build), so cold throughput
498        // collapses (a sorted serial multi_get regressed bloated SLOAD ~4.5x).
499        // The sharded batch path restores it (sorted shards share RocksDB data
500        // blocks and run at high queue depth) and hardens validation against
501        // storage-bloat DoS. The gate counts MISSING (uncached, i.e. cold) slots,
502        // not total accesses, so a warm block never reaches it however many slots
503        // it touches; that is what keeps the path off normal traffic. The sharded
504        // win is already present once a block has this many cold slots (a cold
505        // benchmark shows ~1.4x at 16k and growing with size), while the warm cost
506        // it trades against is a few ms and effectively cannot fire, since warm
507        // slots are not counted here. See `BLOATED_BATCH_THRESHOLD`.
508        let values = if missing.len() >= BLOATED_BATCH_THRESHOLD {
509            // Dispatch to inner's batch path. For the rocksdb-backed
510            // StoreVmDatabase this is a sharded parallel multi_get on
511            // STORAGE_FLATKEYVALUE for the FKV-covered subset; the default impl
512            // loops for other backends.
513            self.inner.get_storage_values_batch(&missing)?
514        } else {
515            self.point_get_storage_many(&missing)?
516        };
517        let mut cache = self.write_storage()?;
518        for (key, value) in missing.into_iter().zip(values.into_iter()) {
519            cache.entry(key).or_insert(value);
520        }
521        Ok(())
522    }
523}
524
525#[cfg(test)]
526#[allow(
527    clippy::unwrap_used,
528    clippy::indexing_slicing,
529    clippy::arithmetic_side_effects,
530    clippy::as_conversions
531)]
532mod code_bytes_tests {
533    use super::*;
534    use bytes::Bytes;
535
536    /// Serves a distinct 4 KiB contract for every code hash.
537    struct BigCodeStore;
538
539    fn code_for(hash: H256) -> Code {
540        let mut bytecode = vec![0x5b; 4096];
541        bytecode[..32].copy_from_slice(hash.as_bytes());
542        Code::from_bytecode_unchecked(Bytes::from(bytecode), hash)
543    }
544
545    impl Database for BigCodeStore {
546        fn get_account_state(&self, _: Address) -> Result<AccountState, DatabaseError> {
547            Ok(AccountState::default())
548        }
549        fn get_storage_value(&self, _: Address, _: H256) -> Result<U256, DatabaseError> {
550            Ok(U256::zero())
551        }
552        fn get_block_hash(&self, _: u64) -> Result<H256, DatabaseError> {
553            Ok(H256::zero())
554        }
555        fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
556            Ok(ChainConfig::default())
557        }
558        fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
559            Ok(code_for(code_hash))
560        }
561        fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
562            Ok(CodeMetadata {
563                length: code_for(code_hash).len() as u64,
564            })
565        }
566    }
567
568    /// `code_bytes` SHALL count every distinct code once, whether it was read on demand
569    /// or prefetched, so the prewarmer's stop condition sees what the map really holds.
570    #[test]
571    fn code_bytes_counts_each_distinct_code_once() {
572        let cache = CachingDatabase::new(Arc::new(BigCodeStore), false);
573        let size = code_for(H256::zero()).size() as u64;
574        let hashes: Vec<H256> = (1..=4u64).map(H256::from_low_u64_be).collect();
575
576        cache.get_account_code(hashes[0]).unwrap();
577        cache.get_account_code(hashes[0]).unwrap();
578        assert_eq!(cache.code_bytes(), size);
579
580        cache.prefetch_codes(&hashes).unwrap();
581        cache.prefetch_codes(&hashes).unwrap();
582        assert_eq!(cache.code_bytes(), 4 * size);
583        assert_eq!(cache.read_code().unwrap().len(), 4);
584    }
585}