Skip to main content

dovecote_sqlx_postgres/
page.rs

1//! `PostgreSQL` live and finite snapshot paging.
2//!
3//! Live pages are independent reads: they are ordered by the immutable event
4//! row ID, but callers must reconcile later if concurrent commits can invert
5//! row-ID allocation and commit order.  [`SnapshotPager`] keeps one read-only
6//! repeatable-read transaction for a finite export instead.
7
8use crate::{
9    error::PageError,
10    hydrate::{EventRow, hydrate_event},
11    rls,
12};
13use dovecote::{
14    AttemptCount, DeliverySnapshot, Failure, Limit, PagedEvent, QuarantineReason, RowId, TenantId,
15    WorkerId,
16};
17use sqlx::{FromRow, PgConnection, PgPool, Postgres, Transaction, query_as, query_scalar};
18use std::marker::PhantomData;
19use time::OffsetDateTime;
20
21pub(crate) async fn page_for_scope(
22    pool: &PgPool,
23    tenant_id: Option<&TenantId>,
24    after_row_id: Option<RowId>,
25    limit: Limit,
26) -> Result<Vec<PagedEvent>, PageError> {
27    if let Some(tenant_id) = tenant_id {
28        let mut transaction = pool
29            .begin()
30            .await
31            .map_err(|source| PageError::sql("begin scoped live page transaction", source))?;
32        rls::bind_tenant(&mut transaction, tenant_id)
33            .await
34            .map_err(|source| PageError::sql("bind live page tenant", source))?;
35        let result = query_page_on_connection(
36            &mut transaction,
37            Some(tenant_id),
38            after_row_id.map_or(0, RowId::get),
39            None,
40            limit,
41        )
42        .await;
43        return match result {
44            Ok(rows) => {
45                transaction.commit().await.map_err(|source| {
46                    PageError::sql("finish scoped live page transaction", source)
47                })?;
48                Ok(rows)
49            }
50            Err(error) => {
51                let _ = transaction.rollback().await;
52                Err(error)
53            }
54        };
55    }
56
57    let mut connection = pool
58        .acquire()
59        .await
60        .map_err(|source| PageError::sql("acquire live page connection", source))?;
61    query_page_on_connection(
62        &mut connection,
63        tenant_id,
64        after_row_id.map_or(0, RowId::get),
65        None,
66        limit,
67    )
68    .await
69}
70
71pub(crate) async fn begin_snapshot_for_scope(
72    pool: &PgPool,
73    tenant_id: Option<&TenantId>,
74) -> Result<SnapshotPager, PageError> {
75    let mut transaction = pool
76        .begin_with(sqlx::AssertSqlSafe(
77            "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY",
78        ))
79        .await
80        .map_err(|source| PageError::sql("begin snapshot transaction", source))?;
81    if let Some(tenant_id) = tenant_id {
82        rls::bind_tenant(&mut transaction, tenant_id)
83            .await
84            .map_err(|source| PageError::sql("bind snapshot tenant", source))?;
85    }
86
87    let upper_bound = query_scalar::<_, Option<i64>>(
88        "SELECT MAX(row_id) FROM dovecote_events WHERE ($1::varchar IS NULL OR tenant_id = $1)",
89    )
90    .bind(tenant_id.map(TenantId::as_str))
91    .fetch_one(&mut *transaction)
92    .await
93    .map_err(|source| PageError::sql("read snapshot upper row ID", source))?
94    .map(|value| RowId::new(value).map_err(|error| PageError::serialization(error.to_string())))
95    .transpose()?;
96
97    Ok(SnapshotPager {
98        transaction,
99        upper_bound,
100        cursor: None,
101        exhausted: upper_bound.is_none(),
102        tenant_id: tenant_id.cloned(),
103        _not_send: PhantomData,
104    })
105}
106
107/// A bounded, finite read over one `PostgreSQL` repeatable-read snapshot.
108///
109/// The pager owns the connection-bound transaction.  It does not accept an
110/// arbitrary executor and never releases the transaction between pages.  A
111/// pager is intentionally not a stream: callers choose explicit page bounds
112/// and must finish or roll back the read transaction.
113///
114/// The pager is deliberately not `Send`: the snapshot and its connection must
115/// stay with the executor that created them.
116///
117/// ```compile_fail
118/// use dovecote_sqlx_postgres::SnapshotPager;
119///
120/// fn requires_send<T: Send>() {}
121///
122/// fn main() {
123///     requires_send::<SnapshotPager>();
124/// }
125/// ```
126pub struct SnapshotPager {
127    transaction: Transaction<'static, Postgres>,
128    upper_bound: Option<RowId>,
129    cursor: Option<RowId>,
130    exhausted: bool,
131    tenant_id: Option<TenantId>,
132    _not_send: PhantomData<*mut ()>,
133}
134
135impl SnapshotPager {
136    /// Returns the last row ID returned by a non-empty page.
137    #[must_use]
138    pub const fn cursor(&self) -> Option<RowId> {
139        self.cursor
140    }
141
142    /// Returns the maximum row ID visible to this pager's finite export.
143    #[must_use]
144    pub const fn upper_bound(&self) -> Option<RowId> {
145        self.upper_bound
146    }
147
148    /// Returns whether the pager has returned its final page.
149    #[must_use]
150    pub const fn is_exhausted(&self) -> bool {
151        self.exhausted
152    }
153
154    /// Reads the next bounded page from the retained snapshot.
155    ///
156    /// An empty page marks the pager exhausted and does not advance its
157    /// cursor.  Once exhausted, subsequent calls return an empty page without
158    /// issuing SQL; call [`finish`](Self::finish) or
159    /// [`rollback`](Self::rollback) to release the transaction explicitly.
160    ///
161    /// # Errors
162    /// Returns an error if a stored event or delivery cannot be validated or the
163    /// database read fails. Roll back or drop the pager after a failed read.
164    pub async fn next_page(&mut self, limit: Limit) -> Result<Vec<PagedEvent>, PageError> {
165        if self.exhausted {
166            return Ok(Vec::new());
167        }
168
169        let upper_bound = self
170            .upper_bound
171            .expect("a non-exhausted pager has an upper bound");
172        let rows = query_page_on_connection(
173            &mut self.transaction,
174            self.tenant_id.as_ref(),
175            self.cursor.map_or(0, RowId::get),
176            Some(upper_bound.get()),
177            limit,
178        )
179        .await?;
180
181        if let Some(last) = rows.last() {
182            self.cursor = Some(last.row_id());
183            if rows.len() < limit.get() as usize || self.cursor == self.upper_bound {
184                self.exhausted = true;
185            }
186        } else {
187            self.exhausted = true;
188        }
189        Ok(rows)
190    }
191
192    /// Commits the read-only transaction and releases its pooled connection.
193    ///
194    /// # Errors
195    /// Returns a database error if committing the read transaction fails. A lost
196    /// commit response does not establish whether the server committed.
197    pub async fn finish(self) -> Result<(), PageError> {
198        self.transaction
199            .commit()
200            .await
201            .map_err(|source| PageError::sql("finish snapshot transaction", source))
202    }
203
204    /// Rolls back the read-only transaction and releases its pooled connection.
205    ///
206    /// # Errors
207    /// Returns a database error if rolling back the read transaction fails.
208    pub async fn rollback(self) -> Result<(), PageError> {
209        self.transaction
210            .rollback()
211            .await
212            .map_err(|source| PageError::sql("rollback snapshot transaction", source))
213    }
214
215    /// Closes the pager by rolling back its read-only transaction.
216    ///
217    /// # Errors
218    /// Returns a database error if rolling back the read transaction fails.
219    pub async fn close(self) -> Result<(), PageError> {
220        self.rollback().await
221    }
222}
223
224/// Executes a page query on the dedicated connection held by the caller.
225async fn query_page_on_connection(
226    connection: &mut PgConnection,
227    tenant_id: Option<&TenantId>,
228    after_row_id: i64,
229    upper_bound: Option<i64>,
230    limit: Limit,
231) -> Result<Vec<PagedEvent>, PageError> {
232    let rows = match upper_bound {
233        Some(upper_bound) => {
234            query_as::<_, PageRow>(SNAPSHOT_PAGE_SQL)
235                .bind(tenant_id.map(TenantId::as_str))
236                .bind(after_row_id)
237                .bind(i64::from(limit.get()))
238                .bind(upper_bound)
239                .fetch_all(&mut *connection)
240                .await
241        }
242        None => {
243            query_as::<_, PageRow>(PAGE_SQL)
244                .bind(tenant_id.map(TenantId::as_str))
245                .bind(after_row_id)
246                .bind(i64::from(limit.get()))
247                .fetch_all(&mut *connection)
248                .await
249        }
250    }
251    .map_err(|source| PageError::sql("read event page", source))?;
252
253    rows.into_iter()
254        .map(hydrate_page)
255        .collect::<Result<Vec<_>, _>>()
256        .map_err(PageError::serialization)
257}
258
259// Keep this SQL in one visible shape for both live and snapshot reads.  The
260// snapshot variant adds an upper bound while retaining the same strict cursor
261// and ordering semantics.
262const PAGE_SQL: &str = r"
263    SELECT e.row_id,
264           e.tenant_id,
265           e.stream,
266           e.specversion,
267           e.event_id,
268           e.source,
269           e.event_type,
270           e.subject,
271           e.occurred_at,
272           e.enqueued_at,
273           e.datacontenttype,
274           e.dataschema,
275           e.partitionkey,
276           e.extensions,
277           e.data_kind,
278           e.data,
279           d.state,
280           d.available_at,
281           d.attempts,
282           d.claim_token,
283           d.claimed_by,
284           d.claim_expires_at,
285           d.last_failure_code,
286           d.last_failure_detail,
287           d.delivered_at,
288           d.quarantined_at,
289           d.quarantine_reason
290    FROM dovecote_events AS e
291    LEFT JOIN dovecote_deliveries AS d
292      ON d.tenant_id = e.tenant_id AND d.event_row_id = e.row_id
293    WHERE ($1::varchar IS NULL OR e.tenant_id = $1) AND e.row_id > $2
294    ORDER BY e.row_id ASC
295    LIMIT $3
296";
297
298const SNAPSHOT_PAGE_SQL: &str = r"
299    SELECT e.row_id,
300           e.tenant_id,
301           e.stream,
302           e.specversion,
303           e.event_id,
304           e.source,
305           e.event_type,
306           e.subject,
307           e.occurred_at,
308           e.enqueued_at,
309           e.datacontenttype,
310           e.dataschema,
311           e.partitionkey,
312           e.extensions,
313           e.data_kind,
314           e.data,
315           d.state,
316           d.available_at,
317           d.attempts,
318           d.claim_token,
319           d.claimed_by,
320           d.claim_expires_at,
321           d.last_failure_code,
322           d.last_failure_detail,
323           d.delivered_at,
324           d.quarantined_at,
325           d.quarantine_reason
326    FROM dovecote_events AS e
327    LEFT JOIN dovecote_deliveries AS d
328      ON d.tenant_id = e.tenant_id AND d.event_row_id = e.row_id
329    WHERE ($1::varchar IS NULL OR e.tenant_id = $1) AND e.row_id > $2 AND e.row_id <= $4
330    ORDER BY e.row_id ASC
331    LIMIT $3
332";
333
334#[derive(Debug, FromRow)]
335struct PageRow {
336    row_id: i64,
337    tenant_id: String,
338    stream: String,
339    specversion: String,
340    event_id: String,
341    source: String,
342    event_type: String,
343    subject: Option<String>,
344    occurred_at: Option<OffsetDateTime>,
345    enqueued_at: OffsetDateTime,
346    datacontenttype: Option<String>,
347    dataschema: Option<String>,
348    partitionkey: Option<String>,
349    extensions: String,
350    data_kind: Option<String>,
351    data: Option<Vec<u8>>,
352    state: Option<String>,
353    available_at: Option<OffsetDateTime>,
354    attempts: Option<i64>,
355    claim_token: Option<Vec<u8>>,
356    claimed_by: Option<String>,
357    claim_expires_at: Option<OffsetDateTime>,
358    last_failure_code: Option<String>,
359    last_failure_detail: Option<String>,
360    delivered_at: Option<OffsetDateTime>,
361    quarantined_at: Option<OffsetDateTime>,
362    quarantine_reason: Option<String>,
363}
364
365impl PageRow {
366    fn event_row(&self) -> EventRow<'_> {
367        EventRow {
368            stream: &self.stream,
369            specversion: &self.specversion,
370            event_id: &self.event_id,
371            source: &self.source,
372            event_type: &self.event_type,
373            subject: self.subject.as_deref(),
374            occurred_at: self.occurred_at,
375            datacontenttype: self.datacontenttype.as_deref(),
376            dataschema: self.dataschema.as_deref(),
377            partitionkey: self.partitionkey.as_deref(),
378            extensions: &self.extensions,
379            data_kind: self.data_kind.as_deref(),
380            data: self.data.as_deref(),
381        }
382    }
383}
384
385fn hydrate_page(row: PageRow) -> Result<PagedEvent, String> {
386    let row_id = RowId::new(row.row_id).map_err(|error| error.to_string())?;
387    let tenant_id = TenantId::new(row.tenant_id.clone()).map_err(|error| error.to_string())?;
388    let event = hydrate_event(&row.event_row())?;
389    let state = row
390        .state
391        .ok_or_else(|| format!("event row {} has no required delivery row", row.row_id))?;
392    let available_at = row
393        .available_at
394        .ok_or_else(|| "delivery row has no available_at".to_owned())?;
395    let attempts = AttemptCount::new(
396        row.attempts
397            .ok_or_else(|| "delivery row has no attempts".to_owned())?,
398    )
399    .map_err(|error| error.to_string())?;
400    let failure = parse_failure(row.last_failure_code, row.last_failure_detail)?;
401    let delivery = match state.as_str() {
402        "pending" => {
403            require_absent("pending claim token", row.claim_token.as_ref())?;
404            require_absent("pending claimed worker", row.claimed_by.as_ref())?;
405            require_absent("pending claim expiry", row.claim_expires_at.as_ref())?;
406            require_absent("pending delivered time", row.delivered_at.as_ref())?;
407            require_absent("pending quarantine time", row.quarantined_at.as_ref())?;
408            require_absent("pending quarantine reason", row.quarantine_reason.as_ref())?;
409            DeliverySnapshot::pending(available_at, attempts, failure)
410        }
411        "claimed" => {
412            require_token_width(row.claim_token.as_deref())?;
413            let worker = row
414                .claimed_by
415                .ok_or_else(|| "claimed delivery has no worker".to_owned())?;
416            let expires_at = row
417                .claim_expires_at
418                .ok_or_else(|| "claimed delivery has no claim expiry".to_owned())?;
419            require_absent("claimed delivered time", row.delivered_at.as_ref())?;
420            require_absent("claimed quarantine time", row.quarantined_at.as_ref())?;
421            require_absent("claimed quarantine reason", row.quarantine_reason.as_ref())?;
422            DeliverySnapshot::claimed(
423                available_at,
424                WorkerId::new(worker).map_err(|error| error.to_string())?,
425                expires_at,
426                attempts,
427                failure,
428            )
429        }
430        "delivered" => {
431            require_absent("delivered claim token", row.claim_token.as_ref())?;
432            require_absent("delivered claimed worker", row.claimed_by.as_ref())?;
433            require_absent("delivered claim expiry", row.claim_expires_at.as_ref())?;
434            let delivered_at = row
435                .delivered_at
436                .ok_or_else(|| "delivered delivery has no delivered time".to_owned())?;
437            require_absent("delivered quarantine time", row.quarantined_at.as_ref())?;
438            require_absent(
439                "delivered quarantine reason",
440                row.quarantine_reason.as_ref(),
441            )?;
442            DeliverySnapshot::delivered(available_at, delivered_at, attempts, failure)
443        }
444        "quarantined" => {
445            require_absent("quarantined claim token", row.claim_token.as_ref())?;
446            require_absent("quarantined claimed worker", row.claimed_by.as_ref())?;
447            require_absent("quarantined claim expiry", row.claim_expires_at.as_ref())?;
448            require_absent("quarantined delivered time", row.delivered_at.as_ref())?;
449            let quarantined_at = row
450                .quarantined_at
451                .ok_or_else(|| "quarantined delivery has no quarantine time".to_owned())?;
452            let reason = row
453                .quarantine_reason
454                .ok_or_else(|| "quarantined delivery has no quarantine reason".to_owned())?;
455            DeliverySnapshot::quarantined(
456                available_at,
457                quarantined_at,
458                attempts,
459                failure,
460                QuarantineReason::new(reason).map_err(|error| error.to_string())?,
461            )
462        }
463        state => return Err(format!("unknown delivery state {state:?}")),
464    }
465    .map_err(|error| error.to_string())?;
466
467    PagedEvent::new(tenant_id, row_id, event, row.enqueued_at, delivery)
468        .map_err(|error| error.to_string())
469}
470
471fn require_absent<T>(field: &str, value: Option<&T>) -> Result<(), String> {
472    if value.is_some() {
473        Err(format!("{field} must be NULL for its delivery state"))
474    } else {
475        Ok(())
476    }
477}
478
479fn require_token_width(value: Option<&[u8]>) -> Result<(), String> {
480    match value {
481        Some(value) if value.len() == dovecote::CLAIM_TOKEN_BYTES => Ok(()),
482        Some(value) => Err(format!(
483            "claimed delivery has an invalid claim token width: {}",
484            value.len()
485        )),
486        None => Err("claimed delivery has no claim token".to_owned()),
487    }
488}
489
490fn parse_failure(code: Option<String>, detail: Option<String>) -> Result<Option<Failure>, String> {
491    match (code, detail) {
492        (None, None) => Ok(None),
493        (Some(code), Some(detail)) => Failure::new(code, detail)
494            .map(Some)
495            .map_err(|error| error.to_string()),
496        _ => Err("delivery failure code and detail must be both NULL or non-NULL".to_owned()),
497    }
498}