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 internal_store: Option<Arc<crate::storage::UnifiedStore>>,
678 shards: Vec<RwLock<Shard>>,
679 namespaces: RwLock<HashSet<String>>,
680 namespace_generations: RwLock<HashMap<String, u64>>,
681 pending_namespace_fences: RwLock<HashSet<String>>,
682 tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
683 dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
684 l2: Option<Arc<dyn BlobCacheL2Backend>>,
685 bytes_in_use: AtomicUsize,
686 namespace_bytes_in_use: RwLock<HashMap<String, usize>>,
687 sweep_shard_cursor: AtomicUsize,
688 namespace_flush_epoch: AtomicU64,
689 namespace_reclaimed_epoch: AtomicU64,
690 stats: AtomicStats,
691 promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
696}
697
698const _: fn() = || {
703 fn assert_send_sync<T: Send + Sync>() {}
704 assert_send_sync::<BlobCache>();
705};
706
707impl BlobCache {
708 pub fn new(config: BlobCacheConfig) -> Self {
712 Self::try_new(config).expect("open blob-cache L2")
713 }
714
715 pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
719 Self::try_new(config)
720 }
721
722 pub fn open_with_internal_l2(
725 config: BlobCacheConfig,
726 store: Arc<crate::storage::UnifiedStore>,
727 ) -> Result<Self, CacheError> {
728 let l2 = (config.l2_bytes_max > 0)
729 .then(|| StoreBlobCacheL2::open(Arc::clone(&store), config.l2_bytes_max))
730 .transpose()?
731 .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
732 let internal_store = l2.as_ref().map(|_| store);
733 Self::with_backend(config, l2, internal_store)
734 }
735
736 fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
737 let l2 = config
738 .l2_path
739 .clone()
740 .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
741 .transpose()?
742 .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
743 Self::with_backend(config, l2, None)
744 }
745
746 fn with_backend(
747 config: BlobCacheConfig,
748 l2: Option<Arc<dyn BlobCacheL2Backend>>,
749 internal_store: Option<Arc<crate::storage::UnifiedStore>>,
750 ) -> Result<Self, CacheError> {
751 let config = BlobCacheConfig {
752 shard_count: config.shard_count.max(1),
753 ..config
754 };
755 let shards = (0..config.shard_count)
756 .map(|_| RwLock::new(Shard::new()))
757 .collect();
758 Ok(Self {
759 config,
760 internal_store,
761 shards,
762 namespaces: RwLock::new(HashSet::new()),
763 namespace_generations: RwLock::new(HashMap::new()),
764 pending_namespace_fences: RwLock::new(HashSet::new()),
765 tag_index: RwLock::new(HashMap::new()),
766 dependency_index: RwLock::new(HashMap::new()),
767 l2,
768 bytes_in_use: AtomicUsize::new(0),
769 namespace_bytes_in_use: RwLock::new(HashMap::new()),
770 sweep_shard_cursor: AtomicUsize::new(0),
771 namespace_flush_epoch: AtomicU64::new(0),
772 namespace_reclaimed_epoch: AtomicU64::new(0),
773 stats: AtomicStats::new(),
774 promotion_pool: OnceLock::new(),
775 })
776 }
777
778 pub fn with_defaults() -> Self {
779 Self::new(BlobCacheConfig::default())
780 }
781
782 pub fn l2_path(&self) -> Option<&std::path::Path> {
788 self.config.l2_path.as_deref()
789 }
790
791 pub fn put(
792 &self,
793 namespace: impl Into<String>,
794 key: impl Into<String>,
795 input: BlobCachePut,
796 ) -> Result<(), CacheError> {
797 self.put_at(namespace, key, input, unix_now_ms())
798 }
799
800 fn put_at(
801 &self,
802 namespace: impl Into<String>,
803 key: impl Into<String>,
804 input: BlobCachePut,
805 now_ms: u64,
806 ) -> Result<(), CacheError> {
807 let namespace = namespace.into();
808 let key = BlobCacheKey::new(namespace.clone(), key);
809 self.validate_blob_size(input.bytes.len(), input.policy)?;
810 self.validate_metadata(&input.content_metadata)?;
811 let compressed = if self.l2.is_some() {
812 Some(match self.config.l2_compression {
816 L2Compression::Off => Compressed::Raw(input.bytes.clone()),
817 L2Compression::On => {
818 let content_type = input
819 .content_metadata
820 .get("content-type")
821 .map(String::as_str);
822 L2BlobCompressor::compress(&input.bytes, content_type, &CompressOpts::default())
823 .map_err(|err| CacheError::L2Io(err.to_string()))?
824 }
825 })
826 } else {
827 None
828 };
829 let _publication = self
834 .internal_store
835 .as_ref()
836 .map(|store| store.vault_publication_lock.lock());
837 self.ensure_namespace(&namespace)?;
838 let namespace_generation = self.current_generation(&namespace);
839 let tags = input.tags.clone();
840 let dependencies = input.dependencies.clone();
841
842 let shard_idx = self.shard_index(&key);
843 if self.l2.is_some() {
844 self.check_version(
845 &self.shards[shard_idx].read(),
846 &key,
847 input.policy.version_value(),
848 namespace_generation,
849 )?;
850 }
851 let entry = Entry::new(
852 input.bytes,
853 input.content_metadata,
854 input.tags,
855 input.dependencies,
856 input.policy,
857 namespace_generation,
858 now_ms,
859 &namespace,
860 &key.key,
861 );
862 let entry_charge = entry.l1_charge_bytes;
863 #[cfg(test)]
864 if let Some(hook) = PUT_PREPARED_HOOK.with(|slot| slot.borrow_mut().take()) {
865 hook();
866 }
867 let mut shard = self.shards[shard_idx].write();
868 self.check_version(
871 &shard,
872 &key,
873 input.policy.version_value(),
874 namespace_generation,
875 )?;
876 shard.clear_l2_hit_marker(&key);
877 if let Some(l2) = &self.l2 {
878 let old_l2_size = l2.record_size(&key);
879 let compressed = compressed.expect("L2 payload prepared before publication");
880 match l2.put(&key, &entry, old_l2_size, compressed) {
881 Ok(()) => {}
882 Err(err @ CacheError::L2Full { .. }) => {
883 self.stats
884 .l2_full_rejections
885 .fetch_add(1, Ordering::Relaxed);
886 return Err(err);
887 }
888 Err(err) => return Err(err),
889 }
890 }
891 let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
892 let old_entry = shard.remove(&key);
893 InsertOutcome {
894 old_entry,
895 admitted: false,
896 }
897 } else {
898 shard.insert(key.clone(), entry)
899 };
900 let old_charge = outcome
901 .old_entry
902 .as_ref()
903 .map_or(0, |entry| entry.l1_charge_bytes);
904 let new_charge = if outcome.admitted { entry_charge } else { 0 };
905 self.adjust_namespace_bytes(&namespace, old_charge, new_charge);
906 if new_charge >= old_charge {
907 self.bytes_in_use
908 .fetch_add(new_charge - old_charge, Ordering::Relaxed);
909 } else {
910 self.bytes_in_use
911 .fetch_sub(old_charge - new_charge, Ordering::Relaxed);
912 }
913 if let Some(old_entry) = outcome.old_entry.as_ref() {
914 self.deindex_entry(&key, old_entry);
915 }
916 if outcome.admitted {
917 self.index_entry(&key, &tags, &dependencies);
918 }
919 drop(shard);
920
921 self.stats.insertions.fetch_add(1, Ordering::Relaxed);
922 if outcome.admitted {
923 self.evict_namespace_until_within_budget(&namespace, shard_idx);
924 self.evict_until_within_budget(shard_idx);
925 }
926 Ok(())
927 }
928
929 pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
930 self.get_at(namespace, key, unix_now_ms())
931 }
932
933 fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
934 let namespace_generation = self.current_generation(namespace);
935 let shard_idx = self.shard_index_parts(namespace, key);
936 if let Some(hit) =
937 self.shards[shard_idx]
938 .read()
939 .shared_hit(namespace, key, now_ms, namespace_generation)
940 {
941 self.stats.hits.fetch_add(1, Ordering::Relaxed);
942 return Some(hit);
943 }
944 let mut shard = self.shards[shard_idx].write();
945 match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
946 Lookup::Hit(hit) => {
947 self.stats.hits.fetch_add(1, Ordering::Relaxed);
948 if hit.is_stale() {
949 self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
950 }
951 Some(hit)
952 }
953 Lookup::Expired(entry) => {
954 let cache_key = BlobCacheKey::new(namespace, key);
955 self.record_removed_entry(&cache_key, &entry);
956 drop(shard);
957 if let Some(l2) = &self.l2 {
958 l2.delete_key(&cache_key);
959 }
960 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
961 self.stats.misses.fetch_add(1, Ordering::Relaxed);
962 None
963 }
964 Lookup::IdleEvicted(entry) => {
965 let cache_key = BlobCacheKey::new(namespace, key);
966 self.record_removed_entry(&cache_key, &entry);
967 drop(shard);
968 if let Some(l2) = &self.l2 {
969 l2.delete_key(&cache_key);
970 }
971 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
972 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
973 self.stats.misses.fetch_add(1, Ordering::Relaxed);
974 None
975 }
976 Lookup::Stale(entry) => {
977 let cache_key = BlobCacheKey::new(namespace, key);
978 self.record_removed_entry(&cache_key, &entry);
979 drop(shard);
980 self.stats.misses.fetch_add(1, Ordering::Relaxed);
981 None
982 }
983 Lookup::Miss => {
984 drop(shard);
985 let cache_key = BlobCacheKey::new(namespace, key);
986 if let Some(pool) = self.promotion_pool.get() {
987 if let Some(l2) = self.l2.as_ref() {
991 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
992 let hit = entry.hit();
993 if self.should_promote_l2_hit(&cache_key, shard_idx) {
994 drop(entry);
999 let request = PromotionRequest {
1000 namespace: cache_key.namespace.clone(),
1001 key: cache_key.key.clone(),
1002 bytes: Arc::clone(hit.bytes()),
1003 policy: BlobCachePolicy::default(),
1004 };
1005 let _ = pool.schedule(request);
1006 }
1007 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1008 return Some(hit);
1009 }
1010 }
1011 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1012 return None;
1013 }
1014 if let Some(hit) =
1015 self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
1016 {
1017 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1018 return Some(hit);
1019 }
1020 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1021 None
1022 }
1023 Lookup::Present => unreachable!("get cannot return presence-only lookup"),
1024 }
1025 }
1026
1027 pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
1045 self.exists_at(namespace, key, unix_now_ms())
1046 }
1047
1048 pub fn expire(&self, namespace: &str, key: &str, ttl_ms: u64) -> Result<bool, CacheError> {
1050 let now_ms = unix_now_ms();
1051 self.update_expiry(namespace, key, Some(now_ms.saturating_add(ttl_ms)), now_ms)
1052 }
1053
1054 pub fn expire_at(
1056 &self,
1057 namespace: &str,
1058 key: &str,
1059 expires_at_unix_ms: u64,
1060 ) -> Result<bool, CacheError> {
1061 self.update_expiry(namespace, key, Some(expires_at_unix_ms), unix_now_ms())
1062 }
1063
1064 pub fn persist(&self, namespace: &str, key: &str) -> Result<bool, CacheError> {
1066 self.update_expiry(namespace, key, None, unix_now_ms())
1067 }
1068
1069 fn update_expiry(
1070 &self,
1071 namespace: &str,
1072 key: &str,
1073 expires_at_unix_ms: Option<u64>,
1074 now_ms: u64,
1075 ) -> Result<bool, CacheError> {
1076 let _publication = self
1077 .internal_store
1078 .as_ref()
1079 .map(|store| store.vault_publication_lock.lock());
1080 if !self.namespace_exists(namespace) {
1081 return Ok(false);
1082 }
1083 let cache_key = BlobCacheKey::new(namespace, key);
1084 let namespace_generation = self.current_generation(namespace);
1085 let shard_index = self.shard_index(&cache_key);
1086 let mut shard = self.shards[shard_index].write();
1087 let l1_live = shard.can_update_expiry(&cache_key, now_ms, namespace_generation);
1088 let l2_updated = match &self.l2 {
1089 Some(l2) => {
1090 l2.update_expiry(&cache_key, namespace_generation, now_ms, expires_at_unix_ms)?
1091 }
1092 None => false,
1093 };
1094 if l1_live {
1095 shard.update_expiry(&cache_key, expires_at_unix_ms, now_ms);
1096 }
1097 Ok(l1_live || l2_updated)
1098 }
1099
1100 fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
1101 let namespace_generation = self.current_generation(namespace);
1102 let shard_idx = self.shard_index_parts(namespace, key);
1103 let mut shard = self.shards[shard_idx].write();
1104 match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
1105 Lookup::Present => {
1106 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1107 CachePresence::Present
1108 }
1109 Lookup::Expired(entry) => {
1110 let cache_key = BlobCacheKey::new(namespace, key);
1111 self.record_removed_entry(&cache_key, &entry);
1112 drop(shard);
1113 if let Some(l2) = &self.l2 {
1114 l2.delete_key(&cache_key);
1115 }
1116 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1117 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1118 CachePresence::Absent
1119 }
1120 Lookup::IdleEvicted(entry) => {
1121 let cache_key = BlobCacheKey::new(namespace, key);
1122 self.record_removed_entry(&cache_key, &entry);
1123 drop(shard);
1124 if let Some(l2) = &self.l2 {
1125 l2.delete_key(&cache_key);
1126 }
1127 self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1128 self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
1129 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1130 CachePresence::Absent
1131 }
1132 Lookup::Stale(entry) => {
1133 let cache_key = BlobCacheKey::new(namespace, key);
1134 self.record_removed_entry(&cache_key, &entry);
1135 drop(shard);
1136 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1137 CachePresence::Absent
1138 }
1139 Lookup::Miss => {
1140 drop(shard);
1141 let Some(l2) = self.l2.as_ref() else {
1142 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1143 return CachePresence::Absent;
1144 };
1145 if l2.synopsis_may_contain(namespace, key) {
1146 self.stats.hits.fetch_add(1, Ordering::Relaxed);
1149 CachePresence::MaybePresent
1150 } else {
1151 self.stats.misses.fetch_add(1, Ordering::Relaxed);
1154 CachePresence::Absent
1155 }
1156 }
1157 Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1158 }
1159 }
1160
1161 pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1167 let _publication = self
1168 .internal_store
1169 .as_ref()
1170 .map(|store| store.vault_publication_lock.lock());
1171 if !self.namespace_exists(namespace) {
1172 return 0;
1173 }
1174 let cache_key = BlobCacheKey::new(namespace, key);
1175 let shard_idx = self.shard_index(&cache_key);
1176 let mut shard = self.shards[shard_idx].write();
1177 let removed = shard.remove(&cache_key);
1178
1179 if let Some(entry) = removed {
1180 self.record_invalidated_entry(&cache_key, &entry);
1181 drop(shard);
1182 1
1183 } else {
1184 drop(shard);
1185 self.l2
1186 .as_ref()
1187 .and_then(|l2| l2.delete_key(&cache_key))
1188 .map(|_| {
1189 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1190 1
1191 })
1192 .unwrap_or(0)
1193 }
1194 }
1195
1196 pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1198 let _publication = self
1199 .internal_store
1200 .as_ref()
1201 .map(|store| store.vault_publication_lock.lock());
1202 if !self.namespace_exists(namespace) {
1203 return 0;
1204 }
1205
1206 let mut count = 0usize;
1207 for shard in &self.shards {
1208 let mut shard = shard.write();
1209 let keys = shard
1210 .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1211 for key in keys {
1212 if let Some(entry) = shard.remove(&key) {
1213 self.record_invalidated_entry(&key, &entry);
1214 count += 1;
1215 }
1216 }
1217 }
1218
1219 let l2_count = self
1220 .l2
1221 .as_ref()
1222 .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1223 if l2_count > count {
1224 self.stats
1225 .invalidations
1226 .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1227 }
1228 count.max(l2_count)
1229 }
1230
1231 pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1237 self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1238 }
1239
1240 pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1242 self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1243 }
1244
1245 #[deprecated(
1247 since = "0.1.0",
1248 note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1249 )]
1250 pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1251 self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1252 }
1253
1254 #[deprecated(
1256 since = "0.1.0",
1257 note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1258 )]
1259 pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1260 self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1261 }
1262
1263 pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1266 match self.try_invalidate_namespace(namespace) {
1267 Ok(flushed) => flushed,
1268 Err(error) => {
1269 tracing::error!(
1270 namespace,
1271 ?error,
1272 "failed to persist cache namespace generation"
1273 );
1274 false
1275 }
1276 }
1277 }
1278
1279 pub(crate) fn invalidate_namespace_in_memory(&self, namespace: &str) {
1282 if !self.namespace_exists(namespace) {
1283 return;
1284 }
1285 let current = self.current_generation(namespace);
1286 let mut generations = self.namespace_generations.write();
1287 let generation = generations.entry(namespace.to_string()).or_insert(current);
1288 *generation = generation.saturating_add(1);
1289 self.pending_namespace_fences
1290 .write()
1291 .insert(namespace.to_string());
1292 self.namespace_flush_epoch.fetch_add(1, Ordering::Release);
1293 }
1294
1295 pub(crate) fn try_invalidate_namespace(&self, namespace: &str) -> Result<bool, CacheError> {
1296 let _publication = self
1297 .internal_store
1298 .as_ref()
1299 .map(|store| store.vault_publication_lock.lock());
1300 if !self.namespace_exists(namespace)
1301 && !self.pending_namespace_fences.read().contains(namespace)
1302 {
1303 return Ok(false);
1304 }
1305 let current = self.current_generation(namespace);
1306 let mut generations = self.namespace_generations.write();
1307 let generation = generations.entry(namespace.to_string()).or_insert(current);
1308 *generation = generation.saturating_add(1);
1309 self.namespace_flush_epoch.fetch_add(1, Ordering::Release);
1310 let persisted = if let Some(l2) = &self.l2 {
1311 l2.invalidate_namespace(namespace, *generation)
1312 } else {
1313 Ok(())
1314 };
1315 if persisted.is_ok() {
1316 self.pending_namespace_fences.write().remove(namespace);
1317 }
1318 self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1319 persisted.map(|()| true)
1320 }
1321
1322 pub fn ram_bytes_in_use(&self) -> u64 {
1327 let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1328 let l2 = self
1329 .l2
1330 .as_ref()
1331 .map_or(0, |l2| l2.resident_metadata_bytes());
1332 l1.saturating_add(l2)
1333 }
1334
1335 pub fn stats(&self) -> BlobCacheStats {
1336 BlobCacheStats {
1337 hits: self.stats.hits.load(Ordering::Relaxed),
1338 misses: self.stats.misses.load(Ordering::Relaxed),
1339 insertions: self.stats.insertions.load(Ordering::Relaxed),
1340 evictions: self.stats.evictions.load(Ordering::Relaxed),
1341 expirations: self.stats.expirations.load(Ordering::Relaxed),
1342 invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1343 namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1344 version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1345 entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1346 bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1347 l1_bytes_max: self.config.l1_bytes_max,
1348 namespace_bytes_max: self.config.namespace_bytes_max,
1349 l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1350 l2_bytes_max: self.config.l2_bytes_max,
1351 l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1352 l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1353 l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1354 synopsis_metadata_reads: self
1355 .l2
1356 .as_ref()
1357 .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1358 synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1359 namespaces: self.namespaces.read().len(),
1360 max_namespaces: self.config.max_namespaces,
1361 promotion_queued: self
1362 .promotion_pool
1363 .get()
1364 .map_or(0, |p| p.metrics().queued_total),
1365 promotion_dropped: self
1366 .promotion_pool
1367 .get()
1368 .map_or(0, |p| p.metrics().dropped_total),
1369 promotion_completed: self
1370 .promotion_pool
1371 .get()
1372 .map_or(0, |p| p.metrics().completed_total),
1373 promotion_queue_depth: self
1374 .promotion_pool
1375 .get()
1376 .map_or(0, |p| p.metrics().queue_depth),
1377 l2_compression_original_bytes: self
1378 .l2
1379 .as_ref()
1380 .map_or(0, |l2| l2.stats_compression_original_bytes()),
1381 l2_compression_stored_bytes: self
1382 .l2
1383 .as_ref()
1384 .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1385 l2_compression_skipped_total: self
1386 .l2
1387 .as_ref()
1388 .map_or(0, |l2| l2.stats_compression_skipped_total()),
1389 l2_bytes_saved_total: self
1390 .l2
1391 .as_ref()
1392 .map_or(0, |l2| l2.stats_bytes_saved_total()),
1393 l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1394 l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1395 }
1396 }
1397
1398 pub(crate) fn sweep_expired_l1(
1405 &self,
1406 entries_limit: Option<usize>,
1407 millis_limit: Option<u32>,
1408 ) -> BlobCacheSweepOutcome {
1409 let _publication = self
1410 .internal_store
1411 .as_ref()
1412 .map(|store| store.vault_publication_lock.lock());
1413 let started = Instant::now();
1414 let time_limit = millis_limit.map(|millis| Duration::from_millis(u64::from(millis)));
1415 let now_ms = unix_now_ms();
1416 let hard_entries_limit = entries_limit.unwrap_or(usize::MAX);
1417 let shard_count = self.shards.len();
1418 let first_shard = self.sweep_shard_cursor.fetch_add(1, Ordering::Relaxed) % shard_count;
1419 let flush_epoch = self.namespace_flush_epoch.load(Ordering::Acquire);
1420 let stale_cleanup_pending =
1421 self.namespace_reclaimed_epoch.load(Ordering::Acquire) < flush_epoch;
1422 let mut outcome = BlobCacheSweepOutcome::default();
1423 let mut completed_all_shards = true;
1424
1425 for offset in 0..shard_count {
1426 if outcome.entries_scanned >= hard_entries_limit
1427 || time_limit.is_some_and(|limit| started.elapsed() >= limit)
1428 {
1429 completed_all_shards = false;
1430 break;
1431 }
1432 let shard_index = (first_shard + offset) % shard_count;
1433 let mut shard = self.shards[shard_index].write();
1434 let sweep = shard.sweep_expired(
1435 now_ms,
1436 hard_entries_limit - outcome.entries_scanned,
1437 started,
1438 time_limit,
1439 );
1440 completed_all_shards &= sweep.completed_cycle;
1445 self.record_sweep(sweep, &mut outcome);
1446
1447 if stale_cleanup_pending
1448 && outcome.entries_scanned < hard_entries_limit
1449 && time_limit.is_none_or(|limit| started.elapsed() < limit)
1450 {
1451 let namespace_generations = self.namespace_generations.read();
1454 let stale_sweep = shard.sweep_stale_generations(
1455 &namespace_generations,
1456 flush_epoch,
1457 hard_entries_limit - outcome.entries_scanned,
1458 started,
1459 time_limit,
1460 );
1461 completed_all_shards &= stale_sweep.completed_cycle;
1462 self.record_sweep(stale_sweep, &mut outcome);
1463 }
1464 drop(shard);
1465 }
1466
1467 if stale_cleanup_pending
1468 && self
1469 .shards
1470 .iter()
1471 .all(|shard| shard.read().stale_cleanup_completed(flush_epoch))
1472 {
1473 self.namespace_reclaimed_epoch
1474 .fetch_max(flush_epoch, Ordering::Release);
1475 }
1476
1477 if outcome.expirations > 0 {
1478 let expirations = u64::try_from(outcome.expirations).unwrap_or(u64::MAX);
1479 self.stats
1480 .expirations
1481 .fetch_add(expirations, Ordering::Relaxed);
1482 }
1483 outcome.completed = completed_all_shards;
1484 outcome.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
1485 outcome
1486 }
1487
1488 pub(crate) fn sweep_expired_l2(
1492 &self,
1493 entries_limit: usize,
1494 millis_limit: Option<u32>,
1495 ) -> BlobCacheSweepOutcome {
1496 let _publication = self
1497 .internal_store
1498 .as_ref()
1499 .map(|store| store.vault_publication_lock.lock());
1500 let Some(l2) = &self.l2 else {
1501 return BlobCacheSweepOutcome {
1502 completed: true,
1503 ..BlobCacheSweepOutcome::default()
1504 };
1505 };
1506 let outcome = l2.sweep_expired(unix_now_ms(), entries_limit, millis_limit);
1507 if outcome.expirations > 0 {
1508 self.stats.expirations.fetch_add(
1509 u64::try_from(outcome.expirations).unwrap_or(u64::MAX),
1510 Ordering::Relaxed,
1511 );
1512 }
1513 outcome
1514 }
1515
1516 fn record_sweep(&self, sweep: ShardSweep, outcome: &mut BlobCacheSweepOutcome) {
1517 for (key, entry, _) in &sweep.removed {
1518 self.record_removed_entry(key, entry);
1519 if let Some(l2) = &self.l2 {
1520 l2.delete_key(key);
1521 }
1522 }
1523 outcome.entries_scanned += sweep.entries_scanned;
1524 for (namespace, scanned) in sweep.scanned_by_namespace {
1525 outcome
1526 .by_namespace
1527 .entry(namespace)
1528 .or_default()
1529 .entries_scanned += scanned;
1530 }
1531 for (key, entry, expired) in sweep.removed {
1532 let entry_charge_bytes = u64::try_from(entry.l1_charge_bytes).unwrap_or(u64::MAX);
1533 let namespace_stats = outcome
1534 .by_namespace
1535 .entry(key.namespace.clone())
1536 .or_default();
1537 namespace_stats.entries_evicted += 1;
1538 namespace_stats.bytes_reclaimed = namespace_stats
1539 .bytes_reclaimed
1540 .saturating_add(entry_charge_bytes);
1541 outcome.entries_evicted += 1;
1542 outcome.expirations += usize::from(expired);
1543 outcome.bytes_reclaimed = outcome.bytes_reclaimed.saturating_add(entry_charge_bytes);
1544 }
1545 }
1546
1547 pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1558 let weak: Weak<Self> = Arc::downgrade(self);
1559 let executor: PromotionExecutor = Arc::new(move |req| {
1560 let Some(cache) = weak.upgrade() else {
1564 return Ok(());
1565 };
1566 cache.promote_from_l2(&req)
1567 });
1568 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1569 match self.promotion_pool.set(Arc::clone(&pool)) {
1570 Ok(()) => pool,
1571 Err(losing_pool) => {
1575 losing_pool.shutdown();
1576 Arc::clone(
1577 self.promotion_pool
1578 .get()
1579 .expect("OnceLock set+get inconsistency"),
1580 )
1581 }
1582 }
1583 }
1584
1585 pub fn shutdown_async_promotion(&self) {
1589 if let Some(pool) = self.promotion_pool.get() {
1590 Arc::clone(pool).shutdown();
1591 }
1592 }
1593
1594 #[cfg(test)]
1597 fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1598 self.promotion_pool.get().cloned()
1599 }
1600
1601 #[cfg(test)]
1605 fn enable_async_promotion_with_executor(
1606 self: &Arc<Self>,
1607 opts: PoolOpts,
1608 executor: PromotionExecutor,
1609 ) -> Arc<AsyncPromotionPool> {
1610 let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1611 let _ = self.promotion_pool.set(Arc::clone(&pool));
1612 pool
1613 }
1614
1615 pub fn config(&self) -> &BlobCacheConfig {
1616 &self.config
1617 }
1618
1619 #[cfg(test)]
1620 fn inject_l2_fault_after_blob_write_once(&self) {
1621 self.l2
1622 .as_ref()
1623 .expect("L2 enabled")
1624 .inject_fault_after_blob_write_once();
1625 }
1626
1627 #[cfg(test)]
1628 fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1629 self.l2
1630 .as_ref()
1631 .expect("L2 enabled")
1632 .inject_synopsis_maybe_present(namespace, key);
1633 }
1634
1635 #[cfg(test)]
1639 fn inject_l2_v1_entry(
1640 &self,
1641 namespace: &str,
1642 key: &str,
1643 payload: &[u8],
1644 ) -> Result<(), CacheError> {
1645 let l2 = self.l2.as_ref().expect("L2 enabled");
1646 let cache_key = BlobCacheKey::new(namespace, key);
1647 l2.inject_v1_entry(&cache_key, payload)
1648 }
1649
1650 fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1651 let max = policy
1652 .max_blob_bytes_value()
1653 .unwrap_or(self.config.l1_bytes_max);
1654 if size > max {
1655 Err(CacheError::BlobTooLarge { size, max })
1656 } else {
1657 Ok(())
1658 }
1659 }
1660
1661 fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1662 let keys = metadata.len();
1663 let bytes = metadata
1664 .iter()
1665 .map(|(key, value)| key.len() + value.len())
1666 .sum::<usize>();
1667 if keys > self.config.content_metadata_keys_max
1668 || bytes > self.config.content_metadata_bytes_max
1669 {
1670 Err(CacheError::MetadataTooLarge {
1671 keys,
1672 bytes,
1673 max_keys: self.config.content_metadata_keys_max,
1674 max_bytes: self.config.content_metadata_bytes_max,
1675 })
1676 } else {
1677 Ok(())
1678 }
1679 }
1680
1681 fn rehydrate_l2_entry(
1682 &self,
1683 key: &BlobCacheKey,
1684 now_ms: u64,
1685 namespace_generation: u64,
1686 shard_idx: usize,
1687 ) -> Option<BlobCacheHit> {
1688 let l2 = self.l2.as_ref()?;
1689 let entry = l2.get(key, now_ms, namespace_generation)?;
1690 let hit = entry.hit();
1691 if self.should_promote_l2_hit(key, shard_idx) {
1692 self.do_l1_promotion_sync(key, entry, shard_idx);
1693 }
1694 Some(hit)
1695 }
1696
1697 fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1698 match self.config.l2_promotion_policy {
1699 L2PromotionPolicy::Immediate => true,
1700 L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1701 .write()
1702 .l2_hit_should_promote_on_second_hit(key),
1703 }
1704 }
1705
1706 fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1714 let entry_charge = entry.l1_charge_bytes;
1715 let mut shard = self.shards[shard_idx].write();
1716 let outcome = shard.insert(key.clone(), entry);
1717 let old_charge = outcome
1718 .old_entry
1719 .as_ref()
1720 .map_or(0, |entry| entry.l1_charge_bytes);
1721 self.adjust_namespace_bytes(&key.namespace, old_charge, entry_charge);
1722 if entry_charge >= old_charge {
1723 self.bytes_in_use
1724 .fetch_add(entry_charge - old_charge, Ordering::Relaxed);
1725 } else {
1726 self.bytes_in_use
1727 .fetch_sub(old_charge - entry_charge, Ordering::Relaxed);
1728 }
1729 drop(shard);
1730 self.evict_namespace_until_within_budget(&key.namespace, shard_idx);
1731 self.evict_until_within_budget(shard_idx);
1732 }
1733
1734 fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1740 let l2 = self
1741 .l2
1742 .as_ref()
1743 .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1744 let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1745 let now_ms = unix_now_ms();
1746 let namespace_generation = self.current_generation(req.namespace.as_str());
1747 if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1748 let shard_idx = self.shard_index(&cache_key);
1749 self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1750 }
1751 Ok(())
1752 }
1753
1754 fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1755 {
1756 let namespaces = self.namespaces.read();
1757 if namespaces.contains(namespace) {
1758 return Ok(());
1759 }
1760 }
1761 let generation = self.current_generation(namespace);
1762 let mut namespaces = self.namespaces.write();
1763 if namespaces.contains(namespace) {
1764 return Ok(());
1765 }
1766 if namespaces.len() >= self.config.max_namespaces {
1767 return Err(CacheError::TooManyNamespaces {
1768 max: self.config.max_namespaces,
1769 });
1770 }
1771 namespaces.insert(namespace.to_string());
1772 self.namespace_generations
1773 .write()
1774 .entry(namespace.to_string())
1775 .or_insert(generation);
1776 self.namespace_bytes_in_use
1777 .write()
1778 .entry(namespace.to_string())
1779 .or_insert(0);
1780 Ok(())
1781 }
1782
1783 fn namespace_exists(&self, namespace: &str) -> bool {
1784 self.namespaces.read().contains(namespace)
1785 || self
1786 .l2
1787 .as_ref()
1788 .is_some_and(|l2| l2.has_namespace(namespace))
1789 }
1790
1791 pub(crate) fn current_generation(&self, namespace: &str) -> u64 {
1792 if let Some(generation) = self.namespace_generations.read().get(namespace).copied() {
1793 return generation;
1794 }
1795 let Some(generation) = self
1796 .l2
1797 .as_ref()
1798 .and_then(|l2| l2.namespace_generation(namespace))
1799 else {
1800 return self
1801 .namespace_generations
1802 .read()
1803 .get(namespace)
1804 .copied()
1805 .unwrap_or(0);
1806 };
1807 let mut generations = self.namespace_generations.write();
1808 if let Some(current) = generations.get(namespace) {
1809 *current
1810 } else if generations.len() < self.config.max_namespaces {
1811 *generations
1812 .entry(namespace.to_string())
1813 .or_insert(generation)
1814 } else {
1815 generation
1816 }
1817 }
1818
1819 fn index_entry(
1820 &self,
1821 key: &BlobCacheKey,
1822 tags: &BTreeSet<String>,
1823 dependencies: &BTreeSet<String>,
1824 ) {
1825 if !tags.is_empty() {
1826 let mut index = self.tag_index.write();
1827 for tag in tags {
1828 index
1829 .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1830 .or_default()
1831 .insert(key.clone());
1832 }
1833 }
1834 if !dependencies.is_empty() {
1835 let mut index = self.dependency_index.write();
1836 for dependency in dependencies {
1837 index
1838 .entry(ScopedLabel::new(
1839 key.namespace.as_str(),
1840 dependency.as_str(),
1841 ))
1842 .or_default()
1843 .insert(key.clone());
1844 }
1845 }
1846 }
1847
1848 fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1849 Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1850 Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1851 }
1852
1853 fn remove_indexed_labels(
1854 index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1855 key: &BlobCacheKey,
1856 labels: &BTreeSet<String>,
1857 ) {
1858 if labels.is_empty() {
1859 return;
1860 }
1861 let mut index = index.write();
1862 for label in labels {
1863 let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1864 let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1865 keys.remove(key);
1866 keys.is_empty()
1867 } else {
1868 false
1869 };
1870 if should_remove {
1871 index.remove(&scoped);
1872 }
1873 }
1874 }
1875
1876 fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1877 self.bytes_in_use
1878 .fetch_sub(entry.l1_charge_bytes, Ordering::Relaxed);
1879 self.adjust_namespace_bytes(&key.namespace, entry.l1_charge_bytes, 0);
1880 self.deindex_entry(key, entry);
1881 }
1882
1883 fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1884 self.record_removed_entry(key, entry);
1885 if let Some(l2) = &self.l2 {
1886 l2.delete_key(key);
1887 }
1888 self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1889 }
1890
1891 fn invalidate_indexed_many(
1892 &self,
1893 namespace: &str,
1894 labels: &[&str],
1895 kind: IndexedKind,
1896 ) -> usize {
1897 let _publication = self
1898 .internal_store
1899 .as_ref()
1900 .map(|store| store.vault_publication_lock.lock());
1901 if labels.is_empty() || !self.namespace_exists(namespace) {
1902 return 0;
1903 }
1904
1905 let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1910 {
1911 let index = match kind {
1912 IndexedKind::Tag => self.tag_index.read(),
1913 IndexedKind::Dependency => self.dependency_index.read(),
1914 };
1915 for label in labels {
1916 let scoped = ScopedLabel::new(namespace, *label);
1917 if let Some(keys) = index.get(&scoped) {
1918 for key in keys {
1919 candidates
1920 .entry(key.clone())
1921 .or_default()
1922 .insert((*label).to_string());
1923 }
1924 }
1925 }
1926 }
1927
1928 let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1931 for (key, matched_labels) in candidates {
1932 let shard_idx = self.shard_index(&key);
1933 by_shard
1934 .entry(shard_idx)
1935 .or_default()
1936 .push((key, matched_labels));
1937 }
1938
1939 let mut count = 0usize;
1940 for (shard_idx, keys) in by_shard {
1941 let mut shard = self.shards[shard_idx].write();
1942 for (key, matched_labels) in keys {
1943 let still_matches = match kind {
1944 IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1945 IndexedKind::Dependency => {
1946 shard.entry_has_any_dependency(&key, &matched_labels)
1947 }
1948 };
1949 if still_matches {
1950 if let Some(entry) = shard.remove(&key) {
1951 self.record_invalidated_entry(&key, &entry);
1952 count += 1;
1953 }
1954 }
1955 }
1956 }
1957 let l2_count = self
1962 .l2
1963 .as_ref()
1964 .map_or(0, |l2| l2.delete_namespace(namespace));
1965 if l2_count > 0 {
1966 self.stats
1967 .invalidations
1968 .fetch_add(l2_count as u64, Ordering::Relaxed);
1969 }
1970 count.saturating_add(l2_count)
1971 }
1972
1973 fn shard_index(&self, key: &BlobCacheKey) -> usize {
1974 self.shard_index_parts(&key.namespace, &key.key)
1975 }
1976
1977 fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1978 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1979 namespace.hash(&mut hasher);
1980 key.hash(&mut hasher);
1981 (hasher.finish() as usize) % self.shards.len()
1982 }
1983
1984 fn check_version(
1985 &self,
1986 shard: &Shard,
1987 key: &BlobCacheKey,
1988 attempted: Option<u64>,
1989 namespace_generation: u64,
1990 ) -> Result<(), CacheError> {
1991 let Some(attempted) = attempted else {
1992 return Ok(());
1993 };
1994 let Some(existing) = shard.existing_version(key, namespace_generation) else {
1995 return Ok(());
1996 };
1997 if existing >= attempted {
1998 self.stats
1999 .version_mismatches
2000 .fetch_add(1, Ordering::Relaxed);
2001 Err(CacheError::VersionMismatch {
2002 existing,
2003 attempted,
2004 })
2005 } else {
2006 Ok(())
2007 }
2008 }
2009
2010 fn evict_until_within_budget(&self, preferred_start: usize) {
2011 while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
2012 let mut evicted = false;
2013 for offset in 0..self.shards.len() {
2014 let idx = (preferred_start + offset) % self.shards.len();
2015 let mut shard = self.shards[idx].write();
2016 if let Some((key, entry)) = shard.evict_one() {
2017 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
2018 self.record_removed_entry(&key, &entry);
2019 drop(shard);
2020 evicted = true;
2021 break;
2022 }
2023 }
2024 if !evicted {
2025 break;
2026 }
2027 }
2028 }
2029
2030 fn evict_namespace_until_within_budget(&self, namespace: &str, preferred_start: usize) {
2031 while self.namespace_bytes(namespace) > self.config.namespace_bytes_max {
2032 let mut evicted = false;
2033 for offset in 0..self.shards.len() {
2034 let idx = (preferred_start + offset) % self.shards.len();
2035 let mut shard = self.shards[idx].write();
2036 if let Some((key, entry)) = shard.remove_one_from_namespace(namespace) {
2037 self.stats.evictions.fetch_add(1, Ordering::Relaxed);
2038 self.record_removed_entry(&key, &entry);
2039 drop(shard);
2040 evicted = true;
2041 break;
2042 }
2043 }
2044 if !evicted {
2045 break;
2046 }
2047 }
2048 }
2049
2050 fn namespace_bytes(&self, namespace: &str) -> usize {
2051 self.namespace_bytes_in_use
2052 .read()
2053 .get(namespace)
2054 .copied()
2055 .unwrap_or(0)
2056 }
2057
2058 fn adjust_namespace_bytes(&self, namespace: &str, old_charge: usize, new_charge: usize) {
2059 let mut usage = self.namespace_bytes_in_use.write();
2060 let bytes = usage.entry(namespace.to_string()).or_insert(0);
2061 *bytes = bytes.saturating_sub(old_charge).saturating_add(new_charge);
2062 }
2063}
2064
2065#[derive(Debug, Default)]
2066pub(crate) struct BlobCacheSweepOutcome {
2067 pub(crate) entries_scanned: usize,
2068 pub(crate) entries_evicted: usize,
2069 pub(crate) expirations: usize,
2070 pub(crate) bytes_reclaimed: u64,
2071 pub(crate) elapsed_ms: u32,
2072 pub(crate) completed: bool,
2073 pub(crate) by_namespace: BTreeMap<String, BlobCacheNamespaceSweep>,
2074}
2075
2076#[derive(Debug, Default)]
2077pub(crate) struct BlobCacheNamespaceSweep {
2078 pub(crate) entries_scanned: usize,
2079 pub(crate) entries_evicted: usize,
2080 pub(crate) bytes_reclaimed: u64,
2081}
2082
2083fn unix_now_ms() -> u64 {
2084 SystemTime::now()
2085 .duration_since(UNIX_EPOCH)
2086 .map(|duration| duration.as_millis() as u64)
2087 .unwrap_or(0)
2088}
2089
2090impl Default for BlobCache {
2091 fn default() -> Self {
2092 Self::with_defaults()
2093 }
2094}
2095
2096#[cfg(test)]
2097mod tests;