klukai_types/
sqlite.rs

1use std::{
2    ops::{Deref, DerefMut},
3    time::{Duration, Instant},
4};
5
6use crate::sqlite_pool::{self, Committable, SqliteConn};
7use once_cell::sync::Lazy;
8use rusqlite::{
9    Connection, Error, Result, Transaction, functions::FunctionFlags, params,
10    trace::TraceEventCodes,
11};
12use serde_json::Value;
13use tempfile::TempDir;
14use tracing::{error, info, trace, warn};
15
16pub type SqlitePool = sqlite_pool::Pool<CrConn>;
17pub type SqlitePoolError = sqlite_pool::PoolError;
18
19const CRSQL_EXT_GENERIC_NAME: &str = "crsqlite";
20
21#[cfg(target_os = "macos")]
22pub const CRSQL_EXT_FILENAME: &str = "crsqlite.dylib";
23#[cfg(target_os = "linux")]
24pub const CRSQL_EXT_FILENAME: &str = "crsqlite.so";
25
26#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
27pub const CRSQL_EXT: &[u8] = include_bytes!("../crsqlite-darwin-aarch64.dylib");
28#[cfg(all(target_arch = "x86_64", target_os = "linux"))]
29pub const CRSQL_EXT: &[u8] = include_bytes!("../crsqlite-linux-x86_64.so");
30#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
31pub const CRSQL_EXT: &[u8] = include_bytes!("../crsqlite-linux-aarch64.so");
32
33// TODO: support windows
34
35// need to keep this alive!
36static CRSQL_EXT_DIR: Lazy<TempDir> = Lazy::new(|| {
37    let dir = TempDir::new().expect("could not create temp dir!");
38    std::fs::write(dir.path().join(CRSQL_EXT_GENERIC_NAME), CRSQL_EXT)
39        .expect("could not write crsql ext file");
40    dir
41});
42
43pub fn rusqlite_to_crsqlite_write(conn: rusqlite::Connection) -> rusqlite::Result<CrConn> {
44    let conn = rusqlite_to_crsqlite(conn)?;
45    conn.execute_batch("PRAGMA cache_size = -32000;")?;
46
47    Ok(conn)
48}
49
50pub fn rusqlite_to_crsqlite(mut conn: rusqlite::Connection) -> rusqlite::Result<CrConn> {
51    init_cr_conn(&mut conn)?;
52    setup_conn(&conn)?;
53    add_to_connection(&conn)?;
54
55    const SLOW_THRESHOLD: Duration = Duration::from_secs(1);
56    conn.trace_v2(
57        TraceEventCodes::SQLITE_TRACE_PROFILE,
58        Some(|event| {
59            if let rusqlite::trace::TraceEvent::Profile(stmt_ref, duration) = event
60                && duration >= SLOW_THRESHOLD
61            {
62                warn!("SLOW query {duration:?} => {}", stmt_ref.sql());
63            }
64        }),
65    );
66
67    Ok(CrConn(conn))
68}
69
70#[derive(Debug)]
71pub struct CrConn(Connection);
72
73impl CrConn {
74    pub fn init(mut conn: Connection) -> Result<Self, rusqlite::Error> {
75        init_cr_conn(&mut conn)?;
76        Ok(Self(conn))
77    }
78
79    pub fn immediate_transaction(&mut self) -> rusqlite::Result<Transaction<'_>> {
80        self.0
81            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
82    }
83}
84
85impl SqliteConn for CrConn {
86    fn conn(&self) -> &rusqlite::Connection {
87        &self.0
88    }
89}
90
91impl Deref for CrConn {
92    type Target = Connection;
93
94    fn deref(&self) -> &Self::Target {
95        &self.0
96    }
97}
98
99impl DerefMut for CrConn {
100    fn deref_mut(&mut self) -> &mut Self::Target {
101        &mut self.0
102    }
103}
104
105impl Drop for CrConn {
106    fn drop(&mut self) {
107        if let Err(e) = self.execute_batch("select crsql_finalize();") {
108            error!("could not crsql_finalize: {e}");
109        }
110    }
111}
112
113impl Committable for CrConn {
114    fn commit(self) -> Result<(), rusqlite::Error> {
115        Ok(())
116    }
117
118    fn savepoint(&mut self) -> Result<rusqlite::Savepoint<'_>, rusqlite::Error> {
119        Err(rusqlite::Error::ModuleError(String::from(
120            "cannot create savepoint from connection",
121        )))
122    }
123}
124
125fn init_cr_conn(conn: &mut Connection) -> Result<(), rusqlite::Error> {
126    let ext_dir = &CRSQL_EXT_DIR;
127    trace!(
128        "loading crsqlite extension from path: {}",
129        ext_dir.path().display()
130    );
131    unsafe {
132        trace!("enabled loading extension");
133        conn.load_extension_enable()?;
134        conn.load_extension(
135            ext_dir.path().join(CRSQL_EXT_GENERIC_NAME),
136            Some("sqlite3_crsqlite_init"),
137        )?;
138        conn.load_extension_disable()?;
139    }
140    trace!("loaded crsqlite extension");
141
142    Ok(())
143}
144
145pub fn setup_conn(conn: &Connection) -> Result<(), rusqlite::Error> {
146    // WAL journal mode and synchronous NORMAL for best performance / crash resilience compromise
147    conn.execute_batch(
148        r#"
149            PRAGMA journal_mode = WAL;
150            PRAGMA journal_size_limit = 1073741824;
151            PRAGMA synchronous = NORMAL;
152            PRAGMA recursive_triggers = ON;
153            PRAGMA mmap_size = 8589934592; -- 8GB
154        "#,
155    )?;
156
157    rusqlite::vtab::series::load_module(conn)?;
158
159    Ok(())
160}
161
162pub trait Migration {
163    fn migrate(&self, tx: &Transaction) -> rusqlite::Result<()>;
164}
165
166impl<F> Migration for F
167where
168    F: Fn(&Transaction) -> rusqlite::Result<()>,
169{
170    fn migrate(&self, tx: &Transaction) -> rusqlite::Result<()> {
171        self(tx)
172    }
173}
174
175const SCHEMA_VERSION_KEY: &str = "schema_version";
176
177// Read migration version field from the SQLite db
178pub fn migration_version(tx: &Transaction) -> Option<usize> {
179    #[allow(deprecated)] // To keep compatibility with lower rusqlite versions
180    tx.query_row(
181        "SELECT value FROM __corro_state WHERE key = ?",
182        [SCHEMA_VERSION_KEY],
183        |row| row.get::<_, i64>(0),
184    )
185    .map(|v| v as usize)
186    .ok()
187}
188
189// Set user version field from the SQLite db
190pub fn set_migration_version(tx: &Transaction, v: usize) -> rusqlite::Result<usize> {
191    tx.execute(
192        "INSERT OR REPLACE INTO __corro_state VALUES (?, ?)",
193        params![SCHEMA_VERSION_KEY, v],
194    )
195}
196
197// should be a noop if up to date!
198pub fn migrate(conn: &mut Connection, migrations: Vec<Box<dyn Migration>>) -> rusqlite::Result<()> {
199    let target_version = migrations.len();
200
201    let tx = conn.transaction()?;
202
203    // determine how many migrations to skip (skip as many as we are at)
204    let skip_n = migration_version(&tx).unwrap_or_default();
205
206    if skip_n > migrations.len() {
207        warn!(
208            "Skipping migrations, database is at migration version {skip_n} which is greater than {}",
209            migrations.len()
210        );
211        return Ok(());
212    }
213
214    for (i, migration) in migrations.into_iter().skip(skip_n).enumerate() {
215        let new_version = skip_n + i;
216        info!("Applying migration to v{new_version}");
217        let start = Instant::now();
218        migration.migrate(&tx)?;
219        info!("Applied v{new_version} in {:?}", start.elapsed());
220    }
221    set_migration_version(&tx, target_version)?;
222
223    tx.commit()?;
224    Ok(())
225}
226
227/// Add custom Corrosion functions into SQLite connection.
228fn add_to_connection(db: &Connection) -> Result<()> {
229    add_corro_json_contains(db)?;
230
231    Ok(())
232}
233
234// corro_json_contains returns true if the first argument of the function
235// (JSON object) is fully contained within the second argument of the
236// function (also JSON object)
237fn add_corro_json_contains(db: &Connection) -> Result<()> {
238    db.create_scalar_function(
239        "corro_json_contains",
240        2,
241        FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
242        move |ctx| {
243            assert_eq!(ctx.len(), 2, "called with unexpected number of arguments");
244
245            let selector: Value = serde_json::from_str(ctx.get_raw(0).as_str()?)
246                .map_err(|e| Error::UserFunctionError(e.into()))?;
247            let object: Value = serde_json::from_str(ctx.get_raw(1).as_str()?)
248                .map_err(|e| Error::UserFunctionError(e.into()))?;
249
250            Ok(corro_json_contains(selector, object))
251        },
252    )
253}
254
255// Helper function that returns true if the first JSON object is full
256// contained within the second JSON object.
257fn corro_json_contains(selector: Value, object: Value) -> bool {
258    match (selector, object) {
259        (Value::Object(s_map), Value::Object(mut o_map)) => {
260            for (s_k, s_v) in s_map {
261                if let Some(o_v) = o_map.remove(&s_k) {
262                    if !corro_json_contains(s_v, o_v) {
263                        return false;
264                    }
265                } else {
266                    return false;
267                }
268            }
269
270            true
271        }
272        (s, o) => s == o,
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use crate::sqlite_pool::Config;
279    use crate::sqlite_pool::InterruptibleTransaction;
280    use futures::{TryStreamExt, stream::FuturesUnordered};
281    use rusqlite::{Connection, Result};
282    use tokio::task::block_in_place;
283
284    use super::*;
285
286    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
287    async fn concurrent_writes() -> Result<(), Box<dyn std::error::Error>> {
288        let tmpdir = tempfile::TempDir::new()?;
289
290        let pool = sqlite_pool::Config::new(tmpdir.path().join("test.db"))
291            .max_size(1)
292            .create_pool_transform(rusqlite_to_crsqlite)?;
293
294        {
295            let conn = pool.get().await?;
296
297            conn.execute_batch(
298                "
299                CREATE TABLE foo (a INTEGER NOT NULL PRIMARY KEY, b INTEGER);
300                SELECT crsql_as_crr('foo');
301            ",
302            )?;
303        }
304
305        let total: i64 = 1000;
306        let per_worker: i64 = 5;
307
308        let futs = FuturesUnordered::from_iter((0..total).map(|_| {
309            let pool = pool.clone();
310            async move {
311                tokio::spawn(async move {
312                    let _: () = FuturesUnordered::from_iter((0..per_worker).map(|_| {
313                        let pool = pool.clone();
314                        async move {
315                            let conn = pool.get().await?;
316                            block_in_place(|| {
317                                conn.prepare_cached(
318                                    "INSERT INTO foo (a, b) VALUES (random(), random())",
319                                )?
320                                .execute(())?;
321                                Ok::<_, TestError>(())
322                            })?;
323                            Ok::<_, TestError>(())
324                        }
325                    }))
326                    .try_collect()
327                    .await?;
328                    Ok::<_, TestError>(())
329                })
330                .await??;
331                Ok::<_, TestError>(())
332            }
333        }));
334
335        let _: () = futs.try_collect().await?;
336
337        let conn = pool.get().await?;
338
339        let count: i64 = conn.query_row("SELECT COUNT(*) FROM foo;", (), |row| row.get(0))?;
340
341        assert_eq!(count, total * per_worker);
342
343        Ok(())
344    }
345
346    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
347    async fn test_interruptible_transaction() -> Result<(), Box<dyn std::error::Error>> {
348        let tmpdir = tempfile::tempdir()?;
349
350        let path = tmpdir.path().join("db.sqlite");
351        let pool = Config::new(path)
352            .max_size(1)
353            .create_pool_transform(rusqlite_to_crsqlite)?;
354
355        let mut conn = pool.get().await.unwrap();
356        conn.execute_batch(
357            "CREATE TABLE IF NOT EXISTS testsbool (
358            id INTEGER NOT NULL PRIMARY KEY,
359            b boolean not null default false
360        ); SELECT crsql_as_crr('testsbool')",
361        )?;
362
363        {
364            let tx = conn.transaction()?;
365            let timeout = Some(tokio::time::Duration::from_millis(5));
366            let itx = InterruptibleTransaction::new(tx, timeout, "test_interruptible_transaction");
367            let res = itx.execute("INSERT INTO testsbool (id) WITH RECURSIVE    cte(id) AS (       SELECT random()       UNION ALL       SELECT random()         FROM cte        LIMIT 100000000  ) SELECT id FROM cte;", &[]);
368
369            assert!(res.is_err_and(
370                |e| e.sqlite_error_code() == Some(rusqlite::ErrorCode::OperationInterrupted)
371            ));
372        }
373
374        let count: i64 = conn.query_row("SELECT COUNT(*) FROM testsbool;", (), |row| row.get(0))?;
375        assert_eq!(count, 0);
376        Ok(())
377    }
378
379    fn get_conn() -> Connection {
380        let conn = Connection::open_in_memory().expect("cannot open in-memory connection");
381        super::add_corro_json_contains(&conn)
382            .expect("cannot add corrosion functions to connection");
383
384        conn
385    }
386
387    fn query_corro_json_contains(conn: &Connection, obj1: &str, obj2: &str) -> Result<bool> {
388        conn.query_row("SELECT corro_json_contains(?1, ?2)", [obj1, obj2], |row| {
389            row.get(0)
390        })
391    }
392
393    #[test]
394    fn test_corro_json_contains() {
395        let conn = get_conn();
396
397        // Empty object is contained in other empty objects
398        assert_eq!(Ok(true), query_corro_json_contains(&conn, "{}", "{}"));
399        // Empty object is contained in non-empty objects
400        assert_eq!(
401            Ok(true),
402            query_corro_json_contains(&conn, "{}", r#"{"key": "value"}"#)
403        );
404        // An object with keys is not contained in an empty object
405        assert_ne!(
406            Ok(true),
407            query_corro_json_contains(&conn, r#"{"key": "value"}"#, "{}")
408        );
409
410        // Equal key/values match
411        assert_eq!(
412            Ok(true),
413            query_corro_json_contains(&conn, r#"{"key": "value"}"#, r#"{"key": "value"}"#)
414        );
415        // Smaller objects is also Ok
416        assert_eq!(
417            Ok(true),
418            query_corro_json_contains(
419                &conn,
420                r#"{"key": "value"}"#,
421                r#"{"key": "value", "key2": "value2"}"#
422            )
423        );
424        // Not equal key/values do not match
425        assert_ne!(
426            Ok(true),
427            query_corro_json_contains(&conn, r#"{"key": "value"}"#, r#"{"key": "wrong value"}"#)
428        );
429
430        // Nested objects work
431        assert_eq!(
432            Ok(true),
433            query_corro_json_contains(
434                &conn,
435                r#"{"metadata": { "key": "value"} }"#,
436                r#"{"metadata": { "key": "value"} }"#
437            )
438        );
439        // And do not match with different key/values
440        assert_ne!(
441            Ok(true),
442            query_corro_json_contains(
443                &conn,
444                r#"{"metadata": { "key": "value"} }"#,
445                r#"{"metadata": { "key": "wrong value"} }"#
446            )
447        );
448    }
449
450    #[derive(Debug, thiserror::Error)]
451    enum TestError {
452        #[error(transparent)]
453        Rusqlite(#[from] rusqlite::Error),
454        #[error(transparent)]
455        Bb8Rusqlite(#[from] SqlitePoolError),
456        #[error(transparent)]
457        Join(#[from] tokio::task::JoinError),
458    }
459}