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    pub const fn cursor(&self) -> Option<RowId> {
138        self.cursor
139    }
140
141    /// Returns the maximum row ID visible to this pager's finite export.
142    pub const fn upper_bound(&self) -> Option<RowId> {
143        self.upper_bound
144    }
145
146    /// Returns whether the pager has returned its final page.
147    pub const fn is_exhausted(&self) -> bool {
148        self.exhausted
149    }
150
151    /// Reads the next bounded page from the retained snapshot.
152    ///
153    /// An empty page marks the pager exhausted and does not advance its
154    /// cursor.  Once exhausted, subsequent calls return an empty page without
155    /// issuing SQL; call [`finish`](Self::finish) or
156    /// [`rollback`](Self::rollback) to release the transaction explicitly.
157    pub async fn next_page(&mut self, limit: Limit) -> Result<Vec<PagedEvent>, PageError> {
158        if self.exhausted {
159            return Ok(Vec::new());
160        }
161
162        let upper_bound = self
163            .upper_bound
164            .expect("a non-exhausted pager has an upper bound");
165        let rows = query_page_on_connection(
166            &mut self.transaction,
167            self.tenant_id.as_ref(),
168            self.cursor.map_or(0, RowId::get),
169            Some(upper_bound.get()),
170            limit,
171        )
172        .await?;
173
174        if let Some(last) = rows.last() {
175            self.cursor = Some(last.row_id());
176            if rows.len() < limit.get() as usize || self.cursor == self.upper_bound {
177                self.exhausted = true;
178            }
179        } else {
180            self.exhausted = true;
181        }
182        Ok(rows)
183    }
184
185    /// Commits the read-only transaction and releases its pooled connection.
186    pub async fn finish(self) -> Result<(), PageError> {
187        self.transaction
188            .commit()
189            .await
190            .map_err(|source| PageError::sql("finish snapshot transaction", source))
191    }
192
193    /// Rolls back the read-only transaction and releases its pooled connection.
194    pub async fn rollback(self) -> Result<(), PageError> {
195        self.transaction
196            .rollback()
197            .await
198            .map_err(|source| PageError::sql("rollback snapshot transaction", source))
199    }
200
201    /// Closes the pager by rolling back its read-only transaction.
202    pub async fn close(self) -> Result<(), PageError> {
203        self.rollback().await
204    }
205}
206
207/// Executes a page query on the dedicated connection held by the caller.
208async fn query_page_on_connection(
209    connection: &mut PgConnection,
210    tenant_id: Option<&TenantId>,
211    after_row_id: i64,
212    upper_bound: Option<i64>,
213    limit: Limit,
214) -> Result<Vec<PagedEvent>, PageError> {
215    let rows = match upper_bound {
216        Some(upper_bound) => {
217            query_as::<_, PageRow>(SNAPSHOT_PAGE_SQL)
218                .bind(tenant_id.map(TenantId::as_str))
219                .bind(after_row_id)
220                .bind(i64::from(limit.get()))
221                .bind(upper_bound)
222                .fetch_all(&mut *connection)
223                .await
224        }
225        None => {
226            query_as::<_, PageRow>(PAGE_SQL)
227                .bind(tenant_id.map(TenantId::as_str))
228                .bind(after_row_id)
229                .bind(i64::from(limit.get()))
230                .fetch_all(&mut *connection)
231                .await
232        }
233    }
234    .map_err(|source| PageError::sql("read event page", source))?;
235
236    rows.into_iter()
237        .map(hydrate_page)
238        .collect::<Result<Vec<_>, _>>()
239        .map_err(PageError::serialization)
240}
241
242// Keep this SQL in one visible shape for both live and snapshot reads.  The
243// snapshot variant adds an upper bound while retaining the same strict cursor
244// and ordering semantics.
245const PAGE_SQL: &str = r#"
246    SELECT e.row_id,
247           e.tenant_id,
248           e.stream,
249           e.specversion,
250           e.event_id,
251           e.source,
252           e.event_type,
253           e.subject,
254           e.occurred_at,
255           e.enqueued_at,
256           e.datacontenttype,
257           e.dataschema,
258           e.partitionkey,
259           e.extensions,
260           e.data_kind,
261           e.data,
262           d.state,
263           d.available_at,
264           d.attempts,
265           d.claim_token,
266           d.claimed_by,
267           d.claim_expires_at,
268           d.last_failure_code,
269           d.last_failure_detail,
270           d.delivered_at,
271           d.quarantined_at,
272           d.quarantine_reason
273    FROM dovecote_events AS e
274    LEFT JOIN dovecote_deliveries AS d
275      ON d.tenant_id = e.tenant_id AND d.event_row_id = e.row_id
276    WHERE ($1::varchar IS NULL OR e.tenant_id = $1) AND e.row_id > $2
277    ORDER BY e.row_id ASC
278    LIMIT $3
279"#;
280
281const SNAPSHOT_PAGE_SQL: &str = r#"
282    SELECT e.row_id,
283           e.tenant_id,
284           e.stream,
285           e.specversion,
286           e.event_id,
287           e.source,
288           e.event_type,
289           e.subject,
290           e.occurred_at,
291           e.enqueued_at,
292           e.datacontenttype,
293           e.dataschema,
294           e.partitionkey,
295           e.extensions,
296           e.data_kind,
297           e.data,
298           d.state,
299           d.available_at,
300           d.attempts,
301           d.claim_token,
302           d.claimed_by,
303           d.claim_expires_at,
304           d.last_failure_code,
305           d.last_failure_detail,
306           d.delivered_at,
307           d.quarantined_at,
308           d.quarantine_reason
309    FROM dovecote_events AS e
310    LEFT JOIN dovecote_deliveries AS d
311      ON d.tenant_id = e.tenant_id AND d.event_row_id = e.row_id
312    WHERE ($1::varchar IS NULL OR e.tenant_id = $1) AND e.row_id > $2 AND e.row_id <= $4
313    ORDER BY e.row_id ASC
314    LIMIT $3
315"#;
316
317#[derive(Debug, FromRow)]
318struct PageRow {
319    row_id: i64,
320    tenant_id: String,
321    stream: String,
322    specversion: String,
323    event_id: String,
324    source: String,
325    event_type: String,
326    subject: Option<String>,
327    occurred_at: Option<OffsetDateTime>,
328    enqueued_at: OffsetDateTime,
329    datacontenttype: Option<String>,
330    dataschema: Option<String>,
331    partitionkey: Option<String>,
332    extensions: String,
333    data_kind: Option<String>,
334    data: Option<Vec<u8>>,
335    state: Option<String>,
336    available_at: Option<OffsetDateTime>,
337    attempts: Option<i64>,
338    claim_token: Option<Vec<u8>>,
339    claimed_by: Option<String>,
340    claim_expires_at: Option<OffsetDateTime>,
341    last_failure_code: Option<String>,
342    last_failure_detail: Option<String>,
343    delivered_at: Option<OffsetDateTime>,
344    quarantined_at: Option<OffsetDateTime>,
345    quarantine_reason: Option<String>,
346}
347
348impl PageRow {
349    fn event_row(&self) -> EventRow {
350        EventRow {
351            stream: self.stream.clone(),
352            specversion: self.specversion.clone(),
353            event_id: self.event_id.clone(),
354            source: self.source.clone(),
355            event_type: self.event_type.clone(),
356            subject: self.subject.clone(),
357            occurred_at: self.occurred_at,
358            datacontenttype: self.datacontenttype.clone(),
359            dataschema: self.dataschema.clone(),
360            partitionkey: self.partitionkey.clone(),
361            extensions: self.extensions.clone(),
362            data_kind: self.data_kind.clone(),
363            data: self.data.clone(),
364        }
365    }
366}
367
368fn hydrate_page(row: PageRow) -> Result<PagedEvent, String> {
369    let row_id = RowId::new(row.row_id).map_err(|error| error.to_string())?;
370    let tenant_id = TenantId::new(row.tenant_id.clone()).map_err(|error| error.to_string())?;
371    let event = hydrate_event(&row.event_row())?;
372    let state = row
373        .state
374        .ok_or_else(|| format!("event row {} has no required delivery row", row.row_id))?;
375    let available_at = row
376        .available_at
377        .ok_or_else(|| "delivery row has no available_at".to_owned())?;
378    let attempts = AttemptCount::new(
379        row.attempts
380            .ok_or_else(|| "delivery row has no attempts".to_owned())?,
381    )
382    .map_err(|error| error.to_string())?;
383    let failure = parse_failure(row.last_failure_code, row.last_failure_detail)?;
384    let delivery = match state.as_str() {
385        "pending" => {
386            require_absent("pending claim token", row.claim_token.as_ref())?;
387            require_absent("pending claimed worker", row.claimed_by.as_ref())?;
388            require_absent("pending claim expiry", row.claim_expires_at.as_ref())?;
389            require_absent("pending delivered time", row.delivered_at.as_ref())?;
390            require_absent("pending quarantine time", row.quarantined_at.as_ref())?;
391            require_absent("pending quarantine reason", row.quarantine_reason.as_ref())?;
392            DeliverySnapshot::pending(available_at, attempts, failure)
393        }
394        "claimed" => {
395            require_token_width(row.claim_token.as_deref())?;
396            let worker = row
397                .claimed_by
398                .ok_or_else(|| "claimed delivery has no worker".to_owned())?;
399            let expires_at = row
400                .claim_expires_at
401                .ok_or_else(|| "claimed delivery has no claim expiry".to_owned())?;
402            require_absent("claimed delivered time", row.delivered_at.as_ref())?;
403            require_absent("claimed quarantine time", row.quarantined_at.as_ref())?;
404            require_absent("claimed quarantine reason", row.quarantine_reason.as_ref())?;
405            DeliverySnapshot::claimed(
406                available_at,
407                WorkerId::new(worker).map_err(|error| error.to_string())?,
408                expires_at,
409                attempts,
410                failure,
411            )
412        }
413        "delivered" => {
414            require_absent("delivered claim token", row.claim_token.as_ref())?;
415            require_absent("delivered claimed worker", row.claimed_by.as_ref())?;
416            require_absent("delivered claim expiry", row.claim_expires_at.as_ref())?;
417            let delivered_at = row
418                .delivered_at
419                .ok_or_else(|| "delivered delivery has no delivered time".to_owned())?;
420            require_absent("delivered quarantine time", row.quarantined_at.as_ref())?;
421            require_absent(
422                "delivered quarantine reason",
423                row.quarantine_reason.as_ref(),
424            )?;
425            DeliverySnapshot::delivered(available_at, delivered_at, attempts, failure)
426        }
427        "quarantined" => {
428            require_absent("quarantined claim token", row.claim_token.as_ref())?;
429            require_absent("quarantined claimed worker", row.claimed_by.as_ref())?;
430            require_absent("quarantined claim expiry", row.claim_expires_at.as_ref())?;
431            require_absent("quarantined delivered time", row.delivered_at.as_ref())?;
432            let quarantined_at = row
433                .quarantined_at
434                .ok_or_else(|| "quarantined delivery has no quarantine time".to_owned())?;
435            let reason = row
436                .quarantine_reason
437                .ok_or_else(|| "quarantined delivery has no quarantine reason".to_owned())?;
438            DeliverySnapshot::quarantined(
439                available_at,
440                quarantined_at,
441                attempts,
442                failure,
443                QuarantineReason::new(reason).map_err(|error| error.to_string())?,
444            )
445        }
446        state => return Err(format!("unknown delivery state {state:?}")),
447    }
448    .map_err(|error| error.to_string())?;
449
450    PagedEvent::new(tenant_id, row_id, event, row.enqueued_at, delivery)
451        .map_err(|error| error.to_string())
452}
453
454fn require_absent<T>(field: &str, value: Option<&T>) -> Result<(), String> {
455    if value.is_some() {
456        Err(format!("{field} must be NULL for its delivery state"))
457    } else {
458        Ok(())
459    }
460}
461
462fn require_token_width(value: Option<&[u8]>) -> Result<(), String> {
463    match value {
464        Some(value) if value.len() == dovecote::CLAIM_TOKEN_BYTES => Ok(()),
465        Some(value) => Err(format!(
466            "claimed delivery has an invalid claim token width: {}",
467            value.len()
468        )),
469        None => Err("claimed delivery has no claim token".to_owned()),
470    }
471}
472
473fn parse_failure(code: Option<String>, detail: Option<String>) -> Result<Option<Failure>, String> {
474    match (code, detail) {
475        (None, None) => Ok(None),
476        (Some(code), Some(detail)) => Failure::new(code, detail)
477            .map(Some)
478            .map_err(|error| error.to_string()),
479        _ => Err("delivery failure code and detail must be both NULL or non-NULL".to_owned()),
480    }
481}