paigasus_helikon_sessions_sqlite/lib.rs
1//! SQLite-backed [`Session`] implementation for the Paigasus Helikon SDK.
2//!
3//! Stores conversation event logs in a single SQLite database. Multiple
4//! sessions share one `SqlitePool` and are isolated by `session_id`. Safe
5//! for concurrent writers — appends serialize through SQLite's database-level
6//! write lock; the `(session_id, sequence)` primary key is the uniqueness
7//! backstop.
8//!
9//! ## Recommended pool configuration
10//!
11//! ```no_run
12//! use sqlx::sqlite::{
13//! SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous,
14//! };
15//! use std::time::Duration;
16//!
17//! # async fn build() -> Result<sqlx::SqlitePool, sqlx::Error> {
18//! let opts = SqliteConnectOptions::new()
19//! .filename("sessions.db")
20//! .create_if_missing(true)
21//! .journal_mode(SqliteJournalMode::Wal)
22//! .synchronous(SqliteSynchronous::Normal)
23//! .busy_timeout(Duration::from_secs(30));
24//! SqlitePoolOptions::new().connect_with(opts).await
25//! # }
26//! ```
27//!
28//! `synchronous = NORMAL` is the recommended pairing with WAL for
29//! multi-writer workloads: commits stop fsyncing individually (the WAL is
30//! synced at checkpoints instead), which multiplies write throughput under
31//! contention. The trade-off is durability, not integrity — after a power
32//! loss the most recent commits may be missing, but the database cannot
33//! corrupt. Keep sqlx's default `FULL` if losing any acknowledged append is
34//! unacceptable.
35//!
36//! ## Concurrent appends under contention
37//!
38//! Writers serialize on SQLite's single database-level write lock.
39//! `busy_timeout` caps how long one `append` waits for that lock, and under
40//! sustained contention the worst-placed writer waits out the *entire
41//! backlog* ahead of it — so size the timeout against
42//! `(concurrent writers) × (appends per writer) × (worst-case transaction
43//! latency)`, not against a single transaction. An `append` that exhausts
44//! the timeout fails with [`SessionError::Backend`] wrapping `SQLITE_BUSY`
45//! ("database is locked"). The failure is clean: nothing from the failed
46//! call is persisted, no stored data is lost or corrupted, and the session
47//! remains usable (pinned by this crate's `busy_timeout` integration test).
48//! 30 seconds is a sane starting point; tune upward for heavy multi-writer
49//! contention.
50//!
51//! ## Provider-translator caveat
52//!
53//! The [`project`] function in `paigasus-helikon-core` renders [`Compacted`]
54//! events as `Item::System`. Both shipped provider translators reshape
55//! system messages — Anthropic hoists them to the top-level `system` field,
56//! OpenAI concatenates them. Compaction summaries reach the model but as
57//! top-level instructions, not positional cutovers.
58//!
59//! [`Session`]: paigasus_helikon_core::Session
60//! [`SessionError::Backend`]: paigasus_helikon_core::SessionError::Backend
61//! [`project`]: paigasus_helikon_core::project
62//! [`Compacted`]: paigasus_helikon_core::SessionEvent::Compacted
63
64use async_trait::async_trait;
65use paigasus_helikon_core::{
66 project, ConversationSnapshot, SequenceId, Session, SessionError, SessionEvent,
67};
68use sqlx::SqlitePool;
69
70static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
71
72/// SQLite-backed [`Session`] implementation. One instance is one session
73/// (identified by `session_id`); pools are shared across instances.
74#[derive(Debug, Clone)]
75pub struct SqliteSession {
76 pool: SqlitePool,
77 session_id: String,
78}
79
80impl SqliteSession {
81 /// Run embedded migrations on `pool`. Idempotent — safe on every startup.
82 ///
83 /// Optional: [`SqliteSession::open`] runs migrations internally. Call
84 /// this directly if you manage many sessions and want to migrate once at
85 /// process start, then use [`SqliteSession::open_without_migrate`] on the
86 /// hot path to skip the per-`open` round-trip to `_sqlx_migrations`.
87 ///
88 /// **Editing a migration file after first deploy will fail** with a
89 /// checksum mismatch against the `_sqlx_migrations` table. Add a new
90 /// numbered file (e.g., `0002_…`) instead of mutating an existing one.
91 pub async fn migrate(pool: &SqlitePool) -> Result<(), SessionError> {
92 MIGRATOR.run(pool).await.map_err(SessionError::backend)?;
93 Ok(())
94 }
95
96 /// Open (or implicitly create) a session within `pool`. Runs migrations
97 /// as a side effect (one round-trip to `_sqlx_migrations`). For repeated
98 /// session-opens against an already-migrated pool, prefer
99 /// [`SqliteSession::open_without_migrate`].
100 pub async fn open(
101 pool: SqlitePool,
102 session_id: impl Into<String>,
103 ) -> Result<Self, SessionError> {
104 Self::migrate(&pool).await?;
105 Ok(Self::open_without_migrate(pool, session_id))
106 }
107
108 /// Open a session without running migrations. The caller must have
109 /// already invoked [`SqliteSession::migrate`] on this pool; otherwise
110 /// the first [`Session::append`] fails with `SessionError::Backend`
111 /// wrapping a `no such table` error.
112 pub fn open_without_migrate(pool: SqlitePool, session_id: impl Into<String>) -> Self {
113 Self {
114 pool,
115 session_id: session_id.into(),
116 }
117 }
118
119 /// The `session_id` this instance reads and writes.
120 pub fn session_id(&self) -> &str {
121 &self.session_id
122 }
123}
124
125#[async_trait]
126impl Session for SqliteSession {
127 async fn append(&self, events: &[SessionEvent]) -> Result<(), SessionError> {
128 if events.is_empty() {
129 return Ok(());
130 }
131
132 // `BEGIN IMMEDIATE` acquires SQLite's RESERVED lock up-front, so the
133 // SELECT-MAX-then-INSERT sequence can't race two concurrent writers
134 // into a UNIQUE-constraint collision on `(session_id, sequence)`.
135 // sqlx's plain `pool.begin()` issues `BEGIN` (DEFERRED), which would
136 // let two readers compute the same `next` value before either locks
137 // for write. See `begin_ansi_transaction_sql` in sqlx-core.
138 let mut tx = self
139 .pool
140 .begin_with("BEGIN IMMEDIATE")
141 .await
142 .map_err(SessionError::backend)?;
143
144 // Find next sequence number for this session. COALESCE handles the
145 // first-append case (MAX returns NULL on an empty result set).
146 let row: (i64,) = sqlx::query_as(
147 "SELECT COALESCE(MAX(sequence), -1) + 1 FROM session_events WHERE session_id = ?",
148 )
149 .bind(&self.session_id)
150 .fetch_one(&mut *tx)
151 .await
152 .map_err(SessionError::backend)?;
153 let start: i64 = row.0;
154
155 for (offset, ev) in events.iter().enumerate() {
156 let next = start + offset as i64;
157 let (kind, ts_nanos) = event_metadata(ev);
158 let payload = serde_json::to_string(ev).map_err(SessionError::backend)?;
159
160 sqlx::query(
161 "INSERT INTO session_events (session_id, sequence, ts_nanos, kind, payload) \
162 VALUES (?, ?, ?, ?, ?)",
163 )
164 .bind(&self.session_id)
165 .bind(next)
166 .bind(ts_nanos)
167 .bind(kind)
168 .bind(&payload)
169 .execute(&mut *tx)
170 .await
171 .map_err(SessionError::backend)?;
172 }
173
174 tx.commit().await.map_err(SessionError::backend)?;
175 Ok(())
176 }
177
178 async fn events(&self, since: Option<SequenceId>) -> Result<Vec<SessionEvent>, SessionError> {
179 // `since` is exclusive ("those after"). Default to -1 so the
180 // `sequence > ?` filter is a no-op when None.
181 let watermark: i64 = match since {
182 // `s.0` is u64; `> i64::MAX` is unreachable in practice but means "after
183 // every possible sequence." Saturating to i64::MAX makes the filter
184 // return empty rather than erroring — semantically correct.
185 Some(s) => i64::try_from(s.0).unwrap_or(i64::MAX),
186 None => -1,
187 };
188
189 let rows: Vec<(String,)> = sqlx::query_as(
190 "SELECT payload FROM session_events \
191 WHERE session_id = ? AND sequence > ? \
192 ORDER BY sequence",
193 )
194 .bind(&self.session_id)
195 .bind(watermark)
196 .fetch_all(&self.pool)
197 .await
198 .map_err(SessionError::backend)?;
199
200 rows.into_iter()
201 .map(|(payload,)| {
202 serde_json::from_str::<SessionEvent>(&payload).map_err(SessionError::backend)
203 })
204 .collect()
205 }
206
207 async fn snapshot(&self) -> Result<ConversationSnapshot, SessionError> {
208 let events = self.events(None).await?;
209 Ok(project(&events))
210 }
211}
212
213/// Extract the `(kind, ts_nanos)` denormalized columns for an event. `kind`
214/// matches the serde tag of the variant; `ts_nanos` is the timestamp in
215/// i64 nanoseconds since the Unix epoch (covers ±292 years from 1970).
216///
217/// Delegates to [`SessionEvent::kind`] and [`SessionEvent::ts_nanos_saturating`]
218/// so new variants added to core are handled automatically without requiring
219/// updates here.
220fn event_metadata(ev: &SessionEvent) -> (&'static str, i64) {
221 (ev.kind(), ev.ts_nanos_saturating())
222}