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;
7use std::sync::{Arc, OnceLock, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
8
9pub mod gen_db;
10
11/// Distinct-cold-key count above which a prefetch routes to the sorted, sharded
12/// batch read instead of per-key parallel point-gets. Shared by the account and
13/// storage prefetch gates here and by the merkle trie-node prefetch gate in
14/// `ethrex-blockchain`, so tuning it moves all three together. ~16384 cold
15/// accesses is ~34M gas of cold reads: above ordinary cold blocks, below the
16/// large-state blocks these paths target. Tunable.
17pub const BLOATED_BATCH_THRESHOLD: usize = 16_384;
18
19// Type aliases for cache storage maps
20type AccountCache = FxHashMap<Address, AccountState>;
21type StorageCache = FxHashMap<(Address, H256), U256>;
22type CodeCache = FxHashMap<H256, Code>;
23/// Touched-key snapshot returned by [`CachingDatabase::touched_keys_where`].
24pub struct TouchedKeys {
25    /// Touched accounts with their storage roots.
26    pub accounts: Vec<(Address, H256)>,
27    /// Touched storage slots as `(account address, slot key)`.
28    pub slots: Vec<(Address, H256)>,
29}
30
31pub trait Database: Send + Sync {
32    fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError>;
33    fn get_storage_value(&self, address: Address, key: H256) -> Result<U256, DatabaseError>;
34    fn get_block_hash(&self, block_number: u64) -> Result<H256, DatabaseError>;
35    fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError>;
36    fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError>;
37    fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError>;
38    /// Access the precompile cache, if available at this database layer.
39    fn precompile_cache(&self) -> Option<&PrecompileCache> {
40        None
41    }
42    /// Batch lookup. Default: loop. Backends with a batched read path (e.g. rocksdb
43    /// `multi_get_cf` on the flat key-value table) should override this and the
44    /// caching layer above will dispatch to it.
45    fn get_account_states_batch(
46        &self,
47        addresses: &[Address],
48    ) -> Result<Vec<AccountState>, DatabaseError> {
49        addresses
50            .iter()
51            .map(|a| self.get_account_state(*a))
52            .collect()
53    }
54    /// Batch storage-slot lookup. Default: loop. Backends with a batched read
55    /// path (e.g. rocksdb `multi_get_cf` on the storage flat key-value table)
56    /// should override this and the caching layer above will dispatch to it.
57    fn get_storage_values_batch(
58        &self,
59        keys: &[(Address, H256)],
60    ) -> Result<Vec<U256>, DatabaseError> {
61        keys.iter()
62            .map(|&(addr, key)| self.get_storage_value(addr, key))
63            .collect()
64    }
65    /// Prefetch a batch of accounts into the cache. Default: sequential fallback.
66    fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> {
67        for &addr in addresses {
68            self.get_account_state(addr)?;
69        }
70        Ok(())
71    }
72    /// Prefetch a batch of storage slots into the cache. Default: sequential fallback.
73    fn prefetch_storage(&self, keys: &[(Address, H256)]) -> Result<(), DatabaseError> {
74        for &(addr, key) in keys {
75            self.get_storage_value(addr, key)?;
76        }
77        Ok(())
78    }
79}
80
81/// A database wrapper that caches state lookups for parallel pre-warming.
82///
83/// This enables parallel warming workers to share cached data, and allows
84/// the sequential execution phase to reuse warmed state. Reduces redundant
85/// database/trie lookups when multiple transactions touch the same accounts.
86///
87/// Thread-safe via RwLock - optimized for read-heavy concurrent access.
88///
89/// This caching database is inspired by reth's overlay/proof worker cache.
90///
91/// Besides the per-block warmer/executor sharing above, the mempool
92/// prewarmer builds one instance per slot and publishes it across the block
93/// boundary: `execute_block_pipeline` seeds the *next* block's execution
94/// with it when the parent state and fork match (see
95/// `ethrex-blockchain::prewarm`).
96///
97/// # Invariant
98///
99/// Because one instance is shared across the block boundary (and the
100/// prewarmer may still be filling it while the next block executes), every
101/// cached entry must be a pure function of the parent state root. A cache
102/// layer whose entries also depend on the executing block (fork, number,
103/// timestamp, ...) needs a matching handoff guard in
104/// `execute_block_pipeline` — see `precompile_cache`, whose fork-dependent
105/// entries are covered by the fork-equality check there.
106pub struct CachingDatabase {
107    inner: Arc<dyn Database>,
108    /// Cached account states (balance, nonce, code_hash, storage_root)
109    accounts: RwLock<AccountCache>,
110    /// Cached storage values
111    storage: RwLock<StorageCache>,
112    /// Cached contract code
113    code: RwLock<CodeCache>,
114    /// Shared precompile result cache (warmer populates, executor reuses).
115    /// `None` when the cache is disabled via `BlockchainOptions::precompile_cache_enabled = false`.
116    precompile_cache: Option<PrecompileCache>,
117    /// Cached chain config (constant for the lifetime of this database)
118    chain_config: OnceLock<ChainConfig>,
119}
120
121impl CachingDatabase {
122    pub fn new(inner: Arc<dyn Database>, precompile_cache_enabled: bool) -> Self {
123        Self {
124            inner,
125            accounts: RwLock::new(FxHashMap::default()),
126            storage: RwLock::new(FxHashMap::default()),
127            code: RwLock::new(FxHashMap::default()),
128            precompile_cache: precompile_cache_enabled.then(PrecompileCache::new),
129            chain_config: OnceLock::new(),
130        }
131    }
132
133    fn read_accounts(&self) -> Result<RwLockReadGuard<'_, AccountCache>, DatabaseError> {
134        self.accounts.read().map_err(poison_error_to_db_error)
135    }
136
137    fn write_accounts(&self) -> Result<RwLockWriteGuard<'_, AccountCache>, DatabaseError> {
138        self.accounts.write().map_err(poison_error_to_db_error)
139    }
140
141    fn read_storage(&self) -> Result<RwLockReadGuard<'_, StorageCache>, DatabaseError> {
142        self.storage.read().map_err(poison_error_to_db_error)
143    }
144
145    fn write_storage(&self) -> Result<RwLockWriteGuard<'_, StorageCache>, DatabaseError> {
146        self.storage.write().map_err(poison_error_to_db_error)
147    }
148
149    fn read_code(&self) -> Result<RwLockReadGuard<'_, CodeCache>, DatabaseError> {
150        self.code.read().map_err(poison_error_to_db_error)
151    }
152
153    fn write_code(&self) -> Result<RwLockWriteGuard<'_, CodeCache>, DatabaseError> {
154        self.code.write().map_err(poison_error_to_db_error)
155    }
156
157    /// Per-slot parallel point-gets, in `missing` order. Warm-optimal fan-out
158    /// for normal-sized prefetch batches; bloated batches use the sorted batch
159    /// multi_get instead (see `prefetch_storage`).
160    #[cfg(feature = "rayon")]
161    fn point_get_storage_many(
162        &self,
163        missing: &[(Address, H256)],
164    ) -> Result<Vec<U256>, DatabaseError> {
165        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
166        missing
167            .par_iter()
168            .map(|&(addr, key)| self.inner.get_storage_value(addr, key))
169            .collect()
170    }
171
172    #[cfg(not(feature = "rayon"))]
173    fn point_get_storage_many(
174        &self,
175        missing: &[(Address, H256)],
176    ) -> Result<Vec<U256>, DatabaseError> {
177        missing
178            .iter()
179            .map(|&(addr, key)| self.inner.get_storage_value(addr, key))
180            .collect()
181    }
182
183    /// Snapshot of the touched key sets matching the given filters: cached
184    /// accounts (with their storage roots) and cached storage slot keys. The
185    /// filters let a caller that tracks already-processed keys collect only
186    /// the delta, keeping the per-call allocation O(new) while the scan
187    /// stays O(cache).
188    pub fn touched_keys_where(
189        &self,
190        account_filter: &dyn Fn(&Address) -> bool,
191        slot_filter: &dyn Fn(&(Address, H256)) -> bool,
192    ) -> TouchedKeys {
193        let accounts = self
194            .accounts
195            .read()
196            .map(|a| {
197                a.iter()
198                    .filter(|(addr, _)| account_filter(addr))
199                    .map(|(addr, st)| (*addr, st.storage_root))
200                    .collect()
201            })
202            .unwrap_or_default();
203        let storage = self
204            .storage
205            .read()
206            .map(|s| s.keys().filter(|k| slot_filter(k)).copied().collect())
207            .unwrap_or_default();
208        TouchedKeys {
209            accounts,
210            slots: storage,
211        }
212    }
213
214    /// Per-account parallel point-gets, in `missing` order. Warm-optimal fan-out
215    /// for normal-sized prefetch batches; large batches use the sorted sharded
216    /// multi_get instead (see `prefetch_accounts`).
217    #[cfg(feature = "rayon")]
218    fn point_get_accounts_many(
219        &self,
220        missing: &[Address],
221    ) -> Result<Vec<AccountState>, DatabaseError> {
222        use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
223        missing
224            .par_iter()
225            .map(|&addr| self.inner.get_account_state(addr))
226            .collect()
227    }
228
229    #[cfg(not(feature = "rayon"))]
230    fn point_get_accounts_many(
231        &self,
232        missing: &[Address],
233    ) -> Result<Vec<AccountState>, DatabaseError> {
234        missing
235            .iter()
236            .map(|&addr| self.inner.get_account_state(addr))
237            .collect()
238    }
239}
240
241fn poison_error_to_db_error<T>(err: PoisonError<T>) -> DatabaseError {
242    DatabaseError::Custom(format!("Cache lock poisoned: {err}"))
243}
244
245impl Database for CachingDatabase {
246    fn get_account_state(&self, address: Address) -> Result<AccountState, DatabaseError> {
247        // Check cache first
248        if let Some(state) = self.read_accounts()?.get(&address).copied() {
249            return Ok(state);
250        }
251
252        // Cache miss: query underlying database
253        let state = self.inner.get_account_state(address)?;
254
255        // Populate cache (AccountState is Copy, no clone needed)
256        self.write_accounts()?.insert(address, state);
257
258        Ok(state)
259    }
260
261    fn get_storage_value(&self, address: Address, key: H256) -> Result<U256, DatabaseError> {
262        // Check cache first
263        if let Some(value) = self.read_storage()?.get(&(address, key)).copied() {
264            return Ok(value);
265        }
266
267        // Cache miss: query underlying database
268        let value = self.inner.get_storage_value(address, key)?;
269
270        // Populate cache (U256 is Copy, no clone needed)
271        self.write_storage()?.insert((address, key), value);
272
273        Ok(value)
274    }
275
276    fn get_block_hash(&self, block_number: u64) -> Result<H256, DatabaseError> {
277        // Block hashes don't benefit much from caching here
278        // (they're already cached in StoreVmDatabase)
279        self.inner.get_block_hash(block_number)
280    }
281
282    fn get_chain_config(&self) -> Result<ChainConfig, DatabaseError> {
283        if let Some(cfg) = self.chain_config.get() {
284            return Ok(*cfg);
285        }
286        let cfg = self.inner.get_chain_config()?;
287        // Ignore set error: another thread may have raced us; re-read the winner.
288        let _ = self.chain_config.set(cfg);
289        Ok(*self.chain_config.get().unwrap_or(&cfg))
290    }
291
292    fn get_account_code(&self, code_hash: H256) -> Result<Code, DatabaseError> {
293        // Check cache first
294        if let Some(code) = self.read_code()?.get(&code_hash).cloned() {
295            return Ok(code);
296        }
297
298        // Cache miss: query underlying database
299        let code = self.inner.get_account_code(code_hash)?;
300
301        // Populate cache (Code contains Bytes which is ref-counted, clone is cheap)
302        self.write_code()?.insert(code_hash, code.clone());
303
304        Ok(code)
305    }
306
307    fn get_code_metadata(&self, code_hash: H256) -> Result<CodeMetadata, DatabaseError> {
308        // Delegate directly to the underlying database.
309        // The underlying Store already has its own code_metadata_cache,
310        // so we don't need to duplicate caching here.
311        self.inner.get_code_metadata(code_hash)
312    }
313
314    fn precompile_cache(&self) -> Option<&PrecompileCache> {
315        self.precompile_cache.as_ref()
316    }
317
318    fn prefetch_accounts(&self, addresses: &[Address]) -> Result<(), DatabaseError> {
319        // Filter out already-cached addresses before issuing the batch read.
320        let missing: Vec<Address> = {
321            let cache = self.read_accounts()?;
322            addresses
323                .iter()
324                .copied()
325                .filter(|a| !cache.contains_key(a))
326                .collect()
327        };
328        if missing.is_empty() {
329            return Ok(());
330        }
331        // Same gate as `prefetch_storage`: a large set of distinct COLD accounts is
332        // queue-depth bound. The inner batch path on the rocksdb-backed
333        // StoreVmDatabase used a single multi_get (queue depth 1, async_io off),
334        // which collapses on cold account-heavy blocks (coldbench: ~13x slower than
335        // the sharded batch). Route large/cold sets to the (now sharded) batch and
336        // small/warm sets to parallel point-gets. The gate counts MISSING (cold)
337        // accounts, so warm blocks stay on the point-get path however many accounts
338        // they touch. See `BLOATED_BATCH_THRESHOLD`.
339        let states = if missing.len() >= BLOATED_BATCH_THRESHOLD {
340            self.inner.get_account_states_batch(&missing)?
341        } else {
342            self.point_get_accounts_many(&missing)?
343        };
344        let mut cache = self.write_accounts()?;
345        for (addr, state) in missing.into_iter().zip(states.into_iter()) {
346            cache.entry(addr).or_insert(state);
347        }
348        Ok(())
349    }
350
351    fn prefetch_storage(&self, keys: &[(Address, H256)]) -> Result<(), DatabaseError> {
352        // Filter out already-cached slots before issuing the batch read.
353        let missing: Vec<(Address, H256)> = {
354            let cache = self.read_storage()?;
355            keys.iter()
356                .copied()
357                .filter(|k| !cache.contains_key(k))
358                .collect()
359        };
360        if missing.is_empty() {
361            return Ok(());
362        }
363        // Warm is the common case: a normal block touches relatively few storage
364        // slots and they are usually cache-resident, where per-slot point-gets
365        // (parallel fan-out) are warm-optimal. A block that instead reads a large
366        // number of distinct COLD slots is queue-depth bound: a per-slot fan-out
367        // is capped at ncpu reads in flight, and a single serial multi_get runs
368        // at queue depth 1 (async_io is off in our build), so cold throughput
369        // collapses (a sorted serial multi_get regressed bloated SLOAD ~4.5x).
370        // The sharded batch path restores it (sorted shards share RocksDB data
371        // blocks and run at high queue depth) and hardens validation against
372        // storage-bloat DoS. The gate counts MISSING (uncached, i.e. cold) slots,
373        // not total accesses, so a warm block never reaches it however many slots
374        // it touches; that is what keeps the path off normal traffic. The sharded
375        // win is already present once a block has this many cold slots (a cold
376        // benchmark shows ~1.4x at 16k and growing with size), while the warm cost
377        // it trades against is a few ms and effectively cannot fire, since warm
378        // slots are not counted here. See `BLOATED_BATCH_THRESHOLD`.
379        let values = if missing.len() >= BLOATED_BATCH_THRESHOLD {
380            // Dispatch to inner's batch path. For the rocksdb-backed
381            // StoreVmDatabase this is a sharded parallel multi_get on
382            // STORAGE_FLATKEYVALUE for the FKV-covered subset; the default impl
383            // loops for other backends.
384            self.inner.get_storage_values_batch(&missing)?
385        } else {
386            self.point_get_storage_many(&missing)?
387        };
388        let mut cache = self.write_storage()?;
389        for (key, value) in missing.into_iter().zip(values.into_iter()) {
390            cache.entry(key).or_insert(value);
391        }
392        Ok(())
393    }
394}