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 a sidecar control file
71// at `<l2_path>.blob-cache.ctl` (see
72// `cache/blob/l2.rs::BlobCacheL2::open`). Both files are required for a
73// usable restore — the pager file holds the data, the control file
74// holds the root-page pointer + bytes-in-use.
75//
76// When the operator opts into `include_blob_cache=true` on a backup
77// (`red.config.backup.include_blob_cache`), we upload both files to
78// the configured remote backend under a stable prefix.
79//
80// Shape contract:
81//
82// - Two remote keys: `{prefix}l2.pager` (the pager file) and
83//   `{prefix}l2.ctl` (the control sidecar).
84// - The cache is *derived* state (ADR 0006). On any per-file failure
85//   we surface the error to the caller — `trigger_backup` logs and
86//   proceeds so a partial L2 archive never aborts the rest of the
87//   backup.
88//
89// Restore is the symmetric mirror: download both keys back into
90// `l2_path` (and its `.blob-cache.ctl` sibling). The cold-start
91// synopsis rebuild in `BlobCache::new` then re-indexes the metadata
92// B+ tree (per `cache/blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis`).
93
94const L2_BACKUP_PAGER_SUFFIX: &str = "l2.pager";
95const L2_BACKUP_CONTROL_SUFFIX: &str = "l2.ctl";
96const L2_CONTROL_EXTENSION: &str = "blob-cache.ctl";
97
98fn normalize_prefix(prefix: &str) -> String {
99    if prefix.is_empty() || prefix.ends_with('/') {
100        prefix.to_string()
101    } else {
102        format!("{prefix}/")
103    }
104}
105
106fn control_sidecar_for(l2_path: &std::path::Path) -> std::path::PathBuf {
107    l2_path.with_extension(L2_CONTROL_EXTENSION)
108}
109
110/// Archive the L2 pager file + control sidecar to `backend` under
111/// `{prefix}l2.pager` and `{prefix}l2.ctl`. Returns the number of
112/// files uploaded (0..=2).
113///
114/// Caller (`trigger_backup`) decides what to do on error — the cache is
115/// derived state so a partial upload is logged, not fatal.
116pub fn archive_blob_cache_l2(
117    backend: &dyn crate::storage::backend::RemoteBackend,
118    l2_path: &std::path::Path,
119    prefix: &str,
120) -> Result<usize, crate::storage::backend::BackendError> {
121    let prefix = normalize_prefix(prefix);
122    let mut count = 0usize;
123    if l2_path.is_file() {
124        backend.upload(l2_path, &format!("{prefix}{L2_BACKUP_PAGER_SUFFIX}"))?;
125        count += 1;
126    }
127    let control = control_sidecar_for(l2_path);
128    if control.is_file() {
129        backend.upload(&control, &format!("{prefix}{L2_BACKUP_CONTROL_SUFFIX}"))?;
130        count += 1;
131    }
132    Ok(count)
133}
134
135/// Restore the L2 pager file + control sidecar from `backend`'s
136/// `{prefix}l2.pager` and `{prefix}l2.ctl` keys into `l2_path` (and its
137/// `<l2_path>.blob-cache.ctl` sibling). Returns the number of files
138/// downloaded.
139///
140/// Cold-start synopsis rebuild on next `BlobCache::new` re-indexes the
141/// metadata. Surfaced for the documented restore procedure
142/// (`docs/operations/blob-cache-backup-restore.md` §3); not yet wired
143/// into a programmatic restore endpoint.
144pub fn restore_blob_cache_l2(
145    backend: &dyn crate::storage::backend::RemoteBackend,
146    prefix: &str,
147    l2_path: &std::path::Path,
148) -> Result<usize, crate::storage::backend::BackendError> {
149    let prefix = normalize_prefix(prefix);
150    if let Some(parent) = l2_path.parent() {
151        if !parent.as_os_str().is_empty() {
152            std::fs::create_dir_all(parent)
153                .map_err(|err| crate::storage::backend::BackendError::Transport(err.to_string()))?;
154        }
155    }
156    let mut count = 0usize;
157    if backend.download(&format!("{prefix}{L2_BACKUP_PAGER_SUFFIX}"), l2_path)? {
158        count += 1;
159    }
160    let control = control_sidecar_for(l2_path);
161    if backend.download(&format!("{prefix}{L2_BACKUP_CONTROL_SUFFIX}"), &control)? {
162        count += 1;
163    }
164    Ok(count)
165}
166
167#[cfg(test)]
168mod backup_helpers_tests {
169    use super::*;
170    use crate::storage::backend::LocalBackend;
171    use std::sync::atomic::{AtomicU64, Ordering};
172
173    fn write_file(path: &std::path::Path, bytes: &[u8]) {
174        if let Some(parent) = path.parent() {
175            std::fs::create_dir_all(parent).unwrap();
176        }
177        std::fs::write(path, bytes).unwrap();
178    }
179
180    /// Per-test unique scratch root under the system temp dir.
181    /// The `tempfile` crate is not in dev-deps; we synthesize a
182    /// collision-free path using the test name + a process-local
183    /// monotonic counter, mirroring the convention already used by
184    /// `cache/blob/cache.rs::tests::l2_path`.
185    static SCRATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
186    fn scratch(label: &str) -> std::path::PathBuf {
187        let pid = std::process::id();
188        let n = SCRATCH_COUNTER.fetch_add(1, Ordering::SeqCst);
189        let p = std::env::temp_dir().join(format!("reddb-blobcache-bk-{label}-{pid}-{n}"));
190        let _ = std::fs::remove_dir_all(&p);
191        std::fs::create_dir_all(&p).unwrap();
192        p
193    }
194
195    /// `LocalBackend` interprets keys as on-disk paths verbatim; we
196    /// therefore use absolute paths under tempdirs as the "prefix"
197    /// the test backends see. A real S3/HTTP backend would ignore the
198    /// path prefix and treat the keys as opaque strings — both
199    /// directions of the helper round-trip the same way.
200    #[test]
201    fn archive_then_restore_round_trips_l2_pager_and_control_files() {
202        let scratch_dir = scratch("pair-src");
203        let l2_src = scratch_dir.join("cache.rdb");
204        write_file(&l2_src, b"pager-bytes-on-disk");
205        write_file(&control_sidecar_for(&l2_src), b"control-sidecar-bytes");
206
207        let backend_root = scratch("pair-be");
208        let prefix = format!("{}/blob_cache/", backend_root.display());
209
210        let uploaded =
211            archive_blob_cache_l2(&LocalBackend, &l2_src, &prefix).expect("archive succeeds");
212        assert_eq!(uploaded, 2, "pager + control sidecar uploaded");
213
214        let dst_dir = scratch("pair-dst");
215        let l2_dst = dst_dir.join("cache.rdb");
216        let downloaded =
217            restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("restore succeeds");
218        assert_eq!(downloaded, 2);
219
220        assert_eq!(std::fs::read(&l2_dst).unwrap(), b"pager-bytes-on-disk");
221        assert_eq!(
222            std::fs::read(control_sidecar_for(&l2_dst)).unwrap(),
223            b"control-sidecar-bytes"
224        );
225
226        let _ = std::fs::remove_dir_all(&scratch_dir);
227        let _ = std::fs::remove_dir_all(&backend_root);
228        let _ = std::fs::remove_dir_all(&dst_dir);
229    }
230
231    #[test]
232    fn archive_missing_l2_path_is_noop() {
233        let backend_root = scratch("be-missing");
234        let prefix = format!("{}/blob_cache/", backend_root.display());
235        let count = archive_blob_cache_l2(
236            &LocalBackend,
237            std::path::Path::new("/nonexistent/path/for/reddb-test.rdb"),
238            &prefix,
239        )
240        .expect("missing path treated as nothing to archive");
241        assert_eq!(count, 0);
242        let _ = std::fs::remove_dir_all(&backend_root);
243    }
244
245    #[test]
246    fn restore_with_no_objects_creates_empty_parent_dir() {
247        let backend_root = scratch("be-empty");
248        let prefix = format!("{}/blob_cache/", backend_root.display());
249        let dst_dir = scratch("dst-empty");
250        let l2_dst = dst_dir.join("cache.rdb");
251        let count =
252            restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("empty restore is ok");
253        assert_eq!(count, 0);
254        let _ = std::fs::remove_dir_all(&backend_root);
255        let _ = std::fs::remove_dir_all(&dst_dir);
256    }
257
258    /// End-to-end round-trip through a real `BlobCache`:
259    /// 1. Build a cache with an L2 path, put two entries (so blob bytes
260    ///    plus metadata land on disk).
261    /// 2. Drop the cache (closes the L2 metadata B+ tree).
262    /// 3. Archive the L2 directory to the backend.
263    /// 4. Restore into a fresh L2 path.
264    /// 5. Open a new `BlobCache` against the restored path and verify
265    ///    `get` returns the original bytes — proves the cold-start
266    ///    synopsis rebuild (`blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis`) re-indexes
267    ///    the restored tree end-to-end.
268    ///
269    /// This is the integration-test that ADR 0006 §"backup-restore"
270    /// commits to and that the lane plan calls out as the
271    /// `include_blob_cache=true` round-trip success criterion.
272    #[test]
273    fn full_round_trip_via_blob_cache_preserves_entries_after_restore() {
274        use crate::storage::cache::blob::{BlobCache, BlobCacheConfig, BlobCachePut};
275
276        let src_dir = scratch("rt-src");
277        let dst_dir = scratch("rt-dst");
278        let backend_root = scratch("rt-be");
279        let l2_src = src_dir.join("blob-cache.rdb");
280        let l2_dst = dst_dir.join("blob-cache.rdb");
281        let prefix = format!("{}/blob_cache/", backend_root.display());
282
283        // 1+2: put entries + drop the cache so L2 fsyncs.
284        {
285            let cache = BlobCache::open_with_l2(
286                BlobCacheConfig::default()
287                    .with_l1_bytes_max(64 * 1024)
288                    .with_shard_count(2)
289                    .with_max_namespaces(8)
290                    .with_l2_path(&l2_src),
291            )
292            .expect("open l2 src");
293            cache
294                .put("ns-a", "k1", BlobCachePut::new(b"value-1".to_vec()))
295                .expect("put k1");
296            cache
297                .put(
298                    "ns-b",
299                    "k2",
300                    BlobCachePut::new(b"value-2-longer-payload".to_vec()),
301                )
302                .expect("put k2");
303            // L2 path accessor must see the configured file path.
304            assert_eq!(cache.l2_path(), Some(l2_src.as_path()));
305        } // drop cache
306
307        // 3: archive L2 (pager file + control sidecar).
308        let uploaded = archive_blob_cache_l2(&LocalBackend, &l2_src, &prefix).expect("archive l2");
309        assert_eq!(uploaded, 2, "pager + control uploaded");
310
311        // 4: restore into fresh path.
312        let restored = restore_blob_cache_l2(&LocalBackend, &prefix, &l2_dst).expect("restore l2");
313        assert_eq!(restored, 2, "pager + control downloaded");
314
315        // 5: re-open against restored path and verify entries are
316        //    addressable. The synopsis rebuild fires automatically in
317        //    BlobCache::new (per blob/l2.rs::BlobCacheL2::rebuild_l2_synopsis).
318        let restored_cache = BlobCache::open_with_l2(
319            BlobCacheConfig::default()
320                .with_l1_bytes_max(64 * 1024)
321                .with_shard_count(2)
322                .with_max_namespaces(8)
323                .with_l2_path(&l2_dst),
324        )
325        .expect("open l2 dst");
326        let hit_a = restored_cache
327            .get("ns-a", "k1")
328            .expect("k1 survives restore");
329        assert_eq!(hit_a.value(), b"value-1");
330        let hit_b = restored_cache
331            .get("ns-b", "k2")
332            .expect("k2 survives restore");
333        assert_eq!(hit_b.value(), b"value-2-longer-payload");
334
335        let _ = std::fs::remove_dir_all(&src_dir);
336        let _ = std::fs::remove_dir_all(&dst_dir);
337        let _ = std::fs::remove_dir_all(&backend_root);
338    }
339
340    /// Inverse contract: with `include_blob_cache` left at its default
341    /// (false) — i.e., we simply skip the archive step — restoring into
342    /// a fresh path leaves the cache cold. This is the documented
343    /// default behaviour (`docs/operations/blob-cache-backup-restore.md`
344    /// §1: "Consequences for restore: the cache starts empty").
345    #[test]
346    fn skipped_archive_leaves_restored_cache_cold() {
347        use crate::storage::cache::blob::{BlobCache, BlobCacheConfig, BlobCachePut};
348
349        let src_dir = scratch("cold-src");
350        let dst_dir = scratch("cold-dst");
351        let l2_src = src_dir.join("blob-cache.rdb");
352        let l2_dst = dst_dir.join("blob-cache.rdb");
353
354        {
355            let cache = BlobCache::open_with_l2(BlobCacheConfig::default().with_l2_path(&l2_src))
356                .expect("open l2 src");
357            cache
358                .put("ns", "k", BlobCachePut::new(b"value".to_vec()))
359                .expect("put k");
360        }
361
362        // Note: NO archive step. The fresh L2 path stays empty, mirroring
363        // the default backup posture (include_blob_cache=false).
364        let cold_cache = BlobCache::open_with_l2(BlobCacheConfig::default().with_l2_path(&l2_dst))
365            .expect("open l2 dst");
366        assert!(
367            cold_cache.get("ns", "k").is_none(),
368            "restore without include_blob_cache must yield a cold cache"
369        );
370
371        let _ = std::fs::remove_dir_all(&src_dir);
372        let _ = std::fs::remove_dir_all(&dst_dir);
373    }
374}