Skip to main content

a3s_flow/store/
postgres.rs

1use std::fmt;
2
3use a3s_orm::{
4    sql_query, Database, Executor, FromRow, PostgresDialect, PostgresError, PostgresExecutor,
5    PostgresRow, PostgresTransaction, PostgresTransactionError, Query, SqlQuery,
6};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use uuid::Uuid;
10
11use crate::error::{FlowError, Result};
12use crate::model::{
13    ActiveHookSnapshot, FlowEvent, FlowEventEnvelope, HookSnapshot, HookStatus, ScheduledWakeup,
14};
15
16use super::{
17    migrate_postgres_flow, scheduled_wakeup_from_row, scheduled_wakeup_key, verify_postgres_flow,
18    FlowEventStore,
19};
20
21mod retention;
22
23/// A3S ORM-backed PostgreSQL event store for multi-process durable hosts.
24///
25/// The store keeps one row per [`FlowEventEnvelope`]. Appends take the same
26/// transaction-scoped advisory lock used by earlier Flow releases before
27/// checking the latest sequence and inserting the next event. That preserves
28/// per-run event order across rolling upgrades and concurrent workers.
29#[derive(Clone)]
30pub struct PostgresEventStore {
31    executor: PostgresExecutor,
32}
33
34impl fmt::Debug for PostgresEventStore {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("PostgresEventStore")
38            .finish_non_exhaustive()
39    }
40}
41
42impl PostgresEventStore {
43    /// Connect with the ORM's bounded non-TLS pool and run Flow migrations.
44    ///
45    /// Production hosts that require TLS or custom pool controls should create
46    /// a configured [`PostgresExecutor`] and call [`Self::from_executor`].
47    pub async fn connect(database_url: impl AsRef<str>) -> Result<Self> {
48        let executor = PostgresExecutor::connect_no_tls(database_url.as_ref(), 5)
49            .map_err(postgres_driver_error)?;
50        Self::from_executor(executor).await
51    }
52
53    /// Connect and verify that the complete Flow schema was applied by a
54    /// separate migrator, without acquiring DDL authority.
55    pub async fn connect_verified(database_url: impl AsRef<str>) -> Result<Self> {
56        let executor = PostgresExecutor::connect_no_tls(database_url.as_ref(), 5)
57            .map_err(postgres_driver_error)?;
58        Self::from_executor_verified(executor).await
59    }
60
61    /// Create a store from a configured executor and run Flow migrations.
62    pub async fn from_executor(executor: PostgresExecutor) -> Result<Self> {
63        migrate_postgres_flow(&executor).await?;
64        Ok(Self { executor })
65    }
66
67    /// Create a store from a configured executor after read-only admission of
68    /// the complete Flow schema.
69    pub async fn from_executor_verified(executor: PostgresExecutor) -> Result<Self> {
70        verify_postgres_flow(&executor).await?;
71        Ok(Self { executor })
72    }
73
74    /// Return the executor used by this store.
75    pub fn executor(&self) -> &PostgresExecutor {
76        &self.executor
77    }
78
79    async fn append_with_expected_sequence(
80        &self,
81        run_id: &str,
82        expected_sequence: Option<u64>,
83        event: FlowEvent,
84    ) -> Result<FlowEventEnvelope> {
85        let run_id = run_id.to_string();
86        let result = self
87            .executor
88            .transaction(|transaction| {
89                Box::pin(async move {
90                    retention::lock_postgres_retention_guard_shared(transaction).await?;
91                    let linked_run_id =
92                        retention::required_linked_flow_run_id(&event).map(str::to_string);
93                    let mut locked_run_ids = vec![run_id.as_str()];
94                    if let Some(linked_run_id) = linked_run_id.as_deref() {
95                        locked_run_ids.push(linked_run_id);
96                    }
97                    locked_run_ids.sort_unstable();
98                    locked_run_ids.dedup();
99                    for locked_run_id in locked_run_ids {
100                        lock_postgres_run(transaction, locked_run_id).await?;
101                    }
102                    retention::ensure_postgres_history_not_tombstoned(transaction, &run_id).await?;
103                    if let Some(linked_run_id) = linked_run_id.as_deref() {
104                        retention::ensure_postgres_history_not_tombstoned(
105                            transaction,
106                            linked_run_id,
107                        )
108                        .await?;
109                        if latest_postgres_sequence(transaction, linked_run_id).await? == 0 {
110                            return Err(FlowError::RunNotFound(linked_run_id.to_string()));
111                        }
112                    }
113                    let actual_sequence = latest_postgres_sequence(transaction, &run_id).await?;
114                    if let Some(expected_sequence) = expected_sequence {
115                        if actual_sequence != expected_sequence {
116                            return Err(FlowError::EventConflict {
117                                run_id,
118                                expected_sequence,
119                                actual_sequence,
120                            });
121                        }
122                    }
123                    if let FlowEvent::HookCreated { hook_id, token, .. } = &event {
124                        ensure_postgres_active_hook_available(transaction, &run_id, hook_id, token)
125                            .await?;
126                    }
127
128                    let envelope = FlowEventEnvelope {
129                        run_id,
130                        sequence: actual_sequence + 1,
131                        event_id: Uuid::new_v4(),
132                        timestamp: Utc::now(),
133                        event,
134                    };
135                    insert_postgres_envelope(transaction, &envelope).await?;
136                    Ok(envelope)
137                })
138            })
139            .await;
140        map_postgres_transaction(result)
141    }
142}
143
144#[async_trait]
145impl FlowEventStore for PostgresEventStore {
146    async fn append(&self, run_id: &str, event: FlowEvent) -> Result<FlowEventEnvelope> {
147        self.append_with_expected_sequence(run_id, None, event)
148            .await
149    }
150
151    async fn append_if_sequence(
152        &self,
153        run_id: &str,
154        expected_sequence: u64,
155        event: FlowEvent,
156    ) -> Result<FlowEventEnvelope> {
157        self.append_with_expected_sequence(run_id, Some(expected_sequence), event)
158            .await
159    }
160
161    async fn list(&self, run_id: &str) -> Result<Vec<FlowEventEnvelope>> {
162        let database = Database::new(PostgresDialect, self.executor.clone());
163        let rows = database
164            .fetch_all_as(
165                sql_query::<(String, i64, String, String, String)>(
166                    "SELECT run_id, sequence, event_id, timestamp, event_json \
167                     FROM flow_events WHERE run_id = ",
168                )
169                .bind(run_id)
170                .append(" ORDER BY sequence ASC"),
171            )
172            .await
173            .map_err(postgres_orm_error)?
174            .rows;
175        if rows.is_empty() {
176            return Err(FlowError::RunNotFound(run_id.to_string()));
177        }
178        rows.into_iter().map(row_to_envelope).collect()
179    }
180
181    async fn list_run_ids(&self) -> Result<Vec<String>> {
182        let database = Database::new(PostgresDialect, self.executor.clone());
183        Ok(database
184            .fetch_all_as(sql_query::<String>(
185                "SELECT DISTINCT run_id FROM flow_events ORDER BY run_id ASC",
186            ))
187            .await
188            .map_err(postgres_orm_error)?
189            .rows)
190    }
191
192    async fn list_due_wakeups(&self, now: DateTime<Utc>) -> Result<Vec<ScheduledWakeup>> {
193        let database = Database::new(PostgresDialect, self.executor.clone());
194        database
195            .fetch_all_as(
196                sql_query::<(String, i64, String, String, Option<String>)>(
197                    "SELECT wakeup.run_id, wakeup.wakeup_kind, wakeup.subject_id, \
198                     wakeup.scheduled_at_key, \
199                     created.event_json::jsonb -> 'spec' ->> 'runtime_build_id' \
200                     FROM flow_scheduled_wakeups AS wakeup \
201                     JOIN flow_events AS created \
202                       ON created.run_id = wakeup.run_id AND created.sequence = 1 \
203                     WHERE wakeup.scheduled_at_key <= ",
204                )
205                .bind(scheduled_wakeup_key(now))
206                .append(" ORDER BY wakeup.wakeup_kind, wakeup.run_id, wakeup.subject_id"),
207            )
208            .await
209            .map_err(postgres_orm_error)?
210            .rows
211            .into_iter()
212            .map(scheduled_wakeup_from_row)
213            .collect()
214    }
215
216    async fn next_scheduled_wakeup(&self) -> Result<Option<ScheduledWakeup>> {
217        let database = Database::new(PostgresDialect, self.executor.clone());
218        database
219            .fetch_all_as(sql_query::<(String, i64, String, String, Option<String>)>(
220                "SELECT wakeup.run_id, wakeup.wakeup_kind, wakeup.subject_id, \
221                 wakeup.scheduled_at_key, \
222                 created.event_json::jsonb -> 'spec' ->> 'runtime_build_id' \
223                 FROM flow_scheduled_wakeups AS wakeup \
224                 JOIN flow_events AS created \
225                   ON created.run_id = wakeup.run_id AND created.sequence = 1 \
226                 ORDER BY wakeup.scheduled_at_key, wakeup.run_id, \
227                          wakeup.wakeup_kind, wakeup.subject_id LIMIT 1",
228            ))
229            .await
230            .map_err(postgres_orm_error)?
231            .rows
232            .into_iter()
233            .next()
234            .map(scheduled_wakeup_from_row)
235            .transpose()
236    }
237
238    async fn find_active_hooks_by_token(&self, token: &str) -> Result<Vec<ActiveHookSnapshot>> {
239        let database = Database::new(PostgresDialect, self.executor.clone());
240        database
241            .fetch_all_as(
242                sql_query::<(String, String, String, String)>(
243                    "SELECT run_id, hook_id, token, metadata_json \
244                     FROM flow_active_hooks WHERE token = ",
245                )
246                .bind(token)
247                .append(" ORDER BY run_id, hook_id"),
248            )
249            .await
250            .map_err(postgres_orm_error)?
251            .rows
252            .into_iter()
253            .map(active_hook_from_row)
254            .collect()
255    }
256
257    async fn list_active_hooks(&self) -> Result<Vec<ActiveHookSnapshot>> {
258        let database = Database::new(PostgresDialect, self.executor.clone());
259        database
260            .fetch_all_as(sql_query::<(String, String, String, String)>(
261                "SELECT run_id, hook_id, token, metadata_json \
262                 FROM flow_active_hooks ORDER BY run_id, hook_id",
263            ))
264            .await
265            .map_err(postgres_orm_error)?
266            .rows
267            .into_iter()
268            .map(active_hook_from_row)
269            .collect()
270    }
271}
272
273async fn execute_postgres<E>(executor: &E, query: SqlQuery<()>) -> Result<u64>
274where
275    E: Executor<Row = PostgresRow, Error = PostgresError>,
276{
277    let query = query
278        .compile(&PostgresDialect)
279        .map_err(postgres_query_error)?;
280    Ok(executor
281        .execute(&query)
282        .await
283        .map_err(postgres_driver_error)?
284        .rows_affected)
285}
286
287async fn fetch_all_postgres<T, E>(executor: &E, query: SqlQuery<T>) -> Result<Vec<T>>
288where
289    T: FromRow + Send,
290    E: Executor<Row = PostgresRow, Error = PostgresError>,
291{
292    let query = query
293        .compile(&PostgresDialect)
294        .map_err(postgres_query_error)?;
295    executor
296        .fetch_all(&query)
297        .await
298        .map_err(postgres_driver_error)?
299        .rows
300        .iter()
301        .map(T::from_row)
302        .collect::<std::result::Result<Vec<_>, _>>()
303        .map_err(postgres_decode_error)
304}
305
306async fn fetch_optional_postgres<T, E>(executor: &E, query: SqlQuery<T>) -> Result<Option<T>>
307where
308    T: FromRow + Send,
309    E: Executor<Row = PostgresRow, Error = PostgresError>,
310{
311    let mut rows = fetch_all_postgres(executor, query).await?;
312    match rows.len() {
313        0 => Ok(None),
314        1 => Ok(rows.pop()),
315        actual => Err(FlowError::Store(format!(
316            "PostgreSQL Flow query returned {actual} rows where at most one was expected"
317        ))),
318    }
319}
320
321async fn lock_postgres_run(transaction: &PostgresTransaction, run_id: &str) -> Result<()> {
322    // Keep this exact two-key shape for lock compatibility with sqlx-backed
323    // Flow releases: hashtext(run_id) is the first key and zero is the second.
324    let query = sql_query::<i64>("SELECT 1 FROM pg_advisory_xact_lock(hashtext(")
325        .bind(run_id)
326        .append("), 0)")
327        .compile(&PostgresDialect)
328        .map_err(postgres_query_error)?;
329    transaction
330        .fetch_all(&query)
331        .await
332        .map_err(postgres_driver_error)?;
333    Ok(())
334}
335
336async fn lock_postgres_active_hook_token(
337    transaction: &PostgresTransaction,
338    token: &str,
339) -> Result<()> {
340    // Token creation uses a distinct advisory-lock namespace so concurrent
341    // writers serialize only when they compete for the same callback token.
342    let query = sql_query::<i64>("SELECT 1 FROM pg_advisory_xact_lock(hashtext(")
343        .bind(token)
344        .append("), 2)")
345        .compile(&PostgresDialect)
346        .map_err(postgres_query_error)?;
347    transaction
348        .fetch_all(&query)
349        .await
350        .map_err(postgres_driver_error)?;
351    Ok(())
352}
353
354async fn lock_postgres_retention_guard_shared(
355    transaction: &PostgresTransaction,
356    lock_id: &str,
357) -> Result<()> {
358    let query = sql_query::<i64>("SELECT 1 FROM pg_advisory_xact_lock_shared(hashtext(")
359        .bind(lock_id)
360        .append("), 1)")
361        .compile(&PostgresDialect)
362        .map_err(postgres_query_error)?;
363    transaction
364        .fetch_all(&query)
365        .await
366        .map_err(postgres_driver_error)?;
367    Ok(())
368}
369
370async fn lock_postgres_retention_guard_exclusive(
371    transaction: &PostgresTransaction,
372    lock_id: &str,
373) -> Result<()> {
374    let query = sql_query::<i64>("SELECT 1 FROM pg_advisory_xact_lock(hashtext(")
375        .bind(lock_id)
376        .append("), 1)")
377        .compile(&PostgresDialect)
378        .map_err(postgres_query_error)?;
379    transaction
380        .fetch_all(&query)
381        .await
382        .map_err(postgres_driver_error)?;
383    Ok(())
384}
385
386async fn latest_postgres_sequence(transaction: &PostgresTransaction, run_id: &str) -> Result<u64> {
387    let query = sql_query::<i64>(
388        "SELECT COALESCE(MAX(sequence), 0)::BIGINT FROM flow_events WHERE run_id = ",
389    )
390    .bind(run_id)
391    .compile(&PostgresDialect)
392    .map_err(postgres_query_error)?;
393    let rows = transaction
394        .fetch_all(&query)
395        .await
396        .map_err(postgres_driver_error)?
397        .rows;
398    let row = rows
399        .first()
400        .ok_or_else(|| FlowError::Store("PostgreSQL sequence query returned no row".to_string()))?;
401    let sequence = i64::from_row(row).map_err(postgres_decode_error)?;
402    u64::try_from(sequence).map_err(|error| {
403        FlowError::Store(format!(
404            "invalid PostgreSQL event sequence {sequence}: {error}"
405        ))
406    })
407}
408
409async fn ensure_postgres_active_hook_available(
410    transaction: &PostgresTransaction,
411    run_id: &str,
412    hook_id: &str,
413    token: &str,
414) -> Result<()> {
415    lock_postgres_active_hook_token(transaction, token).await?;
416    let owners = fetch_all_postgres::<(String, String), _>(
417        transaction,
418        sql_query::<(String, String)>(
419            "SELECT run_id, hook_id FROM flow_active_hooks WHERE token = ",
420        )
421        .bind(token),
422    )
423    .await?;
424    if let Some((existing_run_id, existing_hook_id)) = owners.into_iter().next() {
425        if existing_run_id == run_id && existing_hook_id == hook_id {
426            return Ok(());
427        }
428        return Err(FlowError::HookTokenConflict {
429            token: token.to_string(),
430            existing_run_id,
431            existing_hook_id,
432        });
433    }
434
435    let existing_tokens = fetch_all_postgres::<String, _>(
436        transaction,
437        sql_query::<String>("SELECT token FROM flow_active_hooks WHERE run_id = ")
438            .bind(run_id)
439            .append(" AND hook_id = ")
440            .bind(hook_id),
441    )
442    .await?;
443    if existing_tokens
444        .first()
445        .is_some_and(|existing_token| existing_token != token)
446    {
447        return Err(FlowError::InvalidTransition(format!(
448            "active hook {hook_id} for run {run_id} already uses a different token (value redacted)"
449        )));
450    }
451    Ok(())
452}
453
454async fn insert_postgres_envelope(
455    transaction: &PostgresTransaction,
456    envelope: &FlowEventEnvelope,
457) -> Result<()> {
458    let sequence = i64::try_from(envelope.sequence).map_err(|error| {
459        FlowError::Store(format!(
460            "event sequence {} exceeds PostgreSQL bigint range: {error}",
461            envelope.sequence
462        ))
463    })?;
464    let query = sql_query::<()>(
465        "INSERT INTO flow_events (run_id, sequence, event_id, timestamp, event_json) VALUES (",
466    )
467    .bind(envelope.run_id.clone())
468    .append(", ")
469    .bind(sequence)
470    .append(", ")
471    .bind(envelope.event_id.to_string())
472    .append(", ")
473    .bind(envelope.timestamp.to_rfc3339())
474    .append(", ")
475    .bind(serde_json::to_string(&envelope.event)?)
476    .append(")")
477    .compile(&PostgresDialect)
478    .map_err(postgres_query_error)?;
479    transaction
480        .execute(&query)
481        .await
482        .map_err(postgres_driver_error)?;
483    Ok(())
484}
485
486fn row_to_envelope(
487    (run_id, sequence, event_id, timestamp, event_json): (String, i64, String, String, String),
488) -> Result<FlowEventEnvelope> {
489    Ok(FlowEventEnvelope {
490        run_id,
491        sequence: u64::try_from(sequence).map_err(|error| {
492            FlowError::Store(format!(
493                "invalid PostgreSQL event sequence {sequence}: {error}"
494            ))
495        })?,
496        event_id: event_id.parse().map_err(|error| {
497            FlowError::Store(format!("invalid PostgreSQL event id {event_id}: {error}"))
498        })?,
499        timestamp: timestamp.parse().map_err(|error| {
500            FlowError::Store(format!(
501                "invalid PostgreSQL event timestamp {timestamp}: {error}"
502            ))
503        })?,
504        event: serde_json::from_str(&event_json)?,
505    })
506}
507
508fn active_hook_from_row(
509    (run_id, hook_id, token, metadata_json): (String, String, String, String),
510) -> Result<ActiveHookSnapshot> {
511    Ok(ActiveHookSnapshot {
512        run_id,
513        hook: HookSnapshot {
514            hook_id,
515            token,
516            status: HookStatus::Active,
517            metadata: serde_json::from_str(&metadata_json)?,
518            payload: None,
519        },
520    })
521}
522
523fn map_postgres_transaction<T>(
524    result: std::result::Result<T, PostgresTransactionError<FlowError>>,
525) -> Result<T> {
526    match result {
527        Ok(value) => Ok(value),
528        Err(PostgresTransactionError::Operation(error)) => Err(error),
529        Err(error) => Err(FlowError::Store(format!(
530            "PostgreSQL Flow transaction failed: {error}"
531        ))),
532    }
533}
534
535fn postgres_query_error(error: a3s_orm::Error) -> FlowError {
536    FlowError::Store(format!("PostgreSQL Flow query build failed: {error}"))
537}
538
539fn postgres_driver_error(error: PostgresError) -> FlowError {
540    FlowError::Store(format!("PostgreSQL Flow storage failed: {error}"))
541}
542
543fn postgres_decode_error(error: a3s_orm::DecodeError) -> FlowError {
544    FlowError::Store(format!("PostgreSQL Flow row decoding failed: {error}"))
545}
546
547fn postgres_orm_error(error: a3s_orm::DatabaseError<PostgresError>) -> FlowError {
548    FlowError::Store(format!("PostgreSQL Flow storage failed: {error}"))
549}