Skip to main content

jellytau_lib/storage/
db_service.rs

1//! Database service: the single owner of the SQLite database.
2//!
3//! Every query in the app goes through [`RusqliteService`], which owns the
4//! connections and hands work to them — callers never touch a `Connection`.
5//!
6//! - **Writes** (`execute`, `insert`, `transaction`, …) are sent as jobs to one
7//!   dedicated writer thread that owns the read-write connection. SQLite allows
8//!   one writer at a time anyway; owning it on one thread makes that explicit,
9//!   keeps connection-wide state (pragmas) out of reach of concurrent callers,
10//!   and parks no tokio blocking threads on a mutex while writes queue up.
11//! - **Reads** (`query_*`) run on a small pool of read-only connections. The
12//!   database is in WAL mode, so readers see the last committed state and never
13//!   wait for the writer — a large catalog-cache transaction no longer stalls
14//!   library pages, thumbnail lookups or settings reads.
15//!
16//! A service built with [`RusqliteService::new`] has no reader pool (in-memory
17//! databases cannot be shared between connections) and routes reads through
18//! the writer, which is the old single-connection behaviour tests rely on.
19//!
20//! See `docs/architecture/08-database-design.md` → "Connection ownership".
21
22use crate::utils::lock::MutexSafe;
23use async_trait::async_trait;
24use log::{debug, error};
25use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
26use std::panic::AssertUnwindSafe;
27use std::sync::mpsc;
28use std::sync::{Arc, Condvar, Mutex};
29
30/// Database query result type
31pub type DbResult<T> = Result<T, String>;
32
33/// Represents a database query that can be executed
34#[derive(Clone)]
35pub struct Query {
36    pub sql: String,
37    pub params: Vec<QueryParam>,
38}
39
40/// Query parameter types supported by the database
41#[derive(Clone, Debug)]
42pub enum QueryParam {
43    String(String),
44    Int(i32),
45    Int64(i64),
46    Float(f64),
47    #[allow(dead_code)]
48    Bool(bool),
49    Null,
50}
51
52impl Query {
53    pub fn new(sql: impl Into<String>) -> Self {
54        Self {
55            sql: sql.into(),
56            params: Vec::new(),
57        }
58    }
59
60    pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self {
61        Self {
62            sql: sql.into(),
63            params,
64        }
65    }
66}
67
68/// Database service trait - abstraction over database operations
69#[async_trait]
70pub trait DatabaseService: Send + Sync {
71    /// Execute a query that doesn't return results (INSERT, UPDATE, DELETE)
72    async fn execute(&self, query: Query) -> DbResult<usize>;
73
74    /// Execute a batch of SQL statements (for migrations)
75    #[allow(dead_code)]
76    async fn execute_batch(&self, sql: &str) -> DbResult<()>;
77
78    /// Query a single row
79    async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
80    where
81        T: Send + 'static,
82        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
83
84    /// Query a single optional row
85    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
86    where
87        T: Send + 'static,
88        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
89
90    /// Query multiple rows
91    async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
92    where
93        T: Send + 'static,
94        F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
95
96    /// Run a transaction with multiple operations
97    async fn transaction<F, T>(&self, f: F) -> DbResult<T>
98    where
99        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
100        T: Send + 'static;
101
102    /// Run a transaction with foreign-key enforcement switched off for its
103    /// duration only.
104    ///
105    /// `PRAGMA foreign_keys` is per connection and is a no-op inside a
106    /// transaction, so it has to be flipped around the `BEGIN`/`COMMIT` — and
107    /// all of that must happen as one job on the writer, or any other write
108    /// that got in between would run unchecked too.
109    async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
110    where
111        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
112        T: Send + 'static;
113
114    /// Execute an INSERT and return the rowid of the row it inserted.
115    ///
116    /// The rowid is read in the same job as the insert. Reading it with a
117    /// second call would race every other write, returning someone else's id.
118    async fn insert(&self, query: Query) -> DbResult<i64>;
119
120    /// Queue a write without waiting for it — for best-effort bookkeeping (an
121    /// LRU access time) that must not hold up the caller. It still runs in
122    /// order with every other write; failures are only logged.
123    fn execute_detached(&self, query: Query);
124}
125
126/// Transaction handle for batching multiple operations
127pub struct Transaction<'a> {
128    conn: &'a Connection,
129}
130
131impl<'a> Transaction<'a> {
132    pub fn new(conn: &'a Connection) -> Self {
133        Self { conn }
134    }
135
136    pub fn execute(&mut self, query: Query) -> DbResult<usize> {
137        execute_query(self.conn, query)
138    }
139
140    pub fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
141    where
142        F: Fn(&Row) -> SqliteResult<T>,
143    {
144        query_many(self.conn, query, mapper)
145    }
146}
147
148type Job = Box<dyn FnOnce(&Connection) + Send>;
149
150/// The thread that owns the read-write connection. Jobs run one at a time, in
151/// the order they were sent; the thread exits when the last service handle
152/// (and so the last sender) is dropped.
153struct Writer {
154    jobs: mpsc::Sender<Job>,
155}
156
157impl Writer {
158    fn spawn(conn: Arc<Mutex<Connection>>) -> Self {
159        let (jobs, queue) = mpsc::channel::<Job>();
160        std::thread::Builder::new()
161            .name("db-writer".into())
162            .spawn(move || {
163                for job in queue {
164                    // The connection stays behind a mutex only so migrations and
165                    // tests can reach it; in the app this thread is its sole user.
166                    // `lock_safe` so a poisoned lock is recovered, not fatal.
167                    let conn = conn.lock_safe();
168                    // A panicking row mapper must not take the owner down with
169                    // it: the job's reply channel drops, its caller gets an
170                    // error, and the next job runs normally. The guard lives
171                    // outside the unwind, so the mutex is not poisoned either.
172                    if std::panic::catch_unwind(AssertUnwindSafe(|| job(&conn))).is_err() {
173                        error!("[db] a database job panicked; the writer carries on");
174                        // Undo whatever connection state the job was midway
175                        // through: an open transaction would make the next
176                        // job's BEGIN fail, and a job that switched foreign
177                        // keys off would leave them off for everyone.
178                        if !conn.is_autocommit() {
179                            let _ = conn.execute_batch("ROLLBACK");
180                        }
181                        let _ = conn.execute_batch("PRAGMA foreign_keys = ON");
182                    }
183                }
184            })
185            .expect("failed to spawn the database writer thread");
186        Self { jobs }
187    }
188
189    async fn run<T, F>(&self, f: F) -> DbResult<T>
190    where
191        T: Send + 'static,
192        F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
193    {
194        let (reply, result) = tokio::sync::oneshot::channel();
195        self.jobs
196            .send(Box::new(move |conn| {
197                let _ = reply.send(f(conn));
198            }))
199            .map_err(|_| "database writer has stopped".to_string())?;
200        result
201            .await
202            .map_err(|_| "database job panicked".to_string())?
203    }
204
205    fn run_detached(&self, f: impl FnOnce(&Connection) + Send + 'static) {
206        if self.jobs.send(Box::new(f)).is_err() {
207            debug!("[db] writer stopped; dropped a detached write");
208        }
209    }
210}
211
212/// Read-only connections, checked out one per query. WAL gives each a
213/// snapshot of the last commit, so they never wait for the writer.
214struct ReaderPool {
215    idle: Mutex<Vec<Connection>>,
216    returned: Condvar,
217}
218
219impl ReaderPool {
220    /// Blocking: waits for a free connection. Call from `spawn_blocking`.
221    fn run<T>(&self, f: impl FnOnce(&Connection) -> T) -> T {
222        let conn = {
223            let mut idle = self.idle.lock_safe();
224            loop {
225                if let Some(conn) = idle.pop() {
226                    break conn;
227                }
228                idle = self
229                    .returned
230                    .wait(idle)
231                    .unwrap_or_else(|poisoned| poisoned.into_inner());
232            }
233        };
234        // Returned on drop, so a panicking mapper does not leak the connection.
235        let checkout = Checkout {
236            pool: self,
237            conn: Some(conn),
238        };
239        f(checkout.conn.as_ref().expect("checked-out connection"))
240    }
241}
242
243struct Checkout<'a> {
244    pool: &'a ReaderPool,
245    conn: Option<Connection>,
246}
247
248impl Drop for Checkout<'_> {
249    fn drop(&mut self) {
250        if let Some(conn) = self.conn.take() {
251            self.pool.idle.lock_safe().push(conn);
252            self.pool.returned.notify_one();
253        }
254    }
255}
256
257/// Rusqlite-based database service: a cheap, cloneable handle to the writer
258/// thread and reader pool. See the module docs.
259#[derive(Clone)]
260pub struct RusqliteService {
261    writer: Arc<Writer>,
262    readers: Option<Arc<ReaderPool>>,
263}
264
265impl RusqliteService {
266    /// A service over a single connection: writes *and* reads go through the
267    /// writer thread. Used for in-memory databases, which cannot be shared
268    /// between connections.
269    #[cfg_attr(not(test), allow(dead_code))]
270    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
271        Self {
272            writer: Arc::new(Writer::spawn(conn)),
273            readers: None,
274        }
275    }
276
277    /// A service whose reads run on `readers` — read-only connections to the
278    /// same (file-backed, WAL-mode) database — alongside the writer.
279    pub fn with_readers(conn: Arc<Mutex<Connection>>, readers: Vec<Connection>) -> Self {
280        let readers = (!readers.is_empty()).then(|| {
281            Arc::new(ReaderPool {
282                idle: Mutex::new(readers),
283                returned: Condvar::new(),
284            })
285        });
286        Self {
287            writer: Arc::new(Writer::spawn(conn)),
288            readers,
289        }
290    }
291
292    async fn read<T, F>(&self, f: F) -> DbResult<T>
293    where
294        T: Send + 'static,
295        F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
296    {
297        match &self.readers {
298            Some(pool) => {
299                let pool = Arc::clone(pool);
300                tokio::task::spawn_blocking(move || pool.run(f))
301                    .await
302                    .map_err(|e| format!("Task join error: {}", e))?
303            }
304            None => self.writer.run(f).await,
305        }
306    }
307}
308
309#[async_trait]
310impl DatabaseService for RusqliteService {
311    async fn execute(&self, query: Query) -> DbResult<usize> {
312        self.writer
313            .run(move |conn| execute_query(conn, query))
314            .await
315    }
316
317    async fn execute_batch(&self, sql: &str) -> DbResult<()> {
318        let sql = sql.to_string();
319        self.writer
320            .run(move |conn| {
321                conn.execute_batch(&sql)
322                    .map_err(|e| format!("Execute batch failed: {}", e))
323            })
324            .await
325    }
326
327    async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
328    where
329        T: Send + 'static,
330        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
331    {
332        self.read(move |conn| query_one(conn, query, mapper)).await
333    }
334
335    async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
336    where
337        T: Send + 'static,
338        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
339    {
340        self.read(move |conn| query_optional(conn, query, mapper))
341            .await
342    }
343
344    async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
345    where
346        T: Send + 'static,
347        F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
348    {
349        self.read(move |conn| query_many(conn, query, mapper)).await
350    }
351
352    async fn transaction<F, T>(&self, f: F) -> DbResult<T>
353    where
354        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
355        T: Send + 'static,
356    {
357        self.writer.run(move |conn| run_transaction(conn, f)).await
358    }
359
360    async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
361    where
362        F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
363        T: Send + 'static,
364    {
365        self.writer
366            .run(move |conn| {
367                conn.execute_batch("PRAGMA foreign_keys = OFF")
368                    .map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
369                let result = run_transaction(conn, f);
370                // Always restored, whatever the transaction did.
371                if let Err(e) = conn.execute_batch("PRAGMA foreign_keys = ON") {
372                    error!("[db] failed to re-enable foreign keys: {}", e);
373                }
374                result
375            })
376            .await
377    }
378
379    async fn insert(&self, query: Query) -> DbResult<i64> {
380        self.writer
381            .run(move |conn| {
382                execute_query(conn, query)?;
383                Ok(conn.last_insert_rowid())
384            })
385            .await
386    }
387
388    fn execute_detached(&self, query: Query) {
389        self.writer.run_detached(move |conn| {
390            if let Err(e) = execute_query(conn, query) {
391                debug!("[db] detached write failed: {}", e);
392            }
393        });
394    }
395}
396
397fn run_transaction<F, T>(conn: &Connection, f: F) -> DbResult<T>
398where
399    F: FnOnce(&mut Transaction) -> DbResult<T>,
400{
401    conn.execute("BEGIN TRANSACTION", [])
402        .map_err(|e| format!("Failed to begin transaction: {}", e))?;
403
404    let mut transaction = Transaction::new(conn);
405    match f(&mut transaction) {
406        Ok(value) => {
407            conn.execute("COMMIT", [])
408                .map_err(|e| format!("Failed to commit transaction: {}", e))?;
409            Ok(value)
410        }
411        Err(e) => {
412            conn.execute("ROLLBACK", [])
413                .map_err(|e| format!("Failed to rollback transaction: {}", e))?;
414            Err(e)
415        }
416    }
417}
418
419// Helper functions for executing queries synchronously
420
421fn execute_query(conn: &Connection, query: Query) -> DbResult<usize> {
422    let params = convert_params(&query.params);
423    conn.execute(&query.sql, params_from_iter(params.iter()))
424        .map_err(|e| format!("Execute failed: {}", e))
425}
426
427fn query_one<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<T>
428where
429    F: Fn(&Row) -> SqliteResult<T>,
430{
431    let params = convert_params(&query.params);
432    conn.query_row(&query.sql, params_from_iter(params.iter()), mapper)
433        .map_err(|e| format!("Query one failed: {}", e))
434}
435
436fn query_optional<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Option<T>>
437where
438    F: Fn(&Row) -> SqliteResult<T>,
439{
440    match query_one(conn, query, mapper) {
441        Ok(value) => Ok(Some(value)),
442        Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
443            Ok(None)
444        }
445        Err(e) => Err(e),
446    }
447}
448
449fn query_many<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Vec<T>>
450where
451    F: Fn(&Row) -> SqliteResult<T>,
452{
453    let params = convert_params(&query.params);
454    let mut stmt = conn
455        .prepare(&query.sql)
456        .map_err(|e| format!("Prepare failed: {}", e))?;
457
458    let rows = stmt
459        .query_map(params_from_iter(params.iter()), mapper)
460        .map_err(|e| format!("Query map failed: {}", e))?;
461
462    rows.collect::<SqliteResult<Vec<T>>>()
463        .map_err(|e| format!("Collect failed: {}", e))
464}
465
466/// Convert QueryParam to rusqlite::types::Value
467fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
468    params
469        .iter()
470        .map(|p| match p {
471            QueryParam::String(s) => rusqlite::types::Value::Text(s.clone()),
472            QueryParam::Int(i) => rusqlite::types::Value::Integer(*i as i64),
473            QueryParam::Int64(i) => rusqlite::types::Value::Integer(*i),
474            QueryParam::Float(f) => rusqlite::types::Value::Real(*f),
475            QueryParam::Bool(b) => rusqlite::types::Value::Integer(if *b { 1 } else { 0 }),
476            QueryParam::Null => rusqlite::types::Value::Null,
477        })
478        .collect()
479}
480
481// TRACES: UR-002, UR-012 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[tokio::test]
487    async fn test_execute_query() {
488        let conn = Connection::open_in_memory().unwrap();
489        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
490            .unwrap();
491
492        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
493
494        let query = Query::with_params(
495            "INSERT INTO test (name) VALUES (?)",
496            vec![QueryParam::String("Alice".to_string())],
497        );
498
499        let rows = service.execute(query).await.unwrap();
500        assert_eq!(rows, 1);
501    }
502
503    #[tokio::test]
504    async fn test_query_one() {
505        let conn = Connection::open_in_memory().unwrap();
506        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
507            .unwrap();
508        conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
509            .unwrap();
510
511        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
512
513        let query = Query::new("SELECT name FROM test WHERE id = 1");
514        let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
515
516        assert_eq!(name, "Bob");
517    }
518
519    #[tokio::test]
520    async fn test_query_many() {
521        let conn = Connection::open_in_memory().unwrap();
522        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
523            .unwrap();
524        conn.execute("INSERT INTO test (name) VALUES ('Alice')", [])
525            .unwrap();
526        conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
527            .unwrap();
528
529        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
530
531        let query = Query::new("SELECT name FROM test ORDER BY id");
532        let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
533
534        assert_eq!(names, vec!["Alice", "Bob"]);
535    }
536
537    #[tokio::test]
538    async fn test_query_optional() {
539        let conn = Connection::open_in_memory().unwrap();
540        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
541            .unwrap();
542
543        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
544
545        let query = Query::new("SELECT name FROM test WHERE id = 999");
546        let result: Option<String> = service
547            .query_optional(query, |row| row.get(0))
548            .await
549            .unwrap();
550
551        assert_eq!(result, None);
552    }
553
554    #[tokio::test]
555    async fn test_transaction() {
556        let conn = Connection::open_in_memory().unwrap();
557        conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
558            .unwrap();
559
560        let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
561
562        let result = service
563            .transaction(|tx| {
564                tx.execute(Query::with_params(
565                    "INSERT INTO test (name) VALUES (?)",
566                    vec![QueryParam::String("Alice".to_string())],
567                ))?;
568                tx.execute(Query::with_params(
569                    "INSERT INTO test (name) VALUES (?)",
570                    vec![QueryParam::String("Bob".to_string())],
571                ))?;
572                Ok(())
573            })
574            .await;
575
576        assert!(result.is_ok());
577
578        // Verify both rows were inserted
579        let query = Query::new("SELECT COUNT(*) FROM test");
580        let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
581        assert_eq!(count, 2);
582    }
583    /// A panic while the connection guard is held must not brick every later
584    /// query.
585    ///
586    /// This is the single busiest lock in the app — every async DB operation
587    /// goes through it. With a raw `.lock()`, one panic under the guard poisons
588    /// the mutex and every subsequent call returns "poisoned lock" until the
589    /// process restarts, which for a database-backed app means the whole UI
590    /// stops working. `utils::lock` exists precisely to stop that cascade, and
591    /// `storage::Database` already used it; this path did not.
592    ///
593    /// TRACES: UR-002 | DR-012 | UT-014
594    #[tokio::test]
595    async fn a_poisoned_connection_still_serves_queries() {
596        let conn = Arc::new(Mutex::new(Connection::open_in_memory().unwrap()));
597        {
598            let c = conn.lock_safe();
599            c.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY);")
600                .unwrap();
601        }
602
603        // Poison the mutex the way a panicking row mapper would.
604        let poisoner = Arc::clone(&conn);
605        let hook = std::panic::take_hook();
606        std::panic::set_hook(Box::new(|_| {}));
607        let _ = std::thread::spawn(move || {
608            let _guard = poisoner.lock().unwrap();
609            panic!("a row mapper blew up while holding the connection");
610        })
611        .join();
612        std::panic::set_hook(hook);
613        assert!(conn.lock().is_err(), "the mutex should now be poisoned");
614
615        // Every operation must still work.
616        let service = RusqliteService::new(Arc::clone(&conn));
617        service
618            .execute(Query::new("INSERT INTO test (id) VALUES (1)"))
619            .await
620            .expect("execute must survive a poisoned connection");
621        let count: i32 = service
622            .query_one(Query::new("SELECT COUNT(*) FROM test"), |row| row.get(0))
623            .await
624            .expect("query_one must survive a poisoned connection");
625        assert_eq!(count, 1);
626    }
627}