Skip to main content

reifydb_engine/
session.rs

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