@@ -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) ]
245292mod 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