Skip to main content

nexo_memory/
compactions.rs

1//! SQLite-backed storage for online history compaction.
2//!
3//! Holds two tables:
4//!
5//! * `compactions_v1` — append-only audit log. One row per successful
6//!   compaction: which session, when it happened, how many turns were
7//!   summarized, what the summary said, model used, token cost. Lets
8//!   operators rebuild a compacted thread or audit an LLM-generated
9//!   summary post-hoc.
10//! * `compaction_locks_v1` — single-row-per-session advisory lock so
11//!   only one compactor at a time can run on a given session
12//!   (multi-process safe). Locks have a TTL: if a process crashes
13//!   mid-compaction, the next acquire after `ttl_seconds` automatically
14//!   evicts the stale entry.
15//!
16//! Table names carry a `_v1` suffix to keep room for a schema bump
17//! without colliding with stale rows in long-running deployments.
18
19use anyhow::Result;
20use sqlx::sqlite::SqliteConnectOptions;
21use sqlx::SqlitePool;
22use std::str::FromStr;
23use uuid::Uuid;
24
25/// One persisted compaction event. `summary` is the LLM-generated
26/// replacement text; `tail_start_index` is the first session-history
27/// index that was preserved verbatim (everything before was folded
28/// into the summary).
29#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
30pub struct CompactionRow {
31    pub session_id: String,
32    pub compacted_at: i64, // unix ms
33    pub head_turn_count: i64,
34    pub tail_start_index: i64,
35    pub summary: String,
36    pub model_used: String,
37    pub input_tokens: i64,
38    pub output_tokens: i64,
39}
40
41pub struct CompactionStore {
42    pool: SqlitePool,
43}
44
45impl CompactionStore {
46    /// Open or create the SQLite file at `db_path`. The directory must
47    /// already exist (we don't create it). Use `:memory:` for tests.
48    pub async fn open(db_path: &str) -> Result<Self> {
49        let opts = SqliteConnectOptions::from_str(db_path)?
50            .create_if_missing(true)
51            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
52        let pool = SqlitePool::connect_with(opts).await?;
53        sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
54        let store = Self { pool };
55        store.migrate().await?;
56        Ok(store)
57    }
58
59    /// Wrap an externally-opened pool. Useful when sharing the same
60    /// SQLite file as another store. Caller is responsible for migrate.
61    pub fn with_pool(pool: SqlitePool) -> Self {
62        Self { pool }
63    }
64
65    pub async fn migrate(&self) -> Result<()> {
66        sqlx::query(
67            "CREATE TABLE IF NOT EXISTS compactions_v1 (
68                session_id        TEXT NOT NULL,
69                compacted_at      INTEGER NOT NULL,
70                head_turn_count   INTEGER NOT NULL,
71                tail_start_index  INTEGER NOT NULL,
72                summary           TEXT NOT NULL,
73                model_used        TEXT NOT NULL,
74                input_tokens      INTEGER NOT NULL,
75                output_tokens     INTEGER NOT NULL,
76                PRIMARY KEY (session_id, compacted_at)
77            )",
78        )
79        .execute(&self.pool)
80        .await?;
81        sqlx::query(
82            "CREATE INDEX IF NOT EXISTS idx_compactions_v1_session
83             ON compactions_v1(session_id, compacted_at DESC)",
84        )
85        .execute(&self.pool)
86        .await?;
87        sqlx::query(
88            "CREATE TABLE IF NOT EXISTS compaction_locks_v1 (
89                session_id TEXT PRIMARY KEY,
90                locked_at  INTEGER NOT NULL,
91                holder     TEXT NOT NULL
92            )",
93        )
94        .execute(&self.pool)
95        .await?;
96        Ok(())
97    }
98
99    /// Append a successful compaction. Errors only on storage problems.
100    pub async fn insert(&self, row: &CompactionRow) -> Result<()> {
101        sqlx::query(
102            "INSERT INTO compactions_v1 (
103                session_id, compacted_at, head_turn_count, tail_start_index,
104                summary, model_used, input_tokens, output_tokens
105            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
106        )
107        .bind(&row.session_id)
108        .bind(row.compacted_at)
109        .bind(row.head_turn_count)
110        .bind(row.tail_start_index)
111        .bind(&row.summary)
112        .bind(&row.model_used)
113        .bind(row.input_tokens)
114        .bind(row.output_tokens)
115        .execute(&self.pool)
116        .await?;
117        Ok(())
118    }
119
120    /// Most recent compaction for a session, if any.
121    pub async fn latest(&self, session_id: Uuid) -> Result<Option<CompactionRow>> {
122        let row: Option<CompactionRow> = sqlx::query_as(
123            "SELECT session_id, compacted_at, head_turn_count, tail_start_index,
124                    summary, model_used, input_tokens, output_tokens
125             FROM compactions_v1
126             WHERE session_id = ?
127             ORDER BY compacted_at DESC
128             LIMIT 1",
129        )
130        .bind(session_id.to_string())
131        .fetch_optional(&self.pool)
132        .await?;
133        Ok(row)
134    }
135
136    /// All compactions for a session, newest first, capped at `limit`.
137    pub async fn list_for_session(
138        &self,
139        session_id: Uuid,
140        limit: u32,
141    ) -> Result<Vec<CompactionRow>> {
142        let rows: Vec<CompactionRow> = sqlx::query_as(
143            "SELECT session_id, compacted_at, head_turn_count, tail_start_index,
144                    summary, model_used, input_tokens, output_tokens
145             FROM compactions_v1
146             WHERE session_id = ?
147             ORDER BY compacted_at DESC
148             LIMIT ?",
149        )
150        .bind(session_id.to_string())
151        .bind(limit as i64)
152        .fetch_all(&self.pool)
153        .await?;
154        Ok(rows)
155    }
156
157    /// Try to take the per-session compaction lock. Returns `true`
158    /// when acquired, `false` when another holder is active. Stale
159    /// locks (older than `ttl_seconds`) are evicted before the
160    /// acquire attempt so a crashed compactor doesn't deadlock the
161    /// session forever.
162    ///
163    /// `holder` is a free-form debug label (`"pid:thread_id"` works
164    /// well) recorded on the row to make orphan diagnosis easier.
165    pub async fn try_acquire_lock(
166        &self,
167        session_id: Uuid,
168        holder: &str,
169        ttl_seconds: u32,
170    ) -> Result<bool> {
171        // Sweep stale locks for this session before attempting the
172        // INSERT — keeps the per-acquire footprint cheap and avoids a
173        // global cleanup pass.
174        let now_ms = chrono::Utc::now().timestamp_millis();
175        let cutoff_ms = now_ms - (ttl_seconds as i64 * 1000);
176        sqlx::query(
177            "DELETE FROM compaction_locks_v1
178             WHERE session_id = ? AND locked_at < ?",
179        )
180        .bind(session_id.to_string())
181        .bind(cutoff_ms)
182        .execute(&self.pool)
183        .await?;
184        // INSERT … OR IGNORE: when another holder owns the lock the
185        // PRIMARY KEY constraint silently rejects the insert and we
186        // report failure to the caller.
187        let result = sqlx::query(
188            "INSERT OR IGNORE INTO compaction_locks_v1 (session_id, locked_at, holder)
189             VALUES (?, ?, ?)",
190        )
191        .bind(session_id.to_string())
192        .bind(now_ms)
193        .bind(holder)
194        .execute(&self.pool)
195        .await?;
196        Ok(result.rows_affected() == 1)
197    }
198
199    /// Release a previously-held lock. Idempotent.
200    pub async fn release_lock(&self, session_id: Uuid) -> Result<()> {
201        sqlx::query("DELETE FROM compaction_locks_v1 WHERE session_id = ?")
202            .bind(session_id.to_string())
203            .execute(&self.pool)
204            .await?;
205        Ok(())
206    }
207
208    /// Sweep every lock older than `ttl_seconds`. Call from a
209    /// background task or on boot to clean up after crashed
210    /// processes. Returns the number of rows removed.
211    pub async fn cleanup_stale_locks(&self, ttl_seconds: u32) -> Result<u64> {
212        let now_ms = chrono::Utc::now().timestamp_millis();
213        let cutoff_ms = now_ms - (ttl_seconds as i64 * 1000);
214        let result = sqlx::query("DELETE FROM compaction_locks_v1 WHERE locked_at < ?")
215            .bind(cutoff_ms)
216            .execute(&self.pool)
217            .await?;
218        Ok(result.rows_affected())
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    async fn open_mem() -> CompactionStore {
227        CompactionStore::open(":memory:").await.unwrap()
228    }
229
230    fn row(session: Uuid, ts: i64) -> CompactionRow {
231        CompactionRow {
232            session_id: session.to_string(),
233            compacted_at: ts,
234            head_turn_count: 5,
235            tail_start_index: 5,
236            summary: "Compacted: discussed weather.".into(),
237            model_used: "claude-sonnet-4-5".into(),
238            input_tokens: 10_000,
239            output_tokens: 800,
240        }
241    }
242
243    #[tokio::test]
244    async fn migrate_is_idempotent() {
245        let s = open_mem().await;
246        s.migrate().await.unwrap();
247        s.migrate().await.unwrap();
248    }
249
250    #[tokio::test]
251    async fn insert_and_latest_roundtrip() {
252        let s = open_mem().await;
253        let session = Uuid::new_v4();
254        s.insert(&row(session, 1)).await.unwrap();
255        s.insert(&row(session, 2)).await.unwrap();
256        let latest = s.latest(session).await.unwrap().unwrap();
257        assert_eq!(latest.compacted_at, 2);
258    }
259
260    #[tokio::test]
261    async fn list_for_session_orders_newest_first() {
262        let s = open_mem().await;
263        let session = Uuid::new_v4();
264        for i in [3, 1, 2] {
265            s.insert(&row(session, i)).await.unwrap();
266        }
267        let rows = s.list_for_session(session, 10).await.unwrap();
268        let timestamps: Vec<i64> = rows.iter().map(|r| r.compacted_at).collect();
269        assert_eq!(timestamps, vec![3, 2, 1]);
270    }
271
272    #[tokio::test]
273    async fn list_respects_limit() {
274        let s = open_mem().await;
275        let session = Uuid::new_v4();
276        for i in 0..5 {
277            s.insert(&row(session, i)).await.unwrap();
278        }
279        let rows = s.list_for_session(session, 2).await.unwrap();
280        assert_eq!(rows.len(), 2);
281    }
282
283    #[tokio::test]
284    async fn lock_acquire_and_double_acquire_blocks() {
285        let s = open_mem().await;
286        let session = Uuid::new_v4();
287        assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
288        assert!(!s.try_acquire_lock(session, "p2", 60).await.unwrap());
289    }
290
291    #[tokio::test]
292    async fn release_unlocks() {
293        let s = open_mem().await;
294        let session = Uuid::new_v4();
295        assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
296        s.release_lock(session).await.unwrap();
297        assert!(s.try_acquire_lock(session, "p1", 60).await.unwrap());
298    }
299
300    #[tokio::test]
301    async fn release_is_idempotent() {
302        let s = open_mem().await;
303        let session = Uuid::new_v4();
304        s.release_lock(session).await.unwrap();
305        s.release_lock(session).await.unwrap();
306    }
307
308    #[tokio::test]
309    async fn stale_lock_evicted_on_acquire() {
310        let s = open_mem().await;
311        let session = Uuid::new_v4();
312        // Plant an artificially old lock by direct insert at ts=0.
313        sqlx::query(
314            "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
315             VALUES (?, 0, 'crashed-process')",
316        )
317        .bind(session.to_string())
318        .execute(&s.pool)
319        .await
320        .unwrap();
321        // ttl=1 second → way past the cutoff → next acquire wins.
322        assert!(s.try_acquire_lock(session, "p2", 1).await.unwrap());
323    }
324
325    #[tokio::test]
326    async fn cleanup_stale_locks_returns_count() {
327        let s = open_mem().await;
328        sqlx::query(
329            "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
330             VALUES (?, 0, 'crashed1')",
331        )
332        .bind(Uuid::new_v4().to_string())
333        .execute(&s.pool)
334        .await
335        .unwrap();
336        sqlx::query(
337            "INSERT INTO compaction_locks_v1 (session_id, locked_at, holder)
338             VALUES (?, 0, 'crashed2')",
339        )
340        .bind(Uuid::new_v4().to_string())
341        .execute(&s.pool)
342        .await
343        .unwrap();
344        // Plant one fresh lock that should NOT be swept.
345        let fresh = Uuid::new_v4();
346        s.try_acquire_lock(fresh, "live", 60).await.unwrap();
347        let removed = s.cleanup_stale_locks(60).await.unwrap();
348        assert_eq!(removed, 2);
349        // Fresh lock survives.
350        assert!(!s.try_acquire_lock(fresh, "other", 60).await.unwrap());
351    }
352}