Skip to main content

aion_store_libsql/
connection.rs

1//! Open embedded and embedded-replica libSQL connections.
2
3use std::path::Path;
4use std::time::Duration;
5
6use aion_store::StoreError;
7
8use crate::config::{LibSqlConfig, LibSqlMode};
9
10/// Busy-handler wait applied to every opened connection.
11///
12/// Aion routinely opens MULTIPLE connections against the SAME local database
13/// file: production `run.rs` opens one `LibSqlStore` for the engine and a second
14/// one for the outbox dispatcher on the identical `store.url`, and recovery /
15/// inspection paths open further read handles. Every durable write runs under
16/// `BEGIN IMMEDIATE`, which takes `SQLite`'s `RESERVED` lock up front. With the
17/// libSQL default busy timeout of zero, a second connection attempting a write
18/// while another holds that lock fails *immediately* with
19/// `SQLITE_BUSY` ("database is locked") instead of waiting for the in-flight
20/// transaction to commit.
21///
22/// Setting a busy timeout makes contending connections retry for the configured
23/// window — the standard, correct way to share a SQLite/libSQL file across
24/// connections. The per-handle `transaction_lock` only serialises writes within
25/// a single handle; it cannot coordinate across the separate engine/dispatcher
26/// handles, so this connection-level timeout is what makes concurrent same-file
27/// access reliable.
28const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
29
30/// Opened libSQL database handle and its mode-agnostic connection.
31pub struct OpenedConnection {
32    /// Database handle used to create the connection and to trigger replica sync.
33    pub database: libsql::Database,
34    /// Connection used by the event-store implementation.
35    pub connection: libsql::Connection,
36}
37
38/// Open the configured libSQL database and return its handle and connection.
39///
40/// Operator-provided journal and synchronous settings are applied only when present on the
41/// configuration. This crate intentionally does not choose durability defaults for omitted
42/// tunables.
43///
44/// # Errors
45///
46/// Returns `StoreError::Backend` when libSQL cannot build/connect the database or when applying an
47/// explicitly configured PRAGMA fails.
48pub async fn open_connection(config: &LibSqlConfig) -> Result<OpenedConnection, StoreError> {
49    let opened = match &config.mode {
50        LibSqlMode::Embedded { path } => open_embedded(path).await?,
51        LibSqlMode::EmbeddedReplica {
52            path,
53            primary_url,
54            auth_token,
55        } => {
56            open_embedded_replica(
57                path,
58                primary_url.clone(),
59                auth_token.clone(),
60                config.sync_interval_seconds,
61            )
62            .await?
63        }
64    };
65
66    opened
67        .connection
68        .busy_timeout(BUSY_TIMEOUT)
69        .map_err(|error| crate::error::libsql_error(&error))?;
70
71    // Default the local file to WAL journaling unless the operator chose a
72    // journal mode explicitly. Aion opens MULTIPLE connections on the same file
73    // (engine store + outbox-dispatcher store on one `store.url`, plus recovery
74    // and inspection handles). In the SQLite default rollback-journal mode a
75    // writer holds an EXCLUSIVE lock for the whole commit and the busy handler
76    // is not always retried for a contending writer, so concurrent same-file
77    // writes can still fail with `SQLITE_BUSY` even with a busy timeout. WAL
78    // lets writers and readers proceed against a write-ahead log and makes the
79    // busy-timeout retry reliable for the writer-vs-writer case. Operator config
80    // still wins: `apply_pragmas` below re-applies an explicit `journal_mode`.
81    let default_journal_mode =
82        matches!(config.mode, LibSqlMode::Embedded { .. }) && config.journal_mode.is_none();
83    if default_journal_mode {
84        execute_pragma(&opened.connection, "journal_mode", "wal").await?;
85    }
86
87    apply_pragmas(
88        &opened.connection,
89        config.journal_mode.as_deref(),
90        config.synchronous.as_deref(),
91    )
92    .await?;
93
94    Ok(opened)
95}
96
97async fn open_embedded(path: &Path) -> Result<OpenedConnection, StoreError> {
98    let database = libsql::Builder::new_local(path)
99        .build()
100        .await
101        .map_err(|error| crate::error::libsql_error(&error))?;
102
103    let connection = database
104        .connect()
105        .map_err(|error| crate::error::libsql_error(&error))?;
106
107    Ok(OpenedConnection {
108        database,
109        connection,
110    })
111}
112
113async fn open_embedded_replica(
114    path: &Path,
115    primary_url: String,
116    auth_token: String,
117    sync_interval_seconds: Option<u64>,
118) -> Result<OpenedConnection, StoreError> {
119    let mut builder = libsql::Builder::new_remote_replica(path, primary_url, auth_token);
120    if let Some(seconds) = sync_interval_seconds {
121        builder = builder.sync_interval(Duration::from_secs(seconds));
122    }
123
124    let db = builder
125        .build()
126        .await
127        .map_err(|error| crate::error::libsql_error(&error))?;
128
129    let connection = db
130        .connect()
131        .map_err(|error| crate::error::libsql_error(&error))?;
132
133    Ok(OpenedConnection {
134        database: db,
135        connection,
136    })
137}
138
139async fn apply_pragmas(
140    conn: &libsql::Connection,
141    journal_mode: Option<&str>,
142    synchronous: Option<&str>,
143) -> Result<(), StoreError> {
144    if let Some(value) = journal_mode {
145        execute_pragma(conn, "journal_mode", value).await?;
146    }
147
148    if let Some(value) = synchronous {
149        execute_pragma(conn, "synchronous", value).await?;
150    }
151
152    Ok(())
153}
154
155async fn execute_pragma(
156    conn: &libsql::Connection,
157    name: &str,
158    value: &str,
159) -> Result<(), StoreError> {
160    let value = validate_pragma_value(value)?;
161    let sql = format!("PRAGMA {name} = {value}");
162    conn.query(&sql, ())
163        .await
164        .map_err(|error| crate::error::libsql_error(&error))?;
165
166    Ok(())
167}
168
169fn validate_pragma_value(value: &str) -> Result<&str, StoreError> {
170    if !value.is_empty()
171        && value
172            .chars()
173            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
174    {
175        Ok(value)
176    } else {
177        Err(StoreError::Backend(format!(
178            "invalid libSQL PRAGMA value {value:?}"
179        )))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use std::path::PathBuf;
186    use std::time::{SystemTime, UNIX_EPOCH};
187
188    use aion_store::StoreError;
189
190    use super::open_connection;
191    use crate::config::{LibSqlConfig, LibSqlMode};
192
193    #[tokio::test]
194    async fn opens_embedded_connection_and_queries() -> Result<(), StoreError> {
195        let config = LibSqlConfig {
196            mode: LibSqlMode::Embedded {
197                path: unique_temp_path("embedded-select"),
198            },
199            journal_mode: Some(String::from("wal")),
200            synchronous: Some(String::from("normal")),
201            sync_interval_seconds: None,
202        };
203
204        let opened = open_connection(&config).await?;
205        let conn = opened.connection;
206        let mut rows = conn
207            .query("SELECT 1", ())
208            .await
209            .map_err(|error| crate::error::libsql_error(&error))?;
210        let row = rows
211            .next()
212            .await
213            .map_err(|error| crate::error::libsql_error(&error))?
214            .ok_or_else(|| StoreError::Backend(String::from("SELECT 1 returned no rows")))?;
215        let value: i64 = row
216            .get(0)
217            .map_err(|error| crate::error::libsql_error(&error))?;
218
219        assert_eq!(value, 1);
220        Ok(())
221    }
222
223    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
224    async fn concurrent_writers_on_the_same_file_do_not_lock_each_other_out()
225    -> Result<(), StoreError> {
226        // Two connections on the SAME file, exactly how production opens the
227        // engine store and the outbox-dispatcher store on one `store.url`. Both
228        // hammer short IMMEDIATE write transactions concurrently. Every durable
229        // write takes the RESERVED/write lock up front; with the libSQL default
230        // busy timeout of zero (and the default rollback journal) one of these
231        // contending writers fails immediately with SQLITE_BUSY ("database is
232        // locked"). The WAL + busy-timeout configuration applied in
233        // `open_connection` makes the contending writer wait out the lock and
234        // commit, so neither side errors.
235        const ROUNDS: i64 = 40;
236        let path = unique_temp_path("concurrent-writers-shared-file");
237        let config = |path: &std::path::Path| LibSqlConfig {
238            mode: LibSqlMode::Embedded {
239                path: path.to_path_buf(),
240            },
241            journal_mode: None,
242            synchronous: None,
243            sync_interval_seconds: None,
244        };
245
246        let setup = open_connection(&config(&path)).await?;
247        setup
248            .connection
249            .execute("CREATE TABLE t (id INTEGER PRIMARY KEY, who INTEGER)", ())
250            .await
251            .map_err(|error| crate::error::libsql_error(&error))?;
252        drop(setup);
253
254        let writer = |who: i64, path: std::path::PathBuf| async move {
255            let opened = open_connection(&config(&path)).await?;
256            for _ in 0..ROUNDS {
257                let tx = opened
258                    .connection
259                    .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
260                    .await
261                    .map_err(|error| crate::error::libsql_error(&error))?;
262                tx.execute("INSERT INTO t (who) VALUES (?1)", [who])
263                    .await
264                    .map_err(|error| crate::error::libsql_error(&error))?;
265                tx.commit()
266                    .await
267                    .map_err(|error| crate::error::libsql_error(&error))?;
268            }
269            Ok::<(), StoreError>(())
270        };
271
272        let a = tokio::spawn(writer(1, path.clone()));
273        let b = tokio::spawn(writer(2, path.clone()));
274        a.await
275            .map_err(|error| StoreError::Backend(error.to_string()))??;
276        b.await
277            .map_err(|error| StoreError::Backend(error.to_string()))??;
278
279        let reader = open_connection(&config(&path)).await?;
280        let mut rows = reader
281            .connection
282            .query("SELECT COUNT(*) FROM t", ())
283            .await
284            .map_err(|error| crate::error::libsql_error(&error))?;
285        let row = rows
286            .next()
287            .await
288            .map_err(|error| crate::error::libsql_error(&error))?
289            .ok_or_else(|| StoreError::Backend(String::from("count returned no row")))?;
290        let count: i64 = row
291            .get(0)
292            .map_err(|error| crate::error::libsql_error(&error))?;
293        assert_eq!(count, ROUNDS * 2, "every concurrent write committed");
294        Ok(())
295    }
296
297    #[tokio::test]
298    async fn maps_replica_open_failure_to_backend() -> Result<(), Box<dyn std::error::Error>> {
299        let config = LibSqlConfig {
300            mode: LibSqlMode::EmbeddedReplica {
301                path: unique_temp_path("replica-unavailable-primary"),
302                primary_url: String::from("http://127.0.0.1:9"),
303                auth_token: String::from("token"),
304            },
305            journal_mode: None,
306            synchronous: None,
307            sync_interval_seconds: Some(1),
308        };
309
310        match open_connection(&config).await {
311            Ok(_) => Err("expected embedded-replica open to fail for an invalid URL".into()),
312            Err(StoreError::Backend(_)) => Ok(()),
313            Err(other) => Err(format!("expected backend error, got {other:?}").into()),
314        }
315    }
316
317    fn unique_temp_path(name: &str) -> PathBuf {
318        let nanos = SystemTime::now()
319            .duration_since(UNIX_EPOCH)
320            .map_or(0, |duration| duration.as_nanos());
321        std::env::temp_dir().join(format!(
322            "aion-store-libsql-{name}-{}-{nanos}.db",
323            std::process::id()
324        ))
325    }
326}