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