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;