Skip to main content

agent_effects_postgres/
lib.rs

1//! PostgreSQL store for [`agent-effects`](https://crates.io/crates/agent-effects).
2//!
3//! For production services: many workers on many hosts sharing one database.
4//!
5//! ```no_run
6//! # async fn demo() -> Result<(), agent_effects_store::StoreError> {
7//! let store = agent_effects_postgres::PostgresStore::connect("postgres://localhost/effects").await?;
8//! # Ok(())
9//! # }
10//! ```
11//!
12//! # Sharing an application's database
13//!
14//! [`PostgresOptions`] fits the store into a database an application already
15//! uses, including through PgBouncer:
16//!
17//! - [`schema`](PostgresOptions::schema) keeps the store's tables and its
18//!   migration history in a schema of their own, named in every query. The
19//!   application's own `sqlx` migrations and `search_path` are left alone.
20//! - [`migrate`](PostgresOptions::migrate) and
21//!   [`attach`](PostgresOptions::attach) are separate steps: migrate once over
22//!   a direct connection, then attach to the application's pool without
23//!   migrating.
24//! - [`transaction_pooling`](PostgresOptions::transaction_pooling) makes every
25//!   query safe through a pooler in transaction mode.
26//!
27//! ```no_run
28//! # async fn demo(direct: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
29//! use agent_effects_postgres::PostgresOptions;
30//! use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
31//!
32//! let options = PostgresOptions::new()
33//!     .schema("agent_effects")
34//!     .transaction_pooling(true);
35//! // Once, at deploy or startup, over a direct connection to Postgres.
36//! options.migrate(&direct).await?;
37//! // The application's pool, which goes through PgBouncer. sqlx sends the
38//! // `extra_float_digits` startup parameter unless told not to, and a
39//! // PgBouncer that does not ignore it refuses the connection.
40//! let pooled = PgPoolOptions::new()
41//!     .connect_with(
42//!         "postgres://app@pgbouncer:6432/app"
43//!             .parse::<PgConnectOptions>()?
44//!             .extra_float_digits(None),
45//!     )
46//!     .await?;
47//! let store = options.attach(pooled).await?;
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! # The database's clock
53//!
54//! By default ([`ClockSource::Database`]), every time the store uses comes
55//! from Postgres's `clock_timestamp()`, not from the calling worker:
56//!
57//! - lease liveness, lease expiry and takeover;
58//! - the times recorded on transitions;
59//! - the "no live lease" filter of recovery scans.
60//!
61//! Workers whose clocks disagree therefore still agree on who holds a lease.
62//! This removes the clock-skew limit that leases otherwise have. Retry
63//! schedules (`next_attempt_at`) are still computed by the worker.
64//! [`ClockSource::Caller`] uses the times the runtime passes in instead, as
65//! the in-memory and SQLite stores do; the conformance suite needs it to
66//! drive time.
67//!
68//! # Concurrency
69//!
70//! Every change is "load the row `FOR UPDATE`, apply the pure
71//! `EffectRecord` operation, save" in one transaction, so concurrent writers
72//! serialize on the row. The update also checks the version it read.
73//! Recovery and pending scans read with `FOR UPDATE SKIP LOCKED`: rows
74//! another worker is changing right now are skipped, not waited on.
75
76use 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
95/// sqlx's default name for the migration history table.
96const 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/// Whose clock the store trusts for leases and recorded times.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum ClockSource {
106    /// Postgres's `clock_timestamp()`: one clock for every worker.
107    #[default]
108    Database,
109    /// The times the runtime passes in, from each worker's own clock.
110    Caller,
111}
112
113/// Where the store keeps its tables and how it talks to the database.
114///
115/// The defaults are what [`PostgresStore::connect`] uses: tables found
116/// through `search_path`, migration history in `_sqlx_migrations`, prepared
117/// statements cached per connection, and 10 connections.
118#[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    /// The defaults.
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Keeps the store's tables in `schema` and names it in every query, so
142    /// `search_path` does not matter. The migration history goes in
143    /// `schema._sqlx_migrations`, apart from any other `sqlx` migrations in
144    /// the database.
145    ///
146    /// `schema` is a name, used exactly as written (case included), not SQL.
147    /// [`migrate`](Self::migrate) creates it if it does not exist.
148    #[must_use]
149    pub fn schema(mut self, schema: impl Into<String>) -> Self {
150        self.schema = Some(schema.into());
151        self
152    }
153
154    /// For a pool that reaches Postgres through PgBouncer, or another pooler,
155    /// in transaction mode.
156    ///
157    /// Such a pooler may hand each transaction, and each statement outside
158    /// one, to a different server connection. So no statement is prepared
159    /// for reuse, and every store operation runs inside a transaction, so
160    /// that a statement's parse, bind and execution all reach the same
161    /// server. Reads that would otherwise run alone cost a `BEGIN` and a
162    /// `COMMIT` more.
163    ///
164    /// The pool is the application's, so its connect options are too. Two of
165    /// sqlx's matter behind a pooler: turn off the `extra_float_digits`
166    /// startup parameter (`PgConnectOptions::extra_float_digits(None)`)
167    /// unless the pooler ignores it, and do not set `search_path` through
168    /// `PgConnectOptions::options`, which a pooler does not pass on; use
169    /// [`schema`](Self::schema) instead. The store has no float columns, so
170    /// it does not depend on `extra_float_digits`.
171    #[must_use]
172    pub fn transaction_pooling(mut self, enabled: bool) -> Self {
173        self.transaction_pooling = enabled;
174        self
175    }
176
177    /// The size of the pool [`connect`](Self::connect) and
178    /// [`connect_with`](Self::connect_with) open; 10 by default.
179    /// [`attach`](Self::attach) uses the pool it is given as it is.
180    #[must_use]
181    pub fn max_connections(mut self, max: u32) -> Self {
182        self.max_connections = max;
183        self
184    }
185
186    /// Connects to `url`, applies migrations, and attaches.
187    ///
188    /// Migrating needs a direct connection (see [`migrate`](Self::migrate)),
189    /// so `url` must not go through a pooler in transaction mode; for that,
190    /// [`migrate`](Self::migrate) directly and [`attach`](Self::attach) to the
191    /// pooled pool.
192    ///
193    /// # Errors
194    ///
195    /// [`StoreError::Backend`] if connecting or migrating fails.
196    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    /// Connects with `options`, applies migrations, and attaches. Like
202    /// [`connect`](Self::connect), it needs a direct connection.
203    ///
204    /// # Errors
205    ///
206    /// [`StoreError::Backend`] if connecting or migrating fails.
207    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    /// Applies the store's migrations through `pool`.
221    ///
222    /// `pool` must reach Postgres directly, not through a pooler in
223    /// transaction mode: migrations hold a session-level advisory lock, so
224    /// concurrent callers are safe. Through such a pooler the lock can be
225    /// left held on a server connection, blocking every later migration in
226    /// the database, the application's included. With a [schema](Self::schema), the
227    /// connection used is closed afterwards instead of going back to the
228    /// pool, so `pool` keeps its own `search_path`.
229    ///
230    /// # Errors
231    ///
232    /// [`StoreError::Backend`] if migrating fails, or if the history table
233    /// holds a different migration under one of the store's versions (as
234    /// when it is shared with an application's migrations; give the store a
235    /// schema of its own).
236    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        // A failed close leaves only a connection the server will drop.
243        let _ = conn.close().await;
244        result
245    }
246
247    /// Uses `pool` without migrating; `pool` may go through a pooler (see
248    /// [`transaction_pooling`](Self::transaction_pooling) for its connect
249    /// options).
250    ///
251    /// Checks that every migration this version needs has been applied, so a
252    /// missing [`migrate`](Self::migrate) fails here rather than on the first
253    /// effect.
254    ///
255    /// # Errors
256    ///
257    /// [`StoreError::Backend`] if the database is unreachable or a migration
258    /// is missing or not the store's own.
259    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
270/// Runs the migrations on `conn` with `schema` as its only `search_path`, so
271/// the unqualified names in the migration files land in it.
272async fn migrate_into(conn: &mut PgConnection, schema: &str) -> Result<(), StoreError> {
273    let quoted = quote_ident(schema);
274    // CREATE SCHEMA IF NOT EXISTS needs CREATE on the database even when the
275    // schema exists, so leave a schema an administrator made alone.
276    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
297/// `name` as a quoted SQL identifier.
298fn quote_ident(name: &str) -> String {
299    format!("\"{}\"", name.replace('"', "\"\""))
300}
301
302/// The store's SQL, with its table names filled in.
303#[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    /// A query for `sql`, prepared for reuse unless behind a pooler.
370    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
375/// The connection one store operation runs on: in a transaction when the
376/// pool goes through a transaction-mode pooler, so every statement of the
377/// operation reaches the same server.
378enum Session {
379    Direct(PoolConnection<Postgres>),
380    Pinned(Transaction<'static, Postgres>),
381}
382
383impl Session {
384    /// Ends the operation, committing its transaction if it has one.
385    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/// An [`EffectStore`] in a PostgreSQL database.
414///
415/// Cheap to clone; clones share the connection pool.
416#[derive(Clone, Debug)]
417pub struct PostgresStore {
418    pool: PgPool,
419    clock: ClockSource,
420    sql: Arc<Statements>,
421}
422
423impl PostgresStore {
424    /// Connects to `url` and applies migrations, with the default
425    /// [`PostgresOptions`].
426    ///
427    /// # Errors
428    ///
429    /// [`StoreError::Backend`] if connecting or migrating fails.
430    pub async fn connect(url: &str) -> Result<Self, StoreError> {
431        PostgresOptions::new().connect(url).await
432    }
433
434    /// Connects with `options` and applies migrations, with the default
435    /// [`PostgresOptions`]. For a schema of the store's own, see
436    /// [`PostgresOptions::schema`].
437    ///
438    /// # Errors
439    ///
440    /// [`StoreError::Backend`] if connecting or migrating fails.
441    pub async fn connect_with(options: PgConnectOptions) -> Result<Self, StoreError> {
442        PostgresOptions::new().connect_with(options).await
443    }
444
445    /// Uses an existing pool and applies migrations, with the default
446    /// [`PostgresOptions`]. Concurrent callers are safe: migrations run under
447    /// an advisory lock. To use a pool without migrating, as through
448    /// PgBouncer, see [`PostgresOptions::attach`].
449    ///
450    /// # Errors
451    ///
452    /// [`StoreError::Backend`] if migrating fails.
453    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    /// Chooses whose clock to trust; see [`ClockSource`].
460    #[must_use]
461    pub fn with_clock_source(mut self, clock: ClockSource) -> Self {
462        self.clock = clock;
463        self
464    }
465
466    /// The underlying pool.
467    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    /// Fails unless every migration is applied, and is the store's own.
481    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    /// The time to use: the database's when it is the source, else `caller`.
546    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    /// Loads `id` locked `FOR UPDATE`, applies `change` (given the
587    /// database's current time once the lock is held), and saves the
588    /// record. The transaction stays open for further writes.
589    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        // Read only now: a clock read in the locking statement is taken
605        // before waiting for the lock, and can be stale by the whole wait.
606        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    /// Writes `record` over the row last read at `read_version`.
615    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            // Unreachable under FOR UPDATE; kept as a second guard.
650            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            // A work-finding scan: skip rows another worker is changing.
836            sql.push_str(" FOR UPDATE SKIP LOCKED");
837        }
838
839        // Only placeholders and the table name are interpolated; every value
840        // is bound.
841        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        // Rows another worker is changing are skipped, and the ones selected
888        // stay locked until both deletes commit.
889        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        // Only fixed fragments and table names are interpolated; every value
908        // is bound.
909        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
1024/// `time` at millisecond precision, the precision the store keeps.
1025fn 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
1037/// Gives every time in `record` its stored precision, so returned records
1038/// equal what a later read returns.
1039fn 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}