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        // Use temporary files for atomic writes in the same directory
86        let temp_path = path.with_file_name(format!(
87            "{}.tmp",
88            path.file_name().unwrap().to_string_lossy()
89        ));
90        let temp_meta_path = meta_path.with_file_name(format!(
91            "{}.tmp",
92            meta_path.file_name().unwrap().to_string_lossy()
93        ));
94
95        // Get timestamp
96        let timestamp = SystemTime::now()
97            .duration_since(UNIX_EPOCH)
98            .unwrap()
99            .as_secs();
100
101        // Write data to temporary file first
102        trace!(
103            "Writing {} bytes to Ribbit cache: {}/{}/{}",
104            data.len(),
105            region,
106            product,
107            endpoint
108        );
109
110        // Handle errors and cleanup temporary files
111        let write_result = async {
112            tokio::fs::write(&temp_path, data).await?;
113            tokio::fs::write(&temp_meta_path, timestamp.to_string()).await?;
114
115            // Atomically rename both files into place
116            // This ensures that both files appear simultaneously
117            tokio::fs::rename(&temp_path, &path).await?;
118            tokio::fs::rename(&temp_meta_path, &meta_path).await?;
119
120            Ok::<(), std::io::Error>(())
121        }
122        .await;
123
124        // Clean up temporary files on error
125        if write_result.is_err() {
126            let _ = tokio::fs::remove_file(&temp_path).await;
127            let _ = tokio::fs::remove_file(&temp_meta_path).await;
128        }
129
130        write_result?;
131
132        Ok(())
133    }
134
135    /// Read response from cache
136    pub async fn read(&self, region: &str, product: &str, endpoint: &str) -> Result<Vec<u8>> {
137        if !self.is_valid(region, product, endpoint).await {
138            return Err(Error::CacheEntryNotFound(format!(
139                "{region}/{product}/{endpoint}"
140            )));
141        }
142
143        let path = self.cache_path(region, product, endpoint);
144        trace!(
145            "Reading from Ribbit cache: {}/{}/{}",
146            region, product, endpoint
147        );
148        Ok(tokio::fs::read(&path).await?)
149    }
150
151    /// Clear expired entries from cache
152    pub async fn clear_expired(&self) -> Result<()> {
153        debug!("Clearing expired entries from Ribbit cache");
154        self.clear_expired_in_dir(&self.base_dir).await
155    }
156
157    /// Recursively clear expired entries in a directory
158    fn clear_expired_in_dir<'a>(
159        &'a self,
160        dir: &'a Path,
161    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
162        Box::pin(async move {
163            let mut entries = tokio::fs::read_dir(dir).await?;
164
165            while let Some(entry) = entries.next_entry().await? {
166                let path = entry.path();
167
168                if path.is_dir() {
169                    self.clear_expired_in_dir(&path).await?;
170                } else if path.extension().and_then(|s| s.to_str()) == Some("meta") {
171                    // Check if this metadata file indicates an expired entry
172                    if let Ok(metadata) = tokio::fs::read_to_string(&path).await {
173                        if let Ok(timestamp) = metadata.trim().parse::<u64>() {
174                            let now = SystemTime::now()
175                                .duration_since(UNIX_EPOCH)
176                                .unwrap()
177                                .as_secs();
178
179                            if (now - timestamp) >= self.default_ttl.as_secs() {
180                                // Remove both the data and metadata files
181                                let data_path = path.with_extension("");
182                                let _ = tokio::fs::remove_file(&data_path).await;
183                                let _ = tokio::fs::remove_file(&path).await;
184                                trace!("Removed expired cache entry: {:?}", data_path);
185                            }
186                        }
187                    }
188                }
189            }
190
191            Ok(())
192        })
193    }
194
195    /// Get the base directory of this cache
196    pub fn base_dir(&self) -> &PathBuf {
197        &self.base_dir
198    }
199
200    /// Get the current TTL setting
201    pub fn ttl(&self) -> Duration {
202        self.default_ttl
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[tokio::test]
211    async fn test_ribbit_cache_operations() {
212        let cache = RibbitCache::with_ttl(Duration::from_secs(60))
213            .await
214            .unwrap();
215
216        let region = "us";
217        let product = "wow";
218        let endpoint = "versions";
219        let data = b"test ribbit response";
220
221        // Write and verify
222        cache.write(region, product, endpoint, data).await.unwrap();
223        assert!(cache.is_valid(region, product, endpoint).await);
224
225        // Read back
226        let read_data = cache.read(region, product, endpoint).await.unwrap();
227        assert_eq!(read_data, data);
228
229        // Cleanup
230        let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
231        let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
232    }
233
234    #[tokio::test]
235    async fn test_ribbit_cache_expiry() {
236        // Create cache with 0 second TTL
237        let cache = RibbitCache::with_ttl(Duration::from_secs(0)).await.unwrap();
238
239        let region = "eu";
240        let product = "wow";
241        let endpoint = "cdns";
242        let data = b"test data";
243
244        cache.write(region, product, endpoint, data).await.unwrap();
245
246        // Should be immediately expired
247        tokio::time::sleep(Duration::from_millis(10)).await;
248        assert!(!cache.is_valid(region, product, endpoint).await);
249
250        // Should fail to read
251        assert!(cache.read(region, product, endpoint).await.is_err());
252
253        // Cleanup
254        let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
255        let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
256    }
257}