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