Skip to main content

microsandbox_db/
connection.rs

1//! Typed wrappers around `sea_orm::DatabaseConnection`.
2//!
3//! Splits a connection into [`DbReadConnection`] and [`DbWriteConnection`]
4//! so the type system enforces which pool a given operation hits. SQLite is
5//! single-writer system-wide; routing every write through a dedicated
6//! single-connection write pool turns intra-process contention into an
7//! in-process queue rather than `SQLITE_BUSY` retries.
8//!
9//! Both types implement [`sea_orm::ConnectionTrait`], so existing query
10//! builders (`Entity::find().all(db)`, `Entity::insert(...).exec(db)`, etc.)
11//! work without source changes — callers just pick the right type for the
12//! operation.
13
14use std::{future::Future, path::Path, time::Duration};
15
16use sea_orm::{
17    ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, DbErr, ExecResult,
18    QueryResult, Statement, TransactionTrait,
19};
20
21use crate::{pool, retry, retry::IsSqliteBusy};
22
23/// Read pool. Multi-connection; concurrent reads enabled by WAL mode.
24///
25/// `ConnectionTrait` is implemented so SELECTs work transparently. Writes
26/// also technically execute (sea-orm has no read-only enforcement at the
27/// trait level), but doing so via this type defeats the purpose — write
28/// paths must take a [`DbWriteConnection`] argument.
29///
30/// `Clone` is cheap: the inner `DatabaseConnection` holds an `Arc` over
31/// the underlying sqlx pool, so clones share connection state.
32#[derive(Debug, Clone)]
33pub struct DbReadConnection(DatabaseConnection);
34
35/// Write pool. Single connection; serialises in-process writes so the
36/// SQLite writer lock is never contested from within one process.
37///
38/// Cross-process contention with other writers (e.g. the in-VM runtime)
39/// still exists and is absorbed by the `busy_timeout` PRAGMA + the
40/// retry-on-busy transaction helpers (added in a follow-up step).
41///
42/// `Clone` is cheap: the inner `DatabaseConnection` holds an `Arc` over
43/// the underlying sqlx pool, so clones share the same single connection.
44#[derive(Debug, Clone)]
45pub struct DbWriteConnection(DatabaseConnection);
46
47impl DbReadConnection {
48    /// Wrap a sea-orm connection as a read pool.
49    pub fn new(inner: DatabaseConnection) -> Self {
50        Self(inner)
51    }
52
53    /// Open a stand-alone read pool at `db_path` with shared PRAGMAs.
54    ///
55    /// Read-only: opening a non-existent DB fails rather than creating it, so a
56    /// read consumer never authors or pre-empts the catalog owned by `msb`.
57    pub async fn open(
58        db_path: &Path,
59        max_connections: u32,
60        connect_timeout: Duration,
61        busy_timeout: Duration,
62    ) -> Result<Self, sqlx::Error> {
63        let conn = pool::build_pool(
64            db_path,
65            max_connections,
66            connect_timeout,
67            busy_timeout,
68            false,
69        )
70        .await?;
71        Ok(Self(conn))
72    }
73
74    /// Open an existing catalog without creating it or changing its journal mode.
75    ///
76    /// Intended for short control lookups after the caller coordinates with migrations.
77    /// This is a normal WAL-aware reader, never an immutable-file shortcut.
78    pub async fn open_read_only(
79        db_path: &Path,
80        connect_timeout: Duration,
81        busy_timeout: Duration,
82    ) -> Result<Self, sqlx::Error> {
83        let options = sqlx::sqlite::SqliteConnectOptions::new()
84            .filename(db_path)
85            .read_only(true)
86            .create_if_missing(false)
87            .busy_timeout(busy_timeout);
88        let pool = sqlx::sqlite::SqlitePoolOptions::new()
89            .max_connections(1)
90            .acquire_timeout(connect_timeout)
91            .connect_with(options)
92            .await?;
93        Ok(Self(sea_orm::SqlxSqliteConnector::from_sqlx_sqlite_pool(
94            pool,
95        )))
96    }
97
98    /// Borrow the underlying sea-orm connection.
99    pub fn inner(&self) -> &DatabaseConnection {
100        &self.0
101    }
102}
103
104impl DbWriteConnection {
105    /// Wrap a sea-orm connection as a write pool.
106    pub fn new(inner: DatabaseConnection) -> Self {
107        Self(inner)
108    }
109
110    /// Open a stand-alone single-connection write pool at `db_path`.
111    ///
112    /// Used by callers that don't need a paired read pool (e.g. the in-VM
113    /// runtime, which only writes run records).
114    pub async fn open(
115        db_path: &Path,
116        connect_timeout: Duration,
117        busy_timeout: Duration,
118    ) -> Result<Self, sqlx::Error> {
119        let conn = pool::build_pool(db_path, 1, connect_timeout, busy_timeout, true).await?;
120        Ok(Self(conn))
121    }
122
123    /// Borrow the underlying sea-orm connection.
124    pub fn inner(&self) -> &DatabaseConnection {
125        &self.0
126    }
127
128    /// Run a multi-statement atomic write inside a transaction with
129    /// automatic retry on `SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`. Use this
130    /// when you need several writes to commit (or roll back) as a unit.
131    /// Single-statement writes don't need this — auto-commit `.exec(db)`
132    /// already retries via the `ConnectionTrait` impl below.
133    ///
134    /// `f` is invoked once per attempt with a freshly opened transaction.
135    /// Return `Ok((txn, value))` to commit, or any `Err` to roll back (the
136    /// helper drops the transaction on failure, which sea-orm rolls back).
137    /// The closure must be callable multiple times: clone owned data inside
138    /// the body so retries see fresh values.
139    ///
140    /// Generic over the closure's error type `E` so callers can return
141    /// app-level errors directly (e.g. `MicrosandboxError`) provided
142    /// `E: From<DbErr> + IsSqliteBusy`.
143    pub async fn transaction<F, Fut, T, E>(&self, f: F) -> Result<T, E>
144    where
145        F: Fn(DatabaseTransaction) -> Fut,
146        Fut: Future<Output = Result<(DatabaseTransaction, T), E>> + Send,
147        T: Send,
148        E: From<DbErr> + IsSqliteBusy,
149    {
150        retry::retry_on_busy(|| async {
151            let txn = self.0.begin().await?;
152            let (txn, value) = f(txn).await?;
153            txn.commit().await?;
154            Ok(value)
155        })
156        .await
157    }
158}
159
160#[async_trait::async_trait]
161impl ConnectionTrait for DbReadConnection {
162    fn get_database_backend(&self) -> DbBackend {
163        self.0.get_database_backend()
164    }
165
166    async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
167        self.0.execute_raw(stmt).await
168    }
169
170    async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
171        self.0.execute_unprepared(sql).await
172    }
173
174    async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
175        self.0.query_one_raw(stmt).await
176    }
177
178    async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
179        self.0.query_all_raw(stmt).await
180    }
181
182    fn support_returning(&self) -> bool {
183        self.0.support_returning()
184    }
185
186    fn is_mock_connection(&self) -> bool {
187        self.0.is_mock_connection()
188    }
189}
190
191// Auto-retry every auto-commit operation on the writer pool. Sea-orm
192// callers (`Entity::insert(...).exec(db)` etc.) ultimately funnel through
193// these required `*_raw` methods — the provided builder-taking methods
194// (`execute`, `query_one`, `query_all`) build the statement and delegate
195// here — so wrapping them in `retry_on_busy` gives every single-statement
196// write inter-process retry semantics without per-call-site code.
197//
198// `Statement` is `Clone`, so the closure can produce a fresh future on
199// each retry. Multi-statement atomic work still uses `transaction()`
200// above (which retries the whole closure body); statements *inside* a
201// transaction call `ConnectionTrait` methods on `DatabaseTransaction`,
202// not on this type, so no double-retry occurs.
203#[async_trait::async_trait]
204impl ConnectionTrait for DbWriteConnection {
205    fn get_database_backend(&self) -> DbBackend {
206        self.0.get_database_backend()
207    }
208
209    async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
210        retry::retry_on_busy(|| async { self.0.execute_raw(stmt.clone()).await }).await
211    }
212
213    async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
214        retry::retry_on_busy(|| async { self.0.execute_unprepared(sql).await }).await
215    }
216
217    async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
218        retry::retry_on_busy(|| async { self.0.query_one_raw(stmt.clone()).await }).await
219    }
220
221    async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
222        retry::retry_on_busy(|| async { self.0.query_all_raw(stmt.clone()).await }).await
223    }
224
225    fn support_returning(&self) -> bool {
226        self.0.support_returning()
227    }
228
229    fn is_mock_connection(&self) -> bool {
230        self.0.is_mock_connection()
231    }
232}
233
234//--------------------------------------------------------------------------------------------------
235// Tests
236//--------------------------------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    const TIMEOUT: Duration = Duration::from_secs(5);
243
244    #[tokio::test]
245    async fn strict_reader_sees_wal_commits_but_cannot_write() {
246        let dir = tempfile::tempdir().unwrap();
247        let path = dir.path().join("catalog.db");
248        let writer = DbWriteConnection::open(&path, TIMEOUT, TIMEOUT)
249            .await
250            .unwrap();
251        writer
252            .execute_unprepared("CREATE TABLE control_test (value INTEGER)")
253            .await
254            .unwrap();
255        let reader = DbReadConnection::open_read_only(&path, TIMEOUT, TIMEOUT)
256            .await
257            .unwrap();
258        // Keep the writer alive: control reads must see WAL commits, not an immutable
259        // view of only the main database file.
260        writer
261            .execute_unprepared("INSERT INTO control_test VALUES (42)")
262            .await
263            .unwrap();
264        let row = reader
265            .query_one_raw(Statement::from_string(
266                DbBackend::Sqlite,
267                "SELECT value FROM control_test",
268            ))
269            .await
270            .unwrap()
271            .unwrap();
272        assert_eq!(row.try_get_by_index::<i64>(0).unwrap(), 42);
273        assert!(
274            reader
275                .execute_unprepared("INSERT INTO control_test VALUES (43)")
276                .await
277                .is_err()
278        );
279    }
280
281    #[tokio::test]
282    async fn strict_reader_never_creates_a_catalog() {
283        let dir = tempfile::tempdir().unwrap();
284        let path = dir.path().join("missing.db");
285        assert!(
286            DbReadConnection::open_read_only(&path, TIMEOUT, TIMEOUT)
287                .await
288                .is_err()
289        );
290        assert!(!path.exists());
291    }
292
293    #[tokio::test]
294    async fn read_open_does_not_create_db() {
295        // Existing directory, missing DB file.
296        let dir = tempfile::tempdir().unwrap();
297        let db_path = dir.path().join("catalog.db");
298
299        let result = DbReadConnection::open(&db_path, 1, TIMEOUT, TIMEOUT).await;
300
301        assert!(result.is_err(), "read open should fail on a missing db");
302        assert!(
303            !db_path.exists(),
304            "read open must not create the catalog db file"
305        );
306    }
307
308    #[tokio::test]
309    async fn write_open_creates_db() {
310        let dir = tempfile::tempdir().unwrap();
311        let db_path = dir.path().join("catalog.db");
312
313        let conn = DbWriteConnection::open(&db_path, TIMEOUT, TIMEOUT).await;
314
315        assert!(conn.is_ok(), "write open should succeed");
316        assert!(
317            db_path.exists(),
318            "write open should create the catalog db file"
319        );
320    }
321}