Skip to main content

dex_blob_cache/
lib.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use 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-Sustainable-Use-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::collections::HashSet;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{Arc, Condvar, Mutex, RwLock};
26
27pub use config::BlobCacheConfig;
28use config::validate_blob_id;
29use entry::DiskEntry;
30pub use error::BlobCacheError;
31use format::{FileMetadata, calculate_metadata};
32use policy::{DiskPolicyCallback, PolicyCache, new_policy};
33use store::LocalFileStore;
34
35/// Stores immutable Dex blob payloads in a bounded local directory.
36///
37/// A cache is thread-safe. Reads and writes may run concurrently, while deletion and close
38/// coordinate with in-flight operations. [`BlobCache::open`] recovers valid committed entries
39/// after a process restart and removes interrupted writes. Writes are not synchronized to disk,
40/// so a crash may leave a missing or corrupt entry that callers must fetch again.
41///
42/// # Examples
43///
44/// ```no_run
45/// use dex_blob_cache::{BlobCache, BlobCacheConfig};
46///
47/// let config = BlobCacheConfig::new("/tmp/dex-blobs", 64 * 1024 * 1024, 0)?;
48/// let cache = BlobCache::open(config)?;
49/// assert!(cache.put("orders/123", b"payload")?);
50/// assert_eq!(cache.get("orders/123")?, Some(b"payload".to_vec()));
51/// cache.close()?;
52/// # Ok::<(), dex_blob_cache::BlobCacheError>(())
53/// ```
54pub struct BlobCache {
55    config: BlobCacheConfig,
56    store: LocalFileStore,
57    policy: RwLock<Option<PolicyCache>>,
58    lifecycle: RwLock<()>,
59    commit: Mutex<()>,
60    pending: Mutex<HashSet<String>>,
61    pending_drained: Condvar,
62    callback: DiskPolicyCallback,
63    closed: AtomicBool,
64}
65
66enum ExistingEntry {
67    Missing,
68    Reused,
69}
70
71struct PendingPutGuard<'a> {
72    cache: &'a BlobCache,
73    blob_id: String,
74}
75
76impl Drop for PendingPutGuard<'_> {
77    fn drop(&mut self) {
78        let mut pending = self
79            .cache
80            .pending
81            .lock()
82            .expect("pending put lock is poisoned");
83        pending.remove(&self.blob_id);
84        self.cache.pending_drained.notify_all();
85    }
86}
87
88impl BlobCache {
89    /// Opens a cache and recovers its committed entries.
90    ///
91    /// `config` supplies the owned directory, byte budget, and admission-policy sizing. The call
92    /// creates the directory when needed and returns only after recovery completes.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`BlobCacheError`] when the directory cannot be prepared, recovery finds an
97    /// unrecoverable storage failure, or the admission policy cannot be initialized.
98    pub fn open(config: BlobCacheConfig) -> Result<Self, BlobCacheError> {
99        let store = LocalFileStore::new(config.directory())?;
100        store.prepare()?;
101        let callback = DiskPolicyCallback::new(store.clone());
102        let policy = new_policy(&config, callback.clone())?;
103        let cache = Self {
104            config,
105            store,
106            policy: RwLock::new(Some(policy)),
107            lifecycle: RwLock::new(()),
108            commit: Mutex::new(()),
109            pending: Mutex::new(HashSet::new()),
110            pending_drained: Condvar::new(),
111            callback,
112            closed: AtomicBool::new(false),
113        };
114        if let Err(error) = cache.recover() {
115            cache.close_policy_preserving_files();
116            return Err(error);
117        }
118        Ok(cache)
119    }
120
121    /// Reads one payload and records an admission-policy access.
122    ///
123    /// Returns `Ok(Some(payload))` for a valid entry and `Ok(None)` when the ID is absent or its
124    /// file disappeared or became corrupt. Corrupt entries are invalidated before returning.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`BlobCacheError::InvalidBlob`] for an invalid ID, [`BlobCacheError::Closed`] after
129    /// close, or a storage/policy error that cannot be treated as a cache miss.
130    pub fn get(&self, blob_id: &str) -> Result<Option<Vec<u8>>, BlobCacheError> {
131        validate_blob_id(blob_id)?;
132        let _lifecycle = self
133            .lifecycle
134            .read()
135            .expect("blob cache lifecycle lock is poisoned");
136        self.require_open()?;
137
138        let entry = {
139            let policy = self
140                .policy
141                .read()
142                .expect("blob cache policy lock is poisoned");
143            let policy = policy.as_ref().ok_or(BlobCacheError::Closed)?;
144            policy.get(blob_id).map(|entry| Arc::clone(entry.value()))
145        };
146        let Some(entry) = entry else {
147            return Ok(None);
148        };
149        let Some(lease) = entry.acquire_read() else {
150            return Ok(None);
151        };
152        let result = self.store.read(&entry);
153        drop(lease);
154        match result {
155            Ok(payload) => Ok(Some(payload)),
156            Err(error) if error.is_missing_or_corrupt() => {
157                self.invalidate_entry(&entry)?;
158                Ok(None)
159            }
160            Err(error) => Err(error),
161        }
162    }
163
164    /// Attempts to admit an immutable payload under `blob_id`.
165    ///
166    /// Returns `Ok(true)` when the payload is committed or the identical payload already exists.
167    /// Returns `Ok(false)` when the payload exceeds the byte budget or the policy rejects it.
168    /// Reusing an ID for different bytes is an error.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`BlobCacheError`] for invalid IDs, content mismatches, closed lifecycle, or failed
173    /// filesystem and policy operations.
174    pub fn put(&self, blob_id: &str, payload: &[u8]) -> Result<bool, BlobCacheError> {
175        validate_blob_id(blob_id)?;
176        let _lifecycle = self
177            .lifecycle
178            .read()
179            .expect("blob cache lifecycle lock is poisoned");
180        self.require_open()?;
181        let metadata = calculate_metadata(blob_id, payload, self.store.path_for(blob_id))?;
182        if metadata.size > self.config.max_bytes() {
183            return Ok(false);
184        }
185        let _pending = self.reserve_put(blob_id);
186        let entry = {
187            let _commit = self
188                .commit
189                .lock()
190                .expect("blob cache commit lock is poisoned");
191            self.callback.retry_cleanup()?;
192            if matches!(
193                self.reuse_existing(&metadata, payload)?,
194                ExistingEntry::Reused
195            ) {
196                return Ok(true);
197            }
198
199            let entry = Arc::new(DiskEntry::pending(metadata));
200            self.callback.reset_error();
201            let inserted = self.with_policy(|policy| {
202                Ok(policy.insert(blob_id.to_owned(), Arc::clone(&entry), entry.metadata.size))
203            })?;
204            if !inserted {
205                entry.begin_eviction();
206                return Ok(false);
207            }
208            self.wait_for_policy()?;
209            if let Some(error) = self.callback.take_error() {
210                self.remove_candidate(&entry)?;
211                return Err(BlobCacheError::Reconciliation(error));
212            }
213            if !self.policy_contains(blob_id, &entry)? {
214                entry.begin_eviction();
215                return Ok(false);
216            }
217            entry
218        };
219
220        let staged = self.store.stage(&entry.metadata, payload);
221        let _commit = self
222            .commit
223            .lock()
224            .expect("blob cache commit lock is poisoned");
225        let temp_path = match staged {
226            Ok(path) => path,
227            Err(failure) => {
228                if let Some(path) = failure.orphan_path {
229                    self.callback.add_cleanup_path(&path);
230                }
231                self.remove_candidate(&entry)?;
232                return Err(failure.error);
233            }
234        };
235        if !self.policy_contains(blob_id, &entry)? {
236            entry.begin_eviction();
237            if let Err(error) = self.store.remove(&temp_path) {
238                self.callback.add_cleanup_path(&temp_path);
239                return Err(error);
240            }
241            return Ok(false);
242        }
243        if let Err(failure) = self.store.publish(&entry.metadata, &temp_path) {
244            if let Some(path) = failure.orphan_path {
245                self.callback.add_cleanup_path(&path);
246            }
247            self.remove_candidate(&entry)?;
248            return Err(failure.error);
249        }
250        if !entry.mark_ready() {
251            return Err(BlobCacheError::Reconciliation(
252                "admitted entry left pending state".to_owned(),
253            ));
254        }
255        Ok(true)
256    }
257
258    fn reserve_put(&self, blob_id: &str) -> PendingPutGuard<'_> {
259        let mut pending = self.pending.lock().expect("pending put lock is poisoned");
260        while pending.contains(blob_id) {
261            pending = self
262                .pending_drained
263                .wait(pending)
264                .expect("pending put lock is poisoned");
265        }
266        pending.insert(blob_id.to_owned());
267        PendingPutGuard {
268            cache: self,
269            blob_id: blob_id.to_owned(),
270        }
271    }
272
273    /// Deletes one blob if present.
274    ///
275    /// Missing IDs succeed, making deletion idempotent.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`BlobCacheError`] for an invalid ID, a closed cache, or a failed storage/policy
280    /// operation.
281    pub fn delete(&self, blob_id: &str) -> Result<(), BlobCacheError> {
282        validate_blob_id(blob_id)?;
283        let _lifecycle = self
284            .lifecycle
285            .read()
286            .expect("blob cache lifecycle lock is poisoned");
287        self.require_open()?;
288        let _commit = self
289            .commit
290            .lock()
291            .expect("blob cache commit lock is poisoned");
292        self.callback.retry_cleanup()?;
293
294        let entry = self.policy_entry(blob_id)?;
295        let Some(entry) = entry else {
296            let path = self.store.path_for(blob_id);
297            if let Err(error) = self.store.remove(&path) {
298                self.callback.add_cleanup_path(&path);
299                return Err(error);
300            }
301            return Ok(());
302        };
303        entry.begin_eviction();
304        if let Err(error) = self.store.remove(&entry.metadata.path) {
305            entry.restore_ready();
306            return Err(error);
307        }
308        self.with_policy(|policy| {
309            policy.remove(&blob_id.to_owned());
310            Ok(())
311        })?;
312        self.wait_for_policy()
313    }
314
315    /// Removes every cache entry while keeping the cache open.
316    ///
317    /// The call excludes concurrent cache operations until both policy and disk state are cleared.
318    ///
319    /// # Errors
320    ///
321    /// Returns [`BlobCacheError::Closed`] after close or a reconciliation/storage failure when the
322    /// cache cannot fully purge its state.
323    pub fn delete_all(&self) -> Result<(), BlobCacheError> {
324        let _lifecycle = self
325            .lifecycle
326            .write()
327            .expect("blob cache lifecycle lock is poisoned");
328        self.require_open()?;
329        let _commit = self
330            .commit
331            .lock()
332            .expect("blob cache commit lock is poisoned");
333
334        self.callback.reset_error();
335        self.with_policy(|policy| {
336            policy
337                .clear()
338                .map_err(|error| BlobCacheError::Policy(error.to_string()))
339        })?;
340        self.wait_for_policy()?;
341        let callback_error = self.callback.take_error();
342        if let Err(error) = self.store.purge() {
343            self.callback.require_purge();
344            return Err(match callback_error {
345                Some(callback_error) => BlobCacheError::Reconciliation(format!(
346                    "{callback_error}; purge blob cache: {error}"
347                )),
348                None => error,
349            });
350        }
351        self.callback.reset_after_purge();
352        Ok(())
353    }
354
355    /// Releases policy resources and rejects future operations.
356    ///
357    /// Close is idempotent and preserves committed files for the next [`BlobCache::open`].
358    ///
359    /// # Errors
360    ///
361    /// Returns a cleanup error after closing when a previously deferred file removal still fails.
362    pub fn close(&self) -> Result<(), BlobCacheError> {
363        let _lifecycle = self
364            .lifecycle
365            .write()
366            .expect("blob cache lifecycle lock is poisoned");
367        if self.closed.load(Ordering::Acquire) {
368            return Ok(());
369        }
370        let _commit = self
371            .commit
372            .lock()
373            .expect("blob cache commit lock is poisoned");
374        let cleanup_result = self.callback.retry_cleanup();
375        self.close_policy_preserving_files();
376        cleanup_result
377    }
378
379    fn recover(&self) -> Result<(), BlobCacheError> {
380        let _commit = self
381            .commit
382            .lock()
383            .expect("blob cache commit lock is poisoned");
384        self.store.purge_temp().map_err(|error| {
385            BlobCacheError::Reconciliation(format!("purge interrupted writes: {error}"))
386        })?;
387        let scan = self.store.scan().map_err(|error| {
388            BlobCacheError::Reconciliation(format!("scan committed files: {error}"))
389        })?;
390        for path in scan.invalid_paths {
391            self.store.remove(&path).map_err(|error| {
392                BlobCacheError::Reconciliation(format!("remove invalid file: {error}"))
393            })?;
394        }
395        for metadata in scan.entries {
396            self.recover_entry(metadata)?;
397        }
398        Ok(())
399    }
400
401    fn recover_entry(&self, metadata: FileMetadata) -> Result<(), BlobCacheError> {
402        if metadata.size > self.config.max_bytes() {
403            return self.store.remove(&metadata.path).map_err(|error| {
404                BlobCacheError::Reconciliation(format!("remove oversized file: {error}"))
405            });
406        }
407
408        let entry = Arc::new(DiskEntry::ready(metadata));
409        self.callback.reset_error();
410        let inserted = self.with_policy(|policy| {
411            Ok(policy.insert(
412                entry.metadata.blob_id.clone(),
413                Arc::clone(&entry),
414                entry.metadata.size,
415            ))
416        })?;
417        if !inserted {
418            entry.begin_eviction();
419            return self.store.remove(&entry.metadata.path).map_err(|error| {
420                BlobCacheError::Reconciliation(format!("remove rejected file: {error}"))
421            });
422        }
423        self.wait_for_policy()?;
424        if let Some(error) = self.callback.take_error() {
425            return Err(BlobCacheError::Reconciliation(error));
426        }
427        if self.policy_contains(&entry.metadata.blob_id, &entry)? {
428            return Ok(());
429        }
430        self.store.remove(&entry.metadata.path).map_err(|error| {
431            BlobCacheError::Reconciliation(format!("remove dropped file: {error}"))
432        })
433    }
434
435    fn reuse_existing(
436        &self,
437        metadata: &FileMetadata,
438        payload: &[u8],
439    ) -> Result<ExistingEntry, BlobCacheError> {
440        let Some(entry) = self.policy_entry(&metadata.blob_id)? else {
441            return Ok(ExistingEntry::Missing);
442        };
443        let Some(lease) = entry.acquire_read() else {
444            return Ok(ExistingEntry::Missing);
445        };
446        let result = self.store.read(&entry);
447        drop(lease);
448        match result {
449            Ok(existing) => {
450                if entry.metadata.size != metadata.size
451                    || entry.metadata.checksum != metadata.checksum
452                    || existing != payload
453                {
454                    return Err(BlobCacheError::ContentMismatch(metadata.blob_id.clone()));
455                }
456                Ok(ExistingEntry::Reused)
457            }
458            Err(error) if error.is_missing_or_corrupt() => {
459                entry.begin_eviction();
460                if let Err(remove_error) = self.store.remove(&entry.metadata.path) {
461                    self.callback.add_cleanup_path(&entry.metadata.path);
462                    self.remove_policy_key(&metadata.blob_id)?;
463                    return Err(remove_error);
464                }
465                self.remove_policy_key(&metadata.blob_id)?;
466                Ok(ExistingEntry::Missing)
467            }
468            Err(error) => Err(error),
469        }
470    }
471
472    fn invalidate_entry(&self, entry: &Arc<DiskEntry>) -> Result<(), BlobCacheError> {
473        let _commit = self
474            .commit
475            .lock()
476            .expect("blob cache commit lock is poisoned");
477        let current = self.policy_entry(&entry.metadata.blob_id)?;
478        if !current
479            .as_ref()
480            .is_some_and(|current| Arc::ptr_eq(current, entry))
481        {
482            return Ok(());
483        }
484        entry.begin_eviction();
485        let remove_result = self.store.remove(&entry.metadata.path);
486        self.remove_policy_key(&entry.metadata.blob_id)?;
487        if remove_result.is_err() {
488            self.callback.add_cleanup_path(&entry.metadata.path);
489        }
490        remove_result
491    }
492
493    fn remove_candidate(&self, entry: &Arc<DiskEntry>) -> Result<(), BlobCacheError> {
494        if self.policy_contains(&entry.metadata.blob_id, entry)? {
495            self.remove_policy_key(&entry.metadata.blob_id)?;
496        }
497        entry.begin_eviction();
498        Ok(())
499    }
500
501    fn remove_policy_key(&self, blob_id: &str) -> Result<(), BlobCacheError> {
502        self.with_policy(|policy| {
503            policy.remove(&blob_id.to_owned());
504            Ok(())
505        })?;
506        self.wait_for_policy()
507    }
508
509    fn policy_entry(&self, blob_id: &str) -> Result<Option<Arc<DiskEntry>>, BlobCacheError> {
510        self.with_policy(|policy| Ok(policy.get(blob_id).map(|entry| Arc::clone(entry.value()))))
511    }
512
513    fn policy_contains(
514        &self,
515        blob_id: &str,
516        expected: &Arc<DiskEntry>,
517    ) -> Result<bool, BlobCacheError> {
518        self.with_policy(|policy| {
519            Ok(policy
520                .get(blob_id)
521                .is_some_and(|current| Arc::ptr_eq(current.value(), expected)))
522        })
523    }
524
525    fn wait_for_policy(&self) -> Result<(), BlobCacheError> {
526        self.with_policy(|policy| {
527            policy
528                .wait()
529                .map_err(|error| BlobCacheError::Policy(error.to_string()))
530        })
531    }
532
533    fn with_policy<T>(
534        &self,
535        operation: impl FnOnce(&PolicyCache) -> Result<T, BlobCacheError>,
536    ) -> Result<T, BlobCacheError> {
537        let policy = self
538            .policy
539            .read()
540            .expect("blob cache policy lock is poisoned");
541        operation(policy.as_ref().ok_or(BlobCacheError::Closed)?)
542    }
543
544    fn require_open(&self) -> Result<(), BlobCacheError> {
545        if self.closed.load(Ordering::Acquire) {
546            Err(BlobCacheError::Closed)
547        } else {
548            Ok(())
549        }
550    }
551
552    fn close_policy_preserving_files(&self) {
553        self.callback.set_closing();
554        self.closed.store(true, Ordering::Release);
555        let policy = self
556            .policy
557            .write()
558            .expect("blob cache policy lock is poisoned")
559            .take();
560        drop(policy);
561    }
562}
563
564impl Drop for BlobCache {
565    fn drop(&mut self) {
566        self.callback.set_closing();
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use std::sync::mpsc;
573    use std::thread;
574    use std::time::Duration;
575
576    use super::*;
577
578    #[test]
579    fn distinct_put_progresses_during_blocked_write() {
580        let directory = tempfile::tempdir().expect("cache directory");
581        let mut cache = BlobCache::open(
582            BlobCacheConfig::new(directory.path(), 1 << 20, 0).expect("cache configuration"),
583        )
584        .expect("open cache");
585        let release = Arc::new((Mutex::new(false), Condvar::new()));
586        let (started_sender, started_receiver) = mpsc::channel();
587        let release_for_hook = Arc::clone(&release);
588        cache.store.write_hook = Some(Arc::new(move |blob_id| {
589            if blob_id == "first" {
590                started_sender.send(()).expect("report blocked write");
591                let (lock, released) = &*release_for_hook;
592                let mut is_released = lock.lock().expect("release lock");
593                while !*is_released {
594                    is_released = released.wait(is_released).expect("release lock");
595                }
596            }
597        }));
598        let cache = Arc::new(cache);
599        let first_cache = Arc::clone(&cache);
600        let first = thread::spawn(move || first_cache.put("first", b"payload"));
601        started_receiver
602            .recv_timeout(Duration::from_secs(2))
603            .expect("first write reached stage");
604
605        let second_cache = Arc::clone(&cache);
606        let (second_sender, second_receiver) = mpsc::channel();
607        let second = thread::spawn(move || {
608            second_sender
609                .send(second_cache.put("second", b"payload"))
610                .expect("report second Put");
611        });
612        let second_result = second_receiver.recv_timeout(Duration::from_secs(2));
613        let (lock, released) = &*release;
614        *lock.lock().expect("release lock") = true;
615        released.notify_all();
616        assert!(
617            second_result
618                .expect("distinct Put progressed")
619                .expect("second Put")
620        );
621        assert!(first.join().expect("first Put thread").expect("first Put"));
622        second.join().expect("second Put thread");
623        cache.close().expect("close cache");
624    }
625}