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;