1use super::config::{BlobCacheConfig, L2Compression, L2PromotionPolicy};
8use super::entry::{effective_expires_at_unix_ms, jitter_seed, Entry};
9use super::l2::BlobCacheL2;
10use super::l2_backend::BlobCacheL2Backend;
11use super::l2_store::StoreBlobCacheL2;
12use super::shard::{InsertOutcome, Lookup, Shard, ShardSweep};
13
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::hash::{Hash, Hasher};
16#[cfg(test)]
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
19use std::sync::{Arc, OnceLock, Weak};
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22use indexmap::Equivalent;
23use parking_lot::RwLock;
24
25use super::super::compressor::{CompressOpts, Compressed, L2BlobCompressor};
26use super::super::extended_ttl::ExtendedTtlPolicy;
27use super::super::promotion_pool::{
28 AsyncPromotionPool, PoolOpts, PromotionExecutor, PromotionRequest,
29};
30
31#[cfg(test)]
36thread_local! {
37 static PUT_PREPARED_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
38 pub(super) static EFFECTIVE_EXPIRY_COMPUTE_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CacheError {
43 BlobTooLarge {
44 size: usize,
45 max: usize,
46 },
47 MetadataTooLarge {
48 keys: usize,
49 bytes: usize,
50 max_keys: usize,
51 max_bytes: usize,
52 },
53 TooManyNamespaces {
54 max: usize,
55 },
56 VersionMismatch {
57 existing: u64,
58 attempted: u64,
59 },
60 L2Full {
61 size: u64,
62 max: u64,
63 },
64 L2Io(String),
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub(super) struct BlobCacheKey {
69 pub(super) namespace: String,
70 pub(super) key: String,
71}
72
73impl BlobCacheKey {
74 pub(super) fn new(namespace: impl Into<String>, key: impl Into<String>) -> Self {
75 Self {
76 namespace: namespace.into(),
77 key: key.into(),
78 }
79 }
80
81 pub(super) fn borrowed<'a>(namespace: &'a str, key: &'a str) -> BlobCacheKeyRef<'a> {
82 BlobCacheKeyRef { namespace, key }
83 }
84}
85
86pub(super) struct BlobCacheKeyRef<'a> {
87 pub(super) namespace: &'a str,
88 pub(super) key: &'a str,
89}
90
91impl Hash for BlobCacheKeyRef<'_> {
92 fn hash<H: Hasher>(&self, state: &mut H) {
93 self.namespace.hash(state);
94 self.key.hash(state);
95 }
96}
97
98impl Equivalent<BlobCacheKey> for BlobCacheKeyRef<'_> {
99 fn equivalent(&self, key: &BlobCacheKey) -> bool {
100 self.namespace == key.namespace && self.key == key.key
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Hash)]
105struct ScopedLabel {
106 namespace: String,
107 label: String,
108}
109
110impl ScopedLabel {
111 fn new(namespace: impl Into<String>, label: impl Into<String>) -> Self {
112 Self {
113 namespace: namespace.into(),
114 label: label.into(),
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct BlobCacheHit {
121 pub(super) bytes: Arc<[u8]>,
122 pub(super) content_metadata: Option<Arc<BTreeMap<String, String>>>,
123 pub(super) version: Option<u64>,
124 pub(super) expires_at_unix_ms: Option<u64>,
125 pub(super) stale_window_remaining_ms: Option<u64>,
129}
130
131impl BlobCacheHit {
132 pub(crate) fn new(
133 bytes: Arc<[u8]>,
134 content_metadata: Option<Arc<BTreeMap<String, String>>>,
135 version: Option<u64>,
136 expires_at_unix_ms: Option<u64>,
137 ) -> Self {
138 Self {
139 bytes,
140 content_metadata,
141 version,
142 expires_at_unix_ms,
143 stale_window_remaining_ms: None,
144 }
145 }
146
147 pub(crate) fn new_stale(
148 bytes: Arc<[u8]>,
149 content_metadata: Option<Arc<BTreeMap<String, String>>>,
150 version: Option<u64>,
151 expires_at_unix_ms: Option<u64>,
152 window_remaining_ms: u64,
153 ) -> Self {
154 Self {
155 bytes,
156 content_metadata,
157 version,
158 expires_at_unix_ms,
159 stale_window_remaining_ms: Some(window_remaining_ms),
160 }
161 }
162
163 pub fn bytes(&self) -> &Arc<[u8]> {
165 &self.bytes
166 }
167
168 pub fn value(&self) -> &[u8] {
170 &self.bytes
171 }
172
173 pub fn content_metadata(&self) -> &BTreeMap<String, String> {
175 static EMPTY: BTreeMap<String, String> = BTreeMap::new();
176 self.content_metadata.as_deref().unwrap_or(&EMPTY)
177 }
178
179 pub fn version(&self) -> Option<u64> {
181 self.version
182 }
183
184 pub fn expires_at_unix_ms(&self) -> Option<u64> {
186 self.expires_at_unix_ms
187 }
188
189 pub fn remaining_ttl_ms_at(&self, now_ms: u64) -> Option<u64> {
191 self.expires_at_unix_ms
192 .map(|expires_at| expires_at.saturating_sub(now_ms))
193 }
194
195 pub fn is_stale(&self) -> bool {
199 self.stale_window_remaining_ms.is_some()
200 }
201
202 pub fn stale_window_remaining_ms(&self) -> Option<u64> {
205 self.stale_window_remaining_ms
206 }
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Eq)]
210pub struct BlobCachePut {
211 pub bytes: Vec<u8>,
212 pub content_metadata: BTreeMap<String, String>,
213 pub tags: BTreeSet<String>,
214 pub dependencies: BTreeSet<String>,
215 pub policy: BlobCachePolicy,
216}
217
218impl BlobCachePut {
219 pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
220 Self {
221 bytes: bytes.into(),
222 content_metadata: BTreeMap::new(),
223 tags: BTreeSet::new(),
224 dependencies: BTreeSet::new(),
225 policy: BlobCachePolicy::default(),
226 }
227 }
228
229 pub fn with_content_metadata(mut self, content_metadata: BTreeMap<String, String>) -> Self {
230 self.content_metadata = content_metadata;
231 self
232 }
233
234 pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
235 self.tags = tags.into_iter().map(Into::into).collect();
236 self
237 }
238
239 pub fn with_dependencies(
240 mut self,
241 dependencies: impl IntoIterator<Item = impl Into<String>>,
242 ) -> Self {
243 self.dependencies = dependencies.into_iter().map(Into::into).collect();
244 self
245 }
246
247 pub fn with_policy(mut self, policy: BlobCachePolicy) -> Self {
248 self.policy = policy;
249 self
250 }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum L1Admission {
255 Always,
256 Auto,
257 Never,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum CachePresence {
269 Present,
271 Absent,
273 MaybePresent,
276}
277
278impl From<bool> for CachePresence {
279 fn from(present: bool) -> Self {
280 if present {
281 CachePresence::Present
282 } else {
283 CachePresence::Absent
284 }
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct BlobCachePolicy {
290 ttl_ms: Option<u64>,
291 expires_at_unix_ms: Option<u64>,
292 max_blob_bytes: Option<usize>,
293 l1_admission: L1Admission,
294 priority: u8,
295 version: Option<u64>,
296 extended: ExtendedTtlPolicy,
302}
303
304impl Default for BlobCachePolicy {
305 fn default() -> Self {
306 Self {
307 ttl_ms: None,
308 expires_at_unix_ms: None,
309 max_blob_bytes: None,
310 l1_admission: L1Admission::Auto,
311 priority: 128,
312 version: None,
313 extended: ExtendedTtlPolicy::off(),
314 }
315 }
316}
317
318impl BlobCachePolicy {
319 pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
322 self.ttl_ms = Some(ttl_ms);
323 self
324 }
325
326 pub fn expires_at_unix_ms(mut self, expires_at_unix_ms: u64) -> Self {
327 self.expires_at_unix_ms = Some(expires_at_unix_ms);
328 self
329 }
330
331 pub fn max_blob_bytes(mut self, max_blob_bytes: usize) -> Self {
332 self.max_blob_bytes = Some(max_blob_bytes);
333 self
334 }
335
336 pub fn l1_admission(mut self, l1_admission: L1Admission) -> Self {
337 self.l1_admission = l1_admission;
338 self
339 }
340
341 pub fn priority(mut self, priority: u8) -> Self {
342 self.priority = priority;
343 self
344 }
345
346 pub fn version(mut self, version: u64) -> Self {
347 self.version = Some(version);
348 self
349 }
350
351 pub fn extended(mut self, extended: ExtendedTtlPolicy) -> Self {
356 self.extended = extended;
357 self
358 }
359
360 pub fn ttl_ms_value(&self) -> Option<u64> {
367 self.ttl_ms
368 }
369
370 pub fn expires_at_unix_ms_value(&self) -> Option<u64> {
371 self.expires_at_unix_ms
372 }
373
374 pub fn max_blob_bytes_value(&self) -> Option<usize> {
375 self.max_blob_bytes
376 }
377
378 pub fn l1_admission_value(&self) -> L1Admission {
379 self.l1_admission
380 }
381
382 pub fn priority_value(&self) -> u8 {
383 self.priority
384 }
385
386 pub fn version_value(&self) -> Option<u64> {
387 self.version
388 }
389
390 pub fn extended_value(&self) -> ExtendedTtlPolicy {
394 self.extended
395 }
396}
397
398#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
399pub struct BlobCacheStats {
400 pub(super) hits: u64,
401 pub(super) misses: u64,
402 pub(super) insertions: u64,
403 pub(super) evictions: u64,
404 pub(super) expirations: u64,
405 pub(super) invalidations: u64,
406 pub(super) namespace_flushes: u64,
407 pub(super) version_mismatches: u64,
408 pub(super) entries: usize,
409 pub(super) bytes_in_use: usize,
410 pub(super) l1_bytes_max: usize,
411 pub(super) namespace_bytes_max: usize,
412 pub(super) l2_bytes_in_use: u64,
413 pub(super) l2_bytes_max: u64,
414 pub(super) l2_full_rejections: u64,
415 pub(super) l2_metadata_reads: u64,
416 pub(super) l2_negative_skips: u64,
417 pub(super) synopsis_metadata_reads: u64,
421 pub(super) synopsis_bytes: u64,
423 pub(super) namespaces: usize,
424 pub(super) max_namespaces: usize,
425 pub(super) promotion_queued: u64,
428 pub(super) promotion_dropped: u64,
429 pub(super) promotion_completed: u64,
430 pub(super) promotion_queue_depth: usize,
431 pub(super) l2_compression_original_bytes: u64,
435 pub(super) l2_compression_stored_bytes: u64,
438 pub(super) l2_compression_skipped_total: u64,
440 pub(super) l2_bytes_saved_total: u64,
442 pub(super) l1_stale_serves_total: u64,
445 pub(super) l1_idle_evicts_total: u64,
448}
449
450impl BlobCacheStats {
451 pub fn hits(&self) -> u64 {
454 self.hits
455 }
456
457 pub fn misses(&self) -> u64 {
459 self.misses
460 }
461
462 pub fn insertions(&self) -> u64 {
463 self.insertions
464 }
465
466 pub fn evictions(&self) -> u64 {
467 self.evictions
468 }
469
470 pub fn expirations(&self) -> u64 {
471 self.expirations
472 }
473
474 pub fn invalidations(&self) -> u64 {
475 self.invalidations
476 }
477
478 pub fn namespace_flushes(&self) -> u64 {
479 self.namespace_flushes
480 }
481
482 pub fn version_mismatches(&self) -> u64 {
483 self.version_mismatches
484 }
485
486 pub fn entries(&self) -> usize {
487 self.entries
488 }
489
490 pub fn bytes_in_use(&self) -> u64 {
493 self.bytes_in_use as u64
494 }
495
496 pub fn l1_bytes_max(&self) -> usize {
497 self.l1_bytes_max
498 }
499
500 pub fn namespace_bytes_max(&self) -> usize {
501 self.namespace_bytes_max
502 }
503
504 pub fn l2_bytes_in_use(&self) -> u64 {
505 self.l2_bytes_in_use
506 }
507
508 pub fn l2_bytes_max(&self) -> u64 {
509 self.l2_bytes_max
510 }
511
512 pub fn l2_full_rejections(&self) -> u64 {
513 self.l2_full_rejections
514 }
515
516 pub fn l2_metadata_reads(&self) -> u64 {
517 self.l2_metadata_reads
518 }
519
520 pub fn l2_negative_skips(&self) -> u64 {
521 self.l2_negative_skips
522 }
523
524 pub fn synopsis_metadata_reads(&self) -> u64 {
530 self.synopsis_metadata_reads
531 }
532
533 pub fn synopsis_bytes(&self) -> u64 {
535 self.synopsis_bytes
536 }
537
538 pub fn namespaces(&self) -> usize {
539 self.namespaces
540 }
541
542 pub fn max_namespaces(&self) -> usize {
543 self.max_namespaces
544 }
545
546 pub fn promotion_queued(&self) -> u64 {
549 self.promotion_queued
550 }
551
552 pub fn promotion_dropped(&self) -> u64 {
555 self.promotion_dropped
556 }
557
558 pub fn promotion_completed(&self) -> u64 {
561 self.promotion_completed
562 }
563
564 pub fn promotion_queue_depth(&self) -> usize {
567 self.promotion_queue_depth
568 }
569
570 pub fn l2_compression_ratio_observed(&self) -> f64 {
577 if self.l2_compression_stored_bytes == 0 {
578 return 1.0;
579 }
580 self.l2_compression_original_bytes as f64 / self.l2_compression_stored_bytes as f64
581 }
582
583 pub fn l2_compression_skipped_total(&self) -> u64 {
587 self.l2_compression_skipped_total
588 }
589
590 pub fn l2_bytes_saved_total(&self) -> u64 {
594 self.l2_bytes_saved_total
595 }
596
597 pub fn l1_stale_serves_total(&self) -> u64 {
601 self.l1_stale_serves_total
602 }
603
604 pub fn l1_idle_evicts_total(&self) -> u64 {
608 self.l1_idle_evicts_total
609 }
610}
611
612#[derive(Clone, Copy)]
613enum IndexedKind {
614 Tag,
615 Dependency,
616}
617
618#[derive(Debug)]
619struct AtomicStats {
620 hits: AtomicU64,
621 misses: AtomicU64,
622 insertions: AtomicU64,
623 evictions: AtomicU64,
624 expirations: AtomicU64,
625 invalidations: AtomicU64,
626 namespace_flushes: AtomicU64,
627 version_mismatches: AtomicU64,
628 l2_full_rejections: AtomicU64,
629 l1_stale_serves: AtomicU64,
633 l1_idle_evicts: AtomicU64,
637}
638
639impl AtomicStats {
640 fn new() -> Self {
641 Self {
642 hits: AtomicU64::new(0),
643 misses: AtomicU64::new(0),
644 insertions: AtomicU64::new(0),
645 evictions: AtomicU64::new(0),
646 expirations: AtomicU64::new(0),
647 invalidations: AtomicU64::new(0),
648 namespace_flushes: AtomicU64::new(0),
649 version_mismatches: AtomicU64::new(0),
650 l2_full_rejections: AtomicU64::new(0),
651 l1_stale_serves: AtomicU64::new(0),
652 l1_idle_evicts: AtomicU64::new(0),
653 }
654 }
655}
656
657pub struct BlobCache {
676 config: BlobCacheConfig,
677 shards: Vec<RwLock<Shard>>,
678 namespaces: RwLock<HashSet<String>>,
679 namespace_generations: RwLock<HashMap<String, u64>>,
680 tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
681 dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
682 l2: Option<Arc<dyn BlobCacheL2Backend>>,
683 bytes_in_use: AtomicUsize,
684 namespace_bytes_in_use: RwLock<HashMap<String, usize>>,
685 sweep_shard_cursor: AtomicUsize,
686 namespace_flush_epoch: AtomicU64,
687 namespace_reclaimed_epoch: AtomicU64,
688 stats: AtomicStats,
689 promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
694}
695
696const _: fn() = || {
701 fn assert_send_sync<T: Send + Sync>() {}
702 assert_send_sync::<BlobCache>();
703};
704
705impl BlobCache {
706 pub fn new(config: BlobCacheConfig) -> Self {
710 Self::try_new(config).expect("open blob-cache L2")
711 }
712
713 pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
717 Self::try_new(config)
718 }
719
720 pub fn open_with_internal_l2(
723 config: BlobCacheConfig,
724 store: Arc<crate::storage::UnifiedStore>,
725 ) -> Result<Self, CacheError> {
726 let l2 = (config.l2_bytes_max > 0)
727 .then(|| StoreBlobCacheL2::open(store, config.l2_bytes_max))
728 .transpose()?
729 .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
730 Self::with_backend(config, l2)
731 }
732
733 fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
734 let l2 = config
735 .l2_path
736 .clone()
737 .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
738 .transpose()?
739 .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
740 Self::with_backend(config, l2)
741 }
742
743 fn with_backend(
744 config: BlobCacheConfig,
745 l2: Option<Arc<dyn BlobCacheL2Backend>>,
746 ) -> Result<Self, CacheError> {
747 let config = BlobCacheConfig {
748 shard_count: config.shard_count.max(1),
749 ..config
750 };
751 let shards = (0..config.shard_count)
752 .map(|_| RwLock::new(Shard::new()))
753 .collect();
754 Ok(Self {
755 config,
756 shards,
757 namespaces: RwLock::new(HashSet::new()),
758 namespace_generations: RwLock::new(HashMap::new()),
759 tag_index: RwLock::new(HashMap::new()),
760 dependency_index: RwLock::new(HashMap::new()),
761 l2,
762 bytes_in_use: AtomicUsize::new(0),
763 namespace_bytes_in_use: RwLock::new(HashMap::new()),
764 sweep_shard_cursor: AtomicUsize::new(0),
765 namespace_flush_epoch: AtomicU64::new(0),
766 namespace_reclaimed_epoch: AtomicU64::new(0),
767 stats: AtomicStats::new(),
768 promotion_pool: OnceLock::new(),
769 })
770 }
771
772 pub fn with_defaults() -> Self {
773 Self::new(BlobCacheConfig::default())
774 }
775
776 pub fn l2_path(&self) -> Option<&std::path::Path> {
782 self.config.l2_path.as_deref()
783 }
784
785 pub fn put(
786 &self,
787 namespace: impl Into<String>,
788 key: impl Into<String>,
789 input: BlobCachePut,
790 ) -> Result<(), CacheError> {
791 self.put_at(namespace, key, input, unix_now_ms())
792 }
793
794 fn put_at(
795 &self,
796 namespace: impl Into<String>,
797 key: impl Into<String>,
798 input: BlobCachePut,
799 now_ms: u64,
800 ) -> Result<(), CacheError> {
801 let namespace = namespace.into();
802 let key = BlobCacheKey::new(namespace.clone(), key);
803 self.validate_blob_size(input.bytes.len(), input.policy)?;
804 self.validate_metadata(&input.content_metadata)?;
805 self.ensure_namespace(&namespace)?;
806 let namespace_generation = self.current_generation(&namespace);
807 let tags = input.tags.clone();
808 let dependencies = input.dependencies.clone();
809
810 let shard_idx = self.shard_index(&key);
811 if self.l2.is_some() {
812 self.check_version(
813 &self.shards[shard_idx].read(),
814 &key,
815 input.policy.version_value(),
816 namespace_generation,
817 )?;
818 }
819 let entry = Entry::new(
820 input.bytes,
821 input.content_metadata,
822 input.tags,
823 input.dependencies,
824 input.policy,
825 namespace_generation,
826 now_ms,
827 &namespace,
828 &key.key,
829 );
830 let entry_charge = entry.l1_charge_bytes;
831 let compressed = if self.l2.is_some() {
832 Some(match self.config.l2_compression {
839 L2Compression::Off => Compressed::Raw(entry.bytes.as_ref().to_vec()),
840 L2Compression::On => {
841 let content_type = entry
842 .content_metadata
843 .as_ref()
844 .and_then(|metadata| metadata.get("content-type"))
845 .map(String::as_str);
846 L2BlobCompressor::compress(
847 entry.bytes.as_ref(),
848 content_type,
849 &CompressOpts::default(),
850 )
851 .map_err(|err| CacheError::L2Io(err.to_string()))?
852 }
853 })
854 } else {
855 None
856 };
857 #[cfg(test)]
858 if let Some(hook) = PUT_PREPARED_HOOK.with(|slot| slot.borrow_mut().take()) {
859 hook();
860 }
861 let mut shard = self.shards[shard_idx].write();
862 self.check_version(
865 &shard,
866 &key,
867 input.policy.version_value(),
868 namespace_generation,
869 )?;
870 shard.clear_l2_hit_marker(&key);
871 if let Some(l2) = &self.l2 {
872 let old_l2_size = l2.record_size(&key);
873 let compressed = compressed.expect("L2 payload prepared before publication");
874 match l2.put(&key, &entry, old_l2_size, compressed) {
875 Ok(()) => {}
876 Err(err @ CacheError::L2Full { .. }) => {
877 self.stats
878 .l2_full_rejections
879 .fetch_add(1, Ordering::Relaxed);
880 return Err(err);
881 }
882 Err(err) => return Err(err),
883 }
884 }
885 let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
886 let old_entry = shard.remove(&key);
887 InsertOutcome {
888 old_entry,
889 admitted: false,
890 }
891 } else {
892 shard.insert(key.clone(), entry)
893 };
894 let old_charge = outcome
895 .old_entry
896 .as_ref()
897 .map_or(0, |entry| entry.l1_charge_bytes);
898 let new_charge = if outcome.admitted { entry_charge } else { 0 };
899 self.adjust_namespace_bytes(&namespace, old_charge, new_charge);
900 if new_charge >= old_charge {
901 self.bytes_in_use
902 .fetch_add(new_charge - old_charge, Ordering::Relaxed);
903 } else {
904 self.bytes_in_use
905 .fetch_sub(old_charge - new_charge, Ordering::Relaxed);
906 }
907 if let Some(old_entry) = outcome.old_entry.as_ref() {
908 self.deindex_entry(&key, old_entry);
909 }
910 if outcome.admitted {
911 self.index_entry(&key, &tags, &dependencies);
912 }
913 drop(shard);
914
915 self.stats.insertions.fetch_add(1, Ordering::Relaxed);
916 if outcome.admitted {
917 self.evict_namespace_until_within_budget(&namespace, shard_idx);
918 self.evict_until_within_budget(shard_idx);
919 }
920 Ok(())
921 }
922
923 pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
924 self.get_at(namespace, key, unix_now_ms())
925 }
926
927 fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
928 let namespace_generation = self.current_generation(namespace);
929 let shard_idx = self.shard_index_parts(namespace, key);
930 if let Some(hit) =
931 self.shards[shard_idx]
932 .read()
933 .shared_hit(namespace, key, now_ms, namespace_generation)
934 {
935 self.stats.hits.fetch_add(1, Ordering::Relaxed);
936 return Some(hit);
937 }
938 let mut shard = self.shards[shard_idx].write();
939 match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
940 Lookup::Hit(hit) => {
941 self.stats.hits.fetch_add(1, Ordering::Relaxed);
942 if hit.is_stale() {
943 self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
944 }
945 Some(hit)
946 }
947 Lookup::Expired(entry) => {
948 let cache_key = BlobCacheKey::new(namespace, key);
949 self.record_removed_entry(&cache_key, &entry);
950 if let Some(l2) = &self.l2 {
951 l2.delete_key(&cache_key);
952 }
953 drop(shard);
954 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
955 self.stats.misses.fetch_add(1, Ordering::Relaxed);
956 None
957 }
958 Lookup::IdleEvicted(entry) => {
959 let cache_key = BlobCacheKey::new(namespace, key);
960 self.record_removed_entry(&cache_key, &entry);
961 if let Some(l2) = &self.l2 {
962 l2.delete_key(&cache_key);
963 }
964 drop(shard);
965 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
966 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
967 self.stats.misses.fetch_add(1, Ordering::Relaxed);
968 None
969 }
970 Lookup::Stale(entry) => {
971 let cache_key = BlobCacheKey::new(namespace, key);
972 self.record_removed_entry(&cache_key, &entry);
973 drop(shard);
974 self.stats.misses.fetch_add(1, Ordering::Relaxed);
975 None
976 }
977 Lookup::Miss => {
978 drop(shard);
979 let cache_key = BlobCacheKey::new(namespace, key);
980 if let Some(pool) = self.promotion_pool.get() {
981 if let Some(l2) = self.l2.as_ref() {
985 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
986 let hit = entry.hit();
987 if self.should_promote_l2_hit(&cache_key, shard_idx) {
988 drop(entry);
993 let request = PromotionRequest {
994 namespace: cache_key.namespace.clone(),
995 key: cache_key.key.clone(),
996 bytes: Arc::clone(hit.bytes()),
997 policy: BlobCachePolicy::default(),
998 };
999 let _ = pool.schedule(request);
1000 }
1001 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1002 return Some(hit);
1003 }
1004 }
1005 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1006 return None;
1007 }
1008 if let Some(hit) =
1009 self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
1010 {
1011 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1012 return Some(hit);
1013 }
1014 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1015 None
1016 }
1017 Lookup::Present => unreachable!("get cannot return presence-only lookup"),
1018 }
1019 }
1020
1021 pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
1039 self.exists_at(namespace, key, unix_now_ms())
1040 }
1041
1042 pub fn expire(&self, namespace: &str, key: &str, ttl_ms: u64) -> Result<bool, CacheError> {
1044 let now_ms = unix_now_ms();
1045 self.update_expiry(namespace, key, Some(now_ms.saturating_add(ttl_ms)), now_ms)
1046 }
1047
1048 pub fn expire_at(
1050 &self,
1051 namespace: &str,
1052 key: &str,
1053 expires_at_unix_ms: u64,
1054 ) -> Result<bool, CacheError> {
1055 self.update_expiry(namespace, key, Some(expires_at_unix_ms), unix_now_ms())
1056 }
1057
1058 pub fn persist(&self, namespace: &str, key: &str) -> Result<bool, CacheError> {
1060 self.update_expiry(namespace, key, None, unix_now_ms())
1061 }
1062
1063 fn update_expiry(
1064 &self,
1065 namespace: &str,
1066 key: &str,
1067 expires_at_unix_ms: Option<u64>,
1068 now_ms: u64,
1069 ) -> Result<bool, CacheError> {
1070 if !self.namespace_exists(namespace) {
1071 return Ok(false);
1072 }
1073 let cache_key = BlobCacheKey::new(namespace, key);
1074 let namespace_generation = self.current_generation(namespace);
1075 let shard_index = self.shard_index(&cache_key);
1076 let mut shard = self.shards[shard_index].write();
1077 let l1_live = shard.can_update_expiry(&cache_key, now_ms, namespace_generation);
1078 let l2_updated = match &self.l2 {
1079 Some(l2) => {
1080 l2.update_expiry(&cache_key, namespace_generation, now_ms, expires_at_unix_ms)?
1081 }
1082 None => false,
1083 };
1084 if l1_live {
1085 shard.update_expiry(&cache_key, expires_at_unix_ms, now_ms);
1086 }
1087 Ok(l1_live || l2_updated)
1088 }
1089
1090 fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
1091 let namespace_generation = self.current_generation(namespace);
1092 let shard_idx = self.shard_index_parts(namespace, key);
1093 let mut shard = self.shards[shard_idx].write();
1094 match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
1095 Lookup::Present => {
1096 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1097 CachePresence::Present
1098 }
1099 Lookup::Expired(entry) => {
1100 let cache_key = BlobCacheKey::new(namespace, key);
1101 self.record_removed_entry(&cache_key, &entry);
1102 if let Some(l2) = &self.l2 {
1103 l2.delete_key(&cache_key);
1104 }
1105 drop(shard);
1106 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1107 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1108 CachePresence::Absent
1109 }
1110 Lookup::IdleEvicted(entry) => {
1111 let cache_key = BlobCacheKey::new(namespace, key);
1112 self.record_removed_entry(&cache_key, &entry);
1113 if let Some(l2) = &self.l2 {
1114 l2.delete_key(&cache_key);
1115 }
1116 drop(shard);
1117 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1118 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
1119 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1120 CachePresence::Absent
1121 }
1122 Lookup::Stale(entry) => {
1123 let cache_key = BlobCacheKey::new(namespace, key);
1124 self.record_removed_entry(&cache_key, &entry);
1125 drop(shard);
1126 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1127 CachePresence::Absent
1128 }
1129 Lookup::Miss => {
1130 drop(shard);
1131 let Some(l2) = self.l2.as_ref() else {
1132 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1133 return CachePresence::Absent;
1134 };
1135 if l2.synopsis_may_contain(namespace, key) {
1136 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1139 CachePresence::MaybePresent
1140 } else {
1141 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1144 CachePresence::Absent
1145 }
1146 }
1147 Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1148 }
1149 }
1150
1151 pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1157 if !self.namespace_exists(namespace) {
1158 return 0;
1159 }
1160 let cache_key = BlobCacheKey::new(namespace, key);
1161 let shard_idx = self.shard_index(&cache_key);
1162 let mut shard = self.shards[shard_idx].write();
1163 let removed = shard.remove(&cache_key);
1164
1165 if let Some(entry) = removed {
1166 self.record_invalidated_entry(&cache_key, &entry);
1167 drop(shard);
1168 1
1169 } else {
1170 drop(shard);
1171 self.l2
1172 .as_ref()
1173 .and_then(|l2| l2.delete_key(&cache_key))
1174 .map(|_| {
1175 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1176 1
1177 })
1178 .unwrap_or(0)
1179 }
1180 }
1181
1182 pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1184 if !self.namespace_exists(namespace) {
1185 return 0;
1186 }
1187
1188 let mut count = 0usize;
1189 for shard in &self.shards {
1190 let mut shard = shard.write();
1191 let keys = shard
1192 .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1193 for key in keys {
1194 if let Some(entry) = shard.remove(&key) {
1195 self.record_invalidated_entry(&key, &entry);
1196 count += 1;
1197 }
1198 }
1199 }
1200
1201 let l2_count = self
1202 .l2
1203 .as_ref()
1204 .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1205 if l2_count > count {
1206 self.stats
1207 .invalidations
1208 .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1209 }
1210 count.max(l2_count)
1211 }
1212
1213 pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1219 self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1220 }
1221
1222 pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1224 self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1225 }
1226
1227 #[deprecated(
1229 since = "0.1.0",
1230 note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1231 )]
1232 pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1233 self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1234 }
1235
1236 #[deprecated(
1238 since = "0.1.0",
1239 note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1240 )]
1241 pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1242 self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1243 }
1244
1245 pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1251 if !self.namespace_exists(namespace) {
1252 return false;
1253 }
1254 let mut generations = self.namespace_generations.write();
1255 let generation = generations.entry(namespace.to_string()).or_insert(0);
1256 *generation = generation.saturating_add(1);
1257 self.namespace_flush_epoch.fetch_add(1, Ordering::Release);
1258 if let Some(l2) = &self.l2 {
1259 l2.delete_namespace(namespace);
1260 }
1261 self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1262 true
1263 }
1264
1265 pub fn ram_bytes_in_use(&self) -> u64 {
1270 let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1271 let l2 = self
1272 .l2
1273 .as_ref()
1274 .map_or(0, |l2| l2.resident_metadata_bytes());
1275 l1.saturating_add(l2)
1276 }
1277
1278 pub fn stats(&self) -> BlobCacheStats {
1279 BlobCacheStats {
1280 hits: self.stats.hits.load(Ordering::Relaxed),
1281 misses: self.stats.misses.load(Ordering::Relaxed),
1282 insertions: self.stats.insertions.load(Ordering::Relaxed),
1283 evictions: self.stats.evictions.load(Ordering::Relaxed),
1284 expirations: self.stats.expirations.load(Ordering::Relaxed),
1285 invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1286 namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1287 version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1288 entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1289 bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1290 l1_bytes_max: self.config.l1_bytes_max,
1291 namespace_bytes_max: self.config.namespace_bytes_max,
1292 l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1293 l2_bytes_max: self.config.l2_bytes_max,
1294 l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1295 l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1296 l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1297 synopsis_metadata_reads: self
1298 .l2
1299 .as_ref()
1300 .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1301 synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1302 namespaces: self.namespaces.read().len(),
1303 max_namespaces: self.config.max_namespaces,
1304 promotion_queued: self
1305 .promotion_pool
1306 .get()
1307 .map_or(0, |p| p.metrics().queued_total),
1308 promotion_dropped: self
1309 .promotion_pool
1310 .get()
1311 .map_or(0, |p| p.metrics().dropped_total),
1312 promotion_completed: self
1313 .promotion_pool
1314 .get()
1315 .map_or(0, |p| p.metrics().completed_total),
1316 promotion_queue_depth: self
1317 .promotion_pool
1318 .get()
1319 .map_or(0, |p| p.metrics().queue_depth),
1320 l2_compression_original_bytes: self
1321 .l2
1322 .as_ref()
1323 .map_or(0, |l2| l2.stats_compression_original_bytes()),
1324 l2_compression_stored_bytes: self
1325 .l2
1326 .as_ref()
1327 .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1328 l2_compression_skipped_total: self
1329 .l2
1330 .as_ref()
1331 .map_or(0, |l2| l2.stats_compression_skipped_total()),
1332 l2_bytes_saved_total: self
1333 .l2
1334 .as_ref()
1335 .map_or(0, |l2| l2.stats_bytes_saved_total()),
1336 l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1337 l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1338 }
1339 }
1340
1341 pub(crate) fn sweep_expired_l1(
1348 &self,
1349 entries_limit: Option<usize>,
1350 millis_limit: Option<u32>,
1351 ) -> BlobCacheSweepOutcome {
1352 let started = Instant::now();
1353 let time_limit = millis_limit.map(|millis| Duration::from_millis(u64::from(millis)));
1354 let now_ms = unix_now_ms();
1355 let hard_entries_limit = entries_limit.unwrap_or(usize::MAX);
1356 let shard_count = self.shards.len();
1357 let first_shard = self.sweep_shard_cursor.fetch_add(1, Ordering::Relaxed) % shard_count;
1358 let flush_epoch = self.namespace_flush_epoch.load(Ordering::Acquire);
1359 let stale_cleanup_pending =
1360 self.namespace_reclaimed_epoch.load(Ordering::Acquire) < flush_epoch;
1361 let mut outcome = BlobCacheSweepOutcome::default();
1362 let mut completed_all_shards = true;
1363
1364 for offset in 0..shard_count {
1365 if outcome.entries_scanned >= hard_entries_limit
1366 || time_limit.is_some_and(|limit| started.elapsed() >= limit)
1367 {
1368 completed_all_shards = false;
1369 break;
1370 }
1371 let shard_index = (first_shard + offset) % shard_count;
1372 let mut shard = self.shards[shard_index].write();
1373 let sweep = shard.sweep_expired(
1374 now_ms,
1375 hard_entries_limit - outcome.entries_scanned,
1376 started,
1377 time_limit,
1378 );
1379 completed_all_shards &= sweep.completed_cycle;
1384 self.record_sweep(sweep, &mut outcome);
1385
1386 if stale_cleanup_pending
1387 && outcome.entries_scanned < hard_entries_limit
1388 && !time_limit.is_some_and(|limit| started.elapsed() >= limit)
1389 {
1390 let namespace_generations = self.namespace_generations.read();
1393 let stale_sweep = shard.sweep_stale_generations(
1394 &namespace_generations,
1395 flush_epoch,
1396 hard_entries_limit - outcome.entries_scanned,
1397 started,
1398 time_limit,
1399 );
1400 completed_all_shards &= stale_sweep.completed_cycle;
1401 self.record_sweep(stale_sweep, &mut outcome);
1402 }
1403 drop(shard);
1404 }
1405
1406 if stale_cleanup_pending
1407 && self
1408 .shards
1409 .iter()
1410 .all(|shard| shard.read().stale_cleanup_completed(flush_epoch))
1411 {
1412 self.namespace_reclaimed_epoch
1413 .fetch_max(flush_epoch, Ordering::Release);
1414 }
1415
1416 if outcome.expirations > 0 {
1417 let expirations = u64::try_from(outcome.expirations).unwrap_or(u64::MAX);
1418 self.stats
1419 .expirations
1420 .fetch_add(expirations, Ordering::Relaxed);
1421 }
1422 outcome.completed = completed_all_shards;
1423 outcome.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
1424 outcome
1425 }
1426
1427 pub(crate) fn sweep_expired_l2(
1431 &self,
1432 entries_limit: usize,
1433 millis_limit: Option<u32>,
1434 ) -> BlobCacheSweepOutcome {
1435 let Some(l2) = &self.l2 else {
1436 return BlobCacheSweepOutcome {
1437 completed: true,
1438 ..BlobCacheSweepOutcome::default()
1439 };
1440 };
1441 let outcome = l2.sweep_expired(unix_now_ms(), entries_limit, millis_limit);
1442 if outcome.expirations > 0 {
1443 self.stats.expirations.fetch_add(
1444 u64::try_from(outcome.expirations).unwrap_or(u64::MAX),
1445 Ordering::Relaxed,
1446 );
1447 }
1448 outcome
1449 }
1450
1451 fn record_sweep(&self, sweep: ShardSweep, outcome: &mut BlobCacheSweepOutcome) {
1452 for (key, entry, _) in &sweep.removed {
1453 self.record_removed_entry(key, entry);
1454 if let Some(l2) = &self.l2 {
1455 l2.delete_key(key);
1456 }
1457 }
1458 outcome.entries_scanned += sweep.entries_scanned;
1459 for (namespace, scanned) in sweep.scanned_by_namespace {
1460 outcome
1461 .by_namespace
1462 .entry(namespace)
1463 .or_default()
1464 .entries_scanned += scanned;
1465 }
1466 for (key, entry, expired) in sweep.removed {
1467 let entry_charge_bytes = u64::try_from(entry.l1_charge_bytes).unwrap_or(u64::MAX);
1468 let namespace_stats = outcome
1469 .by_namespace
1470 .entry(key.namespace.clone())
1471 .or_default();
1472 namespace_stats.entries_evicted += 1;
1473 namespace_stats.bytes_reclaimed = namespace_stats
1474 .bytes_reclaimed
1475 .saturating_add(entry_charge_bytes);
1476 outcome.entries_evicted += 1;
1477 outcome.expirations += usize::from(expired);
1478 outcome.bytes_reclaimed = outcome.bytes_reclaimed.saturating_add(entry_charge_bytes);
1479 }
1480 }
1481
1482 pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1493 let weak: Weak<Self> = Arc::downgrade(self);
1494 let executor: PromotionExecutor = Arc::new(move |req| {
1495 let Some(cache) = weak.upgrade() else {
1499 return Ok(());
1500 };
1501 cache.promote_from_l2(&req)
1502 });
1503 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1504 match self.promotion_pool.set(Arc::clone(&pool)) {
1505 Ok(()) => pool,
1506 Err(losing_pool) => {
1510 losing_pool.shutdown();
1511 Arc::clone(
1512 self.promotion_pool
1513 .get()
1514 .expect("OnceLock set+get inconsistency"),
1515 )
1516 }
1517 }
1518 }
1519
1520 pub fn shutdown_async_promotion(&self) {
1524 if let Some(pool) = self.promotion_pool.get() {
1525 Arc::clone(pool).shutdown();
1526 }
1527 }
1528
1529 #[cfg(test)]
1532 fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1533 self.promotion_pool.get().cloned()
1534 }
1535
1536 #[cfg(test)]
1540 fn enable_async_promotion_with_executor(
1541 self: &Arc<Self>,
1542 opts: PoolOpts,
1543 executor: PromotionExecutor,
1544 ) -> Arc<AsyncPromotionPool> {
1545 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1546 let _ = self.promotion_pool.set(Arc::clone(&pool));
1547 pool
1548 }
1549
1550 pub fn config(&self) -> &BlobCacheConfig {
1551 &self.config
1552 }
1553
1554 #[cfg(test)]
1555 fn inject_l2_fault_after_blob_write_once(&self) {
1556 self.l2
1557 .as_ref()
1558 .expect("L2 enabled")
1559 .inject_fault_after_blob_write_once();
1560 }
1561
1562 #[cfg(test)]
1563 fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1564 self.l2
1565 .as_ref()
1566 .expect("L2 enabled")
1567 .inject_synopsis_maybe_present(namespace, key);
1568 }
1569
1570 #[cfg(test)]
1574 fn inject_l2_v1_entry(
1575 &self,
1576 namespace: &str,
1577 key: &str,
1578 payload: &[u8],
1579 ) -> Result<(), CacheError> {
1580 let l2 = self.l2.as_ref().expect("L2 enabled");
1581 let cache_key = BlobCacheKey::new(namespace, key);
1582 l2.inject_v1_entry(&cache_key, payload)
1583 }
1584
1585 fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1586 let max = policy
1587 .max_blob_bytes_value()
1588 .unwrap_or(self.config.l1_bytes_max);
1589 if size > max {
1590 Err(CacheError::BlobTooLarge { size, max })
1591 } else {
1592 Ok(())
1593 }
1594 }
1595
1596 fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1597 let keys = metadata.len();
1598 let bytes = metadata
1599 .iter()
1600 .map(|(key, value)| key.len() + value.len())
1601 .sum::<usize>();
1602 if keys > self.config.content_metadata_keys_max
1603 || bytes > self.config.content_metadata_bytes_max
1604 {
1605 Err(CacheError::MetadataTooLarge {
1606 keys,
1607 bytes,
1608 max_keys: self.config.content_metadata_keys_max,
1609 max_bytes: self.config.content_metadata_bytes_max,
1610 })
1611 } else {
1612 Ok(())
1613 }
1614 }
1615
1616 fn rehydrate_l2_entry(
1617 &self,
1618 key: &BlobCacheKey,
1619 now_ms: u64,
1620 namespace_generation: u64,
1621 shard_idx: usize,
1622 ) -> Option<BlobCacheHit> {
1623 let l2 = self.l2.as_ref()?;
1624 let entry = l2.get(key, now_ms, namespace_generation)?;
1625 let hit = entry.hit();
1626 if self.should_promote_l2_hit(key, shard_idx) {
1627 self.do_l1_promotion_sync(key, entry, shard_idx);
1628 }
1629 Some(hit)
1630 }
1631
1632 fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1633 match self.config.l2_promotion_policy {
1634 L2PromotionPolicy::Immediate => true,
1635 L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1636 .write()
1637 .l2_hit_should_promote_on_second_hit(key),
1638 }
1639 }
1640
1641 fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1649 let entry_charge = entry.l1_charge_bytes;
1650 let mut shard = self.shards[shard_idx].write();
1651 let outcome = shard.insert(key.clone(), entry);
1652 let old_charge = outcome
1653 .old_entry
1654 .as_ref()
1655 .map_or(0, |entry| entry.l1_charge_bytes);
1656 self.adjust_namespace_bytes(&key.namespace, old_charge, entry_charge);
1657 if entry_charge >= old_charge {
1658 self.bytes_in_use
1659 .fetch_add(entry_charge - old_charge, Ordering::Relaxed);
1660 } else {
1661 self.bytes_in_use
1662 .fetch_sub(old_charge - entry_charge, Ordering::Relaxed);
1663 }
1664 drop(shard);
1665 self.evict_namespace_until_within_budget(&key.namespace, shard_idx);
1666 self.evict_until_within_budget(shard_idx);
1667 }
1668
1669 fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1675 let l2 = self
1676 .l2
1677 .as_ref()
1678 .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1679 let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1680 let now_ms = unix_now_ms();
1681 let namespace_generation = self.current_generation(req.namespace.as_str());
1682 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1683 let shard_idx = self.shard_index(&cache_key);
1684 self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1685 }
1686 Ok(())
1687 }
1688
1689 fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1690 {
1691 let namespaces = self.namespaces.read();
1692 if namespaces.contains(namespace) {
1693 return Ok(());
1694 }
1695 }
1696 let mut namespaces = self.namespaces.write();
1697 if namespaces.contains(namespace) {
1698 return Ok(());
1699 }
1700 if namespaces.len() >= self.config.max_namespaces {
1701 return Err(CacheError::TooManyNamespaces {
1702 max: self.config.max_namespaces,
1703 });
1704 }
1705 namespaces.insert(namespace.to_string());
1706 self.namespace_generations
1707 .write()
1708 .entry(namespace.to_string())
1709 .or_insert(0);
1710 self.namespace_bytes_in_use
1711 .write()
1712 .entry(namespace.to_string())
1713 .or_insert(0);
1714 Ok(())
1715 }
1716
1717 fn namespace_exists(&self, namespace: &str) -> bool {
1718 self.namespaces.read().contains(namespace)
1719 || self
1720 .l2
1721 .as_ref()
1722 .is_some_and(|l2| l2.has_namespace(namespace))
1723 }
1724
1725 pub(crate) fn current_generation(&self, namespace: &str) -> u64 {
1726 self.namespace_generations
1727 .read()
1728 .get(namespace)
1729 .copied()
1730 .unwrap_or(0)
1731 }
1732
1733 fn index_entry(
1734 &self,
1735 key: &BlobCacheKey,
1736 tags: &BTreeSet<String>,
1737 dependencies: &BTreeSet<String>,
1738 ) {
1739 if !tags.is_empty() {
1740 let mut index = self.tag_index.write();
1741 for tag in tags {
1742 index
1743 .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1744 .or_default()
1745 .insert(key.clone());
1746 }
1747 }
1748 if !dependencies.is_empty() {
1749 let mut index = self.dependency_index.write();
1750 for dependency in dependencies {
1751 index
1752 .entry(ScopedLabel::new(
1753 key.namespace.as_str(),
1754 dependency.as_str(),
1755 ))
1756 .or_default()
1757 .insert(key.clone());
1758 }
1759 }
1760 }
1761
1762 fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1763 Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1764 Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1765 }
1766
1767 fn remove_indexed_labels(
1768 index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1769 key: &BlobCacheKey,
1770 labels: &BTreeSet<String>,
1771 ) {
1772 if labels.is_empty() {
1773 return;
1774 }
1775 let mut index = index.write();
1776 for label in labels {
1777 let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1778 let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1779 keys.remove(key);
1780 keys.is_empty()
1781 } else {
1782 false
1783 };
1784 if should_remove {
1785 index.remove(&scoped);
1786 }
1787 }
1788 }
1789
1790 fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1791 self.bytes_in_use
1792 .fetch_sub(entry.l1_charge_bytes, Ordering::Relaxed);
1793 self.adjust_namespace_bytes(&key.namespace, entry.l1_charge_bytes, 0);
1794 self.deindex_entry(key, entry);
1795 }
1796
1797 fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1798 self.record_removed_entry(key, entry);
1799 if let Some(l2) = &self.l2 {
1800 l2.delete_key(key);
1801 }
1802 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1803 }
1804
1805 fn invalidate_indexed_many(
1806 &self,
1807 namespace: &str,
1808 labels: &[&str],
1809 kind: IndexedKind,
1810 ) -> usize {
1811 if labels.is_empty() || !self.namespace_exists(namespace) {
1812 return 0;
1813 }
1814
1815 let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1820 {
1821 let index = match kind {
1822 IndexedKind::Tag => self.tag_index.read(),
1823 IndexedKind::Dependency => self.dependency_index.read(),
1824 };
1825 for label in labels {
1826 let scoped = ScopedLabel::new(namespace, *label);
1827 if let Some(keys) = index.get(&scoped) {
1828 for key in keys {
1829 candidates
1830 .entry(key.clone())
1831 .or_default()
1832 .insert((*label).to_string());
1833 }
1834 }
1835 }
1836 }
1837
1838 let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1841 for (key, matched_labels) in candidates {
1842 let shard_idx = self.shard_index(&key);
1843 by_shard
1844 .entry(shard_idx)
1845 .or_default()
1846 .push((key, matched_labels));
1847 }
1848
1849 let mut count = 0usize;
1850 for (shard_idx, keys) in by_shard {
1851 let mut shard = self.shards[shard_idx].write();
1852 for (key, matched_labels) in keys {
1853 let still_matches = match kind {
1854 IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1855 IndexedKind::Dependency => {
1856 shard.entry_has_any_dependency(&key, &matched_labels)
1857 }
1858 };
1859 if still_matches {
1860 if let Some(entry) = shard.remove(&key) {
1861 self.record_invalidated_entry(&key, &entry);
1862 count += 1;
1863 }
1864 }
1865 }
1866 }
1867 let l2_count = self
1872 .l2
1873 .as_ref()
1874 .map_or(0, |l2| l2.delete_namespace(namespace));
1875 if l2_count > 0 {
1876 self.stats
1877 .invalidations
1878 .fetch_add(l2_count as u64, Ordering::Relaxed);
1879 }
1880 count.saturating_add(l2_count)
1881 }
1882
1883 fn shard_index(&self, key: &BlobCacheKey) -> usize {
1884 self.shard_index_parts(&key.namespace, &key.key)
1885 }
1886
1887 fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1888 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1889 namespace.hash(&mut hasher);
1890 key.hash(&mut hasher);
1891 (hasher.finish() as usize) % self.shards.len()
1892 }
1893
1894 fn check_version(
1895 &self,
1896 shard: &Shard,
1897 key: &BlobCacheKey,
1898 attempted: Option<u64>,
1899 namespace_generation: u64,
1900 ) -> Result<(), CacheError> {
1901 let Some(attempted) = attempted else {
1902 return Ok(());
1903 };
1904 let Some(existing) = shard.existing_version(key, namespace_generation) else {
1905 return Ok(());
1906 };
1907 if existing >= attempted {
1908 self.stats
1909 .version_mismatches
1910 .fetch_add(1, Ordering::Relaxed);
1911 Err(CacheError::VersionMismatch {
1912 existing,
1913 attempted,
1914 })
1915 } else {
1916 Ok(())
1917 }
1918 }
1919
1920 fn evict_until_within_budget(&self, preferred_start: usize) {
1921 while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
1922 let mut evicted = false;
1923 for offset in 0..self.shards.len() {
1924 let idx = (preferred_start + offset) % self.shards.len();
1925 let mut shard = self.shards[idx].write();
1926 if let Some((key, entry)) = shard.evict_one() {
1927 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1928 self.record_removed_entry(&key, &entry);
1929 drop(shard);
1930 evicted = true;
1931 break;
1932 }
1933 }
1934 if !evicted {
1935 break;
1936 }
1937 }
1938 }
1939
1940 fn evict_namespace_until_within_budget(&self, namespace: &str, preferred_start: usize) {
1941 while self.namespace_bytes(namespace) > self.config.namespace_bytes_max {
1942 let mut evicted = false;
1943 for offset in 0..self.shards.len() {
1944 let idx = (preferred_start + offset) % self.shards.len();
1945 let mut shard = self.shards[idx].write();
1946 if let Some((key, entry)) = shard.remove_one_from_namespace(namespace) {
1947 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1948 self.record_removed_entry(&key, &entry);
1949 drop(shard);
1950 evicted = true;
1951 break;
1952 }
1953 }
1954 if !evicted {
1955 break;
1956 }
1957 }
1958 }
1959
1960 fn namespace_bytes(&self, namespace: &str) -> usize {
1961 self.namespace_bytes_in_use
1962 .read()
1963 .get(namespace)
1964 .copied()
1965 .unwrap_or(0)
1966 }
1967
1968 fn adjust_namespace_bytes(&self, namespace: &str, old_charge: usize, new_charge: usize) {
1969 let mut usage = self.namespace_bytes_in_use.write();
1970 let bytes = usage.entry(namespace.to_string()).or_insert(0);
1971 *bytes = bytes.saturating_sub(old_charge).saturating_add(new_charge);
1972 }
1973}
1974
1975#[derive(Debug, Default)]
1976pub(crate) struct BlobCacheSweepOutcome {
1977 pub(crate) entries_scanned: usize,
1978 pub(crate) entries_evicted: usize,
1979 pub(crate) expirations: usize,
1980 pub(crate) bytes_reclaimed: u64,
1981 pub(crate) elapsed_ms: u32,
1982 pub(crate) completed: bool,
1983 pub(crate) by_namespace: BTreeMap<String, BlobCacheNamespaceSweep>,
1984}
1985
1986#[derive(Debug, Default)]
1987pub(crate) struct BlobCacheNamespaceSweep {
1988 pub(crate) entries_scanned: usize,
1989 pub(crate) entries_evicted: usize,
1990 pub(crate) bytes_reclaimed: u64,
1991}
1992
1993fn unix_now_ms() -> u64 {
1994 SystemTime::now()
1995 .duration_since(UNIX_EPOCH)
1996 .map(|duration| duration.as_millis() as u64)
1997 .unwrap_or(0)
1998}
1999
2000impl Default for BlobCache {
2001 fn default() -> Self {
2002 Self::with_defaults()
2003 }
2004}
2005
2006#[cfg(test)]
2007mod tests;