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