1#![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
35pub 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 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 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 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 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 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 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}