1use crate::{
9 error::{ClaimError, MutationError},
10 hydrate::{EventRow, hydrate_event},
11 lifecycle_mutation::{Mutation, mutate},
12 rls,
13};
14use dovecote::{
15 AttemptCount, ClaimToken, ClaimedEvent, Delay, Failure, Lease, Limit, QuarantineReason, RowId,
16 TenantId, WorkerId,
17};
18use sqlx::{FromRow, PgPool, Postgres, Transaction, query_as, query_scalar};
19use time::OffsetDateTime;
20
21pub(crate) async fn claim_for_scope(
22 pool: &PgPool,
23 tenant_id: Option<&TenantId>,
24 worker: WorkerId,
25 lease_for: Lease,
26 limit: Limit,
27) -> Result<Vec<ClaimedEvent>, ClaimError> {
28 let mut entropy = OsEntropy;
29 claim_with_entropy(pool, tenant_id, worker, lease_for, limit, &mut entropy).await
30}
31
32async fn claim_with_entropy<E: EntropySource>(
33 pool: &PgPool,
34 tenant_id: Option<&TenantId>,
35 worker: WorkerId,
36 lease_for: Lease,
37 limit: Limit,
38 entropy: &mut E,
39) -> Result<Vec<ClaimedEvent>, ClaimError> {
40 let mut transaction = pool
41 .begin()
42 .await
43 .map_err(|source| ClaimError::sql("begin claim transaction", source))?;
44 if let Some(tenant_id) = tenant_id {
45 rls::bind_tenant(&mut transaction, tenant_id)
46 .await
47 .map_err(|source| ClaimError::sql("bind claim tenant", source))?;
48 }
49
50 let operation_time = database_time(&mut transaction)
51 .await
52 .map_err(|source| ClaimError::sql("read claim operation time", source))?;
53
54 let candidates = query_as::<_, ClaimCandidate>(
55 r"
56 SELECT d.event_row_id,
57 d.tenant_id,
58 d.state,
59 d.attempts,
60 d.claim_token,
61 e.stream,
62 e.specversion,
63 e.event_id,
64 e.source,
65 e.event_type,
66 e.subject,
67 e.occurred_at,
68 e.datacontenttype,
69 e.dataschema,
70 e.partitionkey,
71 e.extensions,
72 e.data_kind,
73 e.data
74 FROM dovecote_deliveries AS d
75 JOIN dovecote_events AS e
76 ON e.tenant_id = d.tenant_id AND e.row_id = d.event_row_id
77 WHERE ($1::varchar IS NULL OR d.tenant_id = $1)
78 AND ((d.state = 'pending' AND d.available_at <= $2)
79 OR (d.state = 'claimed' AND d.claim_expires_at <= $2))
80 ORDER BY d.event_row_id ASC
81 LIMIT $3
82 FOR UPDATE OF d SKIP LOCKED
83 ",
84 )
85 .bind(tenant_id.map(TenantId::as_str))
86 .bind(operation_time)
87 .bind(i64::from(limit.get()))
88 .fetch_all(&mut *transaction)
89 .await
90 .map_err(|source| ClaimError::sql("select claim candidates", source))?;
91
92 let mut used_tokens = Vec::with_capacity(candidates.len());
95 let mut prepared = Vec::with_capacity(candidates.len());
96 for candidate in candidates {
97 let row_id = RowId::new(candidate.event_row_id)
98 .map_err(|error| ClaimError::serialization(error.to_string()))?;
99 let attempts = candidate
100 .attempts
101 .checked_add(1)
102 .ok_or(ClaimError::CounterOverflow { row_id })?;
103 let attempts = AttemptCount::new(attempts)
104 .map_err(|error| ClaimError::serialization(error.to_string()))?;
105 let token = fresh_token(candidate.claim_token.as_deref(), &used_tokens, entropy)
106 .map_err(|source| ClaimError::EntropyUnavailable { source })?;
107 used_tokens.push(token);
108 if candidate.state != "pending" && candidate.state != "claimed" {
109 return Err(ClaimError::serialization(
110 "claim candidate has an ineligible state",
111 ));
112 }
113
114 let event = hydrate_event(&candidate.event_row()).map_err(ClaimError::serialization)?;
115 let tenant_id = TenantId::new(candidate.tenant_id.clone())
116 .map_err(|error| ClaimError::serialization(error.to_string()))?;
117 prepared.push((
118 tenant_id,
119 row_id,
120 candidate.event_row_id,
121 event,
122 attempts,
123 token,
124 ));
125 }
126
127 let mut claimed = Vec::with_capacity(prepared.len());
128 for (tenant_id, row_id, event_row_id, event, attempts, token) in prepared {
129 let expiry = query_scalar::<_, OffsetDateTime>(
130 r"
131 UPDATE dovecote_deliveries
132 SET state = 'claimed',
133 attempts = $3,
134 claim_token = $4,
135 claimed_by = $5,
136 claim_expires_at = $6 + $7
137 WHERE tenant_id = $1 AND event_row_id = $2
138 AND (state = 'pending' OR state = 'claimed')
139 RETURNING claim_expires_at
140 ",
141 )
142 .bind(tenant_id.as_str())
143 .bind(event_row_id)
144 .bind(attempts.get())
145 .bind(token.as_slice())
146 .bind(worker.as_str())
147 .bind(operation_time)
148 .bind(lease_for.get())
149 .fetch_optional(&mut *transaction)
150 .await
151 .map_err(|source| ClaimError::sql("update claimed delivery", source))?
152 .ok_or_else(|| {
153 ClaimError::sql(
154 "update claimed delivery",
155 sqlx::Error::Protocol("claim candidate disappeared while locked".to_owned()),
156 )
157 })?;
158
159 let claimed_event = ClaimedEvent::new(
160 tenant_id,
161 row_id,
162 event,
163 attempts,
164 ClaimToken::from_bytes(token),
165 worker.clone(),
166 expiry,
167 )
168 .map_err(|error| ClaimError::serialization(error.to_string()))?;
169 claimed.push(claimed_event);
170 }
171
172 transaction
173 .commit()
174 .await
175 .map_err(|source| ClaimError::sql("commit claim transaction", source))?;
176 Ok(claimed)
177}
178
179pub(crate) async fn renew_for_scope(
180 pool: &PgPool,
181 tenant_id: Option<&TenantId>,
182 row_id: RowId,
183 claim_token: &ClaimToken,
184 lease_for: Lease,
185) -> Result<(), MutationError> {
186 mutate(
187 pool,
188 tenant_id,
189 row_id,
190 claim_token,
191 Mutation::Renew { lease_for },
192 )
193 .await
194}
195
196pub(crate) async fn ack_for_scope(
197 pool: &PgPool,
198 tenant_id: Option<&TenantId>,
199 row_id: RowId,
200 claim_token: &ClaimToken,
201) -> Result<(), MutationError> {
202 mutate(pool, tenant_id, row_id, claim_token, Mutation::Ack).await
203}
204
205pub(crate) async fn retry_for_scope(
206 pool: &PgPool,
207 tenant_id: Option<&TenantId>,
208 row_id: RowId,
209 claim_token: &ClaimToken,
210 failure: &Failure,
211 backoff: Delay,
212) -> Result<(), MutationError> {
213 mutate(
214 pool,
215 tenant_id,
216 row_id,
217 claim_token,
218 Mutation::Retry { failure, backoff },
219 )
220 .await
221}
222
223pub(crate) async fn release_for_scope(
224 pool: &PgPool,
225 tenant_id: Option<&TenantId>,
226 row_id: RowId,
227 claim_token: &ClaimToken,
228 delay: Delay,
229) -> Result<(), MutationError> {
230 mutate(
231 pool,
232 tenant_id,
233 row_id,
234 claim_token,
235 Mutation::Release { delay },
236 )
237 .await
238}
239
240pub(crate) async fn quarantine_for_scope(
241 pool: &PgPool,
242 tenant_id: Option<&TenantId>,
243 row_id: RowId,
244 claim_token: &ClaimToken,
245 reason: &QuarantineReason,
246) -> Result<(), MutationError> {
247 mutate(
248 pool,
249 tenant_id,
250 row_id,
251 claim_token,
252 Mutation::Quarantine { reason },
253 )
254 .await
255}
256
257async fn database_time(
258 transaction: &mut Transaction<'_, Postgres>,
259) -> Result<OffsetDateTime, sqlx::Error> {
260 query_scalar("SELECT clock_timestamp()")
265 .fetch_one(&mut **transaction)
266 .await
267}
268
269fn fresh_token(
270 previous: Option<&[u8]>,
271 used_tokens: &[[u8; dovecote::CLAIM_TOKEN_BYTES]],
272 entropy: &mut impl EntropySource,
273) -> Result<[u8; dovecote::CLAIM_TOKEN_BYTES], getrandom::Error> {
274 loop {
275 let mut token = [0_u8; dovecote::CLAIM_TOKEN_BYTES];
276 entropy.fill(&mut token)?;
277 let differs_from_previous = previous != Some(token.as_slice());
278 let unique_in_batch = used_tokens.iter().all(|used| used != &token);
279 if differs_from_previous && unique_in_batch {
280 return Ok(token);
281 }
282 }
283}
284
285trait EntropySource {
286 fn fill(&mut self, output: &mut [u8]) -> Result<(), getrandom::Error>;
287}
288
289struct OsEntropy;
290
291impl EntropySource for OsEntropy {
292 fn fill(&mut self, output: &mut [u8]) -> Result<(), getrandom::Error> {
293 getrandom::fill(output)
294 }
295}
296
297#[derive(Debug, FromRow)]
298struct ClaimCandidate {
299 event_row_id: i64,
300 tenant_id: String,
301 state: String,
302 attempts: i64,
303 claim_token: Option<Vec<u8>>,
304 stream: String,
305 specversion: String,
306 event_id: String,
307 source: String,
308 event_type: String,
309 subject: Option<String>,
310 occurred_at: Option<OffsetDateTime>,
311 datacontenttype: Option<String>,
312 dataschema: Option<String>,
313 partitionkey: Option<String>,
314 extensions: String,
315 data_kind: Option<String>,
316 data: Option<Vec<u8>>,
317}
318
319impl ClaimCandidate {
320 fn event_row(&self) -> EventRow<'_> {
321 EventRow {
322 stream: &self.stream,
323 specversion: &self.specversion,
324 event_id: &self.event_id,
325 source: &self.source,
326 event_type: &self.event_type,
327 subject: self.subject.as_deref(),
328 occurred_at: self.occurred_at,
329 datacontenttype: self.datacontenttype.as_deref(),
330 dataschema: self.dataschema.as_deref(),
331 partitionkey: self.partitionkey.as_deref(),
332 extensions: &self.extensions,
333 data_kind: self.data_kind.as_deref(),
334 data: self.data.as_deref(),
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::{EntropySource, OsEntropy, claim_with_entropy, fresh_token};
342 use crate::{ClaimError, MIGRATIONS, check_schema, enqueue::enqueue_for_scope};
343 use dovecote::{
344 EventId, EventSource, EventType, Limit, NewEvent, StreamName, TenantId, WorkerId,
345 };
346 use sqlx::{
347 postgres::{PgConnectOptions, PgPoolOptions},
348 query, query_as, raw_sql,
349 };
350 use std::{
351 error::Error,
352 str::FromStr,
353 time::{SystemTime, UNIX_EPOCH},
354 };
355
356 #[test]
357 fn generated_tokens_are_distinct_from_previous_and_batch_values() {
358 let mut entropy = OsEntropy;
359 let first = fresh_token(None, &[], &mut entropy).expect("OS entropy available");
360 let second =
361 fresh_token(Some(&first), &[first], &mut entropy).expect("OS entropy available");
362 assert_ne!(first, second);
363 }
364
365 struct FailsEntropy;
366
367 impl EntropySource for FailsEntropy {
368 fn fill(&mut self, _output: &mut [u8]) -> Result<(), getrandom::Error> {
369 Err(getrandom::Error::new_custom(1))
370 }
371 }
372
373 fn entropy_event(id: &str) -> NewEvent {
374 NewEvent::new(
375 StreamName::new("audit").expect("valid stream"),
376 EventId::new(id).expect("valid id"),
377 EventSource::new("https://example.test/source").expect("valid source"),
378 EventType::new("com.example.entropy").expect("valid event type"),
379 )
380 .expect("valid event")
381 }
382
383 #[test]
384 fn entropy_failure_is_returned_before_a_token_is_accepted() {
385 let mut entropy = FailsEntropy;
386 let error = fresh_token(None, &[], &mut entropy).expect_err("injected failure");
387 assert_eq!(error.raw_os_error(), None);
388 }
389
390 #[tokio::test]
391 async fn injected_entropy_failure_leaves_the_claim_batch_unchanged_when_configured()
392 -> Result<(), Box<dyn Error>> {
393 let Ok(url) = std::env::var("DOVECOTE_POSTGRES_URL") else {
394 return Ok(());
395 };
396
397 let admin = PgPoolOptions::new()
398 .max_connections(2)
399 .connect(&url)
400 .await?;
401 let suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
402 let schema = format!("dovecote_entropy_test_{}_{}", std::process::id(), suffix);
403 query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA \"{schema}\"")))
404 .execute(&admin)
405 .await?;
406 let result = async {
407 let options = PgConnectOptions::from_str(&url)?.options([
408 ("search_path", format!("\"{schema}\"")),
409 ]);
410 let pool = PgPoolOptions::new()
411 .max_connections(3)
412 .connect_with(options)
413 .await?;
414 raw_sql(MIGRATIONS[0].sql()).execute(&pool).await?;
415 check_schema(&pool).await?;
416
417 let mut transaction = pool.begin().await?;
418 let tenant = TenantId::new("entropy-test")?;
419 enqueue_for_scope(&mut transaction, &tenant, entropy_event("entropy-first")).await?;
420 enqueue_for_scope(&mut transaction, &tenant, entropy_event("entropy-second")).await?;
421 transaction.commit().await?;
422
423 let mut entropy = FailsEntropy;
424 let claim = claim_with_entropy(
425 &pool,
426 None,
427 WorkerId::new("entropy-worker")?,
428 dovecote::Lease::new(std::time::Duration::from_secs(5))?,
429 Limit::new(2)?,
430 &mut entropy,
431 )
432 .await;
433 assert!(matches!(claim, Err(ClaimError::EntropyUnavailable { .. })));
434 let snapshots = query_as::<_, (String, i64, Option<Vec<u8>>, Option<time::OffsetDateTime>)>(
435 "SELECT state, attempts, claim_token, claim_expires_at FROM dovecote_deliveries ORDER BY event_row_id",
436 )
437 .fetch_all(&pool)
438 .await?;
439 assert_eq!(snapshots.len(), 2);
440 assert!(snapshots
441 .iter()
442 .all(|(state, attempts, token, expiry)| state == "pending"
443 && *attempts == 0
444 && token.is_none()
445 && expiry.is_none()));
446 pool.close().await;
447 Ok::<_, Box<dyn Error>>(())
448 }
449 .await;
450 query(sqlx::AssertSqlSafe(format!(
451 "DROP SCHEMA \"{schema}\" CASCADE"
452 )))
453 .execute(&admin)
454 .await?;
455 admin.close().await;
456 result
457 }
458}