/build/source/nativelink-util/src/retry.rs
Line | Count | Source |
1 | | // Copyright 2024 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | use core::pin::Pin; |
16 | | use core::time::Duration; |
17 | | use std::sync::Arc; |
18 | | |
19 | | use futures::future::Future; |
20 | | use futures::stream::StreamExt; |
21 | | use nativelink_config::stores::{ErrorCode, Retry}; |
22 | | use nativelink_error::{Code, Error, make_err}; |
23 | | use tracing::{error, info, warn}; |
24 | | |
25 | | use crate::metrics::record_retry_outcome; |
26 | | |
27 | | struct ExponentialBackoff { |
28 | | current: Duration, |
29 | | } |
30 | | |
31 | | impl ExponentialBackoff { |
32 | 476 | const fn new(base: Duration) -> Self { |
33 | 476 | Self { current: base } |
34 | 476 | } |
35 | | } |
36 | | |
37 | | impl Iterator for ExponentialBackoff { |
38 | | type Item = Duration; |
39 | | |
40 | 33 | fn next(&mut self) -> Option<Duration> { |
41 | 33 | self.current *= 2; |
42 | 33 | Some(self.current) |
43 | 33 | } |
44 | | } |
45 | | |
46 | | type SleepFn = Arc<dyn Fn(Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Sync + Send>; |
47 | | pub(crate) type JitterFn = Arc<dyn Fn(Duration) -> Duration + Send + Sync>; |
48 | | |
49 | | #[derive(PartialEq, Eq, Debug)] |
50 | | pub enum RetryResult<T> { |
51 | | Ok(T), |
52 | | Retry(Error), |
53 | | /// Retryable, and the server asked for at least this long before the |
54 | | /// next attempt. Within the retrier's throttle budget the wait is taken |
55 | | /// as is and spends no attempt; past it the backoff still applies when |
56 | | /// it is longer and the attempt counts. |
57 | | RetryAfter(Error, Duration), |
58 | | Err(Error), |
59 | | } |
60 | | |
61 | | /// What became of an operation, or one attempt of it, under a [`Retrier`]. |
62 | | /// Recorded as `retry.outcomes`, labeled with the retrier's client. |
63 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
64 | | pub enum RetryOutcome { |
65 | | /// An attempt failed with a retryable error and another follows. A |
66 | | /// throttle waited out within the throttle budget counts, though it |
67 | | /// spends no attempt. |
68 | | Retried, |
69 | | /// The operation succeeded after at least one retry. |
70 | | Recovered, |
71 | | /// The operation failed with a retryable error and the retries ran out. |
72 | | Exhausted, |
73 | | /// The operation failed with an error that is never retried. `NotFound` |
74 | | /// is left out: on a store it is the ordinary miss. |
75 | | Permanent, |
76 | | } |
77 | | |
78 | | impl RetryOutcome { |
79 | 128 | const fn as_str(self) -> &'static str { |
80 | 128 | match self { |
81 | 75 | Self::Retried => "retried", |
82 | 29 | Self::Recovered => "recovered", |
83 | 23 | Self::Exhausted => "exhausted", |
84 | 1 | Self::Permanent => "permanent", |
85 | | } |
86 | 128 | } |
87 | | } |
88 | | |
89 | | type OutcomeFn = Arc<dyn Fn(RetryOutcome) + Send + Sync>; |
90 | | |
91 | | /// Class used to retry a job with a sleep function in between each retry. |
92 | | #[derive(Clone)] |
93 | | pub struct Retrier { |
94 | | sleep_fn: SleepFn, |
95 | | jitter_fn: JitterFn, |
96 | | config: Retry, |
97 | | client: &'static str, |
98 | | outcome_fn: Option<OutcomeFn>, |
99 | | /// How long one `retry` call may spend on `RetryAfter` waits before |
100 | | /// they start spending attempts. Zero means they always do. |
101 | | throttle_budget: Duration, |
102 | | /// Longest a throttle wait may grow to. Set, each consecutive throttle |
103 | | /// doubles the wait the store asked for, capped here, and jitters it. |
104 | | /// Unset, the wait is what was asked. |
105 | | throttle_max_wait: Option<Duration>, |
106 | | } |
107 | | |
108 | | impl core::fmt::Debug for Retrier { |
109 | 0 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
110 | 0 | f.debug_struct("Retrier") |
111 | 0 | .field("config", &self.config) |
112 | 0 | .field("client", &self.client) |
113 | 0 | .finish_non_exhaustive() |
114 | 0 | } |
115 | | } |
116 | | |
117 | 3 | const fn to_error_code(code: Code) -> ErrorCode { |
118 | 3 | match code { |
119 | 0 | Code::Cancelled => ErrorCode::Cancelled, |
120 | 0 | Code::InvalidArgument => ErrorCode::InvalidArgument, |
121 | 0 | Code::DeadlineExceeded => ErrorCode::DeadlineExceeded, |
122 | 3 | Code::NotFound => ErrorCode::NotFound, |
123 | 0 | Code::AlreadyExists => ErrorCode::AlreadyExists, |
124 | 0 | Code::PermissionDenied => ErrorCode::PermissionDenied, |
125 | 0 | Code::ResourceExhausted => ErrorCode::ResourceExhausted, |
126 | 0 | Code::FailedPrecondition => ErrorCode::FailedPrecondition, |
127 | 0 | Code::Aborted => ErrorCode::Aborted, |
128 | 0 | Code::OutOfRange => ErrorCode::OutOfRange, |
129 | 0 | Code::Unimplemented => ErrorCode::Unimplemented, |
130 | 0 | Code::Internal => ErrorCode::Internal, |
131 | 0 | Code::Unavailable => ErrorCode::Unavailable, |
132 | 0 | Code::DataLoss => ErrorCode::DataLoss, |
133 | 0 | Code::Unauthenticated => ErrorCode::Unauthenticated, |
134 | 0 | _ => ErrorCode::Unknown, |
135 | | } |
136 | 3 | } |
137 | | |
138 | | impl Retrier { |
139 | 0 | pub fn new(sleep_fn: SleepFn, jitter_fn: JitterFn, config: Retry) -> Self { |
140 | 0 | Self { |
141 | 0 | sleep_fn, |
142 | 0 | jitter_fn, |
143 | 0 | config, |
144 | 0 | client: "unknown", |
145 | 0 | outcome_fn: None, |
146 | 0 | throttle_budget: Duration::ZERO, |
147 | 0 | throttle_max_wait: None, |
148 | 0 | } |
149 | 0 | } |
150 | | |
151 | | /// Names what this retrier retries for (`azure`, `s3`, `grpc_store`, |
152 | | /// ...), the `retry.client` label on its `retry.outcomes`. |
153 | | #[must_use] |
154 | 246 | pub const fn with_client(mut self, client: &'static str) -> Self { |
155 | 246 | self.client = client; |
156 | 246 | self |
157 | 246 | } |
158 | | |
159 | | /// Also hands every outcome to `outcome_fn`, after it is recorded as a |
160 | | /// metric. For tests, which cannot read the meter. |
161 | | #[must_use] |
162 | 6 | pub fn with_outcome_fn(mut self, outcome_fn: OutcomeFn) -> Self { |
163 | 6 | self.outcome_fn = Some(outcome_fn); |
164 | 6 | self |
165 | 6 | } |
166 | | |
167 | | /// Lets one `retry` call spend up to `budget` waiting on `RetryAfter` |
168 | | /// results without counting those tries as attempts. |
169 | | #[must_use] |
170 | 0 | pub const fn with_throttle_budget(mut self, budget: Duration) -> Self { |
171 | 0 | self.throttle_budget = budget; |
172 | 0 | self |
173 | 0 | } |
174 | | |
175 | | /// Lets consecutive throttles wait longer each time. The first throttle |
176 | | /// in a row waits what the server asked, the second twice that, the |
177 | | /// third four times, and so on; `max_wait` is the ceiling that growth |
178 | | /// stops at, not a wait of its own. The grown wait is spread upward by |
179 | | /// the jitter and never less than what was asked. Growth applies to the |
180 | | /// waits the throttle budget pays for; once the budget is spent the |
181 | | /// backoff ladder takes over and waits at least the plain ask, so the |
182 | | /// ladder's total stays what the retry config says. A store that is |
183 | | /// told to slow down with no `Retry-After` otherwise has every client |
184 | | /// retry in lockstep at the configured floor. |
185 | | #[must_use] |
186 | 66 | pub const fn with_throttle_max_wait(mut self, max_wait: Duration) -> Self { |
187 | 66 | self.throttle_max_wait = Some(max_wait); |
188 | 66 | self |
189 | 66 | } |
190 | | |
191 | | /// The wait for the `n`-th consecutive throttle whose server asked for |
192 | | /// `asked`: `asked` itself for the first, doubled once per throttle |
193 | | /// before it, capped at the configured ceiling, then jittered. |
194 | 48 | fn throttle_wait(&self, asked: Duration, throttles_in_a_row: u32) -> Duration { |
195 | 48 | let Some(max_wait38 ) = self.throttle_max_wait else { |
196 | 10 | return asked; |
197 | | }; |
198 | | // One doubling per earlier throttle in the row; past 31 the |
199 | | // multiplier would overflow and the ceiling applies anyway. |
200 | 38 | let doublings = throttles_in_a_row.saturating_sub(1).min(31); |
201 | 38 | let grown = asked.saturating_mul(1 << doublings).min(max_wait); |
202 | | // `Retry::make_jitter_fn` spreads a delay either way, and on the |
203 | | // first throttle half of every fleet would land at or below the |
204 | | // ask and retry in lockstep. A low result is mirrored above the |
205 | | // grown wait instead, so every step spreads upward only; a jitter |
206 | | // that zeroes delays (as tests install) leaves the grown wait. |
207 | 38 | let jittered = (self.jitter_fn)(grown); |
208 | 38 | if jittered.is_zero() { |
209 | 21 | grown |
210 | | } else { |
211 | 17 | grown + jittered.abs_diff(grown) |
212 | | } |
213 | 48 | } |
214 | | |
215 | 128 | fn report(&self, outcome: RetryOutcome) { |
216 | 128 | record_retry_outcome(self.client, outcome.as_str()); |
217 | 128 | if let Some(outcome_fn10 ) = &self.outcome_fn { |
218 | 10 | outcome_fn(outcome); |
219 | 118 | } |
220 | 128 | } |
221 | | |
222 | | /// This should only return true if the error code should be interpreted as |
223 | | /// temporary. |
224 | 105 | fn should_retry(&self, code: Code) -> bool { |
225 | 105 | if let Some(retry_codes3 ) = &self.config.retry_on_errors { |
226 | 3 | retry_codes.contains(&to_error_code(code)) |
227 | | } else { |
228 | | // Match is intentionally exhaustive: if a new `Code` variant is added, |
229 | | // the compiler forces a decision here. New codes default to permanent |
230 | | // (no retry) rather than silently being retried. |
231 | 102 | match code { |
232 | | Code::Cancelled |
233 | | | Code::Unknown |
234 | | | Code::DeadlineExceeded |
235 | | | Code::ResourceExhausted |
236 | | | Code::Aborted |
237 | | | Code::Internal |
238 | | | Code::Unavailable |
239 | 95 | | Code::DataLoss => true, |
240 | | Code::Ok |
241 | | | Code::InvalidArgument |
242 | | | Code::NotFound |
243 | | | Code::AlreadyExists |
244 | | | Code::PermissionDenied |
245 | | | Code::FailedPrecondition |
246 | | | Code::OutOfRange |
247 | | | Code::Unimplemented |
248 | 7 | | Code::Unauthenticated => false, |
249 | | } |
250 | | } |
251 | 105 | } |
252 | | |
253 | 476 | fn get_retry_config(&self) -> impl Iterator<Item = Duration> + '_ { |
254 | 476 | ExponentialBackoff::new(Duration::from_secs_f32(self.config.delay)) |
255 | 476 | .map(|d| (self.jitter_fn)33 (d33 )) |
256 | 476 | .take(self.config.max_retries) // Remember this is number of retries, so will run max_retries + 1. |
257 | 476 | } |
258 | | |
259 | | #[expect( |
260 | | clippy::manual_async_fn, |
261 | | reason = "making an `async fn` results in a potential compiler bug in seemingly unrelated \ |
262 | | code" |
263 | | )] |
264 | 476 | pub fn retry<'a, T: Send>( |
265 | 476 | &'a self, |
266 | 476 | operation: impl futures::stream::Stream<Item = RetryResult<T>> + Send + 'a, |
267 | 476 | ) -> impl Future<Output = Result<T, Error>> + Send + 'a { |
268 | 476 | async move { |
269 | 476 | let mut iter = self.get_retry_config(); |
270 | 476 | tokio::pin!(operation); |
271 | 476 | let mut attempt = 0; |
272 | 476 | let mut throttled = Duration::ZERO; |
273 | 476 | let mut throttles_in_a_row: u32 = 0; |
274 | | loop { |
275 | 551 | attempt += 1; |
276 | 551 | let (err105 , min_wait105 , is_throttle105 ) = match operation.next().await { |
277 | | None => { |
278 | 0 | return Err(make_err!( |
279 | 0 | Code::Internal, |
280 | 0 | "Retry stream ended abruptly on attempt {attempt}", |
281 | 0 | )); |
282 | | } |
283 | 423 | Some(RetryResult::Ok(value)) => { |
284 | | // A throttle within the budget is a retry too, |
285 | | // though it leaves `attempt` where it was. |
286 | 423 | if attempt > 1 || !throttled.is_zero()409 { |
287 | 29 | self.report(RetryOutcome::Recovered); |
288 | 394 | } |
289 | 423 | return Ok(value); |
290 | | } |
291 | 22 | Some(RetryResult::Err(e)) => { |
292 | 22 | return Err(e.append(format!("On attempt {attempt}"))); |
293 | | } |
294 | 52 | Some(RetryResult::Retry(err)) => { |
295 | 52 | throttles_in_a_row = 0; |
296 | 52 | (err, Duration::ZERO, false) |
297 | | } |
298 | 53 | Some(RetryResult::RetryAfter(err, min_wait)) => { |
299 | 53 | throttles_in_a_row = throttles_in_a_row.saturating_add(1); |
300 | 53 | (err, min_wait, true) |
301 | | } |
302 | | }; |
303 | 105 | if !self.should_retry(err.code) { |
304 | 7 | if err.code == Code::NotFound { |
305 | 6 | info!(?err, "Not found, not retrying"); |
306 | | } else { |
307 | 1 | self.report(RetryOutcome::Permanent); |
308 | 1 | error!(?attempt, ?err, "Not retrying permanent error"); |
309 | | } |
310 | 7 | return Err(err); |
311 | 98 | } |
312 | | // A throttle within the budget waits what was asked and is |
313 | | // not an attempt: the server said when to come back, and |
314 | | // the budget bounds how long that is listened to. A zero |
315 | | // wait would spin, so it goes through the backoff instead. |
316 | | // The grown wait is what the budget pays for. Past the |
317 | | // budget the backoff ladder below waits at least the plain |
318 | | // ask, never the grown one, so the ladder's total stays what |
319 | | // the retry config says and the caller's own deadline (a |
320 | | // worker's download window, Bazel's remote timeout) holds. |
321 | 98 | if is_throttle && !min_wait.is_zero()52 { |
322 | 48 | let wait = self.throttle_wait(min_wait, throttles_in_a_row); |
323 | 48 | if throttled.saturating_add(wait) <= self.throttle_budget { |
324 | 42 | throttled = throttled.saturating_add(wait); |
325 | 42 | attempt -= 1; |
326 | 42 | self.report(RetryOutcome::Retried); |
327 | 42 | (self.sleep_fn)(wait).await; |
328 | 42 | continue; |
329 | 6 | } |
330 | 50 | } |
331 | 56 | let Some(backoff33 ) = iter.next() else { |
332 | 23 | self.report(RetryOutcome::Exhausted); |
333 | 23 | warn!(client = self.client, ?attempt, ?err, "Retries exhausted"); |
334 | 23 | return Err(err.append(format!("On attempt {attempt}"))); |
335 | | }; |
336 | 33 | self.report(RetryOutcome::Retried); |
337 | 33 | (self.sleep_fn)(backoff.max(min_wait)).await; |
338 | | } |
339 | 475 | } |
340 | 476 | } |
341 | | } |