Skip to main content

reifydb_sub_server/
claim.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use reifydb_value::{
5	fragment::Fragment,
6	value::{duration::Duration, temporal::parse::duration::parse_duration},
7};
8use serde::{Deserialize, Serialize};
9
10pub const DEFAULT_MAX_N: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct WireClaimRequest {
14	pub queue: String,
15	pub worker: String,
16	#[serde(default)]
17	pub max_n: Option<u32>,
18	#[serde(default)]
19	pub lease_ttl: Option<String>,
20	#[serde(default)]
21	pub wait_for: Option<String>,
22}
23
24#[derive(Debug, Clone)]
25pub struct ClaimRequest {
26	pub queue: String,
27	pub worker: String,
28	pub max_n: u32,
29	pub lease_ttl: Option<Duration>,
30	pub wait_for: Duration,
31}
32
33impl WireClaimRequest {
34	pub fn into_claim_request(self) -> Result<ClaimRequest, String> {
35		Ok(ClaimRequest {
36			queue: self.queue,
37			worker: self.worker,
38			max_n: self.max_n.unwrap_or(DEFAULT_MAX_N),
39			lease_ttl: parse_optional_duration("lease_ttl", self.lease_ttl.as_deref())?,
40			wait_for: parse_optional_duration("wait_for", self.wait_for.as_deref())?
41				.unwrap_or(Duration::zero()),
42		})
43	}
44}
45
46fn parse_optional_duration(field: &str, raw: Option<&str>) -> Result<Option<Duration>, String> {
47	let Some(raw) = raw else {
48		return Ok(None);
49	};
50
51	parse_duration(Fragment::internal(raw))
52		.map(Some)
53		.map_err(|err| format!("invalid {field} value '{raw}': {err:?}"))
54}
55
56#[cfg(not(reifydb_single_threaded))]
57pub mod native {
58	use std::{collections::HashMap, sync::Arc};
59
60	use reifydb_core::{
61		actors::server::Operation, interface::catalog::id::QueueId, metrics::execution::ExecutionMetrics,
62	};
63	use reifydb_engine::queue::{lookup::find_queue_id, wake::QueueWakeRegistry};
64	use reifydb_runtime::sync::waiter::WaiterHandle;
65	use reifydb_value::{
66		params::Params,
67		value::{Value, duration::Duration, frame::frame::Frame, identity::IdentityId},
68	};
69	use tokio::{
70		sync::oneshot,
71		time::{Instant, timeout_at},
72	};
73	use tracing::instrument;
74
75	use super::ClaimRequest;
76	use crate::{
77		dispatch::dispatch,
78		execute::ExecuteError,
79		interceptor::{RequestContext, RequestMetadata},
80		state::AppState,
81	};
82
83	const CLAIM_WITH_TTL: &str = "CALL queue::claim($worker, $queue, $max_n, $lease_ttl)";
84
85	#[instrument(name = "dispatch::claim", level = "debug", skip_all, fields(queue = %request.queue))]
86	pub async fn dispatch_claim(
87		state: &AppState,
88		identity: IdentityId,
89		request: ClaimRequest,
90		metadata: RequestMetadata,
91	) -> Result<(Vec<Frame>, ExecutionMetrics), ExecuteError> {
92		let lease_ttl = request.lease_ttl.unwrap_or_else(|| state.claim_lease_ttl());
93		let params = claim_params(&request.queue, &request.worker, request.max_n, lease_ttl);
94
95		let attempt = || RequestContext {
96			identity,
97			operation: Operation::Command,
98			rql: CLAIM_WITH_TTL.to_string(),
99			params: params.clone(),
100			metadata: metadata.clone(),
101		};
102
103		let first = dispatch(state, attempt()).await?;
104		let budget = request.wait_for.min(state.claim_wait_max());
105		if !budget.is_positive() || claimed_any(&first.0) {
106			return Ok(first);
107		}
108
109		let Some(queue_id) = find_queue_id(state.engine(), identity, &request.queue) else {
110			return Ok(first);
111		};
112		let registry = state.engine().queue_wake();
113		let deadline = Instant::now() + budget.to_std();
114
115		loop {
116			let mut guard = ParkGuard::park(&registry, queue_id);
117
118			let attempted = dispatch(state, attempt()).await?;
119			if claimed_any(&attempted.0) {
120				guard.forward_if_consumed();
121				return Ok(attempted);
122			}
123
124			if timeout_at(deadline, guard.notified()).await.is_err() {
125				return Ok(attempted);
126			}
127		}
128	}
129
130	struct ParkGuard<'a> {
131		registry: &'a QueueWakeRegistry,
132		queue: QueueId,
133		waiter: Arc<WaiterHandle>,
134		notified: Option<oneshot::Receiver<()>>,
135	}
136
137	impl<'a> ParkGuard<'a> {
138		fn park(registry: &'a QueueWakeRegistry, queue: QueueId) -> Self {
139			let (sender, receiver) = oneshot::channel();
140			let waiter = Arc::new(WaiterHandle::with_callback(Box::new(move || {
141				let _ = sender.send(());
142			})));
143			registry.register(queue, waiter.clone());
144
145			Self {
146				registry,
147				queue,
148				waiter,
149				notified: Some(receiver),
150			}
151		}
152
153		async fn notified(&mut self) {
154			if let Some(receiver) = self.notified.take() {
155				let _ = receiver.await;
156			}
157		}
158
159		fn forward_if_consumed(&self) {
160			if self.waiter.wait_timeout(Duration::zero()) {
161				self.registry.nudge(self.queue, 1);
162			}
163		}
164	}
165
166	impl Drop for ParkGuard<'_> {
167		fn drop(&mut self) {
168			self.registry.deregister(self.queue, &self.waiter);
169		}
170	}
171
172	fn claim_params(queue: &str, worker: &str, max_n: u32, lease_ttl: Duration) -> Params {
173		Params::Named(Arc::new(HashMap::from_iter([
174			("worker".to_string(), Value::Utf8(worker.to_string())),
175			("queue".to_string(), Value::Utf8(queue.to_string())),
176			("max_n".to_string(), Value::Uint4(max_n)),
177			("lease_ttl".to_string(), Value::Duration(lease_ttl)),
178		])))
179	}
180
181	fn claimed_any(frames: &[Frame]) -> bool {
182		frames.iter().any(|frame| frame.row_count() > 0)
183	}
184}
185
186#[cfg(test)]
187mod tests {
188	use super::*;
189
190	fn wire(wait_for: Option<&str>, lease_ttl: Option<&str>, max_n: Option<u32>) -> WireClaimRequest {
191		WireClaimRequest {
192			queue: "app::jobs".to_string(),
193			worker: "w1".to_string(),
194			max_n,
195			lease_ttl: lease_ttl.map(str::to_string),
196			wait_for: wait_for.map(str::to_string),
197		}
198	}
199
200	#[test]
201	fn test_an_absent_wait_for_is_a_non_blocking_claim() {
202		// Every existing caller posts without wait_for and must keep getting an immediate answer.
203		// Defaulting to anything positive would silently turn all of them into long-polls.
204		let request = wire(None, None, None).into_claim_request().unwrap();
205
206		assert!(request.wait_for.is_zero());
207		assert_eq!(request.max_n, 1, "a claim with no max_n asks for a single item");
208		assert!(request.lease_ttl.is_none(), "an absent lease ttl must defer to the server default");
209	}
210
211	#[test]
212	fn test_durations_are_parsed_from_their_rql_literal_form() {
213		let request = wire(Some("25s"), Some("2m"), Some(10)).into_claim_request().unwrap();
214
215		assert_eq!(request.wait_for.as_nanos().unwrap(), 25_000_000_000);
216		assert_eq!(request.lease_ttl.unwrap().as_nanos().unwrap(), 120_000_000_000);
217		assert_eq!(request.max_n, 10);
218	}
219
220	#[test]
221	fn test_a_malformed_duration_names_the_field_it_came_from() {
222		// The two duration fields fail the same way, so an error that does not name the field
223		// leaves the caller guessing which of the two they typed wrong.
224		let err = wire(Some("soon"), None, None).into_claim_request().unwrap_err();
225		assert!(err.contains("wait_for"), "error must name the offending field, got: {err}");
226
227		let err = wire(None, Some("forever"), None).into_claim_request().unwrap_err();
228		assert!(err.contains("lease_ttl"), "error must name the offending field, got: {err}");
229	}
230}