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