Skip to main content

reddb_server/storage/cache/blob/
cache.rs

1//! Byte-oriented Blob Cache.
2//!
3//! L1 is a sharded, byte-bounded in-process cache with SIEVE eviction. L2 can
4//! use either the legacy standalone pager or RedDB's internal single-file
5//! collection backend.
6
7use 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// Test-only thread-local counter of how many times
32// `EffectiveExpiry::compute` is invoked from `Shard::get`. Thread-local
33// (rather than a global atomic) so the off-fast-path test does not race
34// with other tests in the harness's parallel executor.
35#[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    /// `Some(remaining_ms)` when the hit came from the stale-while-revalidate
126    /// window of an `ExtendedTtlPolicy`; `None` when the entry was fresh.
127    /// Boolean staleness is just `.is_some()`.
128    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    /// Cached payload, refcounted so duplicate readers share the buffer.
164    pub fn bytes(&self) -> &Arc<[u8]> {
165        &self.bytes
166    }
167
168    /// Convenience accessor returning a `&[u8]` view into [`bytes`](Self::bytes).
169    pub fn value(&self) -> &[u8] {
170        &self.bytes
171    }
172
173    /// Opaque content metadata captured on `put`.
174    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    /// Optional CAS / freshness version stamped on `put`.
180    pub fn version(&self) -> Option<u64> {
181        self.version
182    }
183
184    /// Absolute hard-expiry deadline attached to this value, if any.
185    pub fn expires_at_unix_ms(&self) -> Option<u64> {
186        self.expires_at_unix_ms
187    }
188
189    /// Remaining hard TTL at `now_ms`. `None` means the entry does not expire.
190    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    /// `true` when the hit was served from the stale-while-revalidate window
196    /// of an `ExtendedTtlPolicy`. Always `false` when the extended policy is
197    /// `off()` or the entry was within its hard expiry.
198    pub fn is_stale(&self) -> bool {
199        self.stale_window_remaining_ms.is_some()
200    }
201
202    /// Remaining stale-window milliseconds when [`is_stale`](Self::is_stale)
203    /// is `true`; `None` when the hit was fresh.
204    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/// Three-valued answer for [`BlobCache::exists`].
261///
262/// Today the implementation always returns [`Present`](Self::Present) or
263/// [`Absent`](Self::Absent) — it tracks the answer authoritatively. The
264/// [`MaybePresent`](Self::MaybePresent) variant exists in the type so the
265/// upcoming Bloom synopsis (#146) can answer "probably yes" without forcing
266/// a metadata read, all without breaking the `exists` contract.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum CachePresence {
269    /// The cache holds a live entry for this key.
270    Present,
271    /// The cache definitely does not hold this key (negative cache hit).
272    Absent,
273    /// A probabilistic synopsis cannot rule the key out without a deeper
274    /// lookup. Treat as a hit prospect: the caller should fetch.
275    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 TTL knobs (idle / stale-while-revalidate / jitter).
297    /// Defaults to [`ExtendedTtlPolicy::off`] so existing call sites and
298    /// stored entries continue to behave with hard-expiry-only semantics.
299    /// Wired into [`BlobCache::get`] behind the
300    /// `cache.blob.policy.extended` config knob (#194).
301    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    // ----- builder-style setters (consuming) -----------------------------
320
321    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    /// Replace the extended TTL knobs in one chainable call. Defaults to
352    /// [`ExtendedTtlPolicy::off`]; setting an active policy turns on the
353    /// idle / stale-serve / jitter behaviours in [`BlobCache::get`] and
354    /// [`BlobCache::put`] for entries written with this policy.
355    pub fn extended(mut self, extended: ExtendedTtlPolicy) -> Self {
356        self.extended = extended;
357        self
358    }
359
360    // ----- read-back accessors -------------------------------------------
361    //
362    // Setter methods consume `self` and return `Self`, so they cannot share
363    // a name with `&self` getters. The `*_value` suffix keeps both surfaces
364    // available without renaming the public builder API.
365
366    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    /// Read-back accessor for the extended TTL knobs. Mirrors the
391    /// `*_value` getter pattern used by every other [`BlobCachePolicy`]
392    /// field (#151 — fields are private; readers go through getters).
393    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    /// Times the per-namespace Bloom synopsis answered `MaybePresent` but the
418    /// authoritative L2 metadata B+ tree said `Absent` (the false-positive
419    /// cost of the probabilistic synopsis).
420    pub(super) synopsis_metadata_reads: u64,
421    /// Total bytes used by all per-namespace Bloom synopsis filters.
422    pub(super) synopsis_bytes: u64,
423    pub(super) namespaces: usize,
424    pub(super) max_namespaces: usize,
425    /// Async promotion pool counters (issue #193). All zero when the
426    /// pool is not enabled (default — `cache.blob.async_promotion = "off"`).
427    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    /// Numerator of the L2 compression ratio: sum of `original_len` over
432    /// entries that actually compressed (#192). Stored as the ratio's
433    /// component so [`BlobCacheStats`] stays `Eq` (avoids `f64` fields).
434    pub(super) l2_compression_original_bytes: u64,
435    /// Denominator of the L2 compression ratio: sum of `stored_len` over
436    /// entries that actually compressed.
437    pub(super) l2_compression_stored_bytes: u64,
438    /// Counter of L2 entries the compressor returned as `Raw` (any reason).
439    pub(super) l2_compression_skipped_total: u64,
440    /// Cumulative `(original_len - stored_len)` across compressed entries.
441    pub(super) l2_bytes_saved_total: u64,
442    /// Counter — L1 hits that served a stale entry from the SWR window of
443    /// an `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
444    pub(super) l1_stale_serves_total: u64,
445    /// Counter — L1 entries evicted by the idle-TTL gate of an
446    /// `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
447    pub(super) l1_idle_evicts_total: u64,
448}
449
450impl BlobCacheStats {
451    /// Number of `get`/`exists` calls that resolved to `Present` /
452    /// `MaybePresent`. Both count as hit prospects.
453    pub fn hits(&self) -> u64 {
454        self.hits
455    }
456
457    /// Number of `get`/`exists` calls that resolved to `Absent`.
458    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    /// Bytes resident in L1. Returned as `u64` for symmetry with
491    /// [`l2_bytes_in_use`](Self::l2_bytes_in_use); upcast is lossless.
492    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    /// Times the Bloom synopsis answered `MaybePresent` but the authoritative
525    /// L2 metadata B+ tree said `Absent`. This is the cost of the
526    /// probabilistic synopsis: a counter for the false-positive rate in
527    /// production. Negative answers from the filter never trigger a metadata
528    /// read (see [`l2_negative_skips`](Self::l2_negative_skips)).
529    pub fn synopsis_metadata_reads(&self) -> u64 {
530        self.synopsis_metadata_reads
531    }
532
533    /// Total bytes used by all per-namespace Bloom synopsis filters.
534    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    /// Total promotion requests successfully enqueued by `get` since boot.
547    /// `0` when async promotion is disabled.
548    pub fn promotion_queued(&self) -> u64 {
549        self.promotion_queued
550    }
551
552    /// Total promotion requests dropped on queue saturation since boot.
553    /// `0` when async promotion is disabled.
554    pub fn promotion_dropped(&self) -> u64 {
555        self.promotion_dropped
556    }
557
558    /// Total promotion requests executed by workers since boot.
559    /// `0` when async promotion is disabled.
560    pub fn promotion_completed(&self) -> u64 {
561        self.promotion_completed
562    }
563
564    /// Snapshot of pending requests in the promotion queue.
565    /// `0` when async promotion is disabled.
566    pub fn promotion_queue_depth(&self) -> usize {
567        self.promotion_queue_depth
568    }
569
570    /// Running average of `original_len / stored_len` for L2 entries that
571    /// the compressor actually shrank (#192). Returns `1.0` when no
572    /// compressed entry has been observed yet, regardless of how many
573    /// `Raw` entries have passed through (callers should pair this with
574    /// [`l2_compression_skipped_total`](Self::l2_compression_skipped_total)
575    /// to interpret).
576    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    /// Number of L2 entries the compressor returned as `Raw` since boot —
584    /// any reason: payload below `min_bytes`, content type already
585    /// compressed, ratio gate fired, or `cache.blob.l2_compression = "off"`.
586    pub fn l2_compression_skipped_total(&self) -> u64 {
587        self.l2_compression_skipped_total
588    }
589
590    /// Cumulative `(original_len - stored_len)` across all L2 entries the
591    /// compressor shrank. Operators read this to size the L2 budget
592    /// multiplier from real workloads.
593    pub fn l2_bytes_saved_total(&self) -> u64 {
594        self.l2_bytes_saved_total
595    }
596
597    /// Counter — L1 hits served as stale by the SWR window of an
598    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
599    /// active extended policy.
600    pub fn l1_stale_serves_total(&self) -> u64 {
601        self.l1_stale_serves_total
602    }
603
604    /// Counter — L1 entries evicted by the idle-TTL gate of an
605    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
606    /// active extended policy.
607    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    /// Counter incremented every time `BlobCache::get` returns a stale
630    /// entry from the SWR window of an `ExtendedTtlPolicy`. Stays at 0
631    /// when extended is `off()` for every entry.
632    l1_stale_serves: AtomicU64,
633    /// Counter incremented every time the idle-TTL gate of an
634    /// `ExtendedTtlPolicy` evicts an L1 entry. Stays at 0 when extended
635    /// is `off()` for every entry.
636    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
657/// Sharded, byte-bounded blob cache with optional durable L2 backing.
658///
659/// # Concurrency
660///
661/// `BlobCache` is `Send + Sync`. All public methods are safe to call from
662/// multiple threads concurrently. Internal sharding ensures disjoint-key
663/// contention does not serialize: independent keys land on independent
664/// `RwLock<Shard>` instances, and the global indexes (namespace set, tag /
665/// dependency maps) are read-mostly behind their own `RwLock`s.
666///
667/// `BlobCache` is **not** `Clone` — share ownership via `Arc<BlobCache>`.
668///
669/// # Blocking
670///
671/// All methods are synchronous. `put` may perform L2 disk I/O on the
672/// calling thread when an L2 path is configured; tokio callers should wrap
673/// `put` in `spawn_blocking`. `get`, `exists`, and the `invalidate_*`
674/// family touch L2 only on rehydrate / delete paths.
675pub 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    /// Optional async L2->L1 promotion pool (issue #193). When `None`,
692    /// `get` performs the L1 promotion synchronously on the read path.
693    /// When set via `enable_async_promotion`, L2 hits return bytes to
694    /// the caller immediately and the L1 install runs on a worker.
695    promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
696}
697
698// Compile-time guarantee that the documented `Send + Sync` contract above
699// stays in lockstep with the struct's interior. If this ever fails to
700// compile, the docstring is lying — fix the field that broke it, do not
701// remove this assertion.
702const _: fn() = || {
703    fn assert_send_sync<T: Send + Sync>() {}
704    assert_send_sync::<BlobCache>();
705};
706
707impl BlobCache {
708    /// Infallible constructor. Panics if `config.l2_path` is set and the L2
709    /// file cannot be opened — use [`BlobCache::open_with_l2`] instead for
710    /// configs that include an L2 path so boot errors are handled gracefully.
711    pub fn new(config: BlobCacheConfig) -> Self {
712        Self::try_new(config).expect("open blob-cache L2")
713    }
714
715    /// Fallible constructor for configs that include an L2 path.
716    /// Returns `Err(CacheError::L2Io(...))` on invalid path, corrupt control
717    /// sidecar, or any other recoverable I/O failure — the process stays alive.
718    pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
719        Self::try_new(config)
720    }
721
722    /// Open L2 inside the owning RedDB store. The cache rows, byte counter,
723    /// WAL records, and checkpoints remain in the same `.rdb` artifact.
724    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    /// Path to the legacy standalone L2 pager, when that backend is enabled.
783    ///
784    /// The server's internal L2 is part of the owning `.rdb` and therefore
785    /// returns `None` here. The backup orchestrator only needs this path for
786    /// callers that explicitly selected the compatibility file backend.
787    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            // Compress before the store publication fence. The result is not
813            // visible until the fenced publication below, so CPU-heavy zstd
814            // work must not serialize unrelated database writes.
815            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        // The internal L2 publishes through the owning store. Enter that
830        // store's publication fence before any Blob Cache lock so database
831        // writes that invalidate the cache cannot form an AB/BA cycle with a
832        // concurrent cache publication. The standalone L2 has no such fence.
833        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        // Another writer may have published while compression ran. Recheck
869        // under the publication guard before changing either cache tier.
870        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                    // Async path: do the L2 read (we owe the bytes to the
988                    // caller right now) but defer the L1 install onto the
989                    // worker pool. Caller does not pay promotion bookkeeping.
990                    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 the freshly-fetched Entry — the worker will
995                                // re-fetch it. Cost: one extra L2 metadata read +
996                                // blob read per promoted L2 hit while async mode is on.
997                                // Acceptable trade-off for opt-in mode.
998                                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    /// Probe whether `(namespace, key)` is cached.
1028    ///
1029    /// Returns a three-valued [`CachePresence`]:
1030    ///
1031    /// - `Present` when an L1-resident entry is held for the key.
1032    /// - `Absent` when the cache can authoritatively rule the key out: either
1033    ///   no L2 is configured, or the per-namespace Bloom synopsis
1034    ///   (no-false-negatives) says the key was never inserted into L2.
1035    /// - `MaybePresent` when L1 missed but the Bloom synopsis cannot rule the
1036    ///   key out. Callers that need an exact answer must follow up with
1037    ///   [`get`](Self::get), which performs the authoritative metadata read
1038    ///   and either rehydrates a hit or surfaces a genuine miss.
1039    ///
1040    /// `exists` deliberately does NOT touch the L2 metadata B+ tree on a
1041    /// `MaybePresent` answer — that is the whole reason the synopsis exists
1042    /// (#146). The probabilistic answer is the cheap fast path; pay the
1043    /// metadata-read cost only when you actually need the bytes.
1044    pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
1045        self.exists_at(namespace, key, unix_now_ms())
1046    }
1047
1048    /// Replace a live entry's expiry with a relative TTL.
1049    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    /// Replace a live entry's expiry with an absolute Unix-millisecond deadline.
1055    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    /// Remove expiry from a live entry without rewriting its value or tags.
1065    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                    // Filter says maybe — the cheap fast path defers the
1147                    // authoritative read to `get`. Count as a hit prospect.
1148                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
1149                    CachePresence::MaybePresent
1150                } else {
1151                    // Filter says no — definitively absent (no
1152                    // false-negatives).
1153                    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    /// Node-local invalidation for one exact cache key.
1162    ///
1163    /// This does not propagate to replicas. Cluster-wide invalidation is a
1164    /// future contract; callers that need cross-node coherence must rely on the
1165    /// underlying write reaching each node and triggering local eviction there.
1166    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    /// Node-local invalidation for keys with a namespace-local prefix.
1197    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    /// Node-local batched invalidation for all entries carrying any of `tags`.
1232    ///
1233    /// Locks each affected shard once per call, so a batched invalidation
1234    /// from a downstream adapter (#143) does not multiply lock acquisitions
1235    /// the way N singular calls would.
1236    pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1237        self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1238    }
1239
1240    /// Node-local batched invalidation for all entries carrying any of `dependencies`.
1241    pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1242        self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1243    }
1244
1245    /// Node-local invalidation for all entries carrying `tag`.
1246    #[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    /// Node-local invalidation for all entries carrying `dependency`.
1255    #[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    /// Flush a namespace by advancing its generation and removing L2 entries.
1264    /// L1 entries become invisible immediately and are reclaimed lazily.
1265    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    /// Fence existing entries without publishing to L2. The caller must
1280    /// persist the namespace invalidation after its WAL capture ends.
1281    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    /// RAM this cache occupies right now (ADR 0073 §2, the `blob_cache_l1`
1323    /// pool): L1 entry payloads plus L2's resident metadata — its B+ tree
1324    /// pages and Bloom synopsis filters. L2's *disk* extent is not memory and
1325    /// is excluded; `BlobCacheStats::l2_bytes_in_use` reports it separately.
1326    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    /// Remove a bounded set of due L1 entries.
1399    ///
1400    /// This is the storage primitive behind the cache sweeper. It never
1401    /// locks more than one shard at a time. TTL work comes from each shard's
1402    /// deadline index; a rotating scan runs only while a namespace-generation
1403    /// flush is awaiting physical reclamation.
1404    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            // Finish removal bookkeeping under the shard publication lock.
1441            // Otherwise a concurrent put could publish a fresh value for the
1442            // same key before the old tag/dependency indexes or L2 record are
1443            // removed, making the cleanup erase the fresh entry's metadata.
1444            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                // Keep generations stable while this shard is inspected. A
1452                // flush cannot then make a fresh generation look stale.
1453                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    /// Remove a bounded set of due L2-only entries from the persistent
1489    /// deadline projection. The internal backend resolves due ids without a
1490    /// collection scan; the compatibility file backend reports no work.
1491    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    // -- Async promotion (issue #193) ---------------------------------------
1548
1549    /// Initialize the async L2->L1 promotion pool. Must be called on an
1550    /// `Arc<Self>` so the executor closure can hold a `Weak<Self>` (no
1551    /// reference cycle).
1552    ///
1553    /// Idempotent on first call only — `OnceLock` semantics: a second call
1554    /// returns the previously-installed pool unchanged. The returned `Arc`
1555    /// can be used by callers that want to inspect metrics directly; most
1556    /// callers should ignore it and read metrics via `stats()`.
1557    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            // Upgrade only at execution time. If the cache has been
1561            // dropped, the worker silently no-ops (executor never holds
1562            // a strong ref between calls).
1563            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            // Race: another caller already initialized. Drain ours and
1572            // return the winner. The losing pool's workers are spawned;
1573            // shutdown drains them out gracefully.
1574            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    /// Drain and stop the async promotion pool, if enabled. Safe to call
1586    /// from `Drop` impls / test teardown — no-op when the pool was never
1587    /// initialized.
1588    pub fn shutdown_async_promotion(&self) {
1589        if let Some(pool) = self.promotion_pool.get() {
1590            Arc::clone(pool).shutdown();
1591        }
1592    }
1593
1594    /// Test-only escape hatch: schedule outcome of the most recent attempt
1595    /// is internal; tests assert on `stats()` counters instead.
1596    #[cfg(test)]
1597    fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1598        self.promotion_pool.get().cloned()
1599    }
1600
1601    /// Test-only: install a custom executor (e.g. one that sleeps to
1602    /// expose the hot-path / worker-path latency split). Used by the
1603    /// async-promotion wiring tests in this file.
1604    #[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    /// Test-only escape hatch (#192 lane 2/5): synthesise a legacy
1636    /// `V1Raw` L2 entry on disk so the forward-compat read test can
1637    /// verify pre-compression entries still rehydrate.
1638    #[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    /// Pure L1 install bookkeeping: shard write-lock, byte accounting,
1707    /// eviction loop. Extracted so the async promotion pool can call it
1708    /// from a worker (issue #193, lane 1/5).
1709    ///
1710    /// This is intentionally side-effect-only — it does not touch hit/miss
1711    /// stats (the caller already counted the hit) and does not return the
1712    /// `BlobCacheHit` (the caller already handed bytes to the user).
1713    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    /// Worker-side promotion path: re-fetch the entry from L2 and run the
1735    /// L1 install bookkeeping. Idempotent — re-promoting a key that the
1736    /// hot path already promoted (race with another reader) is harmless.
1737    /// Returns `Err` only when L2 is unavailable or the key is no longer
1738    /// present at L2 (silently treated as a no-op upstream).
1739    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        // Snapshot the candidate keys for every label up front so the
1906        // shard-locking pass below sees a stable set. We deduplicate by
1907        // BlobCacheKey so a key tagged with multiple invalidated labels is
1908        // still removed (and counted) exactly once.
1909        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        // Group candidates by shard so each shard lock is taken at most
1929        // once per call.
1930        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        // L2's stable record format predates tag/dependency metadata. Never
1958        // allow an L2-only entry to survive a label invalidation and later
1959        // resurrect. Until that format grows label indexes, conservatively
1960        // flush the remaining L2 namespace while retaining unrelated L1 hits.
1961        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;