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