Skip to main content

decdn_client/
recovery.rs

1//! The funding recovery step at the exhausted candidate set (ADR 003 § Funding
2//! recovery, ADR 039 § Failure handling).
3//!
4//! On a node's refusal, a buyer adds funds only here: the low-water refill at a
5//! lane build reads only the buyer's own pool. The acquire loop and the first
6//! open reach the step when the candidate set is exhausted, a fresh discovery
7//! found nothing new, and at least one candidate refused `Unfunded` or is priced out at the
8//! current deposit. The step runs through the caller's [`Funder`], under the
9//! fetch's [`RecoveryGate`]: the first step of a fetch is always allowed, and a
10//! further step only after at least one new BLAKE3-verified byte. A caller that
11//! reaches the gate after a sibling's step answered its exhausted set makes one
12//! more pass on that step, and takes none of its own. A node cannot
13//! fake a verified byte, so a node that lies with `Unfunded` costs the buyer at
14//! most one step.
15
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Mutex, PoisonError};
18use std::time::Duration;
19
20use alloy::primitives::U256;
21use tokio::time::Instant;
22
23use crate::source::{Funder, PoolReplaced, Recovery};
24
25/// How long after a funding recovery step's top-up a source's `Unfunded`
26/// refusal is read as its stale view of the deposit rather than a refusal at
27/// the new deposit.
28///
29/// A serving node's chain watcher can lag the `topUp` it has to observe, so
30/// its pre-serve deposit gate still sees the old deposit and refuses the
31/// re-open. Retrying an open is money-safe: it sends no voucher. 15 s sits
32/// comfortably above the daemon's chain-event poll cadence.
33pub const RECOVERY_SETTLE: Duration = Duration::from_secs(15);
34
35/// How long a source that refuses during the settle window waits before it is
36/// asked again.
37pub(crate) const SETTLE_STEP: Duration = Duration::from_millis(500);
38
39/// One fetch's funding recovery state: the progress rule, the step that
40/// replaced the pool, and the settle window after a top-up.
41///
42/// One gate spans one fetch. A bundle pull is one fetch for every entry, so
43/// its entries share one gate; a command that runs its remaining work again
44/// against a replaced pool keeps the gate for that pass too. A caller may
45/// share one gate across fetches it runs one after another (the CLI's
46/// per-payer gate, a [`crate::bundle::BundlePuller`]'s manifest fetch and
47/// pulls): each fetch then starts clear of the replacement an earlier one
48/// left ([`Self::leave_carried`]), and [`Self::replacements`] lists the
49/// replacements of all of them. Steps run one at a time. A caller whose exhausted set a sibling's step already answered
50/// takes no step of its own: the sibling topped up or settled since the
51/// caller's last step, raised the deposit above the one the caller saw, or
52/// swapped in a credential past the one the caller ran under.
53#[derive(Debug)]
54pub struct RecoveryGate {
55    /// BLAKE3-verified content bytes the fetch has received, across every
56    /// entry and lane that shares the gate.
57    verified: AtomicU64,
58    state: Mutex<GateState>,
59    /// Held across a step, so concurrent entries take one step between them.
60    step_lock: tokio::sync::Mutex<()>,
61    /// The settle window after a top-up ([`RECOVERY_SETTLE`] by default).
62    settle: Duration,
63}
64
65/// The mutable part of a [`RecoveryGate`].
66#[derive(Debug, Default)]
67struct GateState {
68    /// The verified-byte count when the last step ran, or `None` before the
69    /// first step.
70    last_step: Option<u64>,
71    /// The step that replaced the pool, which every later step reports.
72    replaced: Option<PoolReplaced>,
73    /// Every replacement a step of the gate made, in order. No pass or fetch
74    /// clears it.
75    made: Vec<PoolReplaced>,
76    /// The top-up and settle steps the gate has taken. A caller that last saw
77    /// fewer makes one more pass on the latest.
78    top_ups: u64,
79    /// The deposit the last top-up or settle step reached on the current
80    /// pool. A caller that saw a lower deposit makes one more pass at it.
81    reached: Option<U256>,
82    /// The credential generation the last delegated step handed on. A caller
83    /// that ran under an earlier generation makes one more pass on that step.
84    swapped: Option<u64>,
85    /// The end of the settle window after the last top-up.
86    settle_until: Option<Instant>,
87}
88
89/// What one call of [`RecoveryGate::step`] did.
90#[derive(Debug)]
91#[doc(hidden)]
92pub enum Stepped {
93    /// The deposit is now this total: this step topped the pool up or settled
94    /// at it, or a sibling's step already had. Make one more pass over the
95    /// priced-out candidates.
96    Raised(U256),
97    /// A step opened a new pool. The remaining work runs again against it.
98    Replaced(PoolReplaced),
99    /// The progress rule allows no step: no new byte was verified since the
100    /// last one. The fetch ends "funding needed".
101    NoProgress,
102    /// The funder has no way to add funds. The fetch ends "funding needed".
103    Unavailable,
104    /// The funder's step failed.
105    Failed(anyhow::Error),
106}
107
108/// What one delegated recovery step ([`RecoveryGate::swap_step`]) did.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub(crate) enum SwapStep {
111    /// A new credential is in the slot. Make one more pass under it.
112    Swapped,
113    /// The progress rule allows no step.
114    NoProgress,
115    /// No swap came within the slot's wait.
116    TimedOut,
117}
118
119impl Default for RecoveryGate {
120    fn default() -> Self {
121        Self::with_settle(RECOVERY_SETTLE)
122    }
123}
124
125impl RecoveryGate {
126    /// A gate with the [`RECOVERY_SETTLE`] settle window.
127    #[must_use]
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// A gate whose settle window after a top-up is `settle`.
133    #[must_use]
134    pub fn with_settle(settle: Duration) -> Self {
135        Self {
136            verified: AtomicU64::new(0),
137            state: Mutex::new(GateState::default()),
138            step_lock: tokio::sync::Mutex::new(()),
139            settle,
140        }
141    }
142
143    /// Start the pass that runs the remaining work against the pool a step
144    /// replaced ([`crate::PoolReplaced`]). Later steps top up, or replace, the
145    /// new pool; the progress rule carries over, so a further step still needs
146    /// a byte verified since the step that replaced the pool.
147    pub fn start_next_pass(&self) {
148        let mut state = self.lock();
149        state.replaced = None;
150        state.reached = None;
151    }
152
153    /// The replacement a step made in this pass, which every later step of the
154    /// pass reports, until [`Self::start_next_pass`].
155    #[cfg(test)]
156    pub(crate) fn replaced(&self) -> Option<crate::PoolReplaced> {
157        self.lock().replaced
158    }
159
160    /// Start a fetch clear of the replacement an earlier fetch on the gate
161    /// left, and return it. That fetch's lanes and record name the new pool,
162    /// so this fetch starts as a pass against it ([`Self::start_next_pass`]).
163    /// Left on the gate, it would answer this fetch's first recovery step with
164    /// no step. [`Self::replacements`] still lists it.
165    ///
166    /// Only a caller that runs its fetches one after another clears the gate
167    /// so: a fetch still in its pass on the closed pool would take its next
168    /// step on that pool, and open yet another one.
169    #[doc(hidden)]
170    pub fn leave_carried(&self) -> Option<crate::PoolReplaced> {
171        let mut state = self.lock();
172        let carried = state.replaced.take();
173        if carried.is_some() {
174            state.reached = None;
175        }
176        carried
177    }
178
179    /// Every pool replacement the gate's recovery steps made, in the order
180    /// they made them, across every pass and fetch on the gate. Each closed
181    /// pool may still hold a balance to reclaim once its dispute window ends.
182    /// The list outlives a fetch its caller dropped, so a command names each
183    /// closed pool on every exit.
184    #[must_use]
185    pub fn replacements(&self) -> Vec<crate::PoolReplaced> {
186        self.lock().made.clone()
187    }
188
189    /// Count `bytes` newly BLAKE3-verified content bytes toward the progress
190    /// rule.
191    #[doc(hidden)]
192    pub fn record_verified(&self, bytes: u64) {
193        self.verified.fetch_add(bytes, Ordering::Relaxed);
194    }
195
196    /// The top-up and settle steps the gate has taken: what a caller that
197    /// starts its loop now has seen ([`Self::step`]).
198    #[must_use]
199    #[doc(hidden)]
200    pub fn top_ups(&self) -> u64 {
201        self.lock().top_ups
202    }
203
204    /// Whether `now` lies in the settle window after the last top-up.
205    #[must_use]
206    #[doc(hidden)]
207    pub fn settling(&self, now: Instant) -> bool {
208        self.lock().settle_until.is_some_and(|until| now < until)
209    }
210
211    /// Run one funding recovery step, if the progress rule allows one.
212    ///
213    /// `seen_deposit` is the deposit the caller found its candidate set
214    /// exhausted at; `current_deposit` reads the deposit now. `seen_top_ups`
215    /// is the caller's count of top-up and settle steps ([`Self::top_ups`]),
216    /// which the call brings up to date. A step that a sibling already took is
217    /// not taken twice: a caller that saw fewer top-ups than the gate took, or
218    /// a deposit below the one the gate now reads, gets [`Stepped::Raised`]
219    /// and takes no step. `spent` is the pool's spend,
220    /// which [`Funder::recover`] tops up against. A pool replaced by an
221    /// earlier step stays replaced: every later call reports it without a step.
222    #[doc(hidden)]
223    pub async fn step<F: Funder + ?Sized>(
224        &self,
225        funder: &F,
226        seen_deposit: U256,
227        seen_top_ups: &mut u64,
228        current_deposit: impl Fn() -> U256,
229        spent: U256,
230    ) -> Stepped {
231        let _one_step = self.step_lock.lock().await;
232        let (replaced, reached, top_ups) = {
233            let state = self.lock();
234            (state.replaced, state.reached, state.top_ups)
235        };
236        if let Some(replaced) = replaced {
237            return Stepped::Replaced(replaced);
238        }
239        let deposit = current_deposit()
240            .max(seen_deposit)
241            .max(reached.unwrap_or(U256::ZERO));
242        if deposit > seen_deposit || top_ups > *seen_top_ups {
243            *seen_top_ups = top_ups;
244            return Stepped::Raised(deposit);
245        }
246        if !self.take_step() {
247            return Stepped::NoProgress;
248        }
249        let remaining = deposit.saturating_sub(spent);
250        tracing::info!(
251            %deposit,
252            %remaining,
253            "no candidate serves at the current funding; running a funding recovery step"
254        );
255        match funder.recover(remaining).await {
256            Ok(Recovery::ToppedUp(new_deposit)) => {
257                let mut state = self.lock();
258                state.settle_until = Some(Instant::now() + self.settle);
259                state.reached = Some(state.reached.map_or(new_deposit, |d| d.max(new_deposit)));
260                state.top_ups = state.top_ups.saturating_add(1);
261                *seen_top_ups = state.top_ups;
262                Stepped::Raised(new_deposit)
263            }
264            Ok(Recovery::Replaced(replaced)) => {
265                let mut state = self.lock();
266                state.replaced = Some(replaced);
267                state.made.push(replaced);
268                Stepped::Replaced(replaced)
269            }
270            Ok(Recovery::Unavailable) => Stepped::Unavailable,
271            Err(err) => Stepped::Failed(err),
272        }
273    }
274
275    /// Run one delegated recovery step: wait up to the slot's swap wait for
276    /// the application to swap in a new credential past `seen_generation`.
277    /// The step is under the same progress rule as a top-up, whether the swap
278    /// came before the call or during the wait: a pass under a swapped
279    /// credential that verifies no byte allows no further step. A caller that
280    /// ran under a generation before the one a sibling's step handed on gets
281    /// [`SwapStep::Swapped`] and takes no step of its own.
282    pub(crate) async fn swap_step(
283        &self,
284        slot: &crate::CredentialSlot,
285        seen_generation: u64,
286    ) -> SwapStep {
287        let _one_step = self.step_lock.lock().await;
288        if self
289            .lock()
290            .swapped
291            .is_some_and(|handed| handed > seen_generation)
292        {
293            return SwapStep::Swapped;
294        }
295        if !self.take_step() {
296            return SwapStep::NoProgress;
297        }
298        if slot.generation() <= seen_generation {
299            tracing::info!(
300                wait = ?slot.swap_wait(),
301                "no candidate serves under the current capability; waiting for a new credential"
302            );
303            let swapped = slot.swapped_since(seen_generation);
304            if tokio::time::timeout(slot.swap_wait(), swapped)
305                .await
306                .is_err()
307            {
308                return SwapStep::TimedOut;
309            }
310        }
311        self.lock().swapped = Some(slot.generation());
312        SwapStep::Swapped
313    }
314
315    /// Take a step under the progress rule: the first step always, a further
316    /// one only when a byte was verified since the last.
317    fn take_step(&self) -> bool {
318        let verified = self.verified.load(Ordering::Relaxed);
319        let mut state = self.lock();
320        let allowed = state.last_step.is_none_or(|at| verified > at);
321        if allowed {
322            state.last_step = Some(verified);
323        }
324        allowed
325    }
326
327    fn lock(&self) -> std::sync::MutexGuard<'_, GateState> {
328        self.state.lock().unwrap_or_else(PoisonError::into_inner)
329    }
330}
331
332/// What an exhausted candidate set does after its recovery step: the raised
333/// deposit to make one more pass at, or the fetch's end. The end is
334/// `exhausted` ([`crate::NoAffordableSource`], "funding needed") with the
335/// reason the step gave, the pool replacement, or a step failure only the user
336/// can fix.
337///
338/// # Errors
339///
340/// Every outcome but [`Stepped::Raised`].
341pub(crate) fn after_step(stepped: Stepped, exhausted: anyhow::Error) -> anyhow::Result<U256> {
342    match stepped {
343        Stepped::Raised(deposit) => Ok(deposit),
344        Stepped::Replaced(replaced) => Err(anyhow::Error::new(replaced)),
345        Stepped::NoProgress => Err(exhausted.context(
346            "no node serves at the current funding, and no byte was verified since the last \
347             funding recovery step",
348        )),
349        Stepped::Unavailable => Err(exhausted),
350        Stepped::Failed(err) => Err(match crate::fault::classify(&err) {
351            crate::Fault::Fatal(_) => err,
352            _ => exhausted.context(format!("the funding recovery step failed: {err:#}")),
353        }),
354    }
355}
356
357#[cfg(test)]
358#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
359mod tests;