Skip to main content

whatsapp_rust_sqlite_storage/
shared.rs

1//! Cross-crate access to a store's SQLite database file.
2//!
3//! Sibling crates that keep their own tables in the same file (e.g. a chat/message
4//! store) must not open a second connection pool: two pools mean two WAL writers
5//! fighting over the file lock, two page caches, and two busy queues. [`SharedSqlite`]
6//! hands them the store's own pool and write-serialization semaphore instead.
7
8use diesel::sqlite::SqliteConnection;
9use std::sync::Arc;
10use wacore::store::error::{Result, StoreError};
11
12use crate::sqlite_store::{SqlitePool, SqliteStore};
13
14/// Clonable handle onto a [`SqliteStore`]'s connection pool and serialization
15/// semaphore. Obtained via [`SqliteStore::shared`]. Holding one does not keep any
16/// device row alive — it is purely connection plumbing.
17#[derive(Clone)]
18pub struct SharedSqlite {
19    pool: SqlitePool,
20    semaphore: Arc<tokio::sync::Semaphore>,
21    reads: Option<crate::sqlite_store::ReadPool>,
22}
23
24impl SharedSqlite {
25    /// Run `f` on a pooled connection from a blocking thread, holding one of the
26    /// store's serialization permits for the duration. The closure owns error
27    /// mapping into [`StoreError`] so callers can also run non-query work
28    /// (e.g. their own embedded migrations) through the same choke point.
29    ///
30    /// This is the write path: everything that can modify the database belongs
31    /// here, because the permit is what keeps two writers off SQLite at once.
32    /// For work that only reads, [`read`](Self::read) skips that queue.
33    pub async fn run<F, T>(&self, f: F) -> Result<T>
34    where
35        F: FnOnce(&mut SqliteConnection) -> Result<T> + Send + 'static,
36        T: Send + 'static,
37    {
38        Self::run_on(self.pool.clone(), Arc::clone(&self.semaphore), f).await
39    }
40
41    /// Run a **read-only** `f` without queueing behind the write permit.
42    ///
43    /// WAL lets readers run alongside the single writer, which is the whole
44    /// reason a slow query should not be able to stall the rest of a session.
45    /// Reads still take a permit — one per reader connection — so the number of
46    /// blocking threads stays bounded by the pool.
47    ///
48    /// `f` runs inside a deferred transaction, so every statement in it sees
49    /// one snapshot. The write permit used to supply that for free — while a
50    /// read held it the writer could not commit between its statements — and a
51    /// reader that resolves a chat's identity keys and then queries by them, or
52    /// collects search hits and then hydrates them, would otherwise straddle
53    /// two committed states and come back short. A deferred transaction over
54    /// read-only statements never asks for the write lock, so it pins the
55    /// snapshot without contending with the writer.
56    ///
57    /// Only correct for statements that cannot write. A write sent through here
58    /// escapes the serialization the store relies on and can deadlock against
59    /// the real writer on the transaction upgrade, which `busy_timeout` cannot
60    /// resolve. When a store has no reader connections configured
61    /// ([`SqliteStoreConfig::read_pool_size`](crate::SqliteStoreConfig::read_pool_size)
62    /// left at 0) this queues on the write permit exactly like
63    /// [`run`](Self::run), so it is always safe to call — it simply buys no
64    /// concurrency until the embedder opts in.
65    pub async fn read<F, T>(&self, f: F) -> Result<T>
66    where
67        F: FnOnce(&mut SqliteConnection) -> Result<T> + Send + 'static,
68        T: Send + 'static,
69    {
70        let (pool, semaphore) = match &self.reads {
71            Some(reads) => (reads.pool.clone(), Arc::clone(&reads.semaphore)),
72            None => (self.pool.clone(), Arc::clone(&self.semaphore)),
73        };
74        Self::run_on(pool, semaphore, move |conn| read_snapshot(conn, f)).await
75    }
76
77    async fn run_on<F, T>(
78        pool: SqlitePool,
79        semaphore: Arc<tokio::sync::Semaphore>,
80        f: F,
81    ) -> Result<T>
82    where
83        F: FnOnce(&mut SqliteConnection) -> Result<T> + Send + 'static,
84        T: Send + 'static,
85    {
86        let permit = semaphore
87            .acquire_owned()
88            .await
89            .map_err(|e| StoreError::Database(Box::new(e)))?;
90        tokio::task::spawn_blocking(move || {
91            let _permit = permit;
92            let mut conn = pool
93                .get()
94                .map_err(|e| StoreError::Connection(Box::new(e)))?;
95            f(&mut conn)
96        })
97        .await
98        .map_err(|e| StoreError::Database(Box::new(e)))?
99    }
100}
101
102/// Run `f` under one read snapshot.
103///
104/// Diesel's `transaction` needs an error type it can build from its own, and
105/// [`StoreError`] deliberately has no such conversion (callers choose how a
106/// database error is classified), so both travel in one enum and unwrap on the
107/// way out.
108fn read_snapshot<T>(
109    conn: &mut SqliteConnection,
110    f: impl FnOnce(&mut SqliteConnection) -> Result<T>,
111) -> Result<T> {
112    use diesel::connection::Connection as _;
113
114    enum TxnError {
115        Store(StoreError),
116        Diesel(diesel::result::Error),
117    }
118    impl From<diesel::result::Error> for TxnError {
119        fn from(e: diesel::result::Error) -> Self {
120            Self::Diesel(e)
121        }
122    }
123    conn.transaction::<T, TxnError, _>(|conn| f(conn).map_err(TxnError::Store))
124        .map_err(|e| match e {
125            TxnError::Store(e) => e,
126            TxnError::Diesel(e) => StoreError::Database(Box::new(e)),
127        })
128}
129
130impl SqliteStore {
131    /// Handle for sibling crates to run their own queries and migrations against
132    /// this store's database file through the same pool and semaphore.
133    pub fn shared(&self) -> SharedSqlite {
134        SharedSqlite {
135            pool: self.pool.clone(),
136            semaphore: self.db_semaphore.clone(),
137            reads: self.reads.clone(),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use diesel::prelude::*;
145
146    use crate::sqlite_store::SqliteStore;
147    use wacore::store::error::StoreError;
148
149    fn unique_db_name(tag: &str) -> String {
150        use portable_atomic::AtomicU64;
151        use std::sync::atomic::Ordering;
152        static COUNTER: AtomicU64 = AtomicU64::new(0);
153        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
154        format!(
155            "file:memdb_shared_{tag}_{}_{}?mode=memory&cache=shared",
156            std::process::id(),
157            id
158        )
159    }
160
161    async fn create_test_store(tag: &str) -> SqliteStore {
162        SqliteStore::new(&unique_db_name(tag))
163            .await
164            .expect("Failed to create test store")
165    }
166
167    fn db_err(e: diesel::result::Error) -> StoreError {
168        StoreError::Database(Box::new(e))
169    }
170
171    #[tokio::test]
172    async fn shared_handle_sees_the_same_database() {
173        let store = create_test_store("same_db").await;
174        let shared = store.shared();
175
176        shared
177            .run(|conn| {
178                diesel::sql_query("CREATE TABLE sibling_data (k TEXT PRIMARY KEY, v TEXT)")
179                    .execute(conn)
180                    .map_err(db_err)?;
181                diesel::sql_query("INSERT INTO sibling_data (k, v) VALUES ('a', 'b')")
182                    .execute(conn)
183                    .map_err(db_err)?;
184                Ok(())
185            })
186            .await
187            .expect("create + insert through shared handle");
188
189        // A second (cloned) handle reads what the first wrote: one pool, one file.
190        #[derive(QueryableByName)]
191        struct Row {
192            #[diesel(sql_type = diesel::sql_types::Text)]
193            v: String,
194        }
195        let rows: Vec<Row> = shared
196            .clone()
197            .run(|conn| {
198                diesel::sql_query("SELECT v FROM sibling_data WHERE k = 'a'")
199                    .load(conn)
200                    .map_err(db_err)
201            })
202            .await
203            .expect("read through cloned handle");
204        assert_eq!(rows.len(), 1);
205        assert_eq!(rows[0].v, "b");
206    }
207
208    /// A file-backed store, since reader connections need real WAL and an
209    /// in-memory database has none to switch to. Removed on drop.
210    struct TempDb(std::path::PathBuf);
211
212    impl TempDb {
213        fn new(tag: &str) -> Self {
214            use portable_atomic::AtomicU64;
215            use std::sync::atomic::Ordering;
216            static COUNTER: AtomicU64 = AtomicU64::new(0);
217            let id = COUNTER.fetch_add(1, Ordering::Relaxed);
218            let mut path = std::env::temp_dir();
219            path.push(format!("wa_shared_{tag}_{}_{id}.db", std::process::id()));
220            let _ = std::fs::remove_file(&path);
221            Self(path)
222        }
223
224        fn url(&self) -> String {
225            self.0.to_string_lossy().into_owned()
226        }
227    }
228
229    impl Drop for TempDb {
230        fn drop(&mut self) {
231            for suffix in ["", "-wal", "-shm"] {
232                let mut p = self.0.clone().into_os_string();
233                p.push(suffix);
234                let _ = std::fs::remove_file(p);
235            }
236        }
237    }
238
239    /// A reader parks until released, but never forever: a blocking task cannot
240    /// be aborted, and a test that panics while one is parked would hang the
241    /// runtime on shutdown instead of reporting the failure.
242    fn park_until_released(rx: &std::sync::mpsc::Receiver<()>) {
243        let _ = rx.recv_timeout(std::time::Duration::from_secs(20));
244    }
245
246    /// With reader connections configured, a slow read must not hold up another
247    /// read. Before the split there was one permit for everything, so a long
248    /// query stalled every other read on the session for its whole duration.
249    #[tokio::test]
250    async fn reads_run_concurrently_when_reader_connections_are_configured() {
251        use crate::sqlite_store::SqliteStoreConfig;
252        use std::sync::Arc;
253
254        let db = TempDb::new("read_concurrency");
255        let store = SqliteStore::with_config(
256            &db.url(),
257            SqliteStoreConfig {
258                read_pool_size: 4,
259                ..Default::default()
260            },
261        )
262        .await
263        .expect("store with reader connections");
264        assert!(store.reads.is_some(), "a file-backed store reaches WAL");
265        let shared = store.shared();
266
267        // Every reader announces itself and then parks. With a single shared
268        // permit only the first would ever announce, so the collect below is
269        // what actually proves they overlap.
270        let (ready_tx, mut ready_rx) = tokio::sync::mpsc::unbounded_channel();
271        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
272        let release_rx = Arc::new(std::sync::Mutex::new(release_rx));
273        let mut readers = Vec::new();
274        for _ in 0..4 {
275            let shared = shared.clone();
276            let ready_tx = ready_tx.clone();
277            let release_rx = Arc::clone(&release_rx);
278            readers.push(tokio::spawn(async move {
279                shared
280                    .read(move |conn| {
281                        diesel::sql_query("SELECT 1")
282                            .execute(conn)
283                            .map_err(db_err)?;
284                        let _ = ready_tx.send(());
285                        // Each reader holds its own receiver turn; the guard is
286                        // only contended while parked, which is the point.
287                        park_until_released(&release_rx.lock().expect("release rx"));
288                        Ok(())
289                    })
290                    .await
291            }));
292        }
293        drop(ready_tx);
294
295        // All four must be inside `read` at once.
296        for _ in 0..4 {
297            tokio::time::timeout(std::time::Duration::from_secs(10), ready_rx.recv())
298                .await
299                .expect("readers must overlap, not serialize")
300                .expect("reader alive");
301        }
302        for _ in 0..4 {
303            let _ = release_tx.send(());
304        }
305        for reader in readers {
306            reader.await.expect("join").expect("read");
307        }
308    }
309
310    /// A write keeps its own pool, so readers saturating theirs can never leave
311    /// the writer waiting for a connection.
312    #[tokio::test]
313    async fn a_write_proceeds_while_every_reader_permit_is_held() {
314        use crate::sqlite_store::SqliteStoreConfig;
315        use std::sync::Arc;
316
317        let db = TempDb::new("write_not_starved");
318        let store = SqliteStore::with_config(
319            &db.url(),
320            SqliteStoreConfig {
321                read_pool_size: 2,
322                ..Default::default()
323            },
324        )
325        .await
326        .expect("store with reader connections");
327        let shared = store.shared();
328        shared
329            .run(|conn| {
330                diesel::sql_query("CREATE TABLE probe (k INTEGER PRIMARY KEY)")
331                    .execute(conn)
332                    .map_err(db_err)?;
333                Ok(())
334            })
335            .await
336            .expect("create");
337
338        let (ready_tx, mut ready_rx) = tokio::sync::mpsc::unbounded_channel();
339        let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
340        let release_rx = Arc::new(std::sync::Mutex::new(release_rx));
341        let mut readers = Vec::new();
342        for _ in 0..2 {
343            let shared = shared.clone();
344            let ready_tx = ready_tx.clone();
345            let release_rx = Arc::clone(&release_rx);
346            readers.push(tokio::spawn(async move {
347                shared
348                    .read(move |conn| {
349                        diesel::sql_query("SELECT 1")
350                            .execute(conn)
351                            .map_err(db_err)?;
352                        let _ = ready_tx.send(());
353                        park_until_released(&release_rx.lock().expect("release rx"));
354                        Ok(())
355                    })
356                    .await
357            }));
358        }
359        drop(ready_tx);
360
361        // Only meaningful once both readers actually hold their permits and
362        // connections — otherwise the writer could simply win the race.
363        for _ in 0..2 {
364            tokio::time::timeout(std::time::Duration::from_secs(10), ready_rx.recv())
365                .await
366                .expect("readers must check out")
367                .expect("reader alive");
368        }
369
370        let wrote = tokio::time::timeout(
371            std::time::Duration::from_secs(10),
372            shared.run(|conn| {
373                diesel::sql_query("INSERT INTO probe (k) VALUES (1)")
374                    .execute(conn)
375                    .map_err(db_err)?;
376                Ok(())
377            }),
378        )
379        .await;
380        // Release before asserting: a parked blocking task cannot be aborted,
381        // so panicking first would hang the runtime instead of failing.
382        for _ in 0..2 {
383            let _ = release_tx.send(());
384        }
385        wrote
386            .expect("the writer must not queue behind readers")
387            .expect("insert");
388        for reader in readers {
389            reader.await.expect("join").expect("read");
390        }
391    }
392
393    /// Reader connections are `query_only`, so a write sent down the read path
394    /// fails loudly instead of silently escaping the write serialization.
395    #[tokio::test]
396    async fn a_write_through_the_read_path_is_refused() {
397        use crate::sqlite_store::SqliteStoreConfig;
398
399        let db = TempDb::new("read_only");
400        let store = SqliteStore::with_config(
401            &db.url(),
402            SqliteStoreConfig {
403                read_pool_size: 1,
404                ..Default::default()
405            },
406        )
407        .await
408        .expect("store with reader connections");
409        let shared = store.shared();
410
411        let result = shared
412            .read(|conn| {
413                diesel::sql_query("CREATE TABLE nope (k INTEGER)")
414                    .execute(conn)
415                    .map_err(db_err)?;
416                Ok(())
417            })
418            .await;
419        assert!(matches!(result, Err(StoreError::Database(_))));
420    }
421
422    /// An in-memory database never reaches WAL, so reader connections would buy
423    /// contention instead of concurrency. The store declines them and keeps the
424    /// single queue rather than pretending.
425    #[tokio::test]
426    async fn reader_connections_are_declined_without_wal() {
427        use crate::sqlite_store::SqliteStoreConfig;
428
429        let store = SqliteStore::with_config(
430            &unique_db_name("no_wal"),
431            SqliteStoreConfig {
432                read_pool_size: 4,
433                ..Default::default()
434            },
435        )
436        .await
437        .expect("in-memory store still opens");
438        assert!(store.reads.is_none(), "no WAL, no reader pool");
439
440        // And reads still work, on the write queue.
441        store
442            .shared()
443            .read(|conn| {
444                diesel::sql_query("SELECT 1")
445                    .execute(conn)
446                    .map_err(db_err)?;
447                Ok(())
448            })
449            .await
450            .expect("reads fall back to the write permit");
451    }
452
453    /// Shared cache reaches WAL, so the journal check alone would let reader
454    /// connections through — into table locks that block the writer outright.
455    #[tokio::test]
456    async fn reader_connections_are_declined_for_shared_cache() {
457        use crate::sqlite_store::SqliteStoreConfig;
458
459        let db = TempDb::new("shared_cache");
460        let store = SqliteStore::with_config(
461            &format!("file:{}?cache=shared", db.url()),
462            SqliteStoreConfig {
463                read_pool_size: 4,
464                ..Default::default()
465            },
466        )
467        .await
468        .expect("shared-cache store still opens");
469        assert!(
470            store.reads.is_none(),
471            "shared cache serializes readers against the writer anyway"
472        );
473
474        // The same file without the parameter does get reader connections, so
475        // the decline is about the cache mode and not about the path.
476        let store = SqliteStore::with_config(
477            &db.url(),
478            SqliteStoreConfig {
479                read_pool_size: 4,
480                ..Default::default()
481            },
482        )
483        .await
484        .expect("private-cache store opens");
485        assert!(
486            store.reads.is_some(),
487            "private cache under WAL gets readers"
488        );
489    }
490
491    /// Reader connections hold page caches of their own, so the memory bound
492    /// has to count them or a read-enabled store reports the write pool's usage
493    /// as if it were the whole store's.
494    #[tokio::test]
495    async fn resource_report_counts_reader_connections() {
496        use crate::sqlite_store::SqliteStoreConfig;
497        use wacore::store::traits::DeviceStore;
498
499        let db = TempDb::new("report_readers");
500        let config = || SqliteStoreConfig {
501            read_pool_size: 3,
502            ..Default::default()
503        };
504
505        let with_readers = SqliteStore::with_config(&db.url(), config())
506            .await
507            .expect("store opens");
508        assert!(with_readers.reads.is_some(), "readers are configured");
509        // r2d2 opens min_idle connections eagerly; touch the read path so the
510        // reader pool has definitely opened one to account for.
511        with_readers
512            .shared()
513            .read(|conn| {
514                diesel::sql_query("SELECT 1")
515                    .execute(conn)
516                    .map_err(db_err)?;
517                Ok(())
518            })
519            .await
520            .expect("read succeeds");
521
522        let baseline = TempDb::new("report_no_readers");
523        let without = SqliteStore::new(&baseline.url())
524            .await
525            .expect("store opens");
526
527        let (a, b) = (
528            with_readers.resource_report().await,
529            without.resource_report().await,
530        );
531        let (Some(with_mem), Some(without_mem)) = (a.memory_bytes, b.memory_bytes) else {
532            panic!("both stores report a cache estimate");
533        };
534        assert!(
535            with_mem > without_mem,
536            "reader caches must widen the bound: {with_mem} vs {without_mem}"
537        );
538    }
539
540    /// Left at its default, `read` is the write path — same queue, same
541    /// behaviour as before the knob existed.
542    #[tokio::test]
543    async fn read_falls_back_to_the_write_permit_by_default() {
544        let store = create_test_store("read_default").await;
545        let shared = store.shared();
546        shared
547            .read(|conn| {
548                diesel::sql_query("SELECT 1")
549                    .execute(conn)
550                    .map_err(db_err)?;
551                Ok(())
552            })
553            .await
554            .expect("reads work with no reader connections configured");
555    }
556
557    #[tokio::test]
558    async fn shared_handle_propagates_closure_errors() {
559        let store = create_test_store("err").await;
560        let result = store
561            .shared()
562            .run(|conn| {
563                diesel::sql_query("SELECT * FROM does_not_exist")
564                    .execute(conn)
565                    .map_err(db_err)
566            })
567            .await;
568        assert!(matches!(result, Err(StoreError::Database(_))));
569    }
570}