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}