Skip to main content

reddb_server/storage/cache/
mod.rs

1//! Cache Module
2//!
3//! High-performance caching infrastructure for RedDB.
4//!
5//! # Components
6//!
7//! - **sieve**: SIEVE page cache for database pages (O(1) operations)
8//! - **blob**: Byte-oriented L1 cache for exact-key cached blobs
9//! - **result**: Query result cache with dependency-based invalidation
10//! - **aggregates**: Precomputed aggregations (COUNT, SUM, AVG, etc.)
11//! - **spill**: Graph spill-to-disk for memory-limited environments
12//!
13//! # Architecture (inspired by Turso/Milvus/Neo4j)
14//!
15//! ```text
16//! ┌────────────────────────────────────────────────────────┐
17//! │                    Query Layer                         │
18//! ├────────────────────────────────────────────────────────┤
19//! │  Result Cache   │  Materialized Views  │  Plan Cache   │
20//! ├────────────────────────────────────────────────────────┤
21//! │           Aggregation Cache (COUNT/SUM/AVG)            │
22//! ├────────────────────────────────────────────────────────┤
23//! │   SIEVE Page Cache    │     Spill Manager              │
24//! ├────────────────────────────────────────────────────────┤
25//! │                   Storage Engine                       │
26//! └────────────────────────────────────────────────────────┘
27//! ```
28
29pub mod aggregates;
30pub mod bgwriter;
31pub mod blob;
32pub mod compressor;
33pub mod extended_ttl;
34pub mod promotion_pool;
35pub mod result;
36pub mod ring;
37pub mod sieve;
38pub mod spill;
39pub mod strategy;
40pub mod sweeper;
41
42pub use aggregates::{AggCacheStats, AggValue, AggregationCache, CardinalityEstimate, NumericAgg};
43pub use blob::{
44    BlobCache, BlobCacheConfig, BlobCacheHit, BlobCachePolicy, BlobCachePut, BlobCacheStats,
45    CacheError, L1Admission, L2Compression, DEFAULT_BLOB_L1_BYTES_MAX, DEFAULT_BLOB_L2_BYTES_MAX,
46    DEFAULT_BLOB_MAX_NAMESPACES, METRIC_CACHE_BLOB_L1_BYTES_IN_USE,
47    METRIC_CACHE_BLOB_L2_BYTES_IN_USE, METRIC_CACHE_BLOB_L2_FULL_REJECTIONS_TOTAL,
48    METRIC_CACHE_VERSION_MISMATCH_TOTAL,
49};
50pub use compressor::{CompressError, CompressOpts, Compressed, L2BlobCompressor};
51pub use extended_ttl::{EffectiveExpiry, ExpiryDecision, ExtendedTtlPolicy};
52pub use promotion_pool::{
53    AsyncPromotionPool, PoolOpts, PromotionExecutor, PromotionMetrics, PromotionRequest,
54    ScheduleOutcome,
55};
56pub use result::{
57    CacheKey, CachePolicy, MaterializedViewCache, MaterializedViewDef, RefreshPolicy, ResultCache,
58    ResultCacheStats,
59};
60pub use ring::BufferRing;
61pub use sieve::{CacheConfig, CacheStats, PageCache, PageId};
62pub use spill::{SpillConfig, SpillError, SpillManager, SpillStats, SpillableGraph};
63pub use strategy::BufferAccessStrategy;
64
65// ---------------------------------------------------------------------------
66// L2 Blob Cache backup helpers (issue #148 follow-up)
67// ---------------------------------------------------------------------------
68//
69// `BlobCache` writes the L2 metadata B+ tree and blob chains into a
70// single pager file at `cache.blob.l2_path` plus the reddb-file control
71// sidecar. Both files are required for a usable restore — the pager file
72// holds the data, the control file holds the root-page pointer +
73// bytes-in-use.
74//
75// When the operator opts into `include_blob_cache=true` on a backup
76// (`red.config.backup.include_blob_cache`), we upload both files to
77// the configured remote backend under a stable prefix.
78//
79// Shape contract:
80//
81// - Two remote keys derived by `reddb-file`: one for the pager file and one
82//   for the control sidecar.
83// - The cache is *derived* state (ADR 0006). On any per-file failure
84//   we surface the error to the caller — `trigger_backup` logs and
85//   proceeds so a partial L2 archive never aborts the rest of the
86//   backup.
87//
88// Restore is the symmetric mirror: download both keys back into
89// `l2_path` and its reddb-file control sidecar. The cold-start synopsis
90// rebuild in `BlobCache::new` then re-indexes the metadata B+ tree (per
91// `cache/blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis`).
92
93/// Archive the L2 pager file + control sidecar to `backend`. Returns
94/// the number of files uploaded (0..=2).
95///
96/// Caller (`trigger_backup`) decides what to do on error — the cache is
97/// derived state so a partial upload is logged, not fatal.
98pub fn archive_blob_cache_l2(
99    backend: &dyn crate::storage::backend::RemoteBackend,
100    l2_path: &std::path::Path,
101    prefix: &str,
102) -> Result<usize, crate::storage::backend::BackendError> {
103    let mut count = 0usize;
104    if l2_path.is_file() {
105        backend.upload(l2_path, &reddb_file::blob_cache_l2_backup_pager_key(prefix))?;
106        count += 1;
107    }
108    let control = reddb_file::blob_cache_control_path(l2_path);
109    if control.is_file() {
110        backend.upload(
111            &control,
112            &reddb_file::blob_cache_l2_backup_control_key(prefix),
113        )?;
114        count += 1;
115    }
116    Ok(count)
117}
118
119/// Restore the L2 pager file + control sidecar from `backend` into
120/// `l2_path` and its reddb-file control sidecar. Returns the number of
121/// files downloaded.
122///
123/// Cold-start synopsis rebuild on next `BlobCache::new` re-indexes the
124/// metadata. Surfaced for the documented restore procedure
125/// (`docs/operations/blob-cache-backup-restore.md` §3); not yet wired
126/// into a programmatic restore endpoint.
127pub fn restore_blob_cache_l2(
128    backend: &dyn crate::storage::backend::RemoteBackend,
129    prefix: &str,
130    l2_path: &std::path::Path,
131) -> Result<usize, crate::storage::backend::BackendError> {
132    if let Some(parent) = l2_path.parent() {
133        if !parent.as_os_str().is_empty() {
134            std::fs::create_dir_all(parent)
135                .map_err(|err| crate::storage::backend::BackendError::Transport(err.to_string()))?;
136        }
137    }
138    let mut count = 0usize;
139    if backend.download(&reddb_file::blob_cache_l2_backup_pager_key(prefix), l2_path)? {
140        count += 1;
141    }
142    let control = reddb_file::blob_cache_control_path(l2_path);
143    if backend.download(
144        &reddb_file::blob_cache_l2_backup_control_key(prefix),
145        &control,
146    )? {
147        count += 1;
148    }
149    Ok(count)
150}
151
152#[cfg(test)]
153mod backup_helpers_tests {
154    use super::*;
155    use crate::storage::backend::LocalBackend;
156    use std::sync::atomic::{AtomicU64, Ordering};
157
158    fn write_file(path: &std::path::Path, bytes: &[u8]) {
159        if let Some(parent) = path.parent() {
160            std::fs::create_dir_all(parent).unwrap();
161        }
162        std::fs::write(path, bytes).unwrap();
163    }
164
165    /// Per-test unique scratch root under the system temp dir.
166    /// The `tempfile` crate is not in dev-deps; we synthesize a
167    /// collision-free path using the test name + a process-local
168    /// monotonic counter, mirroring the convention already used by
169    /// `cache/blob/cache.rs::tests::l2_path`.
170    static SCRATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
171    fn scratch(label: &str) -> std::path::PathBuf {
172        let pid = std::process::id();
173        let n = SCRATCH_COUNTER.fetch_add(1, Ordering::SeqCst);
174        let p = std::env::temp_dir().join(format!("reddb-blobcache-bk-{label}-{pid}-{n}"));
175        let _ = std::fs::remove_dir_all(&p);
176        std::fs::create_dir_all(&p).unwrap();
177        p
178    }
179
180    /// `LocalBackend` interprets keys as on-disk paths verbatim; we
181    /// therefore use absolute paths under tempdirs as the "prefix"
182    /// the test backends see. A real S3/HTTP backend would ignore the
183    /// path prefix and treat the keys as opaque strings — both
184    /// directions of the helper round-trip the same way.
185    #[test]
186    fn archive_then_restore_round_trips_l2_pager_and_control_files() {
187        let scratch_dir = scratch("pair-src");
188        let l2_src = scratch_dir.join("cache.rdb");
189        write_file(&l2_src, b"pager-bytes-on-disk");
190        write_file(
191            &reddb_file::blob_cache_control_path(&l2_src),
192            b"control-sidecar-bytes",
193        );
194
195        let backend_root = scratch("pair-be");
196        let prefix = format!("{}/blob_cache/", backend_root.display());
197
198        let uploaded =
199            archive_blob_cache_l2(&LocalBackend, &l2_src, &prefix).expect("archive succeeds");
200        assert_eq!(uploaded, 2, "pager + control sidecar uploaded");
201
202        let dst_dir = scratch("pair-dst");
203        let l2_dst = dst_dir.join("cache.rdb");
204        let downloaded =
205            restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("restore succeeds");
206        assert_eq!(downloaded, 2);
207
208        assert_eq!(std::fs::read(&l2_dst).unwrap(), b"pager-bytes-on-disk");
209        assert_eq!(
210            std::fs::read(reddb_file::blob_cache_control_path(&l2_dst)).unwrap(),
211            b"control-sidecar-bytes"
212        );
213
214        let _ = std::fs::remove_dir_all(&scratch_dir);
215        let _ = std::fs::remove_dir_all(&backend_root);
216        let _ = std::fs::remove_dir_all(&dst_dir);
217    }
218
219    #[test]
220    fn archive_missing_l2_path_is_noop() {
221        let backend_root = scratch("be-missing");
222        let prefix = format!("{}/blob_cache/", backend_root.display());
223        let count = archive_blob_cache_l2(
224            &LocalBackend,
225            std::path::Path::new("/nonexistent/path/for/reddb-test.rdb"),
226            &prefix,
227        )
228        .expect("missing path treated as nothing to archive");
229        assert_eq!(count, 0);
230        let _ = std::fs::remove_dir_all(&backend_root);
231    }
232
233    #[test]
234    fn restore_with_no_objects_creates_empty_parent_dir() {
235        let backend_root = scratch("be-empty");
236        let prefix = format!("{}/blob_cache/", backend_root.display());
237        let dst_dir = scratch("dst-empty");
238        let l2_dst = dst_dir.join("cache.rdb");
239        let count =
240            restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("empty restore is ok");
241        assert_eq!(count, 0);
242        let _ = std::fs::remove_dir_all(&backend_root);
243        let _ = std::fs::remove_dir_all(&dst_dir);
244    }
245
246    /// End-to-end round-trip through a real `BlobCache`:
247    /// 1. Build a cache with an L2 path, put two entries (so blob bytes
248    ///    plus metadata land on disk).
249    /// 2. Drop the cache (closes the L2 metadata B+ tree).
250    /// 3. Archive the L2 directory to the backend.
251    /// 4. Restore into a fresh L2 path.
252    /// 5. Open a new `BlobCache` against the restored path and verify
253    ///    `get` returns the original bytes — proves the cold-start
254    ///    synopsis rebuild (`blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis`) re-indexes
255    ///    the restored tree end-to-end.
256    ///
257    /// This is the integration-test that ADR 0006 §"backup-restore"
258    /// commits to and that the lane plan calls out as the
259    /// `include_blob_cache=true` round-trip success criterion.
260    #[test]
261    fn full_round_trip_via_blob_cache_preserves_entries_after_restore() {
262        use crate::storage::cache::blob::{BlobCache, BlobCacheConfig, BlobCachePut};
263
264        let src_dir = scratch("rt-src");
265        let dst_dir = scratch("rt-dst");
266        let backend_root = scratch("rt-be");
267        let l2_src = src_dir.join("blob-cache.rdb");
268        let l2_dst = dst_dir.join("blob-cache.rdb");
269        let prefix = format!("{}/blob_cache/", backend_root.display());
270
271        // 1+2: put entries + drop the cache so L2 fsyncs.
272        {
273            let cache = BlobCache::open_with_l2(
274                BlobCacheConfig::default()
275                    .with_l1_bytes_max(64 * 1024)
276                    .with_shard_count(2)
277                    .with_max_namespaces(8)
278                    .with_l2_path(&l2_src),
279            )
280            .expect("open l2 src");
281            cache
282                .put("ns-a", "k1", BlobCachePut::new(b"value-1".to_vec()))
283                .expect("put k1");
284            cache
285                .put(
286                    "ns-b",
287                    "k2",
288                    BlobCachePut::new(b"value-2-longer-payload".to_vec()),
289                )
290                .expect("put k2");
291            // L2 path accessor must see the configured file path.
292            assert_eq!(cache.l2_path(), Some(l2_src.as_path()));
293        } // drop cache
294
295        // 3: archive L2 (pager file + control sidecar).
296        let uploaded = archive_blob_cache_l2(&LocalBackend, &l2_src, &prefix).expect("archive l2");
297        assert_eq!(uploaded, 2, "pager + control uploaded");
298
299        // 4: restore into fresh path.
300        let restored = restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("restore l2");
301        assert_eq!(restored, 2, "pager + control downloaded");
302
303        // 5: re-open against restored path and verify entries are
304        //    addressable. The synopsis rebuild fires automatically in
305        //    BlobCache::new (per blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis).
306        let restored_cache = BlobCache::open_with_l2(
307            BlobCacheConfig::default()
308                .with_l1_bytes_max(64 * 1024)
309                .with_shard_count(2)
310                .with_max_namespaces(8)
311                .with_l2_path(&l2_dst),
312        )
313        .expect("open l2 dst");
314        let hit_a = restored_cache
315            .get("ns-a", "k1")
316            .expect("k1 survives restore");
317        assert_eq!(hit_a.value(), b"value-1");
318        let hit_b = restored_cache
319            .get("ns-b", "k2")
320            .expect("k2 survives restore");
321        assert_eq!(hit_b.value(), b"value-2-longer-payload");
322
323        let _ = std::fs::remove_dir_all(&src_dir);
324        let _ = std::fs::remove_dir_all(&dst_dir);
325        let _ = std::fs::remove_dir_all(&backend_root);
326    }
327
328    /// Inverse contract: with `include_blob_cache` left at its default
329    /// (false) — i.e., we simply skip the archive step — restoring into
330    /// a fresh path leaves the cache cold. This is the documented
331    /// default behaviour (`docs/operations/blob-cache-backup-restore.md`
332    /// §1: "Consequences for restore: the cache starts empty").
333    #[test]
334    fn skipped_archive_leaves_restored_cache_cold() {
335        use crate::storage::cache::blob::{BlobCache, BlobCacheConfig, BlobCachePut};
336
337        let src_dir = scratch("cold-src");
338        let dst_dir = scratch("cold-dst");
339        let l2_src = src_dir.join("blob-cache.rdb");
340        let l2_dst = dst_dir.join("blob-cache.rdb");
341
342        {
343            let cache = BlobCache::open_with_l2(BlobCacheConfig::default().with_l2_path(&l2_src))
344                .expect("open l2 src");
345            cache
346                .put("ns", "k", BlobCachePut::new(b"value".to_vec()))
347                .expect("put k");
348        }
349
350        // Note: NO archive step. The fresh L2 path stays empty, mirroring
351        // the default backup posture (include_blob_cache=false).
352        let cold_cache = BlobCache::open_with_l2(BlobCacheConfig::default().with_l2_path(&l2_dst))
353            .expect("open l2 dst");
354        assert!(
355            cold_cache.get("ns", "k").is_none(),
356            "restore without include_blob_cache must yield a cold cache"
357        );
358
359        let _ = std::fs::remove_dir_all(&src_dir);
360        let _ = std::fs::remove_dir_all(&dst_dir);
361    }
362}