mega_evme/common/provider/
cache_store.rs1use std::{
13 fmt, fs,
14 io::Write as _,
15 path::{Path, PathBuf},
16};
17
18use alloy_provider::layers::SharedCache;
19use serde::{Deserialize, Serialize};
20use tracing::{info, warn};
21
22use super::transport::TransportCache;
23use crate::common::{EvmeError, Result};
24
25pub struct RpcCacheStore {
40 inner: Option<RpcCacheStoreInner>,
42}
43
44enum RpcCacheStoreInner {
50 ProviderCache { cache: SharedCache, path: PathBuf },
52 FixtureCapture {
54 cache: TransportCache,
55 path: PathBuf,
56 chain_id: u64,
57 external_env: Option<ExternalEnvSnapshot>,
62 },
63}
64
65impl RpcCacheStore {
66 pub(super) fn new(cache: SharedCache, cache_path: PathBuf) -> Self {
71 Self { inner: Some(RpcCacheStoreInner::ProviderCache { cache, path: cache_path }) }
72 }
73
74 pub(super) fn new_envelope(cache: TransportCache, path: PathBuf, chain_id: u64) -> Self {
80 Self {
81 inner: Some(RpcCacheStoreInner::FixtureCapture {
82 cache,
83 path,
84 chain_id,
85 external_env: None,
86 }),
87 }
88 }
89
90 pub fn set_external_env(&mut self, ext: ExternalEnvSnapshot) {
96 if let Some(RpcCacheStoreInner::FixtureCapture { external_env, .. }) = &mut self.inner {
97 *external_env = Some(ext);
98 }
99 }
100
101 pub(crate) fn noop() -> Self {
105 Self { inner: None }
106 }
107
108 #[cfg(any(test, feature = "test-utils"))]
117 pub fn is_noop(&self) -> bool {
118 self.inner.is_none()
119 }
120
121 #[cfg(any(test, feature = "test-utils"))]
123 pub fn cache(&self) -> Option<&SharedCache> {
124 match &self.inner {
125 Some(RpcCacheStoreInner::ProviderCache { cache, .. }) => Some(cache),
126 _ => None,
127 }
128 }
129
130 #[cfg(any(test, feature = "test-utils"))]
132 pub fn cache_path(&self) -> Option<&Path> {
133 match &self.inner {
134 Some(
135 RpcCacheStoreInner::ProviderCache { path, .. } |
136 RpcCacheStoreInner::FixtureCapture { path, .. },
137 ) => Some(path.as_path()),
138 None => None,
139 }
140 }
141
142 pub fn persist(self) -> Result<()> {
152 let Some(inner) = self.inner else { return Ok(()) };
153 match inner {
154 RpcCacheStoreInner::ProviderCache { cache, path } => {
155 match save_cache_atomic(&cache, &path) {
156 Ok(()) => info!(path = %path.display(), "Persisted RPC cache"),
157 Err(err) => warn!(
158 path = %path.display(),
159 error = %err,
160 "Failed to save RPC cache (continuing)",
161 ),
162 }
163 Ok(())
164 }
165 RpcCacheStoreInner::FixtureCapture { cache, path, chain_id, external_env } => {
166 let entry_count = cache.len();
167 CacheFileEnvelope::new(&cache, chain_id, external_env.as_ref()).save(&path)?;
168 info!(
169 path = %path.display(),
170 entries = entry_count,
171 "Persisted RPC cache envelope",
172 );
173 Ok(())
174 }
175 }
176 }
177}
178
179impl fmt::Debug for RpcCacheStore {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 match &self.inner {
183 Some(
184 RpcCacheStoreInner::ProviderCache { path, .. } |
185 RpcCacheStoreInner::FixtureCapture { path, .. },
186 ) => f.debug_struct("RpcCacheStore").field("path", path).finish_non_exhaustive(),
187 None => f.debug_struct("RpcCacheStore").field("inner", &Option::<()>::None).finish(),
188 }
189 }
190}
191
192fn save_cache_atomic(cache: &SharedCache, target: &Path) -> std::io::Result<()> {
197 let dir = target.parent().unwrap_or_else(|| Path::new("."));
198 let tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
199 std::io::Error::other(format!("failed to create temp file in {}: {e}", dir.display()))
200 })?;
201 let tmp_path = tmp.path().to_path_buf();
202
203 cache.save_cache(tmp_path).map_err(|e| {
205 std::io::Error::other(format!("failed to save cache for {}: {e}", target.display()))
206 })?;
207
208 tmp.persist(target).map_err(|e| {
210 std::io::Error::other(format!(
211 "failed to rename temp file into {}: {}",
212 target.display(),
213 e.error,
214 ))
215 })?;
216 Ok(())
217}
218
219const ENVELOPE_VERSION: u32 = 1;
221
222#[derive(Debug, Serialize, Deserialize)]
226pub(super) struct CacheFileEnvelope {
227 version: u32,
229 pub(super) chain_id: u64,
231 pub(super) cache: serde_json::Value,
233 #[serde(default)]
235 pub(super) external_env: Option<ExternalEnvSnapshot>,
236}
237
238impl CacheFileEnvelope {
239 pub(super) fn new(
241 cache: &TransportCache,
242 chain_id: u64,
243 external_env: Option<&ExternalEnvSnapshot>,
244 ) -> Self {
245 Self {
246 version: ENVELOPE_VERSION,
247 chain_id,
248 cache: cache.to_value(),
249 external_env: external_env.cloned(),
250 }
251 }
252
253 pub(super) fn load(path: &Path) -> Result<Self> {
255 let content = fs::read_to_string(path).map_err(|e| {
256 EvmeError::FixtureError(format!(
257 "Failed to read RPC cache file {}: {e}",
258 path.display()
259 ))
260 })?;
261 let envelope: Self = serde_json::from_str(&content).map_err(|e| {
262 EvmeError::FixtureError(format!(
263 "Failed to parse RPC cache file {}: {e}",
264 path.display()
265 ))
266 })?;
267 if envelope.version != ENVELOPE_VERSION {
268 return Err(EvmeError::FixtureError(format!(
269 "Unsupported cache file version {} in '{}'; expected {ENVELOPE_VERSION}",
270 envelope.version,
271 path.display(),
272 )));
273 }
274 Ok(envelope)
275 }
276
277 pub(super) fn save(&self, path: &Path) -> Result<()> {
279 let dir = path.parent().unwrap_or_else(|| Path::new("."));
280 fs::create_dir_all(dir).map_err(|e| {
281 EvmeError::FixtureError(format!(
282 "Failed to create cache file directory {}: {e}",
283 dir.display()
284 ))
285 })?;
286
287 let serialized = serde_json::to_string_pretty(self).map_err(|e| {
288 EvmeError::FixtureError(format!(
289 "Failed to serialize envelope for {}: {e}",
290 path.display()
291 ))
292 })?;
293
294 let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
295 EvmeError::FixtureError(format!("Failed to create temp file in {}: {e}", dir.display()))
296 })?;
297 tmp.write_all(serialized.as_bytes())
298 .map_err(|e| EvmeError::FixtureError(format!("Failed to write envelope: {e}")))?;
299 tmp.persist(path).map_err(|e| {
300 EvmeError::FixtureError(format!(
301 "Failed to persist envelope to {}: {e}",
302 path.display()
303 ))
304 })?;
305
306 Ok(())
307 }
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
312pub struct ExternalEnvSnapshot {
313 #[serde(default)]
315 pub bucket_capacities: Vec<(u32, u64)>,
316}
317
318#[cfg(test)]
319mod tests {
320 use alloy_primitives::keccak256;
321
322 use super::*;
323
324 #[test]
327 fn test_envelope_roundtrip_preserves_cache() {
328 let dir = tempfile::tempdir().expect("tempdir");
329 let path = dir.path().join("test-cache.json");
330
331 let cache = TransportCache::new();
332 cache
333 .merge(&serde_json::json!([
334 {
335 "key": keccak256("eth_blockNumber"),
336 "value": r#"{"id":0,"jsonrpc":"2.0","result":"0x1"}"#,
337 }
338 ]))
339 .expect("seed cache");
340
341 let ext = ExternalEnvSnapshot { bucket_capacities: vec![(1, 100), (2, 200)] };
342 CacheFileEnvelope::new(&cache, 4326, Some(&ext)).save(&path).expect("save envelope");
343
344 let envelope = CacheFileEnvelope::load(&path).expect("load envelope");
345 assert_eq!(envelope.version, 1);
346 assert_eq!(envelope.chain_id, 4326);
347 assert!(envelope.cache.is_array(), "cache should be a JSON array");
348
349 let loaded = TransportCache::from_value(&envelope.cache).expect("from_value");
351 assert_eq!(loaded.len(), 1);
352
353 let env = envelope.external_env.expect("external_env should round-trip");
354 assert_eq!(env.bucket_capacities, vec![(1, 100), (2, 200)]);
355 }
356
357 #[test]
360 fn test_envelope_rejects_unknown_version() {
361 let dir = tempfile::tempdir().expect("tempdir");
362 let p = dir.path().join("v2.json");
363 fs::write(&p, r#"{"version":2,"chain_id":1,"cache":[]}"#).unwrap();
364 let err = CacheFileEnvelope::load(&p).expect_err("version 2 should be rejected");
365 let msg = format!("{err}");
366 assert!(msg.contains("Unsupported"), "error should mention Unsupported: {msg}");
367 }
368
369 #[test]
371 fn test_envelope_rejects_missing_fields() {
372 let dir = tempfile::tempdir().expect("tempdir");
373
374 let p1 = dir.path().join("no-chain.json");
376 fs::write(&p1, r#"{"version":1,"cache":{}}"#).unwrap();
377 let err = CacheFileEnvelope::load(&p1).expect_err("missing chain_id");
378 let msg = format!("{err}");
379 assert!(msg.contains("parse"), "error should mention parse: {msg}");
380
381 let p2 = dir.path().join("no-cache.json");
383 fs::write(&p2, r#"{"version":1,"chain_id":1}"#).unwrap();
384 let err = CacheFileEnvelope::load(&p2).expect_err("missing cache");
385 let msg = format!("{err}");
386 assert!(msg.contains("parse"), "error should mention parse: {msg}");
387
388 let p3 = dir.path().join("no-version.json");
390 fs::write(&p3, r#"{"chain_id":1,"cache":{}}"#).unwrap();
391 let err = CacheFileEnvelope::load(&p3).expect_err("missing version");
392 let msg = format!("{err}");
393 assert!(msg.contains("parse"), "error should mention parse: {msg}");
394 }
395}