Skip to main content

decdn_cache/
ranged_store.rs

1//! [`NodeRangedStore`]: the node-side [`RangedStore`] backend over the
2//! iroh-blobs cache (#1621).
3//!
4//! A thin, per-blob adapter over [`CacheEngine`]'s
5//! present/missing/admit/export API, adding no new storage
6//! logic. `finalize` relies on the node's own auto-promotion: once every
7//! chunk of a blob is present the store already reports it complete, so
8//! finalize is just that check, erroring [`RangedStoreError::Incomplete`]
9//! otherwise.
10
11use bytes::Bytes;
12use decdn_bao_range::{AlignedRange, RangedFuture, RangedStore, RangedStoreError};
13use iroh_blobs::Hash;
14
15use crate::engine::CacheEngine;
16use crate::serve_store::{EncodeStream, PresentRangeWatch, ServeStore};
17
18/// Node-side [`RangedStore`]: a per-blob view over the iroh-blobs cache.
19#[derive(Debug)]
20pub struct NodeRangedStore {
21    engine: CacheEngine,
22    hash: Hash,
23    total_bytes: u64,
24}
25
26impl NodeRangedStore {
27    /// Wrap `engine`'s view of `hash` (a `total_bytes`-byte blob) as a
28    /// [`RangedStore`]. `engine` is cheap to clone (`Arc`-backed), so this
29    /// holds it by value rather than behind an extra `Arc`.
30    #[must_use]
31    pub const fn new(engine: CacheEngine, hash: Hash, total_bytes: u64) -> Self {
32        Self {
33            engine,
34            hash,
35            total_bytes,
36        }
37    }
38
39    /// The blob hash this store is scoped to.
40    #[must_use]
41    pub const fn hash(&self) -> Hash {
42        self.hash
43    }
44
45    /// The wrapped [`CacheEngine`] handle. Lets a node-crate wrapper (#1621 Task
46    /// 10, `NodeAdmitStore`) reach the engine's `admit_bao_stream` without
47    /// duplicating the `(engine, hash, total_bytes)` triple it already holds.
48    #[must_use]
49    pub const fn engine(&self) -> &CacheEngine {
50        &self.engine
51    }
52
53    /// Does a deny/blacklist/evict/quarantine gate currently refuse this hash? A
54    /// pure in-memory check ([`CacheEngine::refuses`] reads in-memory sets and
55    /// never awaits a store hop), so the serve encoder can fold takedown into its per-leaf
56    /// presence check without a store round trip. Mirrors the guard
57    /// [`CacheEngine::present_ranges`] applies before it answers presence.
58    #[must_use]
59    pub fn refuses(&self) -> bool {
60        self.engine.refuses(self.hash)
61    }
62}
63
64fn backend<E: std::error::Error + Send + Sync + 'static>(e: E) -> RangedStoreError {
65    RangedStoreError::Backend(Box::new(e))
66}
67
68impl RangedStore for NodeRangedStore {
69    fn total_bytes(&self) -> u64 {
70        self.total_bytes
71    }
72
73    fn present_ranges(&self) -> RangedFuture<'_, bao_tree::ChunkRanges> {
74        Box::pin(async move {
75            let p = self
76                .engine
77                .present_ranges(self.hash)
78                .await
79                .map_err(backend)?;
80            Ok(p.chunk_ranges().clone())
81        })
82    }
83
84    fn missing_ranges(
85        &self,
86        byte_offset: u64,
87        byte_len: u64,
88    ) -> RangedFuture<'_, bao_tree::ChunkRanges> {
89        Box::pin(async move {
90            // Validate + align locally so a start at or past the blob end is a
91            // typed `Alignment` error, not an opaque backend fault (a bad start is
92            // an argument error, and the client backend classifies it the same
93            // way). An end past the blob clamps to it rather than erroring: the caller here is a pull leg driving a request whose
94            // claimed size can grow, so its gap computation must clamp exactly as
95            // the serve tiers do, or a signed `ok: true` response then faults
96            // fetching its own pull range.
97            let aligned =
98                decdn_bao_range::align_range_clamped(byte_offset, byte_len, self.total_bytes)?;
99            let present = self
100                .engine
101                .present_ranges(self.hash)
102                .await
103                .map_err(backend)?;
104            Ok(aligned.chunk_ranges().clone() - present.chunk_ranges())
105        })
106    }
107
108    fn admit(&self, range: AlignedRange, bao_bytes: Bytes) -> RangedFuture<'_, ()> {
109        Box::pin(async move {
110            self.engine
111                .admit_bao(self.hash, range.chunk_ranges().clone(), bao_bytes)
112                .await
113                .map_err(backend)
114        })
115    }
116
117    fn read(&self, byte_offset: u64, byte_len: u64) -> RangedFuture<'_, Bytes> {
118        Box::pin(async move {
119            // Reject a start at or past the blob end as a typed `Alignment` error
120            // before touching the store. An end past the blob clamps instead of
121            // erroring; `read` is byte-exact, so the clamped content length, not
122            // the raw, possibly-overshooting `byte_len`, is what gets forwarded
123            // to the backend.
124            let aligned =
125                decdn_bao_range::align_range_clamped(byte_offset, byte_len, self.total_bytes)?;
126            // `byte_len == 0` keeps its "to end" meaning: `export_range` resolves
127            // that dynamically against the store's own reported size, which a
128            // partial blob may not have yet. Only a non-zero, explicit `byte_len`
129            // clamps to the blob's end here.
130            let clamped_len = if byte_len == 0 {
131                0
132            } else {
133                aligned
134                    .blob_size()
135                    .saturating_sub(byte_offset)
136                    .min(byte_len)
137            };
138            self.engine
139                .export_range(self.hash, byte_offset, clamped_len)
140                .await
141                .map_err(backend)
142        })
143    }
144
145    fn is_complete(&self) -> RangedFuture<'_, bool> {
146        Box::pin(async move {
147            Ok(self
148                .engine
149                .present_ranges(self.hash)
150                .await
151                .map_err(backend)?
152                .is_complete())
153        })
154    }
155
156    fn finalize(&self) -> RangedFuture<'_, ()> {
157        Box::pin(async move {
158            if self
159                .engine
160                .present_ranges(self.hash)
161                .await
162                .map_err(backend)?
163                .is_complete()
164            {
165                Ok(())
166            } else {
167                Err(RangedStoreError::Incomplete)
168            }
169        })
170    }
171}
172
173impl ServeStore for NodeRangedStore {
174    fn observe(&self) -> RangedFuture<'_, PresentRangeWatch> {
175        Box::pin(async move {
176            self.engine
177                .observe_present_ranges(self.hash)
178                .await
179                .map_err(backend)
180        })
181    }
182
183    fn encode_range(&self, byte_offset: u64, byte_len: u64) -> RangedFuture<'_, EncodeStream> {
184        Box::pin(async move {
185            let stream = self
186                .engine
187                .export_bao_range_stream(self.hash, byte_offset, byte_len, self.total_bytes)
188                .await
189                .map_err(backend)?;
190            Ok(Box::pin(futures_util::StreamExt::map(stream, |item| {
191                item.map_err(backend)
192            })) as EncodeStream)
193        })
194    }
195}
196
197#[cfg(test)]
198#[allow(clippy::unwrap_used)] // tests
199mod tests;