Skip to main content

reddb_server/storage/cache/blob/
cache.rs

1//! Byte-oriented Blob Cache.
2//!
3//! This is the first internal tracer for RedDB's exact-key blob cache. It is
4//! intentionally L1-only: a sharded, byte-bounded, in-process cache with SIEVE
5//! eviction, namespace caps, and opaque content metadata. Durable L2 storage,
6//! dependency invalidation, and public APIs land in follow-up slices.
7
8use super::config::{BlobCacheConfig, L2Compression, L2PromotionPolicy};
9use super::entry::{effective_expires_at_unix_ms, jitter_seed, Entry};
10use super::l2::BlobCacheL2;
11use super::shard::{InsertOutcome, Lookup, Shard};
12
13use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
14use std::hash::{Hash, Hasher};
15#[cfg(test)]
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18use std::sync::{Arc, OnceLock, Weak};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use indexmap::Equivalent;
22use parking_lot::RwLock;
23
24use super::super::compressor::{CompressOpts, Compressed, L2BlobCompressor};
25use super::super::extended_ttl::ExtendedTtlPolicy;
26use super::super::promotion_pool::{
27    AsyncPromotionPool, PoolOpts, PromotionExecutor, PromotionRequest,
28};
29
30// Test-only thread-local counter of how many times
31// `EffectiveExpiry::compute` is invoked from `Shard::get`. Thread-local
32// (rather than a global atomic) so the off-fast-path test does not race
33// with other tests in the harness's parallel executor.
34#[cfg(test)]
35thread_local! {
36    static PUT_PREPARED_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> = const { std::cell::RefCell::new(None) };
37    pub(super) static EFFECTIVE_EXPIRY_COMPUTE_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum CacheError {
42    BlobTooLarge {
43        size: usize,
44        max: usize,
45    },
46    MetadataTooLarge {
47        keys: usize,
48        bytes: usize,
49        max_keys: usize,
50        max_bytes: usize,
51    },
52    TooManyNamespaces {
53        max: usize,
54    },
55    VersionMismatch {
56        existing: u64,
57        attempted: u64,
58    },
59    L2Full {
60        size: u64,
61        max: u64,
62    },
63    L2Io(String),
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub(super) struct BlobCacheKey {
68    pub(super) namespace: String,
69    pub(super) key: String,
70}
71
72impl BlobCacheKey {
73    pub(super) fn new(namespace: impl Into<String>, key: impl Into<String>) -> Self {
74        Self {
75            namespace: namespace.into(),
76            key: key.into(),
77        }
78    }
79
80    pub(super) fn borrowed<'a>(namespace: &'a str, key: &'a str) -> BlobCacheKeyRef<'a> {
81        BlobCacheKeyRef { namespace, key }
82    }
83}
84
85pub(super) struct BlobCacheKeyRef<'a> {
86    pub(super) namespace: &'a str,
87    pub(super) key: &'a str,
88}
89
90impl Hash for BlobCacheKeyRef<'_> {
91    fn hash<H: Hasher>(&self, state: &mut H) {
92        self.namespace.hash(state);
93        self.key.hash(state);
94    }
95}
96
97impl Equivalent<BlobCacheKey> for BlobCacheKeyRef<'_> {
98    fn equivalent(&self, key: &BlobCacheKey) -> bool {
99        self.namespace == key.namespace && self.key == key.key
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104struct ScopedLabel {
105    namespace: String,
106    label: String,
107}
108
109impl ScopedLabel {
110    fn new(namespace: impl Into<String>, label: impl Into<String>) -> Self {
111        Self {
112            namespace: namespace.into(),
113            label: label.into(),
114        }
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct BlobCacheHit {
120    pub(super) bytes: Arc<[u8]>,
121    pub(super) content_metadata: Option<Arc<BTreeMap<String, String>>>,
122    pub(super) version: Option<u64>,
123    /// `Some(remaining_ms)` when the hit came from the stale-while-revalidate
124    /// window of an `ExtendedTtlPolicy`; `None` when the entry was fresh.
125    /// Boolean staleness is just `.is_some()`.
126    pub(super) stale_window_remaining_ms: Option<u64>,
127}
128
129impl BlobCacheHit {
130    pub(crate) fn new(
131        bytes: Arc<[u8]>,
132        content_metadata: Option<Arc<BTreeMap<String, String>>>,
133        version: Option<u64>,
134    ) -> Self {
135        Self {
136            bytes,
137            content_metadata,
138            version,
139            stale_window_remaining_ms: None,
140        }
141    }
142
143    pub(crate) fn new_stale(
144        bytes: Arc<[u8]>,
145        content_metadata: Option<Arc<BTreeMap<String, String>>>,
146        version: Option<u64>,
147        window_remaining_ms: u64,
148    ) -> Self {
149        Self {
150            bytes,
151            content_metadata,
152            version,
153            stale_window_remaining_ms: Some(window_remaining_ms),
154        }
155    }
156
157    /// Cached payload, refcounted so duplicate readers share the buffer.
158    pub fn bytes(&self) -> &Arc<[u8]> {
159        &self.bytes
160    }
161
162    /// Convenience accessor returning a `&[u8]` view into [`bytes`](Self::bytes).
163    pub fn value(&self) -> &[u8] {
164        &self.bytes
165    }
166
167    /// Opaque content metadata captured on `put`.
168    pub fn content_metadata(&self) -> &BTreeMap<String, String> {
169        static EMPTY: BTreeMap<String, String> = BTreeMap::new();
170        self.content_metadata.as_deref().unwrap_or(&EMPTY)
171    }
172
173    /// Optional CAS / freshness version stamped on `put`.
174    pub fn version(&self) -> Option<u64> {
175        self.version
176    }
177
178    /// `true` when the hit was served from the stale-while-revalidate window
179    /// of an `ExtendedTtlPolicy`. Always `false` when the extended policy is
180    /// `off()` or the entry was within its hard expiry.
181    pub fn is_stale(&self) -> bool {
182        self.stale_window_remaining_ms.is_some()
183    }
184
185    /// Remaining stale-window milliseconds when [`is_stale`](Self::is_stale)
186    /// is `true`; `None` when the hit was fresh.
187    pub fn stale_window_remaining_ms(&self) -> Option<u64> {
188        self.stale_window_remaining_ms
189    }
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq)]
193pub struct BlobCachePut {
194    pub bytes: Vec<u8>,
195    pub content_metadata: BTreeMap<String, String>,
196    pub tags: BTreeSet<String>,
197    pub dependencies: BTreeSet<String>,
198    pub policy: BlobCachePolicy,
199}
200
201impl BlobCachePut {
202    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
203        Self {
204            bytes: bytes.into(),
205            content_metadata: BTreeMap::new(),
206            tags: BTreeSet::new(),
207            dependencies: BTreeSet::new(),
208            policy: BlobCachePolicy::default(),
209        }
210    }
211
212    pub fn with_content_metadata(mut self, content_metadata: BTreeMap<String, String>) -> Self {
213        self.content_metadata = content_metadata;
214        self
215    }
216
217    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
218        self.tags = tags.into_iter().map(Into::into).collect();
219        self
220    }
221
222    pub fn with_dependencies(
223        mut self,
224        dependencies: impl IntoIterator<Item = impl Into<String>>,
225    ) -> Self {
226        self.dependencies = dependencies.into_iter().map(Into::into).collect();
227        self
228    }
229
230    pub fn with_policy(mut self, policy: BlobCachePolicy) -> Self {
231        self.policy = policy;
232        self
233    }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum L1Admission {
238    Always,
239    Auto,
240    Never,
241}
242
243/// Three-valued answer for [`BlobCache::exists`].
244///
245/// Today the implementation always returns [`Present`](Self::Present) or
246/// [`Absent`](Self::Absent) — it tracks the answer authoritatively. The
247/// [`MaybePresent`](Self::MaybePresent) variant exists in the type so the
248/// upcoming Bloom synopsis (#146) can answer "probably yes" without forcing
249/// a metadata read, all without breaking the `exists` contract.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum CachePresence {
252    /// The cache holds a live entry for this key.
253    Present,
254    /// The cache definitely does not hold this key (negative cache hit).
255    Absent,
256    /// A probabilistic synopsis cannot rule the key out without a deeper
257    /// lookup. Treat as a hit prospect: the caller should fetch.
258    MaybePresent,
259}
260
261impl From<bool> for CachePresence {
262    fn from(present: bool) -> Self {
263        if present {
264            CachePresence::Present
265        } else {
266            CachePresence::Absent
267        }
268    }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct BlobCachePolicy {
273    ttl_ms: Option<u64>,
274    expires_at_unix_ms: Option<u64>,
275    max_blob_bytes: Option<usize>,
276    l1_admission: L1Admission,
277    priority: u8,
278    version: Option<u64>,
279    /// Extended TTL knobs (idle / stale-while-revalidate / jitter).
280    /// Defaults to [`ExtendedTtlPolicy::off`] so existing call sites and
281    /// stored entries continue to behave with hard-expiry-only semantics.
282    /// Wired into [`BlobCache::get`] behind the
283    /// `cache.blob.policy.extended` config knob (#194).
284    extended: ExtendedTtlPolicy,
285}
286
287impl Default for BlobCachePolicy {
288    fn default() -> Self {
289        Self {
290            ttl_ms: None,
291            expires_at_unix_ms: None,
292            max_blob_bytes: None,
293            l1_admission: L1Admission::Auto,
294            priority: 128,
295            version: None,
296            extended: ExtendedTtlPolicy::off(),
297        }
298    }
299}
300
301impl BlobCachePolicy {
302    // ----- builder-style setters (consuming) -----------------------------
303
304    pub fn ttl_ms(mut self, ttl_ms: u64) -> Self {
305        self.ttl_ms = Some(ttl_ms);
306        self
307    }
308
309    pub fn expires_at_unix_ms(mut self, expires_at_unix_ms: u64) -> Self {
310        self.expires_at_unix_ms = Some(expires_at_unix_ms);
311        self
312    }
313
314    pub fn max_blob_bytes(mut self, max_blob_bytes: usize) -> Self {
315        self.max_blob_bytes = Some(max_blob_bytes);
316        self
317    }
318
319    pub fn l1_admission(mut self, l1_admission: L1Admission) -> Self {
320        self.l1_admission = l1_admission;
321        self
322    }
323
324    pub fn priority(mut self, priority: u8) -> Self {
325        self.priority = priority;
326        self
327    }
328
329    pub fn version(mut self, version: u64) -> Self {
330        self.version = Some(version);
331        self
332    }
333
334    /// Replace the extended TTL knobs in one chainable call. Defaults to
335    /// [`ExtendedTtlPolicy::off`]; setting an active policy turns on the
336    /// idle / stale-serve / jitter behaviours in [`BlobCache::get`] and
337    /// [`BlobCache::put`] for entries written with this policy.
338    pub fn extended(mut self, extended: ExtendedTtlPolicy) -> Self {
339        self.extended = extended;
340        self
341    }
342
343    // ----- read-back accessors -------------------------------------------
344    //
345    // Setter methods consume `self` and return `Self`, so they cannot share
346    // a name with `&self` getters. The `*_value` suffix keeps both surfaces
347    // available without renaming the public builder API.
348
349    pub fn ttl_ms_value(&self) -> Option<u64> {
350        self.ttl_ms
351    }
352
353    pub fn expires_at_unix_ms_value(&self) -> Option<u64> {
354        self.expires_at_unix_ms
355    }
356
357    pub fn max_blob_bytes_value(&self) -> Option<usize> {
358        self.max_blob_bytes
359    }
360
361    pub fn l1_admission_value(&self) -> L1Admission {
362        self.l1_admission
363    }
364
365    pub fn priority_value(&self) -> u8 {
366        self.priority
367    }
368
369    pub fn version_value(&self) -> Option<u64> {
370        self.version
371    }
372
373    /// Read-back accessor for the extended TTL knobs. Mirrors the
374    /// `*_value` getter pattern used by every other [`BlobCachePolicy`]
375    /// field (#151 — fields are private; readers go through getters).
376    pub fn extended_value(&self) -> ExtendedTtlPolicy {
377        self.extended
378    }
379}
380
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
382pub struct BlobCacheStats {
383    pub(super) hits: u64,
384    pub(super) misses: u64,
385    pub(super) insertions: u64,
386    pub(super) evictions: u64,
387    pub(super) expirations: u64,
388    pub(super) invalidations: u64,
389    pub(super) namespace_flushes: u64,
390    pub(super) version_mismatches: u64,
391    pub(super) entries: usize,
392    pub(super) bytes_in_use: usize,
393    pub(super) l1_bytes_max: usize,
394    pub(super) l2_bytes_in_use: u64,
395    pub(super) l2_bytes_max: u64,
396    pub(super) l2_full_rejections: u64,
397    pub(super) l2_metadata_reads: u64,
398    pub(super) l2_negative_skips: u64,
399    /// Times the per-namespace Bloom synopsis answered `MaybePresent` but the
400    /// authoritative L2 metadata B+ tree said `Absent` (the false-positive
401    /// cost of the probabilistic synopsis).
402    pub(super) synopsis_metadata_reads: u64,
403    /// Total bytes used by all per-namespace Bloom synopsis filters.
404    pub(super) synopsis_bytes: u64,
405    pub(super) namespaces: usize,
406    pub(super) max_namespaces: usize,
407    /// Async promotion pool counters (issue #193). All zero when the
408    /// pool is not enabled (default — `cache.blob.async_promotion = "off"`).
409    pub(super) promotion_queued: u64,
410    pub(super) promotion_dropped: u64,
411    pub(super) promotion_completed: u64,
412    pub(super) promotion_queue_depth: usize,
413    /// Numerator of the L2 compression ratio: sum of `original_len` over
414    /// entries that actually compressed (#192). Stored as the ratio's
415    /// component so [`BlobCacheStats`] stays `Eq` (avoids `f64` fields).
416    pub(super) l2_compression_original_bytes: u64,
417    /// Denominator of the L2 compression ratio: sum of `stored_len` over
418    /// entries that actually compressed.
419    pub(super) l2_compression_stored_bytes: u64,
420    /// Counter of L2 entries the compressor returned as `Raw` (any reason).
421    pub(super) l2_compression_skipped_total: u64,
422    /// Cumulative `(original_len - stored_len)` across compressed entries.
423    pub(super) l2_bytes_saved_total: u64,
424    /// Counter — L1 hits that served a stale entry from the SWR window of
425    /// an `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
426    pub(super) l1_stale_serves_total: u64,
427    /// Counter — L1 entries evicted by the idle-TTL gate of an
428    /// `ExtendedTtlPolicy` (#194). Stays 0 when extended is off.
429    pub(super) l1_idle_evicts_total: u64,
430}
431
432impl BlobCacheStats {
433    /// Number of `get`/`exists` calls that resolved to `Present` /
434    /// `MaybePresent`. Both count as hit prospects.
435    pub fn hits(&self) -> u64 {
436        self.hits
437    }
438
439    /// Number of `get`/`exists` calls that resolved to `Absent`.
440    pub fn misses(&self) -> u64 {
441        self.misses
442    }
443
444    pub fn insertions(&self) -> u64 {
445        self.insertions
446    }
447
448    pub fn evictions(&self) -> u64 {
449        self.evictions
450    }
451
452    pub fn expirations(&self) -> u64 {
453        self.expirations
454    }
455
456    pub fn invalidations(&self) -> u64 {
457        self.invalidations
458    }
459
460    pub fn namespace_flushes(&self) -> u64 {
461        self.namespace_flushes
462    }
463
464    pub fn version_mismatches(&self) -> u64 {
465        self.version_mismatches
466    }
467
468    pub fn entries(&self) -> usize {
469        self.entries
470    }
471
472    /// Bytes resident in L1. Returned as `u64` for symmetry with
473    /// [`l2_bytes_in_use`](Self::l2_bytes_in_use); upcast is lossless.
474    pub fn bytes_in_use(&self) -> u64 {
475        self.bytes_in_use as u64
476    }
477
478    pub fn l1_bytes_max(&self) -> usize {
479        self.l1_bytes_max
480    }
481
482    pub fn l2_bytes_in_use(&self) -> u64 {
483        self.l2_bytes_in_use
484    }
485
486    pub fn l2_bytes_max(&self) -> u64 {
487        self.l2_bytes_max
488    }
489
490    pub fn l2_full_rejections(&self) -> u64 {
491        self.l2_full_rejections
492    }
493
494    pub fn l2_metadata_reads(&self) -> u64 {
495        self.l2_metadata_reads
496    }
497
498    pub fn l2_negative_skips(&self) -> u64 {
499        self.l2_negative_skips
500    }
501
502    /// Times the Bloom synopsis answered `MaybePresent` but the authoritative
503    /// L2 metadata B+ tree said `Absent`. This is the cost of the
504    /// probabilistic synopsis: a counter for the false-positive rate in
505    /// production. Negative answers from the filter never trigger a metadata
506    /// read (see [`l2_negative_skips`](Self::l2_negative_skips)).
507    pub fn synopsis_metadata_reads(&self) -> u64 {
508        self.synopsis_metadata_reads
509    }
510
511    /// Total bytes used by all per-namespace Bloom synopsis filters.
512    pub fn synopsis_bytes(&self) -> u64 {
513        self.synopsis_bytes
514    }
515
516    pub fn namespaces(&self) -> usize {
517        self.namespaces
518    }
519
520    pub fn max_namespaces(&self) -> usize {
521        self.max_namespaces
522    }
523
524    /// Total promotion requests successfully enqueued by `get` since boot.
525    /// `0` when async promotion is disabled.
526    pub fn promotion_queued(&self) -> u64 {
527        self.promotion_queued
528    }
529
530    /// Total promotion requests dropped on queue saturation since boot.
531    /// `0` when async promotion is disabled.
532    pub fn promotion_dropped(&self) -> u64 {
533        self.promotion_dropped
534    }
535
536    /// Total promotion requests executed by workers since boot.
537    /// `0` when async promotion is disabled.
538    pub fn promotion_completed(&self) -> u64 {
539        self.promotion_completed
540    }
541
542    /// Snapshot of pending requests in the promotion queue.
543    /// `0` when async promotion is disabled.
544    pub fn promotion_queue_depth(&self) -> usize {
545        self.promotion_queue_depth
546    }
547
548    /// Running average of `original_len / stored_len` for L2 entries that
549    /// the compressor actually shrank (#192). Returns `1.0` when no
550    /// compressed entry has been observed yet, regardless of how many
551    /// `Raw` entries have passed through (callers should pair this with
552    /// [`l2_compression_skipped_total`](Self::l2_compression_skipped_total)
553    /// to interpret).
554    pub fn l2_compression_ratio_observed(&self) -> f64 {
555        if self.l2_compression_stored_bytes == 0 {
556            return 1.0;
557        }
558        self.l2_compression_original_bytes as f64 / self.l2_compression_stored_bytes as f64
559    }
560
561    /// Number of L2 entries the compressor returned as `Raw` since boot —
562    /// any reason: payload below `min_bytes`, content type already
563    /// compressed, ratio gate fired, or `cache.blob.l2_compression = "off"`.
564    pub fn l2_compression_skipped_total(&self) -> u64 {
565        self.l2_compression_skipped_total
566    }
567
568    /// Cumulative `(original_len - stored_len)` across all L2 entries the
569    /// compressor shrank. Operators read this to size the L2 budget
570    /// multiplier from real workloads.
571    pub fn l2_bytes_saved_total(&self) -> u64 {
572        self.l2_bytes_saved_total
573    }
574
575    /// Counter — L1 hits served as stale by the SWR window of an
576    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
577    /// active extended policy.
578    pub fn l1_stale_serves_total(&self) -> u64 {
579        self.l1_stale_serves_total
580    }
581
582    /// Counter — L1 entries evicted by the idle-TTL gate of an
583    /// `ExtendedTtlPolicy` (#194). `0` when no entry was written with an
584    /// active extended policy.
585    pub fn l1_idle_evicts_total(&self) -> u64 {
586        self.l1_idle_evicts_total
587    }
588}
589
590#[derive(Clone, Copy)]
591enum IndexedKind {
592    Tag,
593    Dependency,
594}
595
596#[derive(Debug)]
597struct AtomicStats {
598    hits: AtomicU64,
599    misses: AtomicU64,
600    insertions: AtomicU64,
601    evictions: AtomicU64,
602    expirations: AtomicU64,
603    invalidations: AtomicU64,
604    namespace_flushes: AtomicU64,
605    version_mismatches: AtomicU64,
606    l2_full_rejections: AtomicU64,
607    /// Counter incremented every time `BlobCache::get` returns a stale
608    /// entry from the SWR window of an `ExtendedTtlPolicy`. Stays at 0
609    /// when extended is `off()` for every entry.
610    l1_stale_serves: AtomicU64,
611    /// Counter incremented every time the idle-TTL gate of an
612    /// `ExtendedTtlPolicy` evicts an L1 entry. Stays at 0 when extended
613    /// is `off()` for every entry.
614    l1_idle_evicts: AtomicU64,
615}
616
617impl AtomicStats {
618    fn new() -> Self {
619        Self {
620            hits: AtomicU64::new(0),
621            misses: AtomicU64::new(0),
622            insertions: AtomicU64::new(0),
623            evictions: AtomicU64::new(0),
624            expirations: AtomicU64::new(0),
625            invalidations: AtomicU64::new(0),
626            namespace_flushes: AtomicU64::new(0),
627            version_mismatches: AtomicU64::new(0),
628            l2_full_rejections: AtomicU64::new(0),
629            l1_stale_serves: AtomicU64::new(0),
630            l1_idle_evicts: AtomicU64::new(0),
631        }
632    }
633}
634
635/// Sharded, byte-bounded blob cache with optional durable L2 backing.
636///
637/// # Concurrency
638///
639/// `BlobCache` is `Send + Sync`. All public methods are safe to call from
640/// multiple threads concurrently. Internal sharding ensures disjoint-key
641/// contention does not serialize: independent keys land on independent
642/// `RwLock<Shard>` instances, and the global indexes (namespace set, tag /
643/// dependency maps) are read-mostly behind their own `RwLock`s.
644///
645/// `BlobCache` is **not** `Clone` — share ownership via `Arc<BlobCache>`.
646///
647/// # Blocking
648///
649/// All methods are synchronous. `put` may perform L2 disk I/O on the
650/// calling thread when an L2 path is configured; tokio callers should wrap
651/// `put` in `spawn_blocking`. `get`, `exists`, and the `invalidate_*`
652/// family touch L2 only on rehydrate / delete paths.
653pub struct BlobCache {
654    config: BlobCacheConfig,
655    shards: Vec<RwLock<Shard>>,
656    namespaces: RwLock<HashSet<String>>,
657    namespace_generations: RwLock<HashMap<String, u64>>,
658    tag_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
659    dependency_index: RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
660    l2: Option<Arc<BlobCacheL2>>,
661    bytes_in_use: AtomicUsize,
662    stats: AtomicStats,
663    /// Optional async L2->L1 promotion pool (issue #193). When `None`,
664    /// `get` performs the L1 promotion synchronously on the read path.
665    /// When set via `enable_async_promotion`, L2 hits return bytes to
666    /// the caller immediately and the L1 install runs on a worker.
667    promotion_pool: OnceLock<Arc<AsyncPromotionPool>>,
668}
669
670// Compile-time guarantee that the documented `Send + Sync` contract above
671// stays in lockstep with the struct's interior. If this ever fails to
672// compile, the docstring is lying — fix the field that broke it, do not
673// remove this assertion.
674const _: fn() = || {
675    fn assert_send_sync<T: Send + Sync>() {}
676    assert_send_sync::<BlobCache>();
677};
678
679impl BlobCache {
680    /// Infallible constructor. Panics if `config.l2_path` is set and the L2
681    /// file cannot be opened — use [`BlobCache::open_with_l2`] instead for
682    /// configs that include an L2 path so boot errors are handled gracefully.
683    pub fn new(config: BlobCacheConfig) -> Self {
684        Self::try_new(config).expect("open blob-cache L2")
685    }
686
687    /// Fallible constructor for configs that include an L2 path.
688    /// Returns `Err(CacheError::L2Io(...))` on invalid path, corrupt control
689    /// sidecar, or any other recoverable I/O failure — the process stays alive.
690    pub fn open_with_l2(config: BlobCacheConfig) -> Result<Self, CacheError> {
691        Self::try_new(config)
692    }
693
694    fn try_new(config: BlobCacheConfig) -> Result<Self, CacheError> {
695        let config = BlobCacheConfig {
696            shard_count: config.shard_count.max(1),
697            ..config
698        };
699        let l2 = config
700            .l2_path
701            .clone()
702            .map(|path| BlobCacheL2::open(path, config.l2_bytes_max))
703            .transpose()?;
704        let shards = (0..config.shard_count)
705            .map(|_| RwLock::new(Shard::new()))
706            .collect();
707        Ok(Self {
708            config,
709            shards,
710            namespaces: RwLock::new(HashSet::new()),
711            namespace_generations: RwLock::new(HashMap::new()),
712            tag_index: RwLock::new(HashMap::new()),
713            dependency_index: RwLock::new(HashMap::new()),
714            l2: l2.map(Arc::new),
715            bytes_in_use: AtomicUsize::new(0),
716            stats: AtomicStats::new(),
717            promotion_pool: OnceLock::new(),
718        })
719    }
720
721    pub fn with_defaults() -> Self {
722        Self::new(BlobCacheConfig::default())
723    }
724
725    /// Path to the L2 metadata B+ tree directory, when L2 is enabled.
726    ///
727    /// Used by the backup orchestrator (`include_blob_cache=true`) so it
728    /// can locate the on-disk L2 tree for tarball / per-file upload, and
729    /// by the runbook procedures in
730    /// `docs/operations/blob-cache-backup-restore.md` §2 / §3 to confirm
731    /// where on disk the cache lives.
732    pub fn l2_path(&self) -> Option<&std::path::Path> {
733        self.config.l2_path.as_deref()
734    }
735
736    pub fn put(
737        &self,
738        namespace: impl Into<String>,
739        key: impl Into<String>,
740        input: BlobCachePut,
741    ) -> Result<(), CacheError> {
742        self.put_at(namespace, key, input, unix_now_ms())
743    }
744
745    fn put_at(
746        &self,
747        namespace: impl Into<String>,
748        key: impl Into<String>,
749        input: BlobCachePut,
750        now_ms: u64,
751    ) -> Result<(), CacheError> {
752        let namespace = namespace.into();
753        let key = BlobCacheKey::new(namespace.clone(), key);
754        self.validate_blob_size(input.bytes.len(), input.policy)?;
755        self.validate_metadata(&input.content_metadata)?;
756        self.ensure_namespace(&namespace)?;
757        let namespace_generation = self.current_generation(&namespace);
758        let tags = input.tags.clone();
759        let dependencies = input.dependencies.clone();
760
761        let shard_idx = self.shard_index(&key);
762        if self.l2.is_some() {
763            self.check_version(
764                &self.shards[shard_idx].read(),
765                &key,
766                input.policy.version_value(),
767                namespace_generation,
768            )?;
769        }
770        let entry = Entry::new(
771            input.bytes,
772            input.content_metadata,
773            input.tags,
774            input.dependencies,
775            input.policy,
776            namespace_generation,
777            now_ms,
778            &namespace,
779            &key.key,
780        );
781        let entry_size = entry.size;
782        let compressed = if self.l2.is_some() {
783            // Compression decision happens in the foreground put — the
784            // outcome (`Compressed::Raw` or `Compressed::Zstd`) is what
785            // gets framed and written to the chain (#192). When the knob
786            // is `Off`, skip the compressor entirely (CPU savings) and
787            // emit a `Raw` variant directly so the on-disk format stays
788            // uniform.
789            Some(match self.config.l2_compression {
790                L2Compression::Off => Compressed::Raw(entry.bytes.as_ref().to_vec()),
791                L2Compression::On => {
792                    let content_type = entry
793                        .content_metadata
794                        .as_ref()
795                        .and_then(|metadata| metadata.get("content-type"))
796                        .map(String::as_str);
797                    L2BlobCompressor::compress(
798                        entry.bytes.as_ref(),
799                        content_type,
800                        &CompressOpts::default(),
801                    )
802                    .map_err(|err| CacheError::L2Io(err.to_string()))?
803                }
804            })
805        } else {
806            None
807        };
808        #[cfg(test)]
809        if let Some(hook) = PUT_PREPARED_HOOK.with(|slot| slot.borrow_mut().take()) {
810            hook();
811        }
812        let mut shard = self.shards[shard_idx].write();
813        // Another writer may have published while compression ran. Recheck
814        // under the publication guard before changing either cache tier.
815        self.check_version(
816            &shard,
817            &key,
818            input.policy.version_value(),
819            namespace_generation,
820        )?;
821        shard.clear_l2_hit_marker(&key);
822        if let Some(l2) = &self.l2 {
823            let old_l2_size = l2.record_size(&key);
824            let compressed = compressed.expect("L2 payload prepared before publication");
825            match l2.put(&key, &entry, old_l2_size, compressed) {
826                Ok(()) => {}
827                Err(err @ CacheError::L2Full { .. }) => {
828                    self.stats
829                        .l2_full_rejections
830                        .fetch_add(1, Ordering::Relaxed);
831                    return Err(err);
832                }
833                Err(err) => return Err(err),
834            }
835        }
836        let outcome = if matches!(input.policy.l1_admission_value(), L1Admission::Never) {
837            let old_entry = shard.remove(&key);
838            InsertOutcome {
839                old_entry,
840                admitted: false,
841            }
842        } else {
843            shard.insert(key.clone(), entry)
844        };
845        drop(shard);
846
847        if let Some(old_entry) = outcome.old_entry.as_ref() {
848            self.deindex_entry(&key, old_entry);
849        }
850        if outcome.admitted {
851            self.index_entry(&key, &tags, &dependencies);
852        }
853
854        let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
855        let new_size = if outcome.admitted { entry_size } else { 0 };
856        if new_size >= old_size {
857            self.bytes_in_use
858                .fetch_add(new_size - old_size, Ordering::Relaxed);
859        } else {
860            self.bytes_in_use
861                .fetch_sub(old_size - new_size, Ordering::Relaxed);
862        }
863        self.stats.insertions.fetch_add(1, Ordering::Relaxed);
864        if outcome.admitted {
865            self.evict_until_within_budget(shard_idx);
866        }
867        Ok(())
868    }
869
870    pub fn get(&self, namespace: &str, key: &str) -> Option<BlobCacheHit> {
871        self.get_at(namespace, key, unix_now_ms())
872    }
873
874    fn get_at(&self, namespace: &str, key: &str, now_ms: u64) -> Option<BlobCacheHit> {
875        let namespace_generation = self.current_generation(namespace);
876        let shard_idx = self.shard_index_parts(namespace, key);
877        if let Some(hit) =
878            self.shards[shard_idx]
879                .read()
880                .shared_hit(namespace, key, now_ms, namespace_generation)
881        {
882            self.stats.hits.fetch_add(1, Ordering::Relaxed);
883            return Some(hit);
884        }
885        let mut shard = self.shards[shard_idx].write();
886        match shard.get_by_parts(namespace, key, now_ms, namespace_generation) {
887            Lookup::Hit(hit) => {
888                self.stats.hits.fetch_add(1, Ordering::Relaxed);
889                if hit.is_stale() {
890                    self.stats.l1_stale_serves.fetch_add(1, Ordering::Relaxed);
891                }
892                Some(hit)
893            }
894            Lookup::Expired(entry) => {
895                drop(shard);
896                let cache_key = BlobCacheKey::new(namespace, key);
897                self.record_removed_entry(&cache_key, &entry);
898                if let Some(l2) = &self.l2 {
899                    l2.delete_key(&cache_key);
900                }
901                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
902                self.stats.misses.fetch_add(1, Ordering::Relaxed);
903                None
904            }
905            Lookup::IdleEvicted(entry) => {
906                drop(shard);
907                let cache_key = BlobCacheKey::new(namespace, key);
908                self.record_removed_entry(&cache_key, &entry);
909                if let Some(l2) = &self.l2 {
910                    l2.delete_key(&cache_key);
911                }
912                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
913                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
914                self.stats.misses.fetch_add(1, Ordering::Relaxed);
915                None
916            }
917            Lookup::Stale(entry) => {
918                drop(shard);
919                let cache_key = BlobCacheKey::new(namespace, key);
920                self.record_removed_entry(&cache_key, &entry);
921                self.stats.misses.fetch_add(1, Ordering::Relaxed);
922                None
923            }
924            Lookup::Miss => {
925                drop(shard);
926                let cache_key = BlobCacheKey::new(namespace, key);
927                if let Some(pool) = self.promotion_pool.get() {
928                    // Async path: do the L2 read (we owe the bytes to the
929                    // caller right now) but defer the L1 install onto the
930                    // worker pool. Caller does not pay promotion bookkeeping.
931                    if let Some(l2) = self.l2.as_ref() {
932                        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
933                            let hit = entry.hit();
934                            if self.should_promote_l2_hit(&cache_key, shard_idx) {
935                                // Drop the freshly-fetched Entry — the worker will
936                                // re-fetch it. Cost: one extra L2 metadata read +
937                                // blob read per promoted L2 hit while async mode is on.
938                                // Acceptable trade-off for opt-in mode.
939                                drop(entry);
940                                let request = PromotionRequest {
941                                    namespace: cache_key.namespace.clone(),
942                                    key: cache_key.key.clone(),
943                                    bytes: Arc::clone(hit.bytes()),
944                                    policy: BlobCachePolicy::default(),
945                                };
946                                let _ = pool.schedule(request);
947                            }
948                            self.stats.hits.fetch_add(1, Ordering::Relaxed);
949                            return Some(hit);
950                        }
951                    }
952                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
953                    return None;
954                }
955                if let Some(hit) =
956                    self.rehydrate_l2_entry(&cache_key, now_ms, namespace_generation, shard_idx)
957                {
958                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
959                    return Some(hit);
960                }
961                self.stats.misses.fetch_add(1, Ordering::Relaxed);
962                None
963            }
964            Lookup::Present => unreachable!("get cannot return presence-only lookup"),
965        }
966    }
967
968    /// Probe whether `(namespace, key)` is cached.
969    ///
970    /// Returns a three-valued [`CachePresence`]:
971    ///
972    /// - `Present` when an L1-resident entry is held for the key.
973    /// - `Absent` when the cache can authoritatively rule the key out: either
974    ///   no L2 is configured, or the per-namespace Bloom synopsis
975    ///   (no-false-negatives) says the key was never inserted into L2.
976    /// - `MaybePresent` when L1 missed but the Bloom synopsis cannot rule the
977    ///   key out. Callers that need an exact answer must follow up with
978    ///   [`get`](Self::get), which performs the authoritative metadata read
979    ///   and either rehydrates a hit or surfaces a genuine miss.
980    ///
981    /// `exists` deliberately does NOT touch the L2 metadata B+ tree on a
982    /// `MaybePresent` answer — that is the whole reason the synopsis exists
983    /// (#146). The probabilistic answer is the cheap fast path; pay the
984    /// metadata-read cost only when you actually need the bytes.
985    pub fn exists(&self, namespace: &str, key: &str) -> CachePresence {
986        self.exists_at(namespace, key, unix_now_ms())
987    }
988
989    fn exists_at(&self, namespace: &str, key: &str, now_ms: u64) -> CachePresence {
990        let namespace_generation = self.current_generation(namespace);
991        let shard_idx = self.shard_index_parts(namespace, key);
992        let mut shard = self.shards[shard_idx].write();
993        match shard.contains_by_parts(namespace, key, now_ms, namespace_generation) {
994            Lookup::Present => {
995                self.stats.hits.fetch_add(1, Ordering::Relaxed);
996                CachePresence::Present
997            }
998            Lookup::Expired(entry) => {
999                drop(shard);
1000                let cache_key = BlobCacheKey::new(namespace, key);
1001                self.record_removed_entry(&cache_key, &entry);
1002                if let Some(l2) = &self.l2 {
1003                    l2.delete_key(&cache_key);
1004                }
1005                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1006                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1007                CachePresence::Absent
1008            }
1009            Lookup::IdleEvicted(entry) => {
1010                drop(shard);
1011                let cache_key = BlobCacheKey::new(namespace, key);
1012                self.record_removed_entry(&cache_key, &entry);
1013                if let Some(l2) = &self.l2 {
1014                    l2.delete_key(&cache_key);
1015                }
1016                self.stats.expirations.fetch_add(1, Ordering::Relaxed);
1017                self.stats.l1_idle_evicts.fetch_add(1, Ordering::Relaxed);
1018                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1019                CachePresence::Absent
1020            }
1021            Lookup::Stale(entry) => {
1022                drop(shard);
1023                let cache_key = BlobCacheKey::new(namespace, key);
1024                self.record_removed_entry(&cache_key, &entry);
1025                self.stats.misses.fetch_add(1, Ordering::Relaxed);
1026                CachePresence::Absent
1027            }
1028            Lookup::Miss => {
1029                drop(shard);
1030                let Some(l2) = self.l2.as_ref() else {
1031                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1032                    return CachePresence::Absent;
1033                };
1034                if l2.synopsis_may_contain(namespace, key) {
1035                    // Filter says maybe — the cheap fast path defers the
1036                    // authoritative read to `get`. Count as a hit prospect.
1037                    self.stats.hits.fetch_add(1, Ordering::Relaxed);
1038                    CachePresence::MaybePresent
1039                } else {
1040                    // Filter says no — definitively absent (no
1041                    // false-negatives).
1042                    self.stats.misses.fetch_add(1, Ordering::Relaxed);
1043                    CachePresence::Absent
1044                }
1045            }
1046            Lookup::Hit(_) => unreachable!("exists cannot return a hit payload"),
1047        }
1048    }
1049
1050    /// Node-local invalidation for one exact cache key.
1051    ///
1052    /// This does not propagate to replicas. Cluster-wide invalidation is a
1053    /// future contract; callers that need cross-node coherence must rely on the
1054    /// underlying write reaching each node and triggering local eviction there.
1055    pub fn invalidate_key(&self, namespace: &str, key: &str) -> usize {
1056        if !self.namespace_exists(namespace) {
1057            return 0;
1058        }
1059        let cache_key = BlobCacheKey::new(namespace, key);
1060        let shard_idx = self.shard_index(&cache_key);
1061        let mut shard = self.shards[shard_idx].write();
1062        let removed = shard.remove(&cache_key);
1063        drop(shard);
1064
1065        if let Some(entry) = removed {
1066            self.record_invalidated_entry(&cache_key, &entry);
1067            1
1068        } else {
1069            self.l2
1070                .as_ref()
1071                .and_then(|l2| l2.delete_key(&cache_key))
1072                .map(|_| {
1073                    self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1074                    1
1075                })
1076                .unwrap_or(0)
1077        }
1078    }
1079
1080    /// Node-local invalidation for keys with a namespace-local prefix.
1081    pub fn invalidate_prefix(&self, namespace: &str, prefix: &str) -> usize {
1082        if !self.namespace_exists(namespace) {
1083            return 0;
1084        }
1085
1086        let mut removed = Vec::new();
1087        for shard in &self.shards {
1088            let mut shard = shard.write();
1089            let keys = shard
1090                .keys_matching(|key| key.namespace == namespace && key.key.starts_with(prefix));
1091            for key in keys {
1092                if let Some(entry) = shard.remove(&key) {
1093                    removed.push((key, entry));
1094                }
1095            }
1096        }
1097
1098        let count = removed.len();
1099        for (key, entry) in removed {
1100            self.record_invalidated_entry(&key, &entry);
1101        }
1102        let l2_count = self
1103            .l2
1104            .as_ref()
1105            .map_or(0, |l2| l2.delete_prefix(namespace, prefix));
1106        if l2_count > count {
1107            self.stats
1108                .invalidations
1109                .fetch_add((l2_count - count) as u64, Ordering::Relaxed);
1110        }
1111        count.max(l2_count)
1112    }
1113
1114    /// Node-local batched invalidation for all entries carrying any of `tags`.
1115    ///
1116    /// Locks each affected shard once per call, so a batched invalidation
1117    /// from a downstream adapter (#143) does not multiply lock acquisitions
1118    /// the way N singular calls would.
1119    pub fn invalidate_tags(&self, namespace: &str, tags: &[&str]) -> usize {
1120        self.invalidate_indexed_many(namespace, tags, IndexedKind::Tag)
1121    }
1122
1123    /// Node-local batched invalidation for all entries carrying any of `dependencies`.
1124    pub fn invalidate_dependencies(&self, namespace: &str, dependencies: &[&str]) -> usize {
1125        self.invalidate_indexed_many(namespace, dependencies, IndexedKind::Dependency)
1126    }
1127
1128    /// Node-local invalidation for all entries carrying `tag`.
1129    #[deprecated(
1130        since = "0.1.0",
1131        note = "use `invalidate_tags(namespace, &[tag])` for batched callers"
1132    )]
1133    pub fn invalidate_tag(&self, namespace: &str, tag: &str) -> usize {
1134        self.invalidate_indexed_many(namespace, &[tag], IndexedKind::Tag)
1135    }
1136
1137    /// Node-local invalidation for all entries carrying `dependency`.
1138    #[deprecated(
1139        since = "0.1.0",
1140        note = "use `invalidate_dependencies(namespace, &[dependency])` for batched callers"
1141    )]
1142    pub fn invalidate_dependency(&self, namespace: &str, dependency: &str) -> usize {
1143        self.invalidate_indexed_many(namespace, &[dependency], IndexedKind::Dependency)
1144    }
1145
1146    /// O(1) foreground namespace flush.
1147    ///
1148    /// The foreground path only bumps a namespace generation. Old entries become
1149    /// invisible immediately and are physically removed by later cache access or
1150    /// a future sweeper.
1151    pub fn invalidate_namespace(&self, namespace: &str) -> bool {
1152        if !self.namespace_exists(namespace) {
1153            return false;
1154        }
1155        let mut generations = self.namespace_generations.write();
1156        let generation = generations.entry(namespace.to_string()).or_insert(0);
1157        *generation = generation.saturating_add(1);
1158        if let Some(l2) = &self.l2 {
1159            l2.delete_namespace(namespace);
1160        }
1161        self.stats.namespace_flushes.fetch_add(1, Ordering::Relaxed);
1162        true
1163    }
1164
1165    /// RAM this cache occupies right now (ADR 0073 §2, the `blob_cache_l1`
1166    /// pool): L1 entry payloads plus L2's resident metadata — its B+ tree
1167    /// pages and Bloom synopsis filters. L2's *disk* extent is not memory and
1168    /// is excluded; `BlobCacheStats::l2_bytes_in_use` reports it separately.
1169    pub fn ram_bytes_in_use(&self) -> u64 {
1170        let l1 = self.bytes_in_use.load(Ordering::Relaxed) as u64;
1171        let l2 = self
1172            .l2
1173            .as_ref()
1174            .map_or(0, |l2| l2.resident_metadata_bytes());
1175        l1.saturating_add(l2)
1176    }
1177
1178    pub fn stats(&self) -> BlobCacheStats {
1179        BlobCacheStats {
1180            hits: self.stats.hits.load(Ordering::Relaxed),
1181            misses: self.stats.misses.load(Ordering::Relaxed),
1182            insertions: self.stats.insertions.load(Ordering::Relaxed),
1183            evictions: self.stats.evictions.load(Ordering::Relaxed),
1184            expirations: self.stats.expirations.load(Ordering::Relaxed),
1185            invalidations: self.stats.invalidations.load(Ordering::Relaxed),
1186            namespace_flushes: self.stats.namespace_flushes.load(Ordering::Relaxed),
1187            version_mismatches: self.stats.version_mismatches.load(Ordering::Relaxed),
1188            entries: self.shards.iter().map(|shard| shard.read().len()).sum(),
1189            bytes_in_use: self.bytes_in_use.load(Ordering::Relaxed),
1190            l1_bytes_max: self.config.l1_bytes_max,
1191            l2_bytes_in_use: self.l2.as_ref().map_or(0, |l2| l2.stats_bytes_in_use()),
1192            l2_bytes_max: self.config.l2_bytes_max,
1193            l2_full_rejections: self.stats.l2_full_rejections.load(Ordering::Relaxed),
1194            l2_metadata_reads: self.l2.as_ref().map_or(0, |l2| l2.stats_metadata_reads()),
1195            l2_negative_skips: self.l2.as_ref().map_or(0, |l2| l2.stats_negative_skips()),
1196            synopsis_metadata_reads: self
1197                .l2
1198                .as_ref()
1199                .map_or(0, |l2| l2.stats_synopsis_metadata_reads()),
1200            synopsis_bytes: self.l2.as_ref().map_or(0, |l2| l2.stats_synopsis_bytes()),
1201            namespaces: self.namespaces.read().len(),
1202            max_namespaces: self.config.max_namespaces,
1203            promotion_queued: self
1204                .promotion_pool
1205                .get()
1206                .map_or(0, |p| p.metrics().queued_total),
1207            promotion_dropped: self
1208                .promotion_pool
1209                .get()
1210                .map_or(0, |p| p.metrics().dropped_total),
1211            promotion_completed: self
1212                .promotion_pool
1213                .get()
1214                .map_or(0, |p| p.metrics().completed_total),
1215            promotion_queue_depth: self
1216                .promotion_pool
1217                .get()
1218                .map_or(0, |p| p.metrics().queue_depth),
1219            l2_compression_original_bytes: self
1220                .l2
1221                .as_ref()
1222                .map_or(0, |l2| l2.stats_compression_original_bytes()),
1223            l2_compression_stored_bytes: self
1224                .l2
1225                .as_ref()
1226                .map_or(0, |l2| l2.stats_compression_stored_bytes()),
1227            l2_compression_skipped_total: self
1228                .l2
1229                .as_ref()
1230                .map_or(0, |l2| l2.stats_compression_skipped_total()),
1231            l2_bytes_saved_total: self
1232                .l2
1233                .as_ref()
1234                .map_or(0, |l2| l2.stats_bytes_saved_total()),
1235            l1_stale_serves_total: self.stats.l1_stale_serves.load(Ordering::Relaxed),
1236            l1_idle_evicts_total: self.stats.l1_idle_evicts.load(Ordering::Relaxed),
1237        }
1238    }
1239
1240    // -- Async promotion (issue #193) ---------------------------------------
1241
1242    /// Initialize the async L2->L1 promotion pool. Must be called on an
1243    /// `Arc<Self>` so the executor closure can hold a `Weak<Self>` (no
1244    /// reference cycle).
1245    ///
1246    /// Idempotent on first call only — `OnceLock` semantics: a second call
1247    /// returns the previously-installed pool unchanged. The returned `Arc`
1248    /// can be used by callers that want to inspect metrics directly; most
1249    /// callers should ignore it and read metrics via `stats()`.
1250    pub fn enable_async_promotion(self: &Arc<Self>, opts: PoolOpts) -> Arc<AsyncPromotionPool> {
1251        let weak: Weak<Self> = Arc::downgrade(self);
1252        let executor: PromotionExecutor = Arc::new(move |req| {
1253            // Upgrade only at execution time. If the cache has been
1254            // dropped, the worker silently no-ops (executor never holds
1255            // a strong ref between calls).
1256            let Some(cache) = weak.upgrade() else {
1257                return Ok(());
1258            };
1259            cache.promote_from_l2(&req)
1260        });
1261        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1262        match self.promotion_pool.set(Arc::clone(&pool)) {
1263            Ok(()) => pool,
1264            // Race: another caller already initialized. Drain ours and
1265            // return the winner. The losing pool's workers are spawned;
1266            // shutdown drains them out gracefully.
1267            Err(losing_pool) => {
1268                losing_pool.shutdown();
1269                Arc::clone(
1270                    self.promotion_pool
1271                        .get()
1272                        .expect("OnceLock set+get inconsistency"),
1273                )
1274            }
1275        }
1276    }
1277
1278    /// Drain and stop the async promotion pool, if enabled. Safe to call
1279    /// from `Drop` impls / test teardown — no-op when the pool was never
1280    /// initialized.
1281    pub fn shutdown_async_promotion(&self) {
1282        if let Some(pool) = self.promotion_pool.get() {
1283            Arc::clone(pool).shutdown();
1284        }
1285    }
1286
1287    /// Test-only escape hatch: schedule outcome of the most recent attempt
1288    /// is internal; tests assert on `stats()` counters instead.
1289    #[cfg(test)]
1290    fn promotion_pool_handle(&self) -> Option<Arc<AsyncPromotionPool>> {
1291        self.promotion_pool.get().cloned()
1292    }
1293
1294    /// Test-only: install a custom executor (e.g. one that sleeps to
1295    /// expose the hot-path / worker-path latency split). Used by the
1296    /// async-promotion wiring tests in this file.
1297    #[cfg(test)]
1298    fn enable_async_promotion_with_executor(
1299        self: &Arc<Self>,
1300        opts: PoolOpts,
1301        executor: PromotionExecutor,
1302    ) -> Arc<AsyncPromotionPool> {
1303        let pool = AsyncPromotionPool::new_with_executor(opts, executor);
1304        let _ = self.promotion_pool.set(Arc::clone(&pool));
1305        pool
1306    }
1307
1308    pub fn config(&self) -> &BlobCacheConfig {
1309        &self.config
1310    }
1311
1312    #[cfg(test)]
1313    fn inject_l2_fault_after_blob_write_once(&self) {
1314        self.l2
1315            .as_ref()
1316            .expect("L2 enabled")
1317            .inject_fault_after_blob_write_once();
1318    }
1319
1320    #[cfg(test)]
1321    fn inject_l2_synopsis_maybe_present(&self, namespace: &str, key: &str) {
1322        self.l2
1323            .as_ref()
1324            .expect("L2 enabled")
1325            .inject_synopsis_maybe_present(namespace, key);
1326    }
1327
1328    /// Test-only escape hatch (#192 lane 2/5): synthesise a legacy
1329    /// `V1Raw` L2 entry on disk so the forward-compat read test can
1330    /// verify pre-compression entries still rehydrate.
1331    #[cfg(test)]
1332    fn inject_l2_v1_entry(
1333        &self,
1334        namespace: &str,
1335        key: &str,
1336        payload: &[u8],
1337    ) -> Result<(), CacheError> {
1338        let l2 = self.l2.as_ref().expect("L2 enabled");
1339        let cache_key = BlobCacheKey::new(namespace, key);
1340        l2.inject_v1_entry(&cache_key, payload)
1341    }
1342
1343    fn validate_blob_size(&self, size: usize, policy: BlobCachePolicy) -> Result<(), CacheError> {
1344        let max = policy
1345            .max_blob_bytes_value()
1346            .unwrap_or(self.config.l1_bytes_max);
1347        if size > max {
1348            Err(CacheError::BlobTooLarge { size, max })
1349        } else {
1350            Ok(())
1351        }
1352    }
1353
1354    fn validate_metadata(&self, metadata: &BTreeMap<String, String>) -> Result<(), CacheError> {
1355        let keys = metadata.len();
1356        let bytes = metadata
1357            .iter()
1358            .map(|(key, value)| key.len() + value.len())
1359            .sum::<usize>();
1360        if keys > self.config.content_metadata_keys_max
1361            || bytes > self.config.content_metadata_bytes_max
1362        {
1363            Err(CacheError::MetadataTooLarge {
1364                keys,
1365                bytes,
1366                max_keys: self.config.content_metadata_keys_max,
1367                max_bytes: self.config.content_metadata_bytes_max,
1368            })
1369        } else {
1370            Ok(())
1371        }
1372    }
1373
1374    fn rehydrate_l2_entry(
1375        &self,
1376        key: &BlobCacheKey,
1377        now_ms: u64,
1378        namespace_generation: u64,
1379        shard_idx: usize,
1380    ) -> Option<BlobCacheHit> {
1381        let l2 = self.l2.as_ref()?;
1382        let entry = l2.get(key, now_ms, namespace_generation)?;
1383        let hit = entry.hit();
1384        if self.should_promote_l2_hit(key, shard_idx) {
1385            self.do_l1_promotion_sync(key, entry, shard_idx);
1386        }
1387        Some(hit)
1388    }
1389
1390    fn should_promote_l2_hit(&self, key: &BlobCacheKey, shard_idx: usize) -> bool {
1391        match self.config.l2_promotion_policy {
1392            L2PromotionPolicy::Immediate => true,
1393            L2PromotionPolicy::OnSecondHit => self.shards[shard_idx]
1394                .write()
1395                .l2_hit_should_promote_on_second_hit(key),
1396        }
1397    }
1398
1399    /// Pure L1 install bookkeeping: shard write-lock, byte accounting,
1400    /// eviction loop. Extracted so the async promotion pool can call it
1401    /// from a worker (issue #193, lane 1/5).
1402    ///
1403    /// This is intentionally side-effect-only — it does not touch hit/miss
1404    /// stats (the caller already counted the hit) and does not return the
1405    /// `BlobCacheHit` (the caller already handed bytes to the user).
1406    fn do_l1_promotion_sync(&self, key: &BlobCacheKey, entry: Entry, shard_idx: usize) {
1407        let entry_size = entry.size;
1408        let mut shard = self.shards[shard_idx].write();
1409        let outcome = shard.insert(key.clone(), entry);
1410        drop(shard);
1411        let old_size = outcome.old_entry.as_ref().map_or(0, |entry| entry.size);
1412        if entry_size >= old_size {
1413            self.bytes_in_use
1414                .fetch_add(entry_size - old_size, Ordering::Relaxed);
1415        } else {
1416            self.bytes_in_use
1417                .fetch_sub(old_size - entry_size, Ordering::Relaxed);
1418        }
1419        self.evict_until_within_budget(shard_idx);
1420    }
1421
1422    /// Worker-side promotion path: re-fetch the entry from L2 and run the
1423    /// L1 install bookkeeping. Idempotent — re-promoting a key that the
1424    /// hot path already promoted (race with another reader) is harmless.
1425    /// Returns `Err` only when L2 is unavailable or the key is no longer
1426    /// present at L2 (silently treated as a no-op upstream).
1427    fn promote_from_l2(&self, req: &PromotionRequest) -> Result<(), String> {
1428        let l2 = self
1429            .l2
1430            .as_ref()
1431            .ok_or_else(|| "promotion executor invoked without L2 configured".to_string())?;
1432        let cache_key = BlobCacheKey::new(req.namespace.as_str(), req.key.as_str());
1433        let now_ms = unix_now_ms();
1434        let namespace_generation = self.current_generation(req.namespace.as_str());
1435        if let Some(entry) = l2.get(&cache_key, now_ms, namespace_generation) {
1436            let shard_idx = self.shard_index(&cache_key);
1437            self.do_l1_promotion_sync(&cache_key, entry, shard_idx);
1438        }
1439        Ok(())
1440    }
1441
1442    fn ensure_namespace(&self, namespace: &str) -> Result<(), CacheError> {
1443        {
1444            let namespaces = self.namespaces.read();
1445            if namespaces.contains(namespace) {
1446                return Ok(());
1447            }
1448        }
1449        let mut namespaces = self.namespaces.write();
1450        if namespaces.contains(namespace) {
1451            return Ok(());
1452        }
1453        if namespaces.len() >= self.config.max_namespaces {
1454            return Err(CacheError::TooManyNamespaces {
1455                max: self.config.max_namespaces,
1456            });
1457        }
1458        namespaces.insert(namespace.to_string());
1459        self.namespace_generations
1460            .write()
1461            .entry(namespace.to_string())
1462            .or_insert(0);
1463        Ok(())
1464    }
1465
1466    fn namespace_exists(&self, namespace: &str) -> bool {
1467        self.namespaces.read().contains(namespace)
1468            || self
1469                .l2
1470                .as_ref()
1471                .is_some_and(|l2| l2.has_namespace(namespace))
1472    }
1473
1474    fn current_generation(&self, namespace: &str) -> u64 {
1475        self.namespace_generations
1476            .read()
1477            .get(namespace)
1478            .copied()
1479            .unwrap_or(0)
1480    }
1481
1482    fn index_entry(
1483        &self,
1484        key: &BlobCacheKey,
1485        tags: &BTreeSet<String>,
1486        dependencies: &BTreeSet<String>,
1487    ) {
1488        if !tags.is_empty() {
1489            let mut index = self.tag_index.write();
1490            for tag in tags {
1491                index
1492                    .entry(ScopedLabel::new(key.namespace.as_str(), tag.as_str()))
1493                    .or_default()
1494                    .insert(key.clone());
1495            }
1496        }
1497        if !dependencies.is_empty() {
1498            let mut index = self.dependency_index.write();
1499            for dependency in dependencies {
1500                index
1501                    .entry(ScopedLabel::new(
1502                        key.namespace.as_str(),
1503                        dependency.as_str(),
1504                    ))
1505                    .or_default()
1506                    .insert(key.clone());
1507            }
1508        }
1509    }
1510
1511    fn deindex_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1512        Self::remove_indexed_labels(&self.tag_index, key, &entry.tags);
1513        Self::remove_indexed_labels(&self.dependency_index, key, &entry.dependencies);
1514    }
1515
1516    fn remove_indexed_labels(
1517        index: &RwLock<HashMap<ScopedLabel, HashSet<BlobCacheKey>>>,
1518        key: &BlobCacheKey,
1519        labels: &BTreeSet<String>,
1520    ) {
1521        if labels.is_empty() {
1522            return;
1523        }
1524        let mut index = index.write();
1525        for label in labels {
1526            let scoped = ScopedLabel::new(key.namespace.as_str(), label.as_str());
1527            let should_remove = if let Some(keys) = index.get_mut(&scoped) {
1528                keys.remove(key);
1529                keys.is_empty()
1530            } else {
1531                false
1532            };
1533            if should_remove {
1534                index.remove(&scoped);
1535            }
1536        }
1537    }
1538
1539    fn record_removed_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1540        self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1541        self.deindex_entry(key, entry);
1542    }
1543
1544    fn record_invalidated_entry(&self, key: &BlobCacheKey, entry: &Entry) {
1545        self.record_removed_entry(key, entry);
1546        if let Some(l2) = &self.l2 {
1547            l2.delete_key(key);
1548        }
1549        self.stats.invalidations.fetch_add(1, Ordering::Relaxed);
1550    }
1551
1552    fn invalidate_indexed_many(
1553        &self,
1554        namespace: &str,
1555        labels: &[&str],
1556        kind: IndexedKind,
1557    ) -> usize {
1558        if labels.is_empty() || !self.namespace_exists(namespace) {
1559            return 0;
1560        }
1561
1562        // Snapshot the candidate keys for every label up front so the
1563        // shard-locking pass below sees a stable set. We deduplicate by
1564        // BlobCacheKey so a key tagged with multiple invalidated labels is
1565        // still removed (and counted) exactly once.
1566        let mut candidates: HashMap<BlobCacheKey, HashSet<String>> = HashMap::new();
1567        {
1568            let index = match kind {
1569                IndexedKind::Tag => self.tag_index.read(),
1570                IndexedKind::Dependency => self.dependency_index.read(),
1571            };
1572            for label in labels {
1573                let scoped = ScopedLabel::new(namespace, *label);
1574                if let Some(keys) = index.get(&scoped) {
1575                    for key in keys {
1576                        candidates
1577                            .entry(key.clone())
1578                            .or_default()
1579                            .insert((*label).to_string());
1580                    }
1581                }
1582            }
1583        }
1584
1585        if candidates.is_empty() {
1586            return 0;
1587        }
1588
1589        // Group candidates by shard so each shard lock is taken at most
1590        // once per call.
1591        let mut by_shard: HashMap<usize, Vec<(BlobCacheKey, HashSet<String>)>> = HashMap::new();
1592        for (key, matched_labels) in candidates {
1593            let shard_idx = self.shard_index(&key);
1594            by_shard
1595                .entry(shard_idx)
1596                .or_default()
1597                .push((key, matched_labels));
1598        }
1599
1600        let mut removed = Vec::new();
1601        for (shard_idx, keys) in by_shard {
1602            let mut shard = self.shards[shard_idx].write();
1603            for (key, matched_labels) in keys {
1604                let still_matches = match kind {
1605                    IndexedKind::Tag => shard.entry_has_any_tag(&key, &matched_labels),
1606                    IndexedKind::Dependency => {
1607                        shard.entry_has_any_dependency(&key, &matched_labels)
1608                    }
1609                };
1610                if still_matches {
1611                    if let Some(entry) = shard.remove(&key) {
1612                        removed.push((key, entry));
1613                    }
1614                }
1615            }
1616        }
1617
1618        let count = removed.len();
1619        for (key, entry) in removed {
1620            self.record_invalidated_entry(&key, &entry);
1621        }
1622        count
1623    }
1624
1625    fn shard_index(&self, key: &BlobCacheKey) -> usize {
1626        self.shard_index_parts(&key.namespace, &key.key)
1627    }
1628
1629    fn shard_index_parts(&self, namespace: &str, key: &str) -> usize {
1630        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1631        namespace.hash(&mut hasher);
1632        key.hash(&mut hasher);
1633        (hasher.finish() as usize) % self.shards.len()
1634    }
1635
1636    fn check_version(
1637        &self,
1638        shard: &Shard,
1639        key: &BlobCacheKey,
1640        attempted: Option<u64>,
1641        namespace_generation: u64,
1642    ) -> Result<(), CacheError> {
1643        let Some(attempted) = attempted else {
1644            return Ok(());
1645        };
1646        let Some(existing) = shard.existing_version(key, namespace_generation) else {
1647            return Ok(());
1648        };
1649        if existing >= attempted {
1650            self.stats
1651                .version_mismatches
1652                .fetch_add(1, Ordering::Relaxed);
1653            Err(CacheError::VersionMismatch {
1654                existing,
1655                attempted,
1656            })
1657        } else {
1658            Ok(())
1659        }
1660    }
1661
1662    fn evict_until_within_budget(&self, preferred_start: usize) {
1663        while self.bytes_in_use.load(Ordering::Relaxed) > self.config.l1_bytes_max {
1664            let mut evicted = false;
1665            for offset in 0..self.shards.len() {
1666                let idx = (preferred_start + offset) % self.shards.len();
1667                let mut shard = self.shards[idx].write();
1668                if let Some((key, entry)) = shard.evict_one() {
1669                    self.bytes_in_use.fetch_sub(entry.size, Ordering::Relaxed);
1670                    self.stats.evictions.fetch_add(1, Ordering::Relaxed);
1671                    evicted = true;
1672                    drop(shard);
1673                    self.deindex_entry(&key, &entry);
1674                    break;
1675                }
1676            }
1677            if !evicted {
1678                break;
1679            }
1680        }
1681    }
1682}
1683
1684fn unix_now_ms() -> u64 {
1685    SystemTime::now()
1686        .duration_since(UNIX_EPOCH)
1687        .map(|duration| duration.as_millis() as u64)
1688        .unwrap_or(0)
1689}
1690
1691impl Default for BlobCache {
1692    fn default() -> Self {
1693        Self::with_defaults()
1694    }
1695}
1696
1697#[cfg(test)]
1698mod tests;