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 gen = LRU_GENERATION.fetch_add(1, Ordering::Relaxed);
147 state.upsert(
148 key,
149 CacheEntry {
150 size,
151 generation: gen,
152 },
153 );
154 }
155 }
156
157 debug!(
158 cache_dir = %config.cache_dir.display(),
159 entries = state.entries.len(),
160 current_size_bytes = state.current_size,
161 max_size_bytes = config.max_size_bytes,
162 "disk cache initialised"
163 );
164
165 Ok(Self {
166 config,
167 state: Mutex::new(state),
168 })
169 }
170
171 pub async fn get(&self, key: &str) -> Option<Vec<u8>> {
175 let file_path = self.key_to_path(key);
176
177 {
179 let mut state = self.state.lock();
180 if state.entries.contains_key(key) {
181 state.touch(key);
182 } else {
183 return None;
184 }
185 }
186
187 match fs::read(&file_path).await {
188 Ok(data) => {
189 debug!(key, size = data.len(), "cache hit");
190 Some(data)
191 }
192 Err(e) => {
193 warn!(key, error = %e, "cache entry missing on disk, evicting from state");
194 self.remove_from_state(key);
195 None
196 }
197 }
198 }
199
200 pub async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
208 let size = data.len() as u64;
209
210 if size > self.config.max_size_bytes {
213 debug!(
214 key,
215 size,
216 max = self.config.max_size_bytes,
217 "object exceeds cache budget, skipping cache"
218 );
219 return Ok(());
220 }
221
222 self.evict_to_fit(size).await?;
226
227 let file_path = self.key_to_path(key);
228
229 {
232 let mut state = self.state.lock();
233 state.upsert(
234 key.to_string(),
235 CacheEntry {
236 size,
237 generation: LRU_GENERATION.fetch_add(1, Ordering::Relaxed),
238 },
239 );
240 }
241
242 if let Err(e) = fs::write(&file_path, data).await {
243 self.remove_from_state(key);
245 return Err(ObjStoreError::CacheIo(e));
246 }
247
248 debug!(key, size, "cached object");
249 Ok(())
250 }
251
252 pub async fn remove(&self, key: &str) -> Result<()> {
258 let file_path = self.key_to_path(key);
259 self.remove_from_state(key);
260
261 match fs::remove_file(&file_path).await {
262 Ok(()) => Ok(()),
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
264 Err(e) => Err(ObjStoreError::CacheIo(e)),
265 }
266 }
267
268 pub fn contains(&self, key: &str) -> bool {
270 self.state.lock().entries.contains_key(key)
271 }
272
273 pub fn current_size(&self) -> u64 {
275 self.state.lock().current_size
276 }
277
278 pub fn entry_count(&self) -> usize {
280 self.state.lock().entries.len()
281 }
282
283 async fn evict_to_fit(&self, needed: u64) -> Result<()> {
285 let max = self.config.max_size_bytes;
286 if needed > max {
287 return Ok(());
289 }
290
291 let to_evict: Vec<(String, PathBuf, u64)> = {
294 let mut state = self.state.lock();
295 if state.current_size + needed <= max {
296 return Ok(()); }
298
299 let mut evict_list = Vec::new();
300 while state.current_size + needed > max {
302 let Some(((_gen, key), _)) = state.lru_index.pop_first() else {
303 break; };
305 if let Some(entry) = state.entries.remove(&key) {
306 let path = self.key_to_path(&key);
307 state.current_size -= entry.size;
308 evict_list.push((key, path, entry.size));
309 }
310 }
311
312 evict_list
313 };
314
315 let mut evicted = 0u64;
316 for (key, path, size) in &to_evict {
317 debug!(key, "evicting cached object");
318 if let Err(e) = fs::remove_file(path).await {
319 if e.kind() != std::io::ErrorKind::NotFound {
320 warn!(key, error = %e, "failed to evict cached file, re-adding to state");
321 let mut state = self.state.lock();
325 state.upsert(
326 key.clone(),
327 CacheEntry {
328 size: *size,
329 generation: 0,
330 },
331 );
332 continue;
333 }
334 }
335 evicted += 1;
336 }
337
338 metrics::counter!("chronix_objstore_cache_evictions_total").increment(evicted);
339 Ok(())
340 }
341
342 fn remove_from_state(&self, key: &str) {
343 self.state.lock().remove(key);
344 }
345
346 fn key_to_path(&self, key: &str) -> PathBuf {
347 let safe_name = key.replace('%', "%25").replace('/', "%2F");
353
354 let filename = if safe_name.len() > 200 {
358 use std::collections::hash_map::DefaultHasher;
359 use std::hash::{Hash, Hasher};
360 let mut hasher = DefaultHasher::new();
361 key.hash(&mut hasher);
362 let h = hasher.finish();
363 format!("{:.100}_{:016x}", safe_name, h)
364 } else {
365 safe_name
366 };
367
368 self.config.cache_dir.join(filename)
369 }
370
371 fn path_to_key(filename: &str) -> String {
373 filename.replace("%2F", "/").replace("%25", "%")
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 async fn make_cache(max_size: u64) -> (DiskCache, TempDir) {
383 let tmp = TempDir::new().unwrap();
384 let config = CacheConfig {
385 cache_dir: tmp.path().to_path_buf(),
386 max_size_bytes: max_size,
387 };
388 let cache = DiskCache::new(config).await.unwrap();
389 (cache, tmp)
390 }
391
392 #[tokio::test]
393 async fn put_and_get_roundtrip() {
394 let (cache, _tmp) = make_cache(1024).await;
395 cache.put("test/object.bin", b"hello world").await.unwrap();
396
397 let data = cache.get("test/object.bin").await.unwrap();
398 assert_eq!(data, b"hello world");
399 }
400
401 #[tokio::test]
402 async fn get_missing_returns_none() {
403 let (cache, _tmp) = make_cache(1024).await;
404 assert!(cache.get("nonexistent").await.is_none());
405 }
406
407 #[tokio::test]
408 async fn remove_deletes_entry() {
409 let (cache, _tmp) = make_cache(1024).await;
410 cache.put("key", b"data").await.unwrap();
411 assert!(cache.contains("key"));
412
413 cache.remove("key").await.unwrap();
414 assert!(!cache.contains("key"));
415 assert!(cache.get("key").await.is_none());
416 }
417
418 #[tokio::test]
419 async fn remove_nonexistent_is_ok() {
420 let (cache, _tmp) = make_cache(1024).await;
421 cache.remove("ghost").await.unwrap();
422 }
423
424 #[tokio::test]
425 async fn overwrite_updates_size() {
426 let (cache, _tmp) = make_cache(1024).await;
427 cache.put("key", b"short").await.unwrap();
428 assert_eq!(cache.current_size(), 5);
429
430 cache.put("key", b"a longer value").await.unwrap();
431 assert_eq!(cache.current_size(), 14);
432 assert_eq!(cache.entry_count(), 1);
433 }
434
435 #[tokio::test]
436 async fn lru_eviction_frees_space() {
437 let (cache, _tmp) = make_cache(20).await;
439
440 cache.put("a", &[0u8; 10]).await.unwrap();
441 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
443 cache.put("b", &[1u8; 10]).await.unwrap();
444
445 assert_eq!(cache.entry_count(), 2);
446 assert_eq!(cache.current_size(), 20);
447
448 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
450 cache.put("c", &[2u8; 10]).await.unwrap();
451
452 assert!(!cache.contains("a"), "oldest entry 'a' should be evicted");
453 assert!(cache.contains("b"));
454 assert!(cache.contains("c"));
455 assert!(cache.current_size() <= 20);
456 }
457
458 #[tokio::test]
459 async fn object_larger_than_cache_skips_caching() {
460 let (cache, _tmp) = make_cache(10).await;
461 cache.put("small", b"hi").await.unwrap();
463
464 cache.put("huge", &[0u8; 100]).await.unwrap();
466
467 assert!(cache.contains("small"));
472 }
473
474 #[tokio::test]
475 async fn rebuild_state_from_disk() {
476 let tmp = TempDir::new().unwrap();
477 let config = CacheConfig {
478 cache_dir: tmp.path().to_path_buf(),
479 max_size_bytes: 1024,
480 };
481
482 fs::write(tmp.path().join("file1"), b"aaa").await.unwrap();
484 fs::write(tmp.path().join("file2"), b"bbbbb").await.unwrap();
485
486 let cache = DiskCache::new(config).await.unwrap();
487 assert_eq!(cache.entry_count(), 2);
488 assert_eq!(cache.current_size(), 8); }
490
491 #[tokio::test]
492 async fn key_to_path_replaces_slashes() {
493 let (cache, tmp) = make_cache(1024).await;
494 let path = cache.key_to_path("shard_1/segment_001.csx");
495 assert_eq!(path, tmp.path().join("shard_1%2Fsegment_001.csx"));
496
497 let filename = path.file_name().unwrap().to_str().unwrap();
499 assert_eq!(DiskCache::path_to_key(filename), "shard_1/segment_001.csx");
500
501 let path2 = cache.key_to_path("100%/done");
503 assert_eq!(path2, tmp.path().join("100%25%2Fdone"));
504 let filename2 = path2.file_name().unwrap().to_str().unwrap();
505 assert_eq!(DiskCache::path_to_key(filename2), "100%/done");
506 }
507
508 #[tokio::test]
509 async fn current_size_and_entry_count() {
510 let (cache, _tmp) = make_cache(1024).await;
511 assert_eq!(cache.current_size(), 0);
512 assert_eq!(cache.entry_count(), 0);
513
514 cache.put("a", b"hello").await.unwrap();
515 assert_eq!(cache.current_size(), 5);
516 assert_eq!(cache.entry_count(), 1);
517
518 cache.put("b", b"world!").await.unwrap();
519 assert_eq!(cache.current_size(), 11);
520 assert_eq!(cache.entry_count(), 2);
521 }
522
523 #[tokio::test]
524 async fn test_long_cache_key_doesnt_overflow() {
525 let (cache, _tmp) = make_cache(4096).await;
526 let long_key = "a/".repeat(120); let path = cache.key_to_path(&long_key);
529 let filename = path.file_name().unwrap().to_str().unwrap();
530
531 assert!(
533 filename.len() <= 255,
534 "filename length {} exceeds 255-byte filesystem limit",
535 filename.len()
536 );
537
538 assert!(
540 filename.contains('_'),
541 "long filename should contain a hash suffix"
542 );
543
544 cache.put(&long_key, b"long key data").await.unwrap();
546 let data = cache.get(&long_key).await.unwrap();
547 assert_eq!(data, b"long key data");
548 }
549}