Skip to main content

decdn_cache/
fill_session.rs

1//! `FillSession` — the cache-resident fill-coordination primitive the
2//! serve-miss legs share (ADR 038).
3//!
4//! # Why this exists
5//!
6//! The serve leg must emit ONE coherent whole-range bao verified stream
7//! while the pull leg fills the cache incrementally beside it. `bao_tree`'s async
8//! [`encode_ranges_validated`](bao_tree::io::fsm::encode_ranges_validated) is the
9//! right engine — it walks the range pre-order, reading leaf DATA and, for every
10//! internal node, the `(left, right)` hash pair from an [`Outboard`]. The data the
11//! serve reads from the cache (via ranged export, gated on the present-range watch).
12//! But the OUTBOARD it cannot: iroh-blobs keeps a partial blob's outboard in actor
13//! memory and exposes no reader, and a pre-order walk needs proof hashes for
14//! not-yet-filled right-spine subtrees (the root's `right_hash` is emitted before
15//! any right-half data lands) that are NOT derivable from present data.
16//!
17//! So the cache keeps its own copy, captured as each range admits (the pull leg
18//! already receives those exact proof nodes in the verified upstream bao it ingests,
19//! and the cache's `admit_bao_stream` captures them via [`FillSession::capture`] into
20//! a whole-tree buffer). The serve leg reads them through a [`SessionOutboardReader`],
21//! whose [`Outboard::load`] AWAITS a node that has not been captured yet — racing the
22//! registry-wide fill liveness so a fill that dies (or a genuinely missing node after
23//! a clean pull) fails the serve rather than hanging.
24//!
25//! # The outboard is per-HASH, shared by every fill of that hash
26//!
27//! A blob is BLAKE3-addressed, so its tree geometry — and thus every captured
28//! `(left, right)` node — is a property of the HASH, not of any single pull. When two
29//! serve-misses want overlapping bytes of one hash, one opens a pull for its
30//! remainder and the other for its own; both admit into the SAME store and each admit
31//! captures proof nodes into the ONE per-hash [`HashOutboard`]. Because
32//! `attach ∪ remainder = R` by construction, the two pulls together capture every
33//! proof node a coherent encode of `R` needs, into that single buffer. A serve leg
34//! reading `R` therefore does not care which pull filled what — it reads the shared
35//! per-hash outboard and the source-agnostic present-range watch.
36//!
37//! The one thing that must generalize past a single pull is termination: "await this
38//! node, or fail" has to consult ALL live fills whose `covered` ranges intersect the
39//! node's byte span, not one pull's terminal signal. [`FillSession::range_still_live`]
40//! (backed by [`FillRegistry::range_still_live`]) answers exactly that — it settles
41//! a parked reader once no live session's `covered` still intersects the range,
42//! which is the N-fill generalization of the single-pull failure race. A settled
43//! read is served from the local store when the store holds what it needs, and
44//! fails otherwise; it never hangs.
45//!
46//! # Ownership seam (cache owns the structure, the node drives it)
47//!
48//! The pull leg (paid peer or own-origin) is node-side and alone knows when/why it
49//! terminated. So the session exposes [`FillSession::mark_ended`]; the node's pull
50//! leg calls it on every exit. The cache owns the DATA STRUCTURE; the node DRIVES
51//! it. [`FillError`] is a flattened terminal message (`anyhow::Error` is not `Clone`).
52
53use std::collections::HashMap;
54use std::io;
55use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
56use std::sync::{Arc, Mutex as StdMutex, OnceLock, PoisonError, Weak};
57
58use bao_tree::io::fsm::Outboard;
59use bao_tree::{BaoTree, BlockSize, ChunkNum, ChunkRanges, TreeNode, blake3};
60use decdn_bao_range::align_range_clamped;
61use tokio::sync::Notify;
62use tokio_util::sync::CancellationToken;
63
64use crate::Hash;
65use crate::engine::CacheEngine;
66use crate::error::{CacheResult, ErrorChain};
67
68/// iroh-blobs' block size — 16 KiB chunk groups (`2^4` 1 KiB chunks). Identical by
69/// construction to `decdn_bao_range::IROH_BLOCK_SIZE` and
70/// `iroh_blobs::store::IROH_BLOCK_SIZE`; the tree geometry (and thus every node
71/// offset) must match the store's, since the captured hashes are the store's.
72const IROH_BLOCK_SIZE: BlockSize = BlockSize::from_chunk_log(4);
73
74/// Bytes per outboard node (a `(left, right)` blake3 hash pair).
75const HASH_PAIR_BYTES: usize = 64;
76
77/// Bytes per bao chunk (the `ChunkNum` unit). `ChunkNum(n)` starts at `n * 1024`.
78const CHUNK_BYTES: u64 = 1024;
79
80/// A flattened terminal error for a fill: the node's pull leg records why its pull
81/// ended, and the serve-side [`SessionOutboardReader`] / data reader surface it.
82/// A `String` message rather than `anyhow::Error` because the outcome must be
83/// `Clone` (every observer reads it) and `anyhow::Error` is not.
84#[derive(Debug, Clone)]
85pub struct FillError(String);
86
87impl FillError {
88    /// Wrap a flattened terminal message.
89    #[must_use]
90    pub fn new(msg: impl Into<String>) -> Self {
91        Self(msg.into())
92    }
93}
94
95impl std::fmt::Display for FillError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(&self.0)
98    }
99}
100
101impl std::error::Error for FillError {}
102
103/// The pre-order outboard buffer + a per-node "captured" flag. Guarded together;
104/// the lock is never held across an await.
105///
106/// Both vectors are allocated lazily on the first capture (see
107/// [`HashOutboard::write_pair`]), not at construction: empty ⇒ nothing captured
108/// yet, which [`HashOutboard::try_load`] reads as "absent" exactly as an all-false
109/// `captured` did. This keeps the MB-scale zeroing for a large blob off the global
110/// registry lock that [`HashOutboard::new`] runs under.
111#[derive(Debug)]
112struct OutboardState {
113    /// `tree.outboard_size()` bytes once sized: node `n`'s pair lives at
114    /// `tree.pre_order_offset(n) * 64`. Empty until the first capture sizes it.
115    bytes: Vec<u8>,
116    /// `captured[i]` is set once node index `i` (its `pre_order_offset`) is saved.
117    /// Empty until the first capture sizes it, alongside `bytes`.
118    captured: Vec<bool>,
119}
120
121/// The captured whole-tree outboard for ONE hash, shared by every [`FillSession`]
122/// that fills that hash. A blob's tree geometry is fixed by its BLAKE3 root, so a
123/// node captured by any pull — of any range, from any source — is valid for every
124/// serve leg reading that hash. The registry keys exactly one of these per hash and
125/// every session for the hash references it, which is what makes partial-overlap
126/// proof-sourcing free: pull A (admitting `attach`) and pull B (admitting
127/// `remainder`) capture into the same buffer, and a serve leg for `R = attach ∪
128/// remainder` reads every node either supplied.
129#[derive(Debug)]
130pub struct HashOutboard {
131    tree: BaoTree,
132    root: blake3::Hash,
133    state: StdMutex<OutboardState>,
134    /// Notified after every [`Self::capture`] so a parked [`Outboard::load`]
135    /// re-checks. Runtime-agnostic, so it crosses the pull/serve runtimes safely.
136    captured: Notify,
137    /// Notified when ANY session for this hash ends or is cancelled, so a parked
138    /// reader re-checks [`FillRegistry::range_still_live`] and fails a range no live
139    /// pull will fill. Shared as an `Arc` so a data reader on the node side can hold
140    /// an owned handle without naming this cache-private type.
141    liveness: Arc<Notify>,
142}
143
144impl HashOutboard {
145    /// Build the empty outboard for a `total_bytes`-byte blob rooted at `root`.
146    ///
147    /// O(1): the `tree.outboard_size()`-byte buffer is NOT allocated here — the
148    /// first [`Self::write_pair`] sizes it under the per-hash `state` lock. This
149    /// keeps a session's outboard construction under the global [`FillRegistry`]
150    /// lock free of MB-scale zeroing, so one large-blob claim does not stall
151    /// every other hash's claim/wakeup for the allocation duration, and a session
152    /// that only adopts the canonical outboard allocates nothing to discard.
153    #[must_use]
154    fn new(root: blake3::Hash, total_bytes: u64) -> Arc<Self> {
155        Arc::new(Self {
156            tree: BaoTree::new(total_bytes, IROH_BLOCK_SIZE),
157            root,
158            state: StdMutex::new(OutboardState {
159                bytes: Vec::new(),
160                captured: Vec::new(),
161            }),
162            captured: Notify::new(),
163            liveness: Arc::new(Notify::new()),
164        })
165    }
166
167    /// Write one internal node's `(left, right)` hash pair into `state`, returning
168    /// whether the pair was actually persisted. `false` for a leaf (no outboard
169    /// slot), an offset past `usize`, or a slot outside the buffers — nothing was
170    /// stored, so the caller must NOT wake readers on its behalf. `true` only once
171    /// both the hash bytes and the captured flag are set, which is exactly the state
172    /// [`Self::try_load`] reads back. Idempotent: re-saving a node (a re-admitted
173    /// range) overwrites with the same bytes. Does NOT notify; the caller wakes
174    /// parked readers once the batch is in, so an N-node admit fires `captured` a
175    /// single time rather than N.
176    fn write_pair(
177        &self,
178        state: &mut OutboardState,
179        node: TreeNode,
180        pair: (blake3::Hash, blake3::Hash),
181    ) -> bool {
182        let Some(offset) = self.tree.pre_order_offset(node) else {
183            return false; // leaf: no hash pair in the outboard
184        };
185        let Ok(idx) = usize::try_from(offset) else {
186            return false; // offset beyond usize: no addressable slot
187        };
188        // Lazily size the outboard on the first capture, under this per-hash
189        // `state` lock rather than the global registry lock `new` runs under. A
190        // real interior node (a `Some` offset) implies `outboard_size() >= 64`, so
191        // an empty buffer here always means "not yet sized", never a zero-node
192        // tree. Sized exactly once: every later capture sees a non-empty buffer.
193        // Bail if the size is not `usize`-representable rather than allocating
194        // `usize::MAX` — the same "not addressable, not capturable" degrade as the
195        // `idx` guard above, never a process-aborting allocation.
196        if state.bytes.is_empty() {
197            let Ok(size) = usize::try_from(self.tree.outboard_size()) else {
198                return false;
199            };
200            state.bytes = vec![0u8; size];
201            state.captured = vec![false; size / HASH_PAIR_BYTES];
202        }
203        let byte_off = idx.saturating_mul(HASH_PAIR_BYTES);
204        let (l, r) = pair;
205        let bytes_written = if let Some(slot) =
206            state.bytes.get_mut(byte_off..byte_off + HASH_PAIR_BYTES)
207            && let Some((left, right)) = slot.split_at_mut_checked(32)
208        {
209            left.copy_from_slice(l.as_bytes());
210            right.copy_from_slice(r.as_bytes());
211            true
212        } else {
213            false
214        };
215        let flag_set = if let Some(flag) = state.captured.get_mut(idx) {
216            *flag = true;
217            true
218        } else {
219            false
220        };
221        bytes_written && flag_set
222    }
223
224    /// Capture one internal node's `(left, right)` hash pair, then wake parked
225    /// readers. A `node` with no outboard slot (a leaf) is ignored and does not notify.
226    fn capture(&self, node: TreeNode, pair: (blake3::Hash, blake3::Hash)) {
227        let wrote = {
228            let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
229            self.write_pair(&mut state, node, pair)
230        };
231        if wrote {
232            self.captured.notify_waiters();
233        }
234    }
235
236    /// Capture a batch of node pairs under one lock acquisition, then fire
237    /// `captured` exactly once. A serve leg parked on any of these nodes reads them
238    /// all back the same as a per-node [`Self::capture`] loop would, but every parked
239    /// reader wakes once per batch instead of once per node. The admit path hands
240    /// each leaf's run of proof nodes here just before the leaf. Leaves are ignored;
241    /// the notify fires only if at least one internal node was written.
242    fn capture_many(
243        &self,
244        pairs: impl IntoIterator<Item = (TreeNode, (blake3::Hash, blake3::Hash))>,
245    ) {
246        let wrote = {
247            let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
248            pairs.into_iter().fold(false, |acc, (node, pair)| {
249                self.write_pair(&mut state, node, pair) || acc
250            })
251        };
252        if wrote {
253            self.captured.notify_waiters();
254        }
255    }
256
257    /// Read node `idx`'s captured pair, or `None` if not captured yet. Never holds
258    /// the lock across an await.
259    fn try_load(&self, idx: usize) -> Option<(blake3::Hash, blake3::Hash)> {
260        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
261        if state.captured.get(idx).copied() != Some(true) {
262            return None;
263        }
264        let byte_off = idx.saturating_mul(HASH_PAIR_BYTES);
265        let slot = state.bytes.get(byte_off..byte_off + HASH_PAIR_BYTES)?;
266        let (l, r) = slot.split_at_checked(32)?;
267        let left: [u8; 32] = l.try_into().ok()?;
268        let right: [u8; 32] = r.try_into().ok()?;
269        Some((blake3::Hash::from(left), blake3::Hash::from(right)))
270    }
271}
272
273/// A forward-only content frontier shared between a fill's serve legs and its pull.
274///
275/// Holders read it with [`Self::get`]. Only the owning [`FillSession`] raises it,
276/// and every raise that moves the value wakes a pull parked in
277/// [`DownstreamWatch::past`]. No holder can lower the frontier or raise it without
278/// that wakeup, so a parked pull never misses an advance.
279#[derive(Debug, Clone)]
280pub struct Frontier {
281    /// The frontier value, a high-water mark.
282    value: Arc<AtomicU64>,
283    /// The session's downstream wakeup, notified when [`Self::raise`] moves `value`.
284    advanced: Arc<Notify>,
285}
286
287impl Frontier {
288    /// A frontier at `start` that notifies `advanced` on each advance.
289    fn new(start: u64, advanced: Arc<Notify>) -> Self {
290        Self {
291            value: Arc::new(AtomicU64::new(start)),
292            advanced,
293        }
294    }
295
296    /// The current frontier value.
297    #[must_use]
298    pub fn get(&self) -> u64 {
299        self.value.load(Ordering::Acquire)
300    }
301
302    /// Raise the frontier to `to` and wake the parked pull if the value moved. A
303    /// lower `to` changes nothing.
304    fn raise(&self, to: u64) {
305        if self.value.fetch_max(to, Ordering::AcqRel) < to {
306            self.advanced.notify_waiters();
307        }
308    }
309}
310
311/// A fill's serve demand: one standing demand per serve leg that has run out of
312/// encoded bytes while its encode waits on content this fill produces.
313///
314/// A leg stands its demand through its [`DemandSlot`] and withdraws it once its
315/// encode moves again or the leg ends. [`Self::get`] reads the NEAREST standing
316/// demand, so a leg parked far down the blob never hides a leg parked at the pull's
317/// frontier. Every change to the nearest demand wakes a pull parked in
318/// [`DownstreamWatch::past`].
319#[derive(Debug, Clone)]
320pub struct Demand {
321    /// The content end each starved serve leg awaits, keyed by its slot id.
322    legs: Arc<StdMutex<HashMap<u64, u64>>>,
323    /// The session's downstream wakeup, shared with its paid frontier.
324    advanced: Arc<Notify>,
325}
326
327impl Demand {
328    /// An empty demand that notifies `advanced` when its nearest value changes.
329    fn new(advanced: Arc<Notify>) -> Self {
330        Self {
331            legs: Arc::new(StdMutex::new(HashMap::new())),
332            advanced,
333        }
334    }
335
336    /// The nearest content end a starved serve leg awaits, or `0` when none waits.
337    #[must_use]
338    pub fn get(&self) -> u64 {
339        nearest(&self.legs.lock().unwrap_or_else(PoisonError::into_inner))
340    }
341
342    /// Record that leg `leg` awaits content up to `end`.
343    fn stand(&self, leg: u64, end: u64) {
344        self.update(|legs| {
345            legs.insert(leg, end);
346        });
347    }
348
349    /// Drop leg `leg`'s standing demand, if any.
350    fn withdraw(&self, leg: u64) {
351        self.update(|legs| {
352            legs.remove(&leg);
353        });
354    }
355
356    /// Apply `change` and wake the parked pull when the nearest demand moved.
357    fn update(&self, change: impl FnOnce(&mut HashMap<u64, u64>)) {
358        let moved = {
359            let mut legs = self.legs.lock().unwrap_or_else(PoisonError::into_inner);
360            let before = nearest(&legs);
361            change(&mut legs);
362            nearest(&legs) != before
363        };
364        if moved {
365            self.advanced.notify_waiters();
366        }
367    }
368}
369
370/// The smallest standing demand in `legs`, or `0` when it is empty.
371fn nearest(legs: &HashMap<u64, u64>) -> u64 {
372    legs.values().copied().min().unwrap_or(0)
373}
374
375/// Source of [`DemandSlot`] ids, unique across every fill in the process.
376static NEXT_DEMAND_SLOT: AtomicU64 = AtomicU64::new(1);
377
378/// One serve leg's handle on the serve demand of its blob's live fills. Minted by
379/// [`FillSession::demand_slot`].
380///
381/// [`Self::stand`] routes the leg's demand to the live fill whose covered range
382/// holds the awaited byte, and withdraws it from every other fill of the hash:
383/// only that fill's pull can produce the byte. Dropping the slot withdraws the
384/// demand, so a finished or abandoned leg leaves nothing standing.
385#[derive(Debug)]
386pub struct DemandSlot {
387    /// The serve leg's own session, the route to the hash's registry.
388    session: Arc<FillSession>,
389    /// This slot's key in every [`Demand`] it stands in.
390    id: u64,
391}
392
393impl DemandSlot {
394    /// Stand this leg's demand for content up to `end`, clamped to the blob size.
395    /// It replaces the leg's previous demand. An `end` of `0` withdraws it.
396    pub fn stand(&self, end: u64) {
397        let end = end.min(self.session.total_bytes());
398        let Some(byte) = end.checked_sub(1) else {
399            self.withdraw();
400            return;
401        };
402        match self.session.registry_and_hash() {
403            Some((registry, hash)) => registry.route_demand(hash, self.id, byte, end),
404            None => self.session.route_own_demand(self.id, byte, end),
405        }
406    }
407
408    /// Withdraw this leg's demand from every fill of the hash.
409    pub fn withdraw(&self) {
410        match self.session.registry_and_hash() {
411            Some((registry, hash)) => registry.withdraw_demand(hash, self.id),
412            None => self.session.serve_demand.withdraw(self.id),
413        }
414    }
415}
416
417impl Drop for DemandSlot {
418    fn drop(&mut self) {
419        self.withdraw();
420    }
421}
422
423/// A pull leg's view of its session's two downstream frontiers: the paid frontier
424/// ([`FillSession::served_frontier`]) and the serve demand
425/// ([`FillSession::serve_demand`]). Read-only and cheap to clone, so a pull on its
426/// own runtime holds an owned copy. Minted by [`FillSession::downstream_watch`].
427#[derive(Debug, Clone)]
428pub struct DownstreamWatch {
429    /// The session's paid content frontier.
430    served_paid: Frontier,
431    /// The session's serve demand. Shares its wakeup with `served_paid`.
432    serve_demand: Demand,
433}
434
435impl DownstreamWatch {
436    /// The current paid content frontier.
437    #[must_use]
438    pub fn served_paid(&self) -> u64 {
439        self.served_paid.get()
440    }
441
442    /// The nearest content end a starved serve leg awaits, or `0` when none waits.
443    #[must_use]
444    pub fn serve_demand(&self) -> u64 {
445        self.serve_demand.get()
446    }
447
448    /// Resolve once `served_paid` lies past `observed_paid` or the serve demand
449    /// differs from `observed_demand`. Returns at once when either already holds.
450    /// The demand counts any change: a nearer leg parking lowers it, and the pull
451    /// must re-decide for that leg as much as for a farther one.
452    ///
453    /// Registers the wakeup BEFORE it re-reads the frontiers. `notify_waiters` wakes
454    /// only waiters registered when it fires and stores no permit, so an advance
455    /// that lands between the caller reading the frontiers and this park would
456    /// otherwise be lost, and the pull would wait forever (#1673).
457    pub async fn past(&self, observed_paid: u64, observed_demand: u64) {
458        let notified = self.served_paid.advanced.notified();
459        tokio::pin!(notified);
460        loop {
461            notified.as_mut().enable();
462            if self.served_paid() > observed_paid || self.serve_demand() != observed_demand {
463                return;
464            }
465            notified.as_mut().await;
466            notified.set(self.served_paid.advanced.notified());
467        }
468    }
469}
470
471/// One in-flight fill of a `total_bytes`-byte blob rooted at `root`.
472///
473/// Holds a reference to the per-hash [`HashOutboard`] (captured by the cache admit
474/// path via [`Self::capture`], read by [`SessionOutboardReader`]), the pull's
475/// terminal outcome ([`Self::mark_ended`] / [`Self::outcome`]), the ranges this fill
476/// covers ([`Self::set_covered`]), and the shared downstream frontiers the pull
477/// leg paces against ([`Self::served_frontier`] / [`Self::serve_demand`], awaited
478/// through [`Self::downstream_watch`]). Shared
479/// behind an `Arc`; every field is interior-mutable, so both legs hold
480/// `Arc<FillSession>` and coordinate on it.
481#[derive(Debug)]
482pub struct FillSession {
483    /// The per-hash captured outboard. A freshly built session starts with its own,
484    /// but registration ([`FillRegistry::register_fill`] / [`FillRegistry::claim`])
485    /// swaps in the hash's canonical one via [`Self::adopt_outboard`], so all
486    /// sessions for a hash — and every reader they mint — share the ONE buffer. The
487    /// swap runs under the registry lock before the session is handed to a pull leg
488    /// or a serve leg, so no capture or read observes a stale outboard.
489    outboard: StdMutex<Arc<HashOutboard>>,
490    /// The pull leg's terminal outcome — `None` while running, `Some(Ok)` on a
491    /// clean end, `Some(Err)` on a failure.
492    ended: StdMutex<Option<Result<(), FillError>>>,
493    /// The client's PAID content frontier: the serve leg raises it after each
494    /// voucher batch commits ([`Self::advance_served`]), the pull leg's pacer reads
495    /// it to bound `pulled − served_paid ≤ window`, plus one floor to serve
496    /// `serve_demand`. A cloneable handle, so a pull leg on its own runtime can hold
497    /// an owned copy.
498    served_paid: Frontier,
499    /// The ABSOLUTE content offset the owning request starts at — the value
500    /// `served_paid` is seeded with. A pull leg's `RampPacer` ramps on
501    /// `served_paid − served_start` plus the owning stream's carried lane credit,
502    /// so a resumed request ramps from that credit, not from its blob offset.
503    served_start: u64,
504    /// Where the starved serve legs reading this fill's content wait. A serve leg's
505    /// frame consumer stands a demand through its [`DemandSlot`] when it has no
506    /// encoded bytes left and its encode is parked on a leaf or a proof node no pull
507    /// has produced ([`SessionOutboardReader::parked_on`]), and withdraws it once
508    /// the encode moves again. A park with encoded bytes still buffered is
509    /// look-ahead and stands nothing, so a client that stops paying cannot drag a
510    /// pull past its window. When the nearest demand lies within one chunk group
511    /// past a pull's frontier, that pull's pacer draws one window floor even with
512    /// its window full. Without it, a pull window that closes before the serve leg's
513    /// credit window leaves both legs waiting on each other. Shares one wakeup with
514    /// `served_paid`, so a parked pull wakes on either.
515    serve_demand: Demand,
516    /// The chunk ranges THIS fill will produce — exactly the bytes this pull
517    /// fetches (its `missing_ranges ∩ R`). [`FillRegistry::range_still_live`]
518    /// intersects a reader's node range against the union of all live sessions'
519    /// `covered` to decide whether to keep awaiting or fail. Set at registration via
520    /// [`Self::set_covered`]; defaults to [`ChunkRanges::all`] so a session built but
521    /// not yet range-scoped coalesces conservatively.
522    covered: StdMutex<ChunkRanges>,
523    /// Live observer count: the pull owner plus every serve leg attached to this
524    /// fill. An [`ObserverLease`] increments on attach and decrements on drop; when
525    /// it reaches 0 while the pull is still running ([`Self::outcome`] is `None`),
526    /// the last-out drop fires [`Self::cancel`] to stop ingest (#1610).
527    observers: AtomicUsize,
528    /// Cancels the pull when the last observer leaves before the pull ends. The
529    /// node's pull leg selects on it to abort ingest; the tagged partial persists
530    /// for a later resume.
531    cancel: CancellationToken,
532    /// A back-link to the owning registry + this session's hash, set once at
533    /// registration ([`Self::bind_registry`]). It lets a reader consult ALL
534    /// live sessions for the hash via [`FillRegistry::range_still_live`]. A session
535    /// built standalone (in tests) leaves this unset and falls back to its own
536    /// single-fill liveness — correct, because a standalone session is the only fill.
537    registry: OnceLock<(Weak<FillRegistry>, Hash)>,
538    /// The node's pull-thread [`JoinHandle`](std::thread::JoinHandle), parked here by
539    /// the owner right after it spawns the pull, so whichever observer leaves LAST
540    /// joins it. That frees an owner whose OWN client finishes first from parking in
541    /// the join while other observers still stream: a non-last-out release returns
542    /// immediately, and the true last-out [`ObserverLease`] drop takes the handle back
543    /// out to join OFF the map lock. `None` when no pull was spawned (an attach, or a
544    /// spawn that failed).
545    pull_thread: StdMutex<Option<std::thread::JoinHandle<()>>>,
546}
547
548impl FillSession {
549    /// Construct the fill session for a `total_bytes`-byte blob rooted at `root`,
550    /// with its served frontier at 0. An owning branch for a request that starts
551    /// past byte 0 uses [`Self::starting_at`] instead.
552    #[must_use]
553    pub fn new(root: blake3::Hash, total_bytes: u64) -> Arc<Self> {
554        Self::starting_at(root, total_bytes, 0)
555    }
556
557    /// Construct the fill session for a `total_bytes`-byte blob rooted at `root`,
558    /// with its served frontier at `served_start`: the ABSOLUTE content offset the
559    /// request starts at, so a non-zero-offset request shows no phantom lead. The
560    /// session starts with a private per-hash outboard, replaced by the registry's
561    /// canonical one at registration.
562    #[must_use]
563    pub fn starting_at(root: blake3::Hash, total_bytes: u64, served_start: u64) -> Arc<Self> {
564        let downstream_advanced = Arc::new(Notify::new());
565        Arc::new(Self {
566            outboard: StdMutex::new(HashOutboard::new(root, total_bytes)),
567            ended: StdMutex::new(None),
568            served_paid: Frontier::new(served_start, Arc::clone(&downstream_advanced)),
569            served_start,
570            serve_demand: Demand::new(downstream_advanced),
571            covered: StdMutex::new(ChunkRanges::all()),
572            observers: AtomicUsize::new(0),
573            cancel: CancellationToken::new(),
574            registry: OnceLock::new(),
575            pull_thread: StdMutex::new(None),
576        })
577    }
578
579    /// Park the owner's pull-thread handle on the session so whichever observer leaves
580    /// LAST joins it. Called once, on an owning branch, right after the pull thread is
581    /// spawned. The pure-attach path never calls this — it drives no pull of its own.
582    ///
583    /// Refuses to overwrite an already-parked handle: a session has exactly one owning
584    /// pull, so a second park cannot happen — but if a future regression called this
585    /// twice, overwriting would DROP the first handle and detach its thread, orphaning
586    /// a pull that keeps paying/draining. So keep the first (the one the last-out
587    /// observer will join) and `debug_assert` the double-park loudly in tests/dev.
588    pub fn set_pull_handle(&self, handle: std::thread::JoinHandle<()>) {
589        let mut slot = self
590            .pull_thread
591            .lock()
592            .unwrap_or_else(PoisonError::into_inner);
593        debug_assert!(
594            slot.is_none(),
595            "a FillSession parks exactly one pull-thread handle; a second park would \
596             detach the first and orphan its pull"
597        );
598        if slot.is_none() {
599            *slot = Some(handle);
600        }
601    }
602
603    /// Take the parked pull-thread handle, if any. Called only by the last-out lease
604    /// teardown, so exactly one caller ever receives it — the one that then joins the
605    /// thread off-task.
606    fn take_pull_handle(&self) -> Option<std::thread::JoinHandle<()>> {
607        self.pull_thread
608            .lock()
609            .unwrap_or_else(PoisonError::into_inner)
610            .take()
611    }
612
613    /// The per-hash outboard this session currently references. Cloned out (a cheap
614    /// `Arc` bump) rather than borrowed, so the swap lock is never held across a
615    /// capture or a read.
616    fn outboard(&self) -> Arc<HashOutboard> {
617        self.outboard
618            .lock()
619            .unwrap_or_else(PoisonError::into_inner)
620            .clone()
621    }
622
623    /// Adopt the hash's canonical outboard (shared with sibling sessions). Called
624    /// once, under the registry lock, before this session drives a pull or mints a
625    /// reader — so every later capture / read lands in the one shared buffer.
626    fn adopt_outboard(&self, shared: Arc<HashOutboard>) {
627        *self.outboard.lock().unwrap_or_else(PoisonError::into_inner) = shared;
628    }
629
630    /// Bind this session to its registry + hash, so a reader it mints can ask the
631    /// registry which live fills still cover a range. Called once at registration.
632    fn bind_registry(&self, registry: Weak<FillRegistry>, hash: Hash) {
633        let _ = self.registry.set((registry, hash));
634    }
635
636    /// Set the chunk ranges this fill will produce. Called once at registration,
637    /// before the pull streams — the registry reads it to plan observer attach and
638    /// to answer [`Self::range_still_live`].
639    pub fn set_covered(&self, covered: ChunkRanges) {
640        *self.covered.lock().unwrap_or_else(PoisonError::into_inner) = covered;
641    }
642
643    /// A clone of this fill's `covered` ranges.
644    fn covered_ranges(&self) -> ChunkRanges {
645        self.covered
646            .lock()
647            .unwrap_or_else(PoisonError::into_inner)
648            .clone()
649    }
650
651    /// The blob's total byte length. The attach path signs its `StreamResponse`
652    /// from this without re-running the upstream header handshake — the session
653    /// already knows the geometry from its `BaoTree`.
654    #[must_use]
655    pub fn total_bytes(&self) -> u64 {
656        self.outboard().tree.size()
657    }
658
659    /// The ABSOLUTE content offset this session's owning request starts at.
660    #[must_use]
661    pub const fn served_start(&self) -> u64 {
662        self.served_start
663    }
664
665    /// The current PAID content frontier (absolute): how far the observers of
666    /// this fill have paid, and therefore how far past it the pull may run.
667    #[must_use]
668    pub fn served_paid(&self) -> u64 {
669        self.served_paid.get()
670    }
671
672    /// The live observer count (pull owner + attached serve legs).
673    #[must_use]
674    pub fn observer_count(&self) -> usize {
675        self.observers.load(Ordering::Acquire)
676    }
677
678    /// Whether the pull has been cancelled because the last observer left.
679    #[must_use]
680    pub fn is_cancelled(&self) -> bool {
681        self.cancel.is_cancelled()
682    }
683
684    /// The pull leg's cancellation token — it selects on this to abort ingest when
685    /// the last observer leaves before the pull completes.
686    #[must_use]
687    pub const fn cancel_token(&self) -> &CancellationToken {
688        &self.cancel
689    }
690
691    /// Whether this session is dead: the pull has recorded a terminal outcome, or
692    /// the fill was cancelled because its last observer left. A dead session must
693    /// not block a fresh pull (its `covered` is excluded from the coverage union)
694    /// and must not be an attach target.
695    #[must_use]
696    fn is_dead(&self) -> bool {
697        self.outcome().is_some() || self.cancel.is_cancelled()
698    }
699
700    /// Mint a serve-side [`SessionOutboardReader`] over this hash's shared outboard.
701    /// The reader snapshots the (already-adopted) outboard and holds this session so
702    /// its termination race can reach the registry. A serve path attaches the store
703    /// ([`SessionOutboardReader::with_store_fallback`]); without it, a load of a node
704    /// no live fill captures fails.
705    #[must_use]
706    pub fn outboard_reader(self: &Arc<Self>) -> SessionOutboardReader {
707        SessionOutboardReader {
708            session: Arc::clone(self),
709            outboard: self.outboard(),
710            parked_on: Arc::new(AtomicU64::new(0)),
711            store: None,
712        }
713    }
714
715    /// The shared PAID content frontier the pull leg's pacer bounds against.
716    #[must_use]
717    pub const fn served_frontier(&self) -> &Frontier {
718        &self.served_paid
719    }
720
721    /// Raise the PAID content frontier to `served` after a downstream voucher batch
722    /// commits, waking the parked pull if it moved. A lower value changes nothing,
723    /// so N observers advancing one shared frontier never regress each other.
724    pub fn advance_served(&self, served: u64) {
725        self.served_paid.raise(served);
726    }
727
728    /// Raise the PAID content frontier to `served` on behalf of an observer whose
729    /// delivery starts at content `offset`, but only when this session's frontier
730    /// already reaches `offset`.
731    ///
732    /// Under partial-overlap coalescing an observer consumes a SUFFIX of this fill's
733    /// covered range, starting at `offset`. Its payment extends this fill's paid
734    /// PREFIX only once that prefix reaches `offset`. Before that, the bytes in
735    /// `[frontier, offset)` are unpaid, and raising the frontier past them would let
736    /// the pull run its window over bytes no observer of this fill has paid for.
737    pub fn extend_served_from(&self, offset: u64, served: u64) {
738        if self.served_paid.get() >= offset {
739            self.served_paid.raise(served);
740        }
741    }
742
743    /// A read-only watch over both downstream frontiers, for a pull leg to pace
744    /// against and park on.
745    #[must_use]
746    pub fn downstream_watch(&self) -> DownstreamWatch {
747        DownstreamWatch {
748            served_paid: self.served_paid.clone(),
749            serve_demand: self.serve_demand.clone(),
750        }
751    }
752
753    /// The serve demand of this fill. When its nearest value lies within one chunk
754    /// group past a pull's frontier, that pull's pacer draws one window floor even
755    /// with its window full; a demand further out is ignored.
756    #[must_use]
757    pub const fn serve_demand(&self) -> &Demand {
758        &self.serve_demand
759    }
760
761    /// Mint the [`DemandSlot`] one serve leg reading this fill stands its demand
762    /// through. Under coalescing the awaited byte may belong to a sibling fill of
763    /// the hash, so the slot routes each demand through the registry.
764    #[must_use]
765    pub fn demand_slot(self: &Arc<Self>) -> DemandSlot {
766        DemandSlot {
767            session: Arc::clone(self),
768            id: NEXT_DEMAND_SLOT.fetch_add(1, Ordering::Relaxed),
769        }
770    }
771
772    /// The owning registry and this session's hash, when registered and the
773    /// registry is still alive.
774    fn registry_and_hash(&self) -> Option<(Arc<FillRegistry>, Hash)> {
775        self.registry
776            .get()
777            .and_then(|(weak, hash)| weak.upgrade().map(|registry| (registry, *hash)))
778    }
779
780    /// Stand slot `slot`'s demand for content up to `end` here when this live fill
781    /// produces `byte`, and withdraw it otherwise.
782    fn route_own_demand(&self, slot: u64, byte: u64, end: u64) {
783        let chunk = bao_tree::ChunkNum::full_chunks(byte);
784        let produces = !self.is_dead() && {
785            let covered = self.covered.lock().unwrap_or_else(PoisonError::into_inner);
786            !(&*covered & &ChunkRanges::from(chunk..chunk + 1)).is_empty()
787        };
788        if produces {
789            self.serve_demand.stand(slot, end);
790        } else {
791            self.serve_demand.withdraw(slot);
792        }
793    }
794
795    /// The per-hash liveness signal, notified whenever any session for this hash
796    /// ends or is cancelled. A serve-side data reader awaits it (alongside the
797    /// present-range watch) so a read of a range no live pull will fill settles
798    /// rather than hanging: served from the store when it holds the bytes, failed
799    /// otherwise. Handed out as an owned `Arc<Notify>` so the node's data reader
800    /// need not name the cache-private [`HashOutboard`].
801    #[must_use]
802    pub fn liveness_signal(&self) -> Arc<Notify> {
803        Arc::clone(&self.outboard().liveness)
804    }
805
806    /// Whether any LIVE fill of this hash still covers `range`, so a not-yet-present
807    /// node / leaf in `range` may still be filled. A registry-bound session consults
808    /// EVERY live session for the hash (the N-fill generalization); a standalone
809    /// session (unregistered, in tests) is the only fill, so it answers from its own
810    /// liveness. Returns `false` when nothing live covers `range` — the caller then
811    /// fails the read instead of hanging.
812    #[must_use]
813    pub fn range_still_live(&self, range: &ChunkRanges) -> bool {
814        match self.registry.get() {
815            Some((weak, hash)) => match weak.upgrade() {
816                Some(registry) => registry.range_still_live(*hash, range),
817                // The registry (the whole cache) is gone; nothing can fill anything.
818                None => false,
819            },
820            // Standalone: this session is the only fill, so its liveness decides.
821            None => !self.is_dead() && !(&self.covered_ranges() & range).is_empty(),
822        }
823    }
824
825    /// Capture one internal node's `(left, right)` hash pair into the per-hash
826    /// outboard. Idempotent; a leaf (no outboard slot) is ignored. Every
827    /// `admit_bao_stream` for the hash, from any pull or source, calls this — so the
828    /// one shared buffer accumulates the whole tree's proof.
829    pub fn capture(&self, node: TreeNode, pair: (blake3::Hash, blake3::Hash)) {
830        self.outboard().capture(node, pair);
831    }
832
833    /// Capture a batch of node pairs into the per-hash outboard in one shot, waking
834    /// parked serve legs once for the batch rather than once per node. The admit path
835    /// hands each leaf's run of proof nodes here just before the leaf, so a serve leg
836    /// reads a leaf's proof as soon as that leaf arrives, with at most one wake per
837    /// leaf.
838    pub fn capture_many(
839        &self,
840        pairs: impl IntoIterator<Item = (TreeNode, (blake3::Hash, blake3::Hash))>,
841    ) {
842        self.outboard().capture_many(pairs);
843    }
844
845    /// Record the pull leg's terminal outcome and wake parked readers. Idempotent
846    /// on the wake; the outcome is set once by the pull leg on its single exit.
847    /// Fires the per-hash liveness so a reader awaiting a range this fill covered
848    /// re-checks [`Self::range_still_live`] at once.
849    pub fn mark_ended(&self, result: Result<(), FillError>) {
850        {
851            let mut guard = self.ended.lock().unwrap_or_else(PoisonError::into_inner);
852            *guard = Some(result);
853        }
854        self.outboard().liveness.notify_waiters();
855    }
856
857    /// The pull leg's terminal outcome, if it has ended: `Some(Ok)` clean,
858    /// `Some(Err)` failed. `None` while the pull is still running.
859    #[must_use]
860    pub fn outcome(&self) -> Option<Result<(), FillError>> {
861        self.ended
862            .lock()
863            .unwrap_or_else(PoisonError::into_inner)
864            .clone()
865    }
866}
867
868/// Serve-side [`Outboard`] over a hash's shared [`HashOutboard`]. Its
869/// [`Outboard::load`] awaits a not-yet-captured node, racing the registry-wide fill
870/// liveness for the no-hang guarantee: once no live fill still covers the node's
871/// byte range, it reads the node from the local store when the reader carries a
872/// store fallback ([`Self::with_store_fallback`]), and fails when that cannot
873/// supply it. Minted via [`FillSession::outboard_reader`].
874#[derive(Debug)]
875pub struct SessionOutboardReader {
876    session: Arc<FillSession>,
877    /// The shared per-hash outboard, snapshot at mint (after the session adopted the
878    /// canonical one), so every `load` reads whatever any pull for the hash captured.
879    outboard: Arc<HashOutboard>,
880    /// The content end this reader waits on, written before each park: one byte
881    /// into the node whose pair no pull has captured. The reader does NOT raise
882    /// serve demand itself — an encoder reads ahead of what its consumer needs, so
883    /// a park here alone does not mean the serve leg is stuck. The frame consumer
884    /// stands this value via its [`DemandSlot`] only once it is
885    /// starved of encoded bytes ([`Self::parked_on`]).
886    parked_on: Arc<AtomicU64>,
887    /// The local store, read for a node no live fill will capture
888    /// ([`Self::with_store_fallback`]). `None` fails such a load.
889    store: Option<StoreFallback>,
890}
891
892/// The local store a [`SessionOutboardReader`] reads a node's pair from when no
893/// live fill will capture it.
894#[derive(Debug)]
895struct StoreFallback {
896    engine: CacheEngine,
897    /// The store's present chunks of the hash as last read. A node they already
898    /// meet needs no fresh read.
899    present: ChunkRanges,
900}
901
902impl StoreFallback {
903    /// Capture into `outboard` the pairs `export_bao` emits for ONE chunk group the
904    /// store holds under a node of chunk range `node_range`: every parent on the
905    /// group's proof path, the node among them, plus the right-siblings over
906    /// absent content. The store reads and verifies at most that one group's
907    /// leaves, however much content the node spans. Captures nothing when the
908    /// store holds nothing under the node. Returns whether the export yielded any
909    /// pairs.
910    async fn capture(
911        &mut self,
912        outboard: &HashOutboard,
913        node_range: &ChunkRanges,
914    ) -> CacheResult<bool> {
915        let hash = Hash::from_bytes(*outboard.root.as_bytes());
916        if (node_range & &self.present).is_empty() {
917            self.present = self
918                .engine
919                .present_ranges(hash)
920                .await?
921                .chunk_ranges()
922                .clone();
923        }
924        let held = node_range & &self.present;
925        let Some(first) = held.boundaries().first().copied() else {
926            return Ok(false);
927        };
928        let group_chunks = 1u64 << IROH_BLOCK_SIZE.chunk_log();
929        let group_start = first.0 / group_chunks * group_chunks;
930        let group = ChunkRanges::from(
931            ChunkNum(group_start)..ChunkNum(group_start.saturating_add(group_chunks)),
932        );
933        let pairs = self.engine.outboard_pairs(hash, &(&held & &group)).await?;
934        let exported = !pairs.is_empty();
935        outboard.capture_many(pairs);
936        Ok(exported)
937    }
938}
939
940impl SessionOutboardReader {
941    /// Read a node no live fill will capture from `engine`, the local store, before
942    /// failing the load. The store can hold content under such a node: a pull that
943    /// faulted landed part of its range first, or an earlier fill stored it. The
944    /// store's outboard then holds the node's pair. Without the fallback such a
945    /// load fails.
946    #[must_use]
947    pub fn with_store_fallback(mut self, engine: CacheEngine) -> Self {
948        self.store = Some(StoreFallback {
949            engine,
950            present: ChunkRanges::empty(),
951        });
952        self
953    }
954
955    /// The cell this reader writes the content end it waits on into. A frame
956    /// consumer shares it with its data reader and stands it as serve demand
957    /// while it has no encoded bytes left to take.
958    #[must_use]
959    pub fn parked_on(&self) -> Arc<AtomicU64> {
960        Arc::clone(&self.parked_on)
961    }
962
963    /// Log a node the store fallback supplied. After a failed pull, or once
964    /// every covering pull ended, the store is the expected source: debug. After
965    /// this session's own pull completed cleanly, the pull should have captured
966    /// the node itself, so the fallback covers a capture defect: warn.
967    fn log_store_hit(&self, node: TreeNode) {
968        let hash = self.outboard.root;
969        if matches!(self.session.outcome(), Some(Ok(()))) {
970            tracing::warn!(
971                %hash,
972                ?node,
973                "a clean pull never captured an outboard node; read it from the store"
974            );
975        } else {
976            tracing::debug!(
977                %hash,
978                ?node,
979                "read an outboard node no live fill will capture from the store"
980            );
981        }
982    }
983
984    /// The terminal error for a node no live fill will supply: a precise message
985    /// when this session's own pull ended (the standalone / single-fill case), a
986    /// generic one when the covering fills were siblings.
987    fn dead_range_error(&self, node: TreeNode) -> io::Error {
988        match self.session.outcome() {
989            Some(Err(msg)) => io::Error::other(format!(
990                "upstream pull failed before supplying outboard node {node:?}: {msg}"
991            )),
992            Some(Ok(())) => io::Error::other(format!(
993                "upstream pull completed but outboard node {node:?} was never captured"
994            )),
995            None => io::Error::other(format!(
996                "no live fill covers outboard node {node:?}; every covering pull ended"
997            )),
998        }
999    }
1000}
1001
1002impl Outboard for SessionOutboardReader {
1003    fn root(&self) -> blake3::Hash {
1004        self.outboard.root
1005    }
1006
1007    fn tree(&self) -> BaoTree {
1008        self.outboard.tree
1009    }
1010
1011    async fn load(&mut self, node: TreeNode) -> io::Result<Option<(blake3::Hash, blake3::Hash)>> {
1012        let Some(offset) = self.outboard.tree.pre_order_offset(node) else {
1013            return Ok(None); // leaf: bao_tree sources this from the data reader
1014        };
1015        let idx = usize::try_from(offset).unwrap_or(usize::MAX);
1016        let node_range = ChunkRanges::from(node.chunk_range());
1017        loop {
1018            // Register the wakers BEFORE inspecting shared state, so a capture /
1019            // fill-end recorded concurrently cannot slip between the check and the
1020            // await.
1021            let captured = self.outboard.captured.notified();
1022            let liveness = self.outboard.liveness.notified();
1023            tokio::pin!(captured);
1024            tokio::pin!(liveness);
1025            captured.as_mut().enable();
1026            liveness.as_mut().enable();
1027
1028            if let Some(pair) = self.outboard.try_load(idx) {
1029                return Ok(Some(pair));
1030            }
1031
1032            // Not captured yet. If no live fill still covers this node's range, no
1033            // pull will capture the node — decide now. A clean end may lag one
1034            // capture behind the outcome, so re-check once. Then read the node
1035            // from the store, which may hold content under it that a fill landed
1036            // before it ended, and fail only when the store cannot supply it.
1037            if !self.session.range_still_live(&node_range) {
1038                if let Some(pair) = self.outboard.try_load(idx) {
1039                    return Ok(Some(pair));
1040                }
1041                let exported = match self.store.as_mut() {
1042                    Some(fallback) => fallback.capture(&self.outboard, &node_range).await,
1043                    None => Ok(false),
1044                };
1045                match exported {
1046                    Ok(exported) => {
1047                        if let Some(pair) = self.outboard.try_load(idx) {
1048                            if exported {
1049                                self.log_store_hit(node);
1050                            }
1051                            return Ok(Some(pair));
1052                        }
1053                        if exported {
1054                            return Err(io::Error::other(format!(
1055                                "the store holds content under outboard node {node:?} but \
1056                                 its export did not yield the node"
1057                            )));
1058                        }
1059                    }
1060                    Err(err) => {
1061                        return Err(io::Error::other(format!(
1062                            "{}; the store read for it failed: {}",
1063                            self.dead_range_error(node),
1064                            ErrorChain::new(&err)
1065                        )));
1066                    }
1067                }
1068                return Err(self.dead_range_error(node));
1069            }
1070
1071            // A pull captures this pair only once it fetches into the node's range.
1072            // Record the first byte of that range as what this encode waits on; the
1073            // frame consumer turns it into serve demand if it starves on this park.
1074            let node_start = node.chunk_range().start.to_bytes();
1075            self.parked_on
1076                .store(node_start.saturating_add(1), Ordering::Relaxed);
1077
1078            // Await the next capture or a liveness change, then re-check.
1079            tokio::select! {
1080                biased;
1081                () = liveness.as_mut() => {}
1082                () = captured.as_mut() => {}
1083            }
1084        }
1085    }
1086}
1087
1088/// RAII observer handle on a [`FillSession`]. One is minted for the pull owner and
1089/// one per attached serve leg ([`FillRegistry::claim`]). Releasing it decrements the
1090/// session's observer count; the last-out release, if the pull is still running,
1091/// cancels the pull so a fill no client is waiting on stops ingesting (#1610) — and
1092/// hands back the session's parked pull-thread handle so THAT caller joins it
1093/// off-task (the owner-join hand-off: whoever leaves LAST joins the pull, freeing an
1094/// owner whose own client finished first).
1095///
1096/// The decrement + cancel decision runs UNDER the registry map lock — the same
1097/// lock `claim`/`register_fill` hold when they attach an observer. That mutual
1098/// exclusion closes the resurrection race: a concurrent claim cannot `fetch_add` a
1099/// session 0→1 in the window between this drop's last-out `fetch_sub` (1→0) and its
1100/// `cancel()`, so no live observer can end up bound to a cancelled fill. The lease
1101/// therefore holds a [`Weak`] back to the registry (plus the session's `hash`) to
1102/// reach that lock at drop. If the upgrade fails the registry is gone, so the fill
1103/// is moot and the drop just skips. It only TAKES the pull handle under the lock;
1104/// the JOIN always runs off it.
1105#[derive(Debug)]
1106pub struct ObserverLease {
1107    session: Arc<FillSession>,
1108    registry: Weak<FillRegistry>,
1109    hash: Hash,
1110    /// Cleared once teardown has run. [`Self::release`] consumes the lease and runs
1111    /// teardown explicitly; the value then still `Drop`s, so this flag makes the
1112    /// second pass a no-op — the decrement fires exactly once.
1113    armed: bool,
1114}
1115
1116impl ObserverLease {
1117    /// Release the lease at a serve leg's teardown, handing back the parked
1118    /// pull-thread handle when THIS release is the last one out. The caller joins that
1119    /// handle OFF the registry map lock and off its accept task (on a blocking
1120    /// thread). A non-last-out release returns `None`, so an owner whose own client
1121    /// finished first returns at once, leaving the pull filling for the remaining
1122    /// observers — the last of which cancels it (#1610 preserved). Named so teardown
1123    /// reads as intent rather than an incidental drop.
1124    #[must_use]
1125    pub fn release(mut self) -> Option<std::thread::JoinHandle<()>> {
1126        self.teardown()
1127    }
1128
1129    /// The last-out decrement + cancel, shared by [`Self::release`] and [`Drop`].
1130    /// Runs under the registry map lock — the SAME lock `claim`/`register_fill` hold
1131    /// to attach — so the decrement, the map removal, and the cancel decision cannot
1132    /// interleave with an attach (the #1610 resurrection-race fix). It only TAKES the
1133    /// pull handle here; it never JOINS under the lock. Idempotent via `armed`.
1134    fn teardown(&mut self) -> Option<std::thread::JoinHandle<()>> {
1135        if !self.armed {
1136            return None;
1137        }
1138        self.armed = false;
1139        let Some(registry) = self.registry.upgrade() else {
1140            // Registry dropped — the fill (and its map entry) are gone; nothing can
1141            // attach, so the decrement + cancel are moot. Skip.
1142            return None;
1143        };
1144        // Take the SAME lock `claim`/`register_fill` hold to attach, so the
1145        // decrement, the map removal, and the cancel decision cannot interleave with
1146        // an attach.
1147        let mut map = registry.map.lock().unwrap_or_else(PoisonError::into_inner);
1148        // `fetch_sub` returns the PREVIOUS value; `1` means this drop took the
1149        // count to 0.
1150        let prev = self.session.observers.fetch_sub(1, Ordering::AcqRel);
1151        if prev != 1 {
1152            return None;
1153        }
1154        // Last observer left: free the session from the map so dead sessions do not
1155        // accumulate forever. Remove by pointer identity so a sibling session for the
1156        // same hash survives; drop the `hash` key if its Vec empties (the per-hash
1157        // outboard then drops once every reader releases it).
1158        if let Some(entry) = map.get_mut(&self.hash) {
1159            entry.sessions.retain(|s| !Arc::ptr_eq(s, &self.session));
1160            if entry.sessions.is_empty() {
1161                map.remove(&self.hash);
1162            }
1163        }
1164        // Cancel only if the pull has not already ended — a completed pull needs no
1165        // cancel, and a failed one already terminated. The session is removed from the
1166        // map regardless. Firing the per-hash liveness wakes any reader parked on a
1167        // range this fill covered so it re-checks at once.
1168        if self.session.outcome().is_none() {
1169            self.session.cancel.cancel();
1170        }
1171        self.session.outboard().liveness.notify_waiters();
1172        // Hand the parked pull-thread handle to this last-out caller to join off-task.
1173        // Taking (not joining) it here keeps the map lock free of a blocking join.
1174        self.session.take_pull_handle()
1175    }
1176}
1177
1178impl Drop for ObserverLease {
1179    fn drop(&mut self) {
1180        let _ = self.teardown();
1181    }
1182}
1183
1184/// The atomic outcome of [`FillRegistry::claim`]: a serve-miss either coalesces
1185/// wholly onto live pulls (`Attach`), owns a fresh pull for its whole range
1186/// (`Owner`), or — on a partial overlap — owns a fresh pull for its non-overlapping
1187/// REMAINDER while attaching to a live sibling for the overlap (`Mixed`). Every
1188/// variant carries the [`FillSession`]s and [`ObserverLease`]s the caller holds
1189/// for the life of its serve leg. `claim` decides the split AND registers any new
1190/// owner session under ONE map-lock acquisition, so two concurrent fresh misses for
1191/// the same blob cannot both open a pull.
1192#[derive(Debug)]
1193pub enum FillClaim {
1194    /// A live pull already covers the whole request; run a serve leg over `session`
1195    /// and open NO new pull. `lease` is an observer lease on the existing fill.
1196    Attach {
1197        /// The live session to serve from (largest overlap with the request).
1198        session: Arc<FillSession>,
1199        /// The observer lease held for the life of the attaching serve leg.
1200        lease: ObserverLease,
1201    },
1202    /// No live pull covers the request; the caller owns the freshly-registered
1203    /// `session` (scoped to the WHOLE request) and must spawn the pull. `lease` is
1204    /// the owner lease (observer count starts at 1).
1205    Owner {
1206        /// The freshly-registered session the caller must drive a pull for.
1207        session: Arc<FillSession>,
1208        /// The owner lease (count 1) held for the life of the owning pull.
1209        lease: ObserverLease,
1210    },
1211    /// A partial overlap: a live sibling covers a prefix/suffix of the request. The
1212    /// caller OWNS a fresh pull for the contiguous `remainder` (`[remainder_offset,
1213    /// remainder_offset + remainder_len)`) and ATTACHES to the sibling for the
1214    /// overlap, serving the whole request from the shared per-hash cache + outboard.
1215    /// Two pulls, each fetching its bytes once.
1216    Mixed {
1217        /// The freshly-registered session covering the remainder; drive its pull.
1218        owner: Arc<FillSession>,
1219        /// The owner lease (count 1) for the remainder pull.
1220        owner_lease: ObserverLease,
1221        /// The live sibling covering the overlap; its pull is shared, not re-opened.
1222        attach: Arc<FillSession>,
1223        /// The observer lease on the sibling, held for the life of the serve leg.
1224        attach_lease: ObserverLease,
1225        /// The remainder's byte offset — the pull leg fetches `[offset, offset+len)`.
1226        remainder_offset: u64,
1227        /// The remainder's byte length.
1228        remainder_len: u64,
1229    },
1230}
1231
1232/// Total chunk count spanned by a (disjoint, sorted) chunk-range set. Boundaries
1233/// come in `[start, end)` pairs; a trailing unpaired boundary (an open-ended set)
1234/// contributes nothing, which is fine for the finite `covered ∩ R` overlaps this
1235/// measures.
1236fn chunk_span(ranges: &ChunkRanges) -> u64 {
1237    let (pairs, _open_end) = ranges.boundaries().as_chunks::<2>();
1238    pairs
1239        .iter()
1240        .map(|[start, end]| end.0.saturating_sub(start.0))
1241        .sum()
1242}
1243
1244/// The single contiguous byte span `[offset, offset+len)` of a chunk-range set, or
1245/// `None` if it is empty or split into two or more disjoint pieces. A `Mixed` claim
1246/// only opens a remainder pull when the remainder is one contiguous range (a
1247/// prefix/suffix overlap), because the pull leg fetches one `[offset, len)` gap; a
1248/// remainder split by an interior overlap falls back to a conservative whole-request
1249/// pull. `total` clamps the final (possibly ragged) group to the blob end.
1250fn contiguous_byte_span(ranges: &ChunkRanges, total: u64) -> Option<(u64, u64)> {
1251    match ranges.boundaries() {
1252        [start, end] => {
1253            let offset = start.0.saturating_mul(CHUNK_BYTES);
1254            let end_bytes = end.0.saturating_mul(CHUNK_BYTES).min(total);
1255            Some((offset, end_bytes.saturating_sub(offset)))
1256        }
1257        _ => None,
1258    }
1259}
1260
1261/// One hash's live in-flight fills plus the ONE per-hash outboard they all capture
1262/// into and serve from.
1263#[derive(Debug)]
1264struct HashEntry {
1265    /// The canonical per-hash outboard, adopted by every session for this hash.
1266    outboard: Arc<HashOutboard>,
1267    /// The live fills for this hash (each a distinct pull). Removed by pointer
1268    /// identity when a session's last observer leaves.
1269    sessions: Vec<Arc<FillSession>>,
1270}
1271
1272/// Range-aware coalescing registry: the live in-flight fills, keyed by hash, plus a
1273/// per-hash captured outboard. A serve-miss consults [`Self::claim`] to split its
1274/// range into an attach (covered by a running pull) and a remainder (fetch it), and
1275/// [`Self::range_still_live`] answers a parked reader's "keep awaiting or fail?".
1276/// Purely synchronous range math under a std `Mutex`; the lock is never held across
1277/// an await.
1278#[derive(Debug, Default)]
1279pub struct FillRegistry {
1280    map: StdMutex<HashMap<Hash, HashEntry>>,
1281    /// Cache metrics for the coalescing decisions this registry makes; `None`
1282    /// in unit fixtures.
1283    metrics: Option<Arc<crate::metrics::CacheMetrics>>,
1284}
1285
1286impl FillRegistry {
1287    /// An empty registry with no metrics handle (unit fixtures).
1288    #[must_use]
1289    pub fn new() -> Self {
1290        Self::with_metrics(None)
1291    }
1292
1293    /// An empty registry that reports its coalescing decisions to `metrics`.
1294    #[must_use]
1295    pub fn with_metrics(metrics: Option<Arc<crate::metrics::CacheMetrics>>) -> Self {
1296        Self {
1297            map: StdMutex::new(HashMap::new()),
1298            metrics,
1299        }
1300    }
1301
1302    /// Increment `session`'s observer count and mint its RAII lease. The caller
1303    /// MUST already hold the map lock — the increment shares the lock the lease's
1304    /// drop takes for its decrement, so attach and teardown are mutually exclusive.
1305    fn mint_lease(self: &Arc<Self>, session: &Arc<FillSession>, hash: Hash) -> ObserverLease {
1306        session.observers.fetch_add(1, Ordering::AcqRel);
1307        ObserverLease {
1308            session: Arc::clone(session),
1309            registry: Arc::downgrade(self),
1310            hash,
1311            armed: true,
1312        }
1313    }
1314
1315    /// Insert a freshly-built `session` for `hash` under the held map lock: adopt the
1316    /// hash's canonical outboard (or seed it from this session if it is the first),
1317    /// bind the session's registry back-link, and publish it. The session's
1318    /// `covered` must already be set by the caller.
1319    fn insert_session(
1320        self: &Arc<Self>,
1321        map: &mut HashMap<Hash, HashEntry>,
1322        hash: Hash,
1323        session: &Arc<FillSession>,
1324    ) {
1325        let entry = map.entry(hash).or_insert_with(|| HashEntry {
1326            outboard: session.outboard(),
1327            sessions: Vec::new(),
1328        });
1329        session.adopt_outboard(Arc::clone(&entry.outboard));
1330        session.bind_registry(Arc::downgrade(self), hash);
1331        entry.sessions.push(Arc::clone(session));
1332    }
1333
1334    /// Publish a new pull's `session` (already scoped via [`FillSession::set_covered`]
1335    /// to the bytes it will fetch) and return the owner lease (observer count starts
1336    /// at 1). The entry stays in the map until the owning pull is joined node-side.
1337    /// A test-facing helper; production registers via [`Self::claim`].
1338    pub fn register_fill(
1339        self: &Arc<Self>,
1340        hash: Hash,
1341        session: &Arc<FillSession>,
1342    ) -> ObserverLease {
1343        let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1344        self.insert_session(&mut map, hash, session);
1345        self.mint_lease(session, hash)
1346    }
1347
1348    /// The blob length of a LIVE in-flight fill for `hash`, if one runs — an
1349    /// ADVISORY peek the serve-miss path uses to skip its upstream header handshake
1350    /// when a pull it can coalesce onto already exists. Returns the `total_bytes` of
1351    /// the first non-dead session under `hash` (every session for one hash shares
1352    /// the blob's geometry, so any live one's length is THE length); `None` when no
1353    /// live fill exists (empty, or only dead sessions remain). Advisory only: a
1354    /// concurrent last-observer drop can retire the peeked session before the
1355    /// caller's atomic [`Self::claim`], which then owns a fresh pull and opens the
1356    /// leg late — so this NEVER replaces `claim`, it only removes the handshake in
1357    /// the common coalescing case.
1358    #[must_use]
1359    pub fn in_flight_total(&self, hash: Hash) -> Option<u64> {
1360        let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1361        map.get(&hash)?
1362            .sessions
1363            .iter()
1364            .find(|session| !session.is_dead())
1365            .map(|session| session.total_bytes())
1366    }
1367
1368    /// Route slot `slot`'s demand for content up to `end` to the live fill of `hash`
1369    /// that produces `byte`, and withdraw it from every other fill of the hash
1370    /// ([`DemandSlot::stand`]).
1371    fn route_demand(&self, hash: Hash, slot: u64, byte: u64, end: u64) {
1372        let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1373        if let Some(entry) = map.get(&hash) {
1374            for session in &entry.sessions {
1375                session.route_own_demand(slot, byte, end);
1376            }
1377        }
1378    }
1379
1380    /// Withdraw slot `slot`'s demand from every fill of `hash`.
1381    fn withdraw_demand(&self, hash: Hash, slot: u64) {
1382        let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1383        if let Some(entry) = map.get(&hash) {
1384            for session in &entry.sessions {
1385                session.serve_demand.withdraw(slot);
1386            }
1387        }
1388    }
1389
1390    /// Whether any LIVE fill of `hash` still covers `range`. A parked reader calls
1391    /// this (via [`FillSession::range_still_live`]) to decide "keep awaiting a
1392    /// capture / present-range advance, or fail because no pull will ever fill this".
1393    /// `true` iff some live session's `covered` intersects `range`.
1394    #[must_use]
1395    pub fn range_still_live(&self, hash: Hash, range: &ChunkRanges) -> bool {
1396        let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1397        match map.get(&hash) {
1398            Some(entry) => entry
1399                .sessions
1400                .iter()
1401                .any(|s| !s.is_dead() && !(&s.covered_ranges() & range).is_empty()),
1402            None => false,
1403        }
1404    }
1405
1406    /// Atomically decide attach/own/mixed AND register any new owner session, all
1407    /// under ONE map-lock acquisition. This closes the coalescing TOCTOU race a
1408    /// separate plan-then-register pair would leave open: between two lock
1409    /// acquisitions another thread can register, so two concurrent fresh misses for
1410    /// the same blob would both see an empty registry and both open a pull. `claim`
1411    /// holds the lock across the whole decision, so exactly one of two racing
1412    /// identical claims registers an owner and the other attaches.
1413    ///
1414    /// `R = align_range_clamped(offset, len, total)`: an end past `total` clamps to
1415    /// it, so a request whose claimed size grows still coalesces
1416    /// with a live fill instead of always falling to Owner. On an align error (a
1417    /// start at or past `total`) or empty `R`, the caller takes the Owner path (its
1418    /// own fetch surfaces any out-of-bounds error).
1419    /// Otherwise, over LIVE sessions **whose paid frontier reaches `R`'s aligned
1420    /// fetch start** (a session behind that frontier is excluded — attaching to it
1421    /// would park this request on a pull that advances only as the OTHER client
1422    /// pays, #2062; the exclusion trades duplicate egress for the overlap, counted
1423    /// as `fill_not_coalesced`), `covered_union = ⋃ covered`,
1424    /// `attach = R ∩ covered_union`, `remainder = R − covered_union`:
1425    /// - `attach` empty → OWNER of the whole `R`.
1426    /// - `remainder` empty (a live pull covers all of `R`) → ATTACH to the
1427    ///   largest-overlap session; `make_session` is NOT called.
1428    /// - both non-empty, and the largest-overlap sibling covers ALL of `attach`, and
1429    ///   `R − sibling.covered` is ONE contiguous span → MIXED: own a pull for that
1430    ///   remainder, attach the sibling for the overlap.
1431    /// - otherwise (a multi-sibling union, or an interior overlap splitting the
1432    ///   remainder in two) → OWNER of the whole `R`, the conservative fallback.
1433    ///   It never wedges; like the frontier exclusion above it can double-pull
1434    ///   an overlap a live sibling also covers.
1435    ///
1436    /// `make_session` runs under the lock only on an owning branch (never
1437    /// built-and-dropped on a pure attach); it does no await (a std lock) and only
1438    /// allocates, so holding the lock across it is safe.
1439    pub fn claim(
1440        self: &Arc<Self>,
1441        hash: Hash,
1442        offset: u64,
1443        len: u64,
1444        total: u64,
1445        make_session: impl FnOnce() -> Arc<FillSession>,
1446    ) -> FillClaim {
1447        let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
1448
1449        // R = the chunk ranges the request spans. An align error (a start at or
1450        // past the blob end) or an empty R has no coalescable range, so fall
1451        // straight to the Owner path. An end past the blob clamps instead.
1452        let (r, fetch_start) = match align_range_clamped(offset, len, total) {
1453            Ok(aligned) => (aligned.chunk_ranges().clone(), aligned.fetch_start()),
1454            Err(_) => (ChunkRanges::empty(), 0),
1455        };
1456
1457        if !r.is_empty() {
1458            let mut covered_union = ChunkRanges::empty();
1459            let mut best: Option<(Arc<FillSession>, ChunkRanges, u64)> = None;
1460            let mut not_coalesced = false;
1461            if let Some(entry) = map.get(&hash) {
1462                for session in &entry.sessions {
1463                    // Dead sessions (ended or cancelled) may never deliver, so their
1464                    // `covered` neither blocks a fresh pull nor is an attach target.
1465                    // Reading `is_dead` under the map lock pins it against a
1466                    // concurrent last-out lease drop (which fires `cancel()` under
1467                    // this same lock).
1468                    if session.is_dead() {
1469                        continue;
1470                    }
1471                    // A live fill advances only as ITS payer pays: the pull runs one
1472                    // credit window past `served_paid` and parks. A request that
1473                    // starts AHEAD of that frontier would park on it too, and an
1474                    // observer's payments extend a fill's paid prefix only once the
1475                    // prefix reaches the observer's start (`extend_served_from`).
1476                    // With the owner not paying — a header-only open (the CLI's
1477                    // multi-source size probe), or a stalled client — the
1478                    // observer starves until its own stall
1479                    // budget expires. So such a request is not coalesced: it OWNS
1480                    // its own span, and the two fills coexist under the hash. A
1481                    // request at or behind the frontier attaches; its payments
1482                    // extend the shared prefix at once.
1483                    //
1484                    // The cost is duplicate egress on the overlap, and only on the
1485                    // part of it not yet in the store: the owner's pull fills just
1486                    // the gaps the store is missing when it starts, so an overlap
1487                    // the live fill already landed is never fetched again. The
1488                    // part still to land is at most the live fill's credit window
1489                    // plus its in-flight draws, and only attaching could save it,
1490                    // which is what starves. So the rule stays, and a claim that
1491                    // skips an overlapping session is counted once.
1492                    if fetch_start > session.served_paid() {
1493                        if !(&r & &session.covered_ranges()).is_empty() {
1494                            not_coalesced = true;
1495                            tracing::debug!(
1496                                %hash,
1497                                fetch_start,
1498                                served_paid = session.served_paid(),
1499                                "fill not coalesced: request starts ahead of the live fill's paid frontier",
1500                            );
1501                        }
1502                        continue;
1503                    }
1504                    let covered = session.covered_ranges();
1505                    let overlap = &r & &covered;
1506                    if !overlap.is_empty() {
1507                        let span = chunk_span(&overlap);
1508                        if best
1509                            .as_ref()
1510                            .is_none_or(|(_, _, best_span)| span > *best_span)
1511                        {
1512                            best = Some((Arc::clone(session), covered.clone(), span));
1513                        }
1514                    }
1515                    covered_union |= covered;
1516                }
1517            }
1518
1519            if not_coalesced && let Some(m) = &self.metrics {
1520                m.fill_not_coalesced.inc();
1521            }
1522
1523            let attach = &r & &covered_union;
1524            let remainder = &r - &covered_union;
1525
1526            if !attach.is_empty()
1527                && let Some((sibling, sib_covered, _)) = best
1528            {
1529                if remainder.is_empty() {
1530                    // Whole R covered by live pulls → pure ATTACH to the sibling.
1531                    let lease = self.mint_lease(&sibling, hash);
1532                    return FillClaim::Attach {
1533                        session: sibling,
1534                        lease,
1535                    };
1536                }
1537                // Partial overlap. Coalesce only when ONE sibling covers all of the
1538                // overlap AND the bytes it does not cover form ONE contiguous span
1539                // the remainder pull can fetch as a single gap. Otherwise fall
1540                // through to a conservative whole-R owner (no double-pull, no wedge).
1541                let attach_covered_by_sibling = (&attach - &sib_covered).is_empty();
1542                let remainder_vs_sibling = &r - &sib_covered;
1543                if attach_covered_by_sibling
1544                    && let Some((rem_offset, rem_len)) =
1545                        contiguous_byte_span(&remainder_vs_sibling, total)
1546                {
1547                    let owner = make_session();
1548                    owner.set_covered(remainder_vs_sibling);
1549                    self.insert_session(&mut map, hash, &owner);
1550                    let owner_lease = self.mint_lease(&owner, hash);
1551                    let attach_lease = self.mint_lease(&sibling, hash);
1552                    return FillClaim::Mixed {
1553                        owner,
1554                        owner_lease,
1555                        attach: sibling,
1556                        attach_lease,
1557                        remainder_offset: rem_offset,
1558                        remainder_len: rem_len,
1559                    };
1560                }
1561            }
1562        }
1563
1564        // OWNER: no overlap, an empty/unalignable R, or the conservative fallback.
1565        // Scope the session to the WHOLE R (so a later identical claim attaches),
1566        // register + mint the owner lease under the SAME lock.
1567        let owner = make_session();
1568        owner.set_covered(r);
1569        self.insert_session(&mut map, hash, &owner);
1570        let lease = self.mint_lease(&owner, hash);
1571        FillClaim::Owner {
1572            session: owner,
1573            lease,
1574        }
1575    }
1576}
1577
1578#[cfg(test)]
1579#[allow(
1580    clippy::unwrap_used,
1581    clippy::expect_used,
1582    clippy::panic,
1583    clippy::indexing_slicing
1584)] // tests
1585mod tests;
1586
1587#[cfg(test)]
1588#[allow(
1589    clippy::unwrap_used,
1590    clippy::expect_used,
1591    clippy::panic,
1592    clippy::indexing_slicing
1593)] // tests
1594mod fill_registry_tests;