1use super::config::{BlobCacheConfig, L2Compression, L2PromotionPolicy};
9use super::entry::{effective_expires_at_unix_ms, jitter_seed, Entry};
10use super::l2::BlobCacheL2;
11use super::shard::{InsertOutcome, Lookup, Shard};
12
13use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
14use std::hash::{Hash, Hasher};
15#[cfg(test)]
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18use std::sync::{Arc, OnceLock, Weak};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use indexmap::Equivalent;
22use parking_lot::RwLock;
23
24use super::super::compressor::{CompressOpts, Compressed, L2BlobCompressor};
25use super::super::extended_ttl::ExtendedTtlPolicy;
26use super::super::promotion_pool::{
27 AsyncPromotionPool, PoolOpts, PromotionExecutor, PromotionRequest,
28};
29
30#[cfg(test)]
35thread_local! {
36 static PUT_PREPARED_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
37 pub(super) static EFFECTIVE_EXPIRY_COMPUTE_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum CacheError {
42 BlobTooLarge {
43 size: usize,
44 max: usize,
45 },
46 MetadataTooLarge {
47 keys: usize,
48 bytes: usize,
49 max_keys: usize,
50 max_bytes: usize,
51 },
52 TooManyNamespaces {
53 max: usize,
54 },
55 VersionMismatch {
56 existing: u64,
57 attempted: u64,
58 },
59 L2Full {
60 size: u64,
61 max: u64,
62 },
63 L2Io(String),
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub(super) struct BlobCacheKey {
68 pub(super) namespace: String,
69 pub(super) key: String,
70}
71
72impl BlobCacheKey {
73 pub(super) fn new(namespace: impl Into<String>, key: impl Into<String>) -> Self {
74 Self {
75 namespace: namespace.into(),
76 key: key.into(),
77 }
78 }
79
80 pub(super) fn borrowed<'a>(namespace: &'a str, key: &'a str) -> BlobCacheKeyRef<'a> {
81 BlobCacheKeyRef { namespace, key }
82 }
83}
84
85pub(super) struct BlobCacheKeyRef<'a> {
86 pub(super) namespace: &'a str,
87 pub(super) key: &'a str,
88}
89
90impl Hash for BlobCacheKeyRef<'_> {
91 fn hash<H: Hasher>(&self, state: &mut H) {
92 self.namespace.hash(state);
93 self.key.hash(state);
94 }
95}
96
97impl Equivalent<BlobCacheKey> for BlobCacheKeyRef<'_> {
98 fn equivalent(&self, key: &BlobCacheKey) -> bool {
99 self.namespace == key.namespace && self.key == key.key
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104struct ScopedLabel {
105 namespace: String,
106 label: String,
107}
108
109impl ScopedLabel {
110 fn new(namespace: impl Into<String>, label: impl Into<String>) -> Self {
111 Self {
112 namespace: namespace.into(),
113 label: label.into(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct BlobCacheHit {
120 pub(super) bytes: Arc<[u8]>,
121 pub(super) content_metadata: Option<Arc<BTreeMap<String, String>>>,
122 pub(super) version: Option<u64>,
123 pub(super) stale_window_remaining_ms: Option<u64>,
127}
128
129impl BlobCacheHit {
130 pub(crate) fn new(
131 bytes: Arc<[u8]>,
132 content_metadata: Option<Arc<BTreeMap<String, String>>>,
133 version: Option<u64>,
134 ) -> Self {
135 Self {
136 bytes,
137 content_metadata,
138 version,
139 stale_window_remaining_ms: None,
140 }
141 }
142
143 pub(crate) fn new_stale(
144 bytes: Arc<[u8]>,
145 content_metadata: Option<Arc<BTreeMap<String, String>>>,
146 version: Option<u64>,
147 window_remaining_ms: u64,
148 ) -> Self {
149 Self {
150 bytes,
151 content_metadata,
152 version,
153 stale_window_remaining_ms: Some(window_remaining_ms),
154 }
155 }
156
157 pub fn bytes(&self) -> &Arc<[u8]> {
159 &self.bytes
160 }
161
162 pub fn value(&self) -> &[u8] {
164 &self.bytes
165 }
166
167 pub fn content_metadata(&self) -> &BTreeMap<String, String> {
169 static EMPTY: BTreeMap<String, String> = BTreeMap::new();
170 self.content_metadata.as_deref().unwrap_or(&EMPTY)
171 }
172
173 pub fn version(&self) -> Option<u64> {
175 self.version
176 }
177
178 pub fn is_stale(&self) -> bool {
182 self.stale_window_remaining_ms.is_some()
183 }
184
185 pub fn stale_window_remaining_ms(&self) -> Option<u64> {
188 self.stale_window_remaining_ms
189 }
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
193pub struct BlobCachePut {
194 pub bytes: Vec<u8>,
195 pub content_metadata: BTreeMap<String, String>,
196 pub tags: BTreeSet<String>,
197 pub dependencies: BTreeSet<String>,
198 pub policy: BlobCachePolicy,
199}
200
201impl BlobCachePut {
202 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
203 Self {
204 bytes: bytes.into(),
205 content_metadata: BTreeMap::new(),
206 tags: BTreeSet::new(),
207 dependencies: BTreeSet::new(),
208 policy: BlobCachePolicy::default(),
209 }
210 }
211
212 pub fn with_content_metadata(mut self, content_metadata: BTreeMap<String, String>) -> Self {
213 self.content_metadata = content_metadata;
214 self
215 }
216
217 pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
218 self.tags = tags.into_iter().map(Into::into).collect();
219 self
220 }
221
222 pub fn with_dependencies(
223 mut self,
224 dependencies: impl IntoIterator<Item = impl Into<String>>,
225 ) -> Self {
226 self.dependencies = dependencies.into_iter().map(Into::into).collect();
227 self
228 }
229
230 pub fn with_policy(mut self, policy: BlobCachePolicy) -> Self {
231 self.policy = policy;
232 self
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum L1Admission {
238 Always,
239 Auto,
240 Never,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CachePresence {
252 Present,
254 Absent,
256 MaybePresent,
259}
260
261impl From<bool> for CachePresence {
262 fn from(present: bool) -> Self {
263 if present {
264 CachePresence::Present
265 } else {
266 CachePresence::Absent
267 }
268 }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct BlobCachePolicy {
273 ttl_ms: Option<u64>,
274 expires_at_unix_ms: Option<u64>,
275 max_blob_bytes: Option<usize>,
276 l1_admission: L1Admission,
277 priority: u8,
278 version: Option<u64>,
279 extended: ExtendedTtlPolicy,
285}
286
287impl Default for BlobCachePolicy {
288 fn default() -> Self {
289 Self {
290 ttl_ms: None,
291 expires_at_unix_ms: None,
292 max_blob_bytes: None,
293 l1_admission: L1Admission::Auto,
294 priority: 128,
295 version: None,
296 extended: ExtendedTtlPolicy::off(),
297 }
298 }
299}
300
301impl BlobCachePolicy {
302 pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
305 self.ttl_ms = Some(ttl_ms);
306 self
307 }
308
309 pub fn expires_at_unix_ms(mut self, expires_at_unix_ms: u64) -> Self {
310 self.expires_at_unix_ms = Some(expires_at_unix_ms);
311 self
312 }
313
314 pub fn max_blob_bytes(mut self, max_blob_bytes: usize) -> Self {
315 self.max_blob_bytes = Some(max_blob_bytes);
316 self
317 }
318
319 pub fn l1_admission(mut self, l1_admission: L1Admission) -> Self {
320 self.l1_admission = l1_admission;
321 self
322 }
323
324 pub fn priority(mut self, priority: u8) -> Self {
325 self.priority = priority;
326 self
327 }
328
329 pub fn version(mut self, version: u64) -> Self {
330 self.version = Some(version);
331 self
332 }
333
334 pub fn extended(mut self, extended: ExtendedTtlPolicy) -> Self {
339 self.extended = extended;
340 self
341 }
342
343 pub fn ttl_ms_value(&self) -> Option<u64> {
350 self.ttl_ms
351 }
352
353 pub fn expires_at_unix_ms_value(&self) -> Option<u64> {
354 self.expires_at_unix_ms
355 }
356
357 pub fn max_blob_bytes_value(&self) -> Option<usize> {
358 self.max_blob_bytes
359 }
360
361 pub fn l1_admission_value(&self) -> L1Admission {
362 self.l1_admission
363 }
364
365 pub fn priority_value(&self) -> u8 {
366 self.priority
367 }
368
369 pub fn version_value(&self) -> Option<u64> {
370 self.version
371 }
372
373 pub fn extended_value(&self) -> ExtendedTtlPolicy {
377 self.extended
378 }
379}
380
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
382pub struct BlobCacheStats {
383 pub(super) hits: u64,
384 pub(super) misses: u64,
385 pub(super) insertions: u64,
386 pub(super) evictions: u64,
387 pub(super) expirations: u64,
388 pub(super) invalidations: u64,
389 pub(super) namespace_flushes: u64,
390 pub(super) version_mismatches: u64,
391 pub(super) entries: usize,
392 pub(super) bytes_in_use: usize,
393 pub(super) l1_bytes_max: usize,
394 pub(super) l2_bytes_in_use: u64,
395 pub(super) l2_bytes_max: u64,
396 pub(super) l2_full_rejections: u64,
397 pub(super) l2_metadata_reads: u64,
398 pub(super) l2_negative_skips: u64,
399 pub(super) synopsis_metadata_reads: u64,
403 pub(super) synopsis_bytes: u64,
405 pub(super) namespaces: usize,
406 pub(super) max_namespaces: usize,
407 pub(super) promotion_queued: u64,
410 pub(super) promotion_dropped: u64,
411 pub(super) promotion_completed: u64,
412 pub(super) promotion_queue_depth: usize,
413 pub(super) l2_compression_original_bytes: u64,
417 pub(super) l2_compression_stored_bytes: u64,
420 pub(super) l2_compression_skipped_total: u64,
422 pub(super) l2_bytes_saved_total: u64,
424 pub(super) l1_stale_serves_total: u64,
427 pub(super) l1_idle_evicts_total: u64,
430}
431
432impl BlobCacheStats {
433 pub fn hits(&self) -> u64 {
436 self.hits
437 }
438
439 pub fn misses(&self) -> u64 {
441 self.misses
442 }
443
444 pub fn insertions(&self) -> u64 {
445 self.insertions
446 }
447
448 pub fn evictions(&self) -> u64 {
449 self.evictions
450 }
451
452 pub fn expirations(&self) -> u64 {
453 self.expirations
454 }
455
456 pub fn invalidations(&self) -> u64 {
457 self.invalidations
458 }
459
460 pub fn namespace_flushes(&self) -> u64 {
461 self.namespace_flushes
462 }
463
464 pub fn version_mismatches(&self) -> u64 {
465 self.version_mismatches
466 }
467
468 pub fn entries(&self) -> usize {
469 self.entries
470 }
471
472 pub fn bytes_in_use(&self) -> u64 {
475 self.bytes_in_use as u64
476 }
477
478 pub fn l1_bytes_max(&self) -> usize {
479 self.l1_bytes_max
480 }
481
482 pub fn l2_bytes_in_use(&self) -> u64 {
483 self.l2_bytes_in_use
484 }
485
486 pub fn l2_bytes_max(&self) -> u64 {
487 self.l2_bytes_max
488 }
489
490 pub fn l2_full_rejections(&self) -> u64 {
491 self.l2_full_rejections
492 }
493
494 pub fn l2_metadata_reads(&self) -> u64 {
495 self.l2_metadata_reads
496 }
497
498 pub fn l2_negative_skips(&self) -> u64 {
499 self.l2_negative_skips
500 }
501
502 pub fn synopsis_metadata_reads(&self) -> u64 {
508 self.synopsis_metadata_reads
509 }
510
511 pub fn synopsis_bytes(&self) -> u64 {
513 self.synopsis_bytes
514 }
515
516 pub fn namespaces(&self) -> usize {
517 self.namespaces
518 }
519
520 pub fn max_namespaces(&self) -> usize {
521 self.max_namespaces
522 }
523
524 pub fn promotion_queued(&self) -> u64 {
527 self.promotion_queued
528 }
529
530 pub fn promotion_dropped(&self) -> u64 {
533 self.promotion_dropped
534 }
535
536 pub fn promotion_completed(&self) -> u64 {
539 self.promotion_completed
540 }
541
542 pub fn promotion_queue_depth(&self) -> usize {
545 self.promotion_queue_depth
546 }
547
548 pub fn l2_compression_ratio_observed(&self) -> f64 {
555 if self.l2_compression_stored_bytes == 0 {
556 return 1.0;
557 }
558 self.l2_compression_original_bytes as f64 / self.l2_compression_stored_bytes as f64
559 }
560
561 pub fn l2_compression_skipped_total(&self) -> u64 {
565 self.l2_compression_skipped_total
566 }
567
568 pub fn l2_bytes_saved_total(&self) -> u64 {
572 self.l2_bytes_saved_total
573 }
574
575 pub fn l1_stale_serves_total(&self) -> u64 {
579 self.l1_stale_serves_total
580 }
581
582 pub fn l1_idle_evicts_total(&self) -> u64 {
586 self.l1_idle_evicts_total
587 }
588}
589
590#[derive(Clone, Copy)]
591enum IndexedKind {
592 Tag,
593 Dependency,
594}
595
596#[derive(Debug)]
597struct AtomicStats {
598 hits: AtomicU64,
599 misses: AtomicU64,
600 insertions: AtomicU64,
601 evictions: AtomicU64,
602 expirations: AtomicU64,
603 invalidations: AtomicU64,
604 namespace_flushes: AtomicU64,
605 version_mismatches: AtomicU64,
606 l2_full_rejections: AtomicU64,
607 l1_stale_serves: AtomicU64,
611 l1_idle_evicts: AtomicU64,
615}
616
617impl AtomicStats {
618 fn new() -> Self {
619 Self {
620 hits: AtomicU64::new(0),
621 misses: AtomicU64::new(0),
622 insertions: AtomicU64::new(0),
623 evictions: AtomicU64::new(0),
624 expirations: AtomicU64::new(0),
625 invalidations: AtomicU64::new(0),
626 namespace_flushes: AtomicU64::new(0),
627 version_mismatches: AtomicU64::new(0),
628 l2_full_rejections: AtomicU64::new(0),
629 l1_stale_serves: AtomicU64::new(0),
630 l1_idle_evicts: AtomicU64::new(0),
631 }
632 }
633}
634
635pub struct BlobCache {
654 config: BlobCacheConfig,
655 shards: Vec<RwLock<Shard>>,
656 namespaces: RwLock<HashSet<String>>,
657 namespace_generations: RwLock<HashMap<String, u64>>,
658 tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
659 dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
660 l2: Option<Arc<BlobCacheL2>>,
661 bytes_in_use: AtomicUsize,
662 stats: AtomicStats,
663 promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
668}
669
670const _: fn() = || {
675 fn assert_send_sync<T: Send + Sync>() {}
676 assert_send_sync::<BlobCache>();
677};
678
679impl BlobCache {
680 pub fn new(config: BlobCacheConfig) -> Self {
684 Self::try_new(config).expect("open blob-cache L2")
685 }
686
687 pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
691 Self::try_new(config)
692 }
693
694 fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
695 let config = BlobCacheConfig {
696 shard_count: config.shard_count.max(1),
697 ..config
698 };
699 let l2 = config
700 .l2_path
701 .clone()
702 .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
703 .transpose()?;
704 let shards = (0..config.shard_count)
705 .map(|_| RwLock::new(Shard::new()))
706 .collect();
707 Ok(Self {
708 config,
709 shards,
710 namespaces: RwLock::new(HashSet::new()),
711 namespace_generations: RwLock::new(HashMap::new()),
712 tag_index: RwLock::new(HashMap::new()),
713 dependency_index: RwLock::new(HashMap::new()),
714 l2: l2.map(Arc::new),
715 bytes_in_use: AtomicUsize::new(0),
716 stats: AtomicStats::new(),
717 promotion_pool: OnceLock::new(),
718 })
719 }
720
721 pub fn with_defaults() -> Self {
722 Self::new(BlobCacheConfig::default())
723 }
724
725 pub fn l2_path(&self) -> Option<&std::path::Path> {
733 self.config.l2_path.as_deref()
734 }
735
736 pub fn put(
737 &self,
738 namespace: impl Into<String>,
739 key: impl Into<String>,
740 input: BlobCachePut,
741 ) -> Result<(), CacheError> {
742 self.put_at(namespace, key, input, unix_now_ms())
743 }
744
745 fn put_at(
746 &self,
747 namespace: impl Into<String>,
748 key: impl Into<String>,
749 input: BlobCachePut,
750 now_ms: u64,
751 ) -> Result<(), CacheError> {
752 let namespace = namespace.into();
753 let key = BlobCacheKey::new(namespace.clone(), key);
754 self.validate_blob_size(input.bytes.len(), input.policy)?;
755 self.validate_metadata(&input.content_metadata)?;
756 self.ensure_namespace(&namespace)?;
757 let namespace_generation = self.current_generation(&namespace);
758 let tags = input.tags.clone();
759 let dependencies = input.dependencies.clone();
760
761 let shard_idx = self.shard_index(&key);
762 if self.l2.is_some() {
763 self.check_version(
764 &self.shards[shard_idx].read(),
765 &key,
766 input.policy.version_value(),
767 namespace_generation,
768 )?;
769 }
770 let entry = Entry::new(
771 input.bytes,
772 input.content_metadata,
773 input.tags,
774 input.dependencies,
775 input.policy,
776 namespace_generation,
777 now_ms,
778 &namespace,
779 &key.key,
780 );
781 let entry_size = entry.size;
782 let compressed = if self.l2.is_some() {
783 Some(match self.config.l2_compression {
790 L2Compression::Off => Compressed::Raw(entry.bytes.as_ref().to_vec()),
791 L2Compression::On => {
792 let content_type = entry
793 .content_metadata
794 .as_ref()
795 .and_then(|metadata| metadata.get("content-type"))
796 .map(String::as_str);
797 L2BlobCompressor::compress(
798 entry.bytes.as_ref(),
799 content_type,
800 &CompressOpts::default(),
801 )
802 .map_err(|err| CacheError::L2Io(err.to_string()))?
803 }
804 })
805 } else {
806 None
807 };
808 #[cfg(test)]
809 if let Some(hook) = PUT_PREPARED_HOOK.with(|slot| slot.borrow_mut().take()) {
810 hook();
811 }
812 let mut shard = self.shards[shard_idx].write();
813 self.check_version(
816 &shard,
817 &key,
818 input.policy.version_value(),
819 namespace_generation,
820 )?;
821 shard.clear_l2_hit_marker(&key);
822 if let Some(l2) = &self.l2 {
823 let old_l2_size = l2.record_size(&key);
824 let compressed = compressed.expect("L2 payload prepared before publication");
825 match l2.put(&key, &entry, old_l2_size, compressed) {
826 Ok(()) => {}
827 Err(err @ CacheError::L2Full { .. }) => {
828 self.stats
829 .l2_full_rejections
830 .fetch_add(1, Ordering::Relaxed);
831 return Err(err);
832 }
833 Err(err) => return Err(err),
834 }
835 }
836 let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
837 let old_entry = shard.remove(&key);
838 InsertOutcome {
839 old_entry,
840 admitted: false,
841 }
842 } else {
843 shard.insert(key.clone(), entry)
844 };
845 drop(shard);
846
847 if let Some(old_entry) = outcome.old_entry.as_ref() {
848 self.deindex_entry(&key, old_entry);
849 }
850 if outcome.admitted {
851 self.index_entry(&key, &tags, &dependencies);
852 }
853
854 let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
855 let new_size = if outcome.admitted { entry_size } else { 0 };
856 if new_size >= old_size {
857 self.bytes_in_use
858 .fetch_add(new_size - old_size, Ordering::Relaxed);
859 } else {
860 self.bytes_in_use
861 .fetch_sub(old_size - new_size, Ordering::Relaxed);
862 }
863 self.stats.insertions.fetch_add(1, Ordering::Relaxed);
864 if outcome.admitted {
865 self.evict_until_within_budget(shard_idx);
866 }
867 Ok(())
868 }
869
870 pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
871 self.get_at(namespace, key, unix_now_ms())
872 }
873
874 fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
875 let namespace_generation = self.current_generation(namespace);
876 let shard_idx = self.shard_index_parts(namespace, key);
877 if let Some(hit) =
878 self.shards[shard_idx]
879 .read()
880 .shared_hit(namespace, key, now_ms, namespace_generation)
881 {
882 self.stats.hits.fetch_add(1, Ordering::Relaxed);
883 return Some(hit);
884 }
885 let mut shard = self.shards[shard_idx].write();
886 match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
887 Lookup::Hit(hit) => {
888 self.stats.hits.fetch_add(1, Ordering::Relaxed);
889 if hit.is_stale() {
890 self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
891 }
892 Some(hit)
893 }
894 Lookup::Expired(entry) => {
895 drop(shard);
896 let cache_key = BlobCacheKey::new(namespace, key);
897 self.record_removed_entry(&cache_key, &entry);
898 if let Some(l2) = &self.l2 {
899 l2.delete_key(&cache_key);
900 }
901 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
902 self.stats.misses.fetch_add(1, Ordering::Relaxed);
903 None
904 }
905 Lookup::IdleEvicted(entry) => {
906 drop(shard);
907 let cache_key = BlobCacheKey::new(namespace, key);
908 self.record_removed_entry(&cache_key, &entry);
909 if let Some(l2) = &self.l2 {
910 l2.delete_key(&cache_key);
911 }
912 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
913 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
914 self.stats.misses.fetch_add(1, Ordering::Relaxed);
915 None
916 }
917 Lookup::Stale(entry) => {
918 drop(shard);
919 let cache_key = BlobCacheKey::new(namespace, key);
920 self.record_removed_entry(&cache_key, &entry);
921 self.stats.misses.fetch_add(1, Ordering::Relaxed);
922 None
923 }
924 Lookup::Miss => {
925 drop(shard);
926 let cache_key = BlobCacheKey::new(namespace, key);
927 if let Some(pool) = self.promotion_pool.get() {
928 if let Some(l2) = self.l2.as_ref() {
932 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
933 let hit = entry.hit();
934 if self.should_promote_l2_hit(&cache_key, shard_idx) {
935 drop(entry);
940 let request = PromotionRequest {
941 namespace: cache_key.namespace.clone(),
942 key: cache_key.key.clone(),
943 bytes: Arc::clone(hit.bytes()),
944 policy: BlobCachePolicy::default(),
945 };
946 let _ = pool.schedule(request);
947 }
948 self.stats.hits.fetch_add(1, Ordering::Relaxed);
949 return Some(hit);
950 }
951 }
952 self.stats.misses.fetch_add(1, Ordering::Relaxed);
953 return None;
954 }
955 if let Some(hit) =
956 self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
957 {
958 self.stats.hits.fetch_add(1, Ordering::Relaxed);
959 return Some(hit);
960 }
961 self.stats.misses.fetch_add(1, Ordering::Relaxed);
962 None
963 }
964 Lookup::Present => unreachable!("get cannot return presence-only lookup"),
965 }
966 }
967
968 pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
986 self.exists_at(namespace, key, unix_now_ms())
987 }
988
989 fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
990 let namespace_generation = self.current_generation(namespace);
991 let shard_idx = self.shard_index_parts(namespace, key);
992 let mut shard = self.shards[shard_idx].write();
993 match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
994 Lookup::Present => {
995 self.stats.hits.fetch_add(1, Ordering::Relaxed);
996 CachePresence::Present
997 }
998 Lookup::Expired(entry) => {
999 drop(shard);
1000 let cache_key = BlobCacheKey::new(namespace, key);
1001 self.record_removed_entry(&cache_key, &entry);
1002 if let Some(l2) = &self.l2 {
1003 l2.delete_key(&cache_key);
1004 }
1005 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1006 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1007 CachePresence::Absent
1008 }
1009 Lookup::IdleEvicted(entry) => {
1010 drop(shard);
1011 let cache_key = BlobCacheKey::new(namespace, key);
1012 self.record_removed_entry(&cache_key, &entry);
1013 if let Some(l2) = &self.l2 {
1014 l2.delete_key(&cache_key);
1015 }
1016 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1017 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
1018 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1019 CachePresence::Absent
1020 }
1021 Lookup::Stale(entry) => {
1022 drop(shard);
1023 let cache_key = BlobCacheKey::new(namespace, key);
1024 self.record_removed_entry(&cache_key, &entry);
1025 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1026 CachePresence::Absent
1027 }
1028 Lookup::Miss => {
1029 drop(shard);
1030 let Some(l2) = self.l2.as_ref() else {
1031 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1032 return CachePresence::Absent;
1033 };
1034 if l2.synopsis_may_contain(namespace, key) {
1035 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1038 CachePresence::MaybePresent
1039 } else {
1040 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1043 CachePresence::Absent
1044 }
1045 }
1046 Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1047 }
1048 }
1049
1050 pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1056 if !self.namespace_exists(namespace) {
1057 return 0;
1058 }
1059 let cache_key = BlobCacheKey::new(namespace, key);
1060 let shard_idx = self.shard_index(&cache_key);
1061 let mut shard = self.shards[shard_idx].write();
1062 let removed = shard.remove(&cache_key);
1063 drop(shard);
1064
1065 if let Some(entry) = removed {
1066 self.record_invalidated_entry(&cache_key, &entry);
1067 1
1068 } else {
1069 self.l2
1070 .as_ref()
1071 .and_then(|l2| l2.delete_key(&cache_key))
1072 .map(|_| {
1073 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1074 1
1075 })
1076 .unwrap_or(0)
1077 }
1078 }
1079
1080 pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1082 if !self.namespace_exists(namespace) {
1083 return 0;
1084 }
1085
1086 let mut removed = Vec::new();
1087 for shard in &self.shards {
1088 let mut shard = shard.write();
1089 let keys = shard
1090 .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1091 for key in keys {
1092 if let Some(entry) = shard.remove(&key) {
1093 removed.push((key, entry));
1094 }
1095 }
1096 }
1097
1098 let count = removed.len();
1099 for (key, entry) in removed {
1100 self.record_invalidated_entry(&key, &entry);
1101 }
1102 let l2_count = self
1103 .l2
1104 .as_ref()
1105 .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1106 if l2_count > count {
1107 self.stats
1108 .invalidations
1109 .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1110 }
1111 count.max(l2_count)
1112 }
1113
1114 pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1120 self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1121 }
1122
1123 pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1125 self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1126 }
1127
1128 #[deprecated(
1130 since = "0.1.0",
1131 note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1132 )]
1133 pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1134 self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1135 }
1136
1137 #[deprecated(
1139 since = "0.1.0",
1140 note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1141 )]
1142 pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1143 self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1144 }
1145
1146 pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1152 if !self.namespace_exists(namespace) {
1153 return false;
1154 }
1155 let mut generations = self.namespace_generations.write();
1156 let generation = generations.entry(namespace.to_string()).or_insert(0);
1157 *generation = generation.saturating_add(1);
1158 if let Some(l2) = &self.l2 {
1159 l2.delete_namespace(namespace);
1160 }
1161 self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1162 true
1163 }
1164
1165 pub fn ram_bytes_in_use(&self) -> u64 {
1170 let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1171 let l2 = self
1172 .l2
1173 .as_ref()
1174 .map_or(0, |l2| l2.resident_metadata_bytes());
1175 l1.saturating_add(l2)
1176 }
1177
1178 pub fn stats(&self) -> BlobCacheStats {
1179 BlobCacheStats {
1180 hits: self.stats.hits.load(Ordering::Relaxed),
1181 misses: self.stats.misses.load(Ordering::Relaxed),
1182 insertions: self.stats.insertions.load(Ordering::Relaxed),
1183 evictions: self.stats.evictions.load(Ordering::Relaxed),
1184 expirations: self.stats.expirations.load(Ordering::Relaxed),
1185 invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1186 namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1187 version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1188 entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1189 bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1190 l1_bytes_max: self.config.l1_bytes_max,
1191 l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1192 l2_bytes_max: self.config.l2_bytes_max,
1193 l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1194 l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1195 l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1196 synopsis_metadata_reads: self
1197 .l2
1198 .as_ref()
1199 .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1200 synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1201 namespaces: self.namespaces.read().len(),
1202 max_namespaces: self.config.max_namespaces,
1203 promotion_queued: self
1204 .promotion_pool
1205 .get()
1206 .map_or(0, |p| p.metrics().queued_total),
1207 promotion_dropped: self
1208 .promotion_pool
1209 .get()
1210 .map_or(0, |p| p.metrics().dropped_total),
1211 promotion_completed: self
1212 .promotion_pool
1213 .get()
1214 .map_or(0, |p| p.metrics().completed_total),
1215 promotion_queue_depth: self
1216 .promotion_pool
1217 .get()
1218 .map_or(0, |p| p.metrics().queue_depth),
1219 l2_compression_original_bytes: self
1220 .l2
1221 .as_ref()
1222 .map_or(0, |l2| l2.stats_compression_original_bytes()),
1223 l2_compression_stored_bytes: self
1224 .l2
1225 .as_ref()
1226 .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1227 l2_compression_skipped_total: self
1228 .l2
1229 .as_ref()
1230 .map_or(0, |l2| l2.stats_compression_skipped_total()),
1231 l2_bytes_saved_total: self
1232 .l2
1233 .as_ref()
1234 .map_or(0, |l2| l2.stats_bytes_saved_total()),
1235 l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1236 l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1237 }
1238 }
1239
1240 pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1251 let weak: Weak<Self> = Arc::downgrade(self);
1252 let executor: PromotionExecutor = Arc::new(move |req| {
1253 let Some(cache) = weak.upgrade() else {
1257 return Ok(());
1258 };
1259 cache.promote_from_l2(&req)
1260 });
1261 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1262 match self.promotion_pool.set(Arc::clone(&pool)) {
1263 Ok(()) => pool,
1264 Err(losing_pool) => {
1268 losing_pool.shutdown();
1269 Arc::clone(
1270 self.promotion_pool
1271 .get()
1272 .expect("OnceLock set+get inconsistency"),
1273 )
1274 }
1275 }
1276 }
1277
1278 pub fn shutdown_async_promotion(&self) {
1282 if let Some(pool) = self.promotion_pool.get() {
1283 Arc::clone(pool).shutdown();
1284 }
1285 }
1286
1287 #[cfg(test)]
1290 fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1291 self.promotion_pool.get().cloned()
1292 }
1293
1294 #[cfg(test)]
1298 fn enable_async_promotion_with_executor(
1299 self: &Arc<Self>,
1300 opts: PoolOpts,
1301 executor: PromotionExecutor,
1302 ) -> Arc<AsyncPromotionPool> {
1303 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1304 let _ = self.promotion_pool.set(Arc::clone(&pool));
1305 pool
1306 }
1307
1308 pub fn config(&self) -> &BlobCacheConfig {
1309 &self.config
1310 }
1311
1312 #[cfg(test)]
1313 fn inject_l2_fault_after_blob_write_once(&self) {
1314 self.l2
1315 .as_ref()
1316 .expect("L2 enabled")
1317 .inject_fault_after_blob_write_once();
1318 }
1319
1320 #[cfg(test)]
1321 fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1322 self.l2
1323 .as_ref()
1324 .expect("L2 enabled")
1325 .inject_synopsis_maybe_present(namespace, key);
1326 }
1327
1328 #[cfg(test)]
1332 fn inject_l2_v1_entry(
1333 &self,
1334 namespace: &str,
1335 key: &str,
1336 payload: &[u8],
1337 ) -> Result<(), CacheError> {
1338 let l2 = self.l2.as_ref().expect("L2 enabled");
1339 let cache_key = BlobCacheKey::new(namespace, key);
1340 l2.inject_v1_entry(&cache_key, payload)
1341 }
1342
1343 fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1344 let max = policy
1345 .max_blob_bytes_value()
1346 .unwrap_or(self.config.l1_bytes_max);
1347 if size > max {
1348 Err(CacheError::BlobTooLarge { size, max })
1349 } else {
1350 Ok(())
1351 }
1352 }
1353
1354 fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1355 let keys = metadata.len();
1356 let bytes = metadata
1357 .iter()
1358 .map(|(key, value)| key.len() + value.len())
1359 .sum::<usize>();
1360 if keys > self.config.content_metadata_keys_max
1361 || bytes > self.config.content_metadata_bytes_max
1362 {
1363 Err(CacheError::MetadataTooLarge {
1364 keys,
1365 bytes,
1366 max_keys: self.config.content_metadata_keys_max,
1367 max_bytes: self.config.content_metadata_bytes_max,
1368 })
1369 } else {
1370 Ok(())
1371 }
1372 }
1373
1374 fn rehydrate_l2_entry(
1375 &self,
1376 key: &BlobCacheKey,
1377 now_ms: u64,
1378 namespace_generation: u64,
1379 shard_idx: usize,
1380 ) -> Option<BlobCacheHit> {
1381 let l2 = self.l2.as_ref()?;
1382 let entry = l2.get(key, now_ms, namespace_generation)?;
1383 let hit = entry.hit();
1384 if self.should_promote_l2_hit(key, shard_idx) {
1385 self.do_l1_promotion_sync(key, entry, shard_idx);
1386 }
1387 Some(hit)
1388 }
1389
1390 fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1391 match self.config.l2_promotion_policy {
1392 L2PromotionPolicy::Immediate => true,
1393 L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1394 .write()
1395 .l2_hit_should_promote_on_second_hit(key),
1396 }
1397 }
1398
1399 fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1407 let entry_size = entry.size;
1408 let mut shard = self.shards[shard_idx].write();
1409 let outcome = shard.insert(key.clone(), entry);
1410 drop(shard);
1411 let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
1412 if entry_size >= old_size {
1413 self.bytes_in_use
1414 .fetch_add(entry_size - old_size, Ordering::Relaxed);
1415 } else {
1416 self.bytes_in_use
1417 .fetch_sub(old_size - entry_size, Ordering::Relaxed);
1418 }
1419 self.evict_until_within_budget(shard_idx);
1420 }
1421
1422 fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1428 let l2 = self
1429 .l2
1430 .as_ref()
1431 .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1432 let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1433 let now_ms = unix_now_ms();
1434 let namespace_generation = self.current_generation(req.namespace.as_str());
1435 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1436 let shard_idx = self.shard_index(&cache_key);
1437 self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1438 }
1439 Ok(())
1440 }
1441
1442 fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1443 {
1444 let namespaces = self.namespaces.read();
1445 if namespaces.contains(namespace) {
1446 return Ok(());
1447 }
1448 }
1449 let mut namespaces = self.namespaces.write();
1450 if namespaces.contains(namespace) {
1451 return Ok(());
1452 }
1453 if namespaces.len() >= self.config.max_namespaces {
1454 return Err(CacheError::TooManyNamespaces {
1455 max: self.config.max_namespaces,
1456 });
1457 }
1458 namespaces.insert(namespace.to_string());
1459 self.namespace_generations
1460 .write()
1461 .entry(namespace.to_string())
1462 .or_insert(0);
1463 Ok(())
1464 }
1465
1466 fn namespace_exists(&self, namespace: &str) -> bool {
1467 self.namespaces.read().contains(namespace)
1468 || self
1469 .l2
1470 .as_ref()
1471 .is_some_and(|l2| l2.has_namespace(namespace))
1472 }
1473
1474 fn current_generation(&self, namespace: &str) -> u64 {
1475 self.namespace_generations
1476 .read()
1477 .get(namespace)
1478 .copied()
1479 .unwrap_or(0)
1480 }
1481
1482 fn index_entry(
1483 &self,
1484 key: &BlobCacheKey,
1485 tags: &BTreeSet<String>,
1486 dependencies: &BTreeSet<String>,
1487 ) {
1488 if !tags.is_empty() {
1489 let mut index = self.tag_index.write();
1490 for tag in tags {
1491 index
1492 .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1493 .or_default()
1494 .insert(key.clone());
1495 }
1496 }
1497 if !dependencies.is_empty() {
1498 let mut index = self.dependency_index.write();
1499 for dependency in dependencies {
1500 index
1501 .entry(ScopedLabel::new(
1502 key.namespace.as_str(),
1503 dependency.as_str(),
1504 ))
1505 .or_default()
1506 .insert(key.clone());
1507 }
1508 }
1509 }
1510
1511 fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1512 Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1513 Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1514 }
1515
1516 fn remove_indexed_labels(
1517 index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1518 key: &BlobCacheKey,
1519 labels: &BTreeSet<String>,
1520 ) {
1521 if labels.is_empty() {
1522 return;
1523 }
1524 let mut index = index.write();
1525 for label in labels {
1526 let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1527 let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1528 keys.remove(key);
1529 keys.is_empty()
1530 } else {
1531 false
1532 };
1533 if should_remove {
1534 index.remove(&scoped);
1535 }
1536 }
1537 }
1538
1539 fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1540 self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1541 self.deindex_entry(key, entry);
1542 }
1543
1544 fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1545 self.record_removed_entry(key, entry);
1546 if let Some(l2) = &self.l2 {
1547 l2.delete_key(key);
1548 }
1549 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1550 }
1551
1552 fn invalidate_indexed_many(
1553 &self,
1554 namespace: &str,
1555 labels: &[&str],
1556 kind: IndexedKind,
1557 ) -> usize {
1558 if labels.is_empty() || !self.namespace_exists(namespace) {
1559 return 0;
1560 }
1561
1562 let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1567 {
1568 let index = match kind {
1569 IndexedKind::Tag => self.tag_index.read(),
1570 IndexedKind::Dependency => self.dependency_index.read(),
1571 };
1572 for label in labels {
1573 let scoped = ScopedLabel::new(namespace, *label);
1574 if let Some(keys) = index.get(&scoped) {
1575 for key in keys {
1576 candidates
1577 .entry(key.clone())
1578 .or_default()
1579 .insert((*label).to_string());
1580 }
1581 }
1582 }
1583 }
1584
1585 if candidates.is_empty() {
1586 return 0;
1587 }
1588
1589 let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1592 for (key, matched_labels) in candidates {
1593 let shard_idx = self.shard_index(&key);
1594 by_shard
1595 .entry(shard_idx)
1596 .or_default()
1597 .push((key, matched_labels));
1598 }
1599
1600 let mut removed = Vec::new();
1601 for (shard_idx, keys) in by_shard {
1602 let mut shard = self.shards[shard_idx].write();
1603 for (key, matched_labels) in keys {
1604 let still_matches = match kind {
1605 IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1606 IndexedKind::Dependency => {
1607 shard.entry_has_any_dependency(&key, &matched_labels)
1608 }
1609 };
1610 if still_matches {
1611 if let Some(entry) = shard.remove(&key) {
1612 removed.push((key, entry));
1613 }
1614 }
1615 }
1616 }
1617
1618 let count = removed.len();
1619 for (key, entry) in removed {
1620 self.record_invalidated_entry(&key, &entry);
1621 }
1622 count
1623 }
1624
1625 fn shard_index(&self, key: &BlobCacheKey) -> usize {
1626 self.shard_index_parts(&key.namespace, &key.key)
1627 }
1628
1629 fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1630 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1631 namespace.hash(&mut hasher);
1632 key.hash(&mut hasher);
1633 (hasher.finish() as usize) % self.shards.len()
1634 }
1635
1636 fn check_version(
1637 &self,
1638 shard: &Shard,
1639 key: &BlobCacheKey,
1640 attempted: Option<u64>,
1641 namespace_generation: u64,
1642 ) -> Result<(), CacheError> {
1643 let Some(attempted) = attempted else {
1644 return Ok(());
1645 };
1646 let Some(existing) = shard.existing_version(key, namespace_generation) else {
1647 return Ok(());
1648 };
1649 if existing >= attempted {
1650 self.stats
1651 .version_mismatches
1652 .fetch_add(1, Ordering::Relaxed);
1653 Err(CacheError::VersionMismatch {
1654 existing,
1655 attempted,
1656 })
1657 } else {
1658 Ok(())
1659 }
1660 }
1661
1662 fn evict_until_within_budget(&self, preferred_start: usize) {
1663 while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
1664 let mut evicted = false;
1665 for offset in 0..self.shards.len() {
1666 let idx = (preferred_start + offset) % self.shards.len();
1667 let mut shard = self.shards[idx].write();
1668 if let Some((key, entry)) = shard.evict_one() {
1669 self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1670 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1671 evicted = true;
1672 drop(shard);
1673 self.deindex_entry(&key, &entry);
1674 break;
1675 }
1676 }
1677 if !evicted {
1678 break;
1679 }
1680 }
1681 }
1682}
1683
1684fn unix_now_ms() -> u64 {
1685 SystemTime::now()
1686 .duration_since(UNIX_EPOCH)
1687 .map(|duration| duration.as_millis() as u64)
1688 .unwrap_or(0)
1689}
1690
1691impl Default for BlobCache {
1692 fn default() -> Self {
1693 Self::with_defaults()
1694 }
1695}
1696
1697#[cfg(test)]
1698mod tests;