Skip to main content

storage/
disk_cache.rs

1//! L2 Disk Cache using RocksDB
2//!
3//! Provides persistent local caching between L1 RAM cache (moka) and L3 object storage (S3).
4//! Optimized for NVMe SSDs with configurable cache size limits.
5
6use std::path::Path;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use rocksdb::{BlockBasedOptions, Cache, IteratorMode, Options, WriteBatch, DB};
11use serde::{Deserialize, Serialize};
12use tracing::{debug, warn};
13
14use common::{DakeraError, Result, Vector, VectorId};
15
16/// Configuration for the disk cache
17#[derive(Debug, Clone)]
18pub struct DiskCacheConfig {
19    /// Path to the RocksDB database
20    pub path: String,
21    /// Maximum cache size in bytes (0 = unlimited)
22    pub max_size_bytes: u64,
23    /// Enable compression (LZ4)
24    pub compression: bool,
25    /// Write buffer size in bytes
26    pub write_buffer_size: usize,
27    /// Max number of write buffers
28    pub max_write_buffer_number: i32,
29}
30
31impl Default for DiskCacheConfig {
32    fn default() -> Self {
33        Self {
34            path: "./cache".to_string(),
35            max_size_bytes: 10 * 1024 * 1024 * 1024, // 10GB default
36            compression: true,
37            write_buffer_size: 64 * 1024 * 1024, // 64MB
38            max_write_buffer_number: 3,
39        }
40    }
41}
42
43/// Cache entry with metadata for eviction
44#[derive(Debug, Serialize, Deserialize)]
45struct CacheEntry {
46    vector: Vector,
47    access_count: u64,
48    created_at: u64,
49}
50
51/// L2 Disk Cache backed by RocksDB
52pub struct DiskCache {
53    db: Arc<DB>,
54    config: DiskCacheConfig,
55    /// Writes accumulated since the last size-eviction sweep. The `max_size_bytes`
56    /// cap is enforced periodically (not on every write) to amortise the O(N) scan
57    /// (DAK-7428).
58    writes_since_eviction: AtomicU64,
59}
60
61impl DiskCache {
62    /// Create a new disk cache
63    pub fn new(config: DiskCacheConfig) -> Result<Self> {
64        let mut opts = Options::default();
65        opts.create_if_missing(true);
66        opts.set_write_buffer_size(config.write_buffer_size);
67        opts.set_max_write_buffer_number(config.max_write_buffer_number);
68
69        if config.compression {
70            opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
71        }
72
73        // Explicit block cache — prevents RocksDB from using unbounded default (DAK-5716).
74        let block_cache_mb: usize = std::env::var("DAKERA_ROCKSDB_BLOCK_CACHE_MB")
75            .ok()
76            .and_then(|v| v.parse().ok())
77            .unwrap_or(64);
78        let cache = Cache::new_lru_cache(block_cache_mb * 1024 * 1024);
79        let mut block_opts = BlockBasedOptions::default();
80        block_opts.set_block_cache(&cache);
81        block_opts.set_optimize_filters_for_memory(true);
82        opts.set_block_based_table_factory(&block_opts);
83
84        // Optimize for SSD
85        opts.set_level_compaction_dynamic_level_bytes(true);
86        opts.set_max_background_jobs(4);
87
88        let db = DB::open(&opts, &config.path)
89            .map_err(|e| DakeraError::Storage(format!("Failed to open RocksDB: {}", e)))?;
90
91        debug!(path = %config.path, "Disk cache initialized");
92
93        Ok(Self {
94            db: Arc::new(db),
95            config,
96            writes_since_eviction: AtomicU64::new(0),
97        })
98    }
99
100    /// Number of writes between size-eviction sweeps. Bounds the amortised cost
101    /// of the O(N) cap-enforcement scan.
102    const EVICTION_CHECK_INTERVAL: u64 = 1024;
103
104    /// Enforce `max_size_bytes` by evicting the oldest entries (by `created_at`)
105    /// until the cache is back under ~90% of the cap. No-op when the cap is 0
106    /// (unlimited). Returns the number of entries evicted (DAK-7428).
107    ///
108    /// Approximate: entry size is the serialized key+value length, and the
109    /// estimate is recomputed from a full scan, so a single sweep is O(N). It is
110    /// therefore triggered only periodically via `note_writes`.
111    pub fn enforce_size_limit(&self) -> Result<usize> {
112        let max = self.config.max_size_bytes;
113        if max == 0 {
114            return Ok(0);
115        }
116
117        let mut entries: Vec<(Box<[u8]>, u64, u64)> = Vec::new();
118        let mut total: u64 = 0;
119        for item in self.db.iterator(IteratorMode::Start) {
120            let (key, value) =
121                item.map_err(|e| DakeraError::Storage(format!("disk cache scan failed: {}", e)))?;
122            let bytes = (key.len() + value.len()) as u64;
123            total += bytes;
124            let created_at = serde_json::from_slice::<CacheEntry>(&value)
125                .map(|e| e.created_at)
126                .unwrap_or(0);
127            entries.push((key, created_at, bytes));
128        }
129
130        if total <= max {
131            return Ok(0);
132        }
133
134        // Evict down to 90% of the cap so we don't sweep again on the next write.
135        let target = max - max / 10;
136        entries.sort_by_key(|(_, created_at, _)| *created_at);
137
138        let mut batch = WriteBatch::default();
139        let mut evicted = 0usize;
140        for (key, _, bytes) in entries {
141            if total <= target {
142                break;
143            }
144            batch.delete(key);
145            total = total.saturating_sub(bytes);
146            evicted += 1;
147        }
148
149        if evicted > 0 {
150            self.db.write(batch).map_err(|e| {
151                DakeraError::Storage(format!("disk cache eviction write failed: {}", e))
152            })?;
153            debug!(
154                evicted,
155                remaining_bytes = total,
156                "disk cache evicted oldest entries to honor max_size_bytes"
157            );
158        }
159        Ok(evicted)
160    }
161
162    /// Record `n` writes and run a size-eviction sweep once the accumulated write
163    /// count crosses `EVICTION_CHECK_INTERVAL`. Best-effort: eviction errors are
164    /// logged, never propagated to the caller's write path.
165    fn note_writes(&self, n: u64) {
166        if self.config.max_size_bytes == 0 {
167            return;
168        }
169        let prev = self.writes_since_eviction.fetch_add(n, Ordering::Relaxed);
170        if prev + n >= Self::EVICTION_CHECK_INTERVAL {
171            self.writes_since_eviction.store(0, Ordering::Relaxed);
172            if let Err(e) = self.enforce_size_limit() {
173                warn!(error = %e, "disk cache size-limit enforcement failed");
174            }
175        }
176    }
177
178    /// Open existing cache or create new one
179    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
180        let config = DiskCacheConfig {
181            path: path.as_ref().to_string_lossy().to_string(),
182            ..Default::default()
183        };
184        Self::new(config)
185    }
186
187    /// Generate cache key from namespace and vector ID
188    fn make_key(namespace: &str, id: &VectorId) -> Vec<u8> {
189        format!("{}:{}", namespace, id).into_bytes()
190    }
191
192    /// Generate namespace prefix for scanning
193    fn namespace_prefix(namespace: &str) -> Vec<u8> {
194        format!("{}:", namespace).into_bytes()
195    }
196
197    /// Insert a vector into the cache
198    pub fn put(&self, namespace: &str, vector: &Vector) -> Result<()> {
199        let key = Self::make_key(namespace, &vector.id);
200        let entry = CacheEntry {
201            vector: vector.clone(),
202            access_count: 1,
203            created_at: std::time::SystemTime::now()
204                .duration_since(std::time::UNIX_EPOCH)
205                .unwrap_or_default()
206                .as_secs(),
207        };
208
209        let value = serde_json::to_vec(&entry)
210            .map_err(|e| DakeraError::Storage(format!("Failed to serialize cache entry: {}", e)))?;
211
212        self.db
213            .put(&key, &value)
214            .map_err(|e| DakeraError::Storage(format!("Failed to write to disk cache: {}", e)))?;
215
216        self.note_writes(1);
217        debug!(namespace = %namespace, id = %vector.id, "Cached vector to disk");
218        Ok(())
219    }
220
221    /// Insert multiple vectors into the cache
222    pub fn put_batch(&self, namespace: &str, vectors: &[Vector]) -> Result<usize> {
223        let mut batch = rocksdb::WriteBatch::default();
224        let now = std::time::SystemTime::now()
225            .duration_since(std::time::UNIX_EPOCH)
226            .unwrap_or_default()
227            .as_secs();
228
229        for vector in vectors {
230            let key = Self::make_key(namespace, &vector.id);
231            let entry = CacheEntry {
232                vector: vector.clone(),
233                access_count: 1,
234                created_at: now,
235            };
236
237            let value = serde_json::to_vec(&entry).map_err(|e| {
238                DakeraError::Storage(format!("Failed to serialize cache entry: {}", e))
239            })?;
240
241            batch.put(&key, &value);
242        }
243
244        let count = vectors.len();
245        self.db.write(batch).map_err(|e| {
246            DakeraError::Storage(format!("Failed to write batch to disk cache: {}", e))
247        })?;
248
249        self.note_writes(count as u64);
250        debug!(namespace = %namespace, count = count, "Batch cached vectors to disk");
251        Ok(count)
252    }
253
254    /// Get a vector from the cache
255    pub fn get(&self, namespace: &str, id: &VectorId) -> Result<Option<Vector>> {
256        let key = Self::make_key(namespace, id);
257
258        match self.db.get(&key) {
259            Ok(Some(value)) => {
260                let entry: CacheEntry = serde_json::from_slice(&value).map_err(|e| {
261                    DakeraError::Storage(format!("Failed to deserialize cache entry: {}", e))
262                })?;
263                Ok(Some(entry.vector))
264            }
265            Ok(None) => Ok(None),
266            Err(e) => {
267                warn!(error = %e, "Failed to read from disk cache");
268                Ok(None)
269            }
270        }
271    }
272
273    /// Get multiple vectors from the cache
274    pub fn get_batch(&self, namespace: &str, ids: &[VectorId]) -> Result<Vec<Vector>> {
275        let keys: Vec<Vec<u8>> = ids.iter().map(|id| Self::make_key(namespace, id)).collect();
276
277        let results = self.db.multi_get(&keys);
278        let mut vectors = Vec::with_capacity(ids.len());
279
280        for result in results {
281            if let Ok(Some(value)) = result {
282                if let Ok(entry) = serde_json::from_slice::<CacheEntry>(&value) {
283                    vectors.push(entry.vector);
284                }
285            }
286        }
287
288        Ok(vectors)
289    }
290
291    /// Get all vectors in a namespace
292    pub fn get_all(&self, namespace: &str) -> Result<Vec<Vector>> {
293        let prefix = Self::namespace_prefix(namespace);
294        let mut vectors = Vec::new();
295
296        let iter = self.db.prefix_iterator(&prefix);
297        for item in iter {
298            match item {
299                Ok((key, value)) => {
300                    // Check if key still starts with prefix (RocksDB iterators can overshoot)
301                    if !key.starts_with(&prefix) {
302                        break;
303                    }
304
305                    if let Ok(entry) = serde_json::from_slice::<CacheEntry>(&value) {
306                        vectors.push(entry.vector);
307                    }
308                }
309                Err(e) => {
310                    warn!(error = %e, "Error iterating disk cache");
311                    break;
312                }
313            }
314        }
315
316        Ok(vectors)
317    }
318
319    /// Delete a vector from the cache
320    pub fn delete(&self, namespace: &str, id: &VectorId) -> Result<bool> {
321        let key = Self::make_key(namespace, id);
322
323        // Check if exists first
324        let existed = self
325            .db
326            .get(&key)
327            .map_err(|e| DakeraError::Storage(format!("Failed to check disk cache: {}", e)))?
328            .is_some();
329
330        if existed {
331            self.db.delete(&key).map_err(|e| {
332                DakeraError::Storage(format!("Failed to delete from disk cache: {}", e))
333            })?;
334        }
335
336        Ok(existed)
337    }
338
339    /// Delete multiple vectors from the cache
340    pub fn delete_batch(&self, namespace: &str, ids: &[VectorId]) -> Result<usize> {
341        let mut batch = rocksdb::WriteBatch::default();
342        let mut count = 0;
343
344        for id in ids {
345            let key = Self::make_key(namespace, id);
346            if self.db.get(&key).ok().flatten().is_some() {
347                batch.delete(&key);
348                count += 1;
349            }
350        }
351
352        self.db.write(batch).map_err(|e| {
353            DakeraError::Storage(format!("Failed to delete batch from disk cache: {}", e))
354        })?;
355
356        Ok(count)
357    }
358
359    /// Clear all entries in a namespace
360    pub fn clear_namespace(&self, namespace: &str) -> Result<usize> {
361        let prefix = Self::namespace_prefix(namespace);
362        let mut batch = rocksdb::WriteBatch::default();
363        let mut count = 0;
364
365        let iter = self.db.prefix_iterator(&prefix);
366        for item in iter {
367            match item {
368                Ok((key, _)) => {
369                    if !key.starts_with(&prefix) {
370                        break;
371                    }
372                    batch.delete(&key);
373                    count += 1;
374                }
375                Err(_) => break,
376            }
377        }
378
379        if count > 0 {
380            self.db.write(batch).map_err(|e| {
381                DakeraError::Storage(format!("Failed to clear namespace from disk cache: {}", e))
382            })?;
383        }
384
385        debug!(namespace = %namespace, count = count, "Cleared namespace from disk cache");
386        Ok(count)
387    }
388
389    /// Get approximate size of the cache in bytes
390    pub fn approximate_size(&self) -> u64 {
391        self.db
392            .property_int_value("rocksdb.estimate-live-data-size")
393            .ok()
394            .flatten()
395            .unwrap_or(0)
396    }
397
398    /// Get cache statistics
399    pub fn stats(&self) -> DiskCacheStats {
400        DiskCacheStats {
401            approximate_size_bytes: self.approximate_size(),
402            approximate_num_keys: self
403                .db
404                .property_int_value("rocksdb.estimate-num-keys")
405                .ok()
406                .flatten()
407                .unwrap_or(0),
408        }
409    }
410
411    /// Flush all pending writes to disk
412    pub fn flush(&self) -> Result<()> {
413        self.db
414            .flush()
415            .map_err(|e| DakeraError::Storage(format!("Failed to flush disk cache: {}", e)))
416    }
417}
418
419/// Statistics for the disk cache
420#[derive(Debug, Clone)]
421pub struct DiskCacheStats {
422    pub approximate_size_bytes: u64,
423    pub approximate_num_keys: u64,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use tempfile::TempDir;
430
431    fn create_test_cache() -> (DiskCache, TempDir) {
432        let temp_dir = TempDir::new().unwrap();
433        let config = DiskCacheConfig {
434            path: temp_dir.path().to_string_lossy().to_string(),
435            ..Default::default()
436        };
437        let cache = DiskCache::new(config).unwrap();
438        (cache, temp_dir)
439    }
440
441    fn test_vector(id: &str) -> Vector {
442        Vector {
443            id: id.to_string(),
444            values: vec![1.0, 2.0, 3.0],
445            metadata: None,
446            ttl_seconds: None,
447            expires_at: None,
448        }
449    }
450
451    #[test]
452    fn test_put_and_get() {
453        let (cache, _dir) = create_test_cache();
454        let namespace = "test";
455        let vector = test_vector("v1");
456
457        cache.put(namespace, &vector).unwrap();
458        let result = cache.get(namespace, &"v1".to_string()).unwrap();
459
460        assert!(result.is_some());
461        let retrieved = result.unwrap();
462        assert_eq!(retrieved.id, "v1");
463        assert_eq!(retrieved.values, vec![1.0, 2.0, 3.0]);
464    }
465
466    #[test]
467    fn test_get_nonexistent() {
468        let (cache, _dir) = create_test_cache();
469        let result = cache.get("test", &"nonexistent".to_string()).unwrap();
470        assert!(result.is_none());
471    }
472
473    // DAK-7428: max_size_bytes must actually bound the cache — previously it was
474    // stored but never enforced, so the L2 cache grew without limit.
475    #[test]
476    fn enforce_size_limit_evicts_when_over_cap() {
477        let temp_dir = TempDir::new().unwrap();
478        let config = DiskCacheConfig {
479            path: temp_dir.path().to_string_lossy().to_string(),
480            max_size_bytes: 2048, // tiny cap so a few hundred entries exceed it
481            ..Default::default()
482        };
483        let cache = DiskCache::new(config).unwrap();
484        let ns = "test";
485        let vectors: Vec<Vector> = (0..200).map(|i| test_vector(&format!("v{i}"))).collect();
486        cache.put_batch(ns, &vectors).unwrap();
487
488        let before = cache.get_all(ns).unwrap().len();
489        let evicted = cache.enforce_size_limit().unwrap();
490        let after = cache.get_all(ns).unwrap().len();
491
492        assert!(evicted > 0, "should evict when over the cap");
493        assert!(after < before, "eviction should reduce entry count");
494    }
495
496    #[test]
497    fn enforce_size_limit_noop_when_unlimited() {
498        let temp_dir = TempDir::new().unwrap();
499        let config = DiskCacheConfig {
500            path: temp_dir.path().to_string_lossy().to_string(),
501            max_size_bytes: 0, // unlimited
502            ..Default::default()
503        };
504        let cache = DiskCache::new(config).unwrap();
505        cache
506            .put_batch("test", &[test_vector("a"), test_vector("b")])
507            .unwrap();
508        assert_eq!(cache.enforce_size_limit().unwrap(), 0);
509        assert_eq!(cache.get_all("test").unwrap().len(), 2);
510    }
511
512    #[test]
513    fn test_batch_operations() {
514        let (cache, _dir) = create_test_cache();
515        let namespace = "test";
516        let vectors = vec![test_vector("v1"), test_vector("v2"), test_vector("v3")];
517
518        let count = cache.put_batch(namespace, &vectors).unwrap();
519        assert_eq!(count, 3);
520
521        let ids: Vec<String> = vec!["v1".to_string(), "v2".to_string(), "v3".to_string()];
522        let retrieved = cache.get_batch(namespace, &ids).unwrap();
523        assert_eq!(retrieved.len(), 3);
524    }
525
526    #[test]
527    fn test_get_all() {
528        let (cache, _dir) = create_test_cache();
529        let namespace = "test";
530        let vectors = vec![test_vector("v1"), test_vector("v2")];
531
532        cache.put_batch(namespace, &vectors).unwrap();
533        let all = cache.get_all(namespace).unwrap();
534        assert_eq!(all.len(), 2);
535    }
536
537    #[test]
538    fn test_delete() {
539        let (cache, _dir) = create_test_cache();
540        let namespace = "test";
541        let vector = test_vector("v1");
542
543        cache.put(namespace, &vector).unwrap();
544        assert!(cache.get(namespace, &"v1".to_string()).unwrap().is_some());
545
546        let deleted = cache.delete(namespace, &"v1".to_string()).unwrap();
547        assert!(deleted);
548
549        assert!(cache.get(namespace, &"v1".to_string()).unwrap().is_none());
550    }
551
552    #[test]
553    fn test_delete_batch() {
554        let (cache, _dir) = create_test_cache();
555        let namespace = "test";
556        let vectors = vec![test_vector("v1"), test_vector("v2"), test_vector("v3")];
557
558        cache.put_batch(namespace, &vectors).unwrap();
559
560        let ids = vec!["v1".to_string(), "v2".to_string()];
561        let deleted = cache.delete_batch(namespace, &ids).unwrap();
562        assert_eq!(deleted, 2);
563
564        assert!(cache.get(namespace, &"v1".to_string()).unwrap().is_none());
565        assert!(cache.get(namespace, &"v2".to_string()).unwrap().is_none());
566        assert!(cache.get(namespace, &"v3".to_string()).unwrap().is_some());
567    }
568
569    #[test]
570    fn test_clear_namespace() {
571        let (cache, _dir) = create_test_cache();
572        let vectors = vec![test_vector("v1"), test_vector("v2")];
573
574        cache.put_batch("ns1", &vectors).unwrap();
575        cache.put_batch("ns2", &vectors).unwrap();
576
577        let cleared = cache.clear_namespace("ns1").unwrap();
578        assert_eq!(cleared, 2);
579
580        assert!(cache.get_all("ns1").unwrap().is_empty());
581        assert_eq!(cache.get_all("ns2").unwrap().len(), 2);
582    }
583
584    #[test]
585    fn test_namespace_isolation() {
586        let (cache, _dir) = create_test_cache();
587        let vector = test_vector("v1");
588
589        cache.put("ns1", &vector).unwrap();
590        cache.put("ns2", &vector).unwrap();
591
592        assert!(cache.get("ns1", &"v1".to_string()).unwrap().is_some());
593        assert!(cache.get("ns2", &"v1".to_string()).unwrap().is_some());
594
595        cache.delete("ns1", &"v1".to_string()).unwrap();
596
597        assert!(cache.get("ns1", &"v1".to_string()).unwrap().is_none());
598        assert!(cache.get("ns2", &"v1".to_string()).unwrap().is_some());
599    }
600
601    #[test]
602    fn test_stats() {
603        let (cache, _dir) = create_test_cache();
604        let vectors = vec![test_vector("v1"), test_vector("v2")];
605
606        cache.put_batch("test", &vectors).unwrap();
607        cache.flush().unwrap();
608
609        let stats = cache.stats();
610        // Stats are approximate, just check they're available
611        let _ = stats.approximate_num_keys;
612    }
613}