use std::{
fmt, fs,
io::Write as _,
path::{Path, PathBuf},
};
use alloy_provider::layers::SharedCache;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use super::transport::TransportCache;
use crate::common::{EvmeError, Result};
pub struct RpcCacheStore {
inner: Option<RpcCacheStoreInner>,
}
enum RpcCacheStoreInner {
ProviderCache { cache: SharedCache, path: PathBuf },
FixtureCapture {
cache: TransportCache,
path: PathBuf,
chain_id: u64,
external_env: Option<ExternalEnvSnapshot>,
},
}
impl RpcCacheStore {
pub(super) fn new(cache: SharedCache, cache_path: PathBuf) -> Self {
Self { inner: Some(RpcCacheStoreInner::ProviderCache { cache, path: cache_path }) }
}
pub(super) fn new_envelope(cache: TransportCache, path: PathBuf, chain_id: u64) -> Self {
Self {
inner: Some(RpcCacheStoreInner::FixtureCapture {
cache,
path,
chain_id,
external_env: None,
}),
}
}
pub fn set_external_env(&mut self, ext: ExternalEnvSnapshot) {
if let Some(RpcCacheStoreInner::FixtureCapture { external_env, .. }) = &mut self.inner {
*external_env = Some(ext);
}
}
pub(crate) fn noop() -> Self {
Self { inner: None }
}
#[cfg(any(test, feature = "test-utils"))]
pub fn is_noop(&self) -> bool {
self.inner.is_none()
}
#[cfg(any(test, feature = "test-utils"))]
pub fn cache(&self) -> Option<&SharedCache> {
match &self.inner {
Some(RpcCacheStoreInner::ProviderCache { cache, .. }) => Some(cache),
_ => None,
}
}
#[cfg(any(test, feature = "test-utils"))]
pub fn cache_path(&self) -> Option<&Path> {
match &self.inner {
Some(
RpcCacheStoreInner::ProviderCache { path, .. } |
RpcCacheStoreInner::FixtureCapture { path, .. },
) => Some(path.as_path()),
None => None,
}
}
pub fn persist(self) -> Result<()> {
let Some(inner) = self.inner else { return Ok(()) };
match inner {
RpcCacheStoreInner::ProviderCache { cache, path } => {
match save_cache_atomic(&cache, &path) {
Ok(()) => info!(path = %path.display(), "Persisted RPC cache"),
Err(err) => warn!(
path = %path.display(),
error = %err,
"Failed to save RPC cache (continuing)",
),
}
Ok(())
}
RpcCacheStoreInner::FixtureCapture { cache, path, chain_id, external_env } => {
let entry_count = cache.len();
CacheFileEnvelope::new(&cache, chain_id, external_env.as_ref()).save(&path)?;
info!(
path = %path.display(),
entries = entry_count,
"Persisted RPC cache envelope",
);
Ok(())
}
}
}
}
impl fmt::Debug for RpcCacheStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.inner {
Some(
RpcCacheStoreInner::ProviderCache { path, .. } |
RpcCacheStoreInner::FixtureCapture { path, .. },
) => f.debug_struct("RpcCacheStore").field("path", path).finish_non_exhaustive(),
None => f.debug_struct("RpcCacheStore").field("inner", &Option::<()>::None).finish(),
}
}
}
fn save_cache_atomic(cache: &SharedCache, target: &Path) -> std::io::Result<()> {
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
std::io::Error::other(format!("failed to create temp file in {}: {e}", dir.display()))
})?;
let tmp_path = tmp.path().to_path_buf();
cache.save_cache(tmp_path).map_err(|e| {
std::io::Error::other(format!("failed to save cache for {}: {e}", target.display()))
})?;
tmp.persist(target).map_err(|e| {
std::io::Error::other(format!(
"failed to rename temp file into {}: {}",
target.display(),
e.error,
))
})?;
Ok(())
}
const ENVELOPE_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct CacheFileEnvelope {
version: u32,
pub(super) chain_id: u64,
pub(super) cache: serde_json::Value,
#[serde(default)]
pub(super) external_env: Option<ExternalEnvSnapshot>,
}
impl CacheFileEnvelope {
pub(super) fn new(
cache: &TransportCache,
chain_id: u64,
external_env: Option<&ExternalEnvSnapshot>,
) -> Self {
Self {
version: ENVELOPE_VERSION,
chain_id,
cache: cache.to_value(),
external_env: external_env.cloned(),
}
}
pub(super) fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path).map_err(|e| {
EvmeError::FixtureError(format!(
"Failed to read RPC cache file {}: {e}",
path.display()
))
})?;
let envelope: Self = serde_json::from_str(&content).map_err(|e| {
EvmeError::FixtureError(format!(
"Failed to parse RPC cache file {}: {e}",
path.display()
))
})?;
if envelope.version != ENVELOPE_VERSION {
return Err(EvmeError::FixtureError(format!(
"Unsupported cache file version {} in '{}'; expected {ENVELOPE_VERSION}",
envelope.version,
path.display(),
)));
}
Ok(envelope)
}
pub(super) fn save(&self, path: &Path) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir).map_err(|e| {
EvmeError::FixtureError(format!(
"Failed to create cache file directory {}: {e}",
dir.display()
))
})?;
let serialized = serde_json::to_string_pretty(self).map_err(|e| {
EvmeError::FixtureError(format!(
"Failed to serialize envelope for {}: {e}",
path.display()
))
})?;
let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
EvmeError::FixtureError(format!("Failed to create temp file in {}: {e}", dir.display()))
})?;
tmp.write_all(serialized.as_bytes())
.map_err(|e| EvmeError::FixtureError(format!("Failed to write envelope: {e}")))?;
tmp.persist(path).map_err(|e| {
EvmeError::FixtureError(format!(
"Failed to persist envelope to {}: {e}",
path.display()
))
})?;
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExternalEnvSnapshot {
#[serde(default)]
pub bucket_capacities: Vec<(u32, u64)>,
}
#[cfg(test)]
mod tests {
use alloy_primitives::keccak256;
use super::*;
#[test]
fn test_envelope_roundtrip_preserves_cache() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test-cache.json");
let cache = TransportCache::new();
cache
.merge(&serde_json::json!([
{
"key": keccak256("eth_blockNumber"),
"value": r#"{"id":0,"jsonrpc":"2.0","result":"0x1"}"#,
}
]))
.expect("seed cache");
let ext = ExternalEnvSnapshot { bucket_capacities: vec![(1, 100), (2, 200)] };
CacheFileEnvelope::new(&cache, 4326, Some(&ext)).save(&path).expect("save envelope");
let envelope = CacheFileEnvelope::load(&path).expect("load envelope");
assert_eq!(envelope.version, 1);
assert_eq!(envelope.chain_id, 4326);
assert!(envelope.cache.is_array(), "cache should be a JSON array");
let loaded = TransportCache::from_value(&envelope.cache).expect("from_value");
assert_eq!(loaded.len(), 1);
let env = envelope.external_env.expect("external_env should round-trip");
assert_eq!(env.bucket_capacities, vec![(1, 100), (2, 200)]);
}
#[test]
fn test_envelope_rejects_unknown_version() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path().join("v2.json");
fs::write(&p, r#"{"version":2,"chain_id":1,"cache":[]}"#).unwrap();
let err = CacheFileEnvelope::load(&p).expect_err("version 2 should be rejected");
let msg = format!("{err}");
assert!(msg.contains("Unsupported"), "error should mention Unsupported: {msg}");
}
#[test]
fn test_envelope_rejects_missing_fields() {
let dir = tempfile::tempdir().expect("tempdir");
let p1 = dir.path().join("no-chain.json");
fs::write(&p1, r#"{"version":1,"cache":{}}"#).unwrap();
let err = CacheFileEnvelope::load(&p1).expect_err("missing chain_id");
let msg = format!("{err}");
assert!(msg.contains("parse"), "error should mention parse: {msg}");
let p2 = dir.path().join("no-cache.json");
fs::write(&p2, r#"{"version":1,"chain_id":1}"#).unwrap();
let err = CacheFileEnvelope::load(&p2).expect_err("missing cache");
let msg = format!("{err}");
assert!(msg.contains("parse"), "error should mention parse: {msg}");
let p3 = dir.path().join("no-version.json");
fs::write(&p3, r#"{"chain_id":1,"cache":{}}"#).unwrap();
let err = CacheFileEnvelope::load(&p3).expect_err("missing version");
let msg = format!("{err}");
assert!(msg.contains("parse"), "error should mention parse: {msg}");
}
}