Skip to main content

camel_core/cache/
redb.rs

1//! Redb-backed persistent cache repository.
2//!
3//! Mirrors `idempotent/redb_repository.rs`: all redb I/O is offloaded to
4//! `tokio::task::spawn_blocking` because `redb::Database` is blocking. Values
5//! are `serde_json`-serialized [`CacheEntry`] blobs.
6//!
7//! # Sweep
8//!
9//! A background task wakes every `sweep_interval` and reclaims entries whose
10//! `expires_at + stale_retention < now`. It is bound to the context's
11//! [`CancellationToken`]: when the context shuts down, the sweep exits. The
12//! token is context-owned — dropping this repository aborts only the sweep
13//! task handle, never the token (cancelling it would tear down the whole
14//! context).
15
16use std::fmt;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::sync::atomic::AtomicU64;
20use std::sync::atomic::Ordering;
21use std::time::Duration;
22use std::time::SystemTime;
23
24use async_trait::async_trait;
25use camel_api::CamelError;
26use camel_api::cache::CacheEntry;
27use camel_api::cache::CacheRepository;
28use camel_api::cache::CacheStats;
29use parking_lot::Mutex;
30use redb::ReadableDatabase;
31use redb::ReadableTable;
32use redb::ReadableTableMetadata;
33use redb::TableDefinition;
34use tokio::task::JoinHandle;
35use tokio_util::sync::CancellationToken;
36
37// ── Table definition ──────────────────────────────────────────────────────────
38
39/// `key → serde_json(CacheEntry)`. Mirrors the table-definition style of
40/// `idempotent/redb_repository.rs`.
41const CACHE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("cache_entries");
42
43// ── Repository ────────────────────────────────────────────────────────────────
44
45/// Redb-backed implementation of [`CacheRepository`].
46///
47/// All counters are `Arc<AtomicU64>` so the spawned sweep task can update
48/// them via cloned references. `sweep_interval` is consumed by the
49/// constructor to build the ticker and is not stored.
50pub struct RedbCacheRepository {
51    name: String,
52    db: Arc<redb::Database>,
53    stale_retention: Duration,
54    max_entries: Option<usize>,
55    hits: Arc<AtomicU64>,
56    misses: Arc<AtomicU64>,
57    evictions: Arc<AtomicU64>,
58    /// Best-effort approximation of `table.len()` for stats display.
59    /// The authoritative count is `table.len()` inside write transactions,
60    /// used for capacity enforcement. Sweep and invalidate decrement via
61    /// saturating-fetch-sub to prevent underflow.
62    entries: Arc<AtomicU64>,
63    /// Context-owned shutdown token. Cloned into the sweep task; never
64    /// cancelled by this repository (doing so would shut down the entire
65    /// context when a single repo is dropped).
66    shutdown_token: CancellationToken,
67    sweep_handle: Mutex<Option<JoinHandle<()>>>,
68}
69
70impl RedbCacheRepository {
71    /// Open (or create) the redb database at `path`, seed the `entries`
72    /// counter from `table.len()`, and spawn the background sweep task.
73    ///
74    /// `shutdown_token` is the **context's** token — binding the sweep to
75    /// context shutdown. The whole open sequence runs in
76    /// `spawn_blocking` because `redb::Database::create` is blocking.
77    #[allow(clippy::too_many_arguments)]
78    pub async fn new(
79        name: impl Into<String>,
80        path: impl Into<PathBuf>,
81        stale_retention: Duration,
82        max_entries: Option<usize>,
83        sweep_interval: Duration,
84        shutdown_token: CancellationToken,
85    ) -> Result<Self, CamelError> {
86        let name = name.into();
87        let path: PathBuf = path.into();
88        let path_for_db = path.clone();
89        let (db, initial_len) = tokio::task::spawn_blocking(move || {
90            if let Some(parent) = path_for_db.parent() {
91                std::fs::create_dir_all(parent)
92                    .map_err(|e| CamelError::Io(format!("redb create_dir_all: {e}")))?;
93            }
94            let db = redb::Database::create(&path_for_db)
95                .map_err(|e| CamelError::Io(format!("redb open: {e}")))?;
96            // Create the table on first open AND read the persisted entry
97            // count in a single write txn so the counter survives reopen.
98            let len = {
99                let wtx = db
100                    .begin_write()
101                    .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
102                // `table` borrows `wtx` mutably, so it must drop before
103                // `wtx.commit()` (which moves `wtx`). Read `len` then drop.
104                let len = {
105                    let table = wtx
106                        .open_table(CACHE_TABLE)
107                        .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
108                    table
109                        .len()
110                        .map_err(|e| CamelError::Io(format!("redb len: {e}")))?
111                };
112                wtx.commit()
113                    .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
114                len
115            };
116            Ok::<_, CamelError>((Arc::new(db), len))
117        })
118        .await
119        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
120
121        let hits = Arc::new(AtomicU64::new(0));
122        let misses = Arc::new(AtomicU64::new(0));
123        let evictions = Arc::new(AtomicU64::new(0));
124        let entries = Arc::new(AtomicU64::new(initial_len));
125
126        // Spawn the sweep loop. All shared state is captured as cloned Arcs;
127        // the context token clone drives termination.
128        let db_clone = Arc::clone(&db);
129        let evictions_clone = Arc::clone(&evictions);
130        let entries_clone = Arc::clone(&entries);
131        let token_clone = shutdown_token.clone();
132        let retention = stale_retention;
133        let handle = tokio::spawn(async move {
134            let mut ticker = tokio::time::interval(sweep_interval);
135            loop {
136                tokio::select! {
137                    _ = ticker.tick() => {
138                        let db = Arc::clone(&db_clone);
139                        let reclaimed = tokio::task::spawn_blocking(move || {
140                            sweep_reclaim(&db, retention).unwrap_or(0)
141                        })
142                        .await
143                        .unwrap_or(0);
144                        evictions_clone.fetch_add(reclaimed, Ordering::Relaxed);
145                        let current = entries_clone.load(Ordering::Relaxed);
146                        let sub = std::cmp::min(current, reclaimed);
147                        entries_clone.fetch_sub(sub, Ordering::Relaxed);
148                    }
149                    _ = token_clone.cancelled() => break,
150                }
151            }
152        });
153
154        Ok(Self {
155            name,
156            db,
157            stale_retention,
158            max_entries,
159            hits,
160            misses,
161            evictions,
162            entries,
163            shutdown_token,
164            sweep_handle: Mutex::new(Some(handle)),
165        })
166    }
167
168    /// Run a single reclamation pass and return the number of entries
169    /// reclaimed. Tests call this directly for deterministic sweep coverage
170    /// without waiting on the background ticker.
171    #[cfg(test)]
172    pub(crate) async fn sweep_once(&self) -> Result<u64, CamelError> {
173        let db = Arc::clone(&self.db);
174        let retention = self.stale_retention;
175        let reclaimed = tokio::task::spawn_blocking(move || sweep_reclaim(&db, retention))
176            .await
177            .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
178        self.evictions.fetch_add(reclaimed, Ordering::Relaxed);
179        let current = self.entries.load(Ordering::Relaxed);
180        let sub = std::cmp::min(current, reclaimed);
181        self.entries.fetch_sub(sub, Ordering::Relaxed);
182        Ok(reclaimed)
183    }
184}
185
186/// Reclaim entries whose `expires_at + stale_retention < now`.
187///
188/// Entries with `expires_at = None` (no expiry) are never reclaimed. Returns
189/// the reclaimed count. Used by both the background sweep loop (errors mapped
190/// to `0`) and [`RedbCacheRepository::sweep_once`] (errors propagated).
191fn sweep_reclaim(db: &redb::Database, stale_retention: Duration) -> Result<u64, CamelError> {
192    let txn = db
193        .begin_write()
194        .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
195    let reclaimed = {
196        let mut table = txn
197            .open_table(CACHE_TABLE)
198            .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
199        let now = SystemTime::now();
200        // Collect keys first — `table.iter()` borrows the table immutably and
201        // would conflict with `remove` (mirrors `idempotent/redb_repository`
202        // clear pattern).
203        let mut to_delete: Vec<String> = Vec::new();
204        for row in table
205            .iter()
206            .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
207        {
208            let (k, v) = row.map_err(|e| CamelError::Io(format!("redb iter item: {e}")))?;
209            let entry: CacheEntry = serde_json::from_slice(v.value())
210                .map_err(|e| CamelError::Io(format!("cache deserialization: {e}")))?;
211            let should_delete = match entry.expires_at {
212                Some(exp) => match exp.checked_add(stale_retention) {
213                    // Threshold in the past → past stale-retention window.
214                    Some(threshold) => threshold < now,
215                    // SystemTime addition overflowed → treat as reclaimable.
216                    None => true,
217                },
218                None => false,
219            };
220            if should_delete {
221                to_delete.push(k.value().to_string());
222            }
223        }
224        for k in &to_delete {
225            let _ = table
226                .remove(k.as_str())
227                .map_err(|e| CamelError::Io(format!("redb remove: {e}")))?;
228        }
229        to_delete.len() as u64
230    };
231    txn.commit()
232        .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
233    Ok(reclaimed)
234}
235
236// ── CacheRepository impl ──────────────────────────────────────────────────────
237
238#[async_trait]
239impl CacheRepository for RedbCacheRepository {
240    fn name(&self) -> &str {
241        &self.name
242    }
243
244    async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
245        let db = Arc::clone(&self.db);
246        let key = key.to_string();
247        let result =
248            tokio::task::spawn_blocking(move || -> Result<Option<CacheEntry>, CamelError> {
249                let rtx = db
250                    .begin_read()
251                    .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
252                let table = rtx
253                    .open_table(CACHE_TABLE)
254                    .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
255                match table
256                    .get(key.as_str())
257                    .map_err(|e| CamelError::Io(format!("redb get: {e}")))?
258                {
259                    Some(guard) => {
260                        let entry: CacheEntry = serde_json::from_slice(guard.value())
261                            .map_err(|e| CamelError::Io(format!("cache deserialization: {e}")))?;
262                        Ok(Some(entry))
263                    }
264                    None => Ok(None),
265                }
266            })
267            .await
268            .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
269        // Expiry check happens outside the blocking closure, mirroring
270        // `MemoryCacheRepository::get` semantics.
271        match result {
272            Some(entry) => {
273                let expired = entry
274                    .expires_at
275                    .map(|e| e <= SystemTime::now())
276                    .unwrap_or(false);
277                if expired {
278                    self.misses.fetch_add(1, Ordering::Relaxed);
279                    Ok(None)
280                } else {
281                    self.hits.fetch_add(1, Ordering::Relaxed);
282                    Ok(Some(entry))
283                }
284            }
285            None => {
286                self.misses.fetch_add(1, Ordering::Relaxed);
287                Ok(None)
288            }
289        }
290    }
291
292    async fn set(
293        &self,
294        key: &str,
295        mut value: CacheEntry,
296        ttl: Option<Duration>,
297    ) -> Result<(), CamelError> {
298        value.expires_at = ttl.map(|d| SystemTime::now() + d);
299        let serialized = serde_json::to_vec(&value)
300            .map_err(|e| CamelError::Io(format!("cache serialization: {e}")))?;
301        let db = Arc::clone(&self.db);
302        let key = key.to_string();
303        let max_entries = self.max_entries;
304        let was_new = tokio::task::spawn_blocking(move || {
305            let txn = db
306                .begin_write()
307                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
308            let was_new = {
309                let mut table = txn
310                    .open_table(CACHE_TABLE)
311                    .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
312                // Scope `prior` so its immutable borrow of `table` ends before
313                // the mutable `insert` below (redb guards borrow the table).
314                let is_new = {
315                    let prior = table
316                        .get(key.as_str())
317                        .map_err(|e| CamelError::Io(format!("redb get: {e}")))?;
318                    // Capacity check only applies to genuinely new keys —
319                    // overwrites don't grow the table.
320                    if prior.is_none()
321                        && let Some(max) = max_entries
322                    {
323                        let count = table
324                            .len()
325                            .map_err(|e| CamelError::Io(format!("redb len: {e}")))?
326                            as usize;
327                        if count >= max {
328                            return Err(CamelError::Config(format!(
329                                "cache: max_entries ({max}) exceeded"
330                            )));
331                        }
332                    }
333                    prior.is_none()
334                };
335                table
336                    .insert(key.as_str(), serialized.as_slice())
337                    .map_err(|e| CamelError::Io(format!("redb insert: {e}")))?;
338                is_new
339            };
340            txn.commit()
341                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
342            Ok::<bool, CamelError>(was_new)
343        })
344        .await
345        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
346        if was_new {
347            self.entries.fetch_add(1, Ordering::Relaxed);
348        }
349        Ok(())
350    }
351
352    async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
353        let db = Arc::clone(&self.db);
354        let key = key.to_string();
355        tokio::task::spawn_blocking(move || {
356            let rtx = db
357                .begin_read()
358                .map_err(|e| CamelError::Io(format!("redb begin_read: {e}")))?;
359            let table = rtx
360                .open_table(CACHE_TABLE)
361                .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
362            match table
363                .get(key.as_str())
364                .map_err(|e| CamelError::Io(format!("redb get: {e}")))?
365            {
366                Some(guard) => {
367                    let entry: CacheEntry = serde_json::from_slice(guard.value())
368                        .map_err(|e| CamelError::Io(format!("cache deserialization: {e}")))?;
369                    Ok(Some(entry))
370                }
371                None => Ok(None),
372            }
373        })
374        .await
375        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))?
376    }
377
378    async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
379        let db = Arc::clone(&self.db);
380        let key = key.to_string();
381        let was_present = tokio::task::spawn_blocking(move || {
382            let txn = db
383                .begin_write()
384                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
385            let was_present = {
386                let mut table = txn
387                    .open_table(CACHE_TABLE)
388                    .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
389                // `remove` is idempotent per the trait contract; the returned
390                // Option tells us whether a value was actually present. Read
391                // `.is_some()` now so the guard (borrowing `table`) drops
392                // before `commit` moves `txn`.
393                table
394                    .remove(key.as_str())
395                    .map_err(|e| CamelError::Io(format!("redb remove: {e}")))?
396                    .is_some()
397            };
398            txn.commit()
399                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
400            Ok::<bool, CamelError>(was_present)
401        })
402        .await
403        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
404        if was_present {
405            let current = self.entries.load(Ordering::Relaxed);
406            let sub = std::cmp::min(current, 1);
407            self.entries.fetch_sub(sub, Ordering::Relaxed);
408        }
409        Ok(())
410    }
411
412    async fn clear(&self) -> Result<(), CamelError> {
413        let db = Arc::clone(&self.db);
414        tokio::task::spawn_blocking(move || {
415            let txn = db
416                .begin_write()
417                .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
418            {
419                let mut table = txn
420                    .open_table(CACHE_TABLE)
421                    .map_err(|e| CamelError::Io(format!("redb open_table: {e}")))?;
422                // Collect-then-remove: `iter()` borrows immutably and cannot
423                // coexist with `remove`. Mirrors `idempotent/redb_repository`
424                // clear pattern.
425                let keys: Vec<String> = table
426                    .iter()
427                    .map_err(|e| CamelError::Io(format!("redb iter: {e}")))?
428                    .map(|r| {
429                        r.map(|(k, _v)| k.value().to_string())
430                            .map_err(|e| CamelError::Io(format!("redb iter item: {e}")))
431                    })
432                    .collect::<Result<_, _>>()?;
433                for k in &keys {
434                    let _ = table
435                        .remove(k.as_str())
436                        .map_err(|e| CamelError::Io(format!("redb remove: {e}")))?;
437                }
438            }
439            txn.commit()
440                .map_err(|e| CamelError::Io(format!("redb commit: {e}")))?;
441            Ok::<_, CamelError>(())
442        })
443        .await
444        .map_err(|e| CamelError::Io(format!("spawn_blocking join: {e}")))??;
445        self.entries.store(0, Ordering::Relaxed);
446        Ok(())
447    }
448
449    fn stats(&self) -> CacheStats {
450        CacheStats {
451            hits: self.hits.load(Ordering::Relaxed),
452            misses: self.misses.load(Ordering::Relaxed),
453            evictions: self.evictions.load(Ordering::Relaxed),
454            entries: self.entries.load(Ordering::Relaxed),
455        }
456    }
457}
458
459impl fmt::Debug for RedbCacheRepository {
460    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461        f.debug_struct("RedbCacheRepository")
462            .field("name", &self.name)
463            .field("stale_retention", &self.stale_retention)
464            .field("max_entries", &self.max_entries)
465            .field("shutdown_cancelled", &self.shutdown_token.is_cancelled())
466            .field("stats", &self.stats())
467            .finish()
468    }
469}
470
471impl Drop for RedbCacheRepository {
472    fn drop(&mut self) {
473        // Abort ONLY the sweep task. Never cancel the context-owned token —
474        // that would shut down the entire context when one repo drops.
475        if let Some(handle) = self.sweep_handle.lock().take() {
476            handle.abort();
477        }
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use tempfile::TempDir;
485    use tempfile::tempdir;
486
487    fn entry() -> CacheEntry {
488        CacheEntry {
489            bytes: vec![1, 2, 3],
490            content_type: camel_api::cache::ContentType::Bytes,
491            expires_at: None,
492        }
493    }
494
495    /// Open a repo at `<tmp>/cache.redb` with a 60s stale-retention, no cap,
496    /// and a 1h sweep interval (so the background loop stays dormant during
497    /// sequential tests).
498    async fn new_repo(tmp: &TempDir, shutdown_token: CancellationToken) -> RedbCacheRepository {
499        let path = tmp.path().join("cache.redb");
500        RedbCacheRepository::new(
501            "redb",
502            path,
503            Duration::from_secs(60),
504            None,
505            Duration::from_secs(3600),
506            shutdown_token,
507        )
508        .await
509        .expect("open redb cache repo")
510    }
511
512    #[tokio::test]
513    async fn entries_survive_handle_drop_and_reopen() {
514        let dir = tempdir().expect("tempdir");
515        let path = dir.path().join("cache.redb");
516        let token = CancellationToken::new();
517        // Keep a clone so the sweep can be stopped via the real shutdown path
518        // (token cancellation) before reopening the same file. redb rejects
519        // reopening a file whose in-process handle is still alive, and the
520        // sweep task holds a cloned Arc<Database> until it exits.
521        let token_for_shutdown = token.clone();
522        {
523            let repo = RedbCacheRepository::new(
524                "redb",
525                path.clone(),
526                Duration::from_secs(60),
527                None,
528                Duration::from_secs(3600),
529                token,
530            )
531            .await
532            .expect("open repo");
533            repo.set("k", entry(), Some(Duration::from_secs(3600)))
534                .await
535                .expect("set");
536            assert_eq!(
537                repo.stats().entries,
538                1,
539                "entries counter must be 1 after first insert"
540            );
541            // Stop the sweep via its bound token and await the task so its
542            // cloned Arc<Database> releases before the repo drops. Bind the
543            // handle separately so the MutexGuard drops before the `.await`
544            // (clippy::await-holding-lock).
545            token_for_shutdown.cancel();
546            let sweep_handle = repo.sweep_handle.lock().take();
547            if let Some(handle) = sweep_handle {
548                handle
549                    .await
550                    .expect("sweep task must exit cleanly on token cancel");
551            }
552            // repo dropped here — handle already taken; self.db Arc → 0 → DB
553            // closes → redb frees its in-process lock.
554        }
555        // Reopen with a fresh token; entries must be reloaded from table.len().
556        let token2 = CancellationToken::new();
557        let repo = RedbCacheRepository::new(
558            "redb",
559            path,
560            Duration::from_secs(60),
561            None,
562            Duration::from_secs(3600),
563            token2,
564        )
565        .await
566        .expect("reopen repo");
567        let found = repo.get("k").await.expect("get after reopen");
568        assert!(
569            found.is_some(),
570            "persisted entry must survive drop + reopen"
571        );
572        assert_eq!(
573            repo.stats().entries,
574            1,
575            "entries counter must be restored from table.len() on reopen"
576        );
577    }
578
579    #[tokio::test]
580    async fn peek_stale_returns_post_expiry_entry_on_redb() {
581        let dir = tempdir().expect("tempdir");
582        let token = CancellationToken::new();
583        let repo = new_repo(&dir, token).await;
584        repo.set("k", entry(), Some(Duration::from_millis(1)))
585            .await
586            .expect("set");
587        tokio::time::sleep(Duration::from_millis(10)).await;
588        let stale = repo.peek_stale("k").await.expect("peek_stale");
589        assert!(
590            stale.is_some(),
591            "peek_stale must return the expired-but-present entry"
592        );
593    }
594
595    #[tokio::test]
596    async fn sweep_once_removes_entries_past_stale_retention() {
597        let dir = tempdir().expect("tempdir");
598        let token = CancellationToken::new();
599        let path = dir.path().join("cache.redb");
600        // stale_retention=10ms; entry expires at +1ms; sleep 50ms so
601        // (expires_at + 10ms) is well in the past → reclaimable.
602        let repo = RedbCacheRepository::new(
603            "redb",
604            path,
605            Duration::from_millis(10),
606            None,
607            Duration::from_secs(3600),
608            token,
609        )
610        .await
611        .expect("open repo");
612        // Neutralize the background sweep so its immediate first tick (which
613        // fires whenever the runtime next polls it) cannot steal the reclaim
614        // from sweep_once. This makes the test deterministic: sweep_once is
615        // the sole reclaimer.
616        if let Some(handle) = repo.sweep_handle.lock().take() {
617            handle.abort();
618        }
619        repo.set("k", entry(), Some(Duration::from_millis(1)))
620            .await
621            .expect("set");
622        tokio::time::sleep(Duration::from_millis(50)).await;
623        let reclaimed = repo.sweep_once().await.expect("sweep_once");
624        assert!(
625            reclaimed >= 1,
626            "sweep_once must reclaim at least 1 entry, got {reclaimed}"
627        );
628        let stale = repo.peek_stale("k").await.expect("peek_stale after sweep");
629        assert!(
630            stale.is_none(),
631            "entry must be gone after sweep_once reclaimed it"
632        );
633    }
634
635    #[tokio::test]
636    async fn sweep_stops_on_context_shutdown() {
637        let dir = tempdir().expect("tempdir");
638        let token = CancellationToken::new();
639        let path = dir.path().join("cache.redb");
640        // Short sweep interval so the loop is definitely armed.
641        let repo = RedbCacheRepository::new(
642            "redb",
643            path,
644            Duration::from_secs(60),
645            None,
646            Duration::from_millis(10),
647            token.clone(),
648        )
649        .await
650        .expect("open repo");
651        token.cancel();
652        // Take the handle out so we can await it directly; Drop will see None.
653        let handle = repo
654            .sweep_handle
655            .lock()
656            .take()
657            .expect("sweep handle must be present after construct");
658        let completed = tokio::time::timeout(Duration::from_secs(5), handle).await;
659        assert!(
660            completed.is_ok(),
661            "sweep task must complete within 5s of context shutdown"
662        );
663    }
664
665    #[tokio::test]
666    async fn redb_errors_surface_as_err() {
667        let dir = tempdir().expect("tempdir");
668        // A regular file where a directory is required — `create_dir_all`
669        // must fail, surfacing as Err(CamelError::Io(_)).
670        let blocker = dir.path().join("blocker");
671        std::fs::write(&blocker, b"not a dir").expect("write blocker");
672        let path = blocker.join("cache.redb");
673        let result = RedbCacheRepository::new(
674            "redb",
675            path,
676            Duration::from_secs(60),
677            None,
678            Duration::from_secs(3600),
679            CancellationToken::new(),
680        )
681        .await;
682        assert!(
683            matches!(result, Err(CamelError::Io(_))),
684            "expected Err(CamelError::Io(_)), got {result:?}"
685        );
686    }
687
688    #[tokio::test]
689    async fn overwrite_does_not_inflate_entries() {
690        let dir = tempdir().expect("tempdir");
691        let token = CancellationToken::new();
692        let repo = new_repo(&dir, token).await;
693        repo.set("k", entry(), None).await.expect("first set");
694        repo.set("k", entry(), None).await.expect("second set");
695        assert_eq!(
696            repo.stats().entries,
697            1,
698            "overwriting an existing key must not inflate the entries counter"
699        );
700    }
701
702    #[tokio::test]
703    async fn max_entries_rejects_new_key_allows_overwrite() {
704        let dir = tempdir().expect("tempdir");
705        let token = CancellationToken::new();
706        let path = dir.path().join("cache.redb");
707        let repo = RedbCacheRepository::new(
708            "redb",
709            path,
710            Duration::from_secs(60),
711            Some(2),
712            Duration::from_secs(3600),
713            token,
714        )
715        .await
716        .expect("open repo");
717        repo.set("a", entry(), None).await.expect("set a");
718        repo.set("b", entry(), None).await.expect("set b");
719        let over = repo.set("c", entry(), None).await;
720        assert!(
721            over.is_err(),
722            "third distinct key must be rejected at max_entries, got {over:?}"
723        );
724        let overw = repo.set("a", entry(), None).await;
725        assert!(
726            overw.is_ok(),
727            "overwrite of an existing key must succeed at max_entries, got {overw:?}"
728        );
729    }
730}