Skip to main content

urma_node/
retrieval.rs

1//! On-demand content acquisition. Sources supply untrusted packages; the local
2//! engine supplies the independently selected chain view. Missing peers do not
3//! imply missing chain data: a known locator can fall back to full witness blocks.
4use crate::{Error, engine::Engine, model::RecordPackage};
5use bitcoin::Txid;
6use std::{
7    collections::BTreeMap,
8    path::Path,
9    sync::atomic::{AtomicBool, Ordering},
10    time::{Duration, Instant},
11};
12use urma_core::multipart::{Geometry, VerifiedRecord};
13use urma_runtime::multipart::{
14    Candidate, FetchError, MultipartSource, RecordRequest, RecoveredObject, RecoveryError,
15    RecoveryLimits,
16};
17
18/// A data source, not a chain verifier or completeness authority. Implementations
19/// must bound their own I/O; the caller's deadline is checked between callbacks.
20pub trait RecordSource {
21    fn fetch(&self, txid: Txid) -> Result<Option<RecordPackage>, Error>;
22}
23
24/// Explicit acquisition budgets, independent of protocol geometry limits.
25#[derive(Clone, Copy)]
26pub struct FetchLimits {
27    pub sources: usize,
28    pub timeout: Duration,
29}
30impl Default for FetchLimits {
31    fn default() -> Self {
32        Self {
33            sources: 4,
34            timeout: Duration::from_secs(30),
35        }
36    }
37}
38
39fn verify_cached(
40    engine: &Engine,
41    expected: Txid,
42    package: &RecordPackage,
43) -> Result<VerifiedRecord, Error> {
44    let chain = engine.chain()?;
45    let reveal = urma_chain::decode::transaction(&package.reveal.transaction, chain)
46        .map_err(|e| Error::Invalid(e.to_string()))?;
47    let commit = urma_chain::decode::transaction(&package.commit, chain)
48        .map_err(|e| Error::Invalid(e.to_string()))?;
49    VerifiedRecord::verify(expected, &reveal, &commit).map_err(|e| Error::Invalid(e.to_string()))
50}
51
52/// Fetch one structurally valid V0 record. This does not check a signed parent
53/// reference hash: multipart reconstruction performs that check AFTER proof.
54/// A returned record is relative to the local selected view at retrieval time.
55pub fn fetch_record(
56    engine: &Engine,
57    txid: Txid,
58    sources: &[&dyn RecordSource],
59    limits: FetchLimits,
60) -> Result<Option<VerifiedRecord>, Error> {
61    let began = Instant::now();
62    let deadline = began
63        .checked_add(limits.timeout)
64        .ok_or_else(|| Error::Invalid("record deadline overflow".into()))?;
65    crate::engine::check_deadline(deadline)?;
66    if let Some(record) = engine.record(txid)? {
67        let verified = verify_cached(engine, txid, &record.package)?;
68        crate::engine::check_deadline(deadline)?;
69        return Ok(Some(verified));
70    }
71    let mut last = None;
72    for source in sources.iter().take(limits.sources) {
73        if began.elapsed() >= limits.timeout {
74            return Err(Error::Busy("record acquisition deadline".into()));
75        }
76        let package = match source.fetch(txid) {
77            Ok(Some(package)) => package,
78            Ok(None) => continue,
79            Err(error) => {
80                last = Some(error);
81                continue;
82            }
83        };
84        // A package that arrives after the deadline is still verified and
85        // persisted: acquisition is resumable, so fetched work is never discarded.
86        let late = began.elapsed() >= limits.timeout;
87        // Bind the requested identifier before admitting any package. Valid data
88        // for an unrelated TXID cannot satisfy this request or pollute its cache.
89        match verify_cached(engine, txid, &package) {
90            Err(error) if late => return Err(error),
91            Err(error) => {
92                last = Some(error);
93                continue;
94            }
95            Ok(verified) => {
96                match engine.admit_record_before(package, (!late).then_some(deadline)) {
97                    Ok(_) => return Ok(Some(verified)),
98                    Err(error) => {
99                        // A peer's stale block claim must not veto another source.
100                        // Admission still checks tickets; recover checks its epoch.
101                        last = Some(error);
102                    }
103                }
104            }
105        }
106    }
107    if began.elapsed() >= limits.timeout {
108        return Err(Error::Busy("record acquisition deadline".into()));
109    }
110    let fallback = engine.retrieve_located_record_before(txid, deadline);
111    crate::engine::check_deadline(deadline)?;
112    match fallback {
113        Ok(Some(record)) => {
114            if began.elapsed() >= limits.timeout {
115                return Err(Error::Busy("record acquisition deadline".into()));
116            }
117            let verified = verify_cached(engine, txid, &record.package)?;
118            crate::engine::check_deadline(deadline)?;
119            Ok(Some(verified))
120        }
121        Err(error) => Err(error),
122        Ok(None) => match last {
123            Some(error) => Err(error),
124            None => Ok(None),
125        },
126    }
127}
128
129/// How one object acquisition spends time. `slice` bounds network fetches only:
130/// records already stored re-verify outside it, and a fetch is started only
131/// while network time remains, so every attempt that reaches a missing record
132/// fetches at least one. Each fetch is bounded by its own `fetch` limits.
133#[derive(Clone, Copy)]
134pub(crate) struct Pace {
135    limits: RecoveryLimits,
136    slice: Duration,
137    fetch: FetchLimits,
138}
139
140impl Pace {
141    fn on_demand(limits: RecoveryLimits) -> Self {
142        Self {
143            limits,
144            slice: limits.timeout,
145            fetch: FetchLimits {
146                sources: limits.attempts.max(1) as usize,
147                timeout: limits.timeout,
148            },
149        }
150    }
151
152    /// Background unpruned acquisition: no size budget beyond the protocol
153    /// geometry and no overall deadline; only network time is sliced. Every
154    /// supplied source is asked in order, so a record one peer lacks or serves
155    /// badly still comes from the next; the caller bounds the source list.
156    pub(crate) fn background(slice: Duration) -> Self {
157        Self {
158            limits: RecoveryLimits {
159                max_payload_bytes: Geometry::MAX_OBJECT_BYTES,
160                max_nodes: Geometry::MAX_NODES,
161                attempts: 1,
162                timeout: RecoveryLimits::TIMEOUT,
163            },
164            slice,
165            fetch: FetchLimits {
166                sources: usize::MAX,
167                ..FetchLimits::default()
168            },
169        }
170    }
171}
172
173/// A completed object, or an attempt whose network slice ran out. A sliced
174/// attempt keeps every record it fetched; the next attempt continues from them.
175pub(crate) enum Recovery {
176    Complete(RecoveredObject),
177    Sliced(RecoveryError),
178}
179
180fn completed(recovery: Recovery) -> Result<RecoveredObject, Error> {
181    match recovery {
182        Recovery::Complete(object) => Ok(object),
183        Recovery::Sliced(cause) => {
184            Err(Error::Busy(format!("object acquisition deadline: {cause}")))
185        }
186    }
187}
188
189/// Recover an object without exporting partial output. This reuses the existing
190/// root/leaf/data signed-reference and final length/digest checks. Reorg during
191/// recovery invalidates the result; cached immutable bytes remain reusable.
192/// The scratch directory belongs to the caller. No plaintext decryption occurs.
193pub fn recover(
194    engine: &Engine,
195    root: Txid,
196    sources: &[&dyn RecordSource],
197    limits: RecoveryLimits,
198    scratch: &Path,
199) -> Result<RecoveredObject, Error> {
200    completed(recover_inner(
201        engine,
202        root,
203        sources,
204        Pace::on_demand(limits),
205        scratch,
206        false,
207        &AtomicBool::new(false),
208    )?)
209}
210
211/// Recover and protect all exact members in the same completion transaction.
212/// On failure, no new completion or pin is published; independently verified
213/// partial cache entries may remain. No user decryption key is required.
214pub fn recover_and_pin(
215    engine: &Engine,
216    root: Txid,
217    sources: &[&dyn RecordSource],
218    limits: RecoveryLimits,
219    scratch: &Path,
220) -> Result<RecoveredObject, Error> {
221    completed(recover_inner(
222        engine,
223        root,
224        sources,
225        Pace::on_demand(limits),
226        scratch,
227        true,
228        &AtomicBool::new(false),
229    )?)
230}
231
232pub(crate) fn recover_inner(
233    engine: &Engine,
234    root: Txid,
235    sources: &[&dyn RecordSource],
236    pace: Pace,
237    scratch: &Path,
238    pin: bool,
239    cancelled: &AtomicBool,
240) -> Result<Recovery, Error> {
241    check_cancelled(cancelled)?;
242    let generation = engine.status()?.generation;
243    let began = Instant::now();
244    let deadline = began
245        .checked_add(pace.limits.timeout)
246        .ok_or_else(|| Error::Invalid("object deadline overflow".into()))?;
247    let root = fetch_record(engine, root, sources, pace.fetch)?
248        .ok_or_else(|| Error::Invalid("root is not available from the supplied sources".into()))?;
249    check_cancelled(cancelled)?;
250    let mut remaining = pace.limits;
251    remaining.timeout = pace.limits.timeout.saturating_sub(began.elapsed());
252    let mut adapter = Adapter {
253        engine,
254        sources,
255        pace,
256        began,
257        network: Duration::ZERO,
258        sliced: false,
259        members: BTreeMap::from([(root.txid(), crate::archive::ObjectMember::from(&root))]),
260        cancelled,
261    };
262    let object = match urma_runtime::multipart::reconstruct(&root, &mut adapter, remaining, scratch)
263    {
264        Ok(object) => object,
265        Err(cause) if adapter.sliced => return Ok(Recovery::Sliced(cause)),
266        Err(error) => return Err(Error::Source(Box::new(error))),
267    };
268    check_cancelled(cancelled)?;
269    // Completion atomically rechecks the epoch and retained identities. A
270    // concurrent prune may refuse completion, never create false availability.
271    engine.complete_object(
272        &object,
273        generation,
274        &adapter.members.into_values().collect::<Vec<_>>(),
275        pin,
276        deadline,
277        cancelled,
278    )?;
279    Ok(Recovery::Complete(object))
280}
281
282fn source_error(message: String) -> FetchError {
283    FetchError::Source(urma_runtime::error::Error::Io(std::io::Error::other(
284        message,
285    )))
286}
287
288struct Adapter<'a> {
289    engine: &'a Engine,
290    sources: &'a [&'a dyn RecordSource],
291    pace: Pace,
292    began: Instant,
293    network: Duration,
294    sliced: bool,
295    members: BTreeMap<Txid, crate::archive::ObjectMember>,
296    cancelled: &'a AtomicBool,
297}
298
299impl Adapter<'_> {
300    /// Stored records re-verify without spending the network slice.
301    fn stored(&self, txid: Txid) -> Result<Option<VerifiedRecord>, Error> {
302        self.engine
303            .record(txid)?
304            .map(|record| verify_cached(self.engine, txid, &record.package))
305            .transpose()
306    }
307
308    fn fetch_missing(&mut self, txid: Txid) -> Result<VerifiedRecord, FetchError> {
309        if self.network >= self.pace.slice {
310            self.sliced = true;
311            return Err(source_error("object acquisition slice ended".into()));
312        }
313        let timeout = self.pace.fetch.timeout.min(
314            self.pace
315                .limits
316                .timeout
317                .saturating_sub(self.began.elapsed()),
318        );
319        let started = Instant::now();
320        // Source-level proof failures have already tried the bounded source list.
321        // A fully authenticated wrong reference hash remains terminal in reconstruct.
322        let fetched = fetch_record(
323            self.engine,
324            txid,
325            self.sources,
326            FetchLimits {
327                timeout,
328                ..self.pace.fetch
329            },
330        );
331        self.network += started.elapsed();
332        match fetched {
333            Ok(Some(record)) => Ok(record),
334            Ok(None) => Err(FetchError::Unavailable),
335            Err(error) => Err(source_error(error.to_string())),
336        }
337    }
338}
339
340impl MultipartSource for Adapter<'_> {
341    fn fetch(
342        &mut self,
343        request: &RecordRequest,
344        _rejected: &[String],
345    ) -> Result<Candidate, FetchError> {
346        if self.cancelled.load(Ordering::Acquire) {
347            return Err(source_error("object retention cancelled".into()));
348        }
349        let txid = request.reference.txid;
350        let record = match self.stored(txid) {
351            Ok(Some(record)) => record,
352            Ok(None) => self.fetch_missing(txid)?,
353            Err(error) => return Err(source_error(error.to_string())),
354        };
355        self.members
356            .insert(record.txid(), crate::archive::ObjectMember::from(&record));
357        Ok(Candidate {
358            origin: "node-selected-view".into(),
359            record,
360        })
361    }
362}
363
364fn check_cancelled(cancelled: &AtomicBool) -> Result<(), Error> {
365    if cancelled.load(Ordering::Acquire) {
366        Err(Error::Busy("object retention cancelled".into()))
367    } else {
368        Ok(())
369    }
370}