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}