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