1use std::ops::{Deref, DerefMut};
77use std::sync::Arc;
78use std::time::{Duration, SystemTime, UNIX_EPOCH};
79
80use agent_effects_store::{
81 EffectEvent, EffectId, EffectKey, EffectKind, EffectName, EffectRecord, EffectStatus,
82 EffectStore, ErrorRecord, InsertOutcome, Lease, ListQuery, LogicalKey, NewEffect, PruneQuery,
83 StoreError, Transition, TransitionRequest, WorkerId,
84};
85use serde_json::Value;
86use sqlx::migrate::Migrator;
87use sqlx::pool::PoolConnection;
88use sqlx::postgres::{PgArguments, PgConnectOptions, PgConnection, PgPool, PgPoolOptions, PgRow};
89use sqlx::query::Query;
90use sqlx::{AssertSqlSafe, Connection, Executor, Postgres, Row, Transaction};
91use time::OffsetDateTime;
92
93static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
94
95const HISTORY: &str = "_sqlx_migrations";
97
98const COLUMNS: &str = "id, effect_name, logical_key, kind, status, input, input_fingerprint, \
99 output, last_error, created_by, attempt_count, may_have_applied, compensation_attempts, \
100 approved, next_attempt_at, attempt_started_at, attempt_ended_at, lease_owner, lease_epoch, \
101 lease_expires_at, version, created_at, updated_at, committed_at";
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum ClockSource {
106 #[default]
108 Database,
109 Caller,
111}
112
113#[derive(Clone, Debug)]
119pub struct PostgresOptions {
120 schema: Option<String>,
121 transaction_pooling: bool,
122 max_connections: u32,
123}
124
125impl Default for PostgresOptions {
126 fn default() -> Self {
127 Self {
128 schema: None,
129 transaction_pooling: false,
130 max_connections: 10,
131 }
132 }
133}
134
135impl PostgresOptions {
136 pub fn new() -> Self {
138 Self::default()
139 }
140
141 #[must_use]
149 pub fn schema(mut self, schema: impl Into<String>) -> Self {
150 self.schema = Some(schema.into());
151 self
152 }
153
154 #[must_use]
172 pub fn transaction_pooling(mut self, enabled: bool) -> Self {
173 self.transaction_pooling = enabled;
174 self
175 }
176
177 #[must_use]
181 pub fn max_connections(mut self, max: u32) -> Self {
182 self.max_connections = max;
183 self
184 }
185
186 pub async fn connect(&self, url: &str) -> Result<PostgresStore, StoreError> {
197 let options: PgConnectOptions = url.parse().map_err(StoreError::backend)?;
198 self.connect_with(options).await
199 }
200
201 pub async fn connect_with(
208 &self,
209 options: PgConnectOptions,
210 ) -> Result<PostgresStore, StoreError> {
211 let pool = PgPoolOptions::new()
212 .max_connections(self.max_connections)
213 .connect_with(options)
214 .await
215 .map_err(StoreError::backend)?;
216 self.migrate(&pool).await?;
217 self.attach(pool).await
218 }
219
220 pub async fn migrate(&self, pool: &PgPool) -> Result<(), StoreError> {
237 let Some(schema) = &self.schema else {
238 return MIGRATOR.run(pool).await.map_err(StoreError::backend);
239 };
240 let mut conn = pool.acquire().await.map_err(StoreError::backend)?.detach();
241 let result = migrate_into(&mut conn, schema).await;
242 let _ = conn.close().await;
244 result
245 }
246
247 pub async fn attach(&self, pool: PgPool) -> Result<PostgresStore, StoreError> {
260 let store = PostgresStore {
261 pool,
262 clock: ClockSource::Database,
263 sql: Arc::new(Statements::new(self)),
264 };
265 store.check_migrated().await?;
266 Ok(store)
267 }
268}
269
270async fn migrate_into(conn: &mut PgConnection, schema: &str) -> Result<(), StoreError> {
273 let quoted = quote_ident(schema);
274 let exists: bool =
277 sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = $1)")
278 .bind(schema)
279 .fetch_one(&mut *conn)
280 .await
281 .map_err(StoreError::backend)?;
282 if !exists {
283 conn.execute(AssertSqlSafe(format!(
284 "CREATE SCHEMA IF NOT EXISTS {quoted}"
285 )))
286 .await
287 .map_err(StoreError::backend)?;
288 }
289 conn.execute(AssertSqlSafe(format!("SET search_path TO {quoted}")))
290 .await
291 .map_err(StoreError::backend)?;
292 let mut migrator = Migrator::with_migrations(MIGRATOR.iter().cloned().collect());
293 migrator.dangerous_set_table_name(format!("{quoted}.{HISTORY}"));
294 migrator.run(conn).await.map_err(StoreError::backend)
295}
296
297fn quote_ident(name: &str) -> String {
299 format!("\"{}\"", name.replace('"', "\"\""))
300}
301
302#[derive(Debug)]
304struct Statements {
305 transaction_pooling: bool,
306 history: String,
307 effects: String,
308 events: String,
309 lock: Arc<str>,
310 insert: Arc<str>,
311 get: Arc<str>,
312 get_by_key: Arc<str>,
313 save: Arc<str>,
314 insert_event: Arc<str>,
315 list_events: Arc<str>,
316}
317
318impl Statements {
319 fn new(options: &PostgresOptions) -> Self {
320 let qualify = |table: &str| match &options.schema {
321 Some(schema) => format!("{}.{table}", quote_ident(schema)),
322 None => table.to_owned(),
323 };
324 let (history, effects, events) = (
325 qualify(HISTORY),
326 qualify("effects"),
327 qualify("effect_events"),
328 );
329 Self {
330 transaction_pooling: options.transaction_pooling,
331 lock: format!("SELECT {COLUMNS} FROM {effects} WHERE id = $1 FOR UPDATE").into(),
332 insert: format!(
333 "INSERT INTO {effects} ({COLUMNS}) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, \
334 $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) \
335 ON CONFLICT (effect_name, logical_key) DO NOTHING"
336 )
337 .into(),
338 get: format!("SELECT {COLUMNS} FROM {effects} WHERE id = $1").into(),
339 get_by_key: format!(
340 "SELECT {COLUMNS} FROM {effects} WHERE effect_name = $1 AND logical_key = $2"
341 )
342 .into(),
343 save: format!(
344 "UPDATE {effects} SET status = $1, output = $2, last_error = $3, \
345 attempt_count = $4, may_have_applied = $5, compensation_attempts = $6, \
346 approved = $7, next_attempt_at = $8, attempt_started_at = $9, \
347 attempt_ended_at = $10, lease_owner = $11, lease_epoch = $12, \
348 lease_expires_at = $13, version = $14, updated_at = $15, committed_at = $16 \
349 WHERE id = $17 AND version = $18"
350 )
351 .into(),
352 insert_event: format!(
353 "INSERT INTO {events} \
354 (effect_id, sequence, transition, from_status, to_status, attempt, actor, \
355 payload, at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
356 )
357 .into(),
358 list_events: format!(
359 "SELECT effect_id, sequence, transition, from_status, to_status, attempt, \
360 actor, payload, at FROM {events} WHERE effect_id = $1 ORDER BY sequence"
361 )
362 .into(),
363 history,
364 effects,
365 events,
366 }
367 }
368
369 fn query<'q>(&self, sql: impl Into<Arc<str>>) -> Query<'q, Postgres, PgArguments> {
371 sqlx::query(AssertSqlSafe(sql.into())).persistent(!self.transaction_pooling)
372 }
373}
374
375enum Session {
379 Direct(PoolConnection<Postgres>),
380 Pinned(Transaction<'static, Postgres>),
381}
382
383impl Session {
384 async fn finish(self) -> Result<(), StoreError> {
386 match self {
387 Self::Direct(_) => Ok(()),
388 Self::Pinned(tx) => tx.commit().await.map_err(StoreError::backend),
389 }
390 }
391}
392
393impl Deref for Session {
394 type Target = PgConnection;
395
396 fn deref(&self) -> &PgConnection {
397 match self {
398 Self::Direct(conn) => conn,
399 Self::Pinned(tx) => tx,
400 }
401 }
402}
403
404impl DerefMut for Session {
405 fn deref_mut(&mut self) -> &mut PgConnection {
406 match self {
407 Self::Direct(conn) => conn,
408 Self::Pinned(tx) => tx,
409 }
410 }
411}
412
413#[derive(Clone, Debug)]
417pub struct PostgresStore {
418 pool: PgPool,
419 clock: ClockSource,
420 sql: Arc<Statements>,
421}
422
423impl PostgresStore {
424 pub async fn connect(url: &str) -> Result<Self, StoreError> {
431 PostgresOptions::new().connect(url).await
432 }
433
434 pub async fn connect_with(options: PgConnectOptions) -> Result<Self, StoreError> {
442 PostgresOptions::new().connect_with(options).await
443 }
444
445 pub async fn from_pool(pool: PgPool) -> Result<Self, StoreError> {
454 let options = PostgresOptions::new();
455 options.migrate(&pool).await?;
456 options.attach(pool).await
457 }
458
459 #[must_use]
461 pub fn with_clock_source(mut self, clock: ClockSource) -> Self {
462 self.clock = clock;
463 self
464 }
465
466 pub fn pool(&self) -> &PgPool {
468 &self.pool
469 }
470
471 async fn session(&self) -> Result<Session, StoreError> {
472 if self.sql.transaction_pooling {
473 self.pool.begin().await.map(Session::Pinned)
474 } else {
475 self.pool.acquire().await.map(Session::Direct)
476 }
477 .map_err(StoreError::backend)
478 }
479
480 async fn check_migrated(&self) -> Result<(), StoreError> {
482 let mut session = self.session().await?;
483 let applied = self
484 .sql
485 .query(format!(
486 "SELECT version, checksum FROM {} WHERE success",
487 self.sql.history
488 ))
489 .fetch_all(&mut *session)
490 .await;
491 let applied = match applied {
492 Ok(rows) => rows,
493 Err(e)
494 if e.as_database_error()
495 .and_then(sqlx::error::DatabaseError::code)
496 .as_deref()
497 == Some("42P01") =>
498 {
499 return Err(StoreError::backend(format!(
500 "no migration history at {}: run PostgresOptions::migrate first",
501 self.sql.history
502 )));
503 }
504 Err(e) => return Err(StoreError::backend(e)),
505 };
506 session.finish().await?;
507 for migration in MIGRATOR.iter() {
508 if migration.migration_type.is_down_migration() {
509 continue;
510 }
511 let found = applied.iter().find_map(|row| {
512 (row.try_get::<i64, _>("version").ok()? == migration.version)
513 .then(|| row.try_get::<Vec<u8>, _>("checksum").ok())
514 });
515 match found {
516 None => {
517 return Err(StoreError::backend(format!(
518 "migration {} ({}) is not applied in {}: run PostgresOptions::migrate \
519 first",
520 migration.version, migration.description, self.sql.history
521 )));
522 }
523 Some(checksum) if checksum.as_deref() != Some(&*migration.checksum) => {
524 return Err(StoreError::backend(format!(
525 "migration {} in {} is not agent-effects' own; give the store a \
526 schema of its own with PostgresOptions::schema",
527 migration.version, self.sql.history
528 )));
529 }
530 Some(_) => {}
531 }
532 }
533 Ok(())
534 }
535
536 async fn database_now(&self, conn: &mut PgConnection) -> Result<SystemTime, StoreError> {
537 let now: OffsetDateTime = sqlx::query_scalar("SELECT clock_timestamp()")
538 .persistent(!self.sql.transaction_pooling)
539 .fetch_one(conn)
540 .await
541 .map_err(StoreError::backend)?;
542 Ok(SystemTime::from(now))
543 }
544
545 fn effective(&self, database: SystemTime, caller: SystemTime) -> SystemTime {
547 match self.clock {
548 ClockSource::Database => database,
549 ClockSource::Caller => caller,
550 }
551 }
552
553 async fn fetch_by_key(
554 &self,
555 conn: &mut PgConnection,
556 key: &EffectKey,
557 ) -> Result<Option<EffectRecord>, StoreError> {
558 self.sql
559 .query(Arc::clone(&self.sql.get_by_key))
560 .bind(key.name.as_str())
561 .bind(key.key.as_str())
562 .fetch_optional(conn)
563 .await
564 .map_err(StoreError::backend)?
565 .as_ref()
566 .map(decode_record)
567 .transpose()
568 }
569
570 async fn fetch(
571 &self,
572 conn: &mut PgConnection,
573 id: EffectId,
574 ) -> Result<Option<EffectRecord>, StoreError> {
575 self.sql
576 .query(Arc::clone(&self.sql.get))
577 .bind(*id.as_uuid())
578 .fetch_optional(conn)
579 .await
580 .map_err(StoreError::backend)?
581 .as_ref()
582 .map(decode_record)
583 .transpose()
584 }
585
586 async fn modify<T>(
590 &self,
591 id: EffectId,
592 change: impl FnOnce(&mut EffectRecord, SystemTime) -> Result<T, StoreError>,
593 ) -> Result<(T, EffectRecord, Transaction<'static, Postgres>), StoreError> {
594 let mut tx = self.pool.begin().await.map_err(StoreError::backend)?;
595 let row = self
596 .sql
597 .query(Arc::clone(&self.sql.lock))
598 .bind(*id.as_uuid())
599 .fetch_optional(&mut *tx)
600 .await
601 .map_err(StoreError::backend)?
602 .ok_or(StoreError::NotFound(id))?;
603 let mut record = decode_record(&row)?;
604 let db_now = self.database_now(&mut tx).await?;
607 let read_version = record.version;
608 let result = change(&mut record, db_now)?;
609 normalize(&mut record);
610 self.save(&mut tx, &record, read_version).await?;
611 Ok((result, record, tx))
612 }
613
614 async fn save(
616 &self,
617 tx: &mut Transaction<'static, Postgres>,
618 record: &EffectRecord,
619 read_version: u64,
620 ) -> Result<(), StoreError> {
621 let updated = self
622 .sql
623 .query(Arc::clone(&self.sql.save))
624 .bind(record.status.as_str())
625 .bind(record.output.clone())
626 .bind(json(record.last_error.as_ref())?)
627 .bind(to_i32(record.attempt_count)?)
628 .bind(record.may_have_applied)
629 .bind(to_i32(record.compensation_attempts)?)
630 .bind(record.approved)
631 .bind(record.next_attempt_at.map(timestamp))
632 .bind(record.attempt_started_at.map(timestamp))
633 .bind(record.attempt_ended_at.map(timestamp))
634 .bind(record.lease_owner.as_ref().map(|w| w.as_str().to_owned()))
635 .bind(to_i64(record.lease_epoch)?)
636 .bind(record.lease_expires_at.map(timestamp))
637 .bind(to_i64(record.version)?)
638 .bind(timestamp(record.updated_at))
639 .bind(record.committed_at.map(timestamp))
640 .bind(*record.id.as_uuid())
641 .bind(to_i64(read_version)?)
642 .execute(&mut **tx)
643 .await
644 .map_err(StoreError::backend)?
645 .rows_affected();
646 if updated == 1 {
647 Ok(())
648 } else {
649 Err(StoreError::backend(format!(
651 "effect {} changed under a row lock",
652 record.id
653 )))
654 }
655 }
656}
657
658impl EffectStore for PostgresStore {
659 async fn insert_or_get(&self, mut new: NewEffect) -> Result<InsertOutcome, StoreError> {
660 let mut session = self.session().await?;
661 if self.clock == ClockSource::Database {
662 new.now = self.database_now(&mut session).await?;
663 }
664 let mut record = EffectRecord::new(new);
665 normalize(&mut record);
666 let inserted = self
667 .sql
668 .query(Arc::clone(&self.sql.insert))
669 .bind(*record.id.as_uuid())
670 .bind(record.key.name.as_str())
671 .bind(record.key.key.as_str())
672 .bind(record.kind.as_str())
673 .bind(record.status.as_str())
674 .bind(record.input.clone())
675 .bind(record.input_fingerprint.clone())
676 .bind(record.output.clone())
677 .bind(json(record.last_error.as_ref())?)
678 .bind(record.created_by.clone())
679 .bind(to_i32(record.attempt_count)?)
680 .bind(record.may_have_applied)
681 .bind(to_i32(record.compensation_attempts)?)
682 .bind(record.approved)
683 .bind(record.next_attempt_at.map(timestamp))
684 .bind(record.attempt_started_at.map(timestamp))
685 .bind(record.attempt_ended_at.map(timestamp))
686 .bind(record.lease_owner.as_ref().map(|w| w.as_str().to_owned()))
687 .bind(to_i64(record.lease_epoch)?)
688 .bind(record.lease_expires_at.map(timestamp))
689 .bind(to_i64(record.version)?)
690 .bind(timestamp(record.created_at))
691 .bind(timestamp(record.updated_at))
692 .bind(record.committed_at.map(timestamp))
693 .execute(&mut *session)
694 .await
695 .map_err(StoreError::backend)?
696 .rows_affected()
697 == 1;
698 if inserted {
699 session.finish().await?;
700 return Ok(InsertOutcome {
701 record,
702 inserted: true,
703 });
704 }
705 let existing = self
706 .fetch_by_key(&mut session, &record.key)
707 .await?
708 .ok_or_else(|| StoreError::backend("conflicting insert, but no existing record"))?;
709 session.finish().await?;
710 Ok(InsertOutcome {
711 record: existing,
712 inserted: false,
713 })
714 }
715
716 async fn get(&self, id: EffectId) -> Result<Option<EffectRecord>, StoreError> {
717 let mut session = self.session().await?;
718 let record = self.fetch(&mut session, id).await?;
719 session.finish().await?;
720 Ok(record)
721 }
722
723 async fn get_by_key(&self, key: &EffectKey) -> Result<Option<EffectRecord>, StoreError> {
724 let mut session = self.session().await?;
725 let record = self.fetch_by_key(&mut session, key).await?;
726 session.finish().await?;
727 Ok(record)
728 }
729
730 async fn acquire_lease(
731 &self,
732 id: EffectId,
733 owner: &WorkerId,
734 now: SystemTime,
735 ttl: Duration,
736 ) -> Result<Lease, StoreError> {
737 let (mut lease, _, tx) = self
738 .modify(id, |record, db_now| {
739 record.acquire_lease(owner, self.effective(db_now, now), ttl)
740 })
741 .await?;
742 tx.commit().await.map_err(StoreError::backend)?;
743 lease.expires_at = round_trip(lease.expires_at);
744 Ok(lease)
745 }
746
747 async fn renew_lease(
748 &self,
749 lease: &Lease,
750 now: SystemTime,
751 ttl: Duration,
752 ) -> Result<Lease, StoreError> {
753 let (mut renewed, _, tx) = self
754 .modify(lease.effect_id, |record, db_now| {
755 record.renew_lease(lease, self.effective(db_now, now), ttl)
756 })
757 .await?;
758 tx.commit().await.map_err(StoreError::backend)?;
759 renewed.expires_at = round_trip(renewed.expires_at);
760 Ok(renewed)
761 }
762
763 async fn release_lease(&self, lease: &Lease) -> Result<(), StoreError> {
764 let (released, _, tx) = self
765 .modify(lease.effect_id, |record, _| Ok(record.release_lease(lease)))
766 .await?;
767 if released {
768 tx.commit().await.map_err(StoreError::backend)?;
769 } else {
770 tx.rollback().await.map_err(StoreError::backend)?;
771 }
772 Ok(())
773 }
774
775 async fn transition(&self, mut request: TransitionRequest) -> Result<EffectRecord, StoreError> {
776 let id = request.id;
777 let (mut event, record, mut tx) = self
778 .modify(id, |record, db_now| {
779 request.now = self.effective(db_now, request.now);
780 record.apply(request)
781 })
782 .await?;
783 event.at = round_trip(event.at);
784 self.sql
785 .query(Arc::clone(&self.sql.insert_event))
786 .bind(*event.effect_id.as_uuid())
787 .bind(to_i64(event.sequence)?)
788 .bind(event.transition.as_str())
789 .bind(event.from.as_str())
790 .bind(event.to.as_str())
791 .bind(to_i32(event.attempt)?)
792 .bind(event.actor.clone())
793 .bind(event.payload.clone())
794 .bind(timestamp(event.at))
795 .execute(&mut *tx)
796 .await
797 .map_err(StoreError::backend)?;
798 tx.commit().await.map_err(StoreError::backend)?;
799 Ok(record)
800 }
801
802 async fn list(&self, query: ListQuery) -> Result<Vec<EffectRecord>, StoreError> {
803 let mut sql = format!("SELECT {COLUMNS} FROM {} WHERE TRUE", self.sql.effects);
804 let mut next = 1;
805 let mut placeholder = || {
806 let p = format!("${next}");
807 next += 1;
808 p
809 };
810 if !query.statuses.is_empty() {
811 let marks: Vec<String> = query.statuses.iter().map(|_| placeholder()).collect();
812 sql.push_str(" AND status IN (");
813 sql.push_str(&marks.join(", "));
814 sql.push(')');
815 }
816 let lease_filter = query.lease_expired_at.is_some();
817 if lease_filter {
818 let now = match self.clock {
819 ClockSource::Database => "clock_timestamp()".to_owned(),
820 ClockSource::Caller => placeholder(),
821 };
822 sql.push_str(
823 " AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at <= ",
824 );
825 sql.push_str(&now);
826 sql.push(')');
827 }
828 if query.after.is_some() {
829 sql.push_str(" AND id > ");
830 sql.push_str(&placeholder());
831 }
832 sql.push_str(" ORDER BY id LIMIT ");
833 sql.push_str(&placeholder());
834 if lease_filter {
835 sql.push_str(" FOR UPDATE SKIP LOCKED");
837 }
838
839 let mut statement = self.sql.query(sql);
842 for status in &query.statuses {
843 statement = statement.bind(status.as_str());
844 }
845 if let (Some(now), ClockSource::Caller) = (query.lease_expired_at, self.clock) {
846 statement = statement.bind(timestamp(now));
847 }
848 if let Some(after) = query.after {
849 statement = statement.bind(*after.as_uuid());
850 }
851 statement = statement.bind(i64::try_from(query.limit).unwrap_or(i64::MAX));
852 let mut session = self.session().await?;
853 let records = statement
854 .fetch_all(&mut *session)
855 .await
856 .map_err(StoreError::backend)?
857 .iter()
858 .map(decode_record)
859 .collect::<Result<_, _>>()?;
860 session.finish().await?;
861 Ok(records)
862 }
863
864 async fn events(&self, id: EffectId) -> Result<Vec<EffectEvent>, StoreError> {
865 let mut session = self.session().await?;
866 if self.fetch(&mut session, id).await?.is_none() {
867 return Err(StoreError::NotFound(id));
868 }
869 let events = self
870 .sql
871 .query(Arc::clone(&self.sql.list_events))
872 .bind(*id.as_uuid())
873 .fetch_all(&mut *session)
874 .await
875 .map_err(StoreError::backend)?
876 .iter()
877 .map(decode_event)
878 .collect::<Result<_, _>>()?;
879 session.finish().await?;
880 Ok(events)
881 }
882
883 async fn prune(&self, query: PruneQuery) -> Result<u64, StoreError> {
884 let Some(cutoff) = query.cutoff().filter(|_| query.status.is_settled()) else {
885 return Ok(0);
886 };
887 let (cutoff_sql, now_sql, limit) = match self.clock {
890 ClockSource::Database => (
891 "clock_timestamp() - $2 * interval '1 microsecond'",
892 "clock_timestamp()",
893 "$3",
894 ),
895 ClockSource::Caller => ("$2", "$3", "$4"),
896 };
897 let (effects, events) = (&self.sql.effects, &self.sql.events);
898 let sql = format!(
899 "WITH doomed AS (\
900 SELECT id FROM {effects} WHERE status = $1 AND updated_at <= {cutoff_sql} \
901 AND (lease_owner IS NULL OR lease_expires_at IS NULL \
902 OR lease_expires_at <= {now_sql}) \
903 ORDER BY id LIMIT {limit} FOR UPDATE SKIP LOCKED), \
904 events AS (DELETE FROM {events} WHERE effect_id IN (SELECT id FROM doomed)) \
905 DELETE FROM {effects} WHERE id IN (SELECT id FROM doomed)"
906 );
907 let mut statement = self.sql.query(sql).bind(query.status.as_str());
910 statement = match self.clock {
911 ClockSource::Database => statement
912 .bind(i64::try_from(query.older_than.as_micros()).map_err(StoreError::backend)?),
913 ClockSource::Caller => statement.bind(timestamp(cutoff)).bind(timestamp(query.now)),
914 };
915 let mut session = self.session().await?;
916 let deleted = statement
917 .bind(i64::try_from(query.limit).unwrap_or(i64::MAX))
918 .execute(&mut *session)
919 .await
920 .map_err(StoreError::backend)?;
921 session.finish().await?;
922 Ok(deleted.rows_affected())
923 }
924}
925
926fn decode_record(row: &PgRow) -> Result<EffectRecord, StoreError> {
927 let kind: String = get(row, "kind")?;
928 let status: String = get(row, "status")?;
929 Ok(EffectRecord {
930 id: EffectId::from_uuid(get(row, "id")?),
931 key: EffectKey::new(
932 EffectName::new(get::<String>(row, "effect_name")?).map_err(StoreError::backend)?,
933 LogicalKey::new(get::<String>(row, "logical_key")?).map_err(StoreError::backend)?,
934 ),
935 kind: EffectKind::parse(&kind).ok_or_else(|| corrupt("kind", &kind))?,
936 status: EffectStatus::parse(&status).ok_or_else(|| corrupt("status", &status))?,
937 input: get(row, "input")?,
938 input_fingerprint: get(row, "input_fingerprint")?,
939 output: get(row, "output")?,
940 last_error: get::<Option<Value>>(row, "last_error")?
941 .map(serde_json::from_value::<ErrorRecord>)
942 .transpose()
943 .map_err(StoreError::backend)?,
944 created_by: get(row, "created_by")?,
945 attempt_count: to_u32(get(row, "attempt_count")?)?,
946 may_have_applied: get(row, "may_have_applied")?,
947 compensation_attempts: to_u32(get(row, "compensation_attempts")?)?,
948 approved: get(row, "approved")?,
949 next_attempt_at: get::<Option<OffsetDateTime>>(row, "next_attempt_at")?
950 .map(SystemTime::from),
951 attempt_started_at: get::<Option<OffsetDateTime>>(row, "attempt_started_at")?
952 .map(SystemTime::from),
953 attempt_ended_at: get::<Option<OffsetDateTime>>(row, "attempt_ended_at")?
954 .map(SystemTime::from),
955 lease_owner: get::<Option<String>>(row, "lease_owner")?.map(WorkerId::new),
956 lease_epoch: to_u64(get(row, "lease_epoch")?)?,
957 lease_expires_at: get::<Option<OffsetDateTime>>(row, "lease_expires_at")?
958 .map(SystemTime::from),
959 version: to_u64(get(row, "version")?)?,
960 created_at: SystemTime::from(get::<OffsetDateTime>(row, "created_at")?),
961 updated_at: SystemTime::from(get::<OffsetDateTime>(row, "updated_at")?),
962 committed_at: get::<Option<OffsetDateTime>>(row, "committed_at")?.map(SystemTime::from),
963 })
964}
965
966fn decode_event(row: &PgRow) -> Result<EffectEvent, StoreError> {
967 let transition: String = get(row, "transition")?;
968 let from: String = get(row, "from_status")?;
969 let to: String = get(row, "to_status")?;
970 Ok(EffectEvent {
971 effect_id: EffectId::from_uuid(get(row, "effect_id")?),
972 sequence: to_u64(get(row, "sequence")?)?,
973 transition: Transition::parse(&transition)
974 .ok_or_else(|| corrupt("transition", &transition))?,
975 from: EffectStatus::parse(&from).ok_or_else(|| corrupt("from_status", &from))?,
976 to: EffectStatus::parse(&to).ok_or_else(|| corrupt("to_status", &to))?,
977 attempt: to_u32(get(row, "attempt")?)?,
978 actor: get(row, "actor")?,
979 payload: get(row, "payload")?,
980 at: SystemTime::from(get::<OffsetDateTime>(row, "at")?),
981 })
982}
983
984fn get<'r, T>(row: &'r PgRow, column: &str) -> Result<T, StoreError>
985where
986 T: sqlx::Decode<'r, Postgres> + sqlx::Type<Postgres>,
987{
988 row.try_get(column).map_err(StoreError::backend)
989}
990
991fn corrupt(column: &str, value: &str) -> StoreError {
992 StoreError::backend(format!(
993 "unreadable {column} in effects database: {value:?}"
994 ))
995}
996
997fn json(error: Option<&ErrorRecord>) -> Result<Option<Value>, StoreError> {
998 error
999 .map(serde_json::to_value)
1000 .transpose()
1001 .map_err(StoreError::backend)
1002}
1003
1004fn to_i32(value: u32) -> Result<i32, StoreError> {
1005 i32::try_from(value).map_err(StoreError::backend)
1006}
1007
1008fn to_u32(value: i32) -> Result<u32, StoreError> {
1009 u32::try_from(value).map_err(StoreError::backend)
1010}
1011
1012fn to_i64(value: u64) -> Result<i64, StoreError> {
1013 i64::try_from(value).map_err(StoreError::backend)
1014}
1015
1016fn to_u64(value: i64) -> Result<u64, StoreError> {
1017 u64::try_from(value).map_err(StoreError::backend)
1018}
1019
1020fn timestamp(time: SystemTime) -> OffsetDateTime {
1021 OffsetDateTime::from(round_trip(time))
1022}
1023
1024fn round_trip(time: SystemTime) -> SystemTime {
1026 match time.duration_since(UNIX_EPOCH) {
1027 Ok(after) => {
1028 UNIX_EPOCH + Duration::from_millis(u64::try_from(after.as_millis()).unwrap_or(u64::MAX))
1029 }
1030 Err(before) => {
1031 let millis = u64::try_from(before.duration().as_millis()).unwrap_or(u64::MAX);
1032 UNIX_EPOCH - Duration::from_millis(millis)
1033 }
1034 }
1035}
1036
1037fn normalize(record: &mut EffectRecord) {
1040 for time in [
1041 &mut record.next_attempt_at,
1042 &mut record.attempt_started_at,
1043 &mut record.attempt_ended_at,
1044 &mut record.lease_expires_at,
1045 &mut record.committed_at,
1046 ]
1047 .into_iter()
1048 .flatten()
1049 {
1050 *time = round_trip(*time);
1051 }
1052 record.created_at = round_trip(record.created_at);
1053 record.updated_at = round_trip(record.updated_at);
1054}