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;