Skip to main content

paigasus_helikon_sessions_postgres/
lib.rs

1//! PostgreSQL-backed [`Session`] implementation. Mirrors the sqlite backend's
2//! event-log shape; safe for concurrent writers via a per-session advisory lock.
3//!
4//! Events are stored in a `session_events` table with a `JSONB` payload column.
5//! Appends take a per-session `pg_advisory_xact_lock` before computing the next
6//! sequence number, ensuring no two concurrent writers can produce the same
7//! `(session_id, sequence)` pair.
8//!
9//! ## Pool setup
10//!
11//! ```no_run
12//! # async fn build() -> Result<sqlx::PgPool, sqlx::Error> {
13//! let pool = sqlx::PgPool::connect("postgres://user:pass@localhost/mydb").await?;
14//! # Ok(pool)
15//! # }
16//! ```
17//!
18//! ## Recommended usage
19//!
20//! ```no_run
21//! # use paigasus_helikon_core::Session;
22//! # use paigasus_helikon_sessions_postgres::PostgresSession;
23//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
24//! let pool = sqlx::PgPool::connect("postgres://user:pass@localhost/mydb").await?;
25//! // Migrate once at process start, then use open_without_migrate on the hot path.
26//! PostgresSession::migrate(&pool).await?;
27//! let session = PostgresSession::open_without_migrate(pool, "session-abc");
28//! session.append(&[]).await?;
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! [`Session`]: paigasus_helikon_core::Session
34
35use async_trait::async_trait;
36use paigasus_helikon_core::{
37    project, ConversationSnapshot, SequenceId, Session, SessionError, SessionEvent,
38};
39use sqlx::PgPool;
40
41static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
42
43/// PostgreSQL-backed [`Session`]. One instance is one session (`session_id`);
44/// pools are shared across instances.
45///
46/// [`Session`]: paigasus_helikon_core::Session
47#[derive(Debug, Clone)]
48pub struct PostgresSession {
49    pool: PgPool,
50    session_id: String,
51}
52
53impl PostgresSession {
54    /// Run embedded migrations on `pool`. Idempotent — safe to call on every startup.
55    ///
56    /// Optional: [`PostgresSession::open`] runs migrations internally. Call
57    /// this directly if you manage many sessions and want to migrate once at
58    /// process start, then use [`PostgresSession::open_without_migrate`] on the
59    /// hot path to skip the per-`open` round-trip to `_sqlx_migrations`.
60    ///
61    /// **Editing a migration file after first deploy will fail** with a
62    /// checksum mismatch against the `_sqlx_migrations` table. Add a new
63    /// numbered file (e.g., `0002_…`) instead of mutating an existing one.
64    pub async fn migrate(pool: &PgPool) -> Result<(), SessionError> {
65        MIGRATOR.run(pool).await.map_err(SessionError::backend)?;
66        Ok(())
67    }
68
69    /// Open (and migrate) a session within `pool`. Runs migrations as a side
70    /// effect (one round-trip to `_sqlx_migrations`). For repeated session-opens
71    /// against an already-migrated pool, prefer
72    /// [`PostgresSession::open_without_migrate`].
73    pub async fn open(pool: PgPool, session_id: impl Into<String>) -> Result<Self, SessionError> {
74        Self::migrate(&pool).await?;
75        Ok(Self::open_without_migrate(pool, session_id))
76    }
77
78    /// Open a session without running migrations. The caller must have already
79    /// invoked [`PostgresSession::migrate`] on this pool; otherwise the first
80    /// [`Session::append`] fails with `SessionError::Backend` wrapping a
81    /// `relation "session_events" does not exist` error.
82    pub fn open_without_migrate(pool: PgPool, session_id: impl Into<String>) -> Self {
83        Self {
84            pool,
85            session_id: session_id.into(),
86        }
87    }
88
89    /// The `session_id` this instance reads and writes.
90    pub fn session_id(&self) -> &str {
91        &self.session_id
92    }
93}
94
95#[async_trait]
96impl Session for PostgresSession {
97    async fn append(&self, events: &[SessionEvent]) -> Result<(), SessionError> {
98        if events.is_empty() {
99            return Ok(());
100        }
101        // Single transaction on ONE pooled connection: the advisory lock must
102        // cover the INSERTs. Per-session lock auto-releases at COMMIT. The key is
103        // `hashtextextended($1, 0)` — a 64-bit hash, so the full advisory-lock key
104        // space is used and distinct sessions effectively never collide (unlike the
105        // 32-bit `hashtext`, which could occasionally serialize unrelated sessions).
106        let mut tx = self.pool.begin().await.map_err(SessionError::backend)?;
107        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
108            .bind(&self.session_id)
109            .execute(&mut *tx)
110            .await
111            .map_err(SessionError::backend)?;
112        let next: i64 = sqlx::query_scalar(
113            "SELECT COALESCE(MAX(sequence), -1) + 1 FROM session_events WHERE session_id = $1",
114        )
115        .bind(&self.session_id)
116        .fetch_one(&mut *tx)
117        .await
118        .map_err(SessionError::backend)?;
119
120        for (offset, ev) in events.iter().enumerate() {
121            let seq = next + offset as i64;
122            let payload = serde_json::to_value(ev).map_err(SessionError::backend)?;
123            sqlx::query(
124                "INSERT INTO session_events (session_id, sequence, ts_nanos, kind, payload) \
125                 VALUES ($1, $2, $3, $4, $5)",
126            )
127            .bind(&self.session_id)
128            .bind(seq)
129            .bind(ev.ts_nanos_saturating())
130            .bind(ev.kind())
131            .bind(payload)
132            .execute(&mut *tx)
133            .await
134            .map_err(SessionError::backend)?;
135        }
136        tx.commit().await.map_err(SessionError::backend)?;
137        Ok(())
138    }
139
140    async fn events(&self, since: Option<SequenceId>) -> Result<Vec<SessionEvent>, SessionError> {
141        // `since` is exclusive ("those after"). Default to -1 so the
142        // `sequence > $2` filter is a no-op when None.
143        let watermark: i64 = match since {
144            // `s.0` is u64; `> i64::MAX` is unreachable in practice but means "after
145            // every possible sequence." Saturating to i64::MAX makes the filter
146            // return empty rather than erroring — semantically correct.
147            Some(s) => i64::try_from(s.0).unwrap_or(i64::MAX),
148            None => -1,
149        };
150        let rows: Vec<(sqlx::types::Json<SessionEvent>,)> = sqlx::query_as(
151            "SELECT payload FROM session_events \
152             WHERE session_id = $1 AND sequence > $2 ORDER BY sequence",
153        )
154        .bind(&self.session_id)
155        .bind(watermark)
156        .fetch_all(&self.pool)
157        .await
158        .map_err(SessionError::backend)?;
159        Ok(rows.into_iter().map(|(j,)| j.0).collect())
160    }
161
162    async fn snapshot(&self) -> Result<ConversationSnapshot, SessionError> {
163        Ok(project(&self.events(None).await?))
164    }
165}