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