Skip to main content

reifydb_core/
retry.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::thread;
5
6use reifydb_runtime::context::rng::Rng;
7use reifydb_value::{error::Error, value::duration::Duration};
8use tracing::{debug, warn};
9
10use crate::execution::ExecutionResult;
11
12pub enum Backoff {
13	None,
14
15	Fixed(Duration),
16
17	Exponential {
18		base: Duration,
19		max: Duration,
20	},
21	ExponentialJitter {
22		base: Duration,
23		max: Duration,
24	},
25}
26
27pub struct RetryStrategy {
28	pub max_attempts: u32,
29	pub backoff: Backoff,
30}
31
32impl Default for RetryStrategy {
33	fn default() -> Self {
34		Self {
35			max_attempts: 10,
36			backoff: Backoff::ExponentialJitter {
37				base: Duration::from_milliseconds(5).unwrap(),
38				max: Duration::from_milliseconds(200).unwrap(),
39			},
40		}
41	}
42}
43
44impl RetryStrategy {
45	pub fn no_retry() -> Self {
46		Self {
47			max_attempts: 1,
48			backoff: Backoff::None,
49		}
50	}
51
52	pub fn default_conflict_retry() -> Self {
53		Self::default()
54	}
55
56	pub fn with_fixed_backoff(max_attempts: u32, delay: Duration) -> Self {
57		Self {
58			max_attempts,
59			backoff: Backoff::Fixed(delay),
60		}
61	}
62
63	pub fn with_exponential_backoff(max_attempts: u32, base: Duration, max: Duration) -> Self {
64		Self {
65			max_attempts,
66			backoff: Backoff::Exponential {
67				base,
68				max,
69			},
70		}
71	}
72
73	pub fn with_jittered_backoff(max_attempts: u32, base: Duration, max: Duration) -> Self {
74		Self {
75			max_attempts,
76			backoff: Backoff::ExponentialJitter {
77				base,
78				max,
79			},
80		}
81	}
82
83	pub fn execute<F>(&self, rng: &Rng, rql: &str, f: F) -> ExecutionResult
84	where
85		F: FnMut() -> ExecutionResult,
86	{
87		self.run(rng, rql, f, |result| result.error.as_ref().is_some_and(is_conflict))
88	}
89
90	pub fn retry<T, F>(&self, rng: &Rng, operation: &str, f: F) -> Result<T, Error>
91	where
92		F: FnMut() -> Result<T, Error>,
93	{
94		self.run(rng, operation, f, |result| result.as_ref().is_err_and(is_conflict))
95	}
96
97	fn run<R>(&self, rng: &Rng, rql: &str, mut f: impl FnMut() -> R, conflicted: impl Fn(&R) -> bool) -> R {
98		let mut last_result = None;
99		for attempt in 0..self.max_attempts {
100			let result = f();
101			if !conflicted(&result) {
102				return result;
103			}
104			last_result = Some(result);
105			let is_last_attempt = attempt + 1 >= self.max_attempts;
106			if is_last_attempt {
107				warn!(
108					attempt = attempt + 1,
109					max_attempts = self.max_attempts,
110					rql = %rql,
111					"Transaction conflict retries exhausted"
112				);
113			} else {
114				let delay = compute_backoff(&self.backoff, attempt, rng);
115				debug!(
116					attempt = attempt + 1,
117					max_attempts = self.max_attempts,
118					delay_us = delay.microseconds().unwrap_or(0) as u64,
119					rql = %rql,
120					"Transaction conflict detected, retrying after backoff"
121				);
122				if !delay.is_zero() {
123					thread::sleep(delay.to_std());
124				}
125			}
126		}
127		last_result.unwrap()
128	}
129}
130
131pub fn is_conflict(err: &Error) -> bool {
132	err.code == "TXN_001"
133}
134
135fn compute_backoff(backoff: &Backoff, attempt: u32, rng: &Rng) -> Duration {
136	match backoff {
137		Backoff::None => Duration::zero(),
138		Backoff::Fixed(d) => *d,
139		Backoff::Exponential {
140			base,
141			max,
142		} => exponential_cap(*base, *max, attempt),
143		Backoff::ExponentialJitter {
144			base,
145			max,
146		} => {
147			let cap = exponential_cap(*base, *max, attempt);
148			let cap_nanos = cap.as_nanos().unwrap_or(0).max(0) as u64;
149			if cap_nanos == 0 {
150				return Duration::zero();
151			}
152			let sampled = rng.infra_u64_inclusive(cap_nanos);
153			Duration::from_nanoseconds(sampled as i64).unwrap()
154		}
155	}
156}
157
158fn exponential_cap(base: Duration, max: Duration, attempt: u32) -> Duration {
159	let shift = attempt.min(30);
160	let multiplier = 1i64 << shift;
161	base.saturating_mul(multiplier).min(max)
162}
163
164#[cfg(test)]
165mod retry_tests {
166	use std::cell::Cell;
167
168	use reifydb_runtime::context::rng::Rng;
169	use reifydb_value::{
170		error::{Diagnostic, Error},
171		fragment::Fragment,
172		value::duration::Duration,
173	};
174
175	use super::{Backoff, RetryStrategy, compute_backoff, exponential_cap};
176	use crate::{execution::ExecutionResult, metrics::execution::ExecutionMetrics};
177
178	fn ok() -> ExecutionResult {
179		ExecutionResult {
180			frames: vec![],
181			named_system_columns: vec![],
182			error: None,
183			metrics: ExecutionMetrics::default(),
184		}
185	}
186
187	fn err(code: &str) -> ExecutionResult {
188		ExecutionResult {
189			frames: vec![],
190			named_system_columns: vec![],
191			error: Some(Error(Box::new(Diagnostic {
192				code: code.to_string(),
193				rql: None,
194				message: format!("{} test", code),
195				column: None,
196				fragment: Fragment::None,
197				label: None,
198				help: None,
199				notes: vec![],
200				cause: None,
201				operator_chain: None,
202			}))),
203			metrics: ExecutionMetrics::default(),
204		}
205	}
206
207	fn no_sleep_strategy(max_attempts: u32) -> RetryStrategy {
208		RetryStrategy {
209			max_attempts,
210			backoff: Backoff::None,
211		}
212	}
213
214	#[test]
215	fn success_first_try_runs_closure_once() {
216		let strategy = no_sleep_strategy(5);
217		let rng = Rng::default();
218		let calls = Cell::new(0u32);
219		let result = strategy.execute(&rng, "", || {
220			calls.set(calls.get() + 1);
221			ok()
222		});
223		assert!(result.is_ok());
224		assert_eq!(calls.get(), 1);
225	}
226
227	#[test]
228	fn non_conflict_error_is_not_retried() {
229		let strategy = no_sleep_strategy(5);
230		let rng = Rng::default();
231		let calls = Cell::new(0u32);
232		let result = strategy.execute(&rng, "", || {
233			calls.set(calls.get() + 1);
234			err("TXN_002")
235		});
236		assert!(result.is_err());
237		assert_eq!(calls.get(), 1);
238	}
239
240	#[test]
241	fn conflict_retries_then_succeeds() {
242		let strategy = no_sleep_strategy(5);
243		let rng = Rng::default();
244		let calls = Cell::new(0u32);
245		let result = strategy.execute(&rng, "", || {
246			let n = calls.get();
247			calls.set(n + 1);
248			if n < 2 {
249				err("TXN_001")
250			} else {
251				ok()
252			}
253		});
254		assert!(result.is_ok());
255		assert_eq!(calls.get(), 3);
256	}
257
258	#[test]
259	fn conflict_exhausts_attempts_returns_last_error() {
260		let strategy = no_sleep_strategy(4);
261		let rng = Rng::default();
262		let calls = Cell::new(0u32);
263		let result = strategy.execute(&rng, "", || {
264			calls.set(calls.get() + 1);
265			err("TXN_001")
266		});
267		assert!(result.is_err());
268		assert_eq!(result.error.as_ref().unwrap().code, "TXN_001");
269		assert_eq!(calls.get(), 4);
270	}
271
272	#[test]
273	fn jittered_backoff_stays_within_cap() {
274		let base = Duration::from_milliseconds(10).unwrap();
275		let max = Duration::from_milliseconds(100).unwrap();
276		let backoff = Backoff::ExponentialJitter {
277			base,
278			max,
279		};
280		let rng = Rng::default();
281		for attempt in 0..8 {
282			let cap = exponential_cap(base, max, attempt);
283			for _ in 0..50 {
284				let d = compute_backoff(&backoff, attempt, &rng);
285				assert!(d <= cap, "attempt {}: {:?} exceeds cap {:?}", attempt, d, cap);
286			}
287		}
288	}
289
290	#[test]
291	fn seeded_rng_produces_deterministic_jitter() {
292		let base = Duration::from_milliseconds(5).unwrap();
293		let max = Duration::from_milliseconds(200).unwrap();
294		let backoff = Backoff::ExponentialJitter {
295			base,
296			max,
297		};
298		let sample = |seed: u64| -> Vec<Duration> {
299			let rng = Rng::seeded(seed);
300			(0..8).map(|attempt| compute_backoff(&backoff, attempt, &rng)).collect()
301		};
302		assert_eq!(sample(42), sample(42));
303		assert_ne!(sample(42), sample(43));
304	}
305
306	#[test]
307	fn seeded_rng_produces_exact_pinned_jitter_values() {
308		let base = Duration::from_milliseconds(5).unwrap();
309		let max = Duration::from_milliseconds(200).unwrap();
310		let backoff = Backoff::ExponentialJitter {
311			base,
312			max,
313		};
314		let nanos = |seed: u64| -> Vec<u64> {
315			let rng = Rng::seeded(seed);
316			(0..8).map(|attempt| compute_backoff(&backoff, attempt, &rng).as_nanos().unwrap() as u64)
317				.collect()
318		};
319
320		let expected_42: Vec<u64> = vec![
321			3_848_394,
322			113_809,
323			2_934_288,
324			23_292_485,
325			77_680_508,
326			31_066_617,
327			36_519_179,
328			190_866_841,
329		];
330		let expected_43: Vec<u64> = vec![
331			3_974_671, 4_842_103, 12_057_439, 29_830_325, 72_334_216, 22_229_100, 36_417_439, 81_417_246,
332		];
333
334		assert_eq!(nanos(42), expected_42);
335		assert_eq!(nanos(43), expected_43);
336
337		assert_eq!(nanos(42), expected_42);
338		assert_eq!(nanos(43), expected_43);
339	}
340
341	#[test]
342	fn exponential_cap_saturates_at_max() {
343		let base = Duration::from_milliseconds(5).unwrap();
344		let max = Duration::from_milliseconds(200).unwrap();
345		assert_eq!(exponential_cap(base, max, 0), Duration::from_milliseconds(5).unwrap());
346		assert_eq!(exponential_cap(base, max, 1), Duration::from_milliseconds(10).unwrap());
347		assert_eq!(exponential_cap(base, max, 5), Duration::from_milliseconds(160).unwrap());
348		assert_eq!(exponential_cap(base, max, 6), max);
349		assert_eq!(exponential_cap(base, max, 100), max);
350	}
351
352	#[test]
353	fn default_uses_jittered_backoff() {
354		let s = RetryStrategy::default();
355		assert_eq!(s.max_attempts, 10);
356		match s.backoff {
357			Backoff::ExponentialJitter {
358				base,
359				max,
360			} => {
361				assert_eq!(base, Duration::from_milliseconds(5).unwrap());
362				assert_eq!(max, Duration::from_milliseconds(200).unwrap());
363			}
364			_ => panic!("expected ExponentialJitter default"),
365		}
366	}
367}