Skip to main content

decdn_client/
sink.rs

1//! Streaming-decode support types for the paid pull path (#1120, ADR 038).
2//!
3//! [`PullReader`] adapts a live [`UpstreamPull`] to the bao decoder's
4//! byte-reader trait while [`StashedFault`] preserves the pull's typed faults
5//! (stall, refusal, voucher rejection) so a misbehaving peer stays scoreable —
6//! the decoder itself can only say "the bytes stopped arriving". The streaming
7//! decode loops that consume these are
8//! [`crate::ranged_store::ClientRangedStore::ingest_stream`], the node's cache
9//! admit, and (`test-util`) the in-memory `stream_fetch*` wrappers.
10//!
11//! [`content_paid_frontier`] maps a paid WIRE-byte watermark back to the
12//! content-byte frontier it covers, for the resume-at-paid-frontier gate.
13//!
14//! [`BlobCache`] is the injected `(hash, range)` cache the
15//! [`Streamer`](crate::Streamer) consults and fills. It is pure — it names no
16//! blob store — which is what keeps `decdn-client` `iroh-blobs`-free; the
17//! default cache is the no-op [`NoCache`].
18
19use bao_tree::io::DecodeError;
20use bytes::{Bytes, BytesMut};
21use decdn_bao_range::{CHUNK_GROUP_BYTES, align_range, bao_encoded_size};
22
23use crate::{HashMismatch, UpstreamPull};
24
25/// An [`iroh_io::AsyncStreamReader`] over a live [`UpstreamPull`], so the bao
26/// decoder can pull wire bytes on demand instead of being handed a complete
27/// buffer.
28///
29/// A reader never asks past the leg's wire end. The node sends its
30/// `StreamEnd` only after the closing voucher, and that voucher goes out in
31/// [`UpstreamPull::finish`] once the decode is done, so a read past the end
32/// waits on a node that waits on the payer. The bao decoder reads exactly the
33/// leg's items and no further.
34///
35/// # Why the error is stashed rather than propagated
36///
37/// The reader trait speaks `io::Error`, but `next_chunk` produces the typed
38/// errors the reputation layer classifies on (`PullStalled`, `PullTimeout`,
39/// `UpstreamRefused`, `UpstreamVoucherRejected`). Collapsing those into an
40/// `io::Error` would silently downgrade, say, a peer's mid-stream refusal into
41/// an anonymous decode failure and stop it being scored. So the original
42/// `anyhow::Error` is parked in `fault` and the trait returns a placeholder; the
43/// decode loop that owns the reader
44/// ([`crate::ranged_store::ClientRangedStore::ingest_stream`], or the `test-util`
45/// in-memory decoder) checks `fault` first and returns the real error, using the
46/// decoder's complaint only when there is no parked fault.
47#[derive(Debug)]
48#[doc(hidden)]
49pub struct PullReader {
50    pull: UpstreamPull,
51    /// Wire bytes received but not yet consumed by the decoder.
52    buf: BytesMut,
53    /// Wire bytes handed to the decoder so far. The decoder checks each item
54    /// it reads (a parent pair or a leaf) against the root before it reads
55    /// the next one, so every byte handed out before the current read has
56    /// verified. Each read reports that count to the pull, which pays for it.
57    consumed: u64,
58    /// `StreamEnd` seen — no more chunks will arrive.
59    ended: bool,
60    /// The first `next_chunk` fault, preserved with its type. See the type docs.
61    fault: Option<anyhow::Error>,
62}
63
64impl PullReader {
65    pub(crate) fn new(pull: UpstreamPull) -> Self {
66        Self {
67            pull,
68            buf: BytesMut::new(),
69            consumed: 0,
70            ended: false,
71            fault: None,
72        }
73    }
74
75    /// Recover the inner [`UpstreamPull`] once the decode loop is done with this
76    /// reader, so [`crate::source::BlobSource::finish`] (or the `test-util`
77    /// in-memory wrapper) can drain it to the stream end and recover the acked
78    /// voucher watermark.
79    ///
80    /// Any buffered-but-unconsumed wire bytes and a parked fault are dropped
81    /// silently: a caller only calls this after the decode loop reached a clean
82    /// end (`Done`, or stopped at a steal's split). The decoder checked every
83    /// item it read on the way there, so every consumed byte is reported
84    /// verified, and `finish`/`stop` pay for it.
85    pub(crate) fn into_inner(mut self) -> UpstreamPull {
86        self.pull.mark_verified(self.consumed);
87        self.pull
88    }
89
90    /// Pull chunks until `buf` holds `n` bytes, the stream ends, or a fault is
91    /// parked. The decoder asks for more only once it has checked the last item
92    /// it read, so everything consumed so far is reported verified first, and
93    /// the pull pays for it before it waits on the node for more.
94    async fn fill_to(&mut self, n: usize) {
95        self.pull.mark_verified(self.consumed);
96        while !self.ended && self.fault.is_none() && self.buf.len() < n {
97            match self.pull.next_chunk().await {
98                Ok(Some(chunk)) => self.buf.extend_from_slice(&chunk),
99                Ok(None) => self.ended = true,
100                Err(e) => self.fault = Some(e),
101            }
102        }
103    }
104
105    /// Take up to `n` buffered bytes. A short read is the decoder's EOF signal,
106    /// which it turns into `ParentNotFound`/`LeafNotFound` — the driver maps
107    /// that to a truncation, not to corruption.
108    fn take(&mut self, n: usize) -> Bytes {
109        let take = self.buf.len().min(n);
110        self.consumed = self.consumed.saturating_add(take as u64);
111        self.buf.split_to(take).freeze()
112    }
113}
114
115impl iroh_io::AsyncStreamReader for PullReader {
116    async fn read_bytes(&mut self, len: usize) -> std::io::Result<Bytes> {
117        self.fill_to(len).await;
118        Ok(self.take(len))
119    }
120
121    async fn read<const L: usize>(&mut self) -> std::io::Result<[u8; L]> {
122        self.fill_to(L).await;
123        let got = self.take(L);
124        let mut out = [0u8; L];
125        if got.len() < L {
126            return Err(std::io::Error::new(
127                std::io::ErrorKind::UnexpectedEof,
128                "paid pull ended before the bao decoder's next fixed-size read",
129            ));
130        }
131        out.copy_from_slice(&got);
132        Ok(out)
133    }
134}
135
136/// A reader that may be holding back a richer error than the one the bao decoder
137/// will report.
138///
139/// The decoder can only say "the bytes stopped arriving". When the reader knows
140/// WHY — a stalled peer, a mid-stream refusal, a rejected voucher — that reason
141/// is what the caller needs in order to score the peer, so it wins.
142///
143/// Public because it is a supertrait of [`crate::source::BaoRangeReader`]: a
144/// [`crate::source::BlobSource`]'s reader must preserve the pull's typed faults
145/// so the gap-driven driver (#1608) surfaces them for scoring.
146#[doc(hidden)]
147pub trait StashedFault {
148    /// Take the parked typed fault, if any. Returns `None` on a source whose
149    /// bytes carry the whole story (an in-memory buffer, a scripted double).
150    fn take_fault(&mut self) -> Option<anyhow::Error>;
151}
152
153impl StashedFault for PullReader {
154    fn take_fault(&mut self) -> Option<anyhow::Error> {
155        self.fault.take()
156    }
157}
158
159/// A plain in-memory wire buffer has no out-of-band failure mode: whatever the
160/// decoder says about it is the whole story.
161///
162/// Test-only, and gated to say so: it exists purely so the streaming ingest
163/// loop can be driven from a fixed wire buffer. A test using it exercises the
164/// `None` branch, where the decoder's error is the whole story, so it must not
165/// be the only coverage of the fault-precedence rule.
166#[cfg(test)]
167impl StashedFault for Bytes {
168    fn take_fault(&mut self) -> Option<anyhow::Error> {
169        None
170    }
171}
172
173/// Split a bao decode failure into "the peer lied" and "the stream ended early",
174/// the taxonomy every decoding consumer and the node-side tee share (ADR 038).
175/// Only a hash mismatch is provably corruption; a truncated stream is
176/// transport-class and must not tar the peer as a liar.
177///
178/// `pub(crate)`: [`crate::ranged_store::ClientRangedStore::ingest_stream`] and the
179/// in-memory `stream_fetch*` decoder reuse this exact classification rather than
180/// duplicating the match.
181pub(crate) fn classify_decode_error(err: DecodeError) -> anyhow::Error {
182    match err {
183        DecodeError::ParentHashMismatch(_) | DecodeError::LeafHashMismatch(_) => {
184            anyhow::Error::new(HashMismatch)
185        }
186        DecodeError::Io(io_err) => anyhow::Error::new(io_err).context("bao decode read failed"),
187        not_found @ (DecodeError::ParentNotFound(_) | DecodeError::LeafNotFound(_)) => {
188            anyhow::anyhow!("bao stream truncated mid-tree: {not_found}")
189        }
190    }
191}
192
193/// Map a **paid wire-byte watermark** back to the **content-byte frontier** it
194/// covers, for a stream that began at content offset `fetch_start` of a
195/// `total_bytes`-byte blob (#1497).
196///
197/// Vouchers pay for **wire** bytes — bao-encoded content PLUS interleaved proof
198/// (ADR 038 §Payment metering) — so `paid_wire` (the channel-cumulative bytes an
199/// ACCEPTED voucher covered, minus this stream's baseline) is a WIRE quantity.
200/// Resuming at `fetch_start + paid_wire` would treat it as a CONTENT offset and,
201/// since wire ≥ content, overshoot the true content paid-frontier: the content
202/// span between the real frontier and that overshoot would never be billed on the
203/// resumed leg (a systematic under-pay), and a large overshoot can even exceed the
204/// on-disk content length. This maps `paid_wire` back into content space instead.
205///
206/// # What it returns, and why it never under-pays
207///
208/// The result is the **largest chunk-group boundary `C` in `[fetch_start,
209/// total_bytes]` whose bao wire cost is fully within `paid_wire`** — i.e. the
210/// greatest `C` with `wire([fetch_start, C)) <= paid_wire`, where `wire(range)` is
211/// [`bao_encoded_size`] over the real `total_bytes` tree (the identical walk the
212/// serve-side encoder and `aligned_wire_len` use, so it equals the bytes actually
213/// on the wire, byte-for-byte).
214///
215/// Because a bao pre-order stream emits every chunk group's proof-and-data before
216/// it descends into any later subtree, the wire position at which content offset
217/// `C` finishes is exactly `wire([fetch_start, C))` (same tree, same pre-order
218/// walk, same node set up to `C`; later subtrees' interior nodes are emitted only
219/// after `C`). So `wire([fetch_start, C)) <= paid_wire` proves every byte needed
220/// to deliver content `[fetch_start, C)` landed inside the paid wire prefix — that
221/// content is paid for. Resuming at `C` therefore re-fetches only `[C, end)`;
222/// nothing past a paid frontier is skipped, so it **cannot under-pay**. The only
223/// slack is the sub-group tail between `C` and the true (possibly mid-group) paid
224/// frontier: that content is re-fetched and re-paid once on the next leg, a
225/// bounded over-pay of **strictly less than one chunk group** (16 KiB).
226///
227/// `fetch_start` MUST be a chunk-group boundary (every resume offset is — it comes
228/// from a checkpointed group boundary or a previous call to this function). A `paid_wire` of
229/// `0`, or a `total_bytes` of `0`, yields `fetch_start` (nothing paid on this leg
230/// ⇒ resume where it began).
231#[must_use]
232#[doc(hidden)]
233pub fn content_paid_frontier(fetch_start: u64, total_bytes: u64, paid_wire: u64) -> u64 {
234    // Wire cost of content `[fetch_start, c)` over the full `total_bytes` tree.
235    // `align_range` bound-checks and snaps to groups; `wire_len` is
236    // `bao_encoded_size(total_bytes, ranges)` — the exact on-wire byte count. An
237    // out-of-range `c` (only reachable if `fetch_start >= total_bytes`, which a
238    // live mid-fetch never is) maps to `u64::MAX`, excluding it from the search.
239    let wire_to = |c: u64| -> u64 {
240        if c <= fetch_start {
241            return 0;
242        }
243        let len = c.saturating_sub(fetch_start);
244        align_range(fetch_start, len, total_bytes).map_or(u64::MAX, |r| {
245            bao_encoded_size(total_bytes, r.chunk_ranges())
246        })
247    };
248
249    let span = total_bytes.saturating_sub(fetch_start);
250    if paid_wire == 0 || span == 0 {
251        return fetch_start.min(total_bytes);
252    }
253    // Candidate content frontiers are the group boundaries `C(k) = fetch_start +
254    // k·GROUP`, capped at `total_bytes` (the final partial group). `wire_to` is
255    // monotonic non-decreasing in `C`, so binary-search the largest `k` that fits.
256    let c_of = |k: u64| -> u64 {
257        k.saturating_mul(CHUNK_GROUP_BYTES)
258            .saturating_add(fetch_start)
259            .min(total_bytes)
260    };
261    let k_max = span.div_ceil(CHUNK_GROUP_BYTES);
262    let (mut lo, mut hi, mut best) = (0u64, k_max, 0u64);
263    while lo <= hi {
264        let mid = lo.saturating_add((hi - lo) / 2);
265        if wire_to(c_of(mid)) <= paid_wire {
266            best = mid;
267            lo = mid.saturating_add(1);
268        } else if mid == 0 {
269            break;
270        } else {
271            hi = mid.saturating_sub(1);
272        }
273    }
274    c_of(best)
275}
276
277/// A boxed, `Send` future returned by the output/cache trait methods below —
278/// the same boxed-future async-trait shape [`crate::source::SourceFuture`] uses
279/// on the input side, so a sink or cache can be held as `&dyn`.
280pub type SinkFuture<'a, T> =
281    core::pin::Pin<Box<dyn core::future::Future<Output = anyhow::Result<T>> + Send + 'a>>;
282
283/// An injected content cache the `Streamer` consults and fills, keyed by
284/// `(hash, offset)` over content bytes.
285///
286/// Pure by design: the trait names no blob store, which is what keeps
287/// `decdn-client` `iroh-blobs`-free — a blob-store-backed implementation lives
288/// ABOVE this crate and is injected. The default is the no-op [`NoCache`] (never
289/// hits, never stores), so a caller that wants no revisit-reuse pays nothing.
290///
291/// On a clean finish the `Streamer` tees the whole verified blob to
292/// [`put`](BlobCache::put). On a revisit it asks [`get`](BlobCache::get) for the
293/// whole blob: a whole-blob hit is served straight from the cache with no fetch,
294/// and anything less is treated as a miss and refetched in full — the store's
295/// gap-driven resume, not the cache, is what avoids re-pulling a partial prefix
296/// today. (Serving a cached PARTIAL prefix and fetching only the complement is a
297/// planned enhancement; [`get`](BlobCache::get) already reports a partial hold as
298/// a miss so an implementer need not stitch fragments.)
299pub trait BlobCache: Send + Sync {
300    /// Whether this cache actually stores what it is given. A caller that would
301    /// have to materialize the whole blob just to tee it here (the `Streamer`'s
302    /// revisit tee) skips that work — and the memory it costs — when this is
303    /// `false`. Defaults to `true`; a no-op cache overrides it.
304    fn caches(&self) -> bool {
305        true
306    }
307
308    /// Return cached content bytes for exactly `[offset, offset + len)` of
309    /// `hash`, or `None` on a miss. A partial hold is a miss — the caller fetches
310    /// the whole range rather than stitching a fragment.
311    ///
312    /// # Errors
313    ///
314    /// Whatever the backing cache raises. A miss is `Ok(None)`, not an error.
315    fn get(&self, hash: [u8; 32], offset: u64, len: u64) -> SinkFuture<'_, Option<Bytes>>;
316
317    /// Store verified content `bytes` as `[offset, offset + bytes.len())` of
318    /// `hash`. A cache that does not want the range may drop it.
319    ///
320    /// # Errors
321    ///
322    /// Whatever the backing cache raises.
323    fn put(&self, hash: [u8; 32], offset: u64, bytes: Bytes) -> SinkFuture<'_, ()>;
324}
325
326/// The default [`BlobCache`]: never caches. Every [`get`](BlobCache::get) misses
327/// and every [`put`](BlobCache::put) discards, so a `Streamer` built with it
328/// always fetches the whole range and stores nothing. A caller that wants
329/// revisit-reuse injects a real cache above `decdn-client`.
330#[derive(Clone, Copy, Debug, Default)]
331pub struct NoCache;
332
333impl BlobCache for NoCache {
334    fn caches(&self) -> bool {
335        false
336    }
337
338    fn get(&self, _hash: [u8; 32], _offset: u64, _len: u64) -> SinkFuture<'_, Option<Bytes>> {
339        Box::pin(async { Ok(None) })
340    }
341
342    fn put(&self, _hash: [u8; 32], _offset: u64, _bytes: Bytes) -> SinkFuture<'_, ()> {
343        Box::pin(async { Ok(()) })
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Test double: an in-memory BlobCache for the `Streamer` and cache unit tests.
349// ---------------------------------------------------------------------------
350
351/// The exact key an in-memory [`BlobCache`] entry hits on: `(hash, offset, len)`.
352#[cfg(test)]
353type CacheKey = ([u8; 32], u64, u64);
354
355/// An in-memory [`BlobCache`] that stores each `put` under its exact
356/// `(hash, offset, len)` key and hits only on an identical key. The minimum a
357/// `Streamer` cache test needs to prove complement-only fetching.
358#[cfg(test)]
359#[derive(Debug, Default)]
360pub(crate) struct MemoryBlobCache {
361    entries: std::sync::Mutex<std::collections::HashMap<CacheKey, Bytes>>,
362}
363
364#[cfg(test)]
365impl MemoryBlobCache {
366    /// An empty cache.
367    #[must_use]
368    pub(crate) fn new() -> Self {
369        Self::default()
370    }
371}
372
373#[cfg(test)]
374impl BlobCache for MemoryBlobCache {
375    fn get(&self, hash: [u8; 32], offset: u64, len: u64) -> SinkFuture<'_, Option<Bytes>> {
376        Box::pin(async move {
377            let entries = self
378                .entries
379                .lock()
380                .map_err(|_| anyhow::anyhow!("memory cache lock poisoned"))?;
381            Ok(entries.get(&(hash, offset, len)).cloned())
382        })
383    }
384
385    fn put(&self, hash: [u8; 32], offset: u64, bytes: Bytes) -> SinkFuture<'_, ()> {
386        Box::pin(async move {
387            let len = u64::try_from(bytes.len())
388                .map_err(|_| anyhow::anyhow!("cached range length exceeds u64"))?;
389            let mut entries = self
390                .entries
391                .lock()
392                .map_err(|_| anyhow::anyhow!("memory cache lock poisoned"))?;
393            entries.insert((hash, offset, len), bytes);
394            Ok(())
395        })
396    }
397}
398
399#[cfg(test)]
400mod tests;