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