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. Steps run one at
45/// a time. A caller whose exhausted set a sibling's step already answered
46/// takes no step of its own: the sibling topped up or settled since the
47/// caller's last step, raised the deposit above the one the caller saw, or
48/// swapped in a credential past the one the caller ran under.
49#[derive(Debug)]
50pub struct RecoveryGate {
51    /// BLAKE3-verified content bytes the fetch has received, across every
52    /// entry and lane that shares the gate.
53    verified: AtomicU64,
54    state: Mutex<GateState>,
55    /// Held across a step, so concurrent entries take one step between them.
56    step_lock: tokio::sync::Mutex<()>,
57    /// The settle window after a top-up ([`RECOVERY_SETTLE`] by default).
58    settle: Duration,
59}
60
61/// The mutable part of a [`RecoveryGate`].
62#[derive(Debug, Default)]
63struct GateState {
64    /// The verified-byte count when the last step ran, or `None` before the
65    /// first step.
66    last_step: Option<u64>,
67    /// The step that replaced the pool, which every later step reports.
68    replaced: Option<PoolReplaced>,
69    /// The top-up and settle steps the gate has taken. A caller that last saw
70    /// fewer makes one more pass on the latest.
71    top_ups: u64,
72    /// The deposit the last top-up or settle step reached on the current
73    /// pool. A caller that saw a lower deposit makes one more pass at it.
74    reached: Option<U256>,
75    /// The credential generation the last delegated step handed on. A caller
76    /// that ran under an earlier generation makes one more pass on that step.
77    swapped: Option<u64>,
78    /// The end of the settle window after the last top-up.
79    settle_until: Option<Instant>,
80}
81
82/// What one call of [`RecoveryGate::step`] did.
83#[derive(Debug)]
84#[doc(hidden)]
85pub enum Stepped {
86    /// The deposit is now this total: this step topped the pool up or settled
87    /// at it, or a sibling's step already had. Make one more pass over the
88    /// priced-out candidates.
89    Raised(U256),
90    /// A step opened a new pool. The remaining work runs again against it.
91    Replaced(PoolReplaced),
92    /// The progress rule allows no step: no new byte was verified since the
93    /// last one. The fetch ends "funding needed".
94    NoProgress,
95    /// The funder has no way to add funds. The fetch ends "funding needed".
96    Unavailable,
97    /// The funder's step failed.
98    Failed(anyhow::Error),
99}
100
101/// What one delegated recovery step ([`RecoveryGate::swap_step`]) did.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum SwapStep {
104    /// A new credential is in the slot. Make one more pass under it.
105    Swapped,
106    /// The progress rule allows no step.
107    NoProgress,
108    /// No swap came within the slot's wait.
109    TimedOut,
110}
111
112impl Default for RecoveryGate {
113    fn default() -> Self {
114        Self::with_settle(RECOVERY_SETTLE)
115    }
116}
117
118impl RecoveryGate {
119    /// A gate for one fetch, with the [`RECOVERY_SETTLE`] settle window.
120    #[must_use]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// A gate whose settle window after a top-up is `settle`.
126    #[must_use]
127    pub fn with_settle(settle: Duration) -> Self {
128        Self {
129            verified: AtomicU64::new(0),
130            state: Mutex::new(GateState::default()),
131            step_lock: tokio::sync::Mutex::new(()),
132            settle,
133        }
134    }
135
136    /// Start the pass that runs the remaining work against the pool a step
137    /// replaced ([`crate::PoolReplaced`]). Later steps top up, or replace, the
138    /// new pool; the progress rule carries over, so a further step still needs
139    /// a byte verified since the step that replaced the pool.
140    pub fn start_next_pass(&self) {
141        let mut state = self.lock();
142        state.replaced = None;
143        state.reached = None;
144    }
145
146    /// Count `bytes` newly BLAKE3-verified content bytes toward the progress
147    /// rule.
148    #[doc(hidden)]
149    pub fn record_verified(&self, bytes: u64) {
150        self.verified.fetch_add(bytes, Ordering::Relaxed);
151    }
152
153    /// The top-up and settle steps the gate has taken: what a caller that
154    /// starts its loop now has seen ([`Self::step`]).
155    #[must_use]
156    #[doc(hidden)]
157    pub fn top_ups(&self) -> u64 {
158        self.lock().top_ups
159    }
160
161    /// Whether `now` lies in the settle window after the last top-up.
162    #[must_use]
163    #[doc(hidden)]
164    pub fn settling(&self, now: Instant) -> bool {
165        self.lock().settle_until.is_some_and(|until| now < until)
166    }
167
168    /// Run one funding recovery step, if the progress rule allows one.
169    ///
170    /// `seen_deposit` is the deposit the caller found its candidate set
171    /// exhausted at; `current_deposit` reads the deposit now. `seen_top_ups`
172    /// is the caller's count of top-up and settle steps ([`Self::top_ups`]),
173    /// which the call brings up to date. A step that a sibling already took is
174    /// not taken twice: a caller that saw fewer top-ups than the gate took, or
175    /// a deposit below the one the gate now reads, gets [`Stepped::Raised`]
176    /// and takes no step. `spent` is the pool's spend,
177    /// which [`Funder::recover`] tops up against. A pool replaced by an
178    /// earlier step stays replaced: every later call reports it without a step.
179    #[doc(hidden)]
180    pub async fn step<F: Funder + ?Sized>(
181        &self,
182        funder: &F,
183        seen_deposit: U256,
184        seen_top_ups: &mut u64,
185        current_deposit: impl Fn() -> U256,
186        spent: U256,
187    ) -> Stepped {
188        let _one_step = self.step_lock.lock().await;
189        let (replaced, reached, top_ups) = {
190            let state = self.lock();
191            (state.replaced, state.reached, state.top_ups)
192        };
193        if let Some(replaced) = replaced {
194            return Stepped::Replaced(replaced);
195        }
196        let deposit = current_deposit()
197            .max(seen_deposit)
198            .max(reached.unwrap_or(U256::ZERO));
199        if deposit > seen_deposit || top_ups > *seen_top_ups {
200            *seen_top_ups = top_ups;
201            return Stepped::Raised(deposit);
202        }
203        if !self.take_step() {
204            return Stepped::NoProgress;
205        }
206        let remaining = deposit.saturating_sub(spent);
207        tracing::info!(
208            %deposit,
209            %remaining,
210            "no candidate serves at the current funding; running a funding recovery step"
211        );
212        match funder.recover(remaining).await {
213            Ok(Recovery::ToppedUp(new_deposit)) => {
214                let mut state = self.lock();
215                state.settle_until = Some(Instant::now() + self.settle);
216                state.reached = Some(state.reached.map_or(new_deposit, |d| d.max(new_deposit)));
217                state.top_ups = state.top_ups.saturating_add(1);
218                *seen_top_ups = state.top_ups;
219                Stepped::Raised(new_deposit)
220            }
221            Ok(Recovery::Replaced(replaced)) => {
222                self.lock().replaced = Some(replaced);
223                Stepped::Replaced(replaced)
224            }
225            Ok(Recovery::Unavailable) => Stepped::Unavailable,
226            Err(err) => Stepped::Failed(err),
227        }
228    }
229
230    /// Run one delegated recovery step: wait up to the slot's swap wait for
231    /// the application to swap in a new credential past `seen_generation`.
232    /// The step is under the same progress rule as a top-up, whether the swap
233    /// came before the call or during the wait: a pass under a swapped
234    /// credential that verifies no byte allows no further step. A caller that
235    /// ran under a generation before the one a sibling's step handed on gets
236    /// [`SwapStep::Swapped`] and takes no step of its own.
237    pub(crate) async fn swap_step(
238        &self,
239        slot: &crate::CredentialSlot,
240        seen_generation: u64,
241    ) -> SwapStep {
242        let _one_step = self.step_lock.lock().await;
243        if self
244            .lock()
245            .swapped
246            .is_some_and(|handed| handed > seen_generation)
247        {
248            return SwapStep::Swapped;
249        }
250        if !self.take_step() {
251            return SwapStep::NoProgress;
252        }
253        if slot.generation() <= seen_generation {
254            tracing::info!(
255                wait = ?slot.swap_wait(),
256                "no candidate serves under the current capability; waiting for a new credential"
257            );
258            let swapped = slot.swapped_since(seen_generation);
259            if tokio::time::timeout(slot.swap_wait(), swapped)
260                .await
261                .is_err()
262            {
263                return SwapStep::TimedOut;
264            }
265        }
266        self.lock().swapped = Some(slot.generation());
267        SwapStep::Swapped
268    }
269
270    /// Take a step under the progress rule: the first step always, a further
271    /// one only when a byte was verified since the last.
272    fn take_step(&self) -> bool {
273        let verified = self.verified.load(Ordering::Relaxed);
274        let mut state = self.lock();
275        let allowed = state.last_step.is_none_or(|at| verified > at);
276        if allowed {
277            state.last_step = Some(verified);
278        }
279        allowed
280    }
281
282    fn lock(&self) -> std::sync::MutexGuard<'_, GateState> {
283        self.state.lock().unwrap_or_else(PoisonError::into_inner)
284    }
285}
286
287/// What an exhausted candidate set does after its recovery step: the raised
288/// deposit to make one more pass at, or the fetch's end. The end is
289/// `exhausted` ([`crate::NoAffordableSource`], "funding needed") with the
290/// reason the step gave, the pool replacement, or a step failure only the user
291/// can fix.
292///
293/// # Errors
294///
295/// Every outcome but [`Stepped::Raised`].
296pub(crate) fn after_step(stepped: Stepped, exhausted: anyhow::Error) -> anyhow::Result<U256> {
297    match stepped {
298        Stepped::Raised(deposit) => Ok(deposit),
299        Stepped::Replaced(replaced) => Err(anyhow::Error::new(replaced)),
300        Stepped::NoProgress => Err(exhausted.context(
301            "no node serves at the current funding, and no byte was verified since the last \
302             funding recovery step",
303        )),
304        Stepped::Unavailable => Err(exhausted),
305        Stepped::Failed(err) => Err(match crate::fault::classify(&err) {
306            crate::Fault::Fatal(_) => err,
307            _ => exhausted.context(format!("the funding recovery step failed: {err:#}")),
308        }),
309    }
310}
311
312#[cfg(test)]
313#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
314mod tests;