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(¤t);
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(¤t);
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: ¬ify,
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: ¬ify,
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;