Skip to main content

dovecote_sqlx_postgres/
lifecycle.rs

1//! PostgreSQL claims and claim-token-fenced delivery mutations.
2//!
3//! A claim transaction only selects and updates durable state.  In particular,
4//! no transport or caller code is run while its locks are held.  The returned
5//! events are assembled before commit but are handed to the caller only after
6//! the claim transaction has committed.
7
8use 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
16/// Claims pending and expired events in ascending row-id order.
17pub 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    // Generate all tokens before touching a delivery row.  Thus an entropy
76    // failure rolls back an entirely unchanged batch, including attempts.
77    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
145/// Renews one current, unexpired claim using PostgreSQL's current time.
146pub 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
155/// Acknowledges one current, unexpired claim.
156pub 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
164/// Returns one current, unexpired claim to pending and records its failure.
165pub 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
181/// Returns one current, unexpired claim to pending after a delay.
182pub 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
191/// Moves one current, unexpired claim to the terminal quarantined state.
192pub 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        // The common case is one conditional update and commit.  The statement
214        // itself materializes a target-row lock before evaluating the database
215        // clock, so a worker blocked behind a lease that expires is never allowed
216        // to mutate using an instant from before the lock wait.  Only a zero-row
217        // result needs the classification path below.
218        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            // The row remains locked.  A second lock/clock read distinguishes a
230            // lease that expired during the retry from an unexpected database-side
231            // no-op; the latter remains an actionable SQL error.
232            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    // `CURRENT_TIMESTAMP` is PostgreSQL's transaction-start timestamp.  The
489    // lifecycle contract needs the instant at which this operation reaches
490    // the database, especially after waiting for a row lock, so use the
491    // database clock that advances during a transaction.
492    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}