1use crate::error::{ClaimError, MutationError};
9use dovecote::{
10 AttemptCount, ClaimToken, ClaimedEvent, Delay, DeliveryState, EventData, EventSizeLimit,
11 Failure, Lease, Limit, NewEvent, QuarantineReason, RowId, StoredEvent, WorkerId,
12};
13use sqlx::{FromRow, PgPool, Postgres, Transaction, query, query_as, query_scalar};
14use time::OffsetDateTime;
15
16pub async fn claim(
18 pool: &PgPool,
19 worker: WorkerId,
20 lease_for: Lease,
21 limit: Limit,
22) -> Result<Vec<ClaimedEvent>, ClaimError> {
23 let mut entropy = OsEntropy;
24 claim_with_entropy(pool, worker, lease_for, limit, &mut entropy).await
25}
26
27async fn claim_with_entropy<E: EntropySource>(
28 pool: &PgPool,
29 worker: WorkerId,
30 lease_for: Lease,
31 limit: Limit,
32 entropy: &mut E,
33) -> Result<Vec<ClaimedEvent>, ClaimError> {
34 let mut transaction = pool
35 .begin()
36 .await
37 .map_err(|source| ClaimError::sql("begin claim transaction", source))?;
38 let operation_time = database_time(&mut transaction)
39 .await
40 .map_err(|source| ClaimError::sql("read claim operation time", source))?;
41 let candidates = query_as::<_, ClaimCandidate>(
42 r#"
43 SELECT d.event_row_id,
44 d.state,
45 d.attempts,
46 d.claim_token,
47 e.stream,
48 e.specversion,
49 e.event_id,
50 e.source,
51 e.event_type,
52 e.subject,
53 e.occurred_at,
54 e.datacontenttype,
55 e.dataschema,
56 e.partitionkey,
57 e.extensions,
58 e.data_kind,
59 e.data
60 FROM dovecote_deliveries AS d
61 JOIN dovecote_events AS e ON e.row_id = d.event_row_id
62 WHERE (d.state = 'pending' AND d.available_at <= $1)
63 OR (d.state = 'claimed' AND d.claim_expires_at <= $1)
64 ORDER BY d.event_row_id ASC
65 LIMIT $2
66 FOR UPDATE OF d SKIP LOCKED
67 "#,
68 )
69 .bind(operation_time)
70 .bind(i64::from(limit.get()))
71 .fetch_all(&mut *transaction)
72 .await
73 .map_err(|source| ClaimError::sql("select claim candidates", source))?;
74
75 let mut used_tokens = Vec::with_capacity(candidates.len());
78 let mut prepared = Vec::with_capacity(candidates.len());
79 for candidate in candidates {
80 let row_id = RowId::new(candidate.event_row_id)
81 .map_err(|error| ClaimError::serialization(error.to_string()))?;
82 let attempts = candidate
83 .attempts
84 .checked_add(1)
85 .ok_or(ClaimError::CounterOverflow { row_id })?;
86 let attempts = AttemptCount::new(attempts)
87 .map_err(|error| ClaimError::serialization(error.to_string()))?;
88 let token = fresh_token(candidate.claim_token.as_deref(), &used_tokens, entropy)
89 .map_err(|source| ClaimError::EntropyUnavailable { source })?;
90 used_tokens.push(token);
91 let event = hydrate_event(&candidate).map_err(ClaimError::serialization)?;
92 prepared.push((row_id, candidate.event_row_id, event, attempts, token));
93 }
94
95 let mut claimed = Vec::with_capacity(prepared.len());
96 for (row_id, event_row_id, event, attempts, token) in prepared {
97 let expiry = query_scalar::<_, OffsetDateTime>(
98 r#"
99 UPDATE dovecote_deliveries
100 SET state = 'claimed',
101 attempts = $2,
102 claim_token = $3,
103 claimed_by = $4,
104 claim_expires_at = $5 + $6
105 WHERE event_row_id = $1
106 AND (state = 'pending' OR state = 'claimed')
107 RETURNING claim_expires_at
108 "#,
109 )
110 .bind(event_row_id)
111 .bind(attempts.get())
112 .bind(token.as_slice())
113 .bind(worker.as_str())
114 .bind(operation_time)
115 .bind(lease_for.get())
116 .fetch_optional(&mut *transaction)
117 .await
118 .map_err(|source| ClaimError::sql("update claimed delivery", source))?
119 .ok_or_else(|| {
120 ClaimError::sql(
121 "update claimed delivery",
122 sqlx::Error::Protocol("claim candidate disappeared while locked".to_owned()),
123 )
124 })?;
125
126 let claimed_event = ClaimedEvent::new(
127 row_id,
128 event,
129 attempts,
130 ClaimToken::from_bytes(token),
131 worker.clone(),
132 expiry,
133 )
134 .map_err(|error| ClaimError::serialization(error.to_string()))?;
135 claimed.push(claimed_event);
136 }
137
138 transaction
139 .commit()
140 .await
141 .map_err(|source| ClaimError::sql("commit claim transaction", source))?;
142 Ok(claimed)
143}
144
145pub async fn renew(
147 pool: &PgPool,
148 row_id: RowId,
149 claim_token: &ClaimToken,
150 lease_for: Lease,
151) -> Result<(), MutationError> {
152 mutate(pool, row_id, claim_token, Mutation::Renew { lease_for }).await
153}
154
155pub async fn ack(
157 pool: &PgPool,
158 row_id: RowId,
159 claim_token: &ClaimToken,
160) -> Result<(), MutationError> {
161 mutate(pool, row_id, claim_token, Mutation::Ack).await
162}
163
164pub async fn retry(
166 pool: &PgPool,
167 row_id: RowId,
168 claim_token: &ClaimToken,
169 failure: &Failure,
170 backoff: Delay,
171) -> Result<(), MutationError> {
172 mutate(
173 pool,
174 row_id,
175 claim_token,
176 Mutation::Retry { failure, backoff },
177 )
178 .await
179}
180
181pub async fn release(
183 pool: &PgPool,
184 row_id: RowId,
185 claim_token: &ClaimToken,
186 delay: Delay,
187) -> Result<(), MutationError> {
188 mutate(pool, row_id, claim_token, Mutation::Release { delay }).await
189}
190
191pub async fn quarantine(
193 pool: &PgPool,
194 row_id: RowId,
195 claim_token: &ClaimToken,
196 reason: &QuarantineReason,
197) -> Result<(), MutationError> {
198 mutate(pool, row_id, claim_token, Mutation::Quarantine { reason }).await
199}
200
201async fn mutate(
202 pool: &PgPool,
203 row_id: RowId,
204 claim_token: &ClaimToken,
205 mutation: Mutation<'_>,
206) -> Result<(), MutationError> {
207 let mut transaction = pool
208 .begin()
209 .await
210 .map_err(|source| MutationError::sql("begin mutation transaction", source))?;
211
212 let result: Result<(), MutationError> = async {
213 let affected = execute_mutation(&mut transaction, row_id, claim_token, mutation).await?;
219 if affected == 1 {
220 return Ok(());
221 }
222
223 let delivery = lock_delivery(&mut transaction, row_id).await?;
224
225 classify_delivery(&delivery, claim_token)?;
226
227 let affected = execute_mutation(&mut transaction, row_id, claim_token, mutation).await?;
228 if affected != 1 {
229 let latest = lock_delivery(&mut transaction, row_id).await?;
233 classify_delivery(&latest, claim_token).map_err(|error| match error {
234 MutationError::LostClaim | MutationError::IllegalTransition { .. } => error,
235 other => other,
236 })?;
237 return Err(MutationError::sql(
238 "conditional delivery mutation",
239 sqlx::Error::Protocol(
240 "locked claimed delivery did not satisfy mutation".to_owned(),
241 ),
242 ));
243 }
244
245 Ok(())
246 }
247 .await;
248
249 match result {
250 Ok(()) => transaction
251 .commit()
252 .await
253 .map_err(|source| MutationError::sql("commit mutation transaction", source)),
254 Err(error) => {
255 let _ = transaction.rollback().await;
256 Err(error)
257 }
258 }
259}
260
261async fn execute_mutation(
262 transaction: &mut Transaction<'_, Postgres>,
263 row_id: RowId,
264 claim_token: &ClaimToken,
265 mutation: Mutation<'_>,
266) -> Result<u64, MutationError> {
267 let token = claim_token.as_bytes().as_slice();
268 let result = match mutation {
269 Mutation::Renew { lease_for } => {
270 query(
271 r#"
272 WITH locked AS MATERIALIZED (
273 SELECT event_row_id
274 FROM dovecote_deliveries
275 WHERE event_row_id = $1
276 FOR UPDATE
277 ), operation AS MATERIALIZED (
278 SELECT locked.event_row_id, clock_timestamp() AS operation_time
279 FROM locked
280 )
281 UPDATE dovecote_deliveries AS delivery
282 SET claim_expires_at = operation.operation_time + $3
283 FROM operation
284 WHERE delivery.event_row_id = operation.event_row_id
285 AND delivery.state = 'claimed'
286 AND delivery.claim_token = $2
287 AND delivery.claim_expires_at > operation.operation_time
288 "#,
289 )
290 .bind(row_id.get())
291 .bind(token)
292 .bind(lease_for.get())
293 .execute(&mut **transaction)
294 .await
295 }
296 Mutation::Ack => {
297 query(
298 r#"
299 WITH locked AS MATERIALIZED (
300 SELECT event_row_id
301 FROM dovecote_deliveries
302 WHERE event_row_id = $1
303 FOR UPDATE
304 ), operation AS MATERIALIZED (
305 SELECT locked.event_row_id, clock_timestamp() AS operation_time
306 FROM locked
307 )
308 UPDATE dovecote_deliveries AS delivery
309 SET state = 'delivered',
310 claim_token = NULL,
311 claimed_by = NULL,
312 claim_expires_at = NULL,
313 delivered_at = operation.operation_time
314 FROM operation
315 WHERE delivery.event_row_id = operation.event_row_id
316 AND delivery.state = 'claimed'
317 AND delivery.claim_token = $2
318 AND delivery.claim_expires_at > operation.operation_time
319 "#,
320 )
321 .bind(row_id.get())
322 .bind(token)
323 .execute(&mut **transaction)
324 .await
325 }
326 Mutation::Retry { failure, backoff } => {
327 query(
328 r#"
329 WITH locked AS MATERIALIZED (
330 SELECT event_row_id
331 FROM dovecote_deliveries
332 WHERE event_row_id = $1
333 FOR UPDATE
334 ), operation AS MATERIALIZED (
335 SELECT locked.event_row_id, clock_timestamp() AS operation_time
336 FROM locked
337 )
338 UPDATE dovecote_deliveries AS delivery
339 SET state = 'pending',
340 available_at = operation.operation_time + $3,
341 claim_token = NULL,
342 claimed_by = NULL,
343 claim_expires_at = NULL,
344 last_failure_code = $4,
345 last_failure_detail = $5
346 FROM operation
347 WHERE delivery.event_row_id = operation.event_row_id
348 AND delivery.state = 'claimed'
349 AND delivery.claim_token = $2
350 AND delivery.claim_expires_at > operation.operation_time
351 "#,
352 )
353 .bind(row_id.get())
354 .bind(token)
355 .bind(backoff.get())
356 .bind(failure.code())
357 .bind(failure.detail())
358 .execute(&mut **transaction)
359 .await
360 }
361 Mutation::Release { delay } => {
362 query(
363 r#"
364 WITH locked AS MATERIALIZED (
365 SELECT event_row_id
366 FROM dovecote_deliveries
367 WHERE event_row_id = $1
368 FOR UPDATE
369 ), operation AS MATERIALIZED (
370 SELECT locked.event_row_id, clock_timestamp() AS operation_time
371 FROM locked
372 )
373 UPDATE dovecote_deliveries AS delivery
374 SET state = 'pending',
375 available_at = operation.operation_time + $3,
376 claim_token = NULL,
377 claimed_by = NULL,
378 claim_expires_at = NULL
379 FROM operation
380 WHERE delivery.event_row_id = operation.event_row_id
381 AND delivery.state = 'claimed'
382 AND delivery.claim_token = $2
383 AND delivery.claim_expires_at > operation.operation_time
384 "#,
385 )
386 .bind(row_id.get())
387 .bind(token)
388 .bind(delay.get())
389 .execute(&mut **transaction)
390 .await
391 }
392 Mutation::Quarantine { reason } => {
393 query(
394 r#"
395 WITH locked AS MATERIALIZED (
396 SELECT event_row_id
397 FROM dovecote_deliveries
398 WHERE event_row_id = $1
399 FOR UPDATE
400 ), operation AS MATERIALIZED (
401 SELECT locked.event_row_id, clock_timestamp() AS operation_time
402 FROM locked
403 )
404 UPDATE dovecote_deliveries AS delivery
405 SET state = 'quarantined',
406 claim_token = NULL,
407 claimed_by = NULL,
408 claim_expires_at = NULL,
409 quarantined_at = operation.operation_time,
410 quarantine_reason = $3
411 FROM operation
412 WHERE delivery.event_row_id = operation.event_row_id
413 AND delivery.state = 'claimed'
414 AND delivery.claim_token = $2
415 AND delivery.claim_expires_at > operation.operation_time
416 "#,
417 )
418 .bind(row_id.get())
419 .bind(token)
420 .bind(reason.as_str())
421 .execute(&mut **transaction)
422 .await
423 }
424 };
425 result
426 .map(|result| result.rows_affected())
427 .map_err(|source| MutationError::sql("execute conditional delivery mutation", source))
428}
429
430async fn lock_delivery(
431 transaction: &mut Transaction<'_, Postgres>,
432 row_id: RowId,
433) -> Result<DeliveryForMutation, MutationError> {
434 query_as::<_, DeliveryForMutation>(
435 r#"
436 WITH locked AS MATERIALIZED (
437 SELECT state, claim_token, claim_expires_at
438 FROM dovecote_deliveries
439 WHERE event_row_id = $1
440 FOR UPDATE
441 ), operation AS MATERIALIZED (
442 SELECT locked.*, clock_timestamp() AS operation_time
443 FROM locked
444 )
445 SELECT state, claim_token, claim_expires_at, operation_time
446 FROM operation
447 "#,
448 )
449 .bind(row_id.get())
450 .fetch_optional(&mut **transaction)
451 .await
452 .map_err(|source| MutationError::sql("lock delivery for mutation", source))?
453 .ok_or(MutationError::NotFound)
454}
455
456fn classify_delivery(
457 delivery: &DeliveryForMutation,
458 claim_token: &ClaimToken,
459) -> Result<(), MutationError> {
460 let state = parse_state(&delivery.state)?;
461 if state != DeliveryState::Claimed {
462 return Err(MutationError::IllegalTransition { state });
463 }
464
465 let stored_token = delivery
466 .claim_token
467 .as_deref()
468 .ok_or_else(|| MutationError::serialization("claimed delivery has no claim token"))?;
469 if stored_token.len() != dovecote::CLAIM_TOKEN_BYTES {
470 return Err(MutationError::serialization(
471 "claimed delivery has an invalid claim token width",
472 ));
473 }
474
475 let expires_at = delivery
476 .claim_expires_at
477 .ok_or_else(|| MutationError::serialization("claimed delivery has no claim expiry"))?;
478 if stored_token != claim_token.as_bytes() || expires_at <= delivery.operation_time {
479 return Err(MutationError::LostClaim);
480 }
481
482 Ok(())
483}
484
485async fn database_time(
486 transaction: &mut Transaction<'_, Postgres>,
487) -> Result<OffsetDateTime, sqlx::Error> {
488 query_scalar("SELECT clock_timestamp()")
493 .fetch_one(&mut **transaction)
494 .await
495}
496
497fn fresh_token(
498 previous: Option<&[u8]>,
499 used_tokens: &[[u8; dovecote::CLAIM_TOKEN_BYTES]],
500 entropy: &mut impl EntropySource,
501) -> Result<[u8; dovecote::CLAIM_TOKEN_BYTES], getrandom::Error> {
502 loop {
503 let mut token = [0_u8; dovecote::CLAIM_TOKEN_BYTES];
504 entropy.fill(&mut token)?;
505 let differs_from_previous = previous != Some(token.as_slice());
506 let unique_in_batch = used_tokens.iter().all(|used| used != &token);
507 if differs_from_previous && unique_in_batch {
508 return Ok(token);
509 }
510 }
511}
512
513trait EntropySource {
514 fn fill(&mut self, output: &mut [u8]) -> Result<(), getrandom::Error>;
515}
516
517struct OsEntropy;
518
519impl EntropySource for OsEntropy {
520 fn fill(&mut self, output: &mut [u8]) -> Result<(), getrandom::Error> {
521 getrandom::fill(output)
522 }
523}
524
525#[derive(Debug, FromRow)]
526struct ClaimCandidate {
527 event_row_id: i64,
528 state: String,
529 attempts: i64,
530 claim_token: Option<Vec<u8>>,
531 stream: String,
532 specversion: String,
533 event_id: String,
534 source: String,
535 event_type: String,
536 subject: Option<String>,
537 occurred_at: Option<OffsetDateTime>,
538 datacontenttype: Option<String>,
539 dataschema: Option<String>,
540 partitionkey: Option<String>,
541 extensions: String,
542 data_kind: Option<String>,
543 data: Option<Vec<u8>>,
544}
545
546#[derive(Debug, FromRow)]
547struct DeliveryForMutation {
548 state: String,
549 claim_token: Option<Vec<u8>>,
550 claim_expires_at: Option<OffsetDateTime>,
551 operation_time: OffsetDateTime,
552}
553
554#[derive(Clone, Copy)]
555enum Mutation<'a> {
556 Renew {
557 lease_for: Lease,
558 },
559 Ack,
560 Retry {
561 failure: &'a Failure,
562 backoff: Delay,
563 },
564 Release {
565 delay: Delay,
566 },
567 Quarantine {
568 reason: &'a QuarantineReason,
569 },
570}
571
572fn parse_state(value: &str) -> Result<DeliveryState, MutationError> {
573 match value {
574 "pending" => Ok(DeliveryState::Pending),
575 "claimed" => Ok(DeliveryState::Claimed),
576 "delivered" => Ok(DeliveryState::Delivered),
577 "quarantined" => Ok(DeliveryState::Quarantined),
578 _ => Err(MutationError::serialization(format!(
579 "unknown delivery state {value:?}"
580 ))),
581 }
582}
583
584fn hydrate_event(candidate: &ClaimCandidate) -> Result<StoredEvent, String> {
585 if candidate.state != "pending" && candidate.state != "claimed" {
586 return Err("claim candidate has an ineligible state".to_owned());
587 }
588
589 if candidate.specversion != dovecote::SPEC_VERSION {
590 return Err("stored event has an unsupported specversion".to_owned());
591 }
592
593 let stream =
594 dovecote::StreamName::new(candidate.stream.clone()).map_err(|error| error.to_string())?;
595 let id =
596 dovecote::EventId::new(candidate.event_id.clone()).map_err(|error| error.to_string())?;
597 let source =
598 dovecote::EventSource::new(candidate.source.clone()).map_err(|error| error.to_string())?;
599 let event_type = dovecote::EventType::new(candidate.event_type.clone())
600 .map_err(|error| error.to_string())?;
601 let mut builder = NewEvent::builder(stream, id, source, event_type);
602 builder = match &candidate.subject {
603 Some(value) => builder.subject(
604 dovecote::EventSubject::new(value.clone()).map_err(|error| error.to_string())?,
605 ),
606 None => builder,
607 };
608
609 builder = match candidate.occurred_at {
610 Some(value) => builder.time(value),
611 None => builder,
612 };
613
614 builder = match &candidate.datacontenttype {
615 Some(value) => builder.datacontenttype(
616 dovecote::ContentType::new(value.clone()).map_err(|error| error.to_string())?,
617 ),
618 None => builder,
619 };
620
621 builder = match &candidate.dataschema {
622 Some(value) => builder.dataschema(
623 dovecote::SchemaUri::new(value.clone()).map_err(|error| error.to_string())?,
624 ),
625 None => builder,
626 };
627
628 builder = match &candidate.partitionkey {
629 Some(value) => builder.partitionkey(
630 dovecote::PartitionKey::new(value.clone()).map_err(|error| error.to_string())?,
631 ),
632 None => builder,
633 };
634
635 builder = builder.extensions(
636 dovecote::Extensions::from_canonical_json(&candidate.extensions)
637 .map_err(|error| error.to_string())?,
638 );
639 match (&candidate.data_kind, &candidate.data) {
640 (None, None) => {}
641 (Some(kind), Some(bytes)) if kind == "json" => {
642 builder =
643 builder.data(EventData::json(bytes.clone()).map_err(|error| error.to_string())?);
644 }
645 (Some(kind), Some(bytes)) if kind == "binary" => {
646 builder = builder.data(EventData::binary(bytes.clone()));
647 }
648 _ => return Err("stored data kind and data columns do not agree".to_owned()),
649 };
650
651 builder
652 .build_with_limit(EventSizeLimit::new(usize::MAX).expect("maximum size is non-zero"))
653 .map_err(|error| error.to_string())?
654 .into_stored()
655 .map_err(|error| error.to_string())
656}
657
658#[cfg(test)]
659mod tests {
660 use super::{EntropySource, OsEntropy, claim_with_entropy, fresh_token};
661 use crate::{ClaimError, MIGRATIONS, check_schema, enqueue};
662 use dovecote::{EventId, EventSource, EventType, Limit, NewEvent, StreamName, WorkerId};
663 use sqlx::{
664 postgres::{PgConnectOptions, PgPoolOptions},
665 query, query_as, raw_sql,
666 };
667 use std::{
668 error::Error,
669 str::FromStr,
670 time::{SystemTime, UNIX_EPOCH},
671 };
672
673 #[test]
674 fn generated_tokens_are_distinct_from_previous_and_batch_values() {
675 let mut entropy = OsEntropy;
676 let first = fresh_token(None, &[], &mut entropy).expect("OS entropy available");
677 let second =
678 fresh_token(Some(&first), &[first], &mut entropy).expect("OS entropy available");
679 assert_ne!(first, second);
680 }
681
682 struct FailsEntropy;
683
684 impl EntropySource for FailsEntropy {
685 fn fill(&mut self, _output: &mut [u8]) -> Result<(), getrandom::Error> {
686 Err(getrandom::Error::new_custom(1))
687 }
688 }
689
690 fn entropy_event(id: &str) -> NewEvent {
691 NewEvent::new(
692 StreamName::new("audit").expect("valid stream"),
693 EventId::new(id).expect("valid id"),
694 EventSource::new("https://example.test/source").expect("valid source"),
695 EventType::new("com.example.entropy").expect("valid event type"),
696 )
697 .expect("valid event")
698 }
699
700 #[test]
701 fn entropy_failure_is_returned_before_a_token_is_accepted() {
702 let mut entropy = FailsEntropy;
703 let error = fresh_token(None, &[], &mut entropy).expect_err("injected failure");
704 assert_eq!(error.raw_os_error(), None);
705 }
706
707 #[tokio::test]
708 async fn injected_entropy_failure_leaves_the_claim_batch_unchanged_when_configured()
709 -> Result<(), Box<dyn Error>> {
710 let Ok(url) = std::env::var("DOVECOTE_POSTGRES_URL") else {
711 return Ok(());
712 };
713
714 let admin = PgPoolOptions::new()
715 .max_connections(2)
716 .connect(&url)
717 .await?;
718 let suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
719 let schema = format!("dovecote_entropy_test_{}_{}", std::process::id(), suffix);
720 query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA \"{schema}\"")))
721 .execute(&admin)
722 .await?;
723 let result = async {
724 let options = PgConnectOptions::from_str(&url)?.options([
725 ("search_path", format!("\"{schema}\"")),
726 ]);
727 let pool = PgPoolOptions::new()
728 .max_connections(3)
729 .connect_with(options)
730 .await?;
731 raw_sql(MIGRATIONS[0].sql()).execute(&pool).await?;
732 check_schema(&pool).await?;
733
734 let mut transaction = pool.begin().await?;
735 enqueue(&mut transaction, entropy_event("entropy-first")).await?;
736 enqueue(&mut transaction, entropy_event("entropy-second")).await?;
737 transaction.commit().await?;
738
739 let mut entropy = FailsEntropy;
740 let claim = claim_with_entropy(
741 &pool,
742 WorkerId::new("entropy-worker")?,
743 dovecote::Lease::new(std::time::Duration::from_secs(5))?,
744 Limit::new(2)?,
745 &mut entropy,
746 )
747 .await;
748 assert!(matches!(claim, Err(ClaimError::EntropyUnavailable { .. })));
749 let snapshots = query_as::<_, (String, i64, Option<Vec<u8>>, Option<time::OffsetDateTime>)>(
750 "SELECT state, attempts, claim_token, claim_expires_at FROM dovecote_deliveries ORDER BY event_row_id",
751 )
752 .fetch_all(&pool)
753 .await?;
754 assert_eq!(snapshots.len(), 2);
755 assert!(snapshots
756 .iter()
757 .all(|(state, attempts, token, expiry)| state == "pending"
758 && *attempts == 0
759 && token.is_none()
760 && expiry.is_none()));
761 pool.close().await;
762 Ok::<_, Box<dyn Error>>(())
763 }
764 .await;
765 query(sqlx::AssertSqlSafe(format!(
766 "DROP SCHEMA \"{schema}\" CASCADE"
767 )))
768 .execute(&admin)
769 .await?;
770 admin.close().await;
771 result
772 }
773}