1use std::collections::{BTreeMap, HashMap};
8use std::path::PathBuf;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use parking_lot::Mutex;
12use tokio::fs;
13use tracing::{debug, warn};
14
15use crate::objstore::error::{ObjStoreError, Result};
16
17static LRU_GENERATION: AtomicU64 = AtomicU64::new(1);
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct CacheConfig {
26 pub cache_dir: PathBuf,
28 pub max_size_bytes: u64,
30}
31
32impl Default for CacheConfig {
33 fn default() -> Self {
34 Self {
35 cache_dir: PathBuf::from("/tmp/chronix-objcache"),
36 max_size_bytes: 1024 * 1024 * 1024, }
38 }
39}
40
41#[derive(Debug, Clone)]
43struct CacheEntry {
44 size: u64,
46 generation: u64,
48}
49
50#[derive(Debug)]
55struct CacheState {
56 entries: HashMap<String, CacheEntry>,
58 lru_index: BTreeMap<(u64, String), u64>,
60 current_size: u64,
62}
63
64impl CacheState {
65 fn new() -> Self {
66 Self {
67 entries: HashMap::new(),
68 lru_index: BTreeMap::new(),
69 current_size: 0,
70 }
71 }
72
73 fn upsert(&mut self, key: String, entry: CacheEntry) {
75 if let Some(old) = self.entries.get(&key) {
77 self.lru_index.remove(&(old.generation, key.clone()));
78 self.current_size -= old.size;
79 }
80 self.lru_index
81 .insert((entry.generation, key.clone()), entry.size);
82 self.current_size += entry.size;
83 self.entries.insert(key, entry);
84 }
85
86 fn remove(&mut self, key: &str) {
88 if let Some(old) = self.entries.remove(key) {
89 self.lru_index.remove(&(old.generation, key.to_string()));
90 self.current_size -= old.size;
91 }
92 }
93
94 fn touch(&mut self, key: &str) {
96 if let Some(entry) = self.entries.get_mut(key) {
97 self.lru_index.remove(&(entry.generation, key.to_string()));
98 entry.generation = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
99 self.lru_index
100 .insert((entry.generation, key.to_string()), entry.size);
101 }
102 }
103}
104
105#[derive(Debug)]
112pub struct DiskCache {
113 config: CacheConfig,
114 state: Mutex<CacheState>,
115}
116
117impl DiskCache {
118 pub async fn new(config: CacheConfig) -> Result<Self> {
127 fs::create_dir_all(&config.cache_dir)
128 .await
129 .map_err(ObjStoreError::CacheIo)?;
130
131 let mut state = CacheState::new();
132
133 let mut entries = fs::read_dir(&config.cache_dir)
135 .await
136 .map_err(ObjStoreError::CacheIo)?;
137
138 while let Some(entry) = entries.next_entry().await.map_err(ObjStoreError::CacheIo)? {
139 let metadata = entry.metadata().await.map_err(ObjStoreError::CacheIo)?;
140 if metadata.is_file() {
141 let filename = entry.file_name().to_string_lossy().to_string();
142 let key = Self::path_to_key(&filename);
144 let size = metadata.len();
145 let generation = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
147 state.upsert(key, CacheEntry { size, generation });
148 }
149 }
150
151 debug!(
152 cache_dir = %config.cache_dir.display(),
153 entries = state.entries.len(),
154 current_size_bytes = state.current_size,
155 max_size_bytes = config.max_size_bytes,
156 "disk cache initialised"
157 );
158
159 Ok(Self {
160 config,
161 state: Mutex::new(state),
162 })
163 }
164
165 pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
169 let file_path = self.key_to_path(key);
170
171 {
173 let mut state = self.state.lock();
174 if state.entries.contains_key(key) {
175 state.touch(key);
176 } else {
177 return None;
178 }
179 }
180
181 match fs::read(&file_path).await {
182 Ok(data) => {
183 debug!(key, size = data.len(), "cache hit");
184 Some(data)
185 }
186 Err(e) => {
187 warn!(key, error = %e, "cache entry missing on disk, evicting from state");
188 self.remove_from_state(key);
189 None
190 }
191 }
192 }
193
194 pub async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
202 let size = data.len() as u64;
203
204 if size > self.config.max_size_bytes {
207 debug!(
208 key,
209 size,
210 max = self.config.max_size_bytes,
211 "object exceeds cache budget, skipping cache"
212 );
213 return Ok(());
214 }
215
216 self.evict_to_fit(size).await?;
220
221 let file_path = self.key_to_path(key);
222
223 {
226 let mut state = self.state.lock();
227 state.upsert(
228 key.to_string(),
229 CacheEntry {
230 size,
231 generation: LRU_GENERATION.fetch_add(1, Ordering::Relaxed),
232 },
233 );
234 }
235
236 if let Err(e) = fs::write(&file_path, data).await {
237 self.remove_from_state(key);
239 return Err(ObjStoreError::CacheIo(e));
240 }
241
242 debug!(key, size, "cached object");
243 Ok(())
244 }
245
246 pub async fn remove(&self, key: &str) -> Result<()> {
252 let file_path = self.key_to_path(key);
253 self.remove_from_state(key);
254
255 match fs::remove_file(&file_path).await {
256 Ok(()) => Ok(()),
257 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
258 Err(e) => Err(ObjStoreError::CacheIo(e)),
259 }
260 }
261
262 pub fn contains(&self, key: &str) -> bool {
264 self.state.lock().entries.contains_key(key)
265 }
266
267 pub fn current_size(&self) -> u64 {
269 self.state.lock().current_size
270 }
271
272 pub fn entry_count(&self) -> usize {
274 self.state.lock().entries.len()
275 }
276
277 async fn evict_to_fit(&self, needed: u64) -> Result<()> {
279 let max = self.config.max_size_bytes;
280 if needed > max {
281 return Ok(());
283 }
284
285 let to_evict: Vec<(String, PathBuf, u64)> = {
288 let mut state = self.state.lock();
289 if state.current_size + needed <= max {
290 return Ok(()); }
292
293 let mut evict_list = Vec::new();
294 while state.current_size + needed > max {
296 let Some(((_gen, key), _)) = state.lru_index.pop_first() else {
297 break; };
299 if let Some(entry) = state.entries.remove(&key) {
300 let path = self.key_to_path(&key);
301 state.current_size -= entry.size;
302 evict_list.push((key, path, entry.size));
303 }
304 }
305
306 evict_list
307 };
308
309 let mut evicted = 0u64;
310 for (key, path, size) in &to_evict {
311 debug!(key, "evicting cached object");
312 if let Err(e) = fs::remove_file(path).await
313 && e.kind() != std::io::ErrorKind::NotFound
314 {
315 warn!(key, error = %e, "failed to evict cached file, re-adding to state");
316 let mut state = self.state.lock();
320 state.upsert(
321 key.clone(),
322 CacheEntry {
323 size: *size,
324 generation: 0,
325 },
326 );
327 continue;
328 }
329 evicted += 1;
330 }
331
332 metrics::counter!("chronix_objstore_cache_evictions_total").increment(evicted);
333 Ok(())
334 }
335
336 fn remove_from_state(&self, key: &str) {
337 self.state.lock().remove(key);
338 }
339
340 fn key_to_path(&self, key: &str) -> PathBuf {
341 let safe_name = key.replace('%', "%25").replace('/', "%2F");
347
348 let filename = if safe_name.len() > 200 {
352 use std::collections::hash_map::DefaultHasher;
353 use std::hash::{Hash, Hasher};
354 let mut hasher = DefaultHasher::new();
355 key.hash(&mut hasher);
356 let h = hasher.finish();
357 format!("{:.100}_{:016x}", safe_name, h)
358 } else {
359 safe_name
360 };
361
362 self.config.cache_dir.join(filename)
363 }
364
365 fn path_to_key(filename: &str) -> String {
367 filename.replace("%2F", "/").replace("%25", "%")
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use tempfile::TempDir;
375
376 async fn make_cache(max_size: u64) -> (DiskCache, TempDir) {
377 let tmp = TempDir::new().unwrap();
378 let config = CacheConfig {
379 cache_dir: tmp.path().to_path_buf(),
380 max_size_bytes: max_size,
381 };
382 let cache = DiskCache::new(config).await.unwrap();
383 (cache, tmp)
384 }
385
386 #[tokio::test]
387 async fn put_and_get_roundtrip() {
388 let (cache, _tmp) = make_cache(1024).await;
389 cache.put("test/object.bin", b"hello world").await.unwrap();
390
391 let data = cache.get("test/object.bin").await.unwrap();
392 assert_eq!(data, b"hello world");
393 }
394
395 #[tokio::test]
396 async fn get_missing_returns_none() {
397 let (cache, _tmp) = make_cache(1024).await;
398 assert!(cache.get("nonexistent").await.is_none());
399 }
400
401 #[tokio::test]
402 async fn remove_deletes_entry() {
403 let (cache, _tmp) = make_cache(1024).await;
404 cache.put("key", b"data").await.unwrap();
405 assert!(cache.contains("key"));
406
407 cache.remove("key").await.unwrap();
408 assert!(!cache.contains("key"));
409 assert!(cache.get("key").await.is_none());
410 }
411
412 #[tokio::test]
413 async fn remove_nonexistent_is_ok() {
414 let (cache, _tmp) = make_cache(1024).await;
415 cache.remove("ghost").await.unwrap();
416 }
417
418 #[tokio::test]
419 async fn overwrite_updates_size() {
420 let (cache, _tmp) = make_cache(1024).await;
421 cache.put("key", b"short").await.unwrap();
422 assert_eq!(cache.current_size(), 5);
423
424 cache.put("key", b"a longer value").await.unwrap();
425 assert_eq!(cache.current_size(), 14);
426 assert_eq!(cache.entry_count(), 1);
427 }
428
429 #[tokio::test]
430 async fn lru_eviction_frees_space() {
431 let (cache, _tmp) = make_cache(20).await;
433
434 cache.put("a", &[0u8; 10]).await.unwrap();
435 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
437 cache.put("b", &[1u8; 10]).await.unwrap();
438
439 assert_eq!(cache.entry_count(), 2);
440 assert_eq!(cache.current_size(), 20);
441
442 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
444 cache.put("c", &[2u8; 10]).await.unwrap();
445
446 assert!(!cache.contains("a"), "oldest entry 'a' should be evicted");
447 assert!(cache.contains("b"));
448 assert!(cache.contains("c"));
449 assert!(cache.current_size() <= 20);
450 }
451
452 #[tokio::test]
453 async fn object_larger_than_cache_skips_caching() {
454 let (cache, _tmp) = make_cache(10).await;
455 cache.put("small", b"hi").await.unwrap();
457
458 cache.put("huge", &[0u8; 100]).await.unwrap();
460
461 assert!(cache.contains("small"));
466 }
467
468 #[tokio::test]
469 async fn rebuild_state_from_disk() {
470 let tmp = TempDir::new().unwrap();
471 let config = CacheConfig {
472 cache_dir: tmp.path().to_path_buf(),
473 max_size_bytes: 1024,
474 };
475
476 fs::write(tmp.path().join("file1"), b"aaa").await.unwrap();
478 fs::write(tmp.path().join("file2"), b"bbbbb").await.unwrap();
479
480 let cache = DiskCache::new(config).await.unwrap();
481 assert_eq!(cache.entry_count(), 2);
482 assert_eq!(cache.current_size(), 8); }
484
485 #[tokio::test]
486 async fn key_to_path_replaces_slashes() {
487 let (cache, tmp) = make_cache(1024).await;
488 let path = cache.key_to_path("shard_1/segment_001.csx");
489 assert_eq!(path, tmp.path().join("shard_1%2Fsegment_001.csx"));
490
491 let filename = path.file_name().unwrap().to_str().unwrap();
493 assert_eq!(DiskCache::path_to_key(filename), "shard_1/segment_001.csx");
494
495 let path2 = cache.key_to_path("100%/done");
497 assert_eq!(path2, tmp.path().join("100%25%2Fdone"));
498 let filename2 = path2.file_name().unwrap().to_str().unwrap();
499 assert_eq!(DiskCache::path_to_key(filename2), "100%/done");
500 }
501
502 #[tokio::test]
503 async fn current_size_and_entry_count() {
504 let (cache, _tmp) = make_cache(1024).await;
505 assert_eq!(cache.current_size(), 0);
506 assert_eq!(cache.entry_count(), 0);
507
508 cache.put("a", b"hello").await.unwrap();
509 assert_eq!(cache.current_size(), 5);
510 assert_eq!(cache.entry_count(), 1);
511
512 cache.put("b", b"world!").await.unwrap();
513 assert_eq!(cache.current_size(), 11);
514 assert_eq!(cache.entry_count(), 2);
515 }
516
517 #[tokio::test]
518 async fn test_long_cache_key_doesnt_overflow() {
519 let (cache, _tmp) = make_cache(4096).await;
520 let long_key = "a/".repeat(120); let path = cache.key_to_path(&long_key);
523 let filename = path.file_name().unwrap().to_str().unwrap();
524
525 assert!(
527 filename.len() <= 255,
528 "filename length {} exceeds 255-byte filesystem limit",
529 filename.len()
530 );
531
532 assert!(
534 filename.contains('_'),
535 "long filename should contain a hash suffix"
536 );
537
538 cache.put(&long_key, b"long key data").await.unwrap();
540 let data = cache.get(&long_key).await.unwrap();
541 assert_eq!(data, b"long key data");
542 }
543}