Skip to main content

dex_blob_cache/
lib.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Super Durable Source License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
9//! Persistent, bounded local cache for Dex blob payloads.
10//!
11//! [`BlobCache`] stores immutable payloads by blob ID and uses an admission and eviction policy to
12//! keep committed files within the configured byte budget.
13
14#![deny(missing_docs)]
15
16mod config;
17mod entry;
18mod error;
19mod format;
20mod policy;
21mod store;
22
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex, RwLock};
25
26pub use config::BlobCacheConfig;
27use config::validate_blob_id;
28use entry::DiskEntry;
29pub use error::BlobCacheError;
30use format::{FileMetadata, calculate_metadata};
31use policy::{DiskPolicyCallback, PolicyCache, new_policy};
32use store::LocalFileStore;
33
34/// Stores immutable Dex blob payloads in a bounded local directory.
35///
36/// A cache is thread-safe. Reads and writes may run concurrently, while deletion and close
37/// coordinate with in-flight operations. Payloads survive process restarts; [`BlobCache::open`]
38/// recovers valid committed entries and removes interrupted writes.
39///
40/// # Examples
41///
42/// ```no_run
43/// use dex_blob_cache::{BlobCache, BlobCacheConfig};
44///
45/// let config = BlobCacheConfig::new("/tmp/dex-blobs", 64 * 1024 * 1024, 0)?;
46/// let cache = BlobCache::open(config)?;
47/// assert!(cache.put("orders/123", b"payload")?);
48/// assert_eq!(cache.get("orders/123")?, Some(b"payload".to_vec()));
49/// cache.close()?;
50/// # Ok::<(), dex_blob_cache::BlobCacheError>(())
51/// ```
52pub struct BlobCache {
53    config: BlobCacheConfig,
54    store: LocalFileStore,
55    policy: RwLock<Option<PolicyCache>>,
56    lifecycle: RwLock<()>,
57    commit: Mutex<()>,
58    callback: DiskPolicyCallback,
59    closed: AtomicBool,
60}
61
62enum ExistingEntry {
63    Missing,
64    Reused,
65}
66
67impl BlobCache {
68    /// Opens a cache and recovers its committed entries.
69    ///
70    /// `config` supplies the owned directory, byte budget, and admission-policy sizing. The call
71    /// creates the directory when needed and returns only after recovery completes.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`BlobCacheError`] when the directory cannot be prepared, recovery finds an
76    /// unrecoverable storage failure, or the admission policy cannot be initialized.
77    pub fn open(config: BlobCacheConfig) -> Result<Self, BlobCacheError> {
78        let store = LocalFileStore::new(config.directory())?;
79        store.prepare()?;
80        let callback = DiskPolicyCallback::new(store.clone());
81        let policy = new_policy(&config, callback.clone())?;
82        let cache = Self {
83            config,
84            store,
85            policy: RwLock::new(Some(policy)),
86            lifecycle: RwLock::new(()),
87            commit: Mutex::new(()),
88            callback,
89            closed: AtomicBool::new(false),
90        };
91        if let Err(error) = cache.recover() {
92            cache.close_policy_preserving_files();
93            return Err(error);
94        }
95        Ok(cache)
96    }
97
98    /// Reads one payload and records an admission-policy access.
99    ///
100    /// Returns `Ok(Some(payload))` for a valid entry and `Ok(None)` when the ID is absent or its
101    /// file disappeared or became corrupt. Corrupt entries are invalidated before returning.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`BlobCacheError::InvalidBlob`] for an invalid ID, [`BlobCacheError::Closed`] after
106    /// close, or a storage/policy error that cannot be treated as a cache miss.
107    pub fn get(&self, blob_id: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
108        validate_blob_id(blob_id)?;
109        let _lifecycle = self
110            .lifecycle
111            .read()
112            .expect("blob cache lifecycle lock is poisoned");
113        self.require_open()?;
114
115        let entry = {
116            let policy = self
117                .policy
118                .read()
119                .expect("blob cache policy lock is poisoned");
120            let policy = policy.as_ref().ok_or(BlobCacheError::Closed)?;
121            policy.get(blob_id).map(|entry| Arc::clone(entry.value()))
122        };
123        let Some(entry) = entry else {
124            return Ok(None);
125        };
126        let Some(lease) = entry.acquire_read() else {
127            return Ok(None);
128        };
129        let result = self.store.read(&entry);
130        drop(lease);
131        match result {
132            Ok(payload) => Ok(Some(payload)),
133            Err(error) if error.is_missing_or_corrupt() => {
134                self.invalidate_entry(&entry)?;
135                Ok(None)
136            }
137            Err(error) => Err(error),
138        }
139    }
140
141    /// Attempts to admit an immutable payload under `blob_id`.
142    ///
143    /// Returns `Ok(true)` when the payload is committed or the identical payload already exists.
144    /// Returns `Ok(false)` when the payload exceeds the byte budget or the policy rejects it.
145    /// Reusing an ID for different bytes is an error.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`BlobCacheError`] for invalid IDs, content mismatches, closed lifecycle, or failed
150    /// filesystem and policy operations.
151    pub fn put(&self, blob_id: &str, payload: &[u8]) -> Result<bool, BlobCacheError> {
152        validate_blob_id(blob_id)?;
153        let _lifecycle = self
154            .lifecycle
155            .read()
156            .expect("blob cache lifecycle lock is poisoned");
157        self.require_open()?;
158        let _commit = self
159            .commit
160            .lock()
161            .expect("blob cache commit lock is poisoned");
162        self.callback.retry_cleanup()?;
163
164        let metadata = calculate_metadata(blob_id, payload, self.store.path_for(blob_id))?;
165        if metadata.size > self.config.max_bytes() {
166            return Ok(false);
167        }
168        if matches!(
169            self.reuse_existing(&metadata, payload)?,
170            ExistingEntry::Reused
171        ) {
172            return Ok(true);
173        }
174
175        let entry = Arc::new(DiskEntry::pending(metadata));
176        self.callback.reset_error();
177        let inserted = self.with_policy(|policy| {
178            Ok(policy.insert(blob_id.to_owned(), Arc::clone(&entry), entry.metadata.size))
179        })?;
180        if !inserted {
181            entry.begin_eviction();
182            return Ok(false);
183        }
184        self.wait_for_policy()?;
185        if let Some(error) = self.callback.take_error() {
186            self.remove_candidate(&entry)?;
187            return Err(BlobCacheError::Reconciliation(error));
188        }
189        if !self.policy_contains(blob_id, &entry)? {
190            entry.begin_eviction();
191            return Ok(false);
192        }
193
194        if let Err(failure) = self.store.commit(&entry.metadata, payload) {
195            if let Some(path) = failure.orphan_path {
196                self.callback.add_cleanup_path(&path);
197            }
198            self.remove_candidate(&entry)?;
199            return Err(failure.error);
200        }
201        if !entry.mark_ready() {
202            return Err(BlobCacheError::Reconciliation(
203                "admitted entry left pending state".to_owned(),
204            ));
205        }
206        Ok(true)
207    }
208
209    /// Deletes one blob if present.
210    ///
211    /// Missing IDs succeed, making deletion idempotent.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`BlobCacheError`] for an invalid ID, a closed cache, or a failed storage/policy
216    /// operation.
217    pub fn delete(&self, blob_id: &str) -> Result<(), BlobCacheError> {
218        validate_blob_id(blob_id)?;
219        let _lifecycle = self
220            .lifecycle
221            .read()
222            .expect("blob cache lifecycle lock is poisoned");
223        self.require_open()?;
224        let _commit = self
225            .commit
226            .lock()
227            .expect("blob cache commit lock is poisoned");
228        self.callback.retry_cleanup()?;
229
230        let entry = self.policy_entry(blob_id)?;
231        let Some(entry) = entry else {
232            let path = self.store.path_for(blob_id);
233            if let Err(error) = self.store.remove(&path) {
234                self.callback.add_cleanup_path(&path);
235                return Err(error);
236            }
237            return Ok(());
238        };
239        entry.begin_eviction();
240        if let Err(error) = self.store.remove(&entry.metadata.path) {
241            entry.restore_ready();
242            return Err(error);
243        }
244        self.with_policy(|policy| {
245            policy.remove(&blob_id.to_owned());
246            Ok(())
247        })?;
248        self.wait_for_policy()
249    }
250
251    /// Removes every cache entry while keeping the cache open.
252    ///
253    /// The call excludes concurrent cache operations until both policy and disk state are cleared.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`BlobCacheError::Closed`] after close or a reconciliation/storage failure when the
258    /// cache cannot fully purge its state.
259    pub fn delete_all(&self) -> Result<(), BlobCacheError> {
260        let _lifecycle = self
261            .lifecycle
262            .write()
263            .expect("blob cache lifecycle lock is poisoned");
264        self.require_open()?;
265        let _commit = self
266            .commit
267            .lock()
268            .expect("blob cache commit lock is poisoned");
269
270        self.callback.reset_error();
271        self.with_policy(|policy| {
272            policy
273                .clear()
274                .map_err(|error| BlobCacheError::Policy(error.to_string()))
275        })?;
276        self.wait_for_policy()?;
277        let callback_error = self.callback.take_error();
278        if let Err(error) = self.store.purge() {
279            self.callback.require_purge();
280            return Err(match callback_error {
281                Some(callback_error) => BlobCacheError::Reconciliation(format!(
282                    "{callback_error}; purge blob cache: {error}"
283                )),
284                None => error,
285            });
286        }
287        self.callback.reset_after_purge();
288        Ok(())
289    }
290
291    /// Releases policy resources and rejects future operations.
292    ///
293    /// Close is idempotent and preserves committed files for the next [`BlobCache::open`].
294    ///
295    /// # Errors
296    ///
297    /// Returns a cleanup error after closing when a previously deferred file removal still fails.
298    pub fn close(&self) -> Result<(), BlobCacheError> {
299        let _lifecycle = self
300            .lifecycle
301            .write()
302            .expect("blob cache lifecycle lock is poisoned");
303        if self.closed.load(Ordering::Acquire) {
304            return Ok(());
305        }
306        let _commit = self
307            .commit
308            .lock()
309            .expect("blob cache commit lock is poisoned");
310        let cleanup_result = self.callback.retry_cleanup();
311        self.close_policy_preserving_files();
312        cleanup_result
313    }
314
315    fn recover(&self) -> Result<(), BlobCacheError> {
316        let _commit = self
317            .commit
318            .lock()
319            .expect("blob cache commit lock is poisoned");
320        self.store.purge_temp().map_err(|error| {
321            BlobCacheError::Reconciliation(format!("purge interrupted writes: {error}"))
322        })?;
323        let scan = self.store.scan().map_err(|error| {
324            BlobCacheError::Reconciliation(format!("scan committed files: {error}"))
325        })?;
326        for path in scan.invalid_paths {
327            self.store.remove(&path).map_err(|error| {
328                BlobCacheError::Reconciliation(format!("remove invalid file: {error}"))
329            })?;
330        }
331        for metadata in scan.entries {
332            self.recover_entry(metadata)?;
333        }
334        Ok(())
335    }
336
337    fn recover_entry(&self, metadata: FileMetadata) -> Result<(), BlobCacheError> {
338        if metadata.size > self.config.max_bytes() {
339            return self.store.remove(&metadata.path).map_err(|error| {
340                BlobCacheError::Reconciliation(format!("remove oversized file: {error}"))
341            });
342        }
343
344        let entry = Arc::new(DiskEntry::ready(metadata));
345        self.callback.reset_error();
346        let inserted = self.with_policy(|policy| {
347            Ok(policy.insert(
348                entry.metadata.blob_id.clone(),
349                Arc::clone(&entry),
350                entry.metadata.size,
351            ))
352        })?;
353        if !inserted {
354            entry.begin_eviction();
355            return self.store.remove(&entry.metadata.path).map_err(|error| {
356                BlobCacheError::Reconciliation(format!("remove rejected file: {error}"))
357            });
358        }
359        self.wait_for_policy()?;
360        if let Some(error) = self.callback.take_error() {
361            return Err(BlobCacheError::Reconciliation(error));
362        }
363        if self.policy_contains(&entry.metadata.blob_id, &entry)? {
364            return Ok(());
365        }
366        self.store.remove(&entry.metadata.path).map_err(|error| {
367            BlobCacheError::Reconciliation(format!("remove dropped file: {error}"))
368        })
369    }
370
371    fn reuse_existing(
372        &self,
373        metadata: &FileMetadata,
374        payload: &[u8],
375    ) -> Result<ExistingEntry, BlobCacheError> {
376        let Some(entry) = self.policy_entry(&metadata.blob_id)? else {
377            return Ok(ExistingEntry::Missing);
378        };
379        let Some(lease) = entry.acquire_read() else {
380            return Ok(ExistingEntry::Missing);
381        };
382        let result = self.store.read(&entry);
383        drop(lease);
384        match result {
385            Ok(existing) => {
386                if entry.metadata.size != metadata.size
387                    || entry.metadata.checksum != metadata.checksum
388                    || existing != payload
389                {
390                    return Err(BlobCacheError::ContentMismatch(metadata.blob_id.clone()));
391                }
392                Ok(ExistingEntry::Reused)
393            }
394            Err(error) if error.is_missing_or_corrupt() => {
395                entry.begin_eviction();
396                if let Err(remove_error) = self.store.remove(&entry.metadata.path) {
397                    self.callback.add_cleanup_path(&entry.metadata.path);
398                    self.remove_policy_key(&metadata.blob_id)?;
399                    return Err(remove_error);
400                }
401                self.remove_policy_key(&metadata.blob_id)?;
402                Ok(ExistingEntry::Missing)
403            }
404            Err(error) => Err(error),
405        }
406    }
407
408    fn invalidate_entry(&self, entry: &Arc<DiskEntry>) -> Result<(), BlobCacheError> {
409        let _commit = self
410            .commit
411            .lock()
412            .expect("blob cache commit lock is poisoned");
413        let current = self.policy_entry(&entry.metadata.blob_id)?;
414        if !current
415            .as_ref()
416            .is_some_and(|current| Arc::ptr_eq(current, entry))
417        {
418            return Ok(());
419        }
420        entry.begin_eviction();
421        let remove_result = self.store.remove(&entry.metadata.path);
422        self.remove_policy_key(&entry.metadata.blob_id)?;
423        if remove_result.is_err() {
424            self.callback.add_cleanup_path(&entry.metadata.path);
425        }
426        remove_result
427    }
428
429    fn remove_candidate(&self, entry: &Arc<DiskEntry>) -> Result<(), BlobCacheError> {
430        if self.policy_contains(&entry.metadata.blob_id, entry)? {
431            self.remove_policy_key(&entry.metadata.blob_id)?;
432        }
433        entry.begin_eviction();
434        Ok(())
435    }
436
437    fn remove_policy_key(&self, blob_id: &str) -> Result<(), BlobCacheError> {
438        self.with_policy(|policy| {
439            policy.remove(&blob_id.to_owned());
440            Ok(())
441        })?;
442        self.wait_for_policy()
443    }
444
445    fn policy_entry(&self, blob_id: &str) -> Result<Option<Arc<DiskEntry>>, BlobCacheError> {
446        self.with_policy(|policy| Ok(policy.get(blob_id).map(|entry| Arc::clone(entry.value()))))
447    }
448
449    fn policy_contains(
450        &self,
451        blob_id: &str,
452        expected: &Arc<DiskEntry>,
453    ) -> Result<bool, BlobCacheError> {
454        self.with_policy(|policy| {
455            Ok(policy
456                .get(blob_id)
457                .is_some_and(|current| Arc::ptr_eq(current.value(), expected)))
458        })
459    }
460
461    fn wait_for_policy(&self) -> Result<(), BlobCacheError> {
462        self.with_policy(|policy| {
463            policy
464                .wait()
465                .map_err(|error| BlobCacheError::Policy(error.to_string()))
466        })
467    }
468
469    fn with_policy<T>(
470        &self,
471        operation: impl FnOnce(&PolicyCache) -> Result<T, BlobCacheError>,
472    ) -> Result<T, BlobCacheError> {
473        let policy = self
474            .policy
475            .read()
476            .expect("blob cache policy lock is poisoned");
477        operation(policy.as_ref().ok_or(BlobCacheError::Closed)?)
478    }
479
480    fn require_open(&self) -> Result<(), BlobCacheError> {
481        if self.closed.load(Ordering::Acquire) {
482            Err(BlobCacheError::Closed)
483        } else {
484            Ok(())
485        }
486    }
487
488    fn close_policy_preserving_files(&self) {
489        self.callback.set_closing();
490        self.closed.store(true, Ordering::Release);
491        let policy = self
492            .policy
493            .write()
494            .expect("blob cache policy lock is poisoned")
495            .take();
496        drop(policy);
497    }
498}
499
500impl Drop for BlobCache {
501    fn drop(&mut self) {
502        self.callback.set_closing();
503    }
504}