1use 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
17pub 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
40pub 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
69pub 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 pub const fn cursor(&self) -> Option<RowId> {
99 self.cursor
100 }
101
102 pub const fn upper_bound(&self) -> Option<RowId> {
104 self.upper_bound
105 }
106
107 pub const fn is_exhausted(&self) -> bool {
109 self.exhausted
110 }
111
112 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 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 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 pub async fn close(self) -> Result<(), PageError> {
163 self.rollback().await
164 }
165}
166
167async 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
199const 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}