Skip to main content

foundry_evm_core/fork/
prefetch.rs

1//! Prefetches a new fork block's remote cache from the keys of an older cached block.
2//!
3//! A fork of `latest` gets an empty cache on each new head, and every cold account or storage read
4//! then blocks the EVM for one RPC round trip. Earlier runs on the same chain read mostly the same
5//! accounts and slots, so the keys of the newest older cache file predict the reads. The keys are
6//! read again at the new block through the fork backend, so the cache stays exact for that block.
7//! The cache file keeps only the keys the EVM then reads, so a wrong prediction only costs an RPC
8//! request and does not carry over to the next block.
9
10use super::Fork;
11use alloy_network::Network;
12use alloy_primitives::{Address, U256};
13use foundry_config::Config;
14use foundry_fork_db::{
15    ForkBlockEnv, SharedBackend,
16    backend::BlockingMode,
17    cache::{JsonBlockCacheDB, MemDb},
18};
19use parking_lot::Mutex;
20use std::{
21    path::{Path, PathBuf},
22    sync::{
23        Arc,
24        atomic::{AtomicBool, Ordering},
25        mpsc,
26    },
27};
28
29/// Prefetch requests in flight at once. The fork backend sends its own cold reads without a limit;
30/// this bound keeps a large key set from flooding a public endpoint.
31const MAX_CONCURRENT_REQUESTS: usize = 32;
32
33/// Largest number of accounts and slots to prefetch. Older cache files can hold the union of many
34/// runs; past this size, a prefetch can cost more requests than it saves.
35const MAX_KEYS: usize = 4096;
36
37enum Key {
38    Account(Address),
39    Slot(Address, U256),
40}
41
42/// Starts a background prefetch through `backend` if its cache is empty.
43///
44/// Each key is read through the backend like an EVM read, so the backend fetches and caches it with
45/// its own request path, and a key it already has costs no request. Loading the previous cache file
46/// runs in the background too, so it does not block the fork handler.
47pub(crate) fn spawn<N: Network, B: ForkBlockEnv>(
48    fork: &Fork,
49    backend: &SharedBackend<N, B>,
50) -> Option<Prefetch> {
51    if !backend.data().accounts.read().is_empty() {
52        return None;
53    }
54    let runtime = tokio::runtime::Handle::try_current().ok()?;
55    let (chain, number) = (fork.context().source_chain_id, fork.number());
56    let stop = Arc::new(AtomicBool::new(false));
57    let (done_tx, done) = mpsc::channel::<()>();
58    let backend = backend.with_blocking_mode(BlockingMode::Block);
59    let coordinator_stop = Arc::clone(&stop);
60    runtime.clone().spawn_blocking(move || {
61        let stop = coordinator_stop;
62        if let Some(path) =
63            Config::foundry_chain_cache_dir(chain).and_then(|dir| previous_cache_file(&dir, number))
64            && let Ok(previous) = JsonBlockCacheDB::<B>::load(&path)
65            && !stop.load(Ordering::Relaxed)
66        {
67            let keys = keys(previous.db());
68            debug!(target: "fork::prefetch", ?path, keys = keys.len(), "prefetching fork cache");
69            // Workers take the next key without a lock held during a read.
70            let keys = Arc::new(Mutex::new(keys.into_iter()));
71            for _ in 0..MAX_CONCURRENT_REQUESTS {
72                let (keys, stop, done_tx) = (Arc::clone(&keys), Arc::clone(&stop), done_tx.clone());
73                let backend = backend.clone();
74                runtime.spawn_blocking(move || {
75                    while !stop.load(Ordering::Relaxed) {
76                        let Some(key) = keys.lock().next() else { break };
77                        // A prefetched key stays out of the cache file until the EVM reads it.
78                        let read = match key {
79                            Key::Account(address) => backend.prefetch_basic(address),
80                            Key::Slot(address, slot) => backend.prefetch_storage(address, slot),
81                        };
82                        // The EVM fetches any key it still needs, so stop at the first failure.
83                        if read.is_err() {
84                            break;
85                        }
86                    }
87                    // Drop the backend clone before reporting done, as below.
88                    drop(backend);
89                    drop(done_tx);
90                });
91            }
92        }
93        // The last backend clone flushes the disk cache, so drop it before reporting done.
94        drop(backend);
95        drop(done_tx);
96    });
97    Some(Prefetch { stop, done })
98}
99
100/// Background prefetch workers of one fork backend.
101pub(crate) struct Prefetch {
102    stop: Arc<AtomicBool>,
103    done: mpsc::Receiver<()>,
104}
105
106impl Prefetch {
107    /// Stops the workers and waits until they and their coordinator exit, so whichever holds the
108    /// last backend clone flushes the disk cache before shutdown completes.
109    pub(crate) fn stop(self) {
110        self.stop.store(true, Ordering::Relaxed);
111        // Returns once every worker has dropped its sender.
112        let _ = self.done.recv();
113    }
114}
115
116/// Returns the cache file of the newest block below `number` in a chain cache directory.
117fn previous_cache_file(dir: &Path, number: u64) -> Option<PathBuf> {
118    std::fs::read_dir(dir)
119        .ok()?
120        .filter_map(|entry| {
121            let entry = entry.ok()?;
122            let block = entry.file_name().to_str()?.parse::<u64>().ok()?;
123            (block < number && entry.file_type().ok()?.is_file()).then_some((block, entry.path()))
124        })
125        .max_by_key(|(block, _)| *block)
126        .map(|(_, path)| path)
127}
128
129/// Returns the accounts first, then the slots, up to [`MAX_KEYS`].
130fn keys(db: &MemDb) -> Vec<Key> {
131    let accounts = db.accounts.read();
132    let storage = db.storage.read();
133    accounts
134        .keys()
135        .map(|&address| Key::Account(address))
136        .chain(
137            storage.iter().flat_map(|(&address, slots)| {
138                slots.keys().map(move |&slot| Key::Slot(address, slot))
139            }),
140        )
141        .take(MAX_KEYS)
142        .collect()
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn picks_newest_cache_file_below_block() {
151        let dir = std::env::temp_dir().join(format!("foundry-prefetch-{}", std::process::id()));
152        std::fs::create_dir_all(dir.join("120")).unwrap();
153        for block in ["90", "100", "110", "130", "notes"] {
154            std::fs::write(dir.join(block), b"{}").unwrap();
155        }
156
157        // `120` is a directory, as anvil writes it, and `130` is not below the block.
158        assert_eq!(previous_cache_file(&dir, 125), Some(dir.join("110")));
159        assert_eq!(previous_cache_file(&dir, 90), None);
160        std::fs::remove_dir_all(&dir).unwrap();
161    }
162}