mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
Merge pull request #12 from 0xrsydn/fix/resilient-cache
fix(cache): treat corrupted cache entries as cache misses
This commit is contained in:
commit
db4ff4f9fe
2 changed files with 97 additions and 5 deletions
94
src/cache.rs
94
src/cache.rs
|
|
@ -53,7 +53,7 @@ impl Cache {
|
||||||
};
|
};
|
||||||
let age = Utc::now().signed_duration_since(entry.fetched_at);
|
let age = Utc::now().signed_duration_since(entry.fetched_at);
|
||||||
if age
|
if age
|
||||||
> chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs))
|
>= chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs))
|
||||||
.map_err(|e| IdxError::CacheMiss(e.to_string()))?
|
.map_err(|e| IdxError::CacheMiss(e.to_string()))?
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|
@ -163,9 +163,26 @@ impl Cache {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
let raw = match fs::read_to_string(&path) {
|
||||||
let entry: CacheEntry<T> =
|
Ok(s) => s,
|
||||||
serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(IdxError::Io(e.to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry: CacheEntry<T> = match serde_json::from_str(&raw) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: corrupted cache entry for {}/{}, treating as miss: {}",
|
||||||
|
data_type, symbol, e
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"debug: cache schema mismatch for {} (got {}, expected {})",
|
"debug: cache schema mismatch for {} (got {}, expected {})",
|
||||||
|
|
@ -185,6 +202,14 @@ impl Cache {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
||||||
|
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
|
||||||
|
&& !dir.is_empty()
|
||||||
|
{
|
||||||
|
let path = PathBuf::from(dir);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return Ok(path.join("idx"));
|
||||||
|
}
|
||||||
|
}
|
||||||
ProjectDirs::from("", "", "idx")
|
ProjectDirs::from("", "", "idx")
|
||||||
.map(|d| d.cache_dir().to_path_buf())
|
.map(|d| d.cache_dir().to_path_buf())
|
||||||
.ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string()))
|
.ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string()))
|
||||||
|
|
@ -193,18 +218,23 @@ pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::{Cache, CacheEntry};
|
use super::{Cache, CacheEntry};
|
||||||
|
|
||||||
|
static TMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
struct T {
|
struct T {
|
||||||
v: i32,
|
v: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tmp() -> std::path::PathBuf {
|
fn tmp() -> std::path::PathBuf {
|
||||||
let p = std::env::temp_dir().join(format!("idx-cache-test-{}", std::process::id()));
|
let suffix = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let p =
|
||||||
|
std::env::temp_dir().join(format!("idx-cache-test-{}-{suffix}", std::process::id()));
|
||||||
let _ = fs::remove_dir_all(&p);
|
let _ = fs::remove_dir_all(&p);
|
||||||
fs::create_dir_all(&p).expect("create tmp cache dir");
|
fs::create_dir_all(&p).expect("create tmp cache dir");
|
||||||
p
|
p
|
||||||
|
|
@ -240,4 +270,58 @@ mod tests {
|
||||||
.expect("cache read stale");
|
.expect("cache read stale");
|
||||||
assert_eq!(stale, Some(T { v: 7 }));
|
assert_eq!(stale, Some(T { v: 7 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupted_cache_entry_returns_none_and_deletes_file() {
|
||||||
|
let root = tmp();
|
||||||
|
let cache = Cache::with_root(root.clone());
|
||||||
|
|
||||||
|
// Write invalid JSON to a cache file
|
||||||
|
let path = root.join("quote/CORRUPT.JK.json");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create parent dir");
|
||||||
|
}
|
||||||
|
fs::write(&path, "this is not valid json {{{").expect("write corrupted cache");
|
||||||
|
|
||||||
|
assert!(path.exists(), "corrupted file should exist before get()");
|
||||||
|
|
||||||
|
// get() should return Ok(None), not an error
|
||||||
|
let result: Option<T> = cache
|
||||||
|
.get("quote", "CORRUPT.JK")
|
||||||
|
.expect("get should not error");
|
||||||
|
assert_eq!(result, None, "corrupted entry should be treated as miss");
|
||||||
|
|
||||||
|
// The corrupted file should be deleted
|
||||||
|
assert!(!path.exists(), "corrupted file should be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() {
|
||||||
|
let root = tmp();
|
||||||
|
let cache = Cache::with_root(root.clone());
|
||||||
|
|
||||||
|
// Write invalid JSON to a cache file
|
||||||
|
let path = root.join("quote/STALE_CORRUPT.JK.json");
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create parent dir");
|
||||||
|
}
|
||||||
|
fs::write(&path, "{ not valid json at all").expect("write corrupted cache");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
path.exists(),
|
||||||
|
"corrupted file should exist before get_stale()"
|
||||||
|
);
|
||||||
|
|
||||||
|
// get_stale() should return Ok(None), not an error
|
||||||
|
let result: Option<T> = cache
|
||||||
|
.get_stale("quote", "STALE_CORRUPT.JK")
|
||||||
|
.expect("get_stale should not error");
|
||||||
|
assert_eq!(
|
||||||
|
result, None,
|
||||||
|
"corrupted entry should be treated as miss in get_stale"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The corrupted file should be deleted
|
||||||
|
assert!(!path.exists(), "corrupted file should be deleted");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,14 @@ pub fn default_config_toml() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_path() -> Result<PathBuf, IdxError> {
|
pub fn config_path() -> Result<PathBuf, IdxError> {
|
||||||
|
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME")
|
||||||
|
&& !dir.is_empty()
|
||||||
|
{
|
||||||
|
let path = PathBuf::from(dir);
|
||||||
|
if path.is_absolute() {
|
||||||
|
return Ok(path.join("idx").join("config.toml"));
|
||||||
|
}
|
||||||
|
}
|
||||||
ProjectDirs::from("", "", "idx")
|
ProjectDirs::from("", "", "idx")
|
||||||
.map(|d| d.config_dir().join("config.toml"))
|
.map(|d| d.config_dir().join("config.toml"))
|
||||||
.ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string()))
|
.ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string()))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue