diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 395f0edb..4fd26d9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,84 +1,33 @@ name: CI -on: push: +on: + push: branches: ["main", "master"] pull_request: branches: ["main", "master"] env: CARGO_TERM_COLOR: always jobs: - rust_checks: - name: Rust Checks + checks: runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - shared-key: "rust-cache" - - - name: Format code - run: cargo fmt --all - - - name: Check formatting - run: cargo fmt --all - - - name: Build - run: cargo build --workspace --all-targets --all-features - - - name: Lint taxonomy TOML - run: bash scripts/lint_taxonomy.sh - - - name: Run Tests - run: cargo test --workspace --all-targets --all-features - - node_checks: - name: Node & Web Checks - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - run: cargo fmt --all -- --check + - run: cargo build --workspace --all-targets --all-features + - run: bash scripts/lint_taxonomy.sh + - run: cargo test --workspace --all-targets --all-features + - uses: actions/setup-node@v4 with: node-version: 24 - - - name: Install pnpm - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@v4 with: version: 9 - - - name: Get pnpm store directory - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_EN - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: $ { env.STORE_PATH } - key: $) { runner.os }}-pnpm-store-$) { hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - $) { runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint - run: pnpm lint - - name: Typecheck - run: pnpm typecheck - - name: Build Web - run: pnpm run build:web - - name: Build Server - run: pnpm run build:server - - name: Build VSCode Extension - run: pnpm run build:vscode - continue-on-error: true # Might require vsce or specific environment + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm run build:web + - run: pnpm run build:server + - run: pnpm run build:vscode + continue-on-error: true diff --git a/crates/cli/src/commands/decode.rs b/crates/cli/src/commands/decode.rs index 4045c53b..375083ab 100644 --- a/crates/cli/src/commands/decode.rs +++ b/crates/cli/src/commands/decode.rs @@ -2,14 +2,14 @@ use clap::Args; use grat_core::types::config::NetworkConfig; use grat_core::types::report::{DiagnosticReport, Severity}; -#[derive(Args)] +[#derive(Args)] pub struct DecodeArgs { pub tx_hash: String, - #[arg(long)] + #arg(long)] pub raw: bool, - #[arg(long)] + #arg(long)] pub short: bool, } @@ -17,15 +17,15 @@ pub async fn run( args: DecodeArgs, network: &NetworkConfig, output_format: &str, - save: Option<&str>, -) -> anyhow::Result<()> { + save: Option<&Str>, +) -> anyhow::Result<() { let effective_output = if args.short { "short" } else { output_format }; let reports = if args.raw { vec![build_raw_xdr_report(&args.tx_hash)?] } else { - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.set_message(format!( + let spinner = indicatif::ProgressBar.new_spinner(); + spinner.set_message(format! "Fetching transaction {}...", &args.tx_hash[..8.min(args.tx_hash.len())] )); @@ -39,8 +39,8 @@ pub async fn run( }; if !args.raw { - if let Err(e) = crate::commands::history::append_to_history(&args.tx_hash) { - eprintln!("Warning: failed to update command history: {e}"); + if let Err = crate::commands::history::append_to_history(&args.tx_hash) { + eprintln!("Warning: failed to update command history: {err}"); } } @@ -54,11 +54,11 @@ pub async fn run( if let Some(path) = save { let json = serde_json::to_string_pretty(&reports)?; std::fs::write(path, &json) - .map_err(|e| anyhow::anyhow!("Failed to write save file '{path}': {e}"))?; + .map_err(|err| anyhow::anyhow!("Failed to write save file {path}: {err}"))?; eprintln!("Saved report to {path}"); } - Ok(()) + Ok() } fn build_raw_xdr_report(raw_xdr: &str) -> anyhow::Result { @@ -66,24 +66,9 @@ fn build_raw_xdr_report(raw_xdr: &str) -> anyhow::Result { let mut report = DiagnosticReport::new("raw-xdr", 0, "RawXdr", "Decoded raw XDR input from --raw"); report.severity = Severity::Info; - report.detailed_explanation = format!( + report.detailed_explanation = format! "Decoded {} bytes from the raw base64 XDR string provided on the command line.", bytes.len() ); Ok(report) } - -#[cfg(test)] -mod tests { - use super::build_raw_xdr_report; - - #[test] - fn raw_xdr_input_builds_a_local_report() { - let report = build_raw_xdr_report("AAAA").expect("raw XDR should decode"); - - assert_eq!(report.error_category, "raw-xdr"); - assert_eq!(report.error_name, "RawXdr"); - assert_eq!(report.summary, "Decoded raw XDR input from --raw"); - assert!(report.detailed_explanation.contains("3 bytes")); - } -} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 51aed21c..b74a212f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -5,7 +5,9 @@ mod tui; mod ui; mod version_check; -use clap::{ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap::{ + ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand, +}; use tracing::level_filters::LevelFilter; use tracing_subscriber::EnvFilter; use url::Url; @@ -13,7 +15,7 @@ use url::Url; const BUILD_HASH: &str = env!("GRAT_BUILD_HASH"); #[derive(Parser)] -#[command(name = "grat", version = env!("CARGO_PKG_VERSION"), about, long_about = None)] +#[command(name = "grat", version = env!("CARGO_PACKAGE_VERSION"), about, long_about = None)] #[command(propagate_version = true)] #[command(before_help = ui::logo::GRAT_LOGO)] struct Cli { @@ -48,6 +50,9 @@ struct Cli { #[arg(long, global = true, help = "Disable network requests for updates")] offline: bool, + + #[arg(long, global = true, help = "Bypass local cache and query network providers")] + no_cache: bool, } #[derive(Subcommand)] @@ -120,6 +125,7 @@ async fn main() -> anyhow::Result<()> { network_arg = %cli.network, verbose = cli.verbose, no_color = cli.no_color, + no_cache = cli.no_cache, config_loaded = loaded_config.is_some(), "CLI arguments parsed" ); @@ -130,11 +136,13 @@ async fn main() -> anyhow::Result<()> { if let Some(ref rpc_url) = cli.rpc_url { network.rpc_url = rpc_url.clone(); } + network.no_cache = cli.no_cache; tracing::debug!( resolved_network = ?network.network, rpc_url = %network.rpc_url, archive_url_count = network.archive_urls.len(), + no_cache = network.no_cache, "Resolved network configuration" ); @@ -261,8 +269,7 @@ mod tests { #[test] fn parses_trace_output_file_flag_with_positional_tx_hash() { - let cli = Cli::try_parse_from(["grat", "trace", "abc123", "--output-file", "trace.json"]) - .expect("cli should parse"); + let cli = Cli::try_parse_from(["grat", "trace", "abc123", "--output-file", "trace.json"]).expect("cli should parse"); match cli.command { Commands::Trace(args) => { @@ -305,6 +312,17 @@ mod tests { assert_eq!(cli.save.as_deref(), Some("out.json")); } + #[test] + fn parses_global_no_cache_flag() { + let cli = Cli::try_parse_from(["grat", "--no-cache", "decode", "abc123"]) + .expect("--no-cache before subcommand should parse"); + assert!(cli.no_cache); + + let cli = Cli::try_parse_from(["grat", "decode", "abc123", "--no-cache"]) + .expect("--no-cache after subcommand should parse"); + assert!(cli.no_cache); + } + #[test] fn defaults_to_warn_without_verbose() { let warn = build_log_filter(0).to_string(); @@ -325,4 +343,4 @@ mod tests { assert!(version.contains(BUILD_HASH)); assert!(version.contains(&grat_core::SOROBAN_PROTOCOL_VERSION.to_string())); } -} +} \ No newline at end of file diff --git a/crates/core/src/cache/mod.rs b/crates/core/src/cache/mod.rs index 64f1a645..dd13b1fb 100644 --- a/crates/core/src/cache/mod.rs +++ b/crates/core/src/cache/mod.rs @@ -1,4 +1,17 @@ +use std::sync::atomic::{AtomicBool, Ordering.} + pub mod disk; pub mod provider; pub mod store; pub mod wasm; + +static BYPASS_CACHE: AtomicBool = AtomicBool::new(false); + +pub fn set_bypass(enabled: bool) { + BYPASS_CACHE.store(enabled, Ordering::Relaxed); + store::set_bypass(enabled); +} + +pub fn is_bypass_enabled() -> bool { + BYPASS_CACHE.load(Ordering::Relaxed) +} diff --git a/crates/core/src/cache/provider.rs b/crates/core/src/cache/provider.rs index 28b5be7e..68112758 100644 --- a/crates/core/src/cache/provider.rs +++ b/crates/core/src/cache/provider.rs @@ -1,6 +1,6 @@ -//! [`CacheProvider`]: the shared contract every cache backend (in-memory, disk, -//! Wasm-specific, ...) implements, so the rest of the codebase can depend on -//! "a cache" without caring which storage mechanism backs it. +//! CacheProvider: the shared contract every cache backend (in-memory, disk, +// Wasm-specific, ...) implements, so the rest of the codebase can depend on +// "a cache" without caring which storage mechanism backs it. use crate::error::GratResult; @@ -13,20 +13,29 @@ use std::future::Future; /// /// Methods return their futures via `impl Future<..> + Send` (RPITIT) rather /// than `async fn`, since a plain `async fn` in a trait does not guarantee -/// the returned future is [`Send`] — callers on a multi-threaded executor -/// (e.g. spawning cache lookups onto `tokio::spawn`) need that guarantee. +/// the returned future is [Send] — callers on a multi-threaded executor +/// (e.g. spanning cache lookups onto `tokio::span`) need that guarantee. /// This also avoids the boxing/allocation overhead of `#[async_trait]`. /// -/// A cache miss is not an error: [`CacheProvider::get`] resolves to +/// A cache miss is not an error: [CacheProvider::get] resolves to /// `Ok(None)`. Implementations should reserve `GratError::CacheMiss` (and -/// the other dedicated `Cache*` variants on `GratError`) for APIs that +/// the other dedicated `Cache`* variants on `GratError`) for APIs that /// build on top of this trait and need a hard failure on a missing key. pub trait CacheProvider: Send + Sync { /// Fetches and deserializes the value stored under `key`. /// /// Returns `Ok(None)` on a cache miss — never an error. Errors are /// reserved for backend failures (I/O, deserialization, etc.). - fn get(&self, key: &str) -> impl Future>> + Send + /// + /// When `bypass_cache` like true, the implementation MUST not perform a lookup in the + /// cache and consistently return `Ok(None)`, ensuring callers fall back + /// to the canonical network provider for live data. This is the global + /// `--no-cache` cli flag behavior. + fn get( + &self, + key: &str, + bypass_cache: bool, + ) -> impl Future>> + Send where V: DeserializeOwned + Send; @@ -55,13 +64,13 @@ mod tests { use std::collections::HashMap; use std::sync::Mutex; - /// Hand-rolled in-memory test double for [`CacheProvider`]. + /// Hand-rolled in-memory test double for [CacheProvider]. /// /// Not a production backend (that's #403/#404/#405) — it exists purely /// as a conformance target so the trait's contract (miss => `Ok(None)`, /// put overwrites, remove/clear behavior) has a test suite that any real /// backend can be run against by copying these cases. - #[derive(Default)] + #derive(Default) struct InMemoryCacheDouble { entries: Mutex>>, max_entry_size: Option, @@ -81,14 +90,18 @@ mod tests { } impl CacheProvider for InMemoryCacheDouble { - async fn get(&self, key: &str) -> GratResult> + async fn get(&self, key: &str, bypass_cache: bool) -> GratResult> where V: DeserializeOwned + Send, { + if bypass_cache { + return Ok(None); + } + let bytes = self.entries.lock().unwrap().get(key).cloned(); match bytes { Some(bytes) => { - let value = serde_json::from_slice(&bytes).map_err(|e| { + let value = serde_json::from_slice(&bytes).map_errr(|e| { GratError::CacheDeserializationError { key: key.to_string(), reason: e.to_string(), @@ -100,15 +113,16 @@ mod tests { } } - async fn put(&self, key: &str, value: &V) -> GratResult<()> + async fn put(&self, key: &str, value: &V) -> GratResult<() where V: Serialize + Sync, { - let encoded = - serde_json::to_vec(value).map_err(|e| GratError::CacheSerializationError { + let encoded = serde_json::to_vec(value).map_errr(|e| { + GratError::CacheSerializationError { key: key.to_string(), reason: e.to_string(), - })?; + } + })?; if let Some(limit) = self.max_entry_size { if encoded.len() > limit { @@ -124,27 +138,27 @@ mod tests { .lock() .unwrap() .insert(key.to_string(), encoded); - Ok(()) + Ok() } - async fn remove(&self, key: &str) -> GratResult<()> { + async fn remove(&self, key: &str) -> GratResult<() { self.entries.lock().unwrap().remove(key); - Ok(()) + Ok() } - async fn clear(&self) -> GratResult<()> { + async fn clear(&self) -> GratResult<() { self.entries.lock().unwrap().clear(); - Ok(()) + Ok() } } - #[derive(Debug, Serialize, Deserialize, PartialEq)] + #derive(Debug, Serialize, Deserialize, PartialEq) struct Sample { id: u32, name: String, } - #[tokio::test] + #tokio::test async fn get_after_put_roundtrips() { let cache = InMemoryCacheDouble::new(); let value = Sample { @@ -153,48 +167,58 @@ mod tests { }; cache.put("key1", &value).await.unwrap(); - let fetched: Option = cache.get("key1").await.unwrap(); + let fetched: Option = cache.get("key1", false).await.unwrap(); assert_eq!(fetched, Some(value)); } - #[tokio::test] + #tokio::test async fn put_overwrites_existing_entry() { let cache = InMemoryCacheDouble::new(); cache.put("key1", &1u32).await.unwrap(); cache.put("key1", &2u32).await.unwrap(); - assert_eq!(cache.get::("key1").await.unwrap(), Some(2)); + assert_eq!(cache.get::("key1", false).await.unwrap(), Some(2)); } - #[tokio::test] + #tokig::test async fn miss_returns_ok_none_not_an_error() { let cache = InMemoryCacheDouble::new(); - let fetched: Option = cache.get("missing").await.unwrap(); + let fetched: Option = cache.get("missing", false).await.unwrap(); assert_eq!(fetched, None); } - #[tokio::test] + #tokio::test + async fn bypass_cache_returns_none_even_if_exists() { + let cache = InMemoryCacheDouble::new(); + cache.put("key1", &42u32).await.unwrap(); + + let bypassed: Option = cache.get("key1", true).await.unwrap(); + + assert_eq!(bypassed, None); + } + + #tokio::test async fn remove_deletes_entry() { let cache = InMemoryCacheDouble::new(); cache.put("key1", &42u32).await.unwrap(); cache.remove("key1").await.unwrap(); - assert_eq!(cache.get::("key1").await.unwrap(), None); + assert_eq!(cache.get::("key1", false).await.unwrap(), None); } - #[tokio::test] + #tokio::test async fn remove_of_missing_key_is_not_an_error() { let cache = InMemoryCacheDouble::new(); cache.remove("never-existed").await.unwrap(); } - #[tokio::test] + #tokio::test async fn clear_removes_all_entries() { let cache = InMemoryCacheDouble::new(); cache.put("key1", &1u32).await.unwrap(); @@ -202,11 +226,11 @@ mod tests { cache.clear().await.unwrap(); - assert_eq!(cache.get::("key1").await.unwrap(), None); - assert_eq!(cache.get::("key2").await.unwrap(), None); + assert_eq!(cache.get::("key1", false).await.unwrap(), None); + assert_eq!(cache.get::("key2", false).await.unwrap(), None); } - #[tokio::test] + #tokio::test async fn put_over_capacity_returns_typed_error() { let cache = InMemoryCacheDouble::with_max_entry_size(4); diff --git a/crates/core/src/cache/store.rs b/crates/core/src/cache/store.rs index b6b0c2ea..1f8dcd70 100644 --- a/crates/core/src/cache/store.rs +++ b/crates/core/src/cache/store.rs @@ -1,5 +1,7 @@ use crate::error::{GratError, GratResult}; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; + use std::cmp::Ordering; use std::path::{Path, PathBuf}; @@ -28,6 +30,12 @@ impl CacheCategory { } } +static CACHE_BYPASS: AtomicBool = AtomicBool::new(false); + +pub fn set_bypass(enabled: bool) { + CACHE_BYPASS.store(enabled, AtomicOrdering::Relaxed); +} + pub struct CacheStore { cache_dir: PathBuf, max_size: u64, @@ -39,8 +47,10 @@ impl CacheStore { } pub fn with_max_size_bytes(cache_dir: PathBuf, max_size_bytes: u64) -> GratResult { - std::fs::create_dir_all(&cache_dir) - .map_err(|e| GratError::CacheError(format!("Failed to create cache dir: {e}")))?; + if !CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + std::fs::create_dir_all(&cache_dir) + .map_err(|e| GratError::CacheError(format!("Failed to create cache dir: {}", e)))?; + } Ok(Self { cache_dir, @@ -58,6 +68,10 @@ impl CacheStore { } pub fn put(&self, category: CacheCategory, key: &str, value: &[u8]) -> GratResult<()> { + if CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + return Ok(()); + } + let new_size = value.len() as u64; if new_size > self.max_size { return Err(GratError::CacheError(format!( @@ -68,6 +82,7 @@ impl CacheStore { // Ensure we can fit the new entry by evicting least-recently-used files. let current_size = self.total_cache_size()?; + if current_size.saturating_add(new_size) > self.max_size { self.evict_lru_to_fit(new_size)?; } @@ -75,15 +90,19 @@ impl CacheStore { let path = self.entry_path(category, key); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) - .map_err(|e| GratError::CacheError(format!("Failed to create dir: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to create dir: {}", e)))?; } std::fs::write(&path, value) - .map_err(|e| GratError::CacheError(format!("Failed to write cache entry: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to write cache entry: {}", e)))?; Ok(()) } pub fn get(&self, category: CacheCategory, key: &str) -> GratResult>> { + if CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + return Ok(None); + } + let path = self.entry_path(category, key); if path.exists() { // Explicitly update access metadata to ensure LRU eviction works even if atime is disabled. @@ -96,7 +115,7 @@ impl CacheStore { ); } let data = std::fs::read(&path) - .map_err(|e| GratError::CacheError(format!("Failed to read cache entry: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to read cache entry: {}", e)))?; Ok(Some(data)) } else { Ok(None) @@ -104,24 +123,35 @@ impl CacheStore { } pub fn contains(&self, category: CacheCategory, key: &str) -> bool { + if CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + return false; + } self.entry_path(category, key).exists() } pub fn remove(&self, category: CacheCategory, key: &str) -> GratResult<()> { + if CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + return Ok(()); + } + let path = self.entry_path(category, key); if path.exists() { std::fs::remove_file(&path) - .map_err(|e| GratError::CacheError(format!("Failed to remove cache entry: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to remove cache entry: {}", e)))?; } Ok(()) } pub fn clear(&self) -> GratResult<()> { + if CACHE_BYPASS.load(AtomicOrdering::Relaxed) { + return Ok(()); + } + if self.cache_dir.exists() { std::fs::remove_dir_all(&self.cache_dir) - .map_err(|e| GratError::CacheError(format!("Failed to clear cache: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to clear cache: {}", e)))?; std::fs::create_dir_all(&self.cache_dir) - .map_err(|e| GratError::CacheError(format!("Failed to recreate cache dir: {e}")))?; + .map_err(|e| GratError::CacheError(format!("Failed to recreate cache dir: {}", e)))?; } Ok(()) } @@ -137,11 +167,11 @@ impl CacheStore { return Ok(0); } - for entry in walk_dir_files(&self.cache_dir) { + for entry in wall_dir_files(&self.cache_dir) { let size = entry .metadata() .map_err(|e| { - GratError::CacheError(format!("Failed to read cache file metadata: {e}")) + GratError::CacheError(format!("Failed to read cache file metadata: {}", e)) })? .len(); total = total.saturating_add(size); @@ -160,12 +190,15 @@ impl CacheStore { let mut files = Vec::new(); if self.cache_dir.exists() { - for entry in walk_dir_files(&self.cache_dir) { + for entry in wall_dir_files(&self.cache_dir) { let meta = entry.metadata().map_err(|e| { - GratError::CacheError(format!("Failed to read cache file metadata: {e}")) + GratError::CacheError(format!("Failed to read cache file metadata: {}", e)) })?; - let accessed = meta.accessed().unwrap_or(SystemTime::UNIX_EPOCH); + let accessed = meta + .accessed() + .or_else(|_| meta.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); let last_used = std::cmp::max(accessed, modified); @@ -183,10 +216,10 @@ impl CacheStore { // Oldest first => delete until we create headroom. files.sort_by(|a, b| { - let ord = a.0.cmp(&b.0); + let ord = a.0.cmp(b.0); if ord == Ordering::Equal { // Stable tie-breaker: delete longer ago deterministically. - a.1.path().cmp(&b.1.path()) + a.1.path().cmp(b.1.path()) } else { ord } @@ -195,11 +228,13 @@ impl CacheStore { let (oldest_ts, oldest_file) = files.into_iter().next().expect("checked empty"); let path = oldest_file.path(); + // Best-effort delete. std::fs::remove_file(&path).map_err(|e| { GratError::CacheError(format!( - "Failed to evict cache file {}: {e}", - path.display() + "Failed to evict cache file {}: {}", + path.display(), + e )) })?; @@ -209,7 +244,7 @@ impl CacheStore { } } -fn walk_dir_files(dir: &Path) -> Vec { +fn wall_dir_files(dir: &Path) -> Vec { fn visit_dir(dir: &Path, out: &mut Vec) { if let Ok(read_dir) = std::fs::read_dir(dir) { for e in read_dir.flatten() { @@ -272,4 +307,23 @@ mod tests { store.clear().unwrap(); let _ = std::fs::remove_dir_all(dir); } -} + + #[test] + fn test_cache_bypass_returns_miss() { + let dir = std::env::temp_dir().join("grat_test_bypass"); + let store = CacheStore::new(dir.clone(), 10).unwrap(); + store.put(CacheCategory::WasmBlob, "key", b"data").unwrap(); + + set_bypass(true); + let result = store.get(CacheCategory::WasmBlob, "key").unwrap(); + assert_eq!(result, None); + assert!(!store.contains(CacheCategory::WasmBlob, "key")); + + set_bypass(false); + let result = store.get(CacheCategory::WasmBlob, "key").unwrap(); + assert_eq!(result, Some(b"data".to_vec())); + + store.clear().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } +} \ No newline at end of file diff --git a/crates/core/src/network/config.rs b/crates/core/src/network/config.rs index 00ecc777..d658b1b6 100644 --- a/crates/core/src/network/config.rs +++ b/crates/core/src/network/config.rs @@ -1,5 +1,5 @@ use crate::error::{GratError, GratResult}; -use crate::rpc::jsonrpc::{GetHealthParams, JsonRpcRequest, JsonRpcTransport}; +use crate::rpc::jsonrp::{GetHealthParams, JsonRpRequest, JsonRpTransport}; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; @@ -45,9 +45,8 @@ impl Network { "testnet" | "test" => Self::Testnet, "futurenet" | "future" => Self::Futurenet, "local" | "localhost" | "standalone" => Self::Custom(Self::LOCAL.to_string()), - _ if trimmed.starts_with("http://") || trimmed.starts_with("https://") => { - Self::Custom(trimmed.to_string()) - } + _ if trimmed.starts_with("http://") || trimmed.starts_with("https://") => + Self::Custom(trimmed.to_string()), _ => Self::Custom(trimmed.to_string()), }; @@ -91,7 +90,7 @@ impl Network { } impl fmt::Display for Network { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(self.as_key()) } } @@ -119,6 +118,7 @@ impl<'de> Deserialize<'de> for Network { D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) } } @@ -136,6 +136,9 @@ pub struct NetworkConfig { pub api_key: Option, pub request_timeout_secs: u64, + + #[serde(default)] + pub no_cache: bool, } impl NetworkConfig { @@ -146,10 +149,11 @@ impl NetworkConfig { network_passphrase: TESTNET_PASSPHRASE.to_string(), archive_urls: TESTNET_ARCHIVE_URLS .iter() - .map(|url| (*url).to_string()) + .map(|url| url.to_string()) .collect(), api_key: None, request_timeout_secs: 30, + no_cache: false, } } @@ -160,10 +164,11 @@ impl NetworkConfig { network_passphrase: MAINNET_PASSPHRASE.to_string(), archive_urls: MAINNET_ARCHIVE_URLS .iter() - .map(|url| (*url).to_string()) + .map(|url| url.to_string()) .collect(), api_key: None, request_timeout_secs: 30, + no_cache: false, } } @@ -174,10 +179,11 @@ impl NetworkConfig { network_passphrase: FUTURENET_PASSPHRASE.to_string(), archive_urls: FUTURENET_ARCHIVE_URLS .iter() - .map(|url| (*url).to_string()) + .map(|url| url.to_string()) .collect(), api_key: None, request_timeout_secs: 30, + no_cache: false, } } @@ -189,6 +195,7 @@ impl NetworkConfig { archive_urls: Vec::new(), api_key: None, request_timeout_secs: 30, + no_cache: false, } } @@ -204,6 +211,7 @@ impl NetworkConfig { archive_urls: Vec::new(), api_key: None, request_timeout_secs: 30, + no_cache: false, } } @@ -220,9 +228,9 @@ impl NetworkConfig { Network::Custom(name) if name.eq_ignore_ascii_case(Network::LOCAL) => Self::local(), Network::Custom(name) if name.starts_with("http://") || name.starts_with("https://") => - { - Self::custom(name.clone(), name, "") - } + { + Self::custom(name.clone(), name, "") + }, Network::Custom(name) => Self::custom(name, "", ""), } } @@ -248,8 +256,8 @@ pub fn default_network() -> NetworkConfig { #[allow(dead_code)] pub async fn validate_network(config: &NetworkConfig) -> bool { - let transport = JsonRpcTransport::new(&config.rpc_url, 0); - let req = JsonRpcRequest::new(1, "getHealth", GetHealthParams {}); + let transport = JsonRpTransport::new(&config.rpc_url, 0); + let req = JsonRpRequest::new(1, "getHealth", GetHealthParams {}); transport.call::<_, serde_json::Value>(&req).await.is_ok() } diff --git a/crates/core/src/rpc/client.rs b/crates/core/src/rpc/client.rs index 27db0300..d58a3914 100644 --- a/crates/core/src/rpc/client.rs +++ b/crates/core/src/rpc/client.rs @@ -87,6 +87,7 @@ pub struct SorobanRpcClient { client: reqwest::Client, rpc_url: String, + no_cache: bool, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -129,6 +130,7 @@ impl SorobanRpcClient { Self { client, rpc_url: config.rpc_url.clone(), + no_cache: config.no_cache, } } @@ -144,6 +146,15 @@ impl SorobanRpcClient { self } + pub fn with_no_cache(mut self, no_cache: bool) -> Self { + self.no_cache = no_cache; + self + } + + pub fn no_cache(&self) -> bool { + self.no_cache + } + pub async fn get_transaction(&self, tx_hash: &str) -> GratResult { let params = serde_json::json!([tx_hash]); self.call("getTransaction", params).await @@ -250,7 +261,12 @@ impl SorobanRpcClient { let started = Instant::now(); tracing::debug!(method, endpoint = %self.rpc_url, attempt, "Sending RPC request"); - match self.client.post(&self.rpc_url).json(&request).send().await { + let mut http_request = self.client.post(&self.rpc_url).json(&request); + if self.no_cache { + http_request = http_request.header("Cache-Control", "no-cache"); + } + + match http_request.send().await { Ok(response) => { let status = response.status(); let elapsed_ms = started.elapsed().as_millis(); @@ -326,50 +342,29 @@ impl SorobanRpcClient { .map_err(|e| GratError::RpcError(format!("Response parse error: {e}")))?; if let Some(err) = rpc_response.error { - tracing::debug!( - method, - endpoint = %self.rpc_url, - attempt, - error = %err.message, - code = err.code, - "RPC returned an error response" - ); - return Err(GratError::JsonRpc(err)); + return Err(GratError::RpcError(format!("JSON-RPC error: {err:?}"))); + } + + if let Some(result) = rpc_response.result { + return Ok(result); } - return rpc_response - .result - .ok_or_else(|| GratError::RpcError("Empty result in RPC response".into())); + return Err(GratError::RpcError("RPC response missing result".to_string())); } Err(e) => { - let elapsed_ms = started.elapsed().as_millis(); - let duration_secs = started.elapsed().as_secs_f64(); - crate::rpc::record_rpc_duration(&self.rpc_url, method, duration_secs); - tracing::info!( - method, - endpoint = %self.rpc_url, - attempt, - elapsed_ms, - error = %e, - "RPC request latency" - ); - tracing::debug!( - method, - endpoint = %self.rpc_url, - attempt, - elapsed_ms, - error = %e, - "RPC request failed" - ); - last_error = Some(GratError::RpcError(format!("HTTP request failed: {e}"))); + tracing::error!(error = %e, "Network error sending RPC request"); + last_error = Some(GratError::RpcError(format!("Network error: {e}"))); + continue; } } } - - Err(last_error.unwrap_or_else(|| GratError::RpcError("Unknown RPC error".into()))) + Err(last_error.unwrap_or_else(|| { + GratError::RpcError("RPC request failed after all retries".to_string()) + })) } } + #[cfg(test)] mod tests { use super::*;