ngdp_cache/
ribbit.rs

1//! Ribbit response cache implementation
2
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5use tracing::{debug, trace};
6
7use crate::{Error, Result, ensure_dir, get_cache_dir};
8
9/// Cache for Ribbit protocol responses
10pub struct RibbitCache {
11    /// Base directory for Ribbit cache
12    base_dir: PathBuf,
13    /// Default TTL for cached responses (in seconds)
14    default_ttl: Duration,
15}
16
17impl RibbitCache {
18    /// Create a new Ribbit cache with default TTL of 5 minutes
19    pub async fn new() -> Result<Self> {
20        Self::with_ttl(Duration::from_secs(300)).await
21    }
22
23    /// Create a new Ribbit cache with custom TTL
24    pub async fn with_ttl(ttl: Duration) -> Result<Self> {
25        let base_dir = get_cache_dir()?.join("ribbit");
26        ensure_dir(&base_dir).await?;
27
28        debug!(
29            "Initialized Ribbit cache at: {:?} with TTL: {:?}",
30            base_dir, ttl
31        );
32
33        Ok(Self {
34            base_dir,
35            default_ttl: ttl,
36        })
37    }
38
39    /// Get cache path for a specific endpoint
40    pub fn cache_path(&self, region: &str, product: &str, endpoint: &str) -> PathBuf {
41        self.base_dir.join(region).join(product).join(endpoint)
42    }
43
44    /// Get metadata path for cache entry
45    pub fn metadata_path(&self, region: &str, product: &str, endpoint: &str) -> PathBuf {
46        let mut path = self.cache_path(region, product, endpoint);
47        path.set_extension("meta");
48        path
49    }
50
51    /// Check if a cache entry exists and is still valid
52    pub async fn is_valid(&self, region: &str, product: &str, endpoint: &str) -> bool {
53        let meta_path = self.metadata_path(region, product, endpoint);
54
55        if let Ok(metadata) = tokio::fs::read_to_string(&meta_path).await {
56            if let Ok(timestamp) = metadata.trim().parse::<u64>() {
57                let now = SystemTime::now()
58                    .duration_since(UNIX_EPOCH)
59                    .unwrap()
60                    .as_secs();
61
62                return (now - timestamp) < self.default_ttl.as_secs();
63            }
64        }
65
66        false
67    }
68
69    /// Write response to cache
70    pub async fn write(
71        &self,
72        region: &str,
73        product: &str,
74        endpoint: &str,
75        data: &[u8],
76    ) -> Result<()> {
77        let path = self.cache_path(region, product, endpoint);
78        let meta_path = self.metadata_path(region, product, endpoint);
79
80        // Ensure parent directory exists
81        if let Some(parent) = path.parent() {
82            ensure_dir(parent).await?;
83        }
84
85        // Write data
86        trace!(
87            "Writing {} bytes to Ribbit cache: {}/{}/{}",
88            data.len(),
89            region,
90            product,
91            endpoint
92        );
93        tokio::fs::write(&path, data).await?;
94
95        // Write timestamp metadata
96        let timestamp = SystemTime::now()
97            .duration_since(UNIX_EPOCH)
98            .unwrap()
99            .as_secs();
100        tokio::fs::write(&meta_path, timestamp.to_string()).await?;
101
102        Ok(())
103    }
104
105    /// Read response from cache
106    pub async fn read(&self, region: &str, product: &str, endpoint: &str) -> Result<Vec<u8>> {
107        if !self.is_valid(region, product, endpoint).await {
108            return Err(Error::CacheEntryNotFound(format!(
109                "{region}/{product}/{endpoint}"
110            )));
111        }
112
113        let path = self.cache_path(region, product, endpoint);
114        trace!(
115            "Reading from Ribbit cache: {}/{}/{}",
116            region, product, endpoint
117        );
118        Ok(tokio::fs::read(&path).await?)
119    }
120
121    /// Clear expired entries from cache
122    pub async fn clear_expired(&self) -> Result<()> {
123        debug!("Clearing expired entries from Ribbit cache");
124        self.clear_expired_in_dir(&self.base_dir).await
125    }
126
127    /// Recursively clear expired entries in a directory
128    fn clear_expired_in_dir<'a>(
129        &'a self,
130        dir: &'a Path,
131    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
132        Box::pin(async move {
133            let mut entries = tokio::fs::read_dir(dir).await?;
134
135            while let Some(entry) = entries.next_entry().await? {
136                let path = entry.path();
137
138                if path.is_dir() {
139                    self.clear_expired_in_dir(&path).await?;
140                } else if path.extension().and_then(|s| s.to_str()) == Some("meta") {
141                    // Check if this metadata file indicates an expired entry
142                    if let Ok(metadata) = tokio::fs::read_to_string(&path).await {
143                        if let Ok(timestamp) = metadata.trim().parse::<u64>() {
144                            let now = SystemTime::now()
145                                .duration_since(UNIX_EPOCH)
146                                .unwrap()
147                                .as_secs();
148
149                            if (now - timestamp) >= self.default_ttl.as_secs() {
150                                // Remove both the data and metadata files
151                                let data_path = path.with_extension("");
152                                let _ = tokio::fs::remove_file(&data_path).await;
153                                let _ = tokio::fs::remove_file(&path).await;
154                                trace!("Removed expired cache entry: {:?}", data_path);
155                            }
156                        }
157                    }
158                }
159            }
160
161            Ok(())
162        })
163    }
164
165    /// Get the base directory of this cache
166    pub fn base_dir(&self) -> &PathBuf {
167        &self.base_dir
168    }
169
170    /// Get the current TTL setting
171    pub fn ttl(&self) -> Duration {
172        self.default_ttl
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[tokio::test]
181    async fn test_ribbit_cache_operations() {
182        let cache = RibbitCache::with_ttl(Duration::from_secs(60))
183            .await
184            .unwrap();
185
186        let region = "us";
187        let product = "wow";
188        let endpoint = "versions";
189        let data = b"test ribbit response";
190
191        // Write and verify
192        cache.write(region, product, endpoint, data).await.unwrap();
193        assert!(cache.is_valid(region, product, endpoint).await);
194
195        // Read back
196        let read_data = cache.read(region, product, endpoint).await.unwrap();
197        assert_eq!(read_data, data);
198
199        // Cleanup
200        let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
201        let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
202    }
203
204    #[tokio::test]
205    async fn test_ribbit_cache_expiry() {
206        // Create cache with 0 second TTL
207        let cache = RibbitCache::with_ttl(Duration::from_secs(0)).await.unwrap();
208
209        let region = "eu";
210        let product = "wow";
211        let endpoint = "cdns";
212        let data = b"test data";
213
214        cache.write(region, product, endpoint, data).await.unwrap();
215
216        // Should be immediately expired
217        tokio::time::sleep(Duration::from_millis(10)).await;
218        assert!(!cache.is_valid(region, product, endpoint).await);
219
220        // Should fail to read
221        assert!(cache.read(region, product, endpoint).await.is_err());
222
223        // Cleanup
224        let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
225        let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
226    }
227}