1use 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
37const CACHE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("cache_entries");
42
43pub 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 entries: Arc<AtomicU64>,
63 shutdown_token: CancellationToken,
67 sweep_handle: Mutex<Option<JoinHandle<()>>>,
68}
69
70impl RedbCacheRepository {
71 #[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 let len = {
99 let wtx = db
100 .begin_write()
101 .map_err(|e| CamelError::Io(format!("redb begin_write: {e}")))?;
102 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 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 #[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
186fn 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 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 Some(threshold) => threshold < now,
215 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#[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 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 let is_new = {
315 let prior = table
316 .get(key.as_str())
317 .map_err(|e| CamelError::Io(format!("redb get: {e}")))?;
318 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 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 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 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 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 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 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 }
555 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 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 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 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 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 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}