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