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}