Skip to main content

decdn_cache/
engine.rs

1//! Cache engine: local iroh-blobs store fronted by an [`Origin`] for misses.
2
3use std::collections::{HashMap, HashSet};
4use std::fs::OpenOptions;
5use std::io::Write;
6use std::num::NonZeroU64;
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
10use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, Weak};
11use std::time::{Duration, Instant};
12
13use arc_swap::ArcSwap;
14use bao_tree::ChunkRanges;
15use bao_tree::io::BaoContentItem;
16use bao_tree::io::fsm::{ResponseDecoder, ResponseDecoderNext};
17use bytes::{Bytes, BytesMut};
18use dashmap::DashMap;
19use futures_util::StreamExt;
20use iroh_blobs::api::blobs::EncodedItem;
21use iroh_blobs::store::fs::FsStore;
22use iroh_blobs::store::fs::options::Options as FsStoreOptions;
23use iroh_blobs::store::{GcConfig, ProtectOutcome};
24use iroh_blobs::{Hash, HashAndFormat};
25use iroh_io::AsyncStreamReader;
26use tokio::sync::{Notify, broadcast};
27
28use decdn_config_types::{CircuitBreakerPolicy, DeniedHashes, PinDiff, PinnedHashes, RetryPolicy};
29
30use crate::CHUNK_GROUP_BYTES;
31use crate::circuit_breaker::{
32    Admission, Clock, OriginBreaker, OriginOutcome, SystemClock, TrialGuard,
33};
34use crate::error::{CacheError, CacheResult, OriginPullError};
35use crate::fill_session::{FillClaim, FillRegistry, FillSession};
36use crate::metrics::CacheMetrics;
37use crate::origin::{Origin, OriginKind, OutboardFetch};
38use crate::origin_probe::{OriginProbeMemo, OriginProbePolicy, Presence};
39use crate::origin_range::{
40    MAX_CONCURRENT_RANGE_PULLS, OriginRangeCursor, OriginRangeWire, OriginReadBudget,
41    RANGE_PULL_PERMIT_WARN_AFTER, within_origin_timeout,
42};
43use crate::outboard_cache::{OUTBOARD_CACHE_BYTES, OUTBOARD_CACHE_ENTRIES, OutboardCache};
44use crate::probe_hold::ProbeHoldOutcome;
45use crate::range_pull::{AlignedRange, align_range, align_range_clamped};
46use crate::retry::{
47    TerminalFailure, classify_io_error, drain_to_bytes, run_with_retry_classified, should_buffer,
48};
49use crate::{from_store_hash, to_store_hash};
50
51/// Rescan slot: no pass running, and none requested.
52const RESCAN_IDLE: u8 = 0;
53/// A pass owns the slot; nothing queued behind it.
54const RESCAN_RUNNING: u8 = 1;
55/// A pass owns the slot and a further pass is queued behind it.
56const RESCAN_QUEUED: u8 = 2;
57
58/// Releases the rescan slot if a pass leaves without a clean release CAS —
59/// today, only by panicking. Without it the slot stays claimed and every later
60/// rescan returns immediately, so the announce set freezes at whatever the
61/// panicking pass had last published.
62struct RescanSlotGuard<'a> {
63    slot: &'a AtomicU8,
64    armed: bool,
65}
66
67impl Drop for RescanSlotGuard<'_> {
68    fn drop(&mut self) {
69        if self.armed {
70            self.slot.store(RESCAN_IDLE, Ordering::Release);
71        }
72    }
73}
74
75/// Per-candidate ceiling for an origin rescan's existence probes.
76///
77/// Deliberately not `cache.origin_probe_timeout_ms`: that knob is sized so a
78/// slow origin cannot stall the probe serve path, and a rescan is a bulk walk
79/// with nothing waiting on it. Borrowing the tighter budget would classify a
80/// merely slow origin as faulting on every candidate, which on a cold boot —
81/// where nothing can be carried forward — leaves the announce set empty for the
82/// process lifetime.
83const RESCAN_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
84
85/// How far before [`CacheEngine::open`] the open-time recency seed sits.
86///
87/// Every blob the open-time store walk finds enters `access_times` at
88/// `open - COLD_SEED_AGE`. Every access after open records `Instant::now()`,
89/// which is later. So both eviction policies see an unaccessed pre-open blob as
90/// older than anything touched since open: [`crate::policy::LruEviction`]
91/// releases it first, and [`crate::policy::TinyLfuEviction`] releases it first
92/// among blobs of equal frequency. The pre-open blobs tie on recency, and both
93/// policies break that tie largest first.
94const COLD_SEED_AGE: Duration = Duration::from_secs(1);
95
96/// The origin-held index and what the rescan that built it could not resolve.
97///
98/// One `ArcSwap` payload rather than an index plus separate counters: a reader
99/// that saw a fault-truncated index alongside a later rescan's zero fault count
100/// would report a short announce set as healthy, which is the failure the counts
101/// exist to expose. Published together, read together.
102#[derive(Debug, Default)]
103struct OriginHeldIndex {
104    /// Hash -> total byte size, for everything a configured origin can serve.
105    held: HashMap<Hash, u64>,
106    /// Candidates whose size probe faulted rather than answering. Each either
107    /// kept a size carried from the previous index or is missing from `held`.
108    probe_faults: u64,
109    /// Origins whose `enumerate` failed. Their listings contributed nothing to
110    /// this pass, so every hash discoverable only through one of them is absent
111    /// from `held` — with no per-hash fault to count, since none was probed.
112    enumerate_failures: u64,
113}
114
115/// What one [`CacheEngine::rescan_origins`] probe pass resolved.
116#[derive(Debug, Default)]
117struct RescanResolution {
118    /// The rebuilt index: hash -> total byte size.
119    held: HashMap<Hash, u64>,
120    /// Candidates whose probe faulted rather than answering.
121    faults: u64,
122    /// Faulted candidates that kept an entry from the previous index. Always
123    /// `<= faults`, hence the same width.
124    carried: u64,
125}
126
127/// The origin-held announce set plus what the rescan behind it could not
128/// resolve, read as one consistent view — see
129/// [`CacheEngine::origin_held_snapshot`].
130#[derive(Debug, Default)]
131pub struct OriginHeldReport {
132    /// Hashes a configured origin can serve, minus anything currently refused.
133    pub hashes: HashSet<Hash>,
134    /// Size probes that faulted on the rescan that built this set.
135    pub probe_faults: u64,
136    /// Origins whose listing failed on that rescan, contributing nothing.
137    pub enumerate_failures: u64,
138}
139
140/// A live origin-existence answer (#1766), distinguishing a genuine negative
141/// from a backend fault. It is the target of the two-way collapse in
142/// `crate::origin_probe::Presence`: `Present`/`Absent` map straight across,
143/// and `Fault` is folded into "don't advertise" — see
144/// [`CacheEngine::origin_probe_size`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum OriginPresence {
147    /// A configured origin holds the blob; carries the total byte size.
148    Present(u64),
149    /// No configured origin holds the blob: a genuine `HEAD`/`HeadObject`
150    /// 404, or no origin is configured at all ([`CacheError::NoOrigin`] — a
151    /// node with nothing configured genuinely holds nothing; that is not a
152    /// transient condition).
153    Absent,
154    /// The probe could not get an authoritative answer: a transport error, or
155    /// the live `HEAD` overran `cache.origin_probe_timeout_ms`. Distinct from
156    /// `Absent` on purpose — a caller that would otherwise sign an
157    /// authoritative `NotFound` must not do so on a fault; see the
158    /// origin-only serve gate (`dispatch.rs`).
159    Fault,
160}
161
162impl From<Presence> for OriginPresence {
163    fn from(presence: Presence) -> Self {
164        match presence {
165            Presence::Present(size) => OriginPresence::Present(size),
166            Presence::Absent => OriginPresence::Absent,
167            Presence::Fault => OriginPresence::Fault,
168        }
169    }
170}
171
172/// Engine bundling a filesystem-backed iroh-blobs store with an optional
173/// origin backend. Lookups hit the store first; on miss and when an origin is
174/// configured, bytes are pulled and BLAKE3-verified. Insert-before-return is a
175/// property of the buffered path ([`Self::get`] / [`Self::populate`]), not of
176/// this type: [`Self::admit_bao`] commits only a verified sub-range.
177#[derive(Debug, Clone)]
178pub struct CacheEngine {
179    inner: Arc<Inner>,
180}
181
182#[derive(Debug)]
183struct Inner {
184    store: FsStore,
185    /// Ordered list of origin backends consulted on cache misses
186    /// (#284). Empty vec => no pull-through configured; `pull_through`
187    /// short-circuits to `CacheError::NoOrigin`. A single-element vec
188    /// is the single-origin case. Entries are tried in operator-supplied
189    /// order; the next entry is consulted on `NotFound`, permanent error,
190    /// or retry-budget exhaustion.
191    /// Deterministic per-origin failures (`HashMismatch`,
192    /// `BlobTooLarge`, and local-store errors classified as
193    /// `Store`) deliberately do not fall back — they indicate a
194    /// misbehaving backend or a degraded local store that must
195    /// surface, not be masked by trying a different mirror.
196    origins: Vec<Arc<dyn Origin>>,
197    /// Per-origin circuit-breakers (#963), parallel to `origins` by
198    /// index. `breakers[i]` fronts `origins[i]`'s pull-through retry
199    /// loop so a sustained outage on one backend fast-fails its misses
200    /// without burning retry/backoff, while the chain still advances to
201    /// the next backend. Always the same length as `origins` (built
202    /// together in `open_full`); an empty `origins` yields an empty
203    /// `breakers` and the `NoOrigin` short-circuit never reaches them.
204    breakers: Vec<OriginBreaker>,
205    max_blob_bytes: u64,
206    /// Per-hash recency for eviction ordering: the instant of the last access,
207    /// or the open-time seed.
208    ///
209    /// Holds every blob the open-time store walk finds, seeded at
210    /// [`Inner::cold_seed_at`], plus every access since open at the instant of
211    /// that access. The seed makes a
212    /// blob from before a restart an eviction candidate without traffic.
213    ///
214    /// Sharded. A serve completion's [`CacheEngine::record_access`] takes one
215    /// shard's lock, so its cost is independent of the cached-blob count and it
216    /// never queues behind a full-map scan. The scans — `eviction_candidates`
217    /// and `access_times_snapshot` — walk shard by shard and hold one shard at a
218    /// time, so a completion contends only when it lands on the shard the scan
219    /// is inside at that moment.
220    ///
221    /// A scan therefore reads a shard-by-shard view rather than one instant: a
222    /// record that lands while the walk is in progress may or may not appear in
223    /// its result. Recency is advisory input to eviction ordering, and a hash
224    /// missed by one sweep is seen by the next.
225    access_times: DashMap<Hash, Instant>,
226    /// The single instant the open-time seed writes into `access_times`
227    /// ([`COLD_SEED_AGE`] before open). An entry that still holds this value has
228    /// had no access since open, so [`CacheEngine::last_accessed`] reports none
229    /// for it.
230    cold_seed_at: Instant,
231    /// Hashes whose `decdn-partial-` protecting tag this process has already
232    /// written and not observed dropped. A partial's protection only needs the
233    /// tag to EXIST, and the name is deterministic per hash, so re-admitting more
234    /// ranges of the same blob need not re-issue the store write — one fill can
235    /// admit hundreds of ranges. [`CacheEngine::protect_partial`] short-circuits on
236    /// a present entry; [`CacheEngine::drop_named_tags_for`] removes the entry
237    /// before deleting the tag, so the next admit re-protects. Lock-free
238    /// (`DashMap`) so it never serializes the admit hot path. Starts empty each
239    /// process, so the first admit after restart harmlessly re-sets the tag once.
240    partial_protected: DashMap<Hash, ()>,
241    /// Present-byte count of each partial blob, shared by
242    /// [`CacheEngine::size_snapshot`] and the GC callback's walk. See
243    /// [`PARTIAL_SIZE_TTL`] for why the walks do not re-observe every
244    /// partial every time.
245    partial_sizes: Arc<PartialSizeMemo>,
246    /// In-flight pull-through requests. When a pull is in progress for a hash,
247    /// subsequent callers wait on the [`Notify`] rather than issuing a
248    /// duplicate origin fetch (coalescing, fixes #305).
249    ///
250    /// Production code locks it only through [`Inner::lock_inflight`] —
251    /// never `.lock()` directly. See that method for why poison must be
252    /// recovered here rather than skipped. (Tests below reach for `.lock()`
253    /// deliberately, to poison it and to read its length through
254    /// `PoisonError::into_inner`.)
255    inflight: Mutex<HashMap<Hash, Arc<Notify>>>,
256    /// One-shot latch for the [`Inner::lock_inflight`] poison log (#1517).
257    /// The counter carries the true count of poisonings; this bounds the
258    /// log to one line even under a pathological panic loop.
259    inflight_poison_logged: AtomicBool,
260    /// Operator-pinned blob hashes (#276). Pinned hashes are excluded from
261    /// the eviction-candidates snapshot and therefore survive any LRU
262    /// pressure. Held in [`ArcSwap`] so SIGHUP reloads can swap in a new
263    /// set atomically without rebuilding the engine — the pattern mirrors
264    /// the `Arc<AtomicU64>` used for `payment.rate_per_mb` (commit
265    /// 166ae41); pinning sets aren't `Copy`, so `ArcSwap` is the
266    /// non-blocking equivalent for `HashSet<Hash>`.
267    ///
268    /// Pinning interacts with the `evicted` field in one direction only:
269    /// pinning prevents *LRU* eviction (issue #276) but does not protect
270    /// against an explicit operator [`CacheEngine::evict`] (#279) — an
271    /// operator running a DMCA takedown on a pinned hash gets the
272    /// takedown, full stop. The pin just keeps the hash off the LRU
273    /// candidate list.
274    pinned: ArcSwap<HashSet<Hash>>,
275    /// Origin-held discovery index (#1130): hashes this node can serve from a
276    /// configured origin *before* any pull-through, each mapped to its total
277    /// byte size. Rebuilt wholesale by [`CacheEngine::rescan_origins`] (fs
278    /// directory enumeration ∪ present operator pins) and swapped atomically,
279    /// mirroring `pinned`. Probe and DHT-announce read it so cold origin
280    /// content is discoverable on the first request rather than only after a
281    /// warm pulls it into the store. Never includes refused/denied hashes.
282    origin_held: ArcSwap<OriginHeldIndex>,
283    /// Single-flight slot for [`CacheEngine::rescan_origins`], with one queued
284    /// rerun. See `RESCAN_IDLE` / `RESCAN_RUNNING` / `RESCAN_QUEUED`.
285    ///
286    /// A rescan reads the current index (to carry a faulted candidate forward),
287    /// probes every candidate, and only then publishes — a read-modify-write
288    /// spanning the whole walk. Both production triggers fire detached, and a
289    /// walk gets slower exactly when the origin is faulting, so overlapping
290    /// passes would let the slower one publish a payload derived from a
291    /// pre-empted index and drop whatever the fresher pass found.
292    ///
293    /// Excluding is not enough on its own: queueing every trigger behind a lock
294    /// would pile up one waiter per tick for as long as a walk outruns the
295    /// cadence, then run that backlog of obsolete passes back to back. The slot
296    /// collapses any number of triggers into a single rerun, which is all a
297    /// rerun can be worth — the next pass re-derives everything from scratch.
298    rescan_slot: AtomicU8,
299    /// Live-origin probe memo (#1130 pt3). The `origin_held` index only covers
300    /// fs enumeration ∪ pins — http/s3 do not list, so a non-pinned bucket
301    /// object is absent from it. [`CacheEngine::origin_probe_size`] falls back to
302    /// a live `HEAD`/`HeadObject` for such hashes and memoises the answer here
303    /// (positive AND negative) under `cache.origin_probe_ttl_sec`, so a probe
304    /// flood costs at most one origin round-trip per hash per TTL window rather
305    /// than one per probe. Restart-configured like `max_probe_holds`; swapped in
306    /// wholesale by [`CacheEngine::set_origin_probe_config`] at bring-up.
307    origin_probe_memo: Mutex<OriginProbeMemo>,
308    /// Hashes this node refuses to serve, announce, or acquire because the
309    /// operator's own `[content] denied_hashes` names them, and which a later
310    /// config reload can UN-refuse (ADR 011 § Local Denylist). The governance
311    /// half lives in [`Self::chain_denied`].
312    ///
313    /// Distinct from [`Self::evicted`] on exactly one axis: reversibility.
314    /// Eviction is a durable, sticky operator act recorded in `evicted.log`;
315    /// this set is a live policy view swapped wholesale from the current
316    /// denylist. A wrongful takedown must be reversible without editing a log
317    /// file and restarting, and ADR 011 § One-hour removal orders puts this
318    /// mechanism on a statutory clock in both directions.
319    ///
320    /// It lives HERE rather than beside the origin deny-set in `decdn-node`
321    /// because "will this node serve/announce/acquire `hash`" has five
322    /// consumers — the serve path, `try_probe_hold`, the DHT republisher,
323    /// `populate`, and the per-MB in-flight re-check that terminates a stream
324    /// already running when a takedown lands — and the ADR requires one answer
325    /// for all of them. Keeping it next to `evicted` is what lets
326    /// [`CacheEngine::refuses`] be the single predicate they all call, so the
327    /// next consumer inherits the check instead of forgetting it. The in-flight
328    /// one is exactly that: it was added later and needed no wiring of its own.
329    denied: ArcSwap<HashSet<Hash>>,
330    /// Hashes refused because *governance* blacklisted them — the blacklist
331    /// watcher's live projection of `ContentBlacklist`, kept in its own slot
332    /// beside [`Self::denied`] for the same reason the node keeps local and
333    /// on-chain origins apart: the two have independent lifecycles, and a config
334    /// reload calling [`CacheEngine::set_denied`] must not clobber what the
335    /// chain watcher learned (nor the reverse).
336    ///
337    /// Membership is not what stops the serving — the watcher also
338    /// [`CacheEngine::evict`]s, which is sticky and durable. What this set adds
339    /// is the *reason*, and the reason picks the refusal reason the node
340    /// meters: a denied hash rather than `EvictedSinceProbe`, the reason the
341    /// eviction arm reports without this slot. On the wire every one of these
342    /// refusals is `Declined`, so a governance takedown and this operator's own
343    /// denylist stay indistinguishable (ADR 011 §`StreamRequest` Response).
344    ///
345    /// It is also the earlier of the two gates: the watcher denies *before* it
346    /// evicts, so a takedown whose durable eviction fails still stops serving on
347    /// the same tick rather than waiting for the retry.
348    chain_denied: ArcSwap<HashSet<Hash>>,
349    /// Hashes the operator has explicitly evicted via [`CacheEngine::evict`]
350    /// (issue #279). Membership is honored by [`CacheEngine::has`] and
351    /// [`CacheEngine::get`] so an evicted blob is not served, even though
352    /// the underlying iroh-blobs store may still hold the bytes —
353    /// `Blobs::delete` is `pub(crate)` in iroh-blobs and reserved for the
354    /// GC task. [`CacheEngine::evict`] deletes the blob's protecting named
355    /// tag(s) (#860), so reclaim of disk bytes then happens on the next
356    /// iroh-blobs GC sweep, configured via `cache.gc_interval_sec` (#518).
357    /// Reclaim is therefore best-effort: it requires GC to be enabled *and*
358    /// the tag deletion to have succeeded — a failed delete leaves the bytes
359    /// GC-protected and is surfaced via `tag_drop_failures` (serving is
360    /// blocked regardless). On-demand (synchronous) reclamation is tracked
361    /// under #520, blocked on upstream exposing the sweep API.
362    ///
363    /// Persisted alongside the iroh-blobs store at `<cache_dir>/evicted.log`
364    /// on every successful [`CacheEngine::evict`] call so DMCA takedowns and
365    /// other operator evicts survive a process restart — an
366    /// in-memory-only set would silently let evicted content resume serving
367    /// after `decdn run` is restarted, which is exactly the failure mode
368    /// #279 needs to prevent.
369    ///
370    /// Append-only, so the serve-path membership check in
371    /// [`CacheEngine::is_evicted`] is a lock-free load (#1789 item 5) and
372    /// readers never block on the rarer writer. Unlike [`Self::denied`] and
373    /// [`Self::chain_denied`], which are replaced wholesale on a config reload,
374    /// this set may only grow: an operator eviction is a takedown, and a hash
375    /// that stopped being served must not start again. [`MonotoneHashSet`]
376    /// carries that difference.
377    evicted: MonotoneHashSet,
378    /// Held hashes whose own serve export failed bao validation against the
379    /// content root: the stored bytes or outboard diverged after admission
380    /// (disk rot or tampering). [`CacheEngine::refuses`] honors membership, so
381    /// the node stops serving, announcing, and re-acquiring the hash while the
382    /// corrupt entry is on disk. The quarantine drops the protecting tags, so
383    /// the next GC sweep reclaims the entry. The membership lifts on the next
384    /// lookup or origin rescan that finds the store no longer holds the hash
385    /// (see [`CacheEngine::is_quarantined`]). A later pull-through then admits
386    /// a freshly verified copy.
387    ///
388    /// In memory only. A restart before the sweep forgets the entry, and the
389    /// next serve of the corrupt bytes trips the validation and quarantines it
390    /// again. A durable record is unnecessary: the validated export aborts
391    /// every serve at the first mismatching chunk group, so no buyer pays for
392    /// a corrupt group in the window.
393    quarantined: DashMap<Hash, ()>,
394    /// Probe-triggered eviction holds (#318, ADR 005 §Probe-triggered
395    /// eviction hold). Maps a held hash to its hold *expiry* instant; a
396    /// held hash is invisible to [`CacheEngine::eviction_candidates`] until
397    /// expiry, composing *above* the LRU layer. Holds are per-blob, not
398    /// per-probe: many peers probing the same hash share (and refresh) one
399    /// entry, so the slot count is bounded by distinct held blobs, not
400    /// probe volume. Expired entries are swept lazily on every hold
401    /// admission, every `eviction_candidates` call, and every
402    /// [`CacheEngine::probe_hold_slots_used`] read (no background task).
403    /// Expiries run on the tokio clock so a paused test clock drives them.
404    ///
405    /// Like [`Self::pinned`] this only blocks *LRU* eviction — an explicit
406    /// operator [`CacheEngine::evict`] still wins (ADR 040 §Pinning, durable
407    /// operator-evict, and the probe-hold stay engine-enforced: DMCA always
408    /// wins), enforced
409    /// because [`CacheEngine::try_probe_hold`] gates on [`CacheEngine::has`]
410    /// which already
411    /// honors the evicted set.
412    probe_holds: Mutex<HashMap<Hash, tokio::time::Instant>>,
413    /// Hold-budget cap (ADR 005 §Hold budget). `0` disables `has_blob: true`
414    /// entirely. Set once from `cache.max_probe_holds` via
415    /// [`CacheEngine::set_max_probe_holds`] at runtime bring-up — `cache.*`
416    /// is restart-required (not hot-reloaded), so an atomic written once is
417    /// sufficient and avoids threading the value through every `open_*`
418    /// constructor and its many test call sites.
419    max_probe_holds: AtomicUsize,
420    /// Optional shared frequency signal (ADR 040). `None` for the `lru`/`always`
421    /// default — the observe call is skipped, so recency-only pays nothing.
422    frequency: ArcSwap<Option<Arc<dyn crate::policy::FrequencyEstimator>>>,
423    /// Admission policy consulted at store-time (ADR 040). Always present —
424    /// defaults to [`crate::policy::AlwaysAdmit`] (store to
425    /// [`crate::policy::Segment::Main`]), so behavior is unchanged until an
426    /// operator selects a different policy. `ArcSwap<Arc<dyn Trait>>` rather
427    /// than `ArcSwap<dyn Trait>`: the latter needs `RefCnt: Sized`, which
428    /// `arc-swap` 1.9.2 does not give a `dyn` trait object.
429    admission: ArcSwap<Arc<dyn crate::policy::AdmissionPolicy>>,
430    /// Generic `hash -> Segment` membership the engine tracks with no meaning
431    /// attached (ADR 040 §1). Admission stores the label it chose; the eviction
432    /// policy reads and moves it at the sweep. Pure in-memory metadata — the
433    /// blob's normal commit tag still provides GC protection, so no tag I/O is
434    /// tied to it. Only non-default (`Probation`) entries are stored; an absent
435    /// hash reads back as [`crate::policy::Segment::Main`], so under the default
436    /// `AlwaysAdmit` (always `Main`) the map stays empty and inert.
437    segments: Mutex<HashMap<Hash, crate::policy::Segment>>,
438    /// Append-only file holding lowercase-hex evicted hashes, one per line.
439    /// Loaded on [`CacheEngine::open`]; appended to (with `fsync`) on every
440    /// successful [`CacheEngine::evict`]. Lives at `<cache_dir>/evicted.log`.
441    /// The format is intentionally trivial so operators can grep / inspect /
442    /// hand-edit it during incident response; duplicate lines are tolerated
443    /// (loading deduplicates via the `HashSet`).
444    evicted_log_path: PathBuf,
445    /// Origin pull-through retry policy (#285). Set once at construction;
446    /// changes require a restart. `RetryPolicy: Copy` so the per-fetch
447    /// read is a single struct copy.
448    retry_policy: RetryPolicy,
449    /// Optional handle to the cache-side `OpenMetrics` counters (#285).
450    /// The engine bumps `origin_fetches` once per pull-through and the
451    /// retry loop bumps `origin_retry_exhausted` on terminal exhaustion.
452    /// `None` in tests / non-metrics builds — bumps short-circuit.
453    metrics: Option<Arc<CacheMetrics>>,
454    /// Broadcast channel announcing successful blob commits to interested
455    /// subscribers (DHT republish per ADR 022 §STORE Flow). Producers (the
456    /// `pull_through` success arm) call `send` and ignore the
457    /// "no-active-receivers" error — broadcast is fire-and-forget. The
458    /// channel is bounded; lagged receivers see [`broadcast::error::RecvError::Lagged`]
459    /// on the next recv and decide for themselves whether to backfill —
460    /// the DHT-side subscriber treats it as "force a republish sweep on
461    /// the next tick", so a brief stall in the consumer doesn't lose
462    /// blobs from the republish set.
463    inserts_tx: broadcast::Sender<Hash>,
464    /// Strong reference to the GC callback's late-bound `FsStore`
465    /// handle (#518). `Some` when `gc_interval > 0` was passed to
466    /// [`CacheEngine::open_full`], `None` when GC is disabled.
467    ///
468    /// **Why this lives here:** iroh-blobs spawns its GC loop on its
469    /// internal runtime when `Options.gc` is set. The loop's cb captures
470    /// a [`Weak<OnceLock<FsStore>>`] rather than a strong `Arc`, so the
471    /// cb itself contributes no strong refcount to the iroh-blobs
472    /// `FsStore` handle. When this `Inner` drops, the strong `Arc` here
473    /// drops with it; subsequent cb fires see `Weak::upgrade -> None`
474    /// and silently no-op (no metric writes, no work) — the cb is no
475    /// longer wedged in a cycle that keeps it alive.
476    ///
477    /// **What this does NOT do:** the iroh-blobs GC loop itself
478    /// (`run_gc(store: Store, ...)`) owns its own `Store` clone for the
479    /// lifetime of its `loop`, separate from anything in `Inner`. So
480    /// `Inner.drop()` does not shut iroh-blobs' internal runtime down;
481    /// the runtime sits resident-but-idle until the surrounding process
482    /// exits (or until `gc_run_once` errors and breaks the loop). For
483    /// the current single-engine, process-lifetime model this is
484    /// benign. #520 tracks driving the loop ourselves once iroh-blobs
485    /// exposes `gc_run_once`, at which point engine drop will be able
486    /// to abort the loop directly.
487    ///
488    /// **Invariant:** no `Arc::clone` of this field may escape `Inner`.
489    /// Cloning the strong `Arc` into a longer-lived owner would re-
490    /// introduce a cb-side strong ref via the round-trip and defeat
491    /// the cycle-break, leaving the cb running with a populated
492    /// `Weak` past engine drop.
493    ///
494    /// `dead_code` is allowed because nothing *reads* this field —
495    /// its only purpose is keeping the `Arc` strong-ref alive for the
496    /// lifetime of `Inner`. The `Weak` captured into the cb is the
497    /// reading party.
498    #[allow(dead_code)]
499    gc_store_handle: Option<Arc<OnceLock<FsStore>>>,
500    /// Range-aware in-flight fill registry (ADR 038). Coalesces concurrent
501    /// serve-misses for the same hash: a request whose range an in-flight pull
502    /// already covers attaches an observer instead of opening a duplicate pull,
503    /// and a partial overlap opens a pull for only its remainder. Also holds the
504    /// per-hash captured outboard every serve leg reads. Purely synchronous range
505    /// math; holds no blob bytes. Driven through [`CacheEngine::claim_fill`].
506    fill_registry: Arc<FillRegistry>,
507    /// Bounds the [`CacheEngine::origin_range_wire`] encodes that run at once
508    /// (#2065); each holds a permit for its whole span and `O(window + outboard)`
509    /// bytes, so the bound caps their sum.
510    own_origin_range_pulls: Arc<tokio::sync::Semaphore>,
511    /// Per configured origin (same index as `origins`): whether the origin has
512    /// served a clean first range window. Set on the first such window, by the
513    /// serviceability probe ([`CacheEngine::origin_range_serviceable`]) or a
514    /// draw, and never cleared, so the probe's range half reads nothing for a
515    /// confirmed origin. An origin that later stops serving ranges fails its
516    /// draw like any other mid-life origin fault.
517    origin_range_confirmed: Box<[AtomicBool]>,
518    /// Origin `{H}.obao4` outboards already read, keyed by hash, tagged with
519    /// the serving origin, and bounded by bytes. The serviceability probe's
520    /// outboard half ([`CacheEngine::origin_fetch_outboard_bytes`]) and every
521    /// [`CacheEngine::origin_range_wire`] draw read it and fill it. Each
522    /// [`OriginRangeWire`] holds a handle and evicts the copy it used when the
523    /// range fails bao verification.
524    outboards: OutboardCache,
525    /// One async lock per hash whose outboard an origin read is fetching, so
526    /// concurrent cold misses of one hash wait for the first read and reuse its
527    /// cached copy instead of each downloading it. Held as `Weak`: an entry
528    /// lives while a fetch holds its lock, and dead entries are pruned on the
529    /// next lookup.
530    outboard_flights: Mutex<HashMap<Hash, Weak<tokio::sync::Mutex<()>>>>,
531    /// Head start, in milliseconds, of the time budget for one origin read of
532    /// the range-pull path. `0` means no budget. Set once at bring-up by
533    /// [`CacheEngine::set_origin_read_budget`].
534    origin_read_head_start_ms: AtomicU64,
535    /// Throughput floor, in bytes per second, that the rest of that budget
536    /// scales with. `0` means no budget.
537    origin_read_min_bps: AtomicU64,
538}
539
540impl Inner {
541    /// The only way to lock [`Inner::inflight`] (#1517).
542    ///
543    /// **Poison is recovered and cleared, not skipped.** This mutex guards
544    /// the fill-coalescing map, which is what stops N concurrent requests
545    /// for one missing blob from opening N origin pulls. Skipping the
546    /// critical section — what `.lock().ok()` did before #1517 — loses
547    /// coalescing, and because nothing cleared the poison, it lost it for
548    /// the rest of the process. On a metered `http`/`s3` origin that is an
549    /// unbounded multiplier on the egress bill; on the `Peer` origin
550    /// reached via `populate` it is a double-spend of USDC vouchers to
551    /// upstream nodes — the hazard the in-flight coalescing map exists to prevent.
552    /// Recovering the guard keeps that invariant intact, and matches the
553    /// reasoning already written down for `evicted` (see
554    /// [`CacheEngine::evict`] and [`CacheEngine::is_evicted`]).
555    ///
556    /// The map is only ever read, inserted into, and removed from under
557    /// this lock — no user code runs inside the critical section, at any of
558    /// the six call sites — so a recovered guard cannot observe a torn
559    /// `HashMap`. Having established that, [`Mutex::clear_poison`] (stable
560    /// since 1.77) returns the mutex to a healthy state. That
561    /// is what makes the counter below mean *"how many tasks panicked in
562    /// here"* rather than *"how many times we locked since one did"* — the
563    /// latter climbs at request rate forever and reads on a `rate()` panel
564    /// as a raging ongoing incident long after a single panic.
565    ///
566    /// **It is still reported.** The anti-panic policy makes poison close
567    /// to unreachable, so a firing here is a genuine bug: it bumps
568    /// `decdn_cache_inflight_mutex_poisoned_total` once per poisoning, and
569    /// logs on the first one (latched via `inflight_poison_logged` — with
570    /// the clear above, a repeat means a *new* panic rather than an echo of
571    /// the old one, but the latch still bounds a pathological panic loop).
572    ///
573    /// Note the coalescing wait this guards (`notify.notified().await` in
574    /// [`CacheEngine::get`]) has no deadline of its own; callers impose
575    /// their own (the node wraps `populate` in `tokio::time::timeout`).
576    fn lock_inflight(&self) -> MutexGuard<'_, HashMap<Hash, Arc<Notify>>> {
577        match self.inflight.lock() {
578            Ok(guard) => guard,
579            Err(poisoned) => {
580                if let Some(m) = &self.metrics {
581                    m.inflight_mutex_poisoned.inc();
582                }
583                if !self.inflight_poison_logged.swap(true, Ordering::Relaxed) {
584                    tracing::error!(
585                        "inflight coalescing mutex poisoned; recovering inner state and \
586                         clearing the poison. A task panicked while holding it — this is a \
587                         bug, not an operational condition. Coalescing is preserved and no \
588                         restart is needed; any further poisonings are counted by \
589                         decdn_cache_inflight_mutex_poisoned_total but not re-logged."
590                    );
591                }
592                self.inflight.clear_poison();
593                poisoned.into_inner()
594            }
595        }
596    }
597}
598
599// `PinnedHashes` and `PinDiff` moved to the `decdn-config-types` leaf
600// crate (#578) and are imported above. The engine holds its pinned set
601// internally as `HashSet<Hash>` (the iroh-blobs store hash) and converts
602// at the public boundary via `to_store_hash` / `from_store_hash`.
603
604/// Append-only set of hashes with a lock-free read path.
605///
606/// Every published snapshot is a superset of its predecessor. That is the
607/// property [`CacheEngine::is_evicted`] relies on: a takedown observed once is
608/// observed by every later reader, so a lock-free read can never answer
609/// not-evicted for a hash the operator has already evicted.
610///
611/// Writers serialize on `writer`, so the publish is a single uncontended
612/// `Arc` clone-and-swap rather than a CAS retry loop that re-clones the whole
613/// set on every lost race. The clone itself is O(n) in the set's size, which is
614/// the cost this shape accepts to keep the read side free — writes are operator
615/// takedowns and blacklist enforcement, reads are on every serve.
616#[derive(Debug)]
617struct MonotoneHashSet {
618    snapshot: ArcSwap<HashSet<Hash>>,
619    /// Serializes writers so the read-modify-write below is atomic: without it
620    /// the cap check and the "was it already present" answer are both racy.
621    writer: std::sync::Mutex<()>,
622}
623
624impl MonotoneHashSet {
625    fn new(initial: HashSet<Hash>) -> Self {
626        Self {
627            snapshot: ArcSwap::from(Arc::new(initial)),
628            writer: std::sync::Mutex::new(()),
629        }
630    }
631
632    fn contains(&self, hash: Hash) -> bool {
633        self.snapshot.load().contains(&hash)
634    }
635
636    /// The current snapshot, for a caller that needs several questions answered
637    /// against one consistent view.
638    fn snapshot(&self) -> arc_swap::Guard<Arc<HashSet<Hash>>> {
639        self.snapshot.load()
640    }
641
642    /// Add `hash` unless the set already holds it or is at `cap`. Returns
643    /// `false` only when `cap` would be exceeded — an already-present hash is
644    /// a success, since the set already says what the caller wants it to say.
645    ///
646    /// Atomic with respect to other writers, so two concurrent inserts cannot
647    /// both read a set one below `cap` and both land.
648    fn insert_if_absent(&self, hash: Hash, cap: usize) -> bool {
649        let _writer = self.writer.lock().unwrap_or_else(PoisonError::into_inner);
650        let current = self.snapshot.load();
651        if current.contains(&hash) {
652            return true;
653        }
654        if current.len() >= cap {
655            return false;
656        }
657        let mut next = HashSet::clone(&current);
658        next.insert(hash);
659        drop(current);
660        self.snapshot.store(Arc::new(next));
661        true
662    }
663}
664
665/// Serve-path verdict for one hash, from a single store `status()` call.
666///
667/// Answers in one store contact what the delivery path asks in two —
668/// [`CacheEngine::has`] for presence and [`CacheEngine::inspect`] for size
669/// (#1789 item 7 part B). The variants are the delivery path's own branches,
670/// so a size is reachable only where it is serveable and a refusal can never
671/// be paired with a wire size.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub enum ServeAudit {
674    /// The store reports the blob complete and no gate refuses it (denied /
675    /// chain-denied / evicted) — the same condition [`CacheEngine::has`]
676    /// reports. `size` is the whole-blob wire size, and `0` means a genuinely
677    /// empty blob.
678    Serveable {
679        /// Whole-blob byte size to advertise on the wire.
680        size: u64,
681    },
682    /// Nothing serveable from the local store: the blob is absent, partial, or
683    /// refused by a gate.
684    Unavailable {
685        /// Whether this node withdrew the hash on its own authority: an
686        /// operator eviction ([`CacheEngine::is_evicted`], #279) or a
687        /// stored-corruption quarantine ([`CacheEngine::is_quarantined`]). The
688        /// miss path must not fill a withdrawn hash: a fill of an evicted hash
689        /// would undo the takedown, and a fill of a quarantined hash would land
690        /// on the corrupt entry and pay for bytes it can never serve. A
691        /// quarantined hash becomes fillable again only after GC reclaims the
692        /// entry and the quarantine lifts; an evicted hash never does.
693        withdrawn: bool,
694    },
695}
696
697impl ServeAudit {
698    /// Wire size for a warm hit; `None` when the delivery path must fill and
699    /// then size the blob itself.
700    #[must_use]
701    pub const fn hit_size(&self) -> Option<u64> {
702        match self {
703            Self::Serveable { size } => Some(*size),
704            Self::Unavailable { .. } => None,
705        }
706    }
707
708    /// Whether the blob can be served from the local store right now.
709    #[must_use]
710    pub const fn is_serveable(&self) -> bool {
711        matches!(self, Self::Serveable { .. })
712    }
713
714    /// Whether this node withdrew the hash: evicted or quarantined. `false`
715    /// for a serveable hash, since both are gates that refuse a serve.
716    #[must_use]
717    pub const fn is_withdrawn(&self) -> bool {
718        matches!(self, Self::Unavailable { withdrawn: true })
719    }
720}
721
722/// Read-only snapshot of a hash's local-cache state, returned by
723/// [`CacheEngine::inspect`]. Backs `decdn node evict --dry-run`
724/// (issue #379): operators running DMCA takedowns want to confirm the blob's size, last-access
725/// time, pin status, and already-evicted flag before mutating state.
726///
727/// All fields reflect the *underlying* cache state — `size_bytes` reads
728/// from the iroh-blobs store directly, so a hash that has already been
729/// logically evicted (and whose bytes are still on disk pending the
730/// follow-up GC sweep in #518) still reports its size here. That keeps
731/// dry-run honest about disk reclaim potential rather than hiding it once
732/// the operator has flipped the evicted flag. For a complete blob the size
733/// is its byte length; for a partial blob it is the validated total (see
734/// [`Self::size_bytes`]), and [`CacheEngine::size_snapshot`] gives the bytes
735/// it holds on disk.
736#[derive(Debug, Default, Clone, PartialEq, Eq)]
737pub struct EvictionPreview {
738    /// Bytes the iroh-blobs store reports for this hash. `None` when the
739    /// blob isn't in the store; matches `BlobStatus::NotFound`. A partial
740    /// blob reports `BlobStatus::Partial { size }` as is: the whole blob's
741    /// size once its last chunk validates, `None` before. That is the blob's
742    /// total, not the bytes it holds on disk — the delivery path signs it as
743    /// the blob's total and the probe path advertises it, and
744    /// [`CacheEngine::size_snapshot`] is the on-disk measure.
745    pub size_bytes: Option<u64>,
746    /// Microseconds elapsed since the last recorded access to the blob: a
747    /// serve, a hit, or a fill. `None` when the blob has had no access since
748    /// open — including a blob on disk at open that nothing has touched since
749    /// (its open-time recency seed is not an access) — or when eviction or
750    /// quarantine has cleared its entry.
751    pub last_accessed_us_ago: Option<u64>,
752    /// Whether the hash is in the operator-pinned set (#276). Pinning
753    /// protects against LRU eviction but **not** against an explicit
754    /// [`CacheEngine::evict`]; surfaced here so a dry-run operator can
755    /// spot the case before running the takedown.
756    pub pinned: bool,
757    /// Whether the hash already lives in `<cache_dir>/evicted.log`.
758    /// `true` means a real [`CacheEngine::evict`] would short-circuit
759    /// at the idempotency guard at the top of `evict()` — the dry-run
760    /// is reporting on a no-op.
761    pub already_evicted: bool,
762    /// Whether [`CacheEngine::has`] would currently return `true` for
763    /// this hash — equivalently, `BlobStatus::Complete` *and* not
764    /// already evicted. Pre-computed inside `inspect()` so the admin
765    /// RPC doesn't need a second `has()` round-trip to fill the
766    /// `was_present` field on its response.
767    pub served: bool,
768    /// Ordered list of backend kinds the engine would consult on a
769    /// post-eviction miss (#439, #284). Empty when no origin is
770    /// configured (cache-only mode); otherwise one [`OriginKind`] per
771    /// entry of the operator-supplied fallback chain, in order.
772    /// Operators running takedowns or LRU sweeps use this to estimate
773    /// the worst-case origin egress cost — re-pulling from a
774    /// `Filesystem` origin is a local read; re-pulling from `Http` or
775    /// `S3` may consume metered bandwidth, and a chain of three S3
776    /// origins multiplies the bill on a deep fallback.
777    pub origin_kinds: Vec<OriginKind>,
778}
779
780/// The chunk ranges of a hash currently present in the store.
781///
782/// This is the presence oracle the range-aware cache API builds on: `read`
783/// serves only present ranges, resume re-fetches only missing ones, and disk
784/// admission accounts present bytes. Absent and logically-evicted hashes report
785/// empty + not-complete, matching [`CacheEngine::has`]'s eviction masking.
786#[derive(Debug, Clone)]
787pub struct PresentRanges {
788    ranges: ChunkRanges,
789    complete: bool,
790    size: u64,
791}
792
793impl PresentRanges {
794    fn absent() -> Self {
795        Self {
796            ranges: ChunkRanges::empty(),
797            complete: false,
798            size: 0,
799        }
800    }
801
802    /// The whole blob is present.
803    #[must_use]
804    pub const fn is_complete(&self) -> bool {
805        self.complete
806    }
807
808    /// No range of this hash is present (absent or evicted).
809    #[must_use]
810    pub fn is_empty(&self) -> bool {
811        self.ranges.is_empty()
812    }
813
814    /// The present ranges, in chunk units.
815    #[must_use]
816    pub const fn chunk_ranges(&self) -> &ChunkRanges {
817        &self.ranges
818    }
819
820    /// The blob's total size in bytes, from the `observe()` bitfield.
821    ///
822    /// Unlike `status()`, which iroh-blobs leaves unknown for a partial blob
823    /// until its LAST chunk validates, the bitfield knows the full size as
824    /// soon as any chunk carries it (a front-prefix partial with no tail
825    /// still reports it here). `0` for an absent/evicted/refused hash.
826    #[must_use]
827    pub const fn size(&self) -> u64 {
828        self.size
829    }
830}
831
832/// Bytes of a `size`-byte blob that `ranges` marks present.
833///
834/// `ranges` counts 1 KiB chunks ([`bao_tree::ChunkNum`]). An open span ends at
835/// `size`: iroh-blobs gives a bitfield that holds the blob's last chunk an open
836/// end ([`ChunkRanges::all`] once complete). The `.min(size)` clamp bounds every
837/// other span and counts a short last chunk at its true length. iroh-blobs'
838/// `Bitfield::total_bytes` computes the same sum with unchecked subtraction,
839/// which underflows on a span that starts past `size`.
840fn present_byte_count(ranges: &ChunkRanges, size: u64) -> u64 {
841    const CHUNK_BYTES: u64 = 1024;
842    let byte = |chunk: &bao_tree::ChunkNum| chunk.0.saturating_mul(CHUNK_BYTES).min(size);
843    ranges.boundaries().chunks(2).fold(0u64, |acc, span| {
844        let start = span.first().map_or(size, byte);
845        let end = span.get(1).map_or(size, byte);
846        acc.saturating_add(end.saturating_sub(start))
847    })
848}
849
850/// The chunk span of discovery block `index` of a `size`-byte blob.
851///
852/// One bao leaf chunk is 1024 bytes ([`bao_tree::ChunkNum`]'s unit), so one
853/// 64 MiB [`decdn_protocol::discovery_block_bytes`] block is 65536 chunks. The
854/// last block ends at the blob's last chunk. The block size is the same
855/// accessor [`decdn_protocol::num_blocks`] reads, so the two agree when a test
856/// overrides it.
857fn discovery_block_span(index: u32, size: u64) -> ChunkRanges {
858    const BAO_CHUNK_BYTES: u64 = 1024;
859    let chunks_per_block = decdn_protocol::discovery_block_bytes() / BAO_CHUNK_BYTES;
860    let total_chunks = size.div_ceil(BAO_CHUNK_BYTES);
861    let start = u64::from(index) * chunks_per_block;
862    let end = (start + chunks_per_block).min(total_chunks);
863    ChunkRanges::from(bao_tree::ChunkNum(start)..bao_tree::ChunkNum(end))
864}
865
866/// Indices of the discovery blocks of a `size`-byte blob that `present` fully
867/// covers. A block with any missing chunk is not covered.
868fn covered_blocks(size: u64, present: &ChunkRanges) -> impl Iterator<Item = u32> + '_ {
869    // `span - present` is empty iff `present` fully contains `span`, i.e.
870    // every chunk in this block is on disk.
871    (0..decdn_protocol::num_blocks(size))
872        .filter(move |&i| (discovery_block_span(i, size) - present).is_empty())
873}
874
875/// Bytes of `hash` present on disk, from its `observe()` bitfield.
876///
877/// `status()` cannot size a partial blob: iroh-blobs leaves the size unknown
878/// until the last chunk validates, then reports the whole blob's size.
879/// Awaiting `observe()` returns only its first item, the current bitfield,
880/// which the store sends at once, so the call never waits on a later write. A
881/// hash that GC removes after its `status()` reads as 0 bytes.
882async fn observed_present_bytes(
883    blobs: &iroh_blobs::api::blobs::Blobs,
884    hash: Hash,
885) -> CacheResult<u64> {
886    let bitfield = blobs.observe(hash).await.map_err(|e| {
887        CacheError::Store(anyhow::Error::from(e).context(format!("observe partial {hash}")))
888    })?;
889    Ok(present_byte_count(&bitfield.ranges, bitfield.size()))
890}
891
892/// How long a partial blob's present-byte count in [`PartialSizeMemo`] stays
893/// fresh.
894///
895/// Each `observe()` of an idle partial loads its store entry, and the entry's
896/// idle shutdown fsyncs its data, outboard and sizes files and rewrites its
897/// bitfield file. The eviction driver walks the store every tick (1 s by
898/// default), so the walk re-observes a partial at most once per TTL. A growing
899/// fill reads up to one TTL of progress low.
900const PARTIAL_SIZE_TTL: Duration = Duration::from_secs(30);
901
902/// Present-byte count of each partial blob, with the instant the walk
903/// measured it. [`snapshot_blob_sizes`] reads and refreshes it; the eviction
904/// driver's walk and the GC callback's walk share one memo.
905type PartialSizeMemo = Mutex<HashMap<Hash, (u64, Instant)>>;
906
907/// Snapshot of access times for blobs that are eligible for LRU
908/// eviction — i.e. **pinned hashes are already excluded**. Returned by
909/// [`CacheEngine::eviction_candidates`].
910///
911/// The one carve-out is a deny-listed hash: it stays a candidate even when
912/// pinned ("deny wins over pin"), so the space path reclaims an unservable
913/// blob instead of holding it on disk.
914///
915/// The newtype makes "pinned-already-excluded" a *type-level* property:
916/// any future eviction-policy implementation that takes
917/// `EvictionCandidates` is guaranteed by the compiler not to evict
918/// pinned hashes. With a raw `HashMap<Hash, Instant>` return that
919/// guarantee would live only in a doc comment, and a future caller
920/// could substitute [`CacheEngine::access_times_snapshot`] (which
921/// includes pinned) by mistake.
922#[derive(Debug, Default)]
923pub struct EvictionCandidates(HashMap<Hash, Instant>);
924
925impl EvictionCandidates {
926    /// Number of eviction candidates.
927    #[must_use]
928    pub fn len(&self) -> usize {
929        self.0.len()
930    }
931
932    /// Are there no eviction candidates?
933    #[must_use]
934    pub fn is_empty(&self) -> bool {
935        self.0.is_empty()
936    }
937
938    /// Does `hash` appear in the candidate set?
939    #[must_use]
940    pub fn contains_key(&self, hash: &Hash) -> bool {
941        self.0.contains_key(hash)
942    }
943
944    /// Iterate `(hash, last-access)` pairs.
945    pub fn iter(&self) -> std::collections::hash_map::Iter<'_, Hash, Instant> {
946        self.0.iter()
947    }
948
949    /// Consume into the underlying `HashMap`. Eviction policies that
950    /// need to sort by access time and pop top-K can call this once at
951    /// the start of their loop. The newtype's invariant
952    /// (pinned-already-excluded) is preserved by the time this returns
953    /// — the caller just gets a plain map to work with.
954    #[must_use]
955    pub fn into_inner(self) -> HashMap<Hash, Instant> {
956        self.0
957    }
958
959    /// Wrap a hand-built map so policy tests can construct a candidate set
960    /// without running a real sweep.
961    #[cfg(test)]
962    #[must_use]
963    pub const fn from_map_for_test(map: HashMap<Hash, Instant>) -> Self {
964        Self(map)
965    }
966}
967
968impl<'a> IntoIterator for &'a EvictionCandidates {
969    type Item = (&'a Hash, &'a Instant);
970    type IntoIter = std::collections::hash_map::Iter<'a, Hash, Instant>;
971
972    fn into_iter(self) -> Self::IntoIter {
973        self.0.iter()
974    }
975}
976
977/// RAII cleanup for an inflight pull-through entry. Removing the entry and
978/// waking waiters in `Drop` keeps the coalescing map consistent even if the
979/// owning task is cancelled (or panics) mid-fetch — without this, a cancelled
980/// pull would leave the entry in place and every subsequent request for the
981/// same hash would block forever on a `Notify` that never fires.
982struct InflightGuard<'a> {
983    hash: Hash,
984    inner: &'a Inner,
985    notify: &'a Arc<Notify>,
986}
987
988impl Drop for InflightGuard<'_> {
989    fn drop(&mut self) {
990        // Removal goes through `lock_inflight`, which recovers a poisoned
991        // guard (#1517). Dropping the removal on poison — what the previous
992        // `if let Ok(..)` did — leaked the entry, and since `notify_waiters`
993        // only wakes *current* waiters, every later request for this hash
994        // would then park on a `Notify` that never fires again: the exact
995        // permanent hang this guard exists to prevent.
996        self.inner.lock_inflight().remove(&self.hash);
997        self.notify.notify_waiters();
998    }
999}
1000
1001/// Hard cap on the number of distinct hashes the in-memory `evicted`
1002/// set may hold. Bounds (a) the resident memory of the set itself and
1003/// (b) the unbounded growth of `<cache_dir>/evicted.log` under e.g. a
1004/// mass-evict automation gone wrong. At ~64 bytes per `HashSet<Hash>`
1005/// entry the cap is ~64 MB resident. Operators legitimately hitting
1006/// this are operating well outside normal DMCA-takedown caseloads and
1007/// should investigate before raising it. Loading from `evicted.log` on
1008/// `open()` is *not* gated by this cap — entries persisted by a previous
1009/// run always replay (silently dropping a persisted DMCA takedown to
1010/// stay under cap is the worst-case the cap was meant to prevent).
1011pub(crate) const MAX_EVICTED_ENTRIES: usize = 1_000_000;
1012
1013/// Read the evicted-hash log into a [`HashSet`]. A missing file is the
1014/// normal "no evictions yet" case and yields an empty set; any other I/O
1015/// or parse error is fatal because silently dropping persisted evictions
1016/// would resume serving DMCA-flagged content (issue #279). Lines that
1017/// don't parse as a 64-character hex hash are skipped with a `warn` log
1018/// — operators may have hand-edited the file during incident response,
1019/// and one corrupt line shouldn't take the whole log down.
1020fn load_evicted_log(path: &Path) -> CacheResult<HashSet<Hash>> {
1021    let contents = match std::fs::read_to_string(path) {
1022        Ok(s) => s,
1023        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(HashSet::new()),
1024        Err(err) => {
1025            return Err(CacheError::Store(anyhow::Error::from(err).context(
1026                format!("failed to read evicted-hash log at {}", path.display()),
1027            )));
1028        }
1029    };
1030    let mut out = HashSet::new();
1031    for raw in contents.lines() {
1032        let line = raw.trim();
1033        if line.is_empty() || line.starts_with('#') {
1034            continue;
1035        }
1036        match parse_hex_hash(line) {
1037            Some(h) => {
1038                out.insert(h);
1039            }
1040            None => {
1041                tracing::warn!(
1042                    line = %line,
1043                    path = %path.display(),
1044                    "skipping malformed entry in evicted-hash log",
1045                );
1046            }
1047        }
1048    }
1049    Ok(out)
1050}
1051
1052/// Parse a 64-char hex BLAKE3 hash. Accepts either case so hand-edited
1053/// log entries (operators pasting a hash from access logs / takedown
1054/// notices, which may use either case) round-trip through the same
1055/// parser the admin RPC accepts. The persisted format is canonically
1056/// lowercase via `Hash::Display`'s `to_hex()`, so this only relaxes the
1057/// read path — writes remain lowercase, and load-then-save normalizes
1058/// silently. Strict on length: a corrupted log line surfaces as `None`
1059/// (logged + skipped at load time) rather than silently turning into
1060/// the wrong hash.
1061fn parse_hex_hash(s: &str) -> Option<Hash> {
1062    if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
1063        return None;
1064    }
1065    let mut bytes = [0u8; 32];
1066    for (i, byte) in bytes.iter_mut().enumerate() {
1067        // First nibble is the high nibble (bits 7..4): hex `ab` decodes
1068        // to `0xab`, not `0xba`. Naming follows that semantic so an
1069        // audit-sensitive DMCA-takedown codepath isn't decoded against
1070        // mis-labeled variables.
1071        let hi = s.as_bytes().get(i * 2)?;
1072        let lo = s.as_bytes().get(i * 2 + 1)?;
1073        *byte = (hex_digit(*hi)? << 4) | hex_digit(*lo)?;
1074    }
1075    Some(Hash::from_bytes(bytes))
1076}
1077
1078const fn hex_digit(c: u8) -> Option<u8> {
1079    match c {
1080        b'0'..=b'9' => Some(c - b'0'),
1081        b'a'..=b'f' => Some(c - b'a' + 10),
1082        b'A'..=b'F' => Some(c - b'A' + 10),
1083        _ => None,
1084    }
1085}
1086
1087/// `add_protected` body for [`CacheEngine::open_full`]'s GC wiring (#518).
1088///
1089/// Runs once per iroh-blobs sweep cycle, before `gc_run_once`. Snapshots
1090/// the current blob set, attributes "what disappeared since the last
1091/// snapshot" to the previous sweep's reclaim, bumps the cache metrics,
1092/// and saves the snapshot for the next cycle's diff. Always returns
1093/// without adding any hashes to `live` — named-tag promotion in
1094/// [`CacheEngine::pull_through`] and `TempTag` lifetimes are what protect
1095/// cached / in-progress blobs; this callback is purely instrumentation.
1096///
1097/// **Why the spawn-and-wait dance:** iroh-blobs requires the
1098/// `ProtectCb` future to be `Send + Sync`, but its own RPC layer
1099/// (`blobs().list().stream()`, `blobs().status(...)`) returns futures
1100/// that are only `Send`. Awaiting those directly here would leak the
1101/// non-Sync constraint into our outer future. Spawning the snapshot
1102/// work on a separate task and awaiting a [`tokio::sync::oneshot`]
1103/// receiver (Send + Sync as long as `T: Send`) gives the outer future
1104/// the auto-trait shape iroh-blobs demands.
1105///
1106/// **Errors are logged and dropped, never propagated as `Abort`.** Any
1107/// future refactor that wants to surface them should keep returning
1108/// [`ProtectOutcome::Continue`] — `Abort` would skip the sweep itself,
1109/// letting the disk-leak threat the GC was added to mitigate keep
1110/// growing. The next cycle's snapshot recovers the count attribution as
1111/// long as the store eventually services the list/status calls.
1112///
1113/// **Engine-drop semantics:** `store_handle` is a [`Weak`] of the
1114/// `Arc<OnceLock<FsStore>>` that lives in `Inner.gc_store_handle`.
1115/// When the engine drops, that strong `Arc` drops with it, the
1116/// `Weak::upgrade` here returns `None`, and the cb returns silently —
1117/// no metric writes, no work, no contribution to the cb's surviving
1118/// strong-ref graph. iroh-blobs' GC loop itself owns its own `Store`
1119/// clone (via `run_gc(store: Store, ...)`) and keeps running for the
1120/// rest of the process lifetime; that's an upstream design constraint
1121/// tracked under #520. What this `Weak` ensures is that the cb body
1122/// no-ops cleanly past engine drop rather than spuriously snapshotting
1123/// or bumping metrics on a drained engine.
1124async fn gc_protect_callback(
1125    store_handle: Weak<OnceLock<FsStore>>,
1126    prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>>,
1127    partial_sizes: Arc<PartialSizeMemo>,
1128    metrics: Option<Arc<CacheMetrics>>,
1129) {
1130    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1131    tokio::spawn(async move {
1132        gc_protect_inner(store_handle, prev_pre_sweep, partial_sizes, metrics).await;
1133        let _ = tx.send(());
1134    });
1135    if let Err(err) = rx.await {
1136        // The spawned task panicked or was dropped before sending.
1137        // Surface it: a panic in `gc_protect_inner` would otherwise be
1138        // invisible (this cb just returns `Continue` either way).
1139        tracing::warn!(error = %err, "gc protect spawn dropped without completing; metrics may have skipped a cycle");
1140    }
1141}
1142
1143async fn gc_protect_inner(
1144    store_handle: Weak<OnceLock<FsStore>>,
1145    prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>>,
1146    partial_sizes: Arc<PartialSizeMemo>,
1147    metrics: Option<Arc<CacheMetrics>>,
1148) {
1149    // `Weak::upgrade` returning `None` is the post-engine-drop steady
1150    // state: `Inner.gc_store_handle` (the strong `Arc`) has dropped,
1151    // the iroh-blobs runtime is in the process of being aborted, and
1152    // any further cb fires before the abort lands have nothing to
1153    // measure against. Quietly skip — this is not an error.
1154    let Some(store_arc) = store_handle.upgrade() else {
1155        return;
1156    };
1157    let Some(store) = store_arc.get() else {
1158        // Practically unreachable: iroh-blobs' `run_gc` calls `sleep`
1159        // first, and we `set` the OnceLock immediately after
1160        // `load_with_opts` returns. A `None` here would mean a future
1161        // refactor enabled near-zero intervals or moved the `set`. Log
1162        // and skip rather than panic.
1163        tracing::warn!("gc protect callback fired before store handle was set");
1164        return;
1165    };
1166
1167    let current = match snapshot_blob_sizes(
1168        store,
1169        &partial_sizes,
1170        Instant::now(),
1171        metrics.as_deref(),
1172    )
1173    .await
1174    {
1175        Ok(snap) => snap,
1176        Err(err) => {
1177            tracing::warn!(error = %err.display_chain(), "gc snapshot failed; skipping reclaim attribution this cycle");
1178            return;
1179        }
1180    };
1181
1182    let reclaimed_bytes: u64 = {
1183        // Surface poison: this mutex guards metrics-only state, so a
1184        // poisoned lock means a *prior* invocation of this function
1185        // panicked while holding the guard (i.e., somewhere in the
1186        // diff/fold loop). Recover the inner state to keep metrics
1187        // flowing — the next cycle re-establishes a baseline — but log
1188        // once so the panic doesn't hide.
1189        //
1190        // Poison handling in this file is decided **per lock site**, not
1191        // per mutex, so the honest summary is a spectrum rather than a
1192        // taxonomy:
1193        // - `inflight` recovers, clears the poison, `error!`s once and
1194        //   counts it at all six sites, because losing its invariant costs
1195        //   origin egress and USDC. See [`Inner::lock_inflight`].
1196        // - `evicted` and `probe_holds` recover silently everywhere; for
1197        //   `evicted` that is load-bearing (skipping would resume serving
1198        //   a DMCA-takedown hash) and written up at `evict`/`is_evicted`.
1199        // - `access_times` has no entry here: it is a `DashMap`, so no
1200        //   poisoning decision exists to make. A panic while one of its shards
1201        //   is locked leaves that shard usable, and every reader and writer
1202        //   takes one shard at a time.
1203        // - `origin_probe_memo` recovers silently via `probe_memo_lock`.
1204        // - `partial_sizes` recovers silently: it caches store reads, and the
1205        //   next walk re-observes any entry it lost.
1206        // - `prev_pre_sweep` (here) is the only metrics-only one: recover
1207        //   + `warn!`, since the next cycle re-establishes a baseline.
1208        let mut guard = match prev_pre_sweep.lock() {
1209            Ok(g) => g,
1210            Err(poisoned) => {
1211                tracing::warn!("gc prev_pre_sweep mutex poisoned; recovering inner state");
1212                poisoned.into_inner()
1213            }
1214        };
1215        let bytes = guard
1216            .iter()
1217            .filter(|(hash, _)| !current.contains_key(*hash))
1218            .fold(0u64, |acc, (_, size)| acc.saturating_add(*size));
1219        *guard = current;
1220        bytes
1221    };
1222
1223    if let Some(m) = metrics.as_ref() {
1224        m.gc_runs.inc();
1225        m.gc_bytes_reclaimed.inc_by(reclaimed_bytes);
1226    }
1227}
1228
1229/// Snapshot the iroh-blobs blob set keyed by hash, with each entry's
1230/// size in bytes. Used by [`gc_protect_callback`] to diff sweep cycles
1231/// (#518) and by [`CacheEngine::size_snapshot`]. Hashes that race the
1232/// snapshot (deleted between `list` and `status`) report
1233/// `BlobStatus::NotFound` and are dropped — they cannot have contributed
1234/// bytes either way.
1235///
1236/// A `Partial` blob reports the bytes its bitfield marks present
1237/// ([`observed_present_bytes`]), not `status()`'s size. Including
1238/// partials matters: the threat model that motivated #518 is exactly
1239/// the partial-import case (`add_stream` errored mid-flight, the
1240/// `TempTag` was dropped, but the bytes already on disk are what we
1241/// want GC to reclaim). A partial that GC removes between `status` and
1242/// `observe` stays in the map at 0 bytes.
1243///
1244/// `memo` holds each partial's last count. A count younger than
1245/// [`PARTIAL_SIZE_TTL`] at `now` is reused without an `observe()`. A failed
1246/// `observe()` falls back to the last count, else to `status()`'s size, and
1247/// the fallback is memoized for one TTL so a dead store entry is retried and
1248/// logged once per TTL, not every walk. Each failure ticks
1249/// `partial_size_observe_failures`. Only a `list` or `status` failure, which
1250/// is store-wide, fails the walk. The walk drops memo entries for hashes that
1251/// are no longer partial.
1252async fn snapshot_blob_sizes(
1253    store: &FsStore,
1254    memo: &PartialSizeMemo,
1255    now: Instant,
1256    metrics: Option<&CacheMetrics>,
1257) -> CacheResult<HashMap<Hash, u64>> {
1258    let blobs = store.blobs();
1259    let mut stream = blobs
1260        .list()
1261        .stream()
1262        .await
1263        .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1264    let mut out = HashMap::new();
1265    let mut partials = HashSet::new();
1266    while let Some(hash) = stream.next().await {
1267        let hash = hash.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1268        let status = blobs
1269            .status(hash)
1270            .await
1271            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1272        let size = match status {
1273            iroh_blobs::api::blobs::BlobStatus::NotFound => continue,
1274            iroh_blobs::api::blobs::BlobStatus::Partial { size } => {
1275                partials.insert(hash);
1276                partial_size(blobs, hash, size, memo, now, metrics).await
1277            }
1278            iroh_blobs::api::blobs::BlobStatus::Complete { size } => size,
1279        };
1280        out.insert(hash, size);
1281    }
1282    memo.lock()
1283        .unwrap_or_else(PoisonError::into_inner)
1284        .retain(|hash, _| partials.contains(hash));
1285    Ok(out)
1286}
1287
1288/// One partial blob's entry in [`snapshot_blob_sizes`]: the memoized count
1289/// while fresh, else a new `observe()`, else a fallback. `status_size` is
1290/// `status()`'s size for the blob. See [`snapshot_blob_sizes`] for the rules.
1291async fn partial_size(
1292    blobs: &iroh_blobs::api::blobs::Blobs,
1293    hash: Hash,
1294    status_size: Option<u64>,
1295    memo: &PartialSizeMemo,
1296    now: Instant,
1297    metrics: Option<&CacheMetrics>,
1298) -> u64 {
1299    let cached = memo
1300        .lock()
1301        .unwrap_or_else(PoisonError::into_inner)
1302        .get(&hash)
1303        .copied();
1304    if let Some((bytes, at)) = cached
1305        && now.saturating_duration_since(at) < PARTIAL_SIZE_TTL
1306    {
1307        return bytes;
1308    }
1309    let bytes = match observed_present_bytes(blobs, hash).await {
1310        Ok(bytes) => bytes,
1311        Err(err) => {
1312            let fallback = cached.map_or_else(|| status_size.unwrap_or(0), |(bytes, _)| bytes);
1313            tracing::warn!(
1314                %hash,
1315                error = %err.display_chain(),
1316                fallback,
1317                "partial blob size: observe failed; using the last count or the status() size"
1318            );
1319            if let Some(m) = metrics {
1320                m.partial_size_observe_failures.inc();
1321            }
1322            fallback
1323        }
1324    };
1325    memo.lock()
1326        .unwrap_or_else(PoisonError::into_inner)
1327        .insert(hash, (bytes, now));
1328    bytes
1329}
1330
1331/// Append `hash` to the evicted log with `fsync` before returning. The
1332/// caller relies on the durability guarantee — a return-without-error
1333/// means a crash now will replay the eviction on the next `open`.
1334fn append_evicted_log(path: &Path, hash: Hash) -> std::io::Result<()> {
1335    if let Some(parent) = path.parent() {
1336        std::fs::create_dir_all(parent)?;
1337    }
1338    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
1339    // POSIX guarantees `write` calls under PIPE_BUF (typically 4 KiB) are
1340    // atomic on append-mode file handles. A 64-char hash + newline is
1341    // 65 bytes, well under the limit, so concurrent writers from a
1342    // misconfigured shared cache_dir won't interleave bytes mid-line.
1343    let line = format!("{hash}\n");
1344    file.write_all(line.as_bytes())?;
1345    file.sync_all()?;
1346    Ok(())
1347}
1348
1349/// One origin's answer to an outboard read, after the exact-length gate.
1350enum GatedOutboard {
1351    /// The origin served an outboard of the exact length.
1352    Found(Bytes),
1353    /// The origin has no outboard for the hash, or does not publish outboards.
1354    Declined,
1355    /// The origin served an outboard of the wrong length, which cannot verify.
1356    WrongLength,
1357}
1358
1359impl CacheEngine {
1360    /// Open or create the store at `cache_dir`. `max_blob_mb` caps the size
1361    /// of any single blob pulled from the origin. Oversize payloads typically
1362    /// surface as [`CacheError::OriginError`] (the HTTP origin trips the cap
1363    /// mid-stream before the engine sees the bytes), or as
1364    /// [`CacheError::BlobTooLarge`] when a custom `Origin` impl returns bytes
1365    /// that exceed the cap without self-enforcement.
1366    pub async fn open(
1367        cache_dir: &Path,
1368        origins: Vec<Arc<dyn Origin>>,
1369        max_blob_mb: u64,
1370    ) -> CacheResult<Self> {
1371        Self::open_with_pinned(cache_dir, origins, max_blob_mb, PinnedHashes::empty()).await
1372    }
1373
1374    /// Open the cache with an initial pinning set. The set is held in an
1375    /// [`ArcSwap`] internally so subsequent SIGHUP reloads can call
1376    /// [`Self::set_pinned`] without rebuilding the engine.
1377    ///
1378    /// Defaults the retry policy to [`RetryPolicy::default`], wires no
1379    /// metrics handle, and disables periodic GC. Use [`Self::open_full`] to
1380    /// override any of those — the node crate plugs in its
1381    /// `Arc<CacheMetrics>`, the operator-configured retry policy, and the
1382    /// `cache.gc_interval_sec` through that path.
1383    pub async fn open_with_pinned(
1384        cache_dir: &Path,
1385        origins: Vec<Arc<dyn Origin>>,
1386        max_blob_mb: u64,
1387        pinned: PinnedHashes,
1388    ) -> CacheResult<Self> {
1389        Self::open_full(
1390            cache_dir,
1391            origins,
1392            max_blob_mb,
1393            pinned,
1394            RetryPolicy::default(),
1395            CircuitBreakerPolicy::default(),
1396            None,
1397            Duration::ZERO,
1398        )
1399        .await
1400    }
1401
1402    /// Open the cache with full control over policy, metrics, and GC wiring.
1403    /// The runtime's `build_cache` uses this directly; tests usually want
1404    /// [`Self::open`] or [`Self::open_with_pinned`] with their defaults.
1405    ///
1406    /// `gc_interval` controls iroh-blobs' built-in GC sweep loop (#518).
1407    /// [`Duration::ZERO`] disables periodic GC; any other value is forwarded
1408    /// to [`iroh_blobs::store::GcConfig`] and iroh-blobs spawns its own GC
1409    /// task on its internal runtime. Reclaimable bytes accumulate between
1410    /// sweeps — set the interval based on how much disk you're willing to
1411    /// lose to a hostile-origin amplification window.
1412    ///
1413    /// **Why iroh-blobs drives the loop instead of us:** the sweep
1414    /// function (`gc::gc_run_once`) lives in iroh-blobs 0.103's private
1415    /// `store::gc` module and is not re-exported, and `Blobs::delete`
1416    /// is `pub(crate)`. The only externally-reachable trigger is
1417    /// `Options::gc`. #520 tracks switching to a runtime-driven loop
1418    /// with manual on-demand GC (`admin_v1_cacheGc` / `decdn node gc`)
1419    /// once upstream exposes the sweep API.
1420    #[allow(clippy::too_many_arguments)]
1421    pub async fn open_full(
1422        cache_dir: &Path,
1423        origins: Vec<Arc<dyn Origin>>,
1424        max_blob_mb: u64,
1425        pinned: PinnedHashes,
1426        retry_policy: RetryPolicy,
1427        circuit_breaker: CircuitBreakerPolicy,
1428        metrics: Option<Arc<CacheMetrics>>,
1429        gc_interval: Duration,
1430    ) -> CacheResult<Self> {
1431        Self::open_full_with_clock(
1432            cache_dir,
1433            origins,
1434            max_blob_mb,
1435            pinned,
1436            retry_policy,
1437            circuit_breaker,
1438            metrics,
1439            gc_interval,
1440            Arc::new(SystemClock::new()),
1441        )
1442        .await
1443    }
1444
1445    /// [`Self::open_full`] with an injectable [`Clock`] driving the
1446    /// per-origin circuit-breaker cooldown (#963). Production goes
1447    /// through `open_full` (which supplies a [`SystemClock`]); tests use
1448    /// this to drive the breaker's cooldown deterministically with a
1449    /// [`crate::ManualClock`].
1450    #[allow(clippy::too_many_arguments)]
1451    pub async fn open_full_with_clock(
1452        cache_dir: &Path,
1453        origins: Vec<Arc<dyn Origin>>,
1454        max_blob_mb: u64,
1455        pinned: PinnedHashes,
1456        retry_policy: RetryPolicy,
1457        circuit_breaker: CircuitBreakerPolicy,
1458        metrics: Option<Arc<CacheMetrics>>,
1459        gc_interval: Duration,
1460        clock: Arc<dyn Clock>,
1461    ) -> CacheResult<Self> {
1462        tokio::fs::create_dir_all(cache_dir)
1463            .await
1464            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1465
1466        // Late-bound store handle for the GC `add_protected` callback. The
1467        // callback is captured into `Options.gc` *before* `FsStore` exists,
1468        // but the cb needs to query `blobs().list()` to compute the
1469        // pre-sweep snapshot. Solution: build `Arc<OnceLock<FsStore>>` here,
1470        // capture a `Weak` into the cb, and `set` the OnceLock as soon as
1471        // the store handle is in hand. The strong `Arc` is moved into
1472        // `Inner.gc_store_handle` so its lifetime tracks the engine; on
1473        // engine drop the cb's `Weak::upgrade` returns `None`, which is
1474        // exactly what breaks the cb → `FsStore` clone → iroh-blobs actor
1475        // → GC task → cb cycle.
1476        //
1477        // `None` when GC is disabled — no cb is registered, so no late-
1478        // bind handle is needed.
1479        let gc_store_handle: Option<Arc<OnceLock<FsStore>>> = if gc_interval.is_zero() {
1480            None
1481        } else {
1482            Some(Arc::new(OnceLock::new()))
1483        };
1484
1485        // Previous-cycle pre-sweep snapshot. The cb runs once per cycle
1486        // *before* `gc_run_once`, so the diff between the snapshot saved
1487        // last cycle and the snapshot taken this cycle is exactly the
1488        // hash set the previous sweep deleted (the cache crate has no
1489        // other public delete path: `Blobs::delete` is `pub(crate)` in
1490        // iroh-blobs 0.103). Bytes for that diff is what the previous
1491        // sweep reclaimed; we attribute it on the *current* cb fire.
1492        // First-cycle fires bump the runs counter but emit zero on
1493        // the reclaim counter because there is no prior snapshot.
1494        let prev_pre_sweep: Arc<Mutex<HashMap<Hash, u64>>> = Arc::new(Mutex::new(HashMap::new()));
1495        let partial_sizes: Arc<PartialSizeMemo> = Arc::new(Mutex::new(HashMap::new()));
1496
1497        let mut options = FsStoreOptions::new(cache_dir);
1498        if let Some(strong) = gc_store_handle.as_ref() {
1499            let store_weak: Weak<OnceLock<FsStore>> = Arc::downgrade(strong);
1500            let prev_for_cb = Arc::clone(&prev_pre_sweep);
1501            let sizes_for_cb = Arc::clone(&partial_sizes);
1502            let metrics_for_cb = metrics.clone();
1503            options.gc = Some(GcConfig {
1504                interval: gc_interval,
1505                add_protected: Some(Arc::new(move |_live: &mut HashSet<Hash>| {
1506                    let store_weak = store_weak.clone();
1507                    let prev_for_cb = Arc::clone(&prev_for_cb);
1508                    let sizes_for_cb = Arc::clone(&sizes_for_cb);
1509                    let metrics_for_cb = metrics_for_cb.clone();
1510                    Box::pin(async move {
1511                        gc_protect_callback(store_weak, prev_for_cb, sizes_for_cb, metrics_for_cb)
1512                            .await;
1513                        ProtectOutcome::Continue
1514                    })
1515                })),
1516            });
1517        }
1518
1519        let db_path = cache_dir.join("blobs.db");
1520        let store = FsStore::load_with_opts(db_path, options)
1521            .await
1522            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
1523
1524        // Late-bind: the cb's `Weak<OnceLock<FsStore>>` can now upgrade
1525        // and `.get()` to reach the store handle. `set` only fails if
1526        // the OnceLock was already populated, which can't happen on
1527        // this code path (we just constructed it).
1528        if let Some(strong) = gc_store_handle.as_ref() {
1529            let _ = strong.set(store.clone());
1530        }
1531
1532        // Saturate-on-overflow: an operator setting `max_blob_mb = u64::MAX`
1533        // as a de-facto "unlimited" value should still yield a usable byte cap
1534        // rather than overflow-wrap to zero.
1535        let max_blob_bytes = max_blob_mb.saturating_mul(1024 * 1024);
1536
1537        let evicted_log_path = cache_dir.join("evicted.log");
1538        let evicted = load_evicted_log(&evicted_log_path)?;
1539
1540        // One breaker per origin, parallel by index. Each shares the
1541        // single injected clock and the cache metrics handle so trip /
1542        // recovery / short-circuit counters land in the same encoder
1543        // output as the rest of the cache group (#963).
1544        let breakers = origins
1545            .iter()
1546            .map(|_| OriginBreaker::new(circuit_breaker, Arc::clone(&clock), metrics.clone()))
1547            .collect::<Vec<_>>();
1548        let origin_range_confirmed = origins.iter().map(|_| AtomicBool::new(false)).collect();
1549
1550        // `checked_sub` fails only within `COLD_SEED_AGE` of the monotonic
1551        // clock's origin (near boot). Seeding at `opened` then still sorts at or
1552        // before every later access.
1553        let opened = Instant::now();
1554        let cold_seed_at = opened.checked_sub(COLD_SEED_AGE).unwrap_or(opened);
1555
1556        let engine = Self {
1557            inner: Arc::new(Inner {
1558                store,
1559                origins,
1560                breakers,
1561                max_blob_bytes,
1562                access_times: DashMap::new(),
1563                cold_seed_at,
1564                partial_protected: DashMap::new(),
1565                partial_sizes,
1566                inflight: Mutex::new(HashMap::new()),
1567                inflight_poison_logged: AtomicBool::new(false),
1568                pinned: ArcSwap::from(Arc::new(
1569                    pinned
1570                        .iter()
1571                        .map(|h| to_store_hash(*h))
1572                        .collect::<HashSet<Hash>>(),
1573                )),
1574                origin_held: ArcSwap::from(Arc::new(OriginHeldIndex::default())),
1575                rescan_slot: AtomicU8::new(RESCAN_IDLE),
1576                origin_probe_memo: Mutex::new(OriginProbeMemo::default()),
1577                denied: ArcSwap::from(Arc::new(HashSet::new())),
1578                chain_denied: ArcSwap::from(Arc::new(HashSet::new())),
1579                evicted: MonotoneHashSet::new(evicted),
1580                quarantined: DashMap::new(),
1581                probe_holds: Mutex::new(HashMap::new()),
1582                max_probe_holds: AtomicUsize::new(crate::probe_hold::DEFAULT_MAX_PROBE_HOLDS),
1583                frequency: ArcSwap::from_pointee(None),
1584                admission: ArcSwap::from_pointee(
1585                    Arc::new(crate::policy::AlwaysAdmit) as Arc<dyn crate::policy::AdmissionPolicy>
1586                ),
1587                segments: Mutex::new(HashMap::new()),
1588                evicted_log_path,
1589                retry_policy,
1590                fill_registry: Arc::new(FillRegistry::with_metrics(metrics.clone())),
1591                metrics,
1592                // Bounded channel — slow consumers (e.g. a republish
1593                // scheduler under load) lag instead of backpressuring
1594                // the cache hot path. Cap of 1024 matches the dispatch
1595                // limiter's similar in-flight slot count; sized
1596                // generously enough that a steady-state pull rate above
1597                // 1000/s would have to also lose all subscribers for
1598                // the lag to actually fire.
1599                inserts_tx: broadcast::channel(1024).0,
1600                gc_store_handle,
1601                own_origin_range_pulls: Arc::new(tokio::sync::Semaphore::new(
1602                    MAX_CONCURRENT_RANGE_PULLS,
1603                )),
1604                origin_range_confirmed,
1605                outboards: OutboardCache::new(OUTBOARD_CACHE_BYTES, OUTBOARD_CACHE_ENTRIES),
1606                outboard_flights: Mutex::new(HashMap::new()),
1607                origin_read_head_start_ms: AtomicU64::new(0),
1608                origin_read_min_bps: AtomicU64::new(0),
1609            }),
1610        };
1611        engine.seed_access_times_from_store().await;
1612        Ok(engine)
1613    }
1614
1615    /// Enter every blob already on disk into `access_times` at
1616    /// [`Inner::cold_seed_at`], so eviction can release content from before a
1617    /// restart without waiting for traffic to touch it.
1618    ///
1619    /// The seed runs before `open` returns the engine, so no access can land
1620    /// first; `or_insert` is defensive. The walk is [`Self::iter_hashes`], which
1621    /// skips refused hashes. At open only the durable evicted set is populated,
1622    /// so operator-evicted hashes stay out and every other stored blob enters
1623    /// the map. [`Self::eviction_candidates`] then filters pinned and probe-held
1624    /// hashes as for any other entry.
1625    ///
1626    /// A failed walk logs a WARN, increments `recency_seed_failures`, and leaves
1627    /// the map unseeded. Open still succeeds and the cache still serves every
1628    /// blob, but until the next restart eviction sees only blobs accessed after
1629    /// open.
1630    async fn seed_access_times_from_store(&self) {
1631        let hashes = match self.iter_hashes().await {
1632            Ok(hashes) => hashes,
1633            Err(err) => {
1634                if let Some(m) = &self.inner.metrics {
1635                    m.recency_seed_failures.inc();
1636                }
1637                tracing::warn!(
1638                    error = %err.display_chain(),
1639                    "cache open: store walk failed; blobs from before this start are not \
1640                     eviction candidates until accessed, until the next restart \
1641                     (alert on decdn_cache_recency_seed_failures_total)"
1642                );
1643                return;
1644            }
1645        };
1646        let seeded = hashes.len();
1647        let seed = self.inner.cold_seed_at;
1648        for hash in hashes {
1649            self.inner.access_times.entry(hash).or_insert(seed);
1650        }
1651        tracing::info!(seeded, "cache open: seeded eviction recency from the store");
1652    }
1653
1654    /// Peek the in-flight fill registry for a LIVE fill of `hash`, returning its
1655    /// blob `total_bytes` if one runs. ADVISORY: the serve-miss path uses it to skip
1656    /// the upstream header handshake when it will coalesce onto a live pull, but the
1657    /// authoritative own-vs-attach decision stays in [`Self::claim_fill`]. See
1658    /// [`FillRegistry::in_flight_total`].
1659    #[must_use]
1660    pub fn in_flight_total(&self, hash: Hash) -> Option<u64> {
1661        self.inner.fill_registry.in_flight_total(hash)
1662    }
1663
1664    /// Atomically claim a serve-miss of `[offset, offset+len)` (`len == 0` = to end)
1665    /// of the `total`-byte blob `hash`: decide attach/own/mixed AND register any new
1666    /// owner session under one map-lock acquisition (no plan-then-register TOCTOU
1667    /// race). Returns [`FillClaim::Attach`] to coalesce wholly onto a live pull,
1668    /// [`FillClaim::Owner`] with a freshly-registered session covering the whole
1669    /// request, or [`FillClaim::Mixed`] to own a pull for a contiguous remainder while
1670    /// attaching a sibling for the overlap. `make_session` builds the session only on
1671    /// an owning branch (never built-and-dropped on a pure attach). See
1672    /// [`FillRegistry::claim`].
1673    pub fn claim_fill(
1674        &self,
1675        hash: Hash,
1676        offset: u64,
1677        len: u64,
1678        total: u64,
1679        make_session: impl FnOnce() -> Arc<FillSession>,
1680    ) -> FillClaim {
1681        self.inner
1682            .fill_registry
1683            .claim(hash, offset, len, total, make_session)
1684    }
1685
1686    /// Subscribe to a stream of `Hash`es announcing hashes as they become
1687    /// advertisable in the local store: a whole blob that the private
1688    /// `pull_through` committed, or a ranged admit ([`Self::admit_bao`],
1689    /// [`Self::admit_bao_stream`]) that touched a fully present discovery
1690    /// block, whether the admit succeeded or failed partway. Used by the DHT republish scheduler (ADR
1691    /// 022 §STORE Flow — "When a node verifies its first 64 MiB block of blob
1692    /// H") to send the first publish-set to the K+3 closest peers.
1693    ///
1694    /// One hash can arrive more than once — a ranged fill announces once per
1695    /// block it completes — so a consumer dedupes against its own state.
1696    ///
1697    /// The channel is bounded and best-effort: lagged receivers see a
1698    /// [`broadcast::error::RecvError::Lagged`] on the next recv and
1699    /// MUST treat it as "force a full republish sweep" rather than try
1700    /// to backfill — the cache does not retain the missed hashes.
1701    #[must_use]
1702    pub fn subscribe_inserts(&self) -> broadcast::Receiver<Hash> {
1703        self.inner.inserts_tx.subscribe()
1704    }
1705
1706    /// Atomically swap the pinned-hashes set. Called by the runtime's
1707    /// SIGHUP handler when `cache.pinned_hashes` changes — readers (the
1708    /// eviction-candidate snapshot) observe either the old or the new set,
1709    /// never a partial mix. Returns a [`PinDiff`] so callers can log
1710    /// "added X, removed Y" without re-walking either set.
1711    ///
1712    /// Takes `&PinnedHashes` rather than ownership: callers commonly
1713    /// want to compute the diff without giving up their own copy. The
1714    /// leaf [`PinnedHashes`] is converted to the engine-internal
1715    /// `HashSet<Hash>` (iroh-blobs store hash) here — the cold SIGHUP
1716    /// path, so the O(n) conversion is acceptable; the hot
1717    /// `is_pinned`/`eviction_candidates` lookups stay on the store hash.
1718    pub fn set_pinned(&self, new: &PinnedHashes) -> PinDiff {
1719        let new_arc = Arc::new(
1720            new.iter()
1721                .map(|h| to_store_hash(*h))
1722                .collect::<HashSet<Hash>>(),
1723        );
1724        let prev_arc = self.inner.pinned.swap(Arc::clone(&new_arc));
1725        // Same set-difference arithmetic as `PinnedHashes::diff` (in
1726        // `decdn-config-types`), recomputed here over the store-hash
1727        // sets instead of round-tripping back to leaf hashes. The two
1728        // must stay behaviorally identical — `to_store_hash` is a
1729        // bijection on the 32 bytes, so cardinalities and membership are
1730        // preserved; if `PinnedHashes::diff` ever reports more than
1731        // counts (e.g. a sample of changed hashes), update both.
1732        let added = new_arc.iter().filter(|h| !prev_arc.contains(*h)).count();
1733        let removed = prev_arc.iter().filter(|h| !new_arc.contains(*h)).count();
1734        PinDiff { added, removed }
1735    }
1736
1737    /// Borrow a snapshot of the current pinned set, rebuilt as the leaf
1738    /// [`PinnedHashes`] (config-vocabulary hash) from the engine-internal
1739    /// store-hash set.
1740    ///
1741    /// **Cost:** O(n) — this allocates a fresh `HashSet` and converts
1742    /// every hash across the store↔leaf boundary. Call it off the hot
1743    /// path (it backs SIGHUP reload logging and admin snapshots, not
1744    /// per-request lookups).
1745    pub fn pinned_snapshot(&self) -> PinnedHashes {
1746        let set = self
1747            .inner
1748            .pinned
1749            .load()
1750            .iter()
1751            .map(|h| from_store_hash(*h))
1752            .collect::<HashSet<decdn_config_types::Hash>>();
1753        PinnedHashes::new(set)
1754    }
1755
1756    /// Snapshot of the active retry policy. Used by tests and startup
1757    /// logging. The policy is fixed at construction; changes require
1758    /// a restart.
1759    pub fn retry_policy(&self) -> RetryPolicy {
1760        self.inner.retry_policy
1761    }
1762
1763    /// Is `hash` currently pinned? Cheap O(1) lookup against the live set.
1764    pub fn is_pinned(&self, hash: Hash) -> bool {
1765        self.inner.pinned.load().contains(&hash)
1766    }
1767
1768    /// Rebuild the origin-held discovery index (#1130): the hashes this node
1769    /// can serve from a configured origin *before* any pull-through, so probe
1770    /// and DHT-announce can advertise them. The set is the union of
1771    ///
1772    /// - every enumerable origin's [`Origin::enumerate`] listing (the local
1773    ///   filesystem walks its sharded tree; HTTP/S3 enumerate to nothing), and
1774    /// - operator `pinned_hashes` the origin chain actually has (the remote
1775    ///   discovery path — HTTP has no listing, S3's is deliberately not walked,
1776    ///   so the pin list is their advertisement set),
1777    ///
1778    /// each mapped to its total byte size and filtered through [`Self::refuses`]
1779    /// so denied/blacklisted content is never advertised. Rebuilt wholesale and
1780    /// swapped atomically (like `pinned`): a concurrent reader sees the old or
1781    /// the new map, never a partial one.
1782    ///
1783    /// A candidate whose probe *faults* — a transport error, or the walk
1784    /// overrunning the probe timeout — keeps whatever size the previous index
1785    /// held for it instead of being dropped alongside the genuinely-absent. A
1786    /// fault is not an authoritative absence, and dropping on one shrinks the
1787    /// announce set for a whole rescan interval on a throttle window the origin
1788    /// recovers from in seconds. An origin that faults indefinitely therefore
1789    /// keeps a carried entry for as long as it keeps faulting; that is the
1790    /// intended trade, and the serve path answers from the live origin either
1791    /// way. Only a *retry-eligible* fault carries forward — a permanent one (a
1792    /// revoked ACL, a symlink escape) reads the same on every pass, so carrying
1793    /// it would advertise content this node can never serve until an operator
1794    /// intervenes. Faults are counted
1795    /// on `decdn_cache_origin_probe_failures_total` and reported to the DHT seed
1796    /// paths through [`Self::origin_held_snapshot`].
1797    ///
1798    /// An origin whose `enumerate` fails is the coarser version of the same
1799    /// thing: it contributes no candidates, so its hashes leave the index with
1800    /// no per-hash fault and nothing to carry forward. Only operator pins naming
1801    /// them survive. That leg counts on
1802    /// `decdn_cache_origin_enumerate_failures_total` and rides the same
1803    /// snapshot.
1804    ///
1805    /// Every adapter reserves [`Origin::size`]'s `Ok(None)` for an
1806    /// authoritative absence (a genuine 404): an HTTP or S3 5xx/408/429
1807    /// surfaces as a transient fault and carries forward, a permission decline
1808    /// as a permanent one — so no outage is dropped here as an absence.
1809    ///
1810    /// **Cost:** one origin probe per deduped candidate — every listed entry and
1811    /// every pin, including the ones that resolve absent and never enter the
1812    /// index. A local `metadata()` stat per fs entry, one HTTP `HEAD` / S3
1813    /// `HeadObject` per remote one. Runs off the hot path at the configured
1814    /// rescan cadence (startup / interval / reload), never per request.
1815    ///
1816    /// One pass at a time, with any number of triggers arriving during a pass
1817    /// collapsing into a single rerun. A trigger that finds a pass in flight
1818    /// returns immediately rather than awaiting it, so a walk that outruns the
1819    /// cadence cannot accumulate a backlog of waiters — and one rerun covers
1820    /// every trigger it coalesced, because the next pass re-derives everything
1821    /// from scratch.
1822    pub async fn rescan_origins(&self) {
1823        if !self.claim_rescan_slot() {
1824            // A pass owns the slot and will take another one for this request.
1825            return;
1826        }
1827        // RAII: a panic inside the walk would otherwise strand the slot and
1828        // disable every later rescan for the process lifetime.
1829        let mut guard = RescanSlotGuard {
1830            slot: &self.inner.rescan_slot,
1831            armed: true,
1832        };
1833        loop {
1834            self.rescan_once().await;
1835            if self
1836                .inner
1837                .rescan_slot
1838                .compare_exchange(
1839                    RESCAN_RUNNING,
1840                    RESCAN_IDLE,
1841                    Ordering::AcqRel,
1842                    Ordering::Acquire,
1843                )
1844                .is_ok()
1845            {
1846                // Released cleanly with nothing queued. The guard must not store
1847                // again: a trigger may already have claimed the slot.
1848                guard.armed = false;
1849                return;
1850            }
1851            // The CAS can only fail because a trigger arrived, so consume it and
1852            // take another pass.
1853            self.inner
1854                .rescan_slot
1855                .store(RESCAN_RUNNING, Ordering::Release);
1856        }
1857    }
1858
1859    /// Take the rescan slot, or register a rerun behind whoever holds it.
1860    ///
1861    /// Returns whether the caller owns the slot and must do the work. A request
1862    /// is never lost: it either claims the slot or moves it to `RESCAN_QUEUED`,
1863    /// and the running pass's release is a compare-exchange that fails if one
1864    /// landed first.
1865    fn claim_rescan_slot(&self) -> bool {
1866        loop {
1867            match self.inner.rescan_slot.compare_exchange_weak(
1868                RESCAN_IDLE,
1869                RESCAN_RUNNING,
1870                Ordering::AcqRel,
1871                Ordering::Acquire,
1872            ) {
1873                Ok(_) => return true,
1874                Err(RESCAN_RUNNING) => {
1875                    if self
1876                        .inner
1877                        .rescan_slot
1878                        .compare_exchange_weak(
1879                            RESCAN_RUNNING,
1880                            RESCAN_QUEUED,
1881                            Ordering::AcqRel,
1882                            Ordering::Acquire,
1883                        )
1884                        .is_ok()
1885                    {
1886                        return false;
1887                    }
1888                }
1889                // Already queued — that pass has not taken its snapshot yet, so
1890                // it covers this request too.
1891                Err(RESCAN_QUEUED) => return false,
1892                // Spurious failure or a state change under us; re-read and retry.
1893                Err(_) => {}
1894            }
1895        }
1896    }
1897
1898    /// One rescan pass: gather candidates, resolve them, publish the index.
1899    async fn rescan_once(&self) {
1900        self.lift_reclaimed_quarantines().await;
1901        let (candidates, enumerate_failures) = self.rescan_candidates().await;
1902        let RescanResolution {
1903            held,
1904            faults,
1905            carried,
1906        } = self.resolve_candidates(candidates).await;
1907
1908        let count = held.len();
1909        self.inner.origin_held.store(Arc::new(OriginHeldIndex {
1910            held,
1911            probe_faults: faults,
1912            enumerate_failures,
1913        }));
1914        if let Some(m) = &self.inner.metrics {
1915            if faults > 0 {
1916                m.origin_probe_failures.inc_by(faults);
1917            }
1918            if enumerate_failures > 0 {
1919                m.origin_enumerate_failures.inc_by(enumerate_failures);
1920            }
1921        }
1922        if faults > 0 {
1923            tracing::warn!(
1924                faults,
1925                carried,
1926                count,
1927                "rescan_origins: origin size probes faulted; carried the previous \
1928                 index entry forward where there was one. Candidates with no \
1929                 previous entry are absent from the announce set until a rescan \
1930                 resolves them"
1931            );
1932        }
1933        tracing::debug!(count, "rescan_origins: refreshed origin-held index");
1934    }
1935
1936    /// Every hash [`Self::rescan_origins`] considers: each enumerable origin's
1937    /// listing plus the operator pin set.
1938    ///
1939    /// The pin set is snapshotted here rather than read inside the probe loop,
1940    /// so its `ArcSwap` guard is not held across a `size()` await.
1941    ///
1942    /// Also returns how many origins failed to enumerate. Such an origin
1943    /// contributes nothing this pass — its hashes are never probed, so they
1944    /// carry no per-hash fault and simply leave the index. Only the operator
1945    /// pins naming them survive.
1946    async fn rescan_candidates(&self) -> (Vec<Hash>, u64) {
1947        let mut candidates: Vec<Hash> = Vec::new();
1948        let mut enumerate_failures = 0u64;
1949        for origin in &self.inner.origins {
1950            match origin.enumerate().await {
1951                Ok(hashes) => candidates.extend(hashes),
1952                Err(err) => {
1953                    enumerate_failures = enumerate_failures.saturating_add(1);
1954                    tracing::warn!(
1955                        origin = ?origin.kind(),
1956                        error = %format_args!("{err:#}"),
1957                        "rescan_origins: enumerate failed; every hash discoverable \
1958                         only through this origin leaves the announce set until a \
1959                         later rescan lists it"
1960                    );
1961                }
1962            }
1963        }
1964        candidates.extend(self.inner.pinned.load().iter().copied());
1965        (candidates, enumerate_failures)
1966    }
1967
1968    /// Resolve each candidate against the origin chain for
1969    /// [`Self::rescan_origins`], deduping so a hash is probed once.
1970    ///
1971    /// One local stat (fs) or one `HEAD` (pinned remote) per deduped candidate,
1972    /// through [`Self::probe_origin_chain_classified`] rather than
1973    /// [`Self::origin_size`], which folds a transport error into "origin doesn't
1974    /// have it".
1975    ///
1976    /// The probe memo is deliberately bypassed: an enumeration is as large as
1977    /// the origin's listing, and the memo is a bounded map that evicts an
1978    /// arbitrary live entry once full, so walking a rescan through it would push
1979    /// out the serve path's warm answers. The store walk in the DHT seed does go
1980    /// through the memo, because it asks the same question the serve gate asks
1981    /// about the same hashes moments later.
1982    ///
1983    /// Each candidate is bounded by [`RESCAN_PROBE_TIMEOUT`], not by
1984    /// `cache.origin_probe_timeout_ms` — that knob exists to stop a slow origin
1985    /// stalling the serve hot path, and a bulk walk answering nothing on the
1986    /// critical path has no reason to be that impatient. A candidate that
1987    /// overruns is a fault like any other, so borrowing the tighter budget would
1988    /// turn a merely slow origin into an empty index on a cold boot.
1989    async fn resolve_candidates(&self, candidates: Vec<Hash>) -> RescanResolution {
1990        // `load_full` rather than holding the `ArcSwap` guard: the guard would
1991        // be held across every probe await below, which is the long-lived-guard
1992        // case arc-swap tells callers to avoid.
1993        let previous = self.inner.origin_held.load_full();
1994        let mut out = RescanResolution::default();
1995        // Dedupe on a `seen` set, not on `held`: a candidate that resolves
1996        // `Absent`, or faults with nothing to carry forward, never lands in
1997        // `held`, and every origin's listing is concatenated with the pin set —
1998        // so keying off `held` re-probes those hashes once per duplicate and
1999        // counts one fault per probe.
2000        let mut seen: HashSet<Hash> = HashSet::new();
2001        for hash in candidates {
2002            if self.refuses(hash) || !seen.insert(hash) {
2003                continue;
2004            }
2005            match self
2006                .probe_origin_chain_classified(hash, RESCAN_PROBE_TIMEOUT)
2007                .await
2008            {
2009                (OriginPresence::Present(size), _) => {
2010                    out.held.insert(hash, size);
2011                }
2012                (OriginPresence::Absent, _) => {}
2013                (OriginPresence::Fault, transient) => {
2014                    out.faults = out.faults.saturating_add(1);
2015                    // Carry forward only what a later pass might resolve. A
2016                    // permanent fault — a revoked ACL, a symlink escape — will
2017                    // read the same way on every rescan, so carrying it keeps the
2018                    // hash advertised for the process lifetime while the serve
2019                    // path refuses every request for it.
2020                    if !transient {
2021                        continue;
2022                    }
2023                    if let Some(size) = previous.held.get(&hash).copied() {
2024                        out.held.insert(hash, size);
2025                        out.carried = out.carried.saturating_add(1);
2026                    }
2027                }
2028            }
2029        }
2030        out
2031    }
2032
2033    /// The hashes in the origin-held index (#1130) together with what the rescan
2034    /// that built it could not resolve, read from one load of the index.
2035    ///
2036    /// The only way to read the announce set, deliberately: the origin-held half
2037    /// of a DHT seed has no other error channel. A probe fault either carries an
2038    /// older entry forward or leaves the candidate out of the set, a failed
2039    /// enumeration drops a whole origin's listing, and the seed cannot tell
2040    /// either from an origin that genuinely stopped holding the content.
2041    /// Returning the counts with the hashes — from one load, so a truncated set
2042    /// can never be paired with a later healthy rescan's counts — is what stops a
2043    /// caller from reporting a short set as a complete one.
2044    ///
2045    /// `hashes` is filtered through the **live** [`Self::refuses`] set: the index
2046    /// is only a per-rescan snapshot, so a hash blacklisted / evicted / denied
2047    /// *after* the last rescan is still in it — but must never be announced. The
2048    /// live filter closes that window without waiting for the next rescan.
2049    pub fn origin_held_snapshot(&self) -> OriginHeldReport {
2050        let index = self.inner.origin_held.load();
2051        OriginHeldReport {
2052            hashes: index
2053                .held
2054                .keys()
2055                .copied()
2056                .filter(|h| !self.refuses(*h))
2057                .collect(),
2058            probe_faults: index.probe_faults,
2059            enumerate_failures: index.enumerate_failures,
2060        }
2061    }
2062
2063    /// Total byte size of `hash` if this node can serve it from a configured
2064    /// origin (#1130), else `None`. Backs the probe `has_blob` / `total_bytes`
2065    /// answer for origin-held content — `Some` means "advertise and serve" —
2066    /// resolved from the last [`Self::rescan_origins`] with no per-probe I/O.
2067    ///
2068    /// Returns `None` for a [`Self::refuses`]-listed hash even if the snapshot
2069    /// still holds it: the index is rebuilt only on rescan, so a hash
2070    /// blacklisted / evicted / denied since the last rescan would otherwise be
2071    /// advertised (probe `has_blob: true`) for content the serve path would
2072    /// refuse. The live refusal check is the authority.
2073    pub fn origin_held_size(&self, hash: Hash) -> Option<u64> {
2074        if self.refuses(hash) {
2075            return None;
2076        }
2077        self.inner.origin_held.load().held.get(&hash).copied()
2078    }
2079
2080    /// Total byte size of `hash` if a configured origin can serve it, resolved
2081    /// by a **live** `HEAD`/`HeadObject`/stat and memoised (#1130 pt3). This is
2082    /// the per-probe fallback for the http/s3 discovery gap: [`Self::rescan_origins`]
2083    /// can only index what an origin `enumerate`s, and http/s3 enumerate to
2084    /// nothing, so a non-pinned bucket object is absent from
2085    /// [`Self::origin_held_size`]. Callers should consult the in-memory index
2086    /// first (zero I/O) and only fall back here on its miss.
2087    ///
2088    /// A thin wrapper over [`Self::origin_probe_presence`]: `Present(size)`
2089    /// advertises `Some(size)`, and both `Absent` and `Fault` advertise `None`
2090    /// — a probing caller (the `probe` handler's `has_blob`, DHT announce)
2091    /// never distinguishes "genuinely missing" from "backend unreachable
2092    /// right now"; either way it must not advertise the blob.
2093    ///
2094    /// **Never fetches the body** — existence and size only.
2095    pub async fn origin_probe_size(&self, hash: Hash) -> Option<u64> {
2096        match self.origin_probe_presence(hash).await {
2097            OriginPresence::Present(size) => Some(size),
2098            OriginPresence::Absent | OriginPresence::Fault => None,
2099        }
2100    }
2101
2102    /// Walk the origin chain for `hash`, distinguishing a genuine negative from
2103    /// a backend fault, with no memo read or write.
2104    ///
2105    /// Deliberately not routed through [`Self::origin_size`]: that helper's
2106    /// per-origin error handling is swallow-and-advance, so other callers can
2107    /// fall through a dead origin to a live one, and its terminal `Ok(None)`
2108    /// folds a fault into a negative. A fault-aware caller needs the raw
2109    /// per-origin outcomes instead.
2110    ///
2111    /// - ANY origin answering `Ok(Some(size))` is `Present(size)` — the first
2112    ///   such answer wins, same order as [`Self::origin_size`];
2113    /// - failing that, ANY origin answering `Err(_)` (a transport error), or
2114    ///   the whole walk overrunning `timeout`, is `Fault`;
2115    /// - only when every configured origin answered `Ok(None)` — or no origin
2116    ///   is configured at all, a node that genuinely holds nothing — is the
2117    ///   answer `Absent`. This is deliberately the lowest-precedence outcome:
2118    ///   if at least one origin is unreachable and none confirmed the object,
2119    ///   the honest answer is "unknown", never an authoritative `NotFound`.
2120    ///
2121    /// One `timeout` covers the whole chain rather than resetting per origin,
2122    /// so a chain of slow origins cannot add up past the caller's budget.
2123    ///
2124    /// **Never fetches the body** — existence and size only.
2125    async fn probe_origin_chain(&self, hash: Hash, timeout: Duration) -> OriginPresence {
2126        self.probe_origin_chain_classified(hash, timeout).await.0
2127    }
2128
2129    /// [`Self::probe_origin_chain`] plus whether a `Fault` is worth waiting out.
2130    ///
2131    /// The second element is meaningful only for `Fault`, and is `true` when at
2132    /// least one origin failed with a retry-eligible error (or the walk hit its
2133    /// ceiling). A `Fault` built only from
2134    /// [`OriginPullError::Permanent`] — a symlink escape, a permission denied,
2135    /// an HTTP 4xx — will still be there on the next pass and the one after, so
2136    /// a caller that would otherwise hold state open on the strength of "this
2137    /// might come back" must not.
2138    async fn probe_origin_chain_classified(
2139        &self,
2140        hash: Hash,
2141        timeout: Duration,
2142    ) -> (OriginPresence, bool) {
2143        let walk = async {
2144            let mut any_fault = false;
2145            let mut any_transient = false;
2146            for origin in &self.inner.origins {
2147                match origin.size(hash).await {
2148                    Ok(Some(size)) => return (OriginPresence::Present(size), false),
2149                    Ok(None) => {}
2150                    Err(e) => {
2151                        tracing::debug!(
2152                            %hash,
2153                            kind = ?origin.kind(),
2154                            error = %format_args!("{e:#}"),
2155                            transient = e.is_transient(),
2156                            "origin-probe HEAD faulted; checking remaining origins",
2157                        );
2158                        any_fault = true;
2159                        any_transient |= e.is_transient();
2160                    }
2161                }
2162            }
2163            if any_fault {
2164                (OriginPresence::Fault, any_transient)
2165            } else {
2166                (OriginPresence::Absent, false)
2167            }
2168        };
2169        match tokio::time::timeout(timeout, walk).await {
2170            Ok(outcome) => outcome,
2171            // A ceiling the origin overran says nothing about whether it would
2172            // answer given longer, so treat it as the waitable kind.
2173            Err(_) => (OriginPresence::Fault, true),
2174        }
2175    }
2176
2177    /// Live existence probe against the configured origins, distinguishing a
2178    /// genuine negative from a backend fault (#1766). This is the primitive
2179    /// behind [`Self::origin_probe_size`]; callers that must NOT treat a fault
2180    /// as an authoritative absence (the origin-only serve gate) use this
2181    /// directly instead of the size-only wrapper.
2182    ///
2183    /// A memo hit returns with no I/O. On a miss the origin chain is walked
2184    /// under a `cache.origin_probe_timeout_ms` ceiling — deliberately not via
2185    /// [`Self::origin_size`], whose per-origin error handling is
2186    /// swallow-and-advance so other callers can fall through a dead origin to a
2187    /// live one — and this layer decides what to remember:
2188    ///
2189    /// - `Present(size)` is memoised under the positive TTL;
2190    /// - `Fault` is memoised under the fault TTL (#1789 item 6). The memo is
2191    ///   keyed per hash, so this bounds repeat probes OF THE SAME HASH to one
2192    ///   live `HEAD` per fault TTL — the common shape when a client retries a
2193    ///   request against a failing origin. It does not bound namespace-wide
2194    ///   load: during an outage, N distinct hashes still cost N live `HEAD`s
2195    ///   per window. The TTL is graded against its neighbours and the config
2196    ///   resolver enforces `negative <= fault <= positive`: patient enough that
2197    ///   a retried hash is not re-probed as often as an absence, eager enough
2198    ///   that a recovered origin is noticed soon — a memoised fault costs
2199    ///   client-visible availability, since the origin-only serve gate answers
2200    ///   `InternalError` and the probe/DHT paths report this node holds nothing
2201    ///   for as long as it stands.
2202    /// - `Absent` — every configured origin answered `Ok(None)`, or none is
2203    ///   configured at all (a node with nothing configured genuinely holds
2204    ///   nothing, which is not transient) — is memoised under the short
2205    ///   negative TTL. Every adapter reserves `Ok(None)` for an authoritative
2206    ///   absence (a genuine 404) — an outage or a permission decline is an
2207    ///   `Err`, which reaches the `Fault` arm instead — so this arm never
2208    ///   caches a 5xx as an absence.
2209    ///
2210    /// **Never fetches the body** — existence and size only.
2211    pub async fn origin_probe_presence(&self, hash: Hash) -> OriginPresence {
2212        if self.refuses(hash) {
2213            return OriginPresence::Absent;
2214        }
2215        let now = Instant::now();
2216        let timeout = {
2217            let mut memo = self.probe_memo_lock();
2218            if let Some(presence) = memo.get(hash, now) {
2219                return presence.into();
2220            }
2221            memo.timeout()
2222        };
2223        // Live probe off the memo lock (never hold it across the await).
2224        let presence = self.probe_origin_chain(hash, timeout).await;
2225        let memo_presence = match presence {
2226            OriginPresence::Present(size) => Presence::Present(size),
2227            OriginPresence::Absent => Presence::Absent,
2228            OriginPresence::Fault => Presence::Fault,
2229        };
2230        if matches!(presence, OriginPresence::Fault) {
2231            // Warn rather than debug: the memo answers the repeats, so this
2232            // fires at most once per hash per fault TTL — and it is the only
2233            // record
2234            // that this node is about to refuse serves and answer probes with
2235            // "not held" for content it may well hold. The per-origin errors
2236            // behind the fault stay at debug.
2237            tracing::warn!(
2238                %hash,
2239                fault_ttl_secs = self.probe_memo_lock().fault_ttl().as_secs(),
2240                "origin probe faulted; memoising the fault — serves for this hash \
2241                 answer InternalError and probes answer not-held until it expires"
2242            );
2243        }
2244        self.probe_memo_lock().insert(hash, memo_presence, now);
2245        presence
2246    }
2247
2248    /// Lock the origin-probe memo, recovering a poisoned mutex rather than
2249    /// panicking (the anti-panic policy) — a poisoned memo only means a prior
2250    /// holder panicked mid-update, and a best-effort existence cache is safe to
2251    /// keep using.
2252    fn probe_memo_lock(&self) -> std::sync::MutexGuard<'_, OriginProbeMemo> {
2253        self.inner
2254            .origin_probe_memo
2255            .lock()
2256            .unwrap_or_else(PoisonError::into_inner)
2257    }
2258
2259    /// Install the live-origin-probe configuration at runtime bring-up (#1130
2260    /// pt3). `cache.*` is restart-required, so this is called once from the
2261    /// runtime wiring and swaps the memo wholesale (dropping any warm entries) —
2262    /// the same "set once, no threading through every test constructor" pattern
2263    /// as [`Self::set_max_probe_holds`].
2264    pub fn set_origin_probe_config(&self, policy: OriginProbePolicy) {
2265        *self.probe_memo_lock() = OriginProbeMemo::new(policy);
2266    }
2267
2268    /// Swap in the live *local* denied set from `[content] denied_hashes` (ADR
2269    /// 011 § Local Denylist). Returns the delta for the reload log line, same
2270    /// shape as [`Self::set_pinned`].
2271    ///
2272    /// Wholesale replacement, not a merge: removing an entry from the denylist
2273    /// and reloading must un-deny it. Touches only the local slot — a reload
2274    /// must not drop what the blacklist watcher put in [`Self::set_chain_denied`].
2275    pub fn set_denied(&self, new: &DeniedHashes) -> PinDiff {
2276        let new_arc = Arc::new(
2277            new.iter()
2278                .map(|h| to_store_hash(*h))
2279                .collect::<HashSet<Hash>>(),
2280        );
2281        let prev_arc = self.inner.denied.swap(Arc::clone(&new_arc));
2282        let added = new_arc.difference(&prev_arc).count();
2283        let removed = prev_arc.difference(&new_arc).count();
2284        PinDiff { added, removed }
2285    }
2286
2287    /// Is `hash` on the live denied set? Cheap O(1) lookup.
2288    ///
2289    /// Prefer [`Self::refuses`] unless you specifically need to tell a denial
2290    /// apart from an eviction — the serve path does, to pick the right refusal
2291    /// code and metric; nothing else should care.
2292    pub fn is_denied(&self, hash: Hash) -> bool {
2293        self.inner.denied.load().contains(&hash)
2294    }
2295
2296    /// Replace the governance deny-set wholesale.
2297    ///
2298    /// The set records *why* a hash is refused — governance-sourced, which selects
2299    /// the wire refusal code — a fact `evicted.log` (which records only *that* a
2300    /// hash was evicted) cannot express. The blacklist watcher rebuilds it each
2301    /// boot by re-checking every enumerated hash through
2302    /// `isHashBlacklistedForOperator` and writing survivors one at a time via
2303    /// [`Self::set_chain_denied_one`]; this bulk setter is retained for tests and
2304    /// any wholesale reseed.
2305    pub fn set_chain_denied(&self, hashes: HashSet<Hash>) {
2306        self.inner.chain_denied.store(Arc::new(hashes));
2307    }
2308
2309    /// Add or drop one governance-denied hash, returning whether the set
2310    /// actually changed so a caller can skip logging a no-op replay (the watcher
2311    /// re-scans a block range after a restart and re-delivers events it has
2312    /// already applied).
2313    ///
2314    /// Read-modify-write rather than in-place mutation, because [`ArcSwap`] has
2315    /// no such thing. Blacklist events are governance actions and therefore
2316    /// rare, so the clone costs nothing next to keeping the read side — which is
2317    /// on the request hot path — lock-free.
2318    pub fn set_chain_denied_one(&self, hash: Hash, denied: bool) -> bool {
2319        let current = self.inner.chain_denied.load();
2320        if current.contains(&hash) == denied {
2321            return false;
2322        }
2323        let mut next = HashSet::clone(&current);
2324        if denied {
2325            next.insert(hash);
2326        } else {
2327            next.remove(&hash);
2328        }
2329        self.inner.chain_denied.store(Arc::new(next));
2330        true
2331    }
2332
2333    /// Is `hash` on the live governance deny-set? Cheap O(1) lookup.
2334    ///
2335    /// Prefer [`Self::refuses`] unless you specifically need to tell a
2336    /// governance takedown apart from an eviction or a local denylist entry —
2337    /// the serve path does, to pick the operator-side metric; nothing else
2338    /// should care, and the *wire* code deliberately cannot tell this apart from
2339    /// [`Self::is_denied`].
2340    pub fn is_chain_denied(&self, hash: Hash) -> bool {
2341        self.inner.chain_denied.load().contains(&hash)
2342    }
2343
2344    /// Does this node refuse to serve, announce, or acquire `hash`?
2345    ///
2346    /// The single predicate every "should I expose this blob" decision must
2347    /// call. `is_evicted` alone is NOT sufficient and using it directly is the
2348    /// bug this exists to prevent: a denied-but-still-held hash would keep
2349    /// being probe-answered `has_blob: true` and DHT-republished while the
2350    /// serve path refused it — which under ADR 005 § Probe response is exactly
2351    /// the signed evidence pair that makes the operator slashable for a
2352    /// takedown they were discharging.
2353    ///
2354    /// A [quarantined](Self::is_quarantined) hash is refused too. Its stored
2355    /// bytes failed validation, so a serve or an announce only fails every
2356    /// buyer, and a re-acquisition only lands on the corrupt entry until GC
2357    /// reclaims it.
2358    pub fn refuses(&self, hash: Hash) -> bool {
2359        self.is_denied(hash)
2360            || self.is_chain_denied(hash)
2361            || self.is_evicted(hash)
2362            || self.is_quarantined(hash)
2363    }
2364
2365    /// Is this blob already present in the local store?
2366    ///
2367    /// Returns `Ok(false)` when the hash has been logically evicted (issue
2368    /// #279) even if the underlying store still holds the bytes — operators
2369    /// who call `evict` expect the node to stop serving immediately, so
2370    /// `has` reports the blob as absent.
2371    pub async fn has(&self, hash: Hash) -> CacheResult<bool> {
2372        self.lift_reclaimed_quarantine(hash).await;
2373        if self.refuses(hash) {
2374            return Ok(false);
2375        }
2376        self.inner
2377            .store
2378            .blobs()
2379            .has(hash)
2380            .await
2381            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
2382    }
2383
2384    /// Serve-path presence + size audit for `hash` in ONE store actor call.
2385    ///
2386    /// Answers from a single `BlobStatus` what the delivery path otherwise asks
2387    /// in two hops — [`Self::has`] for presence, then [`Self::inspect`] for size
2388    /// (#1789 item 7, part B) — saving one store round-trip on every cache hit.
2389    /// Presence matches [`Self::has`] exactly: the blob must be `Complete` and
2390    /// no gate may refuse it, so a logically-evicted hash is
2391    /// [`ServeAudit::Unavailable`] even while the store still holds its bytes.
2392    ///
2393    /// Unlike [`Self::inspect`], a partial blob reports no size here. `inspect`
2394    /// exists to tell an operator how much disk a partial pull occupies; this
2395    /// audit answers what may go on the wire, and a partial blob's byte count
2396    /// is not that.
2397    pub async fn serve_audit(&self, hash: Hash) -> CacheResult<ServeAudit> {
2398        self.lift_reclaimed_quarantine(hash).await;
2399        let withdrawn = self.is_evicted(hash) || self.is_quarantined(hash);
2400        let refused = self.refuses(hash);
2401        let status = self
2402            .inner
2403            .store
2404            .blobs()
2405            .status(hash)
2406            .await
2407            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2408        match status {
2409            iroh_blobs::api::blobs::BlobStatus::Complete { size } if !refused => {
2410                Ok(ServeAudit::Serveable { size })
2411            }
2412            iroh_blobs::api::blobs::BlobStatus::Complete { .. }
2413            | iroh_blobs::api::blobs::BlobStatus::NotFound
2414            | iroh_blobs::api::blobs::BlobStatus::Partial { .. } => {
2415                Ok(ServeAudit::Unavailable { withdrawn })
2416            }
2417        }
2418    }
2419
2420    /// Which chunk ranges of `hash` are present on disk right now.
2421    ///
2422    /// Classifies via `status()` first (so an absent hash returns immediately),
2423    /// then snapshots the current bitfield by awaiting `observe` directly — the
2424    /// FIRST `observe` item is the current state. It deliberately does NOT call
2425    /// `ObserveProgress::await_completion`, which blocks until the blob is
2426    /// *complete* and would hang forever on a partial or absent blob.
2427    /// Logically-evicted hashes report absent, mirroring [`Self::has`]. Pure
2428    /// query: does not touch access times and changes no serving behavior.
2429    pub async fn present_ranges(&self, hash: Hash) -> CacheResult<PresentRanges> {
2430        if self.refuses(hash) {
2431            return Ok(PresentRanges::absent());
2432        }
2433        // Resolve absence without `observe`: a hash the store has never seen has
2434        // no defined current bitfield to await, and a size-0 bitfield reports
2435        // `is_complete() == true` vacuously — both are wrong answers here.
2436        let status = self
2437            .inner
2438            .store
2439            .blobs()
2440            .status(hash)
2441            .await
2442            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2443        if matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
2444            return Ok(PresentRanges::absent());
2445        }
2446        // The blob exists (Partial or Complete): its current bitfield is the
2447        // first item `observe` yields, available immediately.
2448        let bitfield = self
2449            .inner
2450            .store
2451            .blobs()
2452            .observe(hash)
2453            .await
2454            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2455        Ok(PresentRanges {
2456            complete: bitfield.is_complete(),
2457            size: bitfield.size(),
2458            ranges: bitfield.ranges,
2459        })
2460    }
2461
2462    /// A live watch of which chunk ranges of `hash` are present, for progressive
2463    /// serve-while-filling (#1621). Yields the current bitfield's
2464    /// [`bao_tree::ChunkRanges`] first, then further updates as the blob fills.
2465    ///
2466    /// Mirrors [`Self::present_ranges`]'s guards (refuse an evicted/blacklisted
2467    /// hash, gate a never-seen hash via `status()` before observing — a hash
2468    /// with no defined current state has nothing to watch), but stays a live
2469    /// stream instead of a point-in-time snapshot. Deliberately uses
2470    /// `ObserveProgress::stream()`, NEVER `await_completion`, which blocks
2471    /// until the blob is complete and would hang forever on a partial blob.
2472    pub async fn observe_present_ranges(
2473        &self,
2474        hash: Hash,
2475    ) -> CacheResult<Pin<Box<dyn futures_util::Stream<Item = bao_tree::ChunkRanges> + Send>>> {
2476        use futures_util::StreamExt;
2477        // Same guards as `present_ranges`: never observe an evicted/blacklisted
2478        // hash, and gate a never-seen hash (observe has no defined current state).
2479        if self.refuses(hash) {
2480            return Err(CacheError::Store(anyhow::anyhow!(
2481                "observe_present_ranges: hash is evicted/blacklisted"
2482            )));
2483        }
2484        let status = self
2485            .inner
2486            .store
2487            .blobs()
2488            .status(hash)
2489            .await
2490            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2491        if matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
2492            return Err(CacheError::Store(anyhow::anyhow!(
2493                "observe_present_ranges: blob not present"
2494            )));
2495        }
2496        // `.stream()` yields the current bitfield first, then updates. NEVER
2497        // `.await_completion()` — it loops until complete and hangs on a partial.
2498        let stream = self
2499            .inner
2500            .store
2501            .blobs()
2502            .observe(hash)
2503            .stream()
2504            .await
2505            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2506        Ok(Box::pin(stream.map(|bf| bf.ranges)))
2507    }
2508
2509    /// Which [`decdn_protocol::DISCOVERY_BLOCK_BYTES`] discovery blocks of
2510    /// `hash` this node can serve from its own cache right now.
2511    ///
2512    /// Cached blocks only — an origin-backed node that could re-pull the
2513    /// whole blob on demand does NOT get all-ones here; that capability-aware
2514    /// widening is the probe handler's job (it alone knows whether `hash` has
2515    /// a configured origin), not this cache-only derivation's. Absent,
2516    /// evicted, or otherwise refused hashes report [`decdn_protocol::Coverage::empty`],
2517    /// mirroring [`Self::present_ranges`]'s guards.
2518    ///
2519    /// A block is covered iff every chunk in its byte span is present: this
2520    /// reads the blob's size from the `observe()` bitfield (via
2521    /// [`Self::present_ranges`]), then diffs each block's chunk range against
2522    /// the present ranges the same way [`Self::missing_ranges`] diffs a
2523    /// requested range — a block with any missing chunk is not covered. The
2524    /// bitfield knows the size as soon as any chunk carries it, so a
2525    /// front-prefix partial with no validated tail still reports its covered
2526    /// blocks (where `status()` would leave the size unknown until the last
2527    /// chunk). A `NotFound`, evicted, or refused hash reports no blocks.
2528    pub async fn coverage(&self, hash: Hash) -> CacheResult<decdn_protocol::Coverage> {
2529        Ok(self.coverage_sized(hash).await?.0)
2530    }
2531
2532    /// [`Self::coverage`] plus the blob size it was derived from, so a partial
2533    /// holder can advertise its size alongside its blocks. The size is `None`
2534    /// exactly when the coverage is empty for want of a known size: an absent,
2535    /// evicted, or refused hash.
2536    ///
2537    /// The size comes from the same `observe()` bitfield read as the coverage,
2538    /// so the pair costs one read and cannot disagree.
2539    pub async fn coverage_sized(
2540        &self,
2541        hash: Hash,
2542    ) -> CacheResult<(decdn_protocol::Coverage, Option<u64>)> {
2543        if self.refuses(hash) {
2544            return Ok((decdn_protocol::Coverage::empty(), None));
2545        }
2546        // The size comes from the `observe()` bitfield (via `present_ranges`),
2547        // not `status()`: iroh-blobs leaves a partial blob's size unknown
2548        // until its LAST chunk validates, but the bitfield already knows it
2549        // as soon as any chunk carries it — so a front-prefix partial with no
2550        // tail still derives a non-empty coverage here. `present_ranges`
2551        // already handles the absent/evicted guards (size 0 there too).
2552        let present = self.present_ranges(hash).await?;
2553        let size = present.size();
2554        let coverage = decdn_protocol::Coverage::from_block_indices(
2555            decdn_protocol::num_blocks(size),
2556            covered_blocks(size, present.chunk_ranges()),
2557        );
2558        Ok((coverage, (size > 0).then_some(size)))
2559    }
2560
2561    /// Announce `hash` on [`Self::subscribe_inserts`] when the admit of
2562    /// `admitted` completed at least one discovery block — a block that
2563    /// `admitted` touches and that is now fully present (ADR 022 §STORE Flow:
2564    /// a partial holder publishes once it verifies a 64 MiB block).
2565    ///
2566    /// One admit fills a window, not a block, so most admits touch no fully
2567    /// present block and announce nothing. A front-to-back fill announces about
2568    /// once per block, and the republisher publishes eagerly only once.
2569    ///
2570    /// Fails open: when the coverage query faults, it announces anyway. No
2571    /// later event may name this hash — a blob under 64 MiB has one block, and
2572    /// a finished fill admits nothing more — so a dropped announcement would
2573    /// last until the next lag sweep or restart. The consumer reads coverage
2574    /// itself and publishes nothing for a hash that covers no block. With no
2575    /// subscriber (no DHT task) it skips the store query entirely.
2576    async fn announce_completed_blocks(&self, hash: Hash, admitted: &ChunkRanges) {
2577        if self.inner.inserts_tx.receiver_count() == 0 {
2578            return;
2579        }
2580        let present = match self.present_ranges(hash).await {
2581            Ok(present) => present,
2582            Err(err) => {
2583                tracing::warn!(
2584                    %hash,
2585                    error = %err.display_chain(),
2586                    "admit: coverage query failed; announcing the hash so the republisher \
2587                     decides from its own coverage read"
2588                );
2589                let _ = self.inner.inserts_tx.send(hash);
2590                return;
2591            }
2592        };
2593        let size = present.size();
2594        let completed = covered_blocks(size, present.chunk_ranges())
2595            .any(|i| !discovery_block_span(i, size).is_disjoint(admitted));
2596        if completed {
2597            // `Err` only means no subscriber is left; nothing to announce to.
2598            let _ = self.inner.inserts_tx.send(hash);
2599        }
2600    }
2601
2602    /// The chunk-aligned sub-ranges of `[byte_offset, byte_offset + byte_len)`
2603    /// (`byte_len == 0` = to `blob_size`, an end past `blob_size` clamped to
2604    /// it) that are NOT present on disk.
2605    ///
2606    /// Empty ⇒ the requested span is fully present: a completeness-aware read can
2607    /// serve it with no fetch, and a resumed pull is a no-op. `blob_size` is
2608    /// caller-supplied (the signed `total_bytes`), the same contract as
2609    /// [`Self::export_bao_range_stream`].
2610    pub async fn missing_ranges(
2611        &self,
2612        hash: Hash,
2613        byte_offset: u64,
2614        byte_len: u64,
2615        blob_size: u64,
2616    ) -> CacheResult<ChunkRanges> {
2617        // Same align_range_clamped error mapping as `export_bao_range_stream`:
2618        // only a `byte_offset` at or past `blob_size` is an argument error, not an
2619        // origin fault; an end past `blob_size` clamps rather than erroring, so a
2620        // partial holder does not refuse its own already-complete partial hit.
2621        let aligned = align_range_clamped(byte_offset, byte_len, blob_size).map_err(|e| {
2622            CacheError::Store(anyhow::Error::from(e).context("missing_ranges: range alignment"))
2623        })?;
2624        let present = self.present_ranges(hash).await?;
2625        // requested \ present. `ChunkRanges` is a `RangeSet2`, which implements
2626        // `Sub` (`owned - &ref -> owned`).
2627        Ok(aligned.chunk_ranges().clone() - present.chunk_ranges())
2628    }
2629
2630    /// Mark `hash` as evicted: subsequent [`Self::has`] / [`Self::get`] calls
2631    /// behave as if the blob is absent (returning `false` / `NotFound`
2632    /// respectively). The corresponding [`Self::access_times_snapshot`] entry
2633    /// is cleared so future LRU sweeps don't re-surface the hash.
2634    ///
2635    /// Serving stops immediately via the logical-evicted set (`Blobs::delete`
2636    /// is `pub(crate)` in iroh-blobs and reserved for the GC task, so the
2637    /// bytes are not removed synchronously). `evict` deletes the blob's
2638    /// protecting named tag(s) (#860) so the bytes become GC-eligible; the
2639    /// disk is then reclaimed on the next iroh-blobs GC sweep — cadence
2640    /// `cache.gc_interval_sec`, default 5min (#518) — *when periodic GC is
2641    /// enabled*. With GC disabled (`gc_interval_sec == 0`) serving still stops
2642    /// but the bytes stay on disk until a sweep is configured. Reclaim is
2643    /// best-effort in the other direction too: the tag deletion is not
2644    /// allowed to fail the takedown, so if it errors the bytes remain
2645    /// GC-protected (surfaced via `tag_drop_failures`) while serving stays
2646    /// blocked. The operator-visible behavior — the node stops serving the
2647    /// blob immediately, and reclaims its disk on the next sweep when GC is
2648    /// enabled and the tag delete succeeded — is what `decdn node evict`
2649    /// (issue #279) needs for use cases like DMCA takedown. A stored hash
2650    /// mismatch that a serve detects does not come here: it takes the
2651    /// non-durable [quarantine](Self::is_quarantined), because a corrupt
2652    /// local copy must stay re-acquirable.
2653    ///
2654    /// Persisted: the eviction is appended (with `fsync`) to
2655    /// `<cache_dir>/evicted.log` before this call returns successfully, so
2656    /// the takedown survives a process restart. A persistence failure is
2657    /// surfaced as `Err` rather than silently downgrading to in-memory-only —
2658    /// for DMCA-driven evicts the operator must be able to tell whether
2659    /// the takedown is durable, otherwise a node restart could resume
2660    /// serving the content.
2661    ///
2662    /// Async because the durable append runs the blocking `fsync` on a
2663    /// `spawn_blocking` thread rather than on the async runtime (#845), so a
2664    /// caller on a request-serving worker does not stall on disk I/O.
2665    pub async fn evict(&self, hash: Hash) -> CacheResult<()> {
2666        // Lock-free pre-check on a single snapshot: short-circuit on
2667        // already-evicted (a sequential repeat-evict of the same hash returns
2668        // here and never re-appends) and reject on cap (DoS bound on an
2669        // unbounded public-ish surface). Both checks are advisory against
2670        // concurrency — the snapshot is read before the durable append below —
2671        // and `insert_if_absent` re-decides both under the write lock, so the
2672        // cap is exact and a race on the same new hash appends at most one
2673        // duplicate `evicted.log` line (harmless: replay folds the log into a
2674        // `HashSet`).
2675        let evicted = self.inner.evicted.snapshot();
2676        if evicted.contains(&hash) {
2677            return Ok(());
2678        }
2679        if evicted.len() >= MAX_EVICTED_ENTRIES {
2680            return Err(CacheError::EvictionLimitExceeded {
2681                limit: MAX_EVICTED_ENTRIES,
2682            });
2683        }
2684        drop(evicted);
2685
2686        // Persist FIRST, then commit to the in-memory set: a crash between
2687        // these two steps will at worst replay a successful evict on the
2688        // next open, which is idempotent. The opposite ordering would
2689        // briefly stop serving but lose durability if the fsync failed —
2690        // the worst-case scenario for a takedown.
2691        //
2692        // The append's `fsync` is blocking, so run it on a `spawn_blocking`
2693        // thread (#845) to keep it off the async runtime — `evict` is reached
2694        // from the request-serving hash-mismatch path. A panic in the blocking
2695        // task (JoinError) and an I/O failure are both surfaced as `Err` so the
2696        // operator never gets an `Ok` that silently skipped durable persistence.
2697        let log_path = self.inner.evicted_log_path.clone();
2698        tokio::task::spawn_blocking(move || append_evicted_log(&log_path, hash))
2699            .await
2700            .map_err(|join_err| {
2701                CacheError::Store(anyhow::Error::new(join_err).context("persist eviction task"))
2702            })?
2703            .map_err(|err| {
2704                CacheError::Store(anyhow::Error::from(err).context("persist eviction"))
2705            })?;
2706
2707        // Commit to the in-memory set (#1789 item 5). The publish is a single
2708        // atomic swap of a superset, so no reader can observe `Ok(())` here
2709        // with the set still excluding `hash`, and the read side never has a
2710        // lock that could be poisoned into a "still serving" fallback.
2711        if !self
2712            .inner
2713            .evicted
2714            .insert_if_absent(hash, MAX_EVICTED_ENTRIES)
2715        {
2716            return Err(CacheError::EvictionLimitExceeded {
2717                limit: MAX_EVICTED_ENTRIES,
2718            });
2719        }
2720        self.inner.access_times.remove(&hash);
2721        self.inner
2722            .segments
2723            .lock()
2724            .unwrap_or_else(PoisonError::into_inner)
2725            .remove(&hash);
2726
2727        // Disk reclaim (#860): serving has already stopped via the logical
2728        // set above, but a successfully pulled-through blob carries a named
2729        // tag that protects it from the iroh-blobs GC sweep forever. Delete
2730        // that tag so the next sweep can actually reclaim the bytes — without
2731        // this a DMCA evict retains the content on disk indefinitely. Two
2732        // caveats on the reclaim, both intentional: GC must be enabled
2733        // (`cache.gc_interval_sec > 0`); and a pull-through for the same hash
2734        // that races this evict can re-create a protecting tag *after* the
2735        // delete below (the commit path does not re-check `is_evicted`), in
2736        // which case reclaim waits until that tag is itself cleared — serving
2737        // still stays blocked via the logical set either way.
2738        //
2739        // Best-effort: the durable serve-blocking guarantee is already in
2740        // place, so a tag-delete failure only delays reclaim (it can never
2741        // resurrect serving) and must not turn a successful takedown into an
2742        // `Err`. The failure is surfaced via `tag_drop_failures` + a warn so
2743        // an operator can act; note nothing auto-retries it — a re-run of
2744        // `evict()` short-circuits at the `contains` check above, and open()
2745        // replay only reloads the logical set, so recovery is manual.
2746        match self.drop_named_tags_for(hash).await {
2747            Ok(deleted) => tracing::debug!(%hash, deleted, "evict: dropped protecting tags"),
2748            Err(err) => {
2749                if let Some(m) = &self.inner.metrics {
2750                    m.tag_drop_failures.inc();
2751                }
2752                tracing::warn!(
2753                    %hash,
2754                    error = %err.display_chain(),
2755                    "evict: failed to drop protecting tags; bytes stay GC-protected (not auto-retried)",
2756                );
2757            }
2758        }
2759        // Operator-evict (DMCA/corruption) counter — distinct from the LRU
2760        // `evictions` counter the eviction driver bumps (#1173, ADR 040). Bumped after the
2761        // durable append + logical-set commit succeeded above, so the count
2762        // tracks takedowns that actually stopped serving.
2763        if let Some(m) = &self.inner.metrics {
2764            m.evicted_operator.inc();
2765        }
2766        Ok(())
2767    }
2768
2769    /// Protect a partial (range-admitted) blob from GC by giving its raw hash a
2770    /// deterministic named tag. Idempotent — `set` overwrites the same name, so
2771    /// re-admitting more ranges never proliferates tags. The `decdn-partial-`
2772    /// prefix is opaque to iroh-blobs; `drop_named_tags_for` (evict) matches by
2773    /// hash and removes it. A GC sweep in the sub-second window between
2774    /// `import_bao_bytes` and this `set` is bounded by the GC interval and
2775    /// self-heals on the next admit. #1607.
2776    ///
2777    /// The tag only needs to EXIST, so once this process has written it (tracked
2778    /// in `partial_protected`) the store write is skipped — a single fill admits
2779    /// many ranges, and only the first need pay the tag write. The memo is cleared
2780    /// whenever the tag is dropped (`drop_named_tags_for`), so a re-admit after an
2781    /// eviction re-protects.
2782    ///
2783    /// The memo is a best-effort optimization, not a memo↔tag invariant: the
2784    /// `contains_key`/`insert` here and the `remove` in `drop_named_tags_for` are
2785    /// not atomic against the store, so a `protect_partial` racing a concurrent
2786    /// evict of the same hash can leave the memo set while the tag was deleted.
2787    /// That never affects served correctness — served bytes are hash-verified, and
2788    /// an operator takedown blocks serving through the logical evicted set, not
2789    /// through this tag (see `evict`). Its only cost is that such a partial may go
2790    /// unprotected and be GC-reclaimed, which self-corrects on the next pull.
2791    /// Concurrent first-admits merely repeat one idempotent `set`.
2792    ///
2793    /// A [quarantined](Self::is_quarantined) hash gets no tag: the tag would
2794    /// protect the corrupt entry from the GC sweep that ends the quarantine. A
2795    /// fill already past this check when the quarantine starts can still write
2796    /// the tag. The origin rescan drops it again
2797    /// ([`Self::lift_reclaimed_quarantines`]).
2798    async fn protect_partial(&self, hash: Hash) -> CacheResult<()> {
2799        if self.is_quarantined(hash) || self.inner.partial_protected.contains_key(&hash) {
2800            return Ok(());
2801        }
2802        let name = format!("decdn-partial-{hash}");
2803        self.inner
2804            .store
2805            .tags()
2806            .set(name.as_bytes(), HashAndFormat::raw(hash))
2807            .await
2808            .map_err(|e| {
2809                CacheError::Store(anyhow::Error::from(e).context("protect_partial: tags().set"))
2810            })?;
2811        self.inner.partial_protected.insert(hash, ());
2812        Ok(())
2813    }
2814
2815    /// Delete every named tag pointing at `hash`, making the underlying
2816    /// bytes eligible for the iroh-blobs GC sweep.
2817    ///
2818    /// Pull-through promotes each cached blob to a named tag
2819    /// ([`iroh_blobs::api::tags::Tags::create`] on the streaming path,
2820    /// `add_bytes` on the drain path); iroh-blobs GC protects any blob
2821    /// reachable from a tag, so the bytes are never reclaimed while a tag
2822    /// survives. Tag names are opaque and store-assigned, so matches are
2823    /// found by enumerating tags and comparing hashes. A hash can carry more
2824    /// than one tag, so every match is removed (in practice the engine creates
2825    /// one tag per cached hash — `get`/`populate` short-circuit on a hit and
2826    /// in-flight pulls coalesce (#305) — but the loop is robust to a future
2827    /// caller that tags the same hash twice). Returns the number of tags
2828    /// deleted.
2829    ///
2830    /// Cost scales with the *total* tag set: iroh-blobs has no hash-indexed
2831    /// tag lookup, so this lists every tag and filters in Rust. Fine at
2832    /// operator-evict / mismatch rates; not something to call on a hot path.
2833    async fn drop_named_tags_for(&self, hash: Hash) -> CacheResult<u64> {
2834        // Invalidate the partial-protection memo before touching the store, so a
2835        // later admit re-issues the protecting tag rather than trusting a stale
2836        // "already protected" entry for the tag we are removing. Clearing it up
2837        // front (not after the deletes) also means a delete failure mid-loop still
2838        // leaves the memo clear, so a re-admit re-protects. This is best-effort,
2839        // not atomic against a concurrent `protect_partial` of the same hash — the
2840        // race is benign for the reasons documented on `protect_partial`.
2841        self.inner.partial_protected.remove(&hash);
2842        let tags = self.inner.store.tags();
2843        // Collect matching names before deleting so the (immutable) list
2844        // stream is fully drained before any delete call — keeps the two
2845        // store interactions sequential and easy to reason about.
2846        let mut to_delete = Vec::new();
2847        {
2848            let mut stream = tags
2849                .list()
2850                .await
2851                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2852            while let Some(info) = stream.next().await {
2853                let info = info.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2854                if info.hash == hash {
2855                    to_delete.push(info.name);
2856                }
2857            }
2858        }
2859        let mut deleted = 0u64;
2860        for name in to_delete {
2861            deleted += tags
2862                .delete(name)
2863                .await
2864                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2865        }
2866        Ok(deleted)
2867    }
2868
2869    /// Read-only inspection of a hash's local cache state, used by
2870    /// `admin_v1_evict { dry_run: true }` (issue #379) to preview what
2871    /// an [`Self::evict`] call would touch without mutating any state.
2872    ///
2873    /// Performs a single `BlobStatus` query against the underlying
2874    /// store and reuses the cheap in-memory lookups for pin / access /
2875    /// already-evicted flags. The returned [`EvictionPreview`] also
2876    /// pre-computes a `served` bool so admin can derive `was_present`
2877    /// without a follow-up [`Self::has`] call.
2878    ///
2879    /// The three in-memory probes are each an O(1) lookup and none of them
2880    /// blocks: `evicted` and `pinned` are `ArcSwap` loads and `access_times` is
2881    /// one `DashMap` shard. Same pattern as [`Self::eviction_candidates`].
2882    pub async fn inspect(&self, hash: Hash) -> CacheResult<EvictionPreview> {
2883        let status = self
2884            .inner
2885            .store
2886            .blobs()
2887            .status(hash)
2888            .await
2889            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
2890
2891        // Decompose `BlobStatus` once: `complete` feeds `served`,
2892        // `size_bytes` feeds the wire response. Keeping the `match` here
2893        // (rather than splitting into two helpers) makes the mapping
2894        // between iroh-blobs states and our preview fields auditable in
2895        // one place.
2896        let (size_bytes, complete) = match status {
2897            iroh_blobs::api::blobs::BlobStatus::NotFound => (None, false),
2898            iroh_blobs::api::blobs::BlobStatus::Partial { size } => (size, false),
2899            iroh_blobs::api::blobs::BlobStatus::Complete { size } => (Some(size), true),
2900        };
2901
2902        let already_evicted = self.is_evicted(hash);
2903        // `served` mirrors the `has()` semantic: complete AND not
2904        // evicted. Pre-computed here so admin doesn't issue a second
2905        // round-trip to compute `was_present`.
2906        let served = complete && !already_evicted;
2907
2908        let last_accessed_us_ago = self.last_accessed(hash).map(|inst| {
2909            // Saturate-on-overflow rather than panic. `Instant::elapsed`
2910            // can theoretically exceed u64::MAX microseconds on a
2911            // process that's been up for ~580k years; defensive against
2912            // a future test that builds an `Instant` from a stub.
2913            u64::try_from(inst.elapsed().as_micros()).unwrap_or(u64::MAX)
2914        });
2915
2916        let origin_kinds = self.inner.origins.iter().map(|o| o.kind()).collect();
2917
2918        Ok(EvictionPreview {
2919            size_bytes,
2920            last_accessed_us_ago,
2921            pinned: self.is_pinned(hash),
2922            already_evicted,
2923            served,
2924            origin_kinds,
2925        })
2926    }
2927
2928    /// Has this hash been logically evicted via [`Self::evict`]?
2929    ///
2930    /// Lock-free (#1789 item 5): one `ArcSwap` load and a hash-set probe, so
2931    /// the serve-path gate takes no mutex and has no lock-poisoning failure
2932    /// mode that could answer not-evicted for a taken-down hash. The write side
2933    /// only ever publishes a superset, so an evicted hash is observed evicted
2934    /// by every reader that linearizes after the swap.
2935    pub fn is_evicted(&self, hash: Hash) -> bool {
2936        self.inner.evicted.contains(hash)
2937    }
2938
2939    /// Is this hash quarantined because its stored bytes failed validation on
2940    /// a serve export?
2941    ///
2942    /// A hash mismatch or a short read over held content in
2943    /// [`Self::export_bao_range_stream`] or [`Self::outboard_pairs`] quarantines
2944    /// the hash. [`Self::refuses`] then withholds it from serving, announcing,
2945    /// and re-acquisition. The engine drops its protecting tags, even when the
2946    /// hash is pinned: the pin does not keep corrupt bytes from GC. The
2947    /// quarantine lifts on the next [`Self::has`], [`Self::serve_audit`], or
2948    /// origin rescan that finds the store no longer holds the hash. The quarantine is in memory only.
2949    pub fn is_quarantined(&self, hash: Hash) -> bool {
2950        self.inner.quarantined.contains_key(&hash)
2951    }
2952
2953    /// Quarantine a held `hash` whose serve export failed bao validation
2954    /// against the content root.
2955    ///
2956    /// The store bytes or outboard diverged after admission, so every later
2957    /// serve fails the same way. The quarantine makes [`Self::refuses`]
2958    /// withhold the hash from serving, announcing, and re-acquisition. It
2959    /// drops the protecting named tags, so the next GC sweep reclaims the
2960    /// entry, and it forgets the access-time and segment entries. The tags
2961    /// drop for a pinned hash too: a pin protects content, and these bytes
2962    /// are not that content. [`Self::lift_reclaimed_quarantine`] ends the
2963    /// quarantine when the store no longer holds the hash.
2964    ///
2965    /// Idempotent: only the first trip per hash counts
2966    /// `held_corruption_quarantined` and walks the tag store. A tag-drop
2967    /// failure counts `tag_drop_failures` and leaves the bytes GC-protected
2968    /// until [`Self::lift_reclaimed_quarantines`] retries the drop on the next
2969    /// origin rescan. The hash stays refused regardless.
2970    async fn quarantine_corrupt(&self, hash: Hash) {
2971        if self.inner.quarantined.insert(hash, ()).is_some() {
2972            return;
2973        }
2974        if let Some(m) = &self.inner.metrics {
2975            m.held_corruption_quarantined.inc();
2976        }
2977        if self.inner.gc_store_handle.is_some() {
2978            tracing::warn!(
2979                %hash,
2980                "held blob failed bao validation on export; quarantined it and released it to GC"
2981            );
2982        } else {
2983            tracing::warn!(
2984                %hash,
2985                "held blob failed bao validation on export; quarantined it. GC is disabled \
2986                 (cache.gc_interval_sec = 0), so the corrupt bytes stay on disk and the hash \
2987                 stays withdrawn until a restart"
2988            );
2989        }
2990        self.inner.access_times.remove(&hash);
2991        self.inner
2992            .segments
2993            .lock()
2994            .unwrap_or_else(PoisonError::into_inner)
2995            .remove(&hash);
2996        self.drop_quarantined_tags(hash).await;
2997    }
2998
2999    /// Drop the protecting tags of quarantined `hash`. Returns how many tags
3000    /// were dropped; a failure counts `tag_drop_failures`, logs, and returns 0.
3001    async fn drop_quarantined_tags(&self, hash: Hash) -> u64 {
3002        match self.drop_named_tags_for(hash).await {
3003            Ok(dropped) => dropped,
3004            Err(err) => {
3005                if let Some(m) = &self.inner.metrics {
3006                    m.tag_drop_failures.inc();
3007                }
3008                tracing::warn!(
3009                    %hash,
3010                    error = %err.display_chain(),
3011                    "quarantine: dropping the protecting tags failed; the corrupt bytes stay on \
3012                     disk until the next origin rescan retries"
3013                );
3014                0
3015            }
3016        }
3017    }
3018
3019    /// End the quarantine on `hash` when the store no longer holds it. Returns
3020    /// `true` when `hash` is not quarantined after the call.
3021    ///
3022    /// GC reclaims a quarantined entry because [`Self::quarantine_corrupt`]
3023    /// dropped its tags. After the sweep nothing corrupt remains, so a
3024    /// pull-through may admit a freshly verified copy. A store fault keeps the
3025    /// quarantine, and a later call retries.
3026    async fn lift_reclaimed_quarantine(&self, hash: Hash) -> bool {
3027        if !self.is_quarantined(hash) {
3028            return true;
3029        }
3030        match self.inner.store.blobs().status(hash).await {
3031            Ok(iroh_blobs::api::blobs::BlobStatus::NotFound) => {
3032                self.inner.quarantined.remove(&hash);
3033                tracing::info!(%hash, "quarantined blob reclaimed; lifted the quarantine");
3034                true
3035            }
3036            Ok(_) => false,
3037            Err(err) => {
3038                tracing::debug!(
3039                    %hash,
3040                    error = %err,
3041                    "quarantine: store status failed; the quarantine stays"
3042                );
3043                false
3044            }
3045        }
3046    }
3047
3048    /// Lift every quarantine whose entry GC has reclaimed, and drop the tags
3049    /// again for every quarantined entry the store still holds.
3050    ///
3051    /// The periodic origin rescan calls this, so a reclaimed hash rejoins the
3052    /// origin-held announce set even when no request touches it. The second
3053    /// tag drop covers a fill or pull-through that was already past its
3054    /// quarantine check when the quarantine started and then protected the
3055    /// corrupt entry. Without it, that tag would keep the entry from GC and the
3056    /// quarantine would never lift.
3057    async fn lift_reclaimed_quarantines(&self) {
3058        let quarantined: Vec<Hash> = self.inner.quarantined.iter().map(|e| *e.key()).collect();
3059        for hash in quarantined {
3060            if self.lift_reclaimed_quarantine(hash).await {
3061                continue;
3062            }
3063            let dropped = self.drop_quarantined_tags(hash).await;
3064            if dropped > 0 {
3065                tracing::warn!(
3066                    %hash,
3067                    dropped,
3068                    "quarantine: a fill re-protected the corrupt entry; dropped its tags again"
3069                );
3070            }
3071        }
3072    }
3073
3074    /// Quarantine `hash` when the export error `cause` proves that held content
3075    /// diverged from the root.
3076    ///
3077    /// Three errors can prove it:
3078    /// - `LeafHashMismatch` and `ParentHashMismatch` fail the hash check.
3079    /// - `Io` with `UnexpectedEof` means a data or outboard file is shorter
3080    ///   than the size the store recorded.
3081    ///
3082    /// Each counts only over content the store holds. A partial blob's data
3083    /// file is sparse, so an absent range reads back as zeros or ends early,
3084    /// and fails exactly like corruption. A complete blob holds every range,
3085    /// so any of the three errors quarantines it. A partial blob is
3086    /// quarantined only when the failing chunks lie inside its present ranges.
3087    /// A hash mismatch names the failing leaf or node. A short read names no
3088    /// location, so it counts only when the whole `requested` export range is
3089    /// present. Every other error is a store fault or an absent blob, not
3090    /// corruption.
3091    async fn quarantine_on_mismatch(
3092        &self,
3093        hash: Hash,
3094        cause: &bao_tree::io::EncodeError,
3095        requested: &ChunkRanges,
3096    ) {
3097        use bao_tree::ChunkNum;
3098        use bao_tree::io::EncodeError;
3099
3100        let chunk_log = decdn_bao_range::IROH_BLOCK_SIZE.chunk_log();
3101        // Leaf and node ranges are in 1 KiB chunks, the unit of `ChunkRanges`.
3102        let failing = match cause {
3103            EncodeError::LeafHashMismatch(start) => {
3104                ChunkRanges::from(*start..ChunkNum(start.0.saturating_add(1 << chunk_log)))
3105            }
3106            EncodeError::ParentHashMismatch(node) => ChunkRanges::from(node.chunk_range()),
3107            EncodeError::Io(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {
3108                requested.clone()
3109            }
3110            _ => return,
3111        };
3112        let Ok(present) = self.present_ranges(hash).await else {
3113            return;
3114        };
3115        let held = present.is_complete() || {
3116            // Clip the failing groups to the blob end: the last group is short.
3117            let failing = failing & ChunkRanges::from(..ChunkNum::chunks(present.size()));
3118            !failing.is_empty() && (failing - present.chunk_ranges()).is_empty()
3119        };
3120        if held {
3121            self.quarantine_corrupt(hash).await;
3122        }
3123    }
3124
3125    /// Set the probe-hold budget cap from `cache.max_probe_holds` (ADR 005
3126    /// §Hold budget, #318). Called once by the runtime at bring-up. `0`
3127    /// disables the hold path so [`Self::try_probe_hold`] returns
3128    /// [`ProbeHoldOutcome::HoldsDisabled`] for any present blob (the node then
3129    /// answers `has_blob: false` to every probe).
3130    pub fn set_max_probe_holds(&self, max: usize) {
3131        self.inner.max_probe_holds.store(max, Ordering::Relaxed);
3132    }
3133
3134    /// Set the time budget for each origin read of the range-pull path — one
3135    /// `{H}.obao4` fetch or one data window (ADR 037). Called once by the
3136    /// runtime at bring-up, from `cache.node_pull_stall_window_sec` and
3137    /// `cache.node_pull_min_throughput_bps`. A read of `len` bytes gets
3138    /// `head_start + len / min_bps`, so the budget scales with the read and
3139    /// never caps the blob size: the origin must only sustain `min_bps` on
3140    /// average after the head start. A read past its budget fails as an origin
3141    /// transport fault and bumps `origin_range_timeouts`, so a stuck origin ends
3142    /// the fill instead of parking it until the client gives up.
3143    ///
3144    /// A zero `head_start` or a zero `min_bps` leaves the reads unbounded. A
3145    /// whole-body read has no stream to watch for idle gaps, so there is no
3146    /// idle-only form of the budget. A non-zero `head_start` below one
3147    /// millisecond rounds up to one millisecond.
3148    pub fn set_origin_read_budget(&self, head_start: Duration, min_bps: u64) {
3149        let ms = if head_start.is_zero() {
3150            0
3151        } else {
3152            u64::try_from(head_start.as_millis())
3153                .unwrap_or(u64::MAX)
3154                .max(1)
3155        };
3156        self.inner
3157            .origin_read_head_start_ms
3158            .store(ms, Ordering::Relaxed);
3159        self.inner
3160            .origin_read_min_bps
3161            .store(min_bps, Ordering::Relaxed);
3162    }
3163
3164    /// The budget [`Self::set_origin_read_budget`] set, or `None` for none.
3165    fn origin_read_budget(&self) -> Option<OriginReadBudget> {
3166        let ms = self.inner.origin_read_head_start_ms.load(Ordering::Relaxed);
3167        let min_bps = self.inner.origin_read_min_bps.load(Ordering::Relaxed);
3168        (ms > 0 && min_bps > 0).then(|| OriginReadBudget {
3169            head_start: Duration::from_millis(ms),
3170            min_bps,
3171        })
3172    }
3173
3174    /// The cached outboard for `hash`, if it has the length a `total_bytes`-byte
3175    /// blob needs.
3176    #[cfg(test)]
3177    fn cached_outboard(&self, hash: Hash, total_bytes: u64) -> Option<Bytes> {
3178        self.inner
3179            .outboards
3180            .get(hash, expected_outboard_len(total_bytes))
3181            .map(|c| c.bytes)
3182    }
3183
3184    /// The async lock that serializes origin outboard reads of `hash`
3185    /// ([`Inner::outboard_flights`]). The caller locks it, re-checks the cache,
3186    /// and only then reads from the origin.
3187    fn outboard_flight(&self, hash: Hash) -> Arc<tokio::sync::Mutex<()>> {
3188        let mut flights = self
3189            .inner
3190            .outboard_flights
3191            .lock()
3192            .unwrap_or_else(PoisonError::into_inner);
3193        if let Some(live) = flights.get(&hash).and_then(Weak::upgrade) {
3194            return live;
3195        }
3196        flights.retain(|_, w| w.strong_count() > 0);
3197        let flight = Arc::new(tokio::sync::Mutex::new(()));
3198        flights.insert(hash, Arc::downgrade(&flight));
3199        flight
3200    }
3201
3202    /// The origin read budget as `(head_start, min_bps)`, or `None` when
3203    /// [`Self::set_origin_read_budget`] left the reads unbounded. For the
3204    /// runtime's wiring tests.
3205    #[must_use]
3206    pub fn origin_read_budget_parts(&self) -> Option<(Duration, u64)> {
3207        self.origin_read_budget().map(|b| (b.head_start, b.min_bps))
3208    }
3209
3210    /// [`Self::fetch_gated_outboard`] under `hash`'s flight lock: a concurrent
3211    /// draw that already cached this origin's copy answers from the cache.
3212    async fn flighted_outboard(
3213        &self,
3214        origin_ix: usize,
3215        origin: &Arc<dyn Origin>,
3216        hash: Hash,
3217        expected_len: u64,
3218    ) -> CacheResult<GatedOutboard> {
3219        let flight = self.outboard_flight(hash);
3220        let _reading = flight.lock().await;
3221        match self.inner.outboards.get(hash, expected_len) {
3222            Some(c) if c.origin_ix == origin_ix => Ok(GatedOutboard::Found(c.bytes)),
3223            _ => {
3224                self.fetch_gated_outboard(origin_ix, origin, hash, expected_len)
3225                    .await
3226            }
3227        }
3228    }
3229
3230    /// Read `hash`'s outboard from the origin at `origin_ix` under the read
3231    /// budget and gate it on `expected_len`. A copy of that exact length is
3232    /// cached, tagged with `origin_ix`. Meters every outboard byte the origin
3233    /// sends as `pull_through_bytes`, before the gate, because that egress is
3234    /// paid either way. Logs a transport fault or timeout at `warn`.
3235    ///
3236    /// # Errors
3237    ///
3238    /// [`CacheError::OriginError`] for a transport fault or a read past the
3239    /// budget.
3240    async fn fetch_gated_outboard(
3241        &self,
3242        origin_ix: usize,
3243        origin: &Arc<dyn Origin>,
3244        hash: Hash,
3245        expected_len: u64,
3246    ) -> CacheResult<GatedOutboard> {
3247        let outboard_max = expected_len.saturating_add(64);
3248        let fetched = within_origin_timeout(
3249            self.origin_read_budget(),
3250            expected_len,
3251            hash,
3252            "outboard fetch",
3253            self.inner.metrics.as_deref(),
3254            origin.fetch_outboard(hash, outboard_max),
3255        )
3256        .await
3257        .and_then(|r| {
3258            r.map_err(|e| CacheError::OriginError {
3259                hash,
3260                source: e.into_inner(),
3261            })
3262        });
3263        let ob = match fetched {
3264            Ok(OutboardFetch::Found(ob)) => ob,
3265            Ok(OutboardFetch::NotFound | OutboardFetch::Unsupported) => {
3266                return Ok(GatedOutboard::Declined);
3267            }
3268            Err(e) => {
3269                tracing::warn!(
3270                    %hash,
3271                    kind = ?origin.kind(),
3272                    error = %e.display_chain(),
3273                    "origin outboard fetch failed; trying next origin",
3274                );
3275                return Err(e);
3276            }
3277        };
3278        if let Some(m) = &self.inner.metrics {
3279            m.pull_through_bytes
3280                .inc_by(u64::try_from(ob.len()).unwrap_or(u64::MAX));
3281        }
3282        // Exact-length gate. A wrong-length `{H}.obao4` (a truncated upload, an
3283        // HTML error body under the cap) can never verify against `H`, and a
3284        // broken origin here must not shadow a healthy later one.
3285        if u64::try_from(ob.len()).unwrap_or(u64::MAX) != expected_len {
3286            tracing::warn!(
3287                %hash,
3288                kind = ?origin.kind(),
3289                got = ob.len(),
3290                expected = expected_len,
3291                "origin served a wrong-length outboard; trying next origin",
3292            );
3293            return Ok(GatedOutboard::WrongLength);
3294        }
3295        if !self.inner.outboards.insert(hash, ob.clone(), origin_ix) {
3296            tracing::debug!(
3297                %hash,
3298                len = ob.len(),
3299                budget = OUTBOARD_CACHE_BYTES,
3300                "outboard is larger than the outboard cache; each draw reads it again",
3301            );
3302        }
3303        Ok(GatedOutboard::Found(ob))
3304    }
3305
3306    /// Install the shared frequency estimator. Called once at bring-up when a
3307    /// `tinylfu` policy is selected; absent otherwise.
3308    pub fn set_frequency_estimator(&self, est: Arc<dyn crate::policy::FrequencyEstimator>) {
3309        self.inner.frequency.store(Arc::new(Some(est)));
3310    }
3311
3312    /// Whether a shared frequency estimator is installed (ADR 040 / ADR 041).
3313    /// Introspection for bring-up wiring tests: the estimator is shared by any
3314    /// consumer that needs a heat signal — `tinylfu` eviction/admission and the
3315    /// `margin` serve-economics policy — so this only reports whether *some*
3316    /// consumer requested one, not which.
3317    #[must_use]
3318    pub fn has_frequency_estimator(&self) -> bool {
3319        self.inner.frequency.load().is_some()
3320    }
3321
3322    /// Install the admission policy consulted at store-time (ADR 040). Called
3323    /// once at bring-up when a non-default policy is selected; otherwise the
3324    /// engine keeps [`crate::policy::AlwaysAdmit`].
3325    pub fn set_admission_policy(&self, policy: Arc<dyn crate::policy::AdmissionPolicy>) {
3326        self.inner.admission.store(Arc::new(policy));
3327    }
3328
3329    /// Consult the admission policy for `ctx`, mapping its verdict onto a
3330    /// [`crate::policy::Segment`]. `PassThrough` is reserved: no
3331    /// shipped policy returns it yet, and no pass-through-without-storing leg
3332    /// exists, so it is treated as `Store { Probation }` until one does.
3333    fn admission_segment(&self, ctx: &crate::policy::AdmissionContext) -> crate::policy::Segment {
3334        match self.inner.admission.load().admit(ctx) {
3335            crate::policy::AdmissionDecision::Store { segment } => segment,
3336            crate::policy::AdmissionDecision::PassThrough => crate::policy::Segment::Probation,
3337        }
3338    }
3339
3340    /// `admission_segment` under a test-visible name, so policy wiring can be
3341    /// asserted without reaching through a fill.
3342    #[cfg(test)]
3343    pub fn admission_segment_for_test(
3344        &self,
3345        ctx: &crate::policy::AdmissionContext,
3346    ) -> crate::policy::Segment {
3347        self.admission_segment(ctx)
3348    }
3349
3350    /// Set `hash`'s generic segment membership (ADR 040 §1). Pure in-memory
3351    /// metadata — the blob's commit tag still protects it from GC, so this does
3352    /// no tag I/O. [`crate::policy::Segment::Main`] is the absent default, so
3353    /// setting `Main` removes any entry; under `AlwaysAdmit` (always `Main`)
3354    /// this is a no-op and the map stays empty.
3355    pub fn set_segment(&self, hash: Hash, segment: crate::policy::Segment) {
3356        let mut guard = self
3357            .inner
3358            .segments
3359            .lock()
3360            .unwrap_or_else(PoisonError::into_inner);
3361        match segment {
3362            crate::policy::Segment::Main => {
3363                guard.remove(&hash);
3364            }
3365            crate::policy::Segment::Probation => {
3366                guard.insert(hash, segment);
3367            }
3368        }
3369    }
3370
3371    /// Read `hash`'s segment membership; an untracked hash is
3372    /// [`crate::policy::Segment::Main`].
3373    #[must_use]
3374    pub fn segment_of(&self, hash: Hash) -> crate::policy::Segment {
3375        self.inner
3376            .segments
3377            .lock()
3378            .unwrap_or_else(PoisonError::into_inner)
3379            .get(&hash)
3380            .copied()
3381            .unwrap_or(crate::policy::Segment::Main)
3382    }
3383
3384    /// Sum the sizes of the members of `seg`, using `sizes` for per-hash bytes.
3385    /// For `Main` (the untracked default) this sums every hash in `sizes` not
3386    /// present in the segment map.
3387    #[must_use]
3388    pub fn segment_bytes(&self, seg: crate::policy::Segment, sizes: &HashMap<Hash, u64>) -> u64 {
3389        let guard = self
3390            .inner
3391            .segments
3392            .lock()
3393            .unwrap_or_else(PoisonError::into_inner);
3394        sizes
3395            .iter()
3396            .filter(|(h, _)| {
3397                guard
3398                    .get(*h)
3399                    .copied()
3400                    .unwrap_or(crate::policy::Segment::Main)
3401                    == seg
3402            })
3403            .fold(0u64, |acc, (_, sz)| acc.saturating_add(*sz))
3404    }
3405
3406    /// Snapshot the generic segment membership for the sweep's
3407    /// [`crate::policy::EvictionContext`]. Only non-default (`Probation`)
3408    /// entries are present.
3409    #[must_use]
3410    pub fn segments_snapshot(&self) -> HashMap<Hash, crate::policy::Segment> {
3411        self.inner
3412            .segments
3413            .lock()
3414            .unwrap_or_else(PoisonError::into_inner)
3415            .clone()
3416    }
3417
3418    /// Attempt to take (or refresh) a probe-triggered eviction hold on
3419    /// `hash` for [`crate::probe_hold::PROBE_HOLD_DURATION`] (ADR 005
3420    /// §Probe-triggered eviction hold).
3421    ///
3422    /// Returns [`ProbeHoldOutcome::Held`] when the blob is present, not
3423    /// operator-evicted, and a hold was placed for the full window. The other
3424    /// three variants classify why no hold happened, so the caller need not
3425    /// re-inspect the cache:
3426    /// - [`ProbeHoldOutcome::Unavailable`] — blob absent or operator-evicted.
3427    /// - [`ProbeHoldOutcome::HoldsDisabled`] — holds disabled by config
3428    ///   (`max_probe_holds == 0`); an intentional operator decision.
3429    /// - [`ProbeHoldOutcome::BudgetExhausted`] — present but every hold slot
3430    ///   is live (`max_probe_holds > 0`); genuine budget pressure.
3431    ///
3432    /// Placing a hold and advertising the blob are **not** the same decision:
3433    /// `BudgetExhausted` is advertised despite holding no slot. Callers should
3434    /// read [`ProbeHoldOutcome::advertises`] / [`ProbeHoldOutcome::hold_placed`]
3435    /// rather than comparing against `Held`.
3436    ///
3437    /// Per-blob semantics: a hash already held has its expiry refreshed and
3438    /// consumes no additional slot, so many peers probing one popular blob
3439    /// share a single hold (ADR 005 §Hold budget). The common
3440    /// already-held case is an O(1) lookup that skips the O(N) expiry
3441    /// sweep; the sweep runs only when no live hold exists, bounding map
3442    /// growth without a background task.
3443    ///
3444    /// The operator-evicted set is re-checked **while holding the
3445    /// `probe_holds` lock**, closing the TOCTOU window where a concurrent
3446    /// [`Self::evict`] (DMCA takedown) could land between the initial
3447    /// [`Self::has`] check and granting the hold — a takedown always wins
3448    /// (ADR 040 §Pinning, durable operator-evict, and the probe-hold stay
3449    /// engine-enforced).
3450    pub async fn try_probe_hold(&self, hash: Hash) -> CacheResult<ProbeHoldOutcome> {
3451        // `has` returns false for operator-evicted hashes too. This stays
3452        // *before* the `max == 0` check below so an absent/evicted blob is a
3453        // true negative (`Unavailable`), not a config-disable event.
3454        if !self.has(hash).await? {
3455            return Ok(ProbeHoldOutcome::Unavailable);
3456        }
3457        let max = self.inner.max_probe_holds.load(Ordering::Relaxed);
3458        // Holds disabled by config (#739): answer `HoldsDisabled` before
3459        // touching the lock. This is the "never advertise store-backed content"
3460        // path — it must override an existing live hold so that
3461        // if the budget is ever lowered to 0 while a hold is live, the disable
3462        // wins instead of the fast path below refreshing and re-signing the
3463        // hold. (Today only the tests lower it post-startup;
3464        // `cache.max_probe_holds` is restart-required, not hot-reloaded.) Also
3465        // skips the O(N) expiry sweep entirely while holds are off. Returning
3466        // before the under-lock `is_evicted` re-check is harmless: a blob
3467        // evicted concurrently here is still answered `has_blob: false` (the
3468        // misclassification is `Unavailable`→`HoldsDisabled`, a metric-only
3469        // miscount between two counters — never a false `has_blob: true`).
3470        if max == 0 {
3471            return Ok(ProbeHoldOutcome::HoldsDisabled);
3472        }
3473        let now = tokio::time::Instant::now();
3474        // `Instant + Duration` panics on overflow; saturate instead to keep
3475        // the workspace anti-panic policy (clippy `unwrap_used`/`panic`).
3476        let expiry = now
3477            .checked_add(crate::probe_hold::PROBE_HOLD_DURATION)
3478            .unwrap_or(now);
3479        let mut guard = self
3480            .inner
3481            .probe_holds
3482            .lock()
3483            .unwrap_or_else(PoisonError::into_inner);
3484
3485        // Fast path for popular re-probed blobs: a live hold already exists.
3486        // O(1), no sweep. Still re-check the takedown set under the lock so a
3487        // refresh can't resurrect just-refused content.
3488        if guard.get(&hash).is_some_and(|exp| *exp > now) {
3489            if self.refuses(hash) {
3490                return Ok(ProbeHoldOutcome::Unavailable);
3491            }
3492            guard.insert(hash, expiry);
3493            return Ok(ProbeHoldOutcome::Held);
3494        }
3495
3496        // No live hold — sweep expired entries before consulting the budget.
3497        guard.retain(|_, exp| *exp > now);
3498        // TOCTOU re-check: a concurrent `evict()` or denylist reload may have
3499        // completed after the `has()` above. Under the lock, a refused hash is
3500        // never held — a hold is what authorises signing `has_blob: true`, and
3501        // signing that for a *blacklisted* hash is the ADR 014
3502        // blacklist-violation evidence (and for a locally-evicted one, an
3503        // advertisement the serve path would only refuse).
3504        if self.refuses(hash) {
3505            return Ok(ProbeHoldOutcome::Unavailable);
3506        }
3507        if guard.len() >= max {
3508            // `max == 0` was already handled above, so reaching the budget
3509            // ceiling here is always genuine pressure: every slot is live
3510            // (#739). This is the "increase max_probe_holds" signal (ADR 005
3511            // §Hold budget) — never a config disable.
3512            return Ok(ProbeHoldOutcome::BudgetExhausted);
3513        }
3514        guard.insert(hash, expiry);
3515        Ok(ProbeHoldOutcome::Held)
3516    }
3517
3518    /// Number of currently-active (non-expired) probe holds, for the
3519    /// `probe_hold_slots_used` metric (ADR 005). Sweeps expired entries as
3520    /// a side effect, so a caller that samples it on its own schedule (the
3521    /// node's `/metrics` scrape) reads live holds even with no probe traffic.
3522    pub fn probe_hold_slots_used(&self) -> usize {
3523        let now = tokio::time::Instant::now();
3524        let mut guard = self
3525            .inner
3526            .probe_holds
3527            .lock()
3528            .unwrap_or_else(PoisonError::into_inner);
3529        guard.retain(|_, exp| *exp > now);
3530        guard.len()
3531    }
3532
3533    /// Fetch the blob by hash. Hits the local store on a cache hit; on a miss
3534    /// pulls from the configured origin, BLAKE3-verifies, and inserts before
3535    /// returning.
3536    ///
3537    /// Error variants callers commonly handle:
3538    /// - [`CacheError::NoOrigin`] — miss with no origin configured.
3539    /// - [`CacheError::NotFound`] — origin returned a definitive not-found
3540    ///   (e.g. HTTP 404).
3541    /// - [`CacheError::HashMismatch`] — origin returned bytes whose BLAKE3
3542    ///   hash didn't match the request; bytes are dropped, not cached.
3543    /// - [`CacheError::BlobTooLarge`] / [`CacheError::OriginError`] — size
3544    ///   cap tripped in the engine or in the origin, respectively.
3545    /// - [`CacheError::Store`] — local iroh-blobs store I/O failure.
3546    pub async fn get(&self, hash: Hash) -> CacheResult<Bytes> {
3547        if self.has(hash).await? {
3548            self.observe_hit(hash);
3549            let bytes = self.read_local(hash).await?;
3550            if let Some(m) = &self.inner.metrics {
3551                m.hits.inc();
3552                m.bytes_returned
3553                    .inc_by(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
3554            }
3555            return Ok(bytes);
3556        }
3557
3558        // Logical-eviction guard (#279): once an operator has run
3559        // `decdn node evict <hash>`, a subsequent `get` must not silently
3560        // re-pull from the origin and undo the eviction. The eviction is
3561        // sticky for the life of `<cache_dir>/evicted.log` — there is no
3562        // "unevict" path; an operator who needs to re-cache a previously
3563        // evicted hash hand-edits the log and restarts.
3564        if self.refuses(hash) {
3565            if let Some(m) = &self.inner.metrics {
3566                m.misses.inc();
3567            }
3568            return Err(CacheError::NotFound { hash });
3569        }
3570
3571        // Coalesce concurrent pull-through requests for the same hash (#305).
3572        // A single lock acquisition atomically checks and inserts to avoid the
3573        // race where multiple tasks see an empty map and all proceed to pull.
3574        let bytes = loop {
3575            let state = {
3576                let mut guard = self.inner.lock_inflight();
3577                if let Some(n) = guard.get(&hash) {
3578                    Err(Arc::clone(n))
3579                } else {
3580                    let n = Arc::new(Notify::new());
3581                    guard.insert(hash, Arc::clone(&n));
3582                    Ok(n)
3583                }
3584            };
3585
3586            match state {
3587                // Another task owns the pull — wait, then retry from the top.
3588                Err(notify) => {
3589                    notify.notified().await;
3590                    if self.has(hash).await? {
3591                        let bytes = self.read_local(hash).await?;
3592                        // Waiter found the blob after the owner inserted it:
3593                        // semantically a hit. `bytes_returned` is bumped
3594                        // once after the loop for both arms; bump only
3595                        // `hits` here.
3596                        if let Some(m) = &self.inner.metrics {
3597                            m.hits.inc();
3598                        }
3599                        break bytes;
3600                    }
3601                    // First attempt failed — loop back and either wait on a
3602                    // new owner or become the owner ourselves.
3603                }
3604                // We are the owner — perform the pull. The guard's Drop impl
3605                // removes the inflight entry and wakes waiters even if this
3606                // task is cancelled mid-await, preventing the leak that would
3607                // otherwise hang every future request for `hash`.
3608                Ok(notify) => {
3609                    let _guard = InflightGuard {
3610                        hash,
3611                        inner: &self.inner,
3612                        notify: &notify,
3613                    };
3614                    break self.pull_through_bytes(hash).await?;
3615                }
3616            }
3617        };
3618        self.observe_hit(hash);
3619        if let Some(m) = &self.inner.metrics {
3620            m.bytes_returned
3621                .inc_by(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
3622        }
3623        Ok(bytes)
3624    }
3625
3626    /// Ensure `hash` is present locally, pulling it through the origin chain on
3627    /// a miss — like [`Self::get`] but WITHOUT returning the bytes or bumping
3628    /// the `bytes_returned` / `hits` counters (which measure bytes served to a
3629    /// `get` caller). Use this to fill the cache as a *side effect* — e.g. the
3630    /// node-to-node cache-miss pull-through hook (#831) — so an internal fill is
3631    /// not miscounted as client-facing egress and the whole blob is not
3632    /// re-assembled into a buffer the caller would just drop. A hit is a no-op.
3633    ///
3634    /// That second guarantee is enforced, not merely intended: this path is
3635    /// `FillMode::CommitOnly` all the way down, and neither commit arm can hand a
3636    /// payload back under it — the streaming arm skips the `read_local`, the
3637    /// buffered arm drops its drain buffer (#1132). Before that the blob *was*
3638    /// read back and dropped, which is how the serve path's miss leg came to hold
3639    /// ~708 MB for a 708 MB blob.
3640    ///
3641    /// Note the buffered arm's cost is bounded by
3642    /// `cache.origin_retry.buffered_max_bytes` (4 MiB default) rather than by the
3643    /// mode: an origin that advertises a `size_hint` at or under it is drained
3644    /// before commit either way (a `None` hint always streams, and `0` disables
3645    /// buffering). `CommitOnly` guarantees no blob is handed *back*, not that no
3646    /// blob is ever briefly buffered.
3647    ///
3648    /// The origin-egress metric (`pull_through_bytes`) is still bumped by the
3649    /// pull, which is correct — those bytes really did leave an origin.
3650    ///
3651    /// # Errors
3652    ///
3653    /// Same set as [`Self::get`] (`NoOrigin` / `NotFound` / `HashMismatch` /
3654    /// `BlobTooLarge` / `OriginError` / `Store`).
3655    pub async fn populate(&self, hash: Hash) -> CacheResult<()> {
3656        self.populate_inner(hash, false).await
3657    }
3658
3659    /// Like [`Self::populate`], but restricted to the node's OWN configured
3660    /// origins (fs/http/s3): the `Peer` node→node origin is never consulted, so
3661    /// this fronts no upstream USDC (#1116). The serve path uses it to
3662    /// reactively fill from a local origin a blob a paying, channel-owning
3663    /// client asked for — independent of `node_to_node_pull_through_enabled` —
3664    /// and to prefer a local origin over the paid peer window path. A hit is a
3665    /// no-op; absence from every local origin surfaces `NotFound`, and a chain
3666    /// with no non-`Peer` origin surfaces `NoOrigin`.
3667    ///
3668    /// # Errors
3669    ///
3670    /// Same set as [`Self::populate`].
3671    pub async fn populate_local(&self, hash: Hash) -> CacheResult<()> {
3672        self.populate_inner(hash, true).await
3673    }
3674
3675    /// Shared body of [`Self::populate`] / [`Self::populate_local`]. `local_only`
3676    /// threads through the coalescing loop into [`Self::pull_through`], where it
3677    /// skips the `Peer` origin.
3678    async fn populate_inner(&self, hash: Hash, local_only: bool) -> CacheResult<()> {
3679        if self.has(hash).await? {
3680            // Recency only — a fill is not a hit sighting. The paired serve
3681            // emits the one `observe` through `observe_hit` (ADR 040).
3682            self.record_access(hash);
3683            return Ok(());
3684        }
3685        // Logical-eviction guard (#279): never re-pull a deliberately evicted
3686        // hash (mirrors `get`).
3687        if self.refuses(hash) {
3688            if let Some(m) = &self.inner.metrics {
3689                m.misses.inc();
3690            }
3691            return Err(CacheError::NotFound { hash });
3692        }
3693        // Coalesce concurrent fills for the same hash (#305), mirroring `get`'s
3694        // loop but bumping no `get`-caller metrics: `populate` fills as a side
3695        // effect, so it counts neither a hit nor returned bytes.
3696        loop {
3697            let state = {
3698                let mut guard = self.inner.lock_inflight();
3699                if let Some(n) = guard.get(&hash) {
3700                    Err(Arc::clone(n))
3701                } else {
3702                    let n = Arc::new(Notify::new());
3703                    guard.insert(hash, Arc::clone(&n));
3704                    Ok(n)
3705                }
3706            };
3707            match state {
3708                // Another task owns the pull — wait, then re-check presence.
3709                Err(notify) => {
3710                    notify.notified().await;
3711                    if self.has(hash).await? {
3712                        break;
3713                    }
3714                }
3715                // We own the pull — the guard wakes waiters + clears the entry
3716                // even on cancellation.
3717                Ok(notify) => {
3718                    let _guard = InflightGuard {
3719                        hash,
3720                        inner: &self.inner,
3721                        notify: &notify,
3722                    };
3723                    // Fill-only: the payload is dropped, so the whole blob is
3724                    // never read back out of the store (#1132). The wrapper
3725                    // returns `()`, so there is nothing here to drop by accident.
3726                    self.pull_through_fill(hash, local_only).await?;
3727                    // ADR 040: consult the admission policy now that the fill
3728                    // succeeded and record the chosen segment. Under the default
3729                    // `AlwaysAdmit` the segment is `Main`, so `set_segment` is a
3730                    // no-op and the tag path below is unaffected — membership is
3731                    // pure in-memory metadata, no tag I/O.
3732                    let admission_ctx = crate::policy::AdmissionContext {
3733                        hash,
3734                        known_size: None,
3735                    };
3736                    let segment = self.admission_segment(&admission_ctx);
3737                    self.set_segment(hash, segment);
3738                    break;
3739                }
3740            }
3741        }
3742        // Recency only — the fill's admission read above already consulted the
3743        // estimate; the paired serve emits the one hit sighting (ADR 040).
3744        self.record_access(hash);
3745        Ok(())
3746    }
3747
3748    /// A permit from the own-origin draw pool ([`MAX_CONCURRENT_RANGE_PULLS`]).
3749    /// A full pool bumps `range_pull_permit_waits` and waits rather than
3750    /// degrading, because the degrade is a whole-blob origin pull — more egress,
3751    /// not less. A wait past [`RANGE_PULL_PERMIT_WARN_AFTER`] logs one warning.
3752    async fn range_pull_permit(&self) -> CacheResult<tokio::sync::OwnedSemaphorePermit> {
3753        let pool = &self.inner.own_origin_range_pulls;
3754        if let Ok(permit) = Arc::clone(pool).try_acquire_owned() {
3755            return Ok(permit);
3756        }
3757        if let Some(m) = &self.inner.metrics {
3758            m.range_pull_permit_waits.inc();
3759        }
3760        tracing::debug!(
3761            bound = MAX_CONCURRENT_RANGE_PULLS,
3762            "origin range-pull pool is full; waiting for a permit",
3763        );
3764        let closed =
3765            |e| CacheError::Internal(anyhow::Error::new(e).context("range-pull bound closed"));
3766        let mut acquire = std::pin::pin!(Arc::clone(pool).acquire_owned());
3767        if let Ok(permit) = tokio::time::timeout(RANGE_PULL_PERMIT_WARN_AFTER, &mut acquire).await {
3768            return permit.map_err(closed);
3769        }
3770        tracing::warn!(
3771            bound = MAX_CONCURRENT_RANGE_PULLS,
3772            waited_secs = RANGE_PULL_PERMIT_WARN_AFTER.as_secs(),
3773            "origin range-pull pool still full; a slow or hung origin may be holding \
3774             permits for whole draws, stalling this fill",
3775        );
3776        acquire.await.map_err(closed)
3777    }
3778
3779    /// The `{H}.obao4` outboard for `hash` (a `total_bytes`-byte blob): the
3780    /// cached copy of the exact length, or else the first origin's copy that
3781    /// passes the exact-length gate (`expected_outboard_len`, read with 64
3782    /// bytes of slack), which is then cached. `Ok(None)` when no origin serves
3783    /// one (or none are configured) — the caller degrades exactly as with an
3784    /// unsupported range. Each origin read runs under
3785    /// [`Self::set_origin_read_budget`]'s budget. A per-origin decline, wrong
3786    /// length, transport fault or timeout advances the chain.
3787    ///
3788    /// This is the outboard half of the own-origin serviceability probe
3789    /// ([`Self::origin_range_serviceable`]), which the serve-miss path runs
3790    /// before it signs a `StreamResponse`, so a blob no origin can prove is
3791    /// never advertised as serviceable. A cached copy answers without an origin
3792    /// read.
3793    /// The returned outboard is UNTRUSTED until it verifies against the root `H`
3794    /// (the range encode in [`Self::origin_range_wire`] is where that happens).
3795    ///
3796    /// # Errors
3797    ///
3798    /// [`CacheError::OriginError`] when no origin serves the outboard and at
3799    /// least one failed with a transport fault or a timeout (the last one).
3800    pub async fn origin_fetch_outboard_bytes(
3801        &self,
3802        hash: Hash,
3803        total_bytes: u64,
3804    ) -> CacheResult<Option<Bytes>> {
3805        Ok(self
3806            .outboard_with_origin(hash, total_bytes)
3807            .await?
3808            .map(|(ob, _)| ob))
3809    }
3810
3811    /// [`Self::origin_fetch_outboard_bytes`], with the index of the origin whose
3812    /// copy it is — the cached entry's origin, or the origin that served it.
3813    async fn outboard_with_origin(
3814        &self,
3815        hash: Hash,
3816        total_bytes: u64,
3817    ) -> CacheResult<Option<(Bytes, usize)>> {
3818        let expected_len = expected_outboard_len(total_bytes);
3819        if let Some(cached) = self.inner.outboards.get(hash, expected_len) {
3820            return Ok(Some((cached.bytes, cached.origin_ix)));
3821        }
3822        // A concurrent cold miss of this hash may be reading the outboard now:
3823        // wait for it, then take its cached copy.
3824        let flight = self.outboard_flight(hash);
3825        let _reading = flight.lock().await;
3826        if let Some(cached) = self.inner.outboards.get(hash, expected_len) {
3827            return Ok(Some((cached.bytes, cached.origin_ix)));
3828        }
3829        // A genuine transport fault on an origin (as opposed to a clean
3830        // `NotFound`/`Unsupported` decline) is remembered so it can be surfaced when
3831        // NO origin serves the outboard. The serviceability caller latches this into
3832        // `fault_seen` (#1129): an own-origin miss that fails because the operator's
3833        // origin is degraded must terminate as `InternalError`, not a bare
3834        // `NotFound`. A clean decline stays `Ok(None)` so the caller degrades
3835        // silently (ADR 037 §"Fallback is always correct").
3836        let mut last_err: Option<CacheError> = None;
3837        for (ix, origin) in self.inner.origins.iter().enumerate() {
3838            match self
3839                .fetch_gated_outboard(ix, origin, hash, expected_len)
3840                .await
3841            {
3842                Ok(GatedOutboard::Found(ob)) => return Ok(Some((ob, ix))),
3843                Ok(GatedOutboard::Declined | GatedOutboard::WrongLength) => {}
3844                Err(e) => last_err = Some(e),
3845            }
3846        }
3847        // No origin served the outboard. If any errored on the way, that transport
3848        // fault is the answer (degraded, not absent); otherwise it is a clean
3849        // absence and the caller degrades to the buffered path.
3850        match last_err {
3851            Some(e) => Err(e),
3852            None => Ok(None),
3853        }
3854    }
3855
3856    /// Stream the header-less interleaved bao **wire** (ADR 038) for
3857    /// `aligned`'s span out of the configured origins, verified against the
3858    /// root `H` — the raw-fetch half of a range pull WITHOUT the import (the
3859    /// node's `NodeAdmitStore` sink admits, fed by
3860    /// `decdn_client::BlobSource`).
3861    ///
3862    /// Each origin serves both halves of its own attempt: the outboard and the
3863    /// data. The origin whose outboard is cached goes first and reuses that copy,
3864    /// so a draw reads no outboard from the origin while the hash stays cached.
3865    /// Every other origin, in chain order, reads its own outboard through the
3866    /// exact-length gate and caches it. The first origin that serves the first
3867    /// window wins; a per-origin decline or transport fault advances the chain.
3868    /// Each origin read runs under [`Self::set_origin_read_budget`]'s budget. A
3869    /// verify fault evicts the copy the draw used, so the next draw reads the
3870    /// outboard again. The wire is produced
3871    /// window by window ([`crate::RANGE_PULL_WINDOW_BYTES`]) by a background
3872    /// encode that holds a permit from the engine-wide pool of
3873    /// [`crate::MAX_CONCURRENT_RANGE_PULLS`], so memory stays
3874    /// `O(window + outboard)` whatever the span (#2065).
3875    ///
3876    /// A verify failure here is a HARD fault ([`CacheError::VerifyFailed`]), NOT a
3877    /// degrade: by the time this runs the node has signed a
3878    /// `StreamResponse` committing to serve under `H`, so a corrupt or
3879    /// misconfigured OWN origin is a local-origin fault to surface, not upstream
3880    /// corruption to route around (there is no upstream, and no fallback still
3881    /// honours `H`). A fault after the wire has started ends it with a terminal
3882    /// `Err` from [`OriginRangeWire::next_chunk`].
3883    ///
3884    /// # Errors
3885    ///
3886    /// - [`CacheError::VerifyFailed`] — no origin opened the range, and an origin
3887    ///   served a wrong-length outboard or a wrong-length first window; neither
3888    ///   can verify against `H`.
3889    /// - [`CacheError::OriginError`] — no origin opened the range, and at least
3890    ///   one failed with a transport fault or a timeout (the last such fault).
3891    ///
3892    /// `Ok(None)` when every origin cleanly declines.
3893    pub async fn origin_range_wire(
3894        &self,
3895        hash: Hash,
3896        aligned: &AlignedRange,
3897    ) -> CacheResult<Option<OriginRangeWire>> {
3898        let permit = self.range_pull_permit().await?;
3899        let Some(cursor) = self.open_range_cursor(hash, aligned, None).await? else {
3900            return Ok(None);
3901        };
3902        OriginRangeWire::spawn(cursor, aligned, permit, self.inner.outboards.clone()).map(Some)
3903    }
3904
3905    /// The own-origin serviceability probe the serve-miss path runs before it
3906    /// signs a `StreamResponse`: whether an origin furnishes both the `{H}.obao4`
3907    /// outboard for `hash` (a `total_bytes`-byte blob) and ranged reads of its
3908    /// data. `false` sends the caller to the buffered whole-blob degrade
3909    /// (ADR 037 §"Fallback is always correct"), so an origin that publishes an
3910    /// outboard but declines `Range` does not have a signed stream fail on its
3911    /// first draw. The exception is an origin that served ranges earlier and
3912    /// has stopped since: it fails its draw like any other mid-life origin
3913    /// fault.
3914    ///
3915    /// The range half reads the first chunk group through the same origin
3916    /// chain a draw walks, starting at the origin that served the outboard. It
3917    /// reads nothing when that origin has already served a clean range window.
3918    /// The 0-byte blob has no data to range, so its outboard alone answers.
3919    ///
3920    /// # Errors
3921    ///
3922    /// - [`CacheError::OriginError`] — no origin serves both halves, and at
3923    ///   least one failed with a transport fault or a timeout.
3924    /// - [`CacheError::VerifyFailed`] — no origin serves both halves, and an
3925    ///   origin served a wrong-length outboard or first window, which cannot
3926    ///   verify.
3927    pub async fn origin_range_serviceable(
3928        &self,
3929        hash: Hash,
3930        total_bytes: u64,
3931    ) -> CacheResult<bool> {
3932        let Some((outboard, ix)) = self.outboard_with_origin(hash, total_bytes).await? else {
3933            return Ok(false);
3934        };
3935        if total_bytes == 0 || self.range_confirmed(ix) {
3936            return Ok(true);
3937        }
3938        let first_group =
3939            align_range(0, CHUNK_GROUP_BYTES.min(total_bytes), total_bytes).map_err(|e| {
3940                CacheError::Internal(anyhow::Error::from(e).context("range probe alignment"))
3941            })?;
3942        let opened = self
3943            .open_range_cursor(hash, &first_group, Some((ix, outboard)))
3944            .await?
3945            .is_some();
3946        if !opened {
3947            tracing::debug!(
3948                %hash,
3949                "no own origin serves ranged reads of this blob; the miss degrades to a \
3950                 whole-blob origin pull",
3951            );
3952        }
3953        Ok(opened)
3954    }
3955
3956    /// Whether the origin at `ix` has served a clean first range window.
3957    fn range_confirmed(&self, ix: usize) -> bool {
3958        self.inner
3959            .origin_range_confirmed
3960            .get(ix)
3961            .is_some_and(|c| c.load(Ordering::Relaxed))
3962    }
3963
3964    /// Open a cursor on the first origin that serves both the outboard and a
3965    /// first window of `aligned` of the right length. `preferred` (an origin
3966    /// index and the outboard it served) goes first and reuses that copy; with
3967    /// `None`, the origin whose outboard is cached goes first and reuses the
3968    /// cached copy. Every other origin, in chain order, reads its own outboard
3969    /// through the exact-length gate. A per-origin decline, transport fault,
3970    /// wrong-length outboard or wrong-length first window advances the chain,
3971    /// so a broken origin never shadows a healthy later one. The opened origin
3972    /// is marked range-confirmed. Errors as [`Self::origin_range_wire`].
3973    async fn open_range_cursor(
3974        &self,
3975        hash: Hash,
3976        aligned: &AlignedRange,
3977        preferred: Option<(usize, Bytes)>,
3978    ) -> CacheResult<Option<OriginRangeCursor>> {
3979        let expected_len = expected_outboard_len(aligned.blob_size());
3980        let cached = preferred.or_else(|| {
3981            self.inner
3982                .outboards
3983                .get(hash, expected_len)
3984                .map(|c| (c.origin_ix, c.bytes))
3985        });
3986        let first = cached.as_ref().map(|(ix, _)| *ix);
3987        let order = first
3988            .into_iter()
3989            .chain((0..self.inner.origins.len()).filter(|ix| Some(*ix) != first));
3990        let budget = self.origin_read_budget();
3991        let mut last_err: Option<CacheError> = None;
3992        let mut wrong_length = false;
3993        for ix in order {
3994            let Some(origin) = self.inner.origins.get(ix) else {
3995                continue;
3996            };
3997            let outboard = match &cached {
3998                Some((cached_ix, bytes)) if *cached_ix == ix => bytes.clone(),
3999                _ => match self.flighted_outboard(ix, origin, hash, expected_len).await {
4000                    Ok(GatedOutboard::Found(ob)) => ob,
4001                    Ok(GatedOutboard::Declined) => continue,
4002                    Ok(GatedOutboard::WrongLength) => {
4003                        wrong_length = true;
4004                        continue;
4005                    }
4006                    Err(e) => {
4007                        last_err = Some(e);
4008                        continue;
4009                    }
4010                },
4011            };
4012            let opened = OriginRangeCursor::open(
4013                Arc::clone(origin),
4014                hash,
4015                aligned,
4016                outboard,
4017                budget,
4018                self.inner.metrics.clone(),
4019            )
4020            .await;
4021            match opened {
4022                Ok(Some(cursor)) if cursor.first_is_wrong_length() => {
4023                    tracing::warn!(
4024                        %hash,
4025                        kind = ?origin.kind(),
4026                        "own origin served a wrong-length first range window; it cannot \
4027                         verify against H, trying next origin",
4028                    );
4029                    wrong_length = true;
4030                }
4031                Ok(Some(cursor)) => {
4032                    if let Some(confirmed) = self.inner.origin_range_confirmed.get(ix) {
4033                        confirmed.store(true, Ordering::Relaxed);
4034                    }
4035                    return Ok(Some(cursor));
4036                }
4037                Ok(None) => {}
4038                Err(e) => {
4039                    tracing::warn!(
4040                        %hash,
4041                        kind = ?origin.kind(),
4042                        error = %e.display_chain(),
4043                        "own origin range open failed; trying next origin",
4044                    );
4045                    last_err = Some(e);
4046                }
4047            }
4048        }
4049        match (last_err, wrong_length) {
4050            (Some(e), _) => Err(e),
4051            (None, true) => Err(CacheError::VerifyFailed { expected: hash }),
4052            (None, false) => Ok(None),
4053        }
4054    }
4055
4056    /// Import an already-encoded interleaved bao range for `hash`, verified
4057    /// against the root on import (iroh-blobs `import_bao_bytes`). Thin
4058    /// wrapper over the store call, exposed so `NodeRangedStore::admit` need
4059    /// not reach into the private store handle. Records recency, like every
4060    /// fill path, so the partial it leaves is an eviction candidate. An admit
4061    /// that completes a discovery block announces the hash on
4062    /// [`Self::subscribe_inserts`].
4063    pub async fn admit_bao(
4064        &self,
4065        hash: Hash,
4066        chunk_ranges: bao_tree::ChunkRanges,
4067        bao_bytes: bytes::Bytes,
4068    ) -> CacheResult<()> {
4069        let imported = self
4070            .inner
4071            .store
4072            .blobs()
4073            .import_bao_bytes(hash, chunk_ranges.clone(), bao_bytes)
4074            .await
4075            .map_err(|e| {
4076                CacheError::Store(anyhow::Error::from(e).context("admit_bao: import_bao_bytes"))
4077            });
4078        let finished = match imported {
4079            Ok(()) => self
4080                .protect_partial(hash)
4081                .await
4082                .map(|()| self.record_access(hash)),
4083            Err(e) => Err(e),
4084        };
4085        // On success and failure alike, as in `admit_bao_stream`: items the store
4086        // verified before a fault stay on disk.
4087        self.announce_completed_blocks(hash, &chunk_ranges).await;
4088        finished
4089    }
4090
4091    /// Stream the header-less bao for `chunk_ranges` of `hash` (a `total_bytes`
4092    /// blob) off `reader` into the cache as a **partial**, O(chunk-group),
4093    /// verifying against the root incrementally. Returns the drained `reader`
4094    /// (for `BlobSource::finish`).
4095    ///
4096    /// Drives `bao_tree`'s [`ResponseDecoder`] directly over the header-less wire
4097    /// (ADR 038 — the size is the trusted `total_bytes`, not an in-band prefix),
4098    /// forwarding each decoded [`BaoContentItem`] to iroh-blobs' `import_bao`
4099    /// handle. The decoder pulls one chunk at a time and the import channel
4100    /// backpressures, so memory stays O(one item), not O(range size).
4101    ///
4102    /// When a serve leg shares this fill (`session`), the decoder's `Parent` items
4103    /// ARE the outboard proof nodes, so they are captured into the shared session
4104    /// in the SAME decode pass — no post-admit `export_bao` read-back that would
4105    /// re-stream the whole range through the store actor a second time (#1790 item
4106    /// 4). Each leaf's run of proof nodes goes into the session in one batch just
4107    /// before that leaf goes to the store, so a parked serve leg encodes its first
4108    /// frame after the first leaf, not after the whole range, and wakes at most once
4109    /// per leaf. Front-to-back admits union to the whole tree.
4110    ///
4111    /// # Errors
4112    ///
4113    /// - [`CacheError::VerifyFailed`] — the decoder rejected a chunk group or
4114    ///   parent hash against the root `hash`: the forwarded bytes are corrupt
4115    ///   (a lying upstream). Nothing is admitted.
4116    /// - [`CacheError::Feed`] — a truncated or failed feed off `reader`, or a zero
4117    ///   `total_bytes` for a non-empty `hash`: the sender's fault (distinct from
4118    ///   corruption — see `classify_admit_decode_error`).
4119    /// - [`CacheError::Store`] — a fault of this node's store or its import
4120    ///   channel.
4121    ///
4122    /// The `reader` is carried on BOTH result arms: `Ok(reader)` on success and
4123    /// `Err((reader, err))` on failure. The error arm hands it back so the
4124    /// caller can recover a typed peer fault the reader parked while filling
4125    /// (`PullStalled`/`PullTimeout`/`UpstreamRefused`/`UpstreamVoucherRejected`/
4126    /// buyer-side `LocalPullFault`) — that parked fault is the real reason the
4127    /// stream stopped, and it beats the generic truncated-feed `CacheError` the
4128    /// decoder sees. This crate does not read the fault itself (it must not
4129    /// depend on `decdn-client`); it only returns the reader so the node ingest
4130    /// path can.
4131    pub async fn admit_bao_stream<R>(
4132        &self,
4133        hash: Hash,
4134        chunk_ranges: ChunkRanges,
4135        total_bytes: u64,
4136        reader: R,
4137        session: Option<&Arc<crate::FillSession>>,
4138    ) -> Result<R, (R, CacheError)>
4139    where
4140        R: AsyncStreamReader + Send,
4141    {
4142        let Some(size) = NonZeroU64::new(total_bytes) else {
4143            // An empty blob has no bao to decode. Mirror iroh-blobs' own
4144            // `import_bao_reader`: the canonical empty hash is a no-op success
4145            // (nothing to import, store, or protect), while a zero size under any
4146            // other hash is an upstream inconsistency (the signed `total_bytes`
4147            // disagrees with a non-empty content hash) — the sender's fault.
4148            if hash == Hash::EMPTY {
4149                return Ok(reader);
4150            }
4151            return Err((
4152                reader,
4153                CacheError::Feed(anyhow::anyhow!(
4154                    "admit_bao_stream: zero total_bytes for non-empty hash {hash}"
4155                )),
4156            ));
4157        };
4158        let tree = bao_tree::BaoTree::new(total_bytes, crate::range_pull::IROH_BLOCK_SIZE);
4159        let capture_into = session.cloned();
4160        let admitted = chunk_ranges.clone();
4161
4162        let handle = match self
4163            .inner
4164            .store
4165            .blobs()
4166            .import_bao(hash, size, ADMIT_BAO_LOCAL_UPDATE_CAP)
4167            .await
4168        {
4169            Ok(handle) => handle,
4170            Err(e) => {
4171                return Err((
4172                    reader,
4173                    CacheError::Store(
4174                        anyhow::Error::from(e).context("admit_bao_stream: import_bao"),
4175                    ),
4176                ));
4177            }
4178        };
4179        // Split the handle so the decode driver owns the item sender while the store
4180        // result is awaited concurrently. The sender is bounded, so both halves must
4181        // make progress together — fully draining the driver before awaiting the
4182        // result would deadlock once the channel fills.
4183        let tx = handle.tx;
4184        let rx = handle.rx;
4185
4186        let driver = async move {
4187            let mut decoder = ResponseDecoder::new(hash.into(), chunk_ranges, tree, reader);
4188            let mut pairs = Vec::new();
4189            loop {
4190                match decoder.next().await {
4191                    ResponseDecoderNext::More((rest, item)) => {
4192                        let item = match item {
4193                            Ok(item) => item,
4194                            // A decode/verify fault (corrupt bytes) or a truncated
4195                            // feed. Recover the reader and surface the decoder's
4196                            // `io::Error` for classification.
4197                            Err(e) => break (rest.finish(), Err(std::io::Error::from(e))),
4198                        };
4199                        // The `Parent` items ARE the outboard proof nodes; capture
4200                        // them for the serve leg here, in the one decode pass, rather
4201                        // than re-reading them back out of the store afterwards. The
4202                        // decoder yields a leaf's proof nodes, already verified, right
4203                        // before that leaf, so each run is published in one batch as
4204                        // its leaf arrives.
4205                        if let Some(session) = &capture_into {
4206                            if let BaoContentItem::Parent(parent) = &item {
4207                                pairs.push((parent.node, parent.pair));
4208                            } else if !pairs.is_empty() {
4209                                session.capture_many(pairs.drain(..));
4210                            }
4211                        }
4212                        if tx.send(item).await.is_err() {
4213                            // The store import ended before this item landed — its
4214                            // result (awaited below) explains why. Recover the reader.
4215                            break (rest.finish(), Ok(pairs));
4216                        }
4217                        decoder = rest;
4218                    }
4219                    ResponseDecoderNext::Done(reader) => break (reader, Ok(pairs)),
4220                }
4221            }
4222            // `tx` drops here, ending the fed item stream so the store finalizes.
4223        };
4224        // Await the store's result concurrently with the decode: the item channel is
4225        // bounded, so the store must drain it while the driver fills it.
4226        let ((reader, decode_res), store_res) = tokio::join!(driver, rx);
4227
4228        let finished: CacheResult<()> = 'finish: {
4229            // A decode/verify fault names the real cause (corrupt upstream vs
4230            // truncated feed) and wins over the store side.
4231            let pairs = match decode_res {
4232                Ok(pairs) => pairs,
4233                Err(io_err) => break 'finish Err(classify_admit_decode_error(hash, io_err)),
4234            };
4235            // Then the store's own result, or a dropped receiver (the store task died).
4236            match store_res {
4237                Ok(Ok(())) => {}
4238                Ok(Err(e)) => {
4239                    break 'finish Err(CacheError::Store(
4240                        anyhow::Error::from(e).context("admit_bao_stream: store import"),
4241                    ));
4242                }
4243                Err(_recv) => {
4244                    break 'finish Err(CacheError::Store(anyhow::anyhow!(
4245                        "admit_bao_stream: import result channel dropped"
4246                    )));
4247                }
4248            }
4249
4250            // ADR 040: consult the admission policy, then label the segment only
4251            // after `protect_partial` succeeds, so a failed protect leaves no stale
4252            // membership entry for an unprotected blob. Under the default
4253            // `AlwaysAdmit` the segment is `Main`, so `set_segment` is a no-op —
4254            // membership is pure in-memory metadata, no tag I/O.
4255            let admission_ctx = crate::policy::AdmissionContext {
4256                hash,
4257                known_size: Some(total_bytes),
4258            };
4259            let segment = self.admission_segment(&admission_ctx);
4260            if let Err(e) = self.protect_partial(hash).await {
4261                break 'finish Err(e);
4262            }
4263            self.set_segment(hash, segment);
4264            // Recency only, like every fill path: the partial becomes an eviction
4265            // candidate even when no serve follows (an aborted or unpaid serve leg
4266            // never reaches `observe_hit`), so the bytes `size_snapshot` counts can
4267            // be released.
4268            self.record_access(hash);
4269            // Capture any proof nodes left after the last leaf. `pairs` is empty
4270            // when no serve leg shares this fill.
4271            if let Some(session) = session {
4272                session.capture_many(pairs);
4273            }
4274            Ok(())
4275        };
4276        // On success and failure alike: the store keeps every item it verified
4277        // before a fault, and a gap-fill retry admits only the missing ranges, so
4278        // a block this admit completed is announced here or not at all.
4279        self.announce_completed_blocks(hash, &admitted).await;
4280        match finished {
4281            Ok(()) => Ok(reader),
4282            Err(e) => Err((reader, e)),
4283        }
4284    }
4285
4286    /// Best-effort total byte size of `hash` from the configured origins, for
4287    /// scoping a range pull ([`Self::origin_range_wire`] needs the exact blob
4288    /// size to align + verify a sub-range against the root `H`, and the
4289    /// `{H}.obao4` outboard alone doesn't pin the final chunk's length). Walks
4290    /// the origin fallback chain ([`Origin::size`] — HTTP `HEAD` / S3
4291    /// `HeadObject` / `fs` metadata) and returns the first known size; a
4292    /// per-origin `Ok(None)` (no object / compressed / unsupported) or a
4293    /// transport error advances the chain.
4294    ///
4295    /// Returns `Ok(None)` when every origin cleanly declines — the caller MUST
4296    /// then degrade to a whole-blob [`Self::populate`] / [`Self::get`]. This is
4297    /// a metadata probe only: it never fetches or caches bytes, so it carries no
4298    /// logical-eviction guard (the caller's serve path and whole-blob fallback
4299    /// both enforce it).
4300    ///
4301    /// # Errors
4302    ///
4303    /// - [`CacheError::NoOrigin`] when no origin is configured, so the caller
4304    ///   sees a coherent "can't range-pull" signal rather than a silent `None`.
4305    /// - [`CacheError::OriginError`] when NO origin answers and at least one
4306    ///   failed with a transport fault (the last such fault). A per-origin
4307    ///   fault still advances the chain — a later origin's answer wins — but a
4308    ///   probe that ends on faults reports a degraded node, not an empty one,
4309    ///   so the serve path's #1129 latch can turn the terminal miss into
4310    ///   `InternalError` instead of `NotFound`. Mirrors
4311    ///   [`Self::origin_fetch_outboard_bytes`].
4312    pub async fn origin_size(&self, hash: Hash) -> CacheResult<Option<u64>> {
4313        if self.inner.origins.is_empty() {
4314            return Err(CacheError::NoOrigin { hash });
4315        }
4316        let mut last_err: Option<CacheError> = None;
4317        for origin in &self.inner.origins {
4318            match origin.size(hash).await {
4319                Ok(Some(size)) => return Ok(Some(size)),
4320                Ok(None) => {}
4321                Err(e) => {
4322                    tracing::debug!(
4323                        %hash,
4324                        kind = ?origin.kind(),
4325                        error = %format_args!("{e:#}"),
4326                        "origin size probe failed; trying next origin",
4327                    );
4328                    last_err = Some(CacheError::OriginError {
4329                        hash,
4330                        source: e.into_inner(),
4331                    });
4332                }
4333            }
4334        }
4335        match last_err {
4336            Some(e) => Err(e),
4337            None => Ok(None),
4338        }
4339    }
4340
4341    /// Flush ephemeral state to disk. The iroh-blobs store does its own
4342    /// cleanup on drop, but only an explicit
4343    /// [`iroh_blobs::store::fs::FsStore`] shutdown guarantees that in-flight
4344    /// writes survive a crash of the surrounding process, so the runtime
4345    /// calls this during graceful shutdown.
4346    pub async fn shutdown(&self) -> CacheResult<()> {
4347        tracing::debug!("flushing cache engine store");
4348        self.inner
4349            .store
4350            .shutdown()
4351            .await
4352            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
4353    }
4354
4355    /// Return the last access time for `hash`, or `None` if the hash has had
4356    /// no access since open.
4357    ///
4358    /// A blob that was on disk at open and has had no access since holds the
4359    /// open-time recency seed in `access_times`. The seed is not an access, so
4360    /// this returns `None` for it.
4361    pub fn last_accessed(&self, hash: Hash) -> Option<Instant> {
4362        self.inner
4363            .access_times
4364            .get(&hash)
4365            .map(|e| *e.value())
4366            .filter(|t| *t != self.inner.cold_seed_at)
4367    }
4368
4369    /// Collect every recency entry. Eviction logic can sort by value to
4370    /// determine LRU ordering.
4371    ///
4372    /// Not a point-in-time snapshot: the map is sharded and this walks it shard
4373    /// by shard, so a record that lands mid-walk may or may not appear (see the
4374    /// `access_times` field docs). Recency is advisory input to eviction
4375    /// ordering, so a hash missed by one walk is seen by the next.
4376    ///
4377    /// **Note:** this snapshot is the *raw* access map. It includes the
4378    /// open-time recency seed of every blob on disk at open that has had no
4379    /// access since, and it includes pinned hashes. Eviction implementations
4380    /// should use [`Self::eviction_candidates`] instead, which filters pinned
4381    /// hashes out so they survive LRU pressure (#276). The raw snapshot is
4382    /// still exposed because tests and observability paths sometimes want the
4383    /// unfiltered view.
4384    pub fn access_times_snapshot(&self) -> HashMap<Hash, Instant> {
4385        self.inner
4386            .access_times
4387            .iter()
4388            .map(|e| (*e.key(), *e.value()))
4389            .collect()
4390    }
4391
4392    /// Walk every blob in the local iroh-blobs store, complete or partial, and
4393    /// return its hash, excluding operator-evicted blobs
4394    /// ([ADR 011](../../../adr/011-content-takedown.md)). Consumed by the DHT
4395    /// republish scheduler at startup and by its lag sweep (ADR 022 §Bootstrap;
4396    /// AC 15 cold start, AC 20 lag re-seed): every held blob's first re-publish time is drawn from
4397    /// `uniform(0, 40 min)` per record, so the bootstrap `Store` rate matches
4398    /// steady-state by construction. The complementary
4399    /// [`Self::subscribe_inserts`] stream handles fresh commits and completed
4400    /// discovery blocks during steady state; the two together cover every blob
4401    /// the node may advertise.
4402    ///
4403    /// A partial is listed whether or not it covers a whole discovery block
4404    /// (ADR 022 §STORE Flow: a partial holder publishes once it verifies one).
4405    /// The walk reads only `status()`: deciding coverage here would `observe()`
4406    /// every partial at boot, and each `observe()` of an idle partial costs an
4407    /// fsync on the entry's idle shutdown. The republisher's due-time gate reads
4408    /// coverage instead, one hash at a time across the jittered window, and
4409    /// drops a partial that covers no block.
4410    ///
4411    /// This reads on-disk state. [`Self::open`] calls it to seed
4412    /// `access_times`, so every blob it returns at open is an eviction
4413    /// candidate. Boot therefore pays this walk twice: once for that seed and
4414    /// once for the DHT republish seed.
4415    ///
4416    /// Returns a [`Vec`] rather than an async stream because the consumer
4417    /// drains the input strictly into a hash set, so streaming saves nothing
4418    /// downstream. The transient is small: at C = 100k blobs the allocation
4419    /// is roughly 100k × 32 bytes = 3.2 MiB.
4420    ///
4421    /// Race semantics: a blob evicted between the iroh-blobs `list()`
4422    /// emission and the per-hash [`Self::is_evicted`] filter would be
4423    /// included, but downstream publish paths re-check the evicted set, so a
4424    /// stale entry in the scheduler's heap fails the membership check at
4425    /// publish time rather than re-advertising a takedown.
4426    ///
4427    /// Error semantics: a failure on the iroh-blobs cursor itself
4428    /// (`list()` or `stream.next()`) aborts the walk — we can't trust
4429    /// subsequent yields once the cursor is broken. But a per-blob
4430    /// `status()` failure logs-and-skips so a single inaccessible blob
4431    /// doesn't deny the seed for every other healthy blob. The
4432    /// republish surface is best-effort by design; partial completion
4433    /// strictly beats total failure.
4434    pub async fn iter_hashes(&self) -> CacheResult<Vec<Hash>> {
4435        let blobs = self.inner.store.blobs();
4436        let mut stream = blobs
4437            .list()
4438            .stream()
4439            .await
4440            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4441        let mut out = Vec::new();
4442        while let Some(hash) = stream.next().await {
4443            let hash = hash.map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4444            if self.refuses(hash) {
4445                continue;
4446            }
4447            let status = match blobs.status(hash).await {
4448                Ok(s) => s,
4449                Err(err) => {
4450                    tracing::warn!(
4451                        hash = %hash,
4452                        error = %err,
4453                        "iter_hashes: blob status() failed; skipping (seed continues with remaining blobs)"
4454                    );
4455                    continue;
4456                }
4457            };
4458            if !matches!(status, iroh_blobs::api::blobs::BlobStatus::NotFound) {
4459                out.push(hash);
4460            }
4461        }
4462        Ok(out)
4463    }
4464
4465    /// Return a snapshot of access times **excluding pinned hashes**.
4466    /// This is the canonical input to LRU eviction (#276): a pinned hash
4467    /// never appears here, so any candidate-picking sort or top-K query
4468    /// run against the result inherently respects the pinning policy.
4469    ///
4470    /// The exception is a pinned hash that is deny-listed (local or
4471    /// governance deny): it stays a candidate, because deny wins over pin.
4472    /// The deny set itself never removes a hash from the snapshot.
4473    ///
4474    /// The return type ([`EvictionCandidates`]) is a newtype with no
4475    /// public constructor — callers can iterate or `into_inner` but
4476    /// cannot fabricate one. This makes "pinned-already-excluded" a
4477    /// type-level invariant rather than a documentation claim.
4478    ///
4479    /// Every blob the open-time store walk finds is a candidate from open.
4480    /// Until it is accessed, it ranks older than every blob accessed since
4481    /// open.
4482    ///
4483    /// The pinned set is loaded once at the start of the call so a
4484    /// concurrent `set_pinned` swap doesn't change which hashes get
4485    /// filtered mid-iteration — the snapshot is consistent against
4486    /// *some* pinned generation, just not necessarily the very latest.
4487    ///
4488    /// A third filter layer (after pinned, before the LRU sort) drops any
4489    /// hash under an active probe-triggered eviction hold, even a deny-listed
4490    /// one (#318, ADR 005
4491    /// §Probe-triggered eviction hold; ADR 040 §Pinning, durable operator-evict,
4492    /// and the probe-hold stay engine-enforced:
4493    /// "a held hash is invisible to the LRU driver until the hold
4494    /// expires"). The held set is swept of expired entries here too, so a
4495    /// node with no probe traffic still releases stale holds.
4496    pub fn eviction_candidates(&self) -> EvictionCandidates {
4497        let pinned = self.inner.pinned.load();
4498        // Hoisted once, like `pinned`: a deny-listed hash stays an eviction
4499        // candidate even when pinned, so "deny wins over pin" holds on the space
4500        // path too, not only the takedown `evict()` actuator — otherwise a pinned
4501        // + governance-denied hash would sit on disk (unservable, since `refuses`
4502        // blocks it) until the watcher's `evict()` happened to run. These are the
4503        // lock-free deny halves of `refuses`; `is_evicted` is deliberately NOT
4504        // consulted — it would take a mutex per candidate during the shard walk
4505        // below for nothing, since `evict` removes the `access_times` entry, so an
4506        // already-evicted hash is never in this map to begin with, and the
4507        // open-time seed skips evicted hashes via `iter_hashes`.
4508        let denied = self.inner.denied.load();
4509        let chain_denied = self.inner.chain_denied.load();
4510        let now = tokio::time::Instant::now();
4511        let held: HashSet<Hash> = {
4512            let mut g = self
4513                .inner
4514                .probe_holds
4515                .lock()
4516                .unwrap_or_else(PoisonError::into_inner);
4517            g.retain(|_, exp| *exp > now);
4518            g.keys().copied().collect()
4519        };
4520        let map = self
4521            .inner
4522            .access_times
4523            .iter()
4524            .filter_map(|entry| {
4525                let (h, t) = (entry.key(), entry.value());
4526                // `held` short-circuits BEFORE the deny carve-out, so a probe-held
4527                // hash stays excluded even when denied: a probe-hold is transient
4528                // (seconds, self-expiring) and the takedown `evict()` is the
4529                // reclaim actuator for a denied hash regardless, so the space path
4530                // need not race the hold. The carve-out targets the *pin* (the
4531                // hold-forever case), which is the actual "worst combination".
4532                let deny_listed = denied.contains(h) || chain_denied.contains(h);
4533                if held.contains(h) || (pinned.contains(h) && !deny_listed) {
4534                    None
4535                } else {
4536                    Some((*h, *t))
4537                }
4538            })
4539            .collect();
4540        EvictionCandidates(map)
4541    }
4542
4543    /// Emit the hit signal for `hash`: bump its LRU recency AND forward one
4544    /// sighting to the shared frequency estimator (ADR 040 §Hit signal). This is
4545    /// the one hit sighting a served or `get` request produces.
4546    ///
4547    /// Every client-facing serve chokepoint calls this exactly once per served
4548    /// request — [`crate::CacheEngine::get`] on its own path, and the `node`
4549    /// crate's `deliver` / `serve_leg` on the paid serve paths. The fill paths
4550    /// ([`Self::populate`], [`Self::admit_bao_stream`]) deliberately do NOT emit
4551    /// it; they only record recency, so a miss that fills and then serves counts
4552    /// as ONE sighting (the serve's), never two. This also
4553    /// preserves the admission ordering invariant: a fill reads the frequency
4554    /// estimate for its admission decision before the paired serve emits this
4555    /// request's own `observe`.
4556    pub fn observe_hit(&self, hash: Hash) {
4557        self.record_access(hash);
4558        // Clone the estimator Arc out only when one is installed, dropping the
4559        // arc-swap guard before calling `observe`. The default (no-estimator)
4560        // path pays nothing — no clone, no refcount roundtrip — and the
4561        // estimator's own work never runs under the read guard.
4562        let est = (**self.inner.frequency.load()).clone();
4563        if let Some(est) = est {
4564            est.observe(hash);
4565        }
4566    }
4567
4568    /// Mark `hash` as in use now — recency only, no frequency observe. A serve
4569    /// calls this when it starts, so a blob carrying the open-time recency seed
4570    /// does not rank oldest, and first in line for eviction, while its bytes are
4571    /// on the wire. The serve's one frequency sighting still comes from
4572    /// [`Self::observe_hit`] at clean completion.
4573    pub fn touch_recency(&self, hash: Hash) {
4574        self.record_access(hash);
4575    }
4576
4577    /// Record an access for `hash` at the current instant — LRU recency only, no
4578    /// frequency observe. The fill paths use this so the blob becomes an eviction
4579    /// candidate without counting as a hit sighting; the paired serve emits the
4580    /// one sighting through [`Self::observe_hit`].
4581    fn record_access(&self, hash: Hash) {
4582        self.inner.access_times.insert(hash, Instant::now());
4583    }
4584
4585    /// Snapshot every on-disk blob keyed by hash with its byte size
4586    /// (`Complete` and `Partial` alike; a partial counts only its present
4587    /// bytes), the public form of the internal
4588    /// `snapshot_blob_sizes` helper. This is the authoritative disk-usage
4589    /// input for the capacity-eviction driver (#1173): unlike
4590    /// [`Self::eviction_candidates`] — which drops pinned and probe-held hashes
4591    /// and carries no sizes — this walks the store, so it counts every on-disk
4592    /// byte, including bytes released but not yet reclaimed by GC.
4593    ///
4594    /// Cost scales with the total blob set: one `status()` per blob, plus one
4595    /// `observe()` per partial blob whose memoized count is older than 30 s
4596    /// (each `observe()` of an idle partial fsyncs its store files). A
4597    /// partial's count can therefore trail its disk use by up to 30 s. Call it
4598    /// on the eviction sweep cadence, not per request.
4599    pub async fn size_snapshot(&self) -> CacheResult<HashMap<Hash, u64>> {
4600        snapshot_blob_sizes(
4601            &self.inner.store,
4602            &self.inner.partial_sizes,
4603            Instant::now(),
4604            self.inner.metrics.as_deref(),
4605        )
4606        .await
4607    }
4608
4609    /// Total on-disk bytes across all blobs, the sum of
4610    /// [`Self::size_snapshot`]. Saturating so an implausibly large store can
4611    /// never wrap. Drives the eviction driver's high-water comparison and the
4612    /// current-size cache-health reporting (#1173).
4613    pub async fn total_bytes(&self) -> CacheResult<u64> {
4614        Ok(self
4615            .size_snapshot()
4616            .await?
4617            .values()
4618            .fold(0u64, |acc, sz| acc.saturating_add(*sz)))
4619    }
4620
4621    /// Release `hash` for capacity eviction: drop its protecting named tag(s)
4622    /// so the bytes become eligible for the next iroh-blobs GC sweep, and
4623    /// forget its [`Self::access_times_snapshot`] entry so a later LRU sweep
4624    /// doesn't re-surface it. Returns the number of tags dropped.
4625    ///
4626    /// This is the LRU-driver counterpart to [`Self::evict`] and is
4627    /// deliberately *not* the same path (#1173). `evict` is the permanent,
4628    /// `fsync`'d, `evicted.log`-backed DMCA takedown: it records the hash in a
4629    /// durable logical-evicted set that blocks serving forever and survives
4630    /// restart. Capacity eviction must not do that — a blob dropped only for
4631    /// space pressure has to be freely re-pullable, and an unbounded takedown
4632    /// log per LRU eviction would be a durability leak. So this method only
4633    /// releases GC protection; the blob keeps serving until GC actually
4634    /// reclaims it, after which [`Self::has`] reports it absent naturally
4635    /// (the bytes are gone from the store, not logically masked).
4636    ///
4637    /// Pinned hashes are refused (returns `Ok(0)` without touching state) as a
4638    /// defense in depth — [`Self::eviction_candidates`] already excludes them,
4639    /// but the caller is a background loop and the pinned set can change
4640    /// between candidate selection and this call.
4641    ///
4642    /// The pin exemption is itself carved out for a **deny-listed** hash
4643    /// ([`Self::refuses`]): "deny wins over pin" on every path, so a pinned +
4644    /// governance/local-denied hash is still reclaimable here rather than being
4645    /// held on disk (unservable) until the takedown `evict()` runs. This only
4646    /// drops GC protection — the durable takedown record stays `evict`'s job.
4647    ///
4648    /// Best-effort against the GC cadence: like `evict`, actual disk reclaim
4649    /// only happens when periodic GC is enabled (`cache.gc_interval_sec > 0`).
4650    pub async fn release_for_eviction(&self, hash: Hash) -> CacheResult<u64> {
4651        if self.is_pinned(hash) && !self.refuses(hash) {
4652            return Ok(0);
4653        }
4654        // Drop the protecting tag(s) FIRST, and only forget the access-time
4655        // entry once that succeeded. The reverse order looks tempting (it would
4656        // stop a concurrent `eviction_candidates` re-picking the hash during the
4657        // slower tag walk) but it loses the blob on failure: `eviction_candidates`
4658        // iterates `access_times`, so a hash removed from it before an erroring
4659        // tag walk can never be re-selected, leaving bytes on disk *and*
4660        // GC-protected forever. A transient double-selection is harmless by
4661        // comparison — the second release is a no-op `Ok(0)`.
4662        let deleted = match self.drop_named_tags_for(hash).await {
4663            Ok(deleted) => deleted,
4664            Err(err) => {
4665                // Same observability as `evict`'s tag-drop failure: without this
4666                // the LRU path could burn down the candidate pool silently.
4667                if let Some(m) = &self.inner.metrics {
4668                    m.tag_drop_failures.inc();
4669                }
4670                return Err(err);
4671            }
4672        };
4673        self.inner.access_times.remove(&hash);
4674        self.inner
4675            .segments
4676            .lock()
4677            .unwrap_or_else(PoisonError::into_inner)
4678            .remove(&hash);
4679        Ok(deleted)
4680    }
4681
4682    async fn read_local(&self, hash: Hash) -> CacheResult<Bytes> {
4683        self.inner
4684            .store
4685            .blobs()
4686            .get_bytes(hash)
4687            .await
4688            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))
4689    }
4690
4691    /// Read `[byte_offset, byte_offset + byte_len)` of `hash` from the local
4692    /// store ([ADR 038 §Serve side](../../../adr/038-bao-verified-range-streaming.md)).
4693    /// `byte_len == 0` reads to the blob end. Works against a **partial** blob
4694    /// admitted by [`Self::admit_bao`] — only the bytes covered by an
4695    /// admitted (and thus already-verified) range are readable; asking for
4696    /// bytes outside the imported span surfaces a [`CacheError::Store`] from
4697    /// iroh-blobs rather than zero-filling.
4698    ///
4699    /// Concatenates the exported range into a contiguous [`Bytes`]; for the
4700    /// streaming serve path the node drives iroh-blobs' `export_ranges`
4701    /// directly. Provided here so the engine owns the local-read seam for both
4702    /// whole-blob ([`Self::get`]) and range serves.
4703    ///
4704    /// # Errors
4705    ///
4706    /// [`CacheError::Store`] if the store cannot satisfy the range (blob
4707    /// absent, the requested bytes were never imported / verified, or a
4708    /// read-to-end (`byte_len == 0`) was requested against a partial blob whose
4709    /// size the store cannot yet report).
4710    pub async fn export_range(
4711        &self,
4712        hash: Hash,
4713        byte_offset: u64,
4714        byte_len: u64,
4715    ) -> CacheResult<Bytes> {
4716        // `byte_len == 0` means "to end"; iroh-blobs `export_ranges` takes a
4717        // half-open `Range<u64>`, so resolve the end against the store's known
4718        // size. A `RangeFull`-style read isn't directly expressible, so query
4719        // the size and build the explicit bound.
4720        let end = if byte_len == 0 {
4721            match self
4722                .inner
4723                .store
4724                .blobs()
4725                .status(hash)
4726                .await
4727                .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?
4728            {
4729                iroh_blobs::api::blobs::BlobStatus::NotFound => {
4730                    return Err(CacheError::Store(anyhow::anyhow!(
4731                        "export_range: blob {hash} not present"
4732                    )));
4733                }
4734                // A partial blob whose size the store cannot yet report is an
4735                // explicit failure: silently treating `None` as "ends at
4736                // `byte_offset`" would return an empty range for a `byte_len ==
4737                // 0` ("to end") read and mask the real cause. Surface it so the
4738                // caller retries or falls back to a whole-blob serve.
4739                iroh_blobs::api::blobs::BlobStatus::Partial { size: None } => {
4740                    return Err(CacheError::Store(anyhow::anyhow!(
4741                        "export_range: store cannot report size for partial blob {hash}; \
4742                         cannot resolve a read-to-end bound"
4743                    )));
4744                }
4745                iroh_blobs::api::blobs::BlobStatus::Partial { size: Some(size) }
4746                | iroh_blobs::api::blobs::BlobStatus::Complete { size } => size,
4747            }
4748        } else {
4749            byte_offset.saturating_add(byte_len)
4750        };
4751        let bytes = self
4752            .inner
4753            .store
4754            .blobs()
4755            .export_ranges(hash, byte_offset..end)
4756            .concatenate()
4757            .await
4758            .map_err(|e| CacheError::Store(anyhow::Error::from(e)))?;
4759        Ok(Bytes::from(bytes))
4760    }
4761
4762    /// Whole-blob-in-memory form of [`Self::export_bao_range_stream`]: drains the
4763    /// stream into one contiguous `Bytes`. See that method for the wire format and
4764    /// the range semantics.
4765    ///
4766    /// **Peak memory is the whole aligned range.** The paid serve path must NOT
4767    /// use this — it drives the stream directly, so a large blob costs one frame
4768    /// of RAM rather than a copy of the blob (#1132). Every caller today is a test
4769    /// that needs a buffer to assert against; it is kept for them, and because the
4770    /// ADR 038 round-trip tests are more legible over a buffer than a stream.
4771    ///
4772    /// # Errors
4773    ///
4774    /// Everything [`Self::export_bao_range_stream`] can fail with, except that a
4775    /// fault discovered while exporting — including the truncation refusal —
4776    /// surfaces here as an `Err` return rather than as a terminal stream item,
4777    /// because nothing has been handed to a consumer yet.
4778    pub async fn export_bao_range(
4779        &self,
4780        hash: Hash,
4781        byte_offset: u64,
4782        byte_len: u64,
4783        blob_size: u64,
4784    ) -> CacheResult<Bytes> {
4785        let mut stream = self
4786            .export_bao_range_stream(hash, byte_offset, byte_len, blob_size)
4787            .await?;
4788        // Pre-size to the exact wire length (proof + data) so the buffer never
4789        // reallocates; `wire_len` walks the same node set the export stream emits.
4790        // The range re-validated cleanly inside the call above, so a re-alignment
4791        // fault here is unreachable — degrade to an unsized buffer rather than
4792        // duplicating the error mapping. `align_range_clamped` sizes the buffer
4793        // for an end past the blob too, rather than degrading to 0.
4794        let cap = align_range_clamped(byte_offset, byte_len, blob_size)
4795            .map_or(0, |a| usize::try_from(a.wire_len()).unwrap_or(0));
4796        let mut out = Vec::with_capacity(cap);
4797        while let Some(item) = stream.next().await {
4798            out.extend_from_slice(&item?);
4799        }
4800        Ok(Bytes::from(out))
4801    }
4802
4803    /// Export `[byte_offset, byte_offset + byte_len)` of `hash` as the
4804    /// **header-less bao interleaved verified-stream encoding** that travels on
4805    /// `cdn/client/v1` ([ADR 038 §Serve side](../../../adr/038-bao-verified-range-streaming.md)),
4806    /// yielded incrementally as the store produces it. `byte_len == 0` exports to
4807    /// the blob end. This is the *only* client-facing delivery path — there is no
4808    /// raw-byte fallback (ADR 038 AC#4) — so a whole-blob serve passes
4809    /// `byte_offset == 0, byte_len == 0`.
4810    ///
4811    /// The bytes are proof nodes (64 B each) interleaved with chunk-group data in
4812    /// tree order, **without** the 8-byte size header: the signed
4813    /// `StreamResponse.body.total_bytes` is the authoritative size, so the
4814    /// receiver builds its own `BaoTree` and never reads a header off the wire
4815    /// (avoids a second, unauthenticated size source — ADR 038 §Wire format).
4816    ///
4817    /// The range widens to enclosing 16 KiB chunk-group boundaries
4818    /// ([`align_range_clamped`]) because a bao proof anchors whole groups; an
4819    /// end past the blob clamps to it rather than being refused: a claimed
4820    /// size is a hint. The serve side does **not** trim back to the requested offset
4821    /// (trimming would break verification). The receiver discards the
4822    /// group-aligned prefix. The outboard is read from the store (built at
4823    /// import).
4824    /// Works against a **partial** blob (only imported/verified chunk groups are
4825    /// exportable, exactly like [`Self::export_range`]).
4826    ///
4827    /// Each item is one export item's serialization — a 64-byte proof pair or one
4828    /// chunk group's data — so a consumer that writes items straight to the wire
4829    /// holds O(chunk group) rather than O(blob). This is what the paid serve path
4830    /// drives (#1132), so a 708 MB blob costs O(chunk group) resident per
4831    /// concurrent serve, not ~708 MB.
4832    ///
4833    /// # Held content is validated as it is exported
4834    ///
4835    /// The store validates each exported item against the content root before
4836    /// it yields the item: every parent pair against the hash its parent
4837    /// expects, and every leaf's data against its parent's hash. Bytes or an
4838    /// outboard that diverged on disk after admission therefore never reach
4839    /// the stream. The export ends at the first mismatching chunk group with a
4840    /// terminal `Err` item, and the engine
4841    /// [quarantines](Self::is_quarantined) the hash.
4842    ///
4843    /// # Truncation is reported mid-stream
4844    ///
4845    /// The `!done` refusal below (the store's item channel closing without a
4846    /// terminal `Done`/`Error` — an actor crash or shutdown race) can only be
4847    /// detected at the END of the stream, by which point a streaming consumer has
4848    /// already put earlier bytes on the wire. It therefore surfaces as a terminal
4849    /// `Err` **item** rather than as a pre-flight error, and the consumer must
4850    /// abort the delivery on it. The billing invariant is unchanged: the client
4851    /// sees a short delivery, rejects it, and never pays the closing voucher — but
4852    /// the detection point is after the last byte, not before the first
4853    /// (#915 review, #1132).
4854    ///
4855    /// # Errors
4856    ///
4857    /// [`CacheError::Store`] if `byte_offset` is at or past the blob end, or
4858    /// (for the 0-byte case) the blob is absent. An end past the blob clamps.
4859    /// Faults discovered while exporting, the truncation refusal included,
4860    /// arrive as `Err` items in the stream.
4861    pub async fn export_bao_range_stream(
4862        &self,
4863        hash: Hash,
4864        byte_offset: u64,
4865        byte_len: u64,
4866        blob_size: u64,
4867    ) -> CacheResult<Pin<Box<dyn futures_util::Stream<Item = CacheResult<Bytes>> + Send>>> {
4868        // `blob_size` is the authoritative whole-blob size supplied by the caller
4869        // (the signed `total_bytes`), NOT resolved from the store. An origin-tier
4870        // range pull (#823) imports a *partial* blob whose `status()` size is
4871        // `None`, so re-deriving the size here would fail the very ranged serve the
4872        // partial import enabled. The BaoTree geometry (and hence the proof) is a
4873        // function of the whole-blob size, so the partial and the caller agree by
4874        // construction (#915, ADR 038).
4875
4876        // Snap to chunk-group boundaries. Only a start at or past the blob end
4877        // is rejected; an end past the blob clamps to it instead.
4878        // `align_range_clamped` owns the bound check, the same one the
4879        // dispatch-tier gate applies before this runs.
4880        let aligned = align_range_clamped(byte_offset, byte_len, blob_size).map_err(|e| {
4881            CacheError::Store(anyhow::Error::from(e).context("export_bao_range: range alignment"))
4882        })?;
4883
4884        // A 0-byte blob (#1054) has no chunk groups and no proof: the header-less
4885        // wire form is empty. Return an empty stream directly rather than driving an
4886        // empty `export_bao` stream, whose terminal `Done` we would otherwise depend
4887        // on to clear the `!done` guard. Still confirm presence first: the documented
4888        // contract errors on an absent blob, the non-empty path below faults on
4889        // `export_bao` for a missing hash, and `has` honors a logical eviction
4890        // (#279) — so a present-only early return keeps behavior consistent and
4891        // never serves an empty body for a hash this node has taken down.
4892        //
4893        // An empty stream (rather than a single empty item) is what keeps the serve
4894        // path's "the empty blob goes straight to `StreamEnd`" property: `ChunkData`
4895        // cannot hold an empty payload (#1088), so a zero-length item would be
4896        // unsendable.
4897        if blob_size == 0 {
4898            if !self.has(hash).await? {
4899                return Err(CacheError::Store(anyhow::anyhow!(
4900                    "export_bao_range: blob {hash} not present"
4901                )));
4902            }
4903            return Ok(Box::pin(futures_util::stream::empty()));
4904        }
4905
4906        let stream = self
4907            .inner
4908            .store
4909            .blobs()
4910            .export_bao(hash, aligned.chunk_ranges().clone())
4911            .stream();
4912        // Serialize the header-less wire form: Parent → 64 bytes (left‖right
4913        // hashes), Leaf → its data; skip the `Size` item (the header) and stop on
4914        // `Done`. Mirrors iroh-blobs' `ExportBaoProgress::write` minus the header.
4915        //
4916        // The `bool` in the unfold state is "this stream is finished" — set after
4917        // yielding a terminal error so the consumer cannot poll past it into a
4918        // second, spurious truncation error.
4919        let engine = self.clone();
4920        let requested = aligned.chunk_ranges().clone();
4921        Ok(Box::pin(futures_util::stream::unfold(
4922            (stream, false),
4923            move |(mut stream, finished)| {
4924                let engine = engine.clone();
4925                let requested = requested.clone();
4926                async move {
4927                    if finished {
4928                        return None;
4929                    }
4930                    loop {
4931                        match stream.next().await {
4932                            Some(EncodedItem::Size(_)) => {}
4933                            Some(EncodedItem::Parent(parent)) => {
4934                                let mut frame = BytesMut::with_capacity(64);
4935                                frame.extend_from_slice(parent.pair.0.as_bytes());
4936                                frame.extend_from_slice(parent.pair.1.as_bytes());
4937                                return Some((Ok(frame.freeze()), (stream, false)));
4938                            }
4939                            Some(EncodedItem::Leaf(leaf)) => {
4940                                return Some((Ok(leaf.data), (stream, false)));
4941                            }
4942                            Some(EncodedItem::Done) => return None,
4943                            Some(EncodedItem::Error(cause)) => {
4944                                engine
4945                                    .quarantine_on_mismatch(hash, &cause, &requested)
4946                                    .await;
4947                                let err = CacheError::Store(
4948                                    anyhow::Error::from(cause).context("export_bao stream failed"),
4949                                );
4950                                return Some((Err(err), (stream, true)));
4951                            }
4952                            // The store's item channel closing without a terminal
4953                            // `Done`/`Error` (actor crash / shutdown race) would
4954                            // otherwise yield a silently TRUNCATED wire that the serve
4955                            // path bills the client for and the client rejects as a
4956                            // short delivery — with no server-side signal. Refuse
4957                            // instead (#915 review).
4958                            None => {
4959                                let err = CacheError::Store(anyhow::anyhow!(
4960                                    "export_bao stream for {hash} ended without Done; \
4961                                 refusing truncated export"
4962                                ));
4963                                return Some((Err(err), (stream, true)));
4964                            }
4965                        }
4966                    }
4967                }
4968            },
4969        )))
4970    }
4971
4972    /// Collect the outboard `(node, (left, right))` hash pairs iroh-blobs emits for
4973    /// `chunk_ranges` of `hash`, read from the store's outboard.
4974    ///
4975    /// The store validates each pair and each leaf of the range against the content
4976    /// root as it exports them, like [`Self::export_bao_range_stream`]. A stored
4977    /// hash mismatch returns `Err` and [quarantines](Self::is_quarantined) the
4978    /// hash.
4979    ///
4980    /// The serve leg's shared outboard (ADR 038) is fed from these so it can drive
4981    /// a coherent whole-range bao encode while the pull fills the cache
4982    /// incrementally. Call it for each range as it is admitted (and for the held
4983    /// part of the serve's range at serve start). It reads and verifies every leaf
4984    /// of `chunk_ranges`, so its cost grows with the held bytes it is given.
4985    /// `export_bao` emits every proof `Parent` on the path to the range PLUS the
4986    /// right-siblings covering
4987    /// still-absent content, so the union over a front-to-back admit sequence is the
4988    /// whole tree's internal nodes. Leaf data and the size header are skipped.
4989    ///
4990    /// `chunk_ranges` must be PRESENT (a just-admitted or held range): the proof
4991    /// nodes for absent siblings are emitted from the outboard regardless, but a
4992    /// range whose own leaves are absent faults `export_bao`. On a partial blob
4993    /// that fault can be a hash mismatch, because an absent range reads back as
4994    /// zeros. The quarantine ignores a mismatch outside the present ranges, so
4995    /// a precondition breach returns `Err` without withdrawing the hash.
4996    pub async fn outboard_pairs(
4997        &self,
4998        hash: Hash,
4999        chunk_ranges: &bao_tree::ChunkRanges,
5000    ) -> CacheResult<
5001        Vec<(
5002            bao_tree::TreeNode,
5003            (bao_tree::blake3::Hash, bao_tree::blake3::Hash),
5004        )>,
5005    > {
5006        let mut stream = self
5007            .inner
5008            .store
5009            .blobs()
5010            .export_bao(hash, chunk_ranges.clone())
5011            .stream();
5012        let mut out = Vec::new();
5013        while let Some(item) = stream.next().await {
5014            match item {
5015                EncodedItem::Parent(parent) => out.push((parent.node, parent.pair)),
5016                EncodedItem::Leaf(_) | EncodedItem::Size(_) => {}
5017                EncodedItem::Done => break,
5018                EncodedItem::Error(cause) => {
5019                    self.quarantine_on_mismatch(hash, &cause, chunk_ranges)
5020                        .await;
5021                    return Err(CacheError::Store(
5022                        anyhow::Error::from(cause)
5023                            .context("outboard_pairs: export_bao stream failed"),
5024                    ));
5025                }
5026            }
5027        }
5028        Ok(out)
5029    }
5030
5031    /// Whether the origin chain has any origin `pull_through` would actually try
5032    /// for the given mode: any origin at all normally, or any non-`Peer` origin
5033    /// under `local_only` (#1116).
5034    fn has_eligible_origin(&self, local_only: bool) -> bool {
5035        self.inner
5036            .origins
5037            .iter()
5038            .any(|o| !local_only || o.kind() != OriginKind::Peer)
5039    }
5040
5041    /// [`Self::pull_through`] for a caller that needs the bytes ([`Self::get`]).
5042    ///
5043    /// Exists so no caller has to name a [`FillMode`] or unwrap the `Option`: the
5044    /// mode↔shape correspondence is resolved here, once. A `None` under
5045    /// [`FillMode::ReturnBytes`] is a logic regression, not a runtime condition —
5046    /// surface it as a `Store` fault, since the anti-panic policy rules out an
5047    /// `expect` and a silent empty `Bytes` would be worse (the caller would serve
5048    /// a zero-length blob).
5049    async fn pull_through_bytes(&self, hash: Hash) -> CacheResult<Bytes> {
5050        match self
5051            .pull_through(hash, false, FillMode::ReturnBytes)
5052            .await?
5053        {
5054            Some(bytes) => Ok(bytes),
5055            None => Err(CacheError::Store(anyhow::anyhow!(
5056                "pull_through returned no bytes for {hash} under FillMode::ReturnBytes"
5057            ))),
5058        }
5059    }
5060
5061    /// [`Self::pull_through`] for a caller that only wants the blob present
5062    /// ([`Self::populate`] / [`Self::populate_local`]).
5063    ///
5064    /// The `()` return is the point: under [`FillMode::CommitOnly`] there is no
5065    /// payload to hand back, and a caller that cannot receive one cannot
5066    /// accidentally keep a whole blob alive (#1132).
5067    async fn pull_through_fill(&self, hash: Hash, local_only: bool) -> CacheResult<()> {
5068        self.pull_through(hash, local_only, FillMode::CommitOnly)
5069            .await
5070            .map(drop)
5071    }
5072
5073    /// Walk the origin chain to fill `hash`.
5074    ///
5075    /// The mode↔return-shape correspondence is exact in both directions:
5076    /// [`FillMode::ReturnBytes`] always yields `Some` on success and
5077    /// [`FillMode::CommitOnly`] always yields `None`. Prefer the
5078    /// [`Self::pull_through_bytes`] / [`Self::pull_through_fill`] wrappers, which
5079    /// are total and hide the `Option` entirely.
5080    ///
5081    /// Runs inside an `origin_pull` span: one per chain walk, with the paid
5082    /// node→node fallback's `node_pull` span nested inside when the walk reaches
5083    /// the `Peer` origin.
5084    #[allow(clippy::too_many_lines)]
5085    // One linear chain walk; each outcome arm carries the rationale for its own fallback/return decision, and splitting the match out would separate those from the loop state (`last_err`, `any_not_found`, `any_short_circuit`) they exist to explain.
5086    #[tracing::instrument(
5087        name = "origin_pull",
5088        skip_all,
5089        fields(
5090            %hash,
5091            local_only = local_only,
5092            outcome = tracing::field::Empty,
5093            error = tracing::field::Empty,
5094            otel.status_code = tracing::field::Empty,
5095        )
5096    )]
5097    async fn pull_through(
5098        &self,
5099        hash: Hash,
5100        local_only: bool,
5101        mode: FillMode,
5102    ) -> CacheResult<Option<Bytes>> {
5103        let result: CacheResult<Option<Bytes>> = async {
5104            // Every pull_through entry is a `get()` cache miss, regardless
5105            // of how the pull resolves. Coalesced waiters that find a hit
5106            // on retry never call `pull_through`, so they never reach this
5107            // bump (their `hits` increment lives in the waiter branch of
5108            // `get`).
5109            if let Some(m) = &self.inner.metrics {
5110                m.misses.inc();
5111            }
5112            // Reject pulls with no *eligible* origin early. With `local_only`
5113            // (#1116) the `Peer` origin (the paid node→node fallback) is skipped, so
5114            // a chain of nothing but `Peer` origins is a fast `NoOrigin`, like an
5115            // empty chain — checked before the `origin_fetches` bump so that metric
5116            // still counts only real local fetch attempts.
5117            if !self.has_eligible_origin(local_only) {
5118                return Err(CacheError::NoOrigin { hash });
5119            }
5120
5121            // Bump the per-fetch denominator once per `pull_through` —
5122            // preserving #285 semantics where the counter is per
5123            // cache-miss-call, not per origin attempted. Alerts that
5124            // page on `origin_retry_exhausted_total / origin_fetches_total`
5125            // continue to be a per-call ratio; the new
5126            // `origin_fallback_total` separately counts chain-walk steps.
5127            if let Some(m) = &self.inner.metrics {
5128                m.origin_fetches.inc();
5129            }
5130            let max_blob_bytes = self.inner.max_blob_bytes;
5131            let policy = self.inner.retry_policy;
5132
5133            // Fallback chain walk (#284). Each origin gets its own retry
5134            // budget; on retry-exhaustion / Permanent / NotFound we advance
5135            // to the next entry. Deterministic per-origin failures
5136            // (`HashMismatch`, `BlobTooLarge`, `Store`) intentionally do
5137            // not fall back — they indicate a misbehaving backend that
5138            // must surface, not be masked by trying a different one.
5139            let mut last_err: Option<OriginPullError> = None;
5140            let mut any_not_found = false;
5141            let mut any_short_circuit = false;
5142            let total = self.inner.origins.len();
5143            for (idx, origin) in self.inner.origins.iter().enumerate() {
5144                // #1116: a `local_only` populate never touches the `Peer` origin
5145                // (the paid node→node fallback), so an operator serving its OWN
5146                // configured fs/http/s3 origin fronts no upstream USDC. Skipped
5147                // before the breaker/retry machinery so it costs nothing.
5148                if local_only && origin.kind() == OriginKind::Peer {
5149                    continue;
5150                }
5151                let origin = Arc::clone(origin);
5152                // Per-origin circuit-breaker (#963). An OPEN breaker
5153                // short-circuits this origin *before* the retry/backoff
5154                // loop runs, so a sustained outage on this backend costs no
5155                // backoff — the chain just advances to the next origin (or,
5156                // if every origin is OPEN, surfaces a fast `OriginError`).
5157                // `breakers` is parallel to `origins` by index; `get` keeps
5158                // the access non-panicking per the workspace anti-panic
5159                // policy (a missing slot would be a construction bug, in
5160                // which case we degrade to "no breaker" rather than panic).
5161                let breaker = self.inner.breakers.get(idx);
5162                // Admit (or short-circuit) under the breaker. The `Proceed`
5163                // arm carries a `TrialGuard` that owns any HALF-OPEN trial
5164                // slot; it MUST stay alive across the `.await` below so that
5165                // a cancelled future (client disconnect / timeout) drops it
5166                // and reclaims the slot rather than leaking it (#963). A
5167                // `None` breaker (construction degraded to "no breaker")
5168                // admits unconditionally with no guard.
5169                let trial_guard = match breaker.map(OriginBreaker::acquire) {
5170                    Some(Admission::ShortCircuit) => {
5171                        any_short_circuit = true;
5172                        self.emit_breaker_short_circuit_advance(hash, idx, total, origin.kind());
5173                        continue;
5174                    }
5175                    Some(Admission::Proceed(_state, guard)) => Some(guard),
5176                    None => None,
5177                };
5178
5179                let (outcome, terminal) =
5180                    run_with_retry_classified(policy, self.inner.metrics.as_ref(), hash, || {
5181                        self.pull_through_attempt(
5182                            Arc::clone(&origin),
5183                            hash,
5184                            max_blob_bytes,
5185                            policy,
5186                            mode,
5187                        )
5188                    })
5189                    .await;
5190                // Commit the breaker outcome through the guard (defusing its
5191                // cancellation-release path). A `None` guard is the degraded
5192                // "no breaker" case and records nothing.
5193                Self::record_breaker_outcome(trial_guard, terminal);
5194
5195                // Track *this iteration's* outcome class so the post-match
5196                // log records the correct cause. `last_err.is_some()` is
5197                // cumulative across iterations and would mislabel a later
5198                // NotFound advance as "primary failed" once any earlier
5199                // origin had errored.
5200                let advance_was_error = match outcome {
5201                    Ok(PullThroughOutcome::Bytes(bytes)) => {
5202                        // Announce the successful commit to any DHT
5203                        // republish-scheduler subscribers (ADR 022 §STORE
5204                        // Flow). `broadcast::send` returns `Err(SendError)`
5205                        // only when there are no active subscribers, which
5206                        // is the normal state when no DHT republish task
5207                        // exists — ignore. A local-store hit in `get` does
5208                        // not emit: a hit changes nothing the republisher
5209                        // advertises; commits and completed blocks do.
5210                        let _ = self.inner.inserts_tx.send(hash);
5211                        return Ok(Some(bytes));
5212                    }
5213                    // Same successful commit, minus the read-back the caller did not
5214                    // want (#1132) — so it must broadcast the insert identically.
5215                    Ok(PullThroughOutcome::Committed) => {
5216                        let _ = self.inner.inserts_tx.send(hash);
5217                        return Ok(None);
5218                    }
5219                    Ok(PullThroughOutcome::NotFound) => {
5220                        any_not_found = true;
5221                        false
5222                    }
5223                    Ok(PullThroughOutcome::BlobTooLarge) => {
5224                        return Err(CacheError::BlobTooLarge {
5225                            hash,
5226                            limit_bytes: max_blob_bytes,
5227                        });
5228                    }
5229                    Ok(PullThroughOutcome::HashMismatch { actual }) => {
5230                        return Err(CacheError::HashMismatch {
5231                            expected: hash,
5232                            actual,
5233                        });
5234                    }
5235                    Ok(PullThroughOutcome::Store(err)) => return Err(CacheError::Store(err)),
5236                    Err(e) => {
5237                        last_err = Some(e);
5238                        true
5239                    }
5240                };
5241
5242                // Final entry already tried; don't log a "fallback" for a
5243                // chain that has nowhere left to advance.
5244                if idx + 1 < total {
5245                    self.emit_chain_advance(
5246                        hash,
5247                        idx,
5248                        origin.kind(),
5249                        advance_was_error,
5250                        last_err.as_ref(),
5251                    );
5252                }
5253            }
5254
5255            // All origins exhausted. Any non-NotFound failure beats a pure
5256            // NotFound because "known backend errored" is more diagnostic
5257            // than "no backend had it" — operators triaging a 5xx benefit
5258            // from the underlying error, and a real NotFound only fires
5259            // when every origin agreed the blob is absent.
5260            if let Some(e) = last_err {
5261                Err(CacheError::OriginError {
5262                    hash,
5263                    source: e.into_inner(),
5264                })
5265            } else if any_short_circuit {
5266                // Every origin that wasn't a definitive NotFound was
5267                // short-circuited by an OPEN breaker (#963). There is no
5268                // `last_err` to surface (we never ran the retry loop for
5269                // those origins), but returning `NotFound` would be wrong —
5270                // the blob may well exist; we just refused to pull it while
5271                // the origin is shedding load. Surface a fast `OriginError`
5272                // so the caller sees "origin unavailable" rather than a
5273                // spurious 404, *without* having incurred any backoff.
5274                Err(CacheError::OriginError {
5275                    hash,
5276                    source: anyhow::anyhow!(
5277                        "origin circuit-breaker open: all eligible origins are \
5278                         fast-failing during a sustained outage (#963)"
5279                    ),
5280                })
5281            } else if any_not_found {
5282                Err(CacheError::NotFound { hash })
5283            } else {
5284                // Structurally unreachable: every iteration of the loop
5285                // above takes exactly one match arm. The five non-`Err`
5286                // arms all `return`; the `NotFound` arm sets
5287                // `any_not_found`; the breaker short-circuit sets
5288                // `any_short_circuit`; the `Err` arm sets `last_err`. To
5289                // reach this branch the chain must be non-empty (`is_empty()`
5290                // check at the top of `pull_through`) and have produced
5291                // no `last_err`, no `any_not_found`, and no
5292                // `any_short_circuit` — impossible under the current
5293                // `PullThroughOutcome` taxonomy. Reaching it
5294                // would mean a future variant was added without wiring
5295                // the corresponding flag, and a debug-only assert would
5296                // compile out in release builds. Emit an operator-visible
5297                // log and fall through to a `NotFound` surface so the
5298                // observable behaviour stays bounded (`unreachable!()`
5299                // would also be correct but the workspace policy prefers
5300                // a logged fallback over a release-panic in a hot path).
5301                tracing::error!(
5302                    hash = %hash,
5303                    "internal invariant violated: non-empty origin chain produced \
5304                     neither a NotFound nor an Err — likely a missing flag on a new \
5305                     PullThroughOutcome variant",
5306                );
5307                Err(CacheError::NotFound { hash })
5308            }
5309        }
5310        .await;
5311        record_origin_pull(&tracing::Span::current(), &result);
5312        result
5313    }
5314
5315    /// Bump the `origin_fallback` counter and emit a structured log for
5316    /// the chain-advance event (#284). Kept out of `pull_through`'s
5317    /// hot-path loop body so it stays small enough for clippy's
5318    /// cognitive-complexity lint, and so the WHY of the
5319    /// `warn!`-vs-`info!` branch decision lives in one named place
5320    /// rather than inline with retry control flow.
5321    ///
5322    /// `advance_was_error` is the *current iteration's* outcome class
5323    /// (not the cumulative `last_err.is_some()`), so a chain like
5324    /// `[Permanent, NotFound, Found]` logs `warn!` at the 0→1 step
5325    /// and `info!` at the 1→2 step rather than mislabeling the second
5326    /// step as "primary failed" by virtue of an earlier error still
5327    /// living in `last_err`. The caller is responsible for the
5328    /// `idx + 1 < total` guard that prevents a final-entry log; this
5329    /// method assumes a real advance is about to happen.
5330    fn emit_chain_advance(
5331        &self,
5332        hash: Hash,
5333        idx: usize,
5334        origin_kind: OriginKind,
5335        advance_was_error: bool,
5336        last_err: Option<&OriginPullError>,
5337    ) {
5338        if let Some(m) = &self.inner.metrics {
5339            m.origin_fallback.inc();
5340        }
5341        if advance_was_error {
5342            // Carry this origin's error in the log so operators
5343            // triaging the user-visible 5xx see *this* backend's
5344            // failure mode, not just the chain-final one surfaced via
5345            // `CacheError::OriginError`. `last_err` was assigned by
5346            // the `Err(e)` arm of this iteration and is `Some` here.
5347            tracing::warn!(
5348                hash = %hash,
5349                origin_index = idx,
5350                origin_kind = ?origin_kind,
5351                error = last_err.map(|e| format!("{e:#}")).unwrap_or_default(),
5352                "advancing to next origin in fallback chain (origin failed)",
5353            );
5354        } else {
5355            tracing::info!(
5356                hash = %hash,
5357                origin_index = idx,
5358                origin_kind = ?origin_kind,
5359                "advancing to next origin in fallback chain (NotFound)",
5360            );
5361        }
5362    }
5363
5364    /// Commit a per-origin breaker the health verdict for a completed
5365    /// attempt (#963) by recording it through the admission's
5366    /// [`crate::circuit_breaker::TrialGuard`].
5367    ///
5368    /// A transient that exhausted the retry budget is `Unavailable`
5369    /// (counts toward the trip threshold); everything else — bytes,
5370    /// `NotFound`, permanent per-object errors (404/4xx/decode/cap), and
5371    /// even a `Store`/`HashMismatch`/`BlobTooLarge` (the origin DID
5372    /// respond) — is `Available` and resets the failure count.
5373    ///
5374    /// Recording defuses the guard's cancellation-release path, so the
5375    /// HALF-OPEN trial slot it may own is resolved exactly once. A `None`
5376    /// guard is the degraded "no breaker" case (construction produced no
5377    /// breaker for this index) and records nothing.
5378    fn record_breaker_outcome(guard: Option<TrialGuard<'_>>, terminal: Option<TerminalFailure>) {
5379        let Some(guard) = guard else { return };
5380        let outcome = match terminal {
5381            Some(TerminalFailure::TransientExhausted) => OriginOutcome::Unavailable,
5382            Some(TerminalFailure::Permanent) | None => OriginOutcome::Available,
5383        };
5384        guard.record(outcome);
5385    }
5386
5387    /// Handle a per-origin circuit-breaker short-circuit (#963) that
5388    /// advances the fallback chain: bump `origin_fallback` and emit the
5389    /// operator-visible advance breadcrumb, both gated by `idx + 1 <
5390    /// total` so a short-circuit on the chain-final origin neither
5391    /// logs nor counts a non-existent advance.
5392    ///
5393    /// The `origin_fallback` bump matches the non-breaker advances in
5394    /// [`Self::emit_chain_advance`] — a short-circuit IS a real
5395    /// fallback-chain step, so omitting it would make
5396    /// `origin_fallback_total` undercount. The short-circuit *load-shed*
5397    /// counter itself is bumped separately inside
5398    /// [`crate::circuit_breaker::OriginBreaker::acquire`] (so it counts
5399    /// even on the chain-final origin, where no advance fires).
5400    fn emit_breaker_short_circuit_advance(
5401        &self,
5402        hash: Hash,
5403        idx: usize,
5404        total: usize,
5405        origin_kind: OriginKind,
5406    ) {
5407        if idx + 1 >= total {
5408            return;
5409        }
5410        if let Some(m) = &self.inner.metrics {
5411            m.origin_fallback.inc();
5412        }
5413        tracing::warn!(
5414            hash = %hash,
5415            origin_index = idx,
5416            origin_kind = ?origin_kind,
5417            "advancing to next origin in fallback chain (circuit-breaker open)",
5418        );
5419    }
5420
5421    /// A single end-to-end pull-through attempt: origin.fetch +
5422    /// (buffer-then-commit | stream-and-commit) + hash verify + tag
5423    /// promote. Body-phase errors classified as
5424    /// [`OriginPullError::Transient`] re-enter the retry loop; the
5425    /// non-[`OriginPullError`] outcomes (`Store` / `HashMismatch` /
5426    /// `BlobTooLarge`) ride out through [`PullThroughOutcome`] because
5427    /// they are deterministic and retry would not help.
5428    ///
5429    /// Why this method instead of inlining into `pull_through`: the
5430    /// retry loop ([`run_with_retry_classified`]) needs a callable that
5431    /// produces a fresh attempt on each invocation — the side-channel `Arc`s,
5432    /// `TempTag`s, and origin futures all have to be re-created per
5433    /// attempt and can't be reused across iterations.
5434    ///
5435    /// Under [`FillMode::CommitOnly`] (the `populate` fill path) this returns
5436    /// [`PullThroughOutcome::Committed`] rather than a payload — the streaming arm
5437    /// by skipping its `read_local`, the buffered arm by dropping its drain
5438    /// buffer. See that variant's docs (#1132).
5439    #[allow(clippy::too_many_lines)] // Linear per-attempt flow; the failure-classification arms each need their own context comment, and splitting them across functions would obscure the sequence more than the length.
5440    async fn pull_through_attempt(
5441        &self,
5442        origin: Arc<dyn Origin>,
5443        hash: Hash,
5444        max_blob_bytes: u64,
5445        policy: RetryPolicy,
5446        mode: FillMode,
5447    ) -> Result<PullThroughOutcome, OriginPullError> {
5448        // The origin handle is now plumbed in by the caller
5449        // (`pull_through`'s fallback-chain loop, #284) so this method
5450        // is agnostic to chain position and works identically for the
5451        // singular-origin (chain length 1) and multi-origin paths.
5452        let fetch = origin.fetch(hash, max_blob_bytes).await?;
5453        let (stream, size_hint) = match fetch {
5454            crate::origin::OriginFetch::NotFound => return Ok(PullThroughOutcome::NotFound),
5455            crate::origin::OriginFetch::AlreadyAdmitted => {
5456                // The origin admitted + verified the blob into the store itself
5457                // (node-to-node pull, #1682). Skip the drain / import_and_verify_stream
5458                // and reuse the same post-commit tail the streamed path runs.
5459                return match mode {
5460                    FillMode::CommitOnly => Ok(PullThroughOutcome::Committed),
5461                    FillMode::ReturnBytes => match self.read_local(hash).await {
5462                        Ok(bytes) => Ok(PullThroughOutcome::Bytes(bytes)),
5463                        Err(CacheError::Store(err)) => Ok(PullThroughOutcome::Store(err)),
5464                        Err(other) => Ok(PullThroughOutcome::Store(
5465                            anyhow::Error::from(other).context(
5466                                "read_local returned unexpected variant after AlreadyAdmitted",
5467                            ),
5468                        )),
5469                    },
5470                };
5471            }
5472            crate::origin::OriginFetch::Found { stream, size_hint } => (stream, size_hint),
5473        };
5474
5475        // Pre-stream cap: if the adapter advertised a length, reject
5476        // before reading the first byte. The post-stream cap below is
5477        // load-bearing too — origins can lie or omit the hint. This
5478        // is a deterministic operator-visible cap breach; surface as
5479        // `BlobTooLarge` directly without burning retry budget.
5480        if let Some(advertised) = size_hint
5481            && advertised > max_blob_bytes
5482        {
5483            return Ok(PullThroughOutcome::BlobTooLarge);
5484        }
5485
5486        if should_buffer(size_hint, policy.buffered_max_bytes) {
5487            let drain_cap = policy.buffered_max_bytes.min(max_blob_bytes);
5488            let bytes = match drain_to_bytes(stream, drain_cap, self.inner.metrics.as_ref()).await {
5489                Ok(b) => b,
5490                // Cap-breach: surface as `BlobTooLarge` directly (typed
5491                // outcome). Routing through `classify_io_error` would
5492                // collapse it to a generic `OriginError` and operators
5493                // alerting on the typed `BlobTooLarge` metric would
5494                // lose visibility.
5495                Err(e) if is_blob_too_large_marker(&e) => {
5496                    return Ok(PullThroughOutcome::BlobTooLarge);
5497                }
5498                Err(e) => return Err(classify_io_error(e)),
5499            };
5500            return self.commit_buffered_bytes(hash, bytes, mode).await;
5501        }
5502
5503        // Streaming path: drive the origin stream into the store, verify the
5504        // hash, and promote — shared verbatim with the node-driven tee sink
5505        // (#856) via `import_and_verify_stream`. On a committed blob the engine's
5506        // existing `get()` callers (admin RPC, metrics tests) want the full
5507        // payload as `Bytes`, so re-read it from the local store (one mmap'd read
5508        // with `fs-store`, no extra origin egress).
5509        match self
5510            .import_and_verify_stream(hash, stream, max_blob_bytes)
5511            .await?
5512        {
5513            // The fill-only caller (`populate` / `populate_local`) drops the bytes,
5514            // so reading them back would re-materialise the whole blob for nothing
5515            // — the exact allocation the streaming import above just avoided
5516            // (#1132). Skip it.
5517            StreamCommitOutcome::Committed if mode == FillMode::CommitOnly => {
5518                Ok(PullThroughOutcome::Committed)
5519            }
5520            StreamCommitOutcome::Committed => match self.read_local(hash).await {
5521                Ok(bytes) => Ok(PullThroughOutcome::Bytes(bytes)),
5522                Err(CacheError::Store(err)) => Ok(PullThroughOutcome::Store(err)),
5523                Err(other) => {
5524                    // `read_local` only surfaces `Store`; any other variant is a
5525                    // logic regression. Map to `Store` so the outer
5526                    // `pull_through` still surfaces a coherent error; the inner
5527                    // anyhow chain preserves the cause.
5528                    Ok(PullThroughOutcome::Store(
5529                        anyhow::Error::from(other).context(
5530                            "read_local returned unexpected variant after successful commit",
5531                        ),
5532                    ))
5533                }
5534            },
5535            StreamCommitOutcome::HashMismatch { actual } => {
5536                Ok(PullThroughOutcome::HashMismatch { actual })
5537            }
5538            StreamCommitOutcome::BlobTooLarge => Ok(PullThroughOutcome::BlobTooLarge),
5539            StreamCommitOutcome::Store(err) => Ok(PullThroughOutcome::Store(err)),
5540        }
5541    }
5542
5543    /// Drive a chunk stream into the iroh-blobs store, capturing mid-stream
5544    /// errors via the side channel, verify the committed hash against `hash`,
5545    /// and on match promote the temp tag to a named tag. This is the
5546    /// commit-and-verify tail of the origin pull-through
5547    /// ([`Self::pull_through_attempt`]); `stream` is an `Origin::fetch` stream.
5548    /// It does NOT broadcast the insert or read the bytes back; the caller owns
5549    /// those (the pull-through re-reads for its `Bytes` return).
5550    ///
5551    /// `count_and_cap_stream` enforces `max_blob_bytes` and bumps the
5552    /// origin-egress metric per chunk, so the pulled bytes are metered as the
5553    /// upstream egress they genuinely are (those bytes left an origin) without
5554    /// touching the `get`-caller hit/returned counters.
5555    async fn import_and_verify_stream<S>(
5556        &self,
5557        hash: Hash,
5558        stream: S,
5559        max_blob_bytes: u64,
5560    ) -> Result<StreamCommitOutcome, OriginPullError>
5561    where
5562        S: futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + Unpin + 'static,
5563    {
5564        // `iroh-blobs::add_stream` swallows the upstream `io::Error` (it
5565        // `?`-propagates inside an async block whose error is discarded), so
5566        // without this side-channel capture the engine sees only "unexpected end
5567        // of stream" and operators lose the actionable upstream message. Failed
5568        // attempts strand a partial `TempTag` worth of bytes; iroh-blobs GC
5569        // reclaims them at `cache.gc_interval_sec` cadence.
5570        let captured_err: Arc<Mutex<Option<std::io::Error>>> = Arc::new(Mutex::new(None));
5571        let counted = count_and_cap_stream(
5572            stream,
5573            max_blob_bytes,
5574            self.inner.metrics.clone(),
5575            captured_err.clone(),
5576        );
5577        let progress = self.inner.store.blobs().add_stream(counted).await;
5578        let temp_tag_result = progress.temp_tag().await;
5579
5580        // Side-channel-recorded error wins over both the iroh-blobs Err arm AND a
5581        // "successful" partial import, because the latter's hash is
5582        // deterministically wrong and we'd rather surface the real cause than a
5583        // confusing `HashMismatch`. Drop the temp tag (regardless of inner
5584        // Ok/Err) so iroh-blobs GC reclaims the partial bytes.
5585        let captured = captured_err
5586            .lock()
5587            .unwrap_or_else(PoisonError::into_inner)
5588            .take();
5589        if let Some(upstream) = captured {
5590            drop(temp_tag_result);
5591            // Cap-breach mid-stream: typed `BlobTooLargeMarker` is the documented
5592            // escape hatch — surface as the typed `BlobTooLarge` outcome rather
5593            // than routing through `classify_io_error` which would collapse it to
5594            // a generic `OriginError`.
5595            if is_blob_too_large_marker(&upstream) {
5596                return Ok(StreamCommitOutcome::BlobTooLarge);
5597            }
5598            // Otherwise classify via the shared body-phase classifier: typed
5599            // `OriginError::*` inners surface as Permanent (decompression
5600            // failures, etc.); `io::ErrorKind`-Transient kinds (ConnectionReset,
5601            // TimedOut, …) surface as Transient and re-enter the retry loop.
5602            return Err(classify_io_error(upstream));
5603        }
5604
5605        let temp_tag = match temp_tag_result {
5606            Ok(tt) => tt,
5607            Err(err) => {
5608                // No upstream-captured error: the failure is on iroh-blobs' side
5609                // (disk write, actor crash, serialization-task panic, etc.).
5610                // Surface as `Store` so operators routing on origin-vs-store
5611                // don't misclassify a local store problem as a remote origin one.
5612                return Ok(StreamCommitOutcome::Store(
5613                    anyhow::Error::from(err)
5614                        .context("iroh-blobs add_stream failed during pull-through"),
5615                ));
5616            }
5617        };
5618
5619        let actual = temp_tag.hash();
5620        if actual != hash {
5621            // Drop the temp tag without promotion → the wrong-hash bytes are
5622            // never tagged, so they are GC-eligible inside iroh-blobs and the
5623            // next sweep reclaims them. Deterministic protocol violation: a clean
5624            // stream that hashed wrong is not a transport failure — retry won't
5625            // help.
5626            //
5627            // We deliberately do NOT logically evict `actual`. The persisted
5628            // evicted set is reserved for operator DMCA/corruption takedowns
5629            // (#279); reusing it here would durably censor `actual` — and since
5630            // content is BLAKE3-addressed, bytes whose hash is `actual` *are* the
5631            // authorized content for `actual`. A malicious upstream that answers
5632            // a pull for `hash` with a victim blob's bytes could otherwise make
5633            // us permanently blacklist that legitimate blob (#853). The requested
5634            // hash `hash` is correctly never committed.
5635            drop(temp_tag);
5636            return Ok(StreamCommitOutcome::HashMismatch { actual });
5637        }
5638
5639        // Promote the temp tag to a named tag — same effect as
5640        // `add_bytes(...).await`, which goes through `with_tag()` (iroh-blobs
5641        // `blobs.rs:624-632`). The name is opaque; the store auto-assigns it.
5642        // Tag-create failure is store-side, not origin-side: surface as `Store`
5643        // so retry-class taxonomy doesn't pick it up.
5644        let haf = temp_tag.hash_and_format();
5645        // A pull that started before a quarantine must not protect the corrupt
5646        // entry the quarantine released to GC. The import landed on that entry,
5647        // so nothing verified is lost.
5648        if self.is_quarantined(hash) {
5649            drop(temp_tag);
5650            return Ok(StreamCommitOutcome::Committed);
5651        }
5652        if let Err(err) = self.inner.store.tags().create(haf).await {
5653            return Ok(StreamCommitOutcome::Store(anyhow::Error::from(err)));
5654        }
5655        drop(temp_tag);
5656        Ok(StreamCommitOutcome::Committed)
5657    }
5658
5659    /// Commit a fully-buffered payload from the drain path. Drains do
5660    /// not go through `count_and_cap_stream` (their cap was already
5661    /// enforced inline), so this just hands the buffered `Bytes` to
5662    /// iroh-blobs and verifies the resulting hash. Splitting the
5663    /// commit out of `pull_through_attempt` keeps the two body-phase
5664    /// paths (drain vs. streaming) symmetric: each is followed by the
5665    /// same commit-and-verify sequence.
5666    async fn commit_buffered_bytes(
5667        &self,
5668        hash: Hash,
5669        bytes: Bytes,
5670        mode: FillMode,
5671    ) -> Result<PullThroughOutcome, OriginPullError> {
5672        // iroh-blobs `add_bytes` returns `Ok(NamedTag)` directly,
5673        // skipping the `TempTag` intermediary used by `add_stream`.
5674        // The named tag protects the blob from GC and is the same
5675        // shape the streaming path produces after `tags().create()`.
5676        let tag = match self.inner.store.blobs().add_bytes(bytes.clone()).await {
5677            Ok(t) => t,
5678            Err(err) => {
5679                return Ok(PullThroughOutcome::Store(
5680                    anyhow::Error::from(err)
5681                        .context("iroh-blobs add_bytes failed during pull-through"),
5682                ));
5683            }
5684        };
5685        let actual = tag.hash;
5686        if actual != hash {
5687            // Unlike the streaming path (which drops an unpromoted `TempTag`),
5688            // `add_bytes` already created a *persistent named tag* protecting
5689            // these wrong-hash bytes from GC. Delete it so the bytes become
5690            // GC-eligible — matching the streaming path's drop semantics and
5691            // avoiding the unbounded-disk-growth leak (#837). Best-effort: a
5692            // delete failure only delays reclaim, so it is logged, not
5693            // propagated over the `HashMismatch` we owe the caller.
5694            //
5695            // As on the streaming path we do NOT logically evict `actual`:
5696            // the persisted evicted set is for deliberate takedowns only, and
5697            // logically evicting a content-addressed hash here is the
5698            // durable-censorship vector in #853.
5699            if let Err(err) = self.inner.store.tags().delete(tag.name).await {
5700                if let Some(m) = &self.inner.metrics {
5701                    m.tag_drop_failures.inc();
5702                }
5703                tracing::warn!(
5704                    expected = %hash,
5705                    %actual,
5706                    error = %err,
5707                    "hash-mismatch tag delete failed (drain path); wrong-hash bytes stay GC-protected (not auto-retried)",
5708                );
5709            }
5710            return Ok(PullThroughOutcome::HashMismatch { actual });
5711        }
5712        match mode {
5713            FillMode::ReturnBytes => Ok(PullThroughOutcome::Bytes(bytes)),
5714            // The drain already holds these bytes, so unlike the streaming path
5715            // there is nothing to *avoid* re-reading here — dropping them is not a
5716            // memory win. It is a CONTRACT win: it makes "CommitOnly ⟺ None" true
5717            // in both directions, so the wrappers are total and a reader of
5718            // `FillMode::CommitOnly` can trust it end to end rather than
5719            // discovering this arm as an exception.
5720            FillMode::CommitOnly => {
5721                drop(bytes);
5722                Ok(PullThroughOutcome::Committed)
5723            }
5724        }
5725    }
5726}
5727
5728/// Whether a pull-through hands the committed blob back to its caller.
5729///
5730/// Replaces a `want_bytes: bool` that sat directly beside `local_only: bool` in
5731/// `pull_through`'s argument list — two adjacent booleans the compiler would let
5732/// you swap silently, in a chain where getting it wrong either reinstates the
5733/// #1132 whole-blob read-back or stops `get` consulting peer origins. The
5734/// `local_only` half would at least be caught — `populate_local_skips_peer_origin_and_fills_from_local`
5735/// asserts `peer_fetches == 0`. The read-back half is the silent one, and is what
5736/// the enum is really buying.
5737#[derive(Clone, Copy, PartialEq, Eq, Debug)]
5738enum FillMode {
5739    /// [`CacheEngine::get`] — the caller needs the payload. The streaming arm
5740    /// reads the committed blob back out of the store to produce it; the buffered
5741    /// arm already holds it and returns its drain buffer.
5742    ReturnBytes,
5743    /// [`CacheEngine::populate`] — the caller drops the payload, so it is never
5744    /// read back (#1132). Serving a 708 MB blob would otherwise cost that much
5745    /// again on the miss leg for a buffer nobody looks at.
5746    CommitOnly,
5747}
5748
5749/// Per-attempt outcomes that ride out of the retry loop without
5750/// classification: each is a deterministic, non-retry-class result
5751/// the engine maps directly to a `CacheError` variant.
5752#[derive(Debug)]
5753enum PullThroughOutcome {
5754    Bytes(Bytes),
5755    /// The blob committed to the local store, but the caller asked not to have it
5756    /// read back ([`FillMode::CommitOnly`] — the fill-only path, #1132). Carrying no
5757    /// payload is the entire point: re-reading a 708 MB blob to hand it to
5758    /// [`CacheEngine::populate`], which drops it, was a whole-blob allocation on
5759    /// the serve path's miss leg.
5760    Committed,
5761    NotFound,
5762    BlobTooLarge,
5763    HashMismatch {
5764        actual: Hash,
5765    },
5766    Store(anyhow::Error),
5767}
5768
5769/// Outcome of [`CacheEngine::import_and_verify_stream`] — the commit-and-verify
5770/// tail of the origin pull-through. Mirrors [`PullThroughOutcome`] minus the
5771/// `Bytes`/`NotFound` arms: a stream import either commits, hashes wrong,
5772/// overruns the cap, or hits a store fault.
5773#[derive(Debug)]
5774enum StreamCommitOutcome {
5775    Committed,
5776    BlobTooLarge,
5777    HashMismatch { actual: Hash },
5778    Store(anyhow::Error),
5779}
5780
5781/// Bound on the store's `import_bao` local update queue for
5782/// [`CacheEngine::admit_bao_stream`]: at most this many decoded [`BaoContentItem`]s
5783/// may be in flight to the store before the decode driver awaits. Small enough to
5784/// cap resident memory (a handful of `cdn/client/v1` chunks), large enough that the
5785/// store import and the decode overlap rather than ping-ponging one item at a time.
5786const ADMIT_BAO_LOCAL_UPDATE_CAP: usize = 8;
5787
5788/// Classify the `io::Error` [`CacheEngine::admit_bao_stream`]'s decoder surfaces. A
5789/// genuine bao verify rejection (chunk-group or parent hash mismatch) is an
5790/// `io::Error` of kind `InvalidData` (see `bao_tree::io::error::DecodeError`'s
5791/// `From<DecodeError> for io::Error`); a truncated/short feed instead surfaces
5792/// `UnexpectedEof`, and any other failure is the feed's own read or transport
5793/// fault. Only the mismatch is a corrupt-upstream `VerifyFailed`; everything else
5794/// is a `Feed` fault. Neither is this node's store.
5795fn classify_admit_decode_error(hash: Hash, e: std::io::Error) -> CacheError {
5796    if e.kind() == std::io::ErrorKind::InvalidData {
5797        CacheError::VerifyFailed { expected: hash }
5798    } else {
5799        CacheError::Feed(anyhow::Error::from(e).context("admit_bao_stream: decode/feed failed"))
5800    }
5801}
5802/// True when the body-phase `io::Error` wraps a typed
5803/// [`BlobTooLargeMarker`] — meaning the cap (engine-level
5804/// `count_and_cap_stream`, adapter-level HTTP chunk cap, or the
5805/// drain-path running total) tripped. Lets the engine surface
5806/// `CacheError::BlobTooLarge` directly instead of routing through
5807/// [`classify_io_error`] which would collapse the typed marker into a
5808/// generic `OriginError`.
5809fn is_blob_too_large_marker(e: &std::io::Error) -> bool {
5810    e.get_ref()
5811        .is_some_and(<dyn std::error::Error + Send + Sync>::is::<BlobTooLargeMarker>)
5812}
5813
5814/// Exact byte length a correct pre-order bao outboard for a `blob_size`-byte
5815/// blob has, under iroh-blobs' canonical `IROH_BLOCK_SIZE` (#823). Used to
5816/// bound the untrusted `{H}.obao4` read on the range-pull path before
5817/// `encode_verified_range`'s authoritative length check rejects a malformed
5818/// one. Saturates to `u64::MAX` only if the upstream `outboard_size` ever
5819/// exceeds `u64` (it cannot for any real blob).
5820pub(crate) fn expected_outboard_len(blob_size: u64) -> u64 {
5821    bao_tree::BaoTree::new(blob_size, crate::range_pull::IROH_BLOCK_SIZE).outboard_size()
5822}
5823
5824pub(crate) use crate::origin::BlobTooLargeMarker;
5825
5826/// Adapter that bumps the `pull_through_bytes` metric per chunk,
5827/// captures any upstream error into `captured_err`, and *terminates
5828/// the stream cleanly* (yields `None`, never `Err`) on cap breach
5829/// or upstream error.
5830///
5831/// The engine pipes the returned stream directly into
5832/// [`iroh_blobs::api::blobs::Blobs::add_stream`], so the I/O bound
5833/// becomes the chunk size from the origin (typically a few KiB to a
5834/// few MiB depending on the backend) — a 10 GB blob never pins
5835/// 10 GB of process RSS (issue #271).
5836///
5837/// **Why `None`-on-error rather than `Err`:** iroh-blobs'
5838/// `add_stream` send loop propagates a yielded `Err` via `?`,
5839/// dropping the bidi-channel sender. Its server-side companion
5840/// actor then waits forever for `Done` before yielding any
5841/// progress item, hanging the whole import. Yielding `None` lets
5842/// `add_stream` send the `Done` marker so the import commits
5843/// (under whatever hash the partial bytes produce); the engine
5844/// reads `captured_err` to surface the *real* failure instead of
5845/// the misleading `HashMismatch` the partial-import would
5846/// otherwise produce. The partial blob is left as an unprotected
5847/// `TempTag` and reclaimed by iroh-blobs' GC.
5848///
5849/// **Wrapping the inner stream in `Option`** ensures polling
5850/// returns `None` once we've terminated. Polling a stream after
5851/// a terminal error is undefined; without the sentinel, a
5852/// consumer that resumes polling could re-enter the adapter and
5853/// re-poll an already-errored upstream.
5854///
5855/// The `Send + Sync + 'static` bound on the returned stream is fixed
5856/// by `add_stream`'s signature.
5857fn count_and_cap_stream<S>(
5858    stream: S,
5859    max_bytes: u64,
5860    metrics: Option<Arc<CacheMetrics>>,
5861    captured_err: Arc<Mutex<Option<std::io::Error>>>,
5862) -> impl futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + 'static
5863where
5864    S: futures_util::Stream<Item = std::io::Result<Bytes>> + Send + Sync + Unpin + 'static,
5865{
5866    use futures_util::StreamExt;
5867    futures_util::stream::unfold(
5868        (Some(stream), 0u64, metrics, captured_err),
5869        move |(maybe_s, total, metrics, captured)| async move {
5870            let mut s = maybe_s?;
5871            let next = s.next().await?;
5872            match next {
5873                Err(e) => {
5874                    // Move the real error into the side channel
5875                    // (preserves any typed inner like
5876                    // `io::Error::other(OriginError::*)` that
5877                    // `HttpOrigin` packs in) and *terminate the
5878                    // stream cleanly* by returning `None` on the
5879                    // next poll. We deliberately do **not** yield
5880                    // the error to iroh-blobs' `add_stream`: when
5881                    // we yield `Err` from the source stream,
5882                    // `add_stream`'s send loop returns early via
5883                    // `?`, dropping the bidi-channel sender — but
5884                    // its companion server-side actor then waits
5885                    // forever for `Done` before yielding any
5886                    // progress item, hanging the whole import.
5887                    // Yielding `None` instead lets `add_stream`
5888                    // send the `Done` marker, the import commits
5889                    // with whatever bytes were already received
5890                    // (under a wrong hash), and the engine reads
5891                    // `captured_err` to surface the *real* failure
5892                    // instead of the misleading `HashMismatch` the
5893                    // partial-import would otherwise produce. The
5894                    // partial blob is left as an unprotected
5895                    // `TempTag` and reclaimed by iroh-blobs' GC.
5896                    *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
5897                    None
5898                }
5899                Ok(chunk) => {
5900                    // Bill the chunk before the cap check: the
5901                    // origin already sent us the bytes, so the
5902                    // operator-visible egress meter must count them
5903                    // (matches the pre-streaming "every byte fetched
5904                    // is paid" intent at engine.rs:952-960). A
5905                    // chunk that lands the running total past
5906                    // `max_bytes` is still counted in
5907                    // `pull_through_bytes` even though we then
5908                    // abort the import.
5909                    if let Some(m) = metrics.as_ref() {
5910                        m.pull_through_bytes
5911                            .inc_by(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
5912                    }
5913                    let new_total = total.saturating_add(chunk.len() as u64);
5914                    if new_total > max_bytes {
5915                        // Pack a typed `BlobTooLargeMarker` into the
5916                        // io::Error so the engine surfaces
5917                        // `CacheError::BlobTooLarge` (recovered via
5918                        // `io::Error::get_ref` downcast).
5919                        let err = std::io::Error::other(BlobTooLargeMarker { max_bytes });
5920                        *captured.lock().unwrap_or_else(PoisonError::into_inner) = Some(err);
5921                        return None;
5922                    }
5923                    Some((Ok(chunk), (Some(s), new_total, metrics, captured)))
5924                }
5925            }
5926        },
5927    )
5928}
5929
5930/// Record the end of an `origin_pull` span once. A
5931/// clean miss (`NotFound` / `NoOrigin`) is `not_found` with no error status,
5932/// since walking a chain that lacks the blob is normal; every other failure
5933/// is `failed` with its error text and an error status.
5934fn record_origin_pull<T>(span: &tracing::Span, result: &CacheResult<T>) {
5935    match result {
5936        Ok(_) => {
5937            span.record("outcome", "filled");
5938        }
5939        Err(CacheError::NotFound { .. } | CacheError::NoOrigin { .. }) => {
5940            span.record("outcome", "not_found");
5941        }
5942        Err(e) => {
5943            span.record("outcome", "failed");
5944            span.record("error", tracing::field::display(e.display_chain()));
5945            span.record("otel.status_code", "ERROR");
5946        }
5947    }
5948}
5949
5950#[cfg(test)]
5951#[allow(
5952    clippy::unwrap_used,
5953    clippy::expect_used,
5954    clippy::panic,
5955    clippy::indexing_slicing,
5956    clippy::cast_possible_truncation
5957)]
5958mod tests;