Skip to main content

evm_fork_cache/cold_start/
driver.rs

1//! The cold-start driver: inherent [`EvmCache`] methods that execute rounds and
2//! run the bounded multi-round loop.
3//!
4//! The driver performs every fetch and call; planners are pure and IO-free. The
5//! whole module is gated behind the `reactive` feature.
6
7use std::collections::HashSet;
8
9use alloy_eips::BlockId;
10use alloy_primitives::Address;
11use anyhow::Result;
12
13use crate::cache::{EvmCache, block_in_place_handle};
14use crate::cold_start::config::{ColdStartConfig, ColdStartPin, ColdStartRunReport};
15use crate::cold_start::error::ColdStartError;
16use crate::cold_start::plan::ColdStartPlan;
17use crate::cold_start::planner::{ColdStartPlanner, ColdStartStep};
18use crate::cold_start::results::{ColdStartCallResult, ColdStartResults, RoundOutcome};
19use crate::events::StateView;
20use crate::freshness::{SlotFetch, SlotOutcome};
21
22impl EvmCache {
23    /// View `self` as a [`StateView`] for handing to a planner.
24    ///
25    /// The returned borrow must be **inlined** into each planner call rather than
26    /// held across [`execute_cold_start_round`](Self::execute_cold_start_round),
27    /// which needs `&mut self` (holding the shared borrow across it is a borrowck
28    /// error).
29    fn state_view(&self) -> &dyn StateView {
30        self
31    }
32
33    /// Pre-seed an account synchronously, bridging [`ensure_account`] across the
34    /// async boundary.
35    ///
36    /// [`ensure_account`](EvmCache::ensure_account) early-returns for an
37    /// already-present account; for a missing one it issues a backend fetch that
38    /// can return `Err`. This is the only producer of a cold-start round hard error
39    /// in the accounts phase. Requires a **multi-thread** tokio runtime (the
40    /// cold-start runtime precondition); on a current-thread runtime or with no
41    /// runtime present it returns a typed error via
42    /// [`block_in_place_handle`](crate::cache::block_in_place_handle).
43    pub(crate) fn ensure_account_blocking(&mut self, address: Address) -> Result<()> {
44        let handle = block_in_place_handle()?;
45        tokio::task::block_in_place(|| handle.block_on(self.ensure_account(address)))
46    }
47
48    /// Execute a single cold-start round and return its (possibly partial) outcome.
49    ///
50    /// Fixed phase order: **accounts → verify → probe → discover**.
51    ///
52    /// Per-round fetcher guard: if the plan declares any verify or probe slots and
53    /// the cache has no storage batch fetcher, the round short-circuits with
54    /// [`ColdStartError::NoBatchFetcher`] before issuing any read. A round
55    /// declaring only accounts/discover runs without a fetcher.
56    ///
57    /// - **accounts (first):** each `plan.accounts` address is pre-seeded via
58    ///   `ensure_account_blocking`. A failure here is a hard error in the first
59    ///   phase, so nothing after it ran: every declared verify/probe slot is marked
60    ///   [`SlotFetch::NotAttempted`] and the round returns with `error: Some(..)`.
61    ///   This is the only producer of `NotAttempted`.
62    /// - **verify:** each verify slot is re-fetched, classified into
63    ///   `results.fetched`, and (when changed) injected and recorded in
64    ///   `results.verified`.
65    /// - **probe:** each probe slot is re-fetched at the pinned block and
66    ///   classified into `results.probed` via the same shared `Result<U256>`
67    ///   classification verify uses. Unlike verify, a probe injects nothing and
68    ///   records no [`SlotChange`](crate::freshness::SlotChange): it is the
69    ///   archive-miss classification for slots a consumer does not want to warm.
70    /// - **discover (last):** each [`ColdStartCall`](crate::cold_start::ColdStartCall)
71    ///   is executed via
72    ///   [`call_raw_with_access_list`](EvmCache::call_raw_with_access_list), its
73    ///   access list filtered by `restrict_to`, and the result pushed to
74    ///   `results.discovered`. A discover failure preserves the verify/probe
75    ///   outcomes already computed this round (they ran earlier, so they are *not*
76    ///   `NotAttempted`); the failing call and all subsequent discover calls are
77    ///   dropped, and the round returns with `error: Some(..)`.
78    pub fn execute_cold_start_round(&mut self, plan: &ColdStartPlan) -> RoundOutcome {
79        let mut results = ColdStartResults::default();
80
81        // Per-round fetcher guard: only fires for verify/probe-bearing rounds.
82        if (!plan.verify.is_empty() || !plan.probe.is_empty())
83            && self.storage_batch_fetcher().is_none()
84        {
85            return RoundOutcome {
86                results,
87                error: Some(ColdStartError::NoBatchFetcher),
88            };
89        }
90
91        // Accounts phase (first): pre-seed each declared account. A failure here
92        // short-circuits the round before verify/probe/discover run, so every
93        // declared verify/probe slot is synthesized as NotAttempted.
94        for &address in &plan.accounts {
95            if let Err(e) = self.ensure_account_blocking(address) {
96                results.fetched = not_attempted_outcomes(&plan.verify);
97                results.probed = not_attempted_outcomes(&plan.probe);
98                return RoundOutcome {
99                    results,
100                    error: Some(ColdStartError::Fetch(e)),
101                };
102            }
103        }
104
105        // Verify phase: classify every slot, inject and record the changed ones.
106        if !plan.verify.is_empty() {
107            match self.verify_slots_with_outcomes(&plan.verify) {
108                Ok((changed, outcomes)) => {
109                    results.verified = changed;
110                    results.fetched = outcomes;
111                }
112                Err(e) => {
113                    // The only error surface of verify_slots_with_outcomes is the
114                    // missing-fetcher guard, already front-run above; surface any
115                    // residual error explicitly rather than panicking.
116                    return RoundOutcome {
117                        results,
118                        error: Some(ColdStartError::Fetch(e)),
119                    };
120                }
121            }
122        }
123
124        // Probe phase: classify each declared slot at the pinned block WITHOUT
125        // injecting (the archive-miss classification for slots a consumer does
126        // not want to warm). It records into `results.probed` only — never
127        // `results.verified`, and never writes the cache.
128        if !plan.probe.is_empty() {
129            // The per-round guard already ensured a fetcher is present for a
130            // probe-bearing round. Read pinned to self.block (NOT read_storage_slot,
131            // which is unpinned), classify, and inject NOTHING.
132            let fetcher = self
133                .storage_batch_fetcher()
134                .cloned()
135                .expect("probe-bearing round guarded a fetcher above");
136            let probed = (fetcher)(plan.probe.clone(), Some(self.block()));
137            results.probed = probed
138                .into_iter()
139                .map(|(address, slot, fetched)| SlotOutcome {
140                    address,
141                    slot,
142                    fetch: EvmCache::classify(fetched),
143                })
144                .collect();
145        }
146
147        // Discover phase (last): run each view-call, filter by restrict_to. A
148        // failure drops this and every subsequent call but preserves the
149        // verify/probe outcomes already computed above.
150        for call in &plan.discover {
151            match self.call_raw_with_access_list(call.from, call.to, call.calldata.clone()) {
152                Ok((result, mut access)) => {
153                    if let Some(list) = &call.restrict_to {
154                        let keep: HashSet<Address> = list.iter().copied().collect();
155                        access.slots.retain(|(a, _)| keep.contains(a));
156                        access.accounts.retain(|a| keep.contains(a));
157                    }
158                    results
159                        .discovered
160                        .push(ColdStartCallResult { result, access });
161                }
162                Err(e) => {
163                    return RoundOutcome {
164                        results,
165                        error: Some(ColdStartError::Fetch(e)),
166                    };
167                }
168            }
169        }
170
171        RoundOutcome {
172            results,
173            error: None,
174        }
175    }
176
177    /// Run a bounded multi-round cold start driven by `planner`.
178    ///
179    /// Pin handling per `config.pin`: [`ColdStartPin::CachePinned`] is a no-op;
180    /// [`ColdStartPin::Hash`] pins every round to
181    /// `BlockId::from((hash, Some(require_canonical)))`, capturing the prior block
182    /// and restoring it on **every** exit path (success, budget-exceeded, and
183    /// mid-round error).
184    ///
185    /// The loop checks the round budget at the top: with `max_rounds = N`, rounds
186    /// `0..N` execute and a planner still returning `Continue` after round `N`
187    /// yields [`RoundBudgetExceeded`](ColdStartError::RoundBudgetExceeded). Each
188    /// round's results are absorbed into the report **before** its `error` is
189    /// checked; a round error propagates after restoring the pin and **without**
190    /// calling `on_results`. Note the absorbed report is returned only on the
191    /// `Ok` path — on error it is dropped and the `Err` carries only the cause;
192    /// use [`execute_cold_start_round`](Self::execute_cold_start_round) directly
193    /// to observe a failed round's partial outcomes.
194    pub fn run_cold_start(
195        &mut self,
196        planner: &mut dyn ColdStartPlanner,
197        config: ColdStartConfig,
198    ) -> Result<ColdStartRunReport, ColdStartError> {
199        // Pin handling: capture the block to restore (None == no restore needed).
200        let restore: Option<BlockId> = match config.pin {
201            ColdStartPin::CachePinned => None,
202            ColdStartPin::Hash {
203                hash,
204                require_canonical,
205                ..
206            } => {
207                let prev = self.block();
208                self.set_block(BlockId::from((hash, Some(require_canonical))));
209                Some(prev)
210            }
211        };
212
213        let mut report = ColdStartRunReport::default();
214        // Borrow inlined, not hoisted across the &mut self round call.
215        let mut plan = planner.initial_plan(self.state_view());
216
217        loop {
218            if report.rounds >= config.max_rounds {
219                if let Some(prev) = restore {
220                    self.set_block(prev);
221                }
222                return Err(ColdStartError::RoundBudgetExceeded {
223                    max_rounds: config.max_rounds,
224                });
225            }
226
227            let outcome = self.execute_cold_start_round(&plan);
228            report.absorb_round(&plan, &outcome.results);
229
230            if let Some(err) = outcome.error {
231                if let Some(prev) = restore {
232                    self.set_block(prev);
233                }
234                return Err(err);
235            }
236
237            match planner.on_results(&outcome.results, self.state_view()) {
238                ColdStartStep::Done => break,
239                ColdStartStep::Continue(next) => plan = next,
240            }
241        }
242
243        if let Some(prev) = restore {
244            self.set_block(prev);
245        }
246        Ok(report)
247    }
248}
249
250/// Synthesize one [`SlotFetch::NotAttempted`] outcome per declared slot.
251///
252/// Used when an accounts-phase hard error short-circuits a round before the
253/// verify/probe phases run: every declared slot is reported as `NotAttempted`
254/// rather than silently dropped.
255fn not_attempted_outcomes(slots: &[(Address, alloy_primitives::U256)]) -> Vec<SlotOutcome> {
256    slots
257        .iter()
258        .map(|&(address, slot)| SlotOutcome {
259            address,
260            slot,
261            fetch: SlotFetch::NotAttempted,
262        })
263        .collect()
264}