Skip to content

Commit 17c434b

Browse files
committed
feat(retry): add a drain-wait retry for callers that cannot retry
retry_through_drain re-runs an operation refused with a retryable schema change until it succeeds, fails terminally, or a caller-supplied budget elapses. Each wait ends early when a drain ends on this node.
1 parent ec8c016 commit 17c434b

1 file changed

Lines changed: 107 additions & 0 deletions

File tree

  • nodedb/src/control/server/shared

‎nodedb/src/control/server/shared/retry.rs‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,53 @@ where
241241
}))
242242
}
243243

244+
/// Run `op` until it succeeds or fails terminally, or until `budget` elapses.
245+
///
246+
/// This is for a caller whose client cannot retry, such as an ILP stream that
247+
/// sends no acks. Such a caller waits out a drain for as long as the drain
248+
/// itself can last. Each wait ends early when a drain ends on this node. The
249+
/// last retryable error is returned once `budget` elapses.
250+
pub async fn retry_through_drain<F, Fut, T, E>(
251+
drains: &DescriptorDrainTracker,
252+
budget: Duration,
253+
mut op: F,
254+
) -> Result<T, E>
255+
where
256+
F: FnMut() -> Fut,
257+
Fut: std::future::Future<Output = Result<T, E>>,
258+
E: RetryableSchemaChange,
259+
{
260+
let deadline = tokio::time::Instant::now() + budget;
261+
let ceiling = BACKOFFS[BACKOFFS.len() - 1];
262+
let mut attempt = 0usize;
263+
loop {
264+
// Enabled before the attempt, so a drain that ends while the attempt
265+
// runs still wakes the wait after it.
266+
let drain_ended = drains.drain_ended();
267+
tokio::pin!(drain_ended);
268+
drain_ended.as_mut().enable();
269+
let error = match op().await {
270+
Ok(value) => return Ok(value),
271+
Err(error) => error,
272+
};
273+
let Some(descriptor) = error.retryable_descriptor() else {
274+
return Err(error);
275+
};
276+
let now = tokio::time::Instant::now();
277+
if now >= deadline {
278+
return Err(error);
279+
}
280+
tracing::debug!(attempt, descriptor, "waiting out a descriptor drain");
281+
let backoff = BACKOFFS.get(attempt).copied().unwrap_or(ceiling);
282+
let wake = (now + backoff).min(deadline);
283+
tokio::select! {
284+
_ = drain_ended.as_mut() => {}
285+
_ = tokio::time::sleep_until(wake) => {}
286+
}
287+
attempt += 1;
288+
}
289+
}
290+
244291
#[cfg(test)]
245292
mod tests {
246293
use super::*;
@@ -392,4 +439,64 @@ mod tests {
392439
assert!(matches!(result, Err(Error::PlanError { .. })));
393440
assert_eq!(calls.load(Ordering::SeqCst), 1);
394441
}
442+
443+
/// A drain that outlasts the statement budget is still waited out when
444+
/// the caller's budget covers it.
445+
#[tokio::test(start_paused = true)]
446+
async fn a_drain_longer_than_the_statement_budget_is_waited_out() {
447+
let calls = AtomicUsize::new(0);
448+
let drains = DescriptorDrainTracker::new();
449+
let refusals = MAX_ATTEMPTS * 3;
450+
let result: Result<(), Error> =
451+
retry_through_drain(&drains, Duration::from_secs(35), || {
452+
let n = calls.fetch_add(1, Ordering::SeqCst);
453+
async move {
454+
if n < refusals {
455+
Err(Error::RetryableSchemaChanged {
456+
descriptor: "orders".into(),
457+
})
458+
} else {
459+
Ok(())
460+
}
461+
}
462+
})
463+
.await;
464+
result.expect("the attempt after the drain succeeds");
465+
assert_eq!(calls.load(Ordering::SeqCst), refusals + 1);
466+
}
467+
468+
/// A drain that never ends returns its error once the budget elapses.
469+
#[tokio::test(start_paused = true)]
470+
async fn a_drain_past_the_budget_surfaces_its_error_at_the_deadline() {
471+
let drains = DescriptorDrainTracker::new();
472+
let budget = Duration::from_secs(2);
473+
let started = tokio::time::Instant::now();
474+
let result: Result<(), Error> = retry_through_drain(&drains, budget, || async {
475+
Err(Error::RetryableSchemaChanged {
476+
descriptor: "orders".into(),
477+
})
478+
})
479+
.await;
480+
assert!(matches!(result, Err(Error::RetryableSchemaChanged { .. })));
481+
assert!(started.elapsed() >= budget);
482+
assert!(started.elapsed() < budget + BACKOFFS[BACKOFFS.len() - 1]);
483+
}
484+
485+
#[tokio::test]
486+
async fn retry_through_drain_surfaces_a_terminal_error_at_once() {
487+
let calls = AtomicUsize::new(0);
488+
let drains = DescriptorDrainTracker::new();
489+
let result: Result<(), Error> =
490+
retry_through_drain(&drains, Duration::from_secs(35), || {
491+
calls.fetch_add(1, Ordering::SeqCst);
492+
async move {
493+
Err(Error::PlanError {
494+
detail: "syntax error".into(),
495+
})
496+
}
497+
})
498+
.await;
499+
assert!(matches!(result, Err(Error::PlanError { .. })));
500+
assert_eq!(calls.load(Ordering::SeqCst), 1);
501+
}
395502
}

0 commit comments

Comments
 (0)