Skip to main content

fob_bundler/cache/
storage.rs

1//! redb-backed cache storage.
2//!
3//! Provides persistent key-value storage using redb, an embedded ACID database.
4//! The cache uses a single database file with ACID transactions for reliability.
5
6use std::path::Path;
7
8use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
9
10use super::key::CacheKey;
11use super::serialize::CachedBuild;
12
13/// Cache table: maps cache keys to serialized builds.
14const CACHE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("cache");
15
16/// Metadata table: stores cache-wide metadata.
17const METADATA_TABLE: TableDefinition<&str, &str> = TableDefinition::new("metadata");
18
19/// Error types for cache operations.
20#[derive(Debug, thiserror::Error)]
21pub enum CacheError {
22    /// Cache entry not found.
23    #[error("cache miss")]
24    CacheMiss,
25
26    /// Cache database error.
27    #[error("cache database error: {0}")]
28    DatabaseError(String),
29
30    /// Serialization error.
31    #[error("serialization error: {0}")]
32    SerializationError(String),
33
34    /// Deserialization error.
35    #[error("deserialization error: {0}")]
36    DeserializationError(String),
37
38    /// IO error.
39    #[error("io error: {0}")]
40    IoError(#[from] std::io::Error),
41
42    /// Cache version mismatch.
43    #[error("cache version mismatch: expected {expected}, found {found}")]
44    VersionMismatch { expected: u32, found: u32 },
45
46    /// Cache corrupted.
47    #[error("cache corrupted: {0}")]
48    Corrupted(String),
49
50    /// Invalid data in cache.
51    #[error("invalid data: {0}")]
52    InvalidData(String),
53}
54
55impl From<redb::Error> for CacheError {
56    fn from(err: redb::Error) -> Self {
57        CacheError::DatabaseError(err.to_string())
58    }
59}
60
61impl From<redb::DatabaseError> for CacheError {
62    fn from(err: redb::DatabaseError) -> Self {
63        CacheError::DatabaseError(err.to_string())
64    }
65}
66
67impl From<redb::TableError> for CacheError {
68    fn from(err: redb::TableError) -> Self {
69        CacheError::DatabaseError(err.to_string())
70    }
71}
72
73impl From<redb::TransactionError> for CacheError {
74    fn from(err: redb::TransactionError) -> Self {
75        CacheError::DatabaseError(err.to_string())
76    }
77}
78
79impl From<redb::StorageError> for CacheError {
80    fn from(err: redb::StorageError) -> Self {
81        CacheError::DatabaseError(err.to_string())
82    }
83}
84
85impl From<redb::CommitError> for CacheError {
86    fn from(err: redb::CommitError) -> Self {
87        CacheError::DatabaseError(err.to_string())
88    }
89}
90
91/// Persistent cache store using redb.
92pub struct CacheStore {
93    db: Database,
94}
95
96impl CacheStore {
97    /// Open or create a cache store at the given directory.
98    ///
99    /// Creates the directory and database file if they don't exist.
100    /// The database file is stored at `<cache_dir>/cache.redb`.
101    pub fn open(cache_dir: &Path) -> Result<Self, CacheError> {
102        // Create cache directory if needed
103        std::fs::create_dir_all(cache_dir)?;
104
105        let db_path = cache_dir.join("cache.redb");
106
107        // Open database with default settings
108        let db = Database::create(&db_path)?;
109
110        // Initialize tables
111        let write_txn = db.begin_write()?;
112        {
113            // Create tables if they don't exist
114            let _ = write_txn.open_table(CACHE_TABLE)?;
115            let _ = write_txn.open_table(METADATA_TABLE)?;
116        }
117        write_txn.commit()?;
118
119        Ok(Self { db })
120    }
121
122    /// Get a cached build by key.
123    ///
124    /// Returns `CacheMiss` if the key doesn't exist.
125    pub fn get(&self, key: &CacheKey) -> Result<CachedBuild, CacheError> {
126        let read_txn = self.db.begin_read()?;
127        let table = read_txn.open_table(CACHE_TABLE)?;
128
129        let value = table.get(key.as_hex())?.ok_or(CacheError::CacheMiss)?;
130
131        let bytes = value.value();
132
133        // Deserialize
134        let cached: CachedBuild = bincode::deserialize(bytes)
135            .map_err(|e| CacheError::DeserializationError(e.to_string()))?;
136
137        // Validate metadata
138        if !cached.metadata.is_compatible() {
139            return Err(CacheError::VersionMismatch {
140                expected: super::serialize::CACHE_FORMAT_VERSION,
141                found: cached.metadata.format_version,
142            });
143        }
144
145        Ok(cached)
146    }
147
148    /// Store a cached build by key.
149    pub fn put(&self, key: &CacheKey, build: &CachedBuild) -> Result<(), CacheError> {
150        // Serialize
151        let bytes =
152            bincode::serialize(build).map_err(|e| CacheError::SerializationError(e.to_string()))?;
153
154        // Write to database
155        let write_txn = self.db.begin_write()?;
156        {
157            let mut table = write_txn.open_table(CACHE_TABLE)?;
158            table.insert(key.as_hex(), bytes.as_slice())?;
159        }
160        write_txn.commit()?;
161
162        Ok(())
163    }
164
165    /// Remove a cached build by key.
166    pub fn remove(&self, key: &CacheKey) -> Result<(), CacheError> {
167        let write_txn = self.db.begin_write()?;
168        {
169            let mut table = write_txn.open_table(CACHE_TABLE)?;
170            table.remove(key.as_hex())?;
171        }
172        write_txn.commit()?;
173
174        Ok(())
175    }
176
177    /// Check if a key exists in the cache.
178    pub fn contains(&self, key: &CacheKey) -> Result<bool, CacheError> {
179        let read_txn = self.db.begin_read()?;
180        let table = read_txn.open_table(CACHE_TABLE)?;
181
182        Ok(table.get(key.as_hex())?.is_some())
183    }
184
185    /// Clear all cached entries.
186    pub fn clear(&self) -> Result<(), CacheError> {
187        let write_txn = self.db.begin_write()?;
188        {
189            // Drop and recreate the table to clear it
190            write_txn.delete_table(CACHE_TABLE)?;
191            let _ = write_txn.open_table(CACHE_TABLE)?;
192        }
193        write_txn.commit()?;
194
195        Ok(())
196    }
197
198    /// Get the number of cached entries.
199    pub fn len(&self) -> Result<usize, CacheError> {
200        let read_txn = self.db.begin_read()?;
201        let table = read_txn.open_table(CACHE_TABLE)?;
202
203        // Count entries by iterating
204        let count = table.iter()?.count();
205        Ok(count)
206    }
207
208    /// Check if the cache is empty.
209    pub fn is_empty(&self) -> Result<bool, CacheError> {
210        Ok(self.len()? == 0)
211    }
212
213    /// Set a metadata value.
214    pub fn set_metadata(&self, key: &str, value: &str) -> Result<(), CacheError> {
215        let write_txn = self.db.begin_write()?;
216        {
217            let mut table = write_txn.open_table(METADATA_TABLE)?;
218            table.insert(key, value)?;
219        }
220        write_txn.commit()?;
221
222        Ok(())
223    }
224
225    /// Get a metadata value.
226    pub fn get_metadata(&self, key: &str) -> Result<Option<String>, CacheError> {
227        let read_txn = self.db.begin_read()?;
228        let table = read_txn.open_table(METADATA_TABLE)?;
229
230        Ok(table.get(key)?.map(|v| v.value().to_string()))
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use tempfile::TempDir;
238
239    fn create_test_store() -> (CacheStore, TempDir) {
240        let dir = TempDir::new().unwrap();
241        let store = CacheStore::open(dir.path()).unwrap();
242        (store, dir)
243    }
244
245    #[test]
246    fn test_store_open_creates_directory() {
247        let dir = TempDir::new().unwrap();
248        let cache_dir = dir.path().join("new_cache");
249
250        let _store = CacheStore::open(&cache_dir).unwrap();
251
252        assert!(cache_dir.exists());
253        assert!(cache_dir.join("cache.redb").exists());
254    }
255
256    #[test]
257    fn test_cache_miss() {
258        let (store, _dir) = create_test_store();
259
260        let key = CacheKey::from_hex("nonexistent");
261        let result = store.get(&key);
262
263        assert!(matches!(result, Err(CacheError::CacheMiss)));
264    }
265
266    #[test]
267    fn test_contains() {
268        let (store, _dir) = create_test_store();
269
270        let key = CacheKey::from_hex("test_key");
271
272        assert!(!store.contains(&key).unwrap());
273    }
274
275    #[test]
276    fn test_metadata() {
277        let (store, _dir) = create_test_store();
278
279        store.set_metadata("version", "1.0").unwrap();
280        let value = store.get_metadata("version").unwrap();
281
282        assert_eq!(value, Some("1.0".to_string()));
283    }
284
285    #[test]
286    fn test_clear() {
287        let (store, _dir) = create_test_store();
288
289        // Initially empty
290        assert!(store.is_empty().unwrap());
291
292        // Clear should work even when empty
293        store.clear().unwrap();
294        assert!(store.is_empty().unwrap());
295    }
296}