use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::slo::types::SloDefinition;
use crate::trace::RemoteCallPattern;
pub const DEFAULT_TTL_SECS: u64 = 5 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnrichmentSnapshot {
pub created_at: u64,
pub ttl_secs: u64,
pub project_id: String,
pub workspace_slug: String,
pub slos: Vec<SloDefinition>,
pub trace_patterns: Vec<CachedRemoteCallPattern>,
}
impl EnrichmentSnapshot {
pub fn is_fresh(&self) -> bool {
let now = now_unix_secs();
now < self.created_at.saturating_add(self.ttl_secs)
}
pub fn age_secs(&self) -> u64 {
now_unix_secs().saturating_sub(self.created_at)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedRemoteCallPattern {
pub remote_service_name: String,
pub remote_endpoint: String,
pub observed_count: u32,
pub p99_latency_ms: Option<f64>,
pub local_callers: Vec<String>,
}
impl From<&RemoteCallPattern> for CachedRemoteCallPattern {
fn from(p: &RemoteCallPattern) -> Self {
Self {
remote_service_name: p.remote_service_name.clone(),
remote_endpoint: p.remote_endpoint.clone(),
observed_count: p.observed_count,
p99_latency_ms: p.p99_latency_ms,
local_callers: p.local_callers.clone(),
}
}
}
impl From<CachedRemoteCallPattern> for RemoteCallPattern {
fn from(c: CachedRemoteCallPattern) -> Self {
Self {
remote_service_name: c.remote_service_name,
remote_endpoint: c.remote_endpoint,
observed_count: c.observed_count,
p99_latency_ms: c.p99_latency_ms,
local_callers: c.local_callers,
}
}
}
pub struct EnrichmentCache {
cache_dir: PathBuf,
ttl_secs: u64,
}
impl EnrichmentCache {
pub fn open(workspace_root: &Path, ttl_secs: u64) -> Result<Self> {
let cache_dir = workspace_root
.join(".unfault")
.join("cache")
.join("enrichment");
std::fs::create_dir_all(&cache_dir)
.context("Failed to create enrichment cache directory")?;
Ok(Self {
cache_dir,
ttl_secs,
})
}
pub fn load(&self, project_id: &str, workspace_slug: &str) -> Option<EnrichmentSnapshot> {
let path = self.cache_path(project_id, workspace_slug);
let bytes = std::fs::read(&path).ok()?;
let snapshot: EnrichmentSnapshot = serde_json::from_slice(&bytes).ok()?;
if snapshot.is_fresh() {
Some(snapshot)
} else {
let _ = std::fs::remove_file(&path);
None
}
}
pub fn save(
&self,
project_id: &str,
workspace_slug: &str,
slos: Vec<SloDefinition>,
trace_patterns: Vec<CachedRemoteCallPattern>,
) -> Result<()> {
let snapshot = EnrichmentSnapshot {
created_at: now_unix_secs(),
ttl_secs: self.ttl_secs,
project_id: project_id.to_string(),
workspace_slug: workspace_slug.to_string(),
slos,
trace_patterns,
};
let path = self.cache_path(project_id, workspace_slug);
let json = serde_json::to_string_pretty(&snapshot)
.context("Failed to serialise enrichment snapshot")?;
std::fs::write(&path, json).context("Failed to write enrichment cache file")?;
Ok(())
}
pub fn invalidate(&self, project_id: &str, workspace_slug: &str) {
let path = self.cache_path(project_id, workspace_slug);
let _ = std::fs::remove_file(path);
}
fn cache_path(&self, project_id: &str, workspace_slug: &str) -> PathBuf {
let key = format!("{}-{}", project_id, workspace_slug);
let slug: String = key
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let slug = &slug[..slug.len().min(48)];
self.cache_dir.join(format!("{}.json", slug))
}
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_empty_snapshot() {
let dir = TempDir::new().unwrap();
let cache = EnrichmentCache::open(dir.path(), 300).unwrap();
cache.save("proj-123", "app-a", vec![], vec![]).unwrap();
let snapshot = cache.load("proj-123", "app-a").unwrap();
assert!(snapshot.is_fresh());
assert_eq!(snapshot.project_id, "proj-123");
assert_eq!(snapshot.workspace_slug, "app-a");
assert!(snapshot.slos.is_empty());
assert!(snapshot.trace_patterns.is_empty());
}
#[test]
fn stale_entry_returns_none() {
let dir = TempDir::new().unwrap();
let cache = EnrichmentCache::open(dir.path(), 0).unwrap();
cache.save("proj-123", "app-a", vec![], vec![]).unwrap();
let result = cache.load("proj-123", "app-a");
assert!(result.is_none(), "TTL=0 entry should be stale immediately");
}
#[test]
fn missing_entry_returns_none() {
let dir = TempDir::new().unwrap();
let cache = EnrichmentCache::open(dir.path(), 300).unwrap();
assert!(cache.load("no-project", "no-workspace").is_none());
}
#[test]
fn different_keys_dont_collide() {
let dir = TempDir::new().unwrap();
let cache = EnrichmentCache::open(dir.path(), 300).unwrap();
cache.save("proj-123", "app-a", vec![], vec![]).unwrap();
cache.save("proj-123", "app-b", vec![], vec![]).unwrap();
assert!(cache.load("proj-123", "app-a").is_some());
assert!(cache.load("proj-123", "app-b").is_some());
assert!(cache.load("proj-123", "app-c").is_none());
}
}