Skip to main content

reifydb_engine/
session.rs

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