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::{
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    // Generate all tokens before touching a delivery row.  Thus an entropy
93    // failure rolls back an entirely unchanged batch, including attempts.
94    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    // `CURRENT_TIMESTAMP` is PostgreSQL's transaction-start timestamp.  The
261    // lifecycle contract needs the instant at which this operation reaches
262    // the database, especially after waiting for a row lock, so use the
263    // database clock that advances during a transaction.
264    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}