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    shards: Vec<RwLock<Shard>>,
678    namespaces: RwLock<HashSet<String>>,
679    namespace_generations: RwLock<HashMap<String, u64>>,
680    tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
681    dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
682    l2: Option<Arc<dyn BlobCacheL2Backend>>,
683    bytes_in_use: AtomicUsize,
684    namespace_bytes_in_use: RwLock<HashMap<String, usize>>,
685    sweep_shard_cursor: AtomicUsize,
686    namespace_flush_epoch: AtomicU64,
687    namespace_reclaimed_epoch: AtomicU64,
688    stats: AtomicStats,
689    /// Optional async L2->L1 promotion pool (issue #193). When `None`,
690    /// `get` performs the L1 promotion synchronously on the read path.
691    /// When set via `enable_async_promotion`, L2 hits return bytes to
692    /// the caller immediately and the L1 install runs on a worker.
693    promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
694}
695
696// Compile-time guarantee that the documented `Send + Sync` contract above
697// stays in lockstep with the struct's interior. If this ever fails to
698// compile, the docstring is lying — fix the field that broke it, do not
699// remove this assertion.
700const _: fn() = || {
701    fn assert_send_sync<T: Send + Sync>() {}
702    assert_send_sync::<BlobCache>();
703};
704
705impl BlobCache {
706    /// Infallible constructor. Panics if `config.l2_path` is set and the L2
707    /// file cannot be opened — use [`BlobCache::open_with_l2`] instead for
708    /// configs that include an L2 path so boot errors are handled gracefully.
709    pub fn new(config: BlobCacheConfig) -> Self {
710        Self::try_new(config).expect("open blob-cache L2")
711    }
712
713    /// Fallible constructor for configs that include an L2 path.
714    /// Returns `Err(CacheError::L2Io(...))` on invalid path, corrupt control
715    /// sidecar, or any other recoverable I/O failure — the process stays alive.
716    pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
717        Self::try_new(config)
718    }
719
720    /// Open L2 inside the owning RedDB store. The cache rows, byte counter,
721    /// WAL records, and checkpoints remain in the same `.rdb` artifact.
722    pub fn open_with_internal_l2(
723        config: BlobCacheConfig,
724        store: Arc<crate::storage::UnifiedStore>,
725    ) -> Result<Self, CacheError> {
726        let l2 = (config.l2_bytes_max > 0)
727            .then(|| StoreBlobCacheL2::open(store, config.l2_bytes_max))
728            .transpose()?
729            .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
730        Self::with_backend(config, l2)
731    }
732
733    fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
734        let l2 = config
735            .l2_path
736            .clone()
737            .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
738            .transpose()?
739            .map(|backend| Arc::new(backend) as Arc<dyn BlobCacheL2Backend>);
740        Self::with_backend(config, l2)
741    }
742
743    fn with_backend(
744        config: BlobCacheConfig,
745        l2: Option<Arc<dyn BlobCacheL2Backend>>,
746    ) -> Result<Self, CacheError> {
747        let config = BlobCacheConfig {
748            shard_count: config.shard_count.max(1),
749            ..config
750        };
751        let shards = (0..config.shard_count)
752            .map(|_| RwLock::new(Shard::new()))
753            .collect();
754        Ok(Self {
755            config,
756            shards,
757            namespaces: RwLock::new(HashSet::new()),
758            namespace_generations: RwLock::new(HashMap::new()),
759            tag_index: RwLock::new(HashMap::new()),
760            dependency_index: RwLock::new(HashMap::new()),
761            l2,
762            bytes_in_use: AtomicUsize::new(0),
763            namespace_bytes_in_use: RwLock::new(HashMap::new()),
764            sweep_shard_cursor: AtomicUsize::new(0),
765            namespace_flush_epoch: AtomicU64::new(0),
766            namespace_reclaimed_epoch: AtomicU64::new(0),
767            stats: AtomicStats::new(),
768            promotion_pool: OnceLock::new(),
769        })
770    }
771
772    pub fn with_defaults() -> Self {
773        Self::new(BlobCacheConfig::default())
774    }
775
776    /// Path to the legacy standalone L2 pager, when that backend is enabled.
777    ///
778    /// The server's internal L2 is part of the owning `.rdb` and therefore
779    /// returns `None` here. The backup orchestrator only needs this path for
780    /// callers that explicitly selected the compatibility file backend.
781    pub fn l2_path(&self) -> Option<&std::path::Path> {
782        self.config.l2_path.as_deref()
783    }
784
785    pub fn put(
786        &self,
787        namespace: impl Into<String>,
788        key: impl Into<String>,
789        input: BlobCachePut,
790    ) -> Result<(), CacheError> {
791        self.put_at(namespace, key, input, unix_now_ms())
792    }
793
794    fn put_at(
795        &self,
796        namespace: impl Into<String>,
797        key: impl Into<String>,
798        input: BlobCachePut,
799        now_ms: u64,
800    ) -> Result<(), CacheError> {
801        let namespace = namespace.into();
802        let key = BlobCacheKey::new(namespace.clone(), key);
803        self.validate_blob_size(input.bytes.len(), input.policy)?;
804        self.validate_metadata(&input.content_metadata)?;
805        self.ensure_namespace(&namespace)?;
806        let namespace_generation = self.current_generation(&namespace);
807        let tags = input.tags.clone();
808        let dependencies = input.dependencies.clone();
809
810        let shard_idx = self.shard_index(&key);
811        if self.l2.is_some() {
812            self.check_version(
813                &self.shards[shard_idx].read(),
814                &key,
815                input.policy.version_value(),
816                namespace_generation,
817            )?;
818        }
819        let entry = Entry::new(
820            input.bytes,
821            input.content_metadata,
822            input.tags,
823            input.dependencies,
824            input.policy,
825            namespace_generation,
826            now_ms,
827            &namespace,
828            &key.key,
829        );
830        let entry_charge = entry.l1_charge_bytes;
831        let compressed = if self.l2.is_some() {
832            // Compression decision happens in the foreground put — the
833            // outcome (`Compressed::Raw` or `Compressed::Zstd`) is what
834            // gets framed and written to the chain (#192). When the knob
835            // is `Off`, skip the compressor entirely (CPU savings) and
836            // emit a `Raw` variant directly so the on-disk format stays
837            // uniform.
838            Some(match self.config.l2_compression {
839                L2Compression::Off => Compressed::Raw(entry.bytes.as_ref().to_vec()),
840                L2Compression::On => {
841                    let content_type = entry
842                        .content_metadata
843                        .as_ref()
844                        .and_then(|metadata| metadata.get("content-type"))
845                        .map(String::as_str);
846                    L2BlobCompressor::compress(
847                        entry.bytes.as_ref(),
848                        content_type,
849                        &CompressOpts::default(),
850                    )
851                    .map_err(|err| CacheError::L2Io(err.to_string()))?
852                }
853            })
854        } else {
855            None
856        };
857        #[cfg(test)]
858        if let Some(hook) = PUT_PREPARED_HOOK.with(|slot| slot.borrow_mut().take()) {
859            hook();
860        }
861        let mut shard = self.shards[shard_idx].write();
862        // Another writer may have published while compression ran. Recheck
863        // under the publication guard before changing either cache tier.
864        self.check_version(
865            &shard,
866            &key,
867            input.policy.version_value(),
868            namespace_generation,
869        )?;
870        shard.clear_l2_hit_marker(&key);
871        if let Some(l2) = &self.l2 {
872            let old_l2_size = l2.record_size(&key);
873            let compressed = compressed.expect("L2 payload prepared before publication");
874            match l2.put(&key, &entry, old_l2_size, compressed) {
875                Ok(()) => {}
876                Err(err @ CacheError::L2Full { .. }) => {
877                    self.stats
878                        .l2_full_rejections
879                        .fetch_add(1, Ordering::Relaxed);
880                    return Err(err);
881                }
882                Err(err) => return Err(err),
883            }
884        }
885        let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
886            let old_entry = shard.remove(&key);
887            InsertOutcome {
888                old_entry,
889                admitted: false,
890            }
891        } else {
892            shard.insert(key.clone(), entry)
893        };
894        let old_charge = outcome
895            .old_entry
896            .as_ref()
897            .map_or(0, |entry| entry.l1_charge_bytes);
898        let new_charge = if outcome.admitted { entry_charge } else { 0 };
899        self.adjust_namespace_bytes(&namespace, old_charge, new_charge);
900        if new_charge >= old_charge {
901            self.bytes_in_use
902                .fetch_add(new_charge - old_charge, Ordering::Relaxed);
903        } else {
904            self.bytes_in_use
905                .fetch_sub(old_charge - new_charge, Ordering::Relaxed);
906        }
907        if let Some(old_entry) = outcome.old_entry.as_ref() {
908            self.deindex_entry(&key, old_entry);
909        }
910        if outcome.admitted {
911            self.index_entry(&key, &tags, &dependencies);
912        }
913        drop(shard);
914
915        self.stats.insertions.fetch_add(1, Ordering::Relaxed);
916        if outcome.admitted {
917            self.evict_namespace_until_within_budget(&namespace, shard_idx);
918            self.evict_until_within_budget(shard_idx);
919        }
920        Ok(())
921    }
922
923    pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
924        self.get_at(namespace, key, unix_now_ms())
925    }
926
927    fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
928        let namespace_generation = self.current_generation(namespace);
929        let shard_idx = self.shard_index_parts(namespace, key);
930        if let Some(hit) =
931            self.shards[shard_idx]
932                .read()
933                .shared_hit(namespace, key, now_ms, namespace_generation)
934        {
935            self.stats.hits.fetch_add(1, Ordering::Relaxed);
936            return Some(hit);
937        }
938        let mut shard = self.shards[shard_idx].write();
939        match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
940            Lookup::Hit(hit) => {
941                self.stats.hits.fetch_add(1, Ordering::Relaxed);
942                if hit.is_stale() {
943                    self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
944                }
945                Some(hit)
946            }
947            Lookup::Expired(entry) => {
948                let cache_key = BlobCacheKey::new(namespace, key);
949                self.record_removed_entry(&cache_key, &entry);
950                if let Some(l2) = &self.l2 {
951                    l2.delete_key(&cache_key);
952                }
953                drop(shard);
954                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
955                self.stats.misses.fetch_add(1, Ordering::Relaxed);
956                None
957            }
958            Lookup::IdleEvicted(entry) => {
959                let cache_key = BlobCacheKey::new(namespace, key);
960                self.record_removed_entry(&cache_key, &entry);
961                if let Some(l2) = &self.l2 {
962                    l2.delete_key(&cache_key);
963                }
964                drop(shard);
965                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
966                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
967                self.stats.misses.fetch_add(1, Ordering::Relaxed);
968                None
969            }
970            Lookup::Stale(entry) => {
971                let cache_key = BlobCacheKey::new(namespace, key);
972                self.record_removed_entry(&cache_key, &entry);
973                drop(shard);
974                self.stats.misses.fetch_add(1, Ordering::Relaxed);
975                None
976            }
977            Lookup::Miss => {
978                drop(shard);
979                let cache_key = BlobCacheKey::new(namespace, key);
980                if let Some(pool) = self.promotion_pool.get() {
981                    // Async path: do the L2 read (we owe the bytes to the
982                    // caller right now) but defer the L1 install onto the
983                    // worker pool. Caller does not pay promotion bookkeeping.
984                    if let Some(l2) = self.l2.as_ref() {
985                        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
986                            let hit = entry.hit();
987                            if self.should_promote_l2_hit(&cache_key, shard_idx) {
988                                // Drop the freshly-fetched Entry — the worker will
989                                // re-fetch it. Cost: one extra L2 metadata read +
990                                // blob read per promoted L2 hit while async mode is on.
991                                // Acceptable trade-off for opt-in mode.
992                                drop(entry);
993                                let request = PromotionRequest {
994                                    namespace: cache_key.namespace.clone(),
995                                    key: cache_key.key.clone(),
996                                    bytes: Arc::clone(hit.bytes()),
997                                    policy: BlobCachePolicy::default(),
998                                };
999                                let _ = pool.schedule(request);
1000                            }
1001                            self.stats.hits.fetch_add(1, Ordering::Relaxed);
1002                            return Some(hit);
1003                        }
1004                    }
1005                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1006                    return None;
1007                }
1008                if let Some(hit) =
1009                    self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
1010                {
1011                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
1012                    return Some(hit);
1013                }
1014                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1015                None
1016            }
1017            Lookup::Present => unreachable!("get cannot return presence-only lookup"),
1018        }
1019    }
1020
1021    /// Probe whether `(namespace, key)` is cached.
1022    ///
1023    /// Returns a three-valued [`CachePresence`]:
1024    ///
1025    /// - `Present` when an L1-resident entry is held for the key.
1026    /// - `Absent` when the cache can authoritatively rule the key out: either
1027    ///   no L2 is configured, or the per-namespace Bloom synopsis
1028    ///   (no-false-negatives) says the key was never inserted into L2.
1029    /// - `MaybePresent` when L1 missed but the Bloom synopsis cannot rule the
1030    ///   key out. Callers that need an exact answer must follow up with
1031    ///   [`get`](Self::get), which performs the authoritative metadata read
1032    ///   and either rehydrates a hit or surfaces a genuine miss.
1033    ///
1034    /// `exists` deliberately does NOT touch the L2 metadata B+ tree on a
1035    /// `MaybePresent` answer — that is the whole reason the synopsis exists
1036    /// (#146). The probabilistic answer is the cheap fast path; pay the
1037    /// metadata-read cost only when you actually need the bytes.
1038    pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
1039        self.exists_at(namespace, key, unix_now_ms())
1040    }
1041
1042    /// Replace a live entry's expiry with a relative TTL.
1043    pub fn expire(&self, namespace: &str, key: &str, ttl_ms: u64) -> Result<bool, CacheError> {
1044        let now_ms = unix_now_ms();
1045        self.update_expiry(namespace, key, Some(now_ms.saturating_add(ttl_ms)), now_ms)
1046    }
1047
1048    /// Replace a live entry's expiry with an absolute Unix-millisecond deadline.
1049    pub fn expire_at(
1050        &self,
1051        namespace: &str,
1052        key: &str,
1053        expires_at_unix_ms: u64,
1054    ) -> Result<bool, CacheError> {
1055        self.update_expiry(namespace, key, Some(expires_at_unix_ms), unix_now_ms())
1056    }
1057
1058    /// Remove expiry from a live entry without rewriting its value or tags.
1059    pub fn persist(&self, namespace: &str, key: &str) -> Result<bool, CacheError> {
1060        self.update_expiry(namespace, key, None, unix_now_ms())
1061    }
1062
1063    fn update_expiry(
1064        &self,
1065        namespace: &str,
1066        key: &str,
1067        expires_at_unix_ms: Option<u64>,
1068        now_ms: u64,
1069    ) -> Result<bool, CacheError> {
1070        if !self.namespace_exists(namespace) {
1071            return Ok(false);
1072        }
1073        let cache_key = BlobCacheKey::new(namespace, key);
1074        let namespace_generation = self.current_generation(namespace);
1075        let shard_index = self.shard_index(&cache_key);
1076        let mut shard = self.shards[shard_index].write();
1077        let l1_live = shard.can_update_expiry(&cache_key, now_ms, namespace_generation);
1078        let l2_updated = match &self.l2 {
1079            Some(l2) => {
1080                l2.update_expiry(&cache_key, namespace_generation, now_ms, expires_at_unix_ms)?
1081            }
1082            None => false,
1083        };
1084        if l1_live {
1085            shard.update_expiry(&cache_key, expires_at_unix_ms, now_ms);
1086        }
1087        Ok(l1_live || l2_updated)
1088    }
1089
1090    fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
1091        let namespace_generation = self.current_generation(namespace);
1092        let shard_idx = self.shard_index_parts(namespace, key);
1093        let mut shard = self.shards[shard_idx].write();
1094        match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
1095            Lookup::Present => {
1096                self.stats.hits.fetch_add(1, Ordering::Relaxed);
1097                CachePresence::Present
1098            }
1099            Lookup::Expired(entry) => {
1100                let cache_key = BlobCacheKey::new(namespace, key);
1101                self.record_removed_entry(&cache_key, &entry);
1102                if let Some(l2) = &self.l2 {
1103                    l2.delete_key(&cache_key);
1104                }
1105                drop(shard);
1106                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1107                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1108                CachePresence::Absent
1109            }
1110            Lookup::IdleEvicted(entry) => {
1111                let cache_key = BlobCacheKey::new(namespace, key);
1112                self.record_removed_entry(&cache_key, &entry);
1113                if let Some(l2) = &self.l2 {
1114                    l2.delete_key(&cache_key);
1115                }
1116                drop(shard);
1117                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1118                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
1119                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1120                CachePresence::Absent
1121            }
1122            Lookup::Stale(entry) => {
1123                let cache_key = BlobCacheKey::new(namespace, key);
1124                self.record_removed_entry(&cache_key, &entry);
1125                drop(shard);
1126                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1127                CachePresence::Absent
1128            }
1129            Lookup::Miss => {
1130                drop(shard);
1131                let Some(l2) = self.l2.as_ref() else {
1132                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1133                    return CachePresence::Absent;
1134                };
1135                if l2.synopsis_may_contain(namespace, key) {
1136                    // Filter says maybe — the cheap fast path defers the
1137                    // authoritative read to `get`. Count as a hit prospect.
1138                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
1139                    CachePresence::MaybePresent
1140                } else {
1141                    // Filter says no — definitively absent (no
1142                    // false-negatives).
1143                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1144                    CachePresence::Absent
1145                }
1146            }
1147            Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1148        }
1149    }
1150
1151    /// Node-local invalidation for one exact cache key.
1152    ///
1153    /// This does not propagate to replicas. Cluster-wide invalidation is a
1154    /// future contract; callers that need cross-node coherence must rely on the
1155    /// underlying write reaching each node and triggering local eviction there.
1156    pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1157        if !self.namespace_exists(namespace) {
1158            return 0;
1159        }
1160        let cache_key = BlobCacheKey::new(namespace, key);
1161        let shard_idx = self.shard_index(&cache_key);
1162        let mut shard = self.shards[shard_idx].write();
1163        let removed = shard.remove(&cache_key);
1164
1165        if let Some(entry) = removed {
1166            self.record_invalidated_entry(&cache_key, &entry);
1167            drop(shard);
1168            1
1169        } else {
1170            drop(shard);
1171            self.l2
1172                .as_ref()
1173                .and_then(|l2| l2.delete_key(&cache_key))
1174                .map(|_| {
1175                    self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1176                    1
1177                })
1178                .unwrap_or(0)
1179        }
1180    }
1181
1182    /// Node-local invalidation for keys with a namespace-local prefix.
1183    pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1184        if !self.namespace_exists(namespace) {
1185            return 0;
1186        }
1187
1188        let mut count = 0usize;
1189        for shard in &self.shards {
1190            let mut shard = shard.write();
1191            let keys = shard
1192                .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1193            for key in keys {
1194                if let Some(entry) = shard.remove(&key) {
1195                    self.record_invalidated_entry(&key, &entry);
1196                    count += 1;
1197                }
1198            }
1199        }
1200
1201        let l2_count = self
1202            .l2
1203            .as_ref()
1204            .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1205        if l2_count > count {
1206            self.stats
1207                .invalidations
1208                .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1209        }
1210        count.max(l2_count)
1211    }
1212
1213    /// Node-local batched invalidation for all entries carrying any of `tags`.
1214    ///
1215    /// Locks each affected shard once per call, so a batched invalidation
1216    /// from a downstream adapter (#143) does not multiply lock acquisitions
1217    /// the way N singular calls would.
1218    pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1219        self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1220    }
1221
1222    /// Node-local batched invalidation for all entries carrying any of `dependencies`.
1223    pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1224        self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1225    }
1226
1227    /// Node-local invalidation for all entries carrying `tag`.
1228    #[deprecated(
1229        since = "0.1.0",
1230        note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1231    )]
1232    pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1233        self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1234    }
1235
1236    /// Node-local invalidation for all entries carrying `dependency`.
1237    #[deprecated(
1238        since = "0.1.0",
1239        note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1240    )]
1241    pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1242        self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1243    }
1244
1245    /// O(1) foreground namespace flush.
1246    ///
1247    /// The foreground path only bumps a namespace generation. Old entries become
1248    /// invisible immediately and are physically removed by later cache access or
1249    /// a future sweeper.
1250    pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1251        if !self.namespace_exists(namespace) {
1252            return false;
1253        }
1254        let mut generations = self.namespace_generations.write();
1255        let generation = generations.entry(namespace.to_string()).or_insert(0);
1256        *generation = generation.saturating_add(1);
1257        self.namespace_flush_epoch.fetch_add(1, Ordering::Release);
1258        if let Some(l2) = &self.l2 {
1259            l2.delete_namespace(namespace);
1260        }
1261        self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1262        true
1263    }
1264
1265    /// RAM this cache occupies right now (ADR 0073 §2, the `blob_cache_l1`
1266    /// pool): L1 entry payloads plus L2's resident metadata — its B+ tree
1267    /// pages and Bloom synopsis filters. L2's *disk* extent is not memory and
1268    /// is excluded; `BlobCacheStats::l2_bytes_in_use` reports it separately.
1269    pub fn ram_bytes_in_use(&self) -> u64 {
1270        let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1271        let l2 = self
1272            .l2
1273            .as_ref()
1274            .map_or(0, |l2| l2.resident_metadata_bytes());
1275        l1.saturating_add(l2)
1276    }
1277
1278    pub fn stats(&self) -> BlobCacheStats {
1279        BlobCacheStats {
1280            hits: self.stats.hits.load(Ordering::Relaxed),
1281            misses: self.stats.misses.load(Ordering::Relaxed),
1282            insertions: self.stats.insertions.load(Ordering::Relaxed),
1283            evictions: self.stats.evictions.load(Ordering::Relaxed),
1284            expirations: self.stats.expirations.load(Ordering::Relaxed),
1285            invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1286            namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1287            version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1288            entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1289            bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1290            l1_bytes_max: self.config.l1_bytes_max,
1291            namespace_bytes_max: self.config.namespace_bytes_max,
1292            l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1293            l2_bytes_max: self.config.l2_bytes_max,
1294            l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1295            l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1296            l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1297            synopsis_metadata_reads: self
1298                .l2
1299                .as_ref()
1300                .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1301            synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1302            namespaces: self.namespaces.read().len(),
1303            max_namespaces: self.config.max_namespaces,
1304            promotion_queued: self
1305                .promotion_pool
1306                .get()
1307                .map_or(0, |p| p.metrics().queued_total),
1308            promotion_dropped: self
1309                .promotion_pool
1310                .get()
1311                .map_or(0, |p| p.metrics().dropped_total),
1312            promotion_completed: self
1313                .promotion_pool
1314                .get()
1315                .map_or(0, |p| p.metrics().completed_total),
1316            promotion_queue_depth: self
1317                .promotion_pool
1318                .get()
1319                .map_or(0, |p| p.metrics().queue_depth),
1320            l2_compression_original_bytes: self
1321                .l2
1322                .as_ref()
1323                .map_or(0, |l2| l2.stats_compression_original_bytes()),
1324            l2_compression_stored_bytes: self
1325                .l2
1326                .as_ref()
1327                .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1328            l2_compression_skipped_total: self
1329                .l2
1330                .as_ref()
1331                .map_or(0, |l2| l2.stats_compression_skipped_total()),
1332            l2_bytes_saved_total: self
1333                .l2
1334                .as_ref()
1335                .map_or(0, |l2| l2.stats_bytes_saved_total()),
1336            l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1337            l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1338        }
1339    }
1340
1341    /// Remove a bounded set of due L1 entries.
1342    ///
1343    /// This is the storage primitive behind the cache sweeper. It never
1344    /// locks more than one shard at a time. TTL work comes from each shard's
1345    /// deadline index; a rotating scan runs only while a namespace-generation
1346    /// flush is awaiting physical reclamation.
1347    pub(crate) fn sweep_expired_l1(
1348        &self,
1349        entries_limit: Option<usize>,
1350        millis_limit: Option<u32>,
1351    ) -> BlobCacheSweepOutcome {
1352        let started = Instant::now();
1353        let time_limit = millis_limit.map(|millis| Duration::from_millis(u64::from(millis)));
1354        let now_ms = unix_now_ms();
1355        let hard_entries_limit = entries_limit.unwrap_or(usize::MAX);
1356        let shard_count = self.shards.len();
1357        let first_shard = self.sweep_shard_cursor.fetch_add(1, Ordering::Relaxed) % shard_count;
1358        let flush_epoch = self.namespace_flush_epoch.load(Ordering::Acquire);
1359        let stale_cleanup_pending =
1360            self.namespace_reclaimed_epoch.load(Ordering::Acquire) < flush_epoch;
1361        let mut outcome = BlobCacheSweepOutcome::default();
1362        let mut completed_all_shards = true;
1363
1364        for offset in 0..shard_count {
1365            if outcome.entries_scanned >= hard_entries_limit
1366                || time_limit.is_some_and(|limit| started.elapsed() >= limit)
1367            {
1368                completed_all_shards = false;
1369                break;
1370            }
1371            let shard_index = (first_shard + offset) % shard_count;
1372            let mut shard = self.shards[shard_index].write();
1373            let sweep = shard.sweep_expired(
1374                now_ms,
1375                hard_entries_limit - outcome.entries_scanned,
1376                started,
1377                time_limit,
1378            );
1379            // Finish removal bookkeeping under the shard publication lock.
1380            // Otherwise a concurrent put could publish a fresh value for the
1381            // same key before the old tag/dependency indexes or L2 record are
1382            // removed, making the cleanup erase the fresh entry's metadata.
1383            completed_all_shards &= sweep.completed_cycle;
1384            self.record_sweep(sweep, &mut outcome);
1385
1386            if stale_cleanup_pending
1387                && outcome.entries_scanned < hard_entries_limit
1388                && !time_limit.is_some_and(|limit| started.elapsed() >= limit)
1389            {
1390                // Keep generations stable while this shard is inspected. A
1391                // flush cannot then make a fresh generation look stale.
1392                let namespace_generations = self.namespace_generations.read();
1393                let stale_sweep = shard.sweep_stale_generations(
1394                    &namespace_generations,
1395                    flush_epoch,
1396                    hard_entries_limit - outcome.entries_scanned,
1397                    started,
1398                    time_limit,
1399                );
1400                completed_all_shards &= stale_sweep.completed_cycle;
1401                self.record_sweep(stale_sweep, &mut outcome);
1402            }
1403            drop(shard);
1404        }
1405
1406        if stale_cleanup_pending
1407            && self
1408                .shards
1409                .iter()
1410                .all(|shard| shard.read().stale_cleanup_completed(flush_epoch))
1411        {
1412            self.namespace_reclaimed_epoch
1413                .fetch_max(flush_epoch, Ordering::Release);
1414        }
1415
1416        if outcome.expirations > 0 {
1417            let expirations = u64::try_from(outcome.expirations).unwrap_or(u64::MAX);
1418            self.stats
1419                .expirations
1420                .fetch_add(expirations, Ordering::Relaxed);
1421        }
1422        outcome.completed = completed_all_shards;
1423        outcome.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
1424        outcome
1425    }
1426
1427    /// Remove a bounded set of due L2-only entries from the persistent
1428    /// deadline projection. The internal backend resolves due ids without a
1429    /// collection scan; the compatibility file backend reports no work.
1430    pub(crate) fn sweep_expired_l2(
1431        &self,
1432        entries_limit: usize,
1433        millis_limit: Option<u32>,
1434    ) -> BlobCacheSweepOutcome {
1435        let Some(l2) = &self.l2 else {
1436            return BlobCacheSweepOutcome {
1437                completed: true,
1438                ..BlobCacheSweepOutcome::default()
1439            };
1440        };
1441        let outcome = l2.sweep_expired(unix_now_ms(), entries_limit, millis_limit);
1442        if outcome.expirations > 0 {
1443            self.stats.expirations.fetch_add(
1444                u64::try_from(outcome.expirations).unwrap_or(u64::MAX),
1445                Ordering::Relaxed,
1446            );
1447        }
1448        outcome
1449    }
1450
1451    fn record_sweep(&self, sweep: ShardSweep, outcome: &mut BlobCacheSweepOutcome) {
1452        for (key, entry, _) in &sweep.removed {
1453            self.record_removed_entry(key, entry);
1454            if let Some(l2) = &self.l2 {
1455                l2.delete_key(key);
1456            }
1457        }
1458        outcome.entries_scanned += sweep.entries_scanned;
1459        for (namespace, scanned) in sweep.scanned_by_namespace {
1460            outcome
1461                .by_namespace
1462                .entry(namespace)
1463                .or_default()
1464                .entries_scanned += scanned;
1465        }
1466        for (key, entry, expired) in sweep.removed {
1467            let entry_charge_bytes = u64::try_from(entry.l1_charge_bytes).unwrap_or(u64::MAX);
1468            let namespace_stats = outcome
1469                .by_namespace
1470                .entry(key.namespace.clone())
1471                .or_default();
1472            namespace_stats.entries_evicted += 1;
1473            namespace_stats.bytes_reclaimed = namespace_stats
1474                .bytes_reclaimed
1475                .saturating_add(entry_charge_bytes);
1476            outcome.entries_evicted += 1;
1477            outcome.expirations += usize::from(expired);
1478            outcome.bytes_reclaimed = outcome.bytes_reclaimed.saturating_add(entry_charge_bytes);
1479        }
1480    }
1481
1482    // -- Async promotion (issue #193) ---------------------------------------
1483
1484    /// Initialize the async L2->L1 promotion pool. Must be called on an
1485    /// `Arc<Self>` so the executor closure can hold a `Weak<Self>` (no
1486    /// reference cycle).
1487    ///
1488    /// Idempotent on first call only — `OnceLock` semantics: a second call
1489    /// returns the previously-installed pool unchanged. The returned `Arc`
1490    /// can be used by callers that want to inspect metrics directly; most
1491    /// callers should ignore it and read metrics via `stats()`.
1492    pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1493        let weak: Weak<Self> = Arc::downgrade(self);
1494        let executor: PromotionExecutor = Arc::new(move |req| {
1495            // Upgrade only at execution time. If the cache has been
1496            // dropped, the worker silently no-ops (executor never holds
1497            // a strong ref between calls).
1498            let Some(cache) = weak.upgrade() else {
1499                return Ok(());
1500            };
1501            cache.promote_from_l2(&req)
1502        });
1503        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1504        match self.promotion_pool.set(Arc::clone(&pool)) {
1505            Ok(()) => pool,
1506            // Race: another caller already initialized. Drain ours and
1507            // return the winner. The losing pool's workers are spawned;
1508            // shutdown drains them out gracefully.
1509            Err(losing_pool) => {
1510                losing_pool.shutdown();
1511                Arc::clone(
1512                    self.promotion_pool
1513                        .get()
1514                        .expect("OnceLock set+get inconsistency"),
1515                )
1516            }
1517        }
1518    }
1519
1520    /// Drain and stop the async promotion pool, if enabled. Safe to call
1521    /// from `Drop` impls / test teardown — no-op when the pool was never
1522    /// initialized.
1523    pub fn shutdown_async_promotion(&self) {
1524        if let Some(pool) = self.promotion_pool.get() {
1525            Arc::clone(pool).shutdown();
1526        }
1527    }
1528
1529    /// Test-only escape hatch: schedule outcome of the most recent attempt
1530    /// is internal; tests assert on `stats()` counters instead.
1531    #[cfg(test)]
1532    fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1533        self.promotion_pool.get().cloned()
1534    }
1535
1536    /// Test-only: install a custom executor (e.g. one that sleeps to
1537    /// expose the hot-path / worker-path latency split). Used by the
1538    /// async-promotion wiring tests in this file.
1539    #[cfg(test)]
1540    fn enable_async_promotion_with_executor(
1541        self: &Arc<Self>,
1542        opts: PoolOpts,
1543        executor: PromotionExecutor,
1544    ) -> Arc<AsyncPromotionPool> {
1545        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1546        let _ = self.promotion_pool.set(Arc::clone(&pool));
1547        pool
1548    }
1549
1550    pub fn config(&self) -> &BlobCacheConfig {
1551        &self.config
1552    }
1553
1554    #[cfg(test)]
1555    fn inject_l2_fault_after_blob_write_once(&self) {
1556        self.l2
1557            .as_ref()
1558            .expect("L2 enabled")
1559            .inject_fault_after_blob_write_once();
1560    }
1561
1562    #[cfg(test)]
1563    fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1564        self.l2
1565            .as_ref()
1566            .expect("L2 enabled")
1567            .inject_synopsis_maybe_present(namespace, key);
1568    }
1569
1570    /// Test-only escape hatch (#192 lane 2/5): synthesise a legacy
1571    /// `V1Raw` L2 entry on disk so the forward-compat read test can
1572    /// verify pre-compression entries still rehydrate.
1573    #[cfg(test)]
1574    fn inject_l2_v1_entry(
1575        &self,
1576        namespace: &str,
1577        key: &str,
1578        payload: &[u8],
1579    ) -> Result<(), CacheError> {
1580        let l2 = self.l2.as_ref().expect("L2 enabled");
1581        let cache_key = BlobCacheKey::new(namespace, key);
1582        l2.inject_v1_entry(&cache_key, payload)
1583    }
1584
1585    fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1586        let max = policy
1587            .max_blob_bytes_value()
1588            .unwrap_or(self.config.l1_bytes_max);
1589        if size > max {
1590            Err(CacheError::BlobTooLarge { size, max })
1591        } else {
1592            Ok(())
1593        }
1594    }
1595
1596    fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1597        let keys = metadata.len();
1598        let bytes = metadata
1599            .iter()
1600            .map(|(key, value)| key.len() + value.len())
1601            .sum::<usize>();
1602        if keys > self.config.content_metadata_keys_max
1603            || bytes > self.config.content_metadata_bytes_max
1604        {
1605            Err(CacheError::MetadataTooLarge {
1606                keys,
1607                bytes,
1608                max_keys: self.config.content_metadata_keys_max,
1609                max_bytes: self.config.content_metadata_bytes_max,
1610            })
1611        } else {
1612            Ok(())
1613        }
1614    }
1615
1616    fn rehydrate_l2_entry(
1617        &self,
1618        key: &BlobCacheKey,
1619        now_ms: u64,
1620        namespace_generation: u64,
1621        shard_idx: usize,
1622    ) -> Option<BlobCacheHit> {
1623        let l2 = self.l2.as_ref()?;
1624        let entry = l2.get(key, now_ms, namespace_generation)?;
1625        let hit = entry.hit();
1626        if self.should_promote_l2_hit(key, shard_idx) {
1627            self.do_l1_promotion_sync(key, entry, shard_idx);
1628        }
1629        Some(hit)
1630    }
1631
1632    fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1633        match self.config.l2_promotion_policy {
1634            L2PromotionPolicy::Immediate => true,
1635            L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1636                .write()
1637                .l2_hit_should_promote_on_second_hit(key),
1638        }
1639    }
1640
1641    /// Pure L1 install bookkeeping: shard write-lock, byte accounting,
1642    /// eviction loop. Extracted so the async promotion pool can call it
1643    /// from a worker (issue #193, lane 1/5).
1644    ///
1645    /// This is intentionally side-effect-only — it does not touch hit/miss
1646    /// stats (the caller already counted the hit) and does not return the
1647    /// `BlobCacheHit` (the caller already handed bytes to the user).
1648    fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1649        let entry_charge = entry.l1_charge_bytes;
1650        let mut shard = self.shards[shard_idx].write();
1651        let outcome = shard.insert(key.clone(), entry);
1652        let old_charge = outcome
1653            .old_entry
1654            .as_ref()
1655            .map_or(0, |entry| entry.l1_charge_bytes);
1656        self.adjust_namespace_bytes(&key.namespace, old_charge, entry_charge);
1657        if entry_charge >= old_charge {
1658            self.bytes_in_use
1659                .fetch_add(entry_charge - old_charge, Ordering::Relaxed);
1660        } else {
1661            self.bytes_in_use
1662                .fetch_sub(old_charge - entry_charge, Ordering::Relaxed);
1663        }
1664        drop(shard);
1665        self.evict_namespace_until_within_budget(&key.namespace, shard_idx);
1666        self.evict_until_within_budget(shard_idx);
1667    }
1668
1669    /// Worker-side promotion path: re-fetch the entry from L2 and run the
1670    /// L1 install bookkeeping. Idempotent — re-promoting a key that the
1671    /// hot path already promoted (race with another reader) is harmless.
1672    /// Returns `Err` only when L2 is unavailable or the key is no longer
1673    /// present at L2 (silently treated as a no-op upstream).
1674    fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1675        let l2 = self
1676            .l2
1677            .as_ref()
1678            .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1679        let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1680        let now_ms = unix_now_ms();
1681        let namespace_generation = self.current_generation(req.namespace.as_str());
1682        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1683            let shard_idx = self.shard_index(&cache_key);
1684            self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1685        }
1686        Ok(())
1687    }
1688
1689    fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1690        {
1691            let namespaces = self.namespaces.read();
1692            if namespaces.contains(namespace) {
1693                return Ok(());
1694            }
1695        }
1696        let mut namespaces = self.namespaces.write();
1697        if namespaces.contains(namespace) {
1698            return Ok(());
1699        }
1700        if namespaces.len() >= self.config.max_namespaces {
1701            return Err(CacheError::TooManyNamespaces {
1702                max: self.config.max_namespaces,
1703            });
1704        }
1705        namespaces.insert(namespace.to_string());
1706        self.namespace_generations
1707            .write()
1708            .entry(namespace.to_string())
1709            .or_insert(0);
1710        self.namespace_bytes_in_use
1711            .write()
1712            .entry(namespace.to_string())
1713            .or_insert(0);
1714        Ok(())
1715    }
1716
1717    fn namespace_exists(&self, namespace: &str) -> bool {
1718        self.namespaces.read().contains(namespace)
1719            || self
1720                .l2
1721                .as_ref()
1722                .is_some_and(|l2| l2.has_namespace(namespace))
1723    }
1724
1725    pub(crate) fn current_generation(&self, namespace: &str) -> u64 {
1726        self.namespace_generations
1727            .read()
1728            .get(namespace)
1729            .copied()
1730            .unwrap_or(0)
1731    }
1732
1733    fn index_entry(
1734        &self,
1735        key: &BlobCacheKey,
1736        tags: &BTreeSet<String>,
1737        dependencies: &BTreeSet<String>,
1738    ) {
1739        if !tags.is_empty() {
1740            let mut index = self.tag_index.write();
1741            for tag in tags {
1742                index
1743                    .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1744                    .or_default()
1745                    .insert(key.clone());
1746            }
1747        }
1748        if !dependencies.is_empty() {
1749            let mut index = self.dependency_index.write();
1750            for dependency in dependencies {
1751                index
1752                    .entry(ScopedLabel::new(
1753                        key.namespace.as_str(),
1754                        dependency.as_str(),
1755                    ))
1756                    .or_default()
1757                    .insert(key.clone());
1758            }
1759        }
1760    }
1761
1762    fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1763        Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1764        Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1765    }
1766
1767    fn remove_indexed_labels(
1768        index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1769        key: &BlobCacheKey,
1770        labels: &BTreeSet<String>,
1771    ) {
1772        if labels.is_empty() {
1773            return;
1774        }
1775        let mut index = index.write();
1776        for label in labels {
1777            let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1778            let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1779                keys.remove(key);
1780                keys.is_empty()
1781            } else {
1782                false
1783            };
1784            if should_remove {
1785                index.remove(&scoped);
1786            }
1787        }
1788    }
1789
1790    fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1791        self.bytes_in_use
1792            .fetch_sub(entry.l1_charge_bytes, Ordering::Relaxed);
1793        self.adjust_namespace_bytes(&key.namespace, entry.l1_charge_bytes, 0);
1794        self.deindex_entry(key, entry);
1795    }
1796
1797    fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1798        self.record_removed_entry(key, entry);
1799        if let Some(l2) = &self.l2 {
1800            l2.delete_key(key);
1801        }
1802        self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1803    }
1804
1805    fn invalidate_indexed_many(
1806        &self,
1807        namespace: &str,
1808        labels: &[&str],
1809        kind: IndexedKind,
1810    ) -> usize {
1811        if labels.is_empty() || !self.namespace_exists(namespace) {
1812            return 0;
1813        }
1814
1815        // Snapshot the candidate keys for every label up front so the
1816        // shard-locking pass below sees a stable set. We deduplicate by
1817        // BlobCacheKey so a key tagged with multiple invalidated labels is
1818        // still removed (and counted) exactly once.
1819        let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1820        {
1821            let index = match kind {
1822                IndexedKind::Tag => self.tag_index.read(),
1823                IndexedKind::Dependency => self.dependency_index.read(),
1824            };
1825            for label in labels {
1826                let scoped = ScopedLabel::new(namespace, *label);
1827                if let Some(keys) = index.get(&scoped) {
1828                    for key in keys {
1829                        candidates
1830                            .entry(key.clone())
1831                            .or_default()
1832                            .insert((*label).to_string());
1833                    }
1834                }
1835            }
1836        }
1837
1838        // Group candidates by shard so each shard lock is taken at most
1839        // once per call.
1840        let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1841        for (key, matched_labels) in candidates {
1842            let shard_idx = self.shard_index(&key);
1843            by_shard
1844                .entry(shard_idx)
1845                .or_default()
1846                .push((key, matched_labels));
1847        }
1848
1849        let mut count = 0usize;
1850        for (shard_idx, keys) in by_shard {
1851            let mut shard = self.shards[shard_idx].write();
1852            for (key, matched_labels) in keys {
1853                let still_matches = match kind {
1854                    IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1855                    IndexedKind::Dependency => {
1856                        shard.entry_has_any_dependency(&key, &matched_labels)
1857                    }
1858                };
1859                if still_matches {
1860                    if let Some(entry) = shard.remove(&key) {
1861                        self.record_invalidated_entry(&key, &entry);
1862                        count += 1;
1863                    }
1864                }
1865            }
1866        }
1867        // L2's stable record format predates tag/dependency metadata. Never
1868        // allow an L2-only entry to survive a label invalidation and later
1869        // resurrect. Until that format grows label indexes, conservatively
1870        // flush the remaining L2 namespace while retaining unrelated L1 hits.
1871        let l2_count = self
1872            .l2
1873            .as_ref()
1874            .map_or(0, |l2| l2.delete_namespace(namespace));
1875        if l2_count > 0 {
1876            self.stats
1877                .invalidations
1878                .fetch_add(l2_count as u64, Ordering::Relaxed);
1879        }
1880        count.saturating_add(l2_count)
1881    }
1882
1883    fn shard_index(&self, key: &BlobCacheKey) -> usize {
1884        self.shard_index_parts(&key.namespace, &key.key)
1885    }
1886
1887    fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1888        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1889        namespace.hash(&mut hasher);
1890        key.hash(&mut hasher);
1891        (hasher.finish() as usize) % self.shards.len()
1892    }
1893
1894    fn check_version(
1895        &self,
1896        shard: &Shard,
1897        key: &BlobCacheKey,
1898        attempted: Option<u64>,
1899        namespace_generation: u64,
1900    ) -> Result<(), CacheError> {
1901        let Some(attempted) = attempted else {
1902            return Ok(());
1903        };
1904        let Some(existing) = shard.existing_version(key, namespace_generation) else {
1905            return Ok(());
1906        };
1907        if existing >= attempted {
1908            self.stats
1909                .version_mismatches
1910                .fetch_add(1, Ordering::Relaxed);
1911            Err(CacheError::VersionMismatch {
1912                existing,
1913                attempted,
1914            })
1915        } else {
1916            Ok(())
1917        }
1918    }
1919
1920    fn evict_until_within_budget(&self, preferred_start: usize) {
1921        while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
1922            let mut evicted = false;
1923            for offset in 0..self.shards.len() {
1924                let idx = (preferred_start + offset) % self.shards.len();
1925                let mut shard = self.shards[idx].write();
1926                if let Some((key, entry)) = shard.evict_one() {
1927                    self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1928                    self.record_removed_entry(&key, &entry);
1929                    drop(shard);
1930                    evicted = true;
1931                    break;
1932                }
1933            }
1934            if !evicted {
1935                break;
1936            }
1937        }
1938    }
1939
1940    fn evict_namespace_until_within_budget(&self, namespace: &str, preferred_start: usize) {
1941        while self.namespace_bytes(namespace) > self.config.namespace_bytes_max {
1942            let mut evicted = false;
1943            for offset in 0..self.shards.len() {
1944                let idx = (preferred_start + offset) % self.shards.len();
1945                let mut shard = self.shards[idx].write();
1946                if let Some((key, entry)) = shard.remove_one_from_namespace(namespace) {
1947                    self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1948                    self.record_removed_entry(&key, &entry);
1949                    drop(shard);
1950                    evicted = true;
1951                    break;
1952                }
1953            }
1954            if !evicted {
1955                break;
1956            }
1957        }
1958    }
1959
1960    fn namespace_bytes(&self, namespace: &str) -> usize {
1961        self.namespace_bytes_in_use
1962            .read()
1963            .get(namespace)
1964            .copied()
1965            .unwrap_or(0)
1966    }
1967
1968    fn adjust_namespace_bytes(&self, namespace: &str, old_charge: usize, new_charge: usize) {
1969        let mut usage = self.namespace_bytes_in_use.write();
1970        let bytes = usage.entry(namespace.to_string()).or_insert(0);
1971        *bytes = bytes.saturating_sub(old_charge).saturating_add(new_charge);
1972    }
1973}
1974
1975#[derive(Debug, Default)]
1976pub(crate) struct BlobCacheSweepOutcome {
1977    pub(crate) entries_scanned: usize,
1978    pub(crate) entries_evicted: usize,
1979    pub(crate) expirations: usize,
1980    pub(crate) bytes_reclaimed: u64,
1981    pub(crate) elapsed_ms: u32,
1982    pub(crate) completed: bool,
1983    pub(crate) by_namespace: BTreeMap<String, BlobCacheNamespaceSweep>,
1984}
1985
1986#[derive(Debug, Default)]
1987pub(crate) struct BlobCacheNamespaceSweep {
1988    pub(crate) entries_scanned: usize,
1989    pub(crate) entries_evicted: usize,
1990    pub(crate) bytes_reclaimed: u64,
1991}
1992
1993fn unix_now_ms() -> u64 {
1994    SystemTime::now()
1995        .duration_since(UNIX_EPOCH)
1996        .map(|duration| duration.as_millis() as u64)
1997        .unwrap_or(0)
1998}
1999
2000impl Default for BlobCache {
2001    fn default() -> Self {
2002        Self::with_defaults()
2003    }
2004}
2005
2006#[cfg(test)]
2007mod tests;