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}