Skip to main content

sayiir_postgres/
backend.rs

1//! `PostgresBackend` struct and constructors.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use sayiir_core::codec::{self, CodecIdentity, Decoder, Encoder};
7use sayiir_core::snapshot::WorkflowSnapshot;
8use sayiir_core::snapshot_format;
9use sayiir_persistence::BackendError;
10use sqlx::postgres::PgPoolOptions;
11use sqlx::{PgPool, Row};
12
13use crate::error::PgError;
14use crate::wakeup::WakeupListener;
15
16/// Minimum supported PostgreSQL major version.
17const MIN_PG_MAJOR_VERSION: u32 = 13;
18
19/// Connection-pool configuration for the Postgres backend.
20///
21/// All fields are optional; unset fields fall back to sqlx defaults
22/// (e.g. `max_connections = 10`, no idle/lifetime caps, no session-level
23/// timeouts).
24///
25/// `statement_timeout` and `idle_in_transaction_session_timeout` are applied
26/// at the *session* level via `SET` on every newly-acquired connection, so
27/// they affect every query the pool serves without requiring a server-side
28/// configuration change.
29///
30/// Construct via `PoolOptions::default()` and field-assign, or pass directly
31/// to [`PostgresBackend::connect_with_options`].
32#[derive(Debug, Clone, Default)]
33pub struct PoolOptions {
34    /// Maximum number of connections held by the pool. sqlx default: 10.
35    pub max_connections: Option<u32>,
36    /// Minimum number of connections kept warm. sqlx default: 0.
37    pub min_connections: Option<u32>,
38    /// Time to wait for a connection from the pool before erroring out.
39    pub acquire_timeout: Option<Duration>,
40    /// Drop connections idle for longer than this.
41    pub idle_timeout: Option<Duration>,
42    /// Recycle connections older than this regardless of idle state.
43    pub max_lifetime: Option<Duration>,
44    /// `statement_timeout` value applied to every connection. Aborts queries
45    /// that run longer than this duration.
46    ///
47    /// Note: sub-millisecond and zero durations are floored at 1 ms, because
48    /// PG interprets a `0` value as "timeout disabled" — the opposite of
49    /// what such a tight bound would suggest. Pass `None` to leave the
50    /// server default in place (typically no timeout).
51    pub statement_timeout: Option<Duration>,
52    /// `idle_in_transaction_session_timeout` value applied to every
53    /// connection. Aborts transactions that sit idle for longer than this
54    /// duration, releasing the connection and unblocking VACUUM.
55    ///
56    /// Subject to the same sub-millisecond floor as [`statement_timeout`](
57    /// Self::statement_timeout).
58    pub idle_in_transaction_session_timeout: Option<Duration>,
59}
60
61/// PostgreSQL persistence backend for Sayiir workflows.
62///
63/// Generic over a [`Codec`](sayiir_core::codec::Codec) that determines how
64/// snapshots are serialized into the `BYTEA` column. Use `JsonCodec` for
65/// human-readable storage with Postgres-side queryability, or a binary codec
66/// for faster (de)serialization.
67///
68/// # Example (with `sayiir-runtime` JSON codec)
69///
70/// ```rust,no_run
71/// use sayiir_postgres::PostgresBackend;
72/// use sayiir_runtime::serialization::JsonCodec;
73///
74/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
75/// let backend = PostgresBackend::<JsonCodec>::connect("postgresql://localhost/sayiir").await?;
76/// # Ok(())
77/// # }
78/// ```
79#[derive(Clone)]
80pub struct PostgresBackend<C> {
81    pub(crate) pool: PgPool,
82    pub(crate) codec: C,
83    pub(crate) wakeup: Arc<WakeupListener>,
84}
85
86impl<C> PostgresBackend<C>
87where
88    C: Default,
89{
90    /// Connect to Postgres with sqlx pool defaults and run migrations.
91    ///
92    /// Equivalent to [`Self::connect_with_options`] called with
93    /// [`PoolOptions::default()`]. Use that method instead when you need to
94    /// tune pool size or session-level timeouts.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the connection or migration fails.
99    pub async fn connect(url: &str) -> Result<Self, BackendError> {
100        Self::connect_with_options(url, PoolOptions::default()).await
101    }
102
103    /// Connect to Postgres with explicit pool options and run migrations.
104    ///
105    /// Field-level details on each option are documented on [`PoolOptions`].
106    /// `statement_timeout` and `idle_in_transaction_session_timeout` are
107    /// installed via an `after_connect` hook that runs `SET` on every
108    /// freshly-acquired connection.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the connection, the `SET` hooks, or the migration
113    /// fail.
114    pub async fn connect_with_options(
115        url: &str,
116        options: PoolOptions,
117    ) -> Result<Self, BackendError> {
118        let mut builder = PgPoolOptions::new();
119        if let Some(n) = options.max_connections {
120            builder = builder.max_connections(n);
121        }
122        if let Some(n) = options.min_connections {
123            builder = builder.min_connections(n);
124        }
125        if let Some(d) = options.acquire_timeout {
126            builder = builder.acquire_timeout(d);
127        }
128        if let Some(d) = options.idle_timeout {
129            builder = builder.idle_timeout(d);
130        }
131        if let Some(d) = options.max_lifetime {
132            builder = builder.max_lifetime(d);
133        }
134
135        // Session-level GUCs applied on every new connection so the limits
136        // hold uniformly across the pool, including across recycles.
137        //
138        // PG's `SET` is a utility statement and does not accept bind
139        // parameters, so we go through `set_config(name, value, is_local)`
140        // — a regular function call that does — to keep the safe-by-default
141        // pattern of never interpolating values into SQL text. `is_local =
142        // false` makes the setting session-scoped (equivalent to `SET`).
143        // The value is passed as text in milliseconds; both timeouts treat a
144        // bare integer string as ms.
145        let stmt_to = options.statement_timeout;
146        let idle_tx_to = options.idle_in_transaction_session_timeout;
147        if stmt_to.is_some() || idle_tx_to.is_some() {
148            builder = builder.after_connect(move |conn, _meta| {
149                Box::pin(async move {
150                    if let Some(d) = stmt_to {
151                        sqlx::query("SELECT set_config('statement_timeout', $1, false)")
152                            .bind(duration_to_ms(d).to_string())
153                            .execute(&mut *conn)
154                            .await?;
155                    }
156                    if let Some(d) = idle_tx_to {
157                        sqlx::query(
158                            "SELECT set_config('idle_in_transaction_session_timeout', $1, false)",
159                        )
160                        .bind(duration_to_ms(d).to_string())
161                        .execute(&mut *conn)
162                        .await?;
163                    }
164                    Ok(())
165                })
166            });
167        }
168
169        let pool = builder.connect(url).await.map_err(PgError)?;
170        Self::init(pool).await
171    }
172
173    /// Use an existing connection pool and run migrations.
174    ///
175    /// Prefer [`Self::connect_with_options`] when you only want to tune
176    /// standard pool knobs — this method is meant for callers who need full
177    /// control over the sqlx `PgPool` (custom TLS, listeners, etc.).
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the migration fails.
182    pub async fn connect_with(pool: PgPool) -> Result<Self, BackendError> {
183        Self::init(pool).await
184    }
185
186    async fn init(pool: PgPool) -> Result<Self, BackendError> {
187        check_pg_version(&pool).await?;
188
189        tracing::info!("running postgres migrations");
190        sqlx::migrate!("./migrations")
191            .run(&pool)
192            .await
193            .map_err(|e| BackendError::Backend(format!("migration failed: {e}")))?;
194
195        let wakeup = WakeupListener::spawn(pool.clone());
196
197        tracing::info!("postgres backend ready");
198        Ok(Self {
199            pool,
200            codec: C::default(),
201            wakeup,
202        })
203    }
204}
205
206impl<C> PostgresBackend<C>
207where
208    C: Encoder + CodecIdentity + codec::sealed::EncodeValue<WorkflowSnapshot>,
209{
210    /// Encode a snapshot using the configured codec, wrapped in the durable
211    /// [`snapshot_format`](sayiir_core::snapshot_format) envelope.
212    pub(crate) fn encode(&self, snapshot: &WorkflowSnapshot) -> Result<Vec<u8>, BackendError> {
213        snapshot_format::encode_framed(&self.codec, snapshot)
214            .map_err(|e| BackendError::Serialization(e.to_string()))
215    }
216
217    /// Encode the outputs-stripped blob + its SHA-256, without cloning.
218    ///
219    /// Destructive: strips task outputs in place, no restore. For callers that
220    /// own the snapshot, read only metadata after, and drop it on commit
221    /// (`save_task_result`, the signal-store `check_and_*` paths). Callers that
222    /// read outputs back after encoding use
223    /// [`encode_blob_preserving`](Self::encode_blob_preserving).
224    pub(crate) fn encode_blob(
225        &self,
226        snapshot: &mut WorkflowSnapshot,
227    ) -> Result<(Vec<u8>, [u8; 32]), BackendError> {
228        snapshot.strip_task_outputs();
229        let data = self.encode(snapshot)?;
230        let hash = crate::history::snapshot_hash(&data);
231        Ok((data, hash))
232    }
233
234    /// Like [`encode_blob`] but restores the task outputs after encoding,
235    /// so the snapshot is logically unchanged on return (including on the
236    /// encode-error path). Costs an extra O(n) hydrate; only use it when
237    /// the caller reads outputs back after encoding.
238    pub(crate) fn encode_blob_preserving(
239        &self,
240        snapshot: &mut WorkflowSnapshot,
241    ) -> Result<(Vec<u8>, [u8; 32]), BackendError> {
242        let outputs = snapshot.take_task_outputs();
243        let encoded = self.encode(snapshot);
244        snapshot.hydrate_task_outputs(outputs);
245        let data = encoded?;
246        let hash = crate::history::snapshot_hash(&data);
247        Ok((data, hash))
248    }
249}
250
251impl<C> PostgresBackend<C>
252where
253    C: Decoder + CodecIdentity + codec::sealed::DecodeValue<WorkflowSnapshot>,
254{
255    /// Decode a snapshot from a durable blob: parse the
256    /// [`snapshot_format`](sayiir_core::snapshot_format) envelope, validate the
257    /// codec id matches the configured codec, then decode the payload.
258    pub(crate) fn decode(&self, data: &[u8]) -> Result<WorkflowSnapshot, BackendError> {
259        snapshot_format::decode_framed(&self.codec, data)
260            .map_err(|e| BackendError::Serialization(e.to_string()))
261    }
262}
263
264/// Query `SHOW server_version_num` and reject versions below [`MIN_PG_MAJOR_VERSION`].
265///
266/// PostgreSQL encodes its version as a single integer: major * 10000 + minor.
267/// For example 130005 = 13.5, 170001 = 17.1.
268async fn check_pg_version(pool: &PgPool) -> Result<(), BackendError> {
269    let row = sqlx::query("SHOW server_version_num")
270        .fetch_one(pool)
271        .await
272        .map_err(PgError)?;
273
274    let version_str: &str = row.get("server_version_num");
275    let version_num: u32 = version_str.parse().map_err(|e| {
276        BackendError::Backend(format!(
277            "failed to parse server_version_num '{version_str}': {e}"
278        ))
279    })?;
280
281    let major = version_num / 10000;
282    tracing::info!(pg_version = major, "connected to PostgreSQL {major}");
283
284    if major < MIN_PG_MAJOR_VERSION {
285        return Err(BackendError::Backend(format!(
286            "PostgreSQL {major} is not supported (minimum: {MIN_PG_MAJOR_VERSION})"
287        )));
288    }
289
290    Ok(())
291}
292
293/// Convert a [`Duration`] to a millisecond count usable as a PG GUC value.
294///
295/// PG accepts these timeouts as signed 4-byte integers (ms). Two edge cases:
296///
297/// * Any `Duration` smaller than 1 ms (including [`Duration::ZERO`]) would
298///   quantize to `0`, which Postgres interprets as **disabling** the timeout
299///   — the opposite of a caller asking for "as tight as possible." We floor
300///   at 1 ms so sub-millisecond inputs still produce an active timeout.
301/// * Durations longer than `i32::MAX` ms (~24.8 days) saturate at the
302///   max representable value. No realistic configuration hits this in
303///   practice, but it's an explicit choice over a panic or wraparound.
304fn duration_to_ms(d: Duration) -> i32 {
305    let ms = d.as_millis();
306    if ms == 0 {
307        1
308    } else {
309        i32::try_from(ms).unwrap_or(i32::MAX)
310    }
311}