Skip to main content

http_cache/managers/
redb.rs

1//! HTTP cache manager backed by an embedded [`redb`](https://github.com/cberner/redb)
2//! key/value database.
3//!
4//! Unlike [`CACacheManager`](crate::CACacheManager), this manager performs all
5//! of its I/O **synchronously** and depends on **no async runtime** — `redb`
6//! itself only depends on `libc`. That makes it a good fit for smol-based
7//! clients (such as `http-cache-ureq`) and for embedding runtimes like Bevy,
8//! where there is no tokio reactor available: it pulls in no `tokio`
9//! dependency and requires no reactor at runtime.
10//!
11//! The cache is stored in a single database file. Only one instance may have
12//! the file open at a time — `redb` takes an exclusive file lock — so wrap the
13//! manager in `Arc`/a `static` and share it rather than constructing a second
14//! instance for the same path.
15//!
16//! Stores are batched for durability: entries are committed without an
17//! fsync and flushed to disk every 64 writes (configurable via
18//! [`RedbManager::from_database_with_flush_interval`]) and on drop, so a
19//! crash can lose approximately the most recent 64 stores. Deletes commit
20//! durably, so an invalidated entry cannot come back after a crash. When
21//! sharing a [`Database`] via [`RedbManager::from_database`], do not drop
22//! the last manager clone while holding an open `WriteTransaction` on that
23//! database — the drop-time flush must acquire the writer lock.
24
25use std::path::Path;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::Arc;
28
29use crate::{CacheManager, HttpResponse, Result};
30
31use http_cache_semantics::CachePolicy;
32use redb::{Database, Durability, ReadableDatabase, TableDefinition};
33use serde::{Deserialize, Serialize};
34
35pub(crate) const TABLE: TableDefinition<&str, &[u8]> =
36    TableDefinition::new("http_cache_v1");
37
38/// Implements [`CacheManager`] with [`redb`](https://github.com/cberner/redb)
39/// as the backend — a pure-Rust, embedded, persistent key/value store.
40///
41/// All operations are synchronous and require no async runtime, so this
42/// manager works under any executor (tokio, smol, Bevy, …) and adds no
43/// `tokio` dependency.
44#[cfg_attr(docsrs, doc(cfg(feature = "manager-redb")))]
45#[derive(Clone)]
46pub struct RedbManager {
47    flush: Arc<FlushState>,
48}
49
50impl std::fmt::Debug for RedbManager {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("RedbManager").finish_non_exhaustive()
53    }
54}
55
56// Store format (postcard). `manager-redb` always enables `postcard`, and this
57// is a new backend with no legacy on-disk data, so only postcard is supported.
58#[derive(Debug, Deserialize, Serialize)]
59struct Store {
60    response: HttpResponse,
61    policy: CachePolicy,
62}
63
64/// Stores are committed with `Durability::None` and made durable by an empty
65/// `Immediate` commit every `interval` writes and when the last manager
66/// clone drops.
67const DEFAULT_FLUSH_INTERVAL: u64 = 64;
68
69struct FlushState {
70    db: Arc<Database>,
71    unflushed: AtomicU64,
72    interval: u64,
73}
74
75impl FlushState {
76    fn flush(&self) -> Result<()> {
77        // An empty Immediate commit persists all prior None-durability
78        // commits.
79        let write_txn = self.db.begin_write()?;
80        write_txn.commit()?;
81        Ok(())
82    }
83
84    fn record_write(&self) {
85        if self.unflushed.fetch_add(1, Ordering::Relaxed) + 1 >= self.interval {
86            // Claim the whole batch so writers queued behind a flush in
87            // flight don't each fsync. The write's own commit already
88            // succeeded; on failure the count is restored and the drop
89            // guard retries — though redb latches I/O errors, so retries
90            // fail until the database is reopened.
91            let n = self.unflushed.swap(0, Ordering::Relaxed);
92            if n == 0 {
93                return;
94            }
95            if let Err(e) = self.flush() {
96                self.unflushed.fetch_add(n, Ordering::Relaxed);
97                log::warn!("redb interval flush failed: {e}");
98            }
99        }
100    }
101}
102
103impl Drop for FlushState {
104    fn drop(&mut self) {
105        if self.unflushed.load(Ordering::Relaxed) > 0 {
106            if let Err(e) = self.flush() {
107                log::warn!("redb flush on drop failed: {e}");
108            }
109        }
110    }
111}
112
113impl RedbManager {
114    /// Creates a new [`RedbManager`], creating or opening the redb database
115    /// file at `path`.
116    ///
117    /// Returns an error if the database cannot be opened (for example if
118    /// another instance already holds the exclusive file lock).
119    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
120        let db = Database::create(path)?;
121        Self::from_database(Arc::new(db))
122    }
123
124    /// Wraps an already-open redb [`Database`], allowing callers to configure
125    /// the database (cache size, etc.) before handing it to the manager.
126    pub fn from_database(db: Arc<Database>) -> Result<Self> {
127        Self::from_database_with_flush_interval(db, DEFAULT_FLUSH_INTERVAL)
128    }
129
130    /// Like [`from_database`](Self::from_database), but flushes stores to
131    /// disk every `flush_interval` writes instead of the default 64. An
132    /// interval of 1 flushes after every store.
133    pub fn from_database_with_flush_interval(
134        db: Arc<Database>,
135        flush_interval: u64,
136    ) -> Result<Self> {
137        // Ensure the table exists up front so reads never fail with
138        // "table does not exist".
139        let write_txn = db.begin_write()?;
140        {
141            let _table = write_txn.open_table(TABLE)?;
142        }
143        write_txn.commit()?;
144        Ok(Self {
145            flush: Arc::new(FlushState {
146                db,
147                unflushed: AtomicU64::new(0),
148                interval: flush_interval.max(1),
149            }),
150        })
151    }
152
153    /// Clears out the entire cache.
154    pub async fn clear(&self) -> Result<()> {
155        let write_txn = self.flush.db.begin_write()?;
156        write_txn.delete_table(TABLE)?;
157        // Recreate the (now empty) table so subsequent reads still succeed.
158        {
159            let _table = write_txn.open_table(TABLE)?;
160        }
161        write_txn.commit()?;
162        Ok(())
163    }
164
165    /// Reads and decodes the stored entry for `cache_key`, if present.
166    /// Deserializes from the borrowed value while the read transaction is
167    /// alive to avoid copying the entry out of redb first.
168    fn read_entry(&self, cache_key: &str) -> Result<Option<Store>> {
169        let read_txn = self.flush.db.begin_read()?;
170        let table = read_txn.open_table(TABLE)?;
171        match table.get(cache_key)? {
172            Some(guard) => {
173                match postcard::from_bytes::<Store>(guard.value()) {
174                    Ok(store) => Ok(Some(store)),
175                    Err(e) => {
176                        // Treat undecodable entries as a miss rather than an
177                        // error, matching the other managers.
178                        log::debug!(
179                            "Failed to deserialize cache entry for key \
180                             '{cache_key}': {e}"
181                        );
182                        Ok(None)
183                    }
184                }
185            }
186            None => Ok(None),
187        }
188    }
189}
190
191impl CacheManager for RedbManager {
192    async fn get(
193        &self,
194        cache_key: &str,
195    ) -> Result<Option<(HttpResponse, CachePolicy)>> {
196        // A storage-level read error is treated as a cache miss (degrade to a
197        // fresh fetch) rather than a hard error, matching `CACacheManager`.
198        match self.read_entry(cache_key) {
199            Ok(Some(store)) => Ok(Some((store.response, store.policy))),
200            Ok(None) => Ok(None),
201            Err(e) => {
202                log::debug!("redb read failed for key '{cache_key}': {e}");
203                Ok(None)
204            }
205        }
206    }
207
208    async fn put(
209        &self,
210        cache_key: String,
211        response: HttpResponse,
212        policy: CachePolicy,
213    ) -> Result<HttpResponse> {
214        let data = Store { response, policy };
215        let bytes = postcard::to_allocvec(&data)?;
216        let mut write_txn = self.flush.db.begin_write()?;
217        write_txn.set_durability(Durability::None)?;
218        {
219            let mut table = write_txn.open_table(TABLE)?;
220            table.insert(cache_key.as_str(), bytes.as_slice())?;
221        }
222        write_txn.commit()?;
223        self.flush.record_write();
224        Ok(data.response)
225    }
226
227    async fn delete(&self, cache_key: &str) -> Result<()> {
228        // Deletes commit durably: an invalidation rolled back by a crash
229        // would resurrect an entry the origin already replaced.
230        let write_txn = self.flush.db.begin_write()?;
231        {
232            let mut table = write_txn.open_table(TABLE)?;
233            table.remove(cache_key)?;
234        }
235        write_txn.commit()?;
236        Ok(())
237    }
238}