Skip to main content

evm_fork_cache/cache/
slot_observations.rs

1//! Storage-slot change-frequency tracking and freshness heuristics.
2//!
3//! To decide which cached storage slots can be reused and which must be
4//! re-fetched, this module records how often each observed slot changes over
5//! time. Slots that change frequently are rechecked sooner; stable slots are
6//! trusted longer (subject to a maximum age). The observations are persisted to
7//! disk so the heuristics survive across runs.
8//!
9//! # Clock-agnostic
10//!
11//! The tracker does not read the wall clock itself: callers pass `now` (in
12//! clock units) into [`observe`](SlotObservationTracker::observe) and
13//! [`should_refetch`](SlotObservationTracker::should_refetch), and the thresholds
14//! live in a [`crate::freshness::FreshnessParams`]. This lets the freshness
15//! controller drive the tracker from either a block clock or a wall clock.
16
17use std::{collections::HashMap, path::Path};
18
19use alloy_primitives::{Address, U256};
20use serde::{Deserialize, Serialize};
21use tracing::{debug, warn};
22
23use crate::freshness::FreshnessParams;
24
25use super::versioned;
26use crate::errors::PersistenceError;
27
28const SLOT_OBSERVATIONS_MAGIC: &[u8; 8] = b"EFC-SOBS";
29const SLOT_OBSERVATIONS_VERSION: u32 = 1;
30
31/// Per-slot observation record, persisted to disk.
32#[derive(Serialize, Deserialize, Clone, Debug)]
33pub struct SlotObservation {
34    /// Most recently observed slot value.
35    pub last_value: U256,
36    /// Total number of times this slot has been observed.
37    pub observation_count: u32,
38    /// Number of observations that differed from the previous value.
39    pub change_count: u32,
40    /// Clock value (block number or unix seconds) of the most recent observation.
41    pub last_checked: u64,
42    /// Clock value of the most recent value change.
43    pub last_changed: u64,
44}
45
46/// Serializable key type for bincode persistence.
47#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
48struct SlotKey {
49    address: Address,
50    slot: U256,
51}
52
53/// Tracks per-slot change frequency to drive intelligent purge decisions.
54///
55/// Before purging a slot, check `should_refetch()`. Slots that rarely change
56/// are kept in the EVM cache, avoiding unnecessary RPC calls. On simulation
57/// revert, `take_skipped()` returns all slots that were kept, allowing a
58/// full-refresh fallback.
59pub struct SlotObservationTracker {
60    observations: HashMap<SlotKey, SlotObservation>,
61    /// Slots skipped (not purged) this cycle — kept for revert fallback.
62    skipped_this_cycle: Vec<(Address, U256)>,
63    dirty: bool,
64}
65
66impl SlotObservationTracker {
67    /// Create an empty tracker with no recorded observations.
68    pub fn new() -> Self {
69        Self {
70            observations: HashMap::new(),
71            skipped_this_cycle: Vec::new(),
72            dirty: false,
73        }
74    }
75
76    /// Load persisted observations from disk (versioned binary format).
77    ///
78    /// Returns a fresh tracker if the file doesn't exist, has an unrecognized
79    /// magic/version header, or can't be decoded. Legacy unversioned bincode is
80    /// treated as a cache miss.
81    pub fn load(path: &Path) -> Self {
82        match std::fs::read(path) {
83            Ok(data) => {
84                if let Some(observations) = versioned::decode::<HashMap<SlotKey, SlotObservation>>(
85                    &data,
86                    SLOT_OBSERVATIONS_MAGIC,
87                    SLOT_OBSERVATIONS_VERSION,
88                    "slot observations",
89                ) {
90                    debug!(
91                        entries = observations.len(),
92                        "Loaded slot observation tracker"
93                    );
94                    Self {
95                        observations,
96                        skipped_this_cycle: Vec::new(),
97                        dirty: false,
98                    }
99                } else {
100                    warn!("Slot observations cache miss, starting fresh");
101                    Self::new()
102                }
103            }
104            Err(_) => {
105                debug!("No slot observations file found, starting fresh");
106                Self::new()
107            }
108        }
109    }
110
111    /// Persist observations to disk using the versioned binary format.
112    /// Called at end of cycle or on shutdown.
113    pub fn save(&mut self, path: &Path) -> Result<(), PersistenceError> {
114        if !self.dirty {
115            return Ok(());
116        }
117        if let Some(parent) = path.parent() {
118            std::fs::create_dir_all(parent)
119                .map_err(|err| PersistenceError::create_dir(parent, err))?;
120        }
121        let data = versioned::encode(
122            SLOT_OBSERVATIONS_MAGIC,
123            SLOT_OBSERVATIONS_VERSION,
124            &self.observations,
125            "slot observations",
126        )?;
127        std::fs::write(path, data).map_err(|err| PersistenceError::write(path, err))?;
128        self.dirty = false;
129        debug!(entries = self.observations.len(), "Saved slot observations");
130        Ok(())
131    }
132
133    /// Core decision: should we re-fetch this slot from RPC?
134    ///
135    /// Returns `true` if the slot should be purged and re-fetched.
136    /// Returns `false` if the cached value is likely still valid.
137    ///
138    /// `now` is the current clock value (block number or unix seconds) and
139    /// `params` carries the (clock-unit) thresholds — see
140    /// [`crate::freshness::FreshnessParams`].
141    ///
142    /// The heuristic is fully deterministic (no randomness): a never-observed slot
143    /// always refetches, as does one with fewer than
144    /// [`min_observations`](crate::freshness::FreshnessParams::min_observations);
145    /// once enough observations accrue, a never-changed slot is reused until the
146    /// [`max_reuse`](crate::freshness::FreshnessParams::max_reuse) window elapses,
147    /// while changing slots refetch once the probabilistic expected-change estimate
148    /// crosses [`staleness_threshold`](crate::freshness::FreshnessParams::staleness_threshold).
149    ///
150    /// # Examples
151    /// The deterministic threshold edges around a stable (never-changed) slot:
152    ///
153    /// ```
154    /// use alloy_primitives::{Address, U256};
155    /// use evm_fork_cache::cache::SlotObservationTracker;
156    /// use evm_fork_cache::freshness::FreshnessParams;
157    ///
158    /// let params = FreshnessParams::default();
159    /// let mut tracker = SlotObservationTracker::new();
160    /// let addr = Address::repeat_byte(0x01);
161    /// let slot = U256::from(0);
162    ///
163    /// // An unobserved slot must always be fetched.
164    /// assert!(tracker.should_refetch(addr, slot, 0, &params));
165    ///
166    /// // Record fewer than `min_observations` of the same value: still refetches
167    /// // because there is not enough data to trust the change frequency.
168    /// for now in 0..(params.min_observations - 1) {
169    ///     tracker.observe(addr, slot, U256::from(42), now as u64);
170    /// }
171    /// assert!(tracker.should_refetch(addr, slot, params.min_observations as u64, &params));
172    ///
173    /// // One more identical observation reaches `min_observations`; the slot has
174    /// // never changed, so within the reuse window it is now reused (no refetch).
175    /// let last = params.min_observations as u64 - 1;
176    /// tracker.observe(addr, slot, U256::from(42), last);
177    /// assert!(!tracker.should_refetch(addr, slot, last, &params));
178    /// ```
179    pub fn should_refetch(
180        &self,
181        addr: Address,
182        slot: U256,
183        now: u64,
184        params: &FreshnessParams,
185    ) -> bool {
186        let key = SlotKey {
187            address: addr,
188            slot,
189        };
190        let Some(obs) = self.observations.get(&key) else {
191            return true; // never observed → must fetch
192        };
193
194        // Always refetch if insufficient data to make predictions
195        if obs.observation_count < params.min_observations {
196            return true;
197        }
198
199        // Always refetch if last check was longer than the reuse window ago
200        if now.saturating_sub(obs.last_checked) > params.max_reuse {
201            return true;
202        }
203
204        // Never-changed slots: reuse up to the max-reuse window
205        if obs.change_count == 0 {
206            return false;
207        }
208
209        let change_rate = obs.change_count as f64 / obs.observation_count as f64;
210
211        // Always-changing slots: always refetch
212        if change_rate > params.always_refetch_rate {
213            return true;
214        }
215
216        // Probabilistic: estimate expected changes since last check
217        let units_elapsed = now.saturating_sub(obs.last_checked) as f64;
218        let cycle_interval = params.cycle_interval.max(1) as f64;
219        let cycles_elapsed = (units_elapsed / cycle_interval).max(1.0);
220        let expected_changes = change_rate * cycles_elapsed;
221        expected_changes > params.staleness_threshold
222    }
223
224    /// Record a fresh observation after re-fetch or injection.
225    ///
226    /// `now` is the current clock value (block number or unix seconds).
227    /// Returns `true` if the value changed from the last observation.
228    pub fn observe(&mut self, addr: Address, slot: U256, value: U256, now: u64) -> bool {
229        let key = SlotKey {
230            address: addr,
231            slot,
232        };
233        self.dirty = true;
234
235        match self.observations.get_mut(&key) {
236            Some(obs) => {
237                let changed = obs.last_value != value;
238                obs.observation_count += 1;
239                if changed {
240                    obs.change_count += 1;
241                    obs.last_changed = now;
242                    obs.last_value = value;
243                }
244                obs.last_checked = now;
245                changed
246            }
247            None => {
248                self.observations.insert(
249                    key,
250                    SlotObservation {
251                        last_value: value,
252                        observation_count: 1,
253                        change_count: 0, // first observation = baseline, not a "change"
254                        last_checked: now,
255                        last_changed: 0,
256                    },
257                );
258                false
259            }
260        }
261    }
262
263    /// Record that a slot was skipped (not purged) this cycle.
264    /// Used for revert fallback: if simulation fails, these slots need re-fetching.
265    pub fn record_skip(&mut self, addr: Address, slot: U256) {
266        self.skipped_this_cycle.push((addr, slot));
267    }
268
269    /// Take all slots skipped this cycle (for revert recovery).
270    /// Clears the internal list.
271    pub fn take_skipped(&mut self) -> Vec<(Address, U256)> {
272        std::mem::take(&mut self.skipped_this_cycle)
273    }
274
275    /// Reset all observations for a contract address.
276    /// Called after a simulation revert to force full refresh next cycle.
277    pub fn reset_contract(&mut self, addr: Address) {
278        self.observations.retain(|k, _| k.address != addr);
279        self.dirty = true;
280    }
281
282    /// Clear cycle-specific state. Call at the start of each cycle.
283    pub fn begin_cycle(&mut self) {
284        self.skipped_this_cycle.clear();
285    }
286
287    /// Number of tracked slot observations.
288    pub fn len(&self) -> usize {
289        self.observations.len()
290    }
291
292    /// Returns true if no slots are being tracked.
293    pub fn is_empty(&self) -> bool {
294        self.observations.is_empty()
295    }
296
297    /// Returns the last observed value for a slot, if any.
298    pub fn last_value(&self, addr: Address, slot: U256) -> Option<U256> {
299        let key = SlotKey {
300            address: addr,
301            slot,
302        };
303        self.observations.get(&key).map(|o| o.last_value)
304    }
305}
306
307impl Default for SlotObservationTracker {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn addr(n: u8) -> Address {
318        Address::new([n; 20])
319    }
320
321    /// Block-clock params with a 1-unit cycle so each `observe` advances exactly
322    /// one cycle — keeps the probabilistic arithmetic easy to reason about.
323    fn params() -> FreshnessParams {
324        FreshnessParams::default()
325    }
326
327    #[test]
328    fn test_unknown_slot_always_refetches() {
329        let tracker = SlotObservationTracker::new();
330        assert!(tracker.should_refetch(addr(1), U256::from(0), 100, &params()));
331    }
332
333    #[test]
334    fn test_insufficient_observations_refetches() {
335        let mut tracker = SlotObservationTracker::new();
336        let p = params();
337        let a = addr(1);
338        let slot = U256::from(4);
339        // Record fewer than `min_observations` observations.
340        for now in 0..(p.min_observations - 1) {
341            tracker.observe(a, slot, U256::from(42), now as u64);
342        }
343        assert!(tracker.should_refetch(a, slot, p.min_observations as u64, &p));
344    }
345
346    #[test]
347    fn test_never_changed_slot_skips_refetch() {
348        let mut tracker = SlotObservationTracker::new();
349        let p = params();
350        let a = addr(1);
351        let slot = U256::from(4);
352        let value = U256::from(42);
353        // Build up enough observations with the same value at consecutive ticks.
354        for now in 0..p.min_observations {
355            tracker.observe(a, slot, value, now as u64);
356        }
357        // Re-check immediately after the last observation (within the reuse window).
358        assert!(!tracker.should_refetch(a, slot, p.min_observations as u64 - 1, &p));
359    }
360
361    #[test]
362    fn test_never_changed_slot_refetches_past_max_reuse() {
363        let mut tracker = SlotObservationTracker::new();
364        let p = params();
365        let a = addr(1);
366        let slot = U256::from(4);
367        for now in 0..p.min_observations {
368            tracker.observe(a, slot, U256::from(42), now as u64);
369        }
370        // Far past the reuse window even a never-changed slot is rechecked.
371        let now = p.min_observations as u64 + p.max_reuse + 1;
372        assert!(tracker.should_refetch(a, slot, now, &p));
373    }
374
375    #[test]
376    fn test_always_changing_slot_refetches() {
377        let mut tracker = SlotObservationTracker::new();
378        let p = params();
379        let a = addr(1);
380        let slot = U256::from(4);
381        // Record observations, each with a different value, at consecutive ticks.
382        for now in 0..(p.min_observations + 1) {
383            tracker.observe(a, slot, U256::from(now), now as u64);
384        }
385        assert!(tracker.should_refetch(a, slot, p.min_observations as u64 + 1, &p));
386    }
387
388    #[test]
389    fn test_observe_returns_changed() {
390        let mut tracker = SlotObservationTracker::new();
391        let a = addr(1);
392        let slot = U256::from(0);
393        assert!(!tracker.observe(a, slot, U256::from(1), 0)); // first = baseline
394        assert!(!tracker.observe(a, slot, U256::from(1), 1)); // same
395        assert!(tracker.observe(a, slot, U256::from(2), 2)); // changed
396        assert!(!tracker.observe(a, slot, U256::from(2), 3)); // same again
397    }
398
399    #[test]
400    fn test_observe_records_change_clock() {
401        let mut tracker = SlotObservationTracker::new();
402        let a = addr(1);
403        let slot = U256::from(0);
404        tracker.observe(a, slot, U256::from(1), 10); // baseline at tick 10
405        tracker.observe(a, slot, U256::from(2), 25); // change at tick 25
406        let key = SlotKey { address: a, slot };
407        let obs = &tracker.observations[&key];
408        assert_eq!(obs.last_checked, 25);
409        assert_eq!(obs.last_changed, 25);
410        assert_eq!(obs.change_count, 1);
411        assert_eq!(obs.observation_count, 2);
412    }
413
414    #[test]
415    fn test_reset_contract_clears_observations() {
416        let mut tracker = SlotObservationTracker::new();
417        let p = params();
418        let a = addr(1);
419        for i in 0..p.min_observations {
420            tracker.observe(a, U256::from(i), U256::from(42), i as u64);
421        }
422        assert!(!tracker.is_empty());
423        tracker.reset_contract(a);
424        assert_eq!(tracker.len(), 0);
425        // After reset, should_refetch returns true
426        assert!(tracker.should_refetch(a, U256::from(0), 100, &p));
427    }
428
429    #[test]
430    fn test_skipped_slots_tracking() {
431        let mut tracker = SlotObservationTracker::new();
432        tracker.begin_cycle();
433        tracker.record_skip(addr(1), U256::from(0));
434        tracker.record_skip(addr(1), U256::from(4));
435        tracker.record_skip(addr(2), U256::from(8));
436
437        let skipped = tracker.take_skipped();
438        assert_eq!(skipped.len(), 3);
439        // After take, list is empty
440        assert!(tracker.take_skipped().is_empty());
441    }
442
443    #[test]
444    fn test_begin_cycle_clears_skipped() {
445        let mut tracker = SlotObservationTracker::new();
446        tracker.record_skip(addr(1), U256::from(0));
447        tracker.begin_cycle();
448        assert!(tracker.take_skipped().is_empty());
449    }
450
451    /// A unique, freshly-created temp dir keyed by pid so concurrent `cargo
452    /// test` processes never share (and never `remove_dir_all`) each other's
453    /// directory; each test passes a distinct `tag`. Returns the file path to
454    /// write within it.
455    fn temp_path(tag: &str) -> std::path::PathBuf {
456        let dir = std::env::temp_dir().join(format!(
457            "evm_fork_cache_slot_obs_{tag}_{}",
458            std::process::id()
459        ));
460        let _ = std::fs::remove_dir_all(&dir);
461        std::fs::create_dir_all(&dir).expect("create temp dir");
462        dir.join("observations.bin")
463    }
464
465    #[test]
466    fn test_save_load_round_trip() {
467        let path = temp_path("round_trip");
468
469        let mut tracker = SlotObservationTracker::new();
470        let a = addr(1);
471        tracker.observe(a, U256::from(0), U256::from(100), 0);
472        tracker.observe(a, U256::from(4), U256::from(200), 0);
473        tracker.save(&path).unwrap();
474        let data = std::fs::read(&path).expect("read saved observations");
475        assert!(
476            data.starts_with(b"EFC-SOBS"),
477            "slot observation files must carry a magic/version header"
478        );
479
480        let loaded = SlotObservationTracker::load(&path);
481        assert_eq!(loaded.len(), 2);
482        assert_eq!(loaded.last_value(a, U256::from(0)), Some(U256::from(100)));
483        assert_eq!(loaded.last_value(a, U256::from(4)), Some(U256::from(200)));
484    }
485
486    #[test]
487    fn legacy_raw_bincode_loads_as_default() {
488        let path = temp_path("legacy");
489
490        let a = addr(1);
491        let slot = U256::from(4);
492        let mut observations = HashMap::new();
493        observations.insert(
494            SlotKey { address: a, slot },
495            SlotObservation {
496                last_value: U256::from(42),
497                observation_count: 3,
498                change_count: 0,
499                last_checked: 2,
500                last_changed: 0,
501            },
502        );
503        let legacy = bincode::serialize(&observations).expect("serialize legacy observations");
504        std::fs::write(&path, legacy).expect("write legacy observations");
505
506        let loaded = SlotObservationTracker::load(&path);
507        assert!(
508            loaded.is_empty(),
509            "legacy raw bincode must be treated as a cache miss"
510        );
511    }
512
513    #[test]
514    fn test_last_value() {
515        let mut tracker = SlotObservationTracker::new();
516        let a = addr(1);
517        assert_eq!(tracker.last_value(a, U256::from(0)), None);
518        tracker.observe(a, U256::from(0), U256::from(42), 0);
519        assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(42)));
520        tracker.observe(a, U256::from(0), U256::from(99), 1);
521        assert_eq!(tracker.last_value(a, U256::from(0)), Some(U256::from(99)));
522    }
523
524    // --- T7: probabilistic should_refetch coverage -------------------------
525
526    /// Insert a fully-specified observation so the probabilistic branch can be
527    /// tested with an exact `change_rate = change_count / observation_count` and
528    /// a known `last_checked`, without replaying an `observe` sequence.
529    fn seed_obs(
530        tracker: &mut SlotObservationTracker,
531        a: Address,
532        slot: U256,
533        observation_count: u32,
534        change_count: u32,
535        last_checked: u64,
536    ) {
537        tracker.observations.insert(
538            SlotKey { address: a, slot },
539            SlotObservation {
540                last_value: U256::from(1),
541                observation_count,
542                change_count,
543                last_checked,
544                last_changed: last_checked,
545            },
546        );
547    }
548
549    #[test]
550    fn test_probabilistic_refetches_at_now_equals_last_checked() {
551        // change_rate = 3/20 = 0.15. At now == last_checked, units_elapsed = 0 so
552        // cycles_elapsed clamps to 1.0; expected = 0.15 > 0.05 → refetch.
553        let mut tracker = SlotObservationTracker::new();
554        let p = params();
555        let a = addr(1);
556        let slot = U256::from(7);
557        seed_obs(&mut tracker, a, slot, 20, 3, 100);
558        // Sanity: this is the probabilistic branch (between never and always).
559        assert!((3.0_f64 / 20.0) < p.always_refetch_rate);
560        assert!(tracker.should_refetch(a, slot, 100, &p));
561    }
562
563    #[test]
564    fn test_probabilistic_reuses_then_refetches_after_elapsed() {
565        // change_rate = 1/100 = 0.01. At now == last_checked, expected = 0.01 <
566        // 0.05 → reuse. After 10 cycles elapsed (cycle_interval = 1), expected =
567        // 0.01 * 10 = 0.10 > 0.05 → refetch. Stays within max_reuse (300).
568        let mut tracker = SlotObservationTracker::new();
569        let p = params();
570        let a = addr(1);
571        let slot = U256::from(7);
572        seed_obs(&mut tracker, a, slot, 100, 1, 100);
573
574        // Immediately: reused.
575        assert!(!tracker.should_refetch(a, slot, 100, &p));
576        // After a few units: still under threshold (0.01 * 4 = 0.04 < 0.05).
577        assert!(!tracker.should_refetch(a, slot, 104, &p));
578        // After enough units: over threshold (0.01 * 10 = 0.10 > 0.05).
579        assert!(tracker.should_refetch(a, slot, 110, &p));
580    }
581
582    #[test]
583    fn test_probabilistic_cycle_interval_scaling() {
584        // change_rate = 1/100 = 0.01, cycle_interval = 10. cycles_elapsed =
585        // units_elapsed / 10, so it takes 10x more elapsed units than a unit
586        // cycle to cross the 0.05 threshold.
587        let mut tracker = SlotObservationTracker::new();
588        let p = FreshnessParams {
589            cycle_interval: 10,
590            ..FreshnessParams::default()
591        };
592        let a = addr(1);
593        let slot = U256::from(7);
594        seed_obs(&mut tracker, a, slot, 100, 1, 100);
595
596        // 60 units elapsed → 6 cycles → expected = 0.06 > 0.05 → refetch.
597        assert!(tracker.should_refetch(a, slot, 160, &p));
598        // 40 units elapsed → 4 cycles → expected = 0.04 < 0.05 → reuse. (Under a
599        // unit cycle_interval this same 40-unit gap would be 40 cycles and would
600        // refetch — proving the cycle_interval scaling is applied.)
601        assert!(!tracker.should_refetch(a, slot, 140, &p));
602        let unit = FreshnessParams::default();
603        assert!(
604            tracker.should_refetch(a, slot, 140, &unit),
605            "with cycle_interval = 1 the same elapsed gap refetches"
606        );
607    }
608}