Skip to main content

koan_core/db/
pool.rs

1//! Database connections, opened once and kept.
2//!
3//! Every read used to open its own: a connection, a permissions syscall, the
4//! whole schema DDL and a WAL checkpoint, before a single row came back.
5//! Clicking an album paid all of it, and while downloads were writing, the
6//! checkpoint contended with them and it took seconds.
7//!
8//! A pool rather than one shared connection, because rusqlite's `Connection` is
9//! `Send` but not `Sync`: sharing one means a mutex, and a mutex means every
10//! read waits for every other. SQLite in WAL mode reads concurrently across
11//! connections, so the way to keep that is to have several and hand them out.
12//!
13//! A connection is opened only when every existing one is busy, so the pool
14//! grows to whatever concurrency actually happens. What it *keeps* is capped:
15//! queueing a thousand tracks runs as many transfers at once as
16//! `download_workers` allows and no more, but a burst wider than the cap should
17//! not leave a thousand connections parked for the rest of the session, each
18//! holding its own page cache. Past the cap a returned connection is closed
19//! rather than kept.
20
21use std::ops::Deref;
22use std::path::{Path, PathBuf};
23
24use super::connection::{Database, DbError};
25
26pub struct Pool {
27    path: PathBuf,
28    idle: parking_lot::Mutex<Vec<Database>>,
29    keep: usize,
30}
31
32/// How many idle connections to hold on to.
33///
34/// Comfortably more than the concurrency anything here actually reaches — a
35/// handful of front-end reads alongside the configured download workers — so
36/// the steady state never opens one, while a burst still cannot park hundreds.
37const KEEP_IDLE: usize = 32;
38
39/// The process's pool for the default library.
40///
41/// One database, opened once, shared by everything that reads it: the front
42/// ends, the downloader, the background tasks. Callers with their own path —
43/// tests, mostly — build their own.
44pub fn shared() -> &'static Pool {
45    static POOL: std::sync::OnceLock<Pool> = std::sync::OnceLock::new();
46    POOL.get_or_init(|| Pool::new(crate::config::db_path()))
47}
48
49impl Pool {
50    /// The schema must already be applied — see [`Pool::get`].
51    pub fn new(path: PathBuf) -> Self {
52        Self {
53            path,
54            idle: parking_lot::Mutex::new(Vec::new()),
55            keep: KEEP_IDLE,
56        }
57    }
58
59    /// Borrow a connection, opening one only if none are free.
60    ///
61    /// `open_existing`, so this never re-runs the DDL or checkpoints: the
62    /// schema is applied once at startup, before any pool exists.
63    pub fn get(&self) -> Result<Handle<'_>, DbError> {
64        let pooled = self.idle.lock().pop();
65        let db = match pooled {
66            Some(db) => db,
67            None => Database::open_existing(&self.path)?,
68        };
69        Ok(Handle {
70            db: Some(db),
71            pool: self,
72        })
73    }
74
75    pub fn path(&self) -> &Path {
76        &self.path
77    }
78
79    fn put_back(&self, db: Database) {
80        let mut idle = self.idle.lock();
81        if idle.len() < self.keep {
82            idle.push(db);
83        }
84        // Otherwise it closes here, which is the point of the cap.
85    }
86}
87
88/// A borrowed connection, returned to the pool when it goes out of scope.
89///
90/// Derefs to `Database` so callers reach `.conn` exactly as they did when this
91/// was an owned connection they had opened themselves.
92pub struct Handle<'a> {
93    db: Option<Database>,
94    pool: &'a Pool,
95}
96
97impl Deref for Handle<'_> {
98    type Target = Database;
99
100    fn deref(&self) -> &Database {
101        self.db.as_ref().expect("a handle holds its connection")
102    }
103}
104
105impl Drop for Handle<'_> {
106    fn drop(&mut self) {
107        if let Some(db) = self.db.take() {
108            self.pool.put_back(db);
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn pool() -> (tempfile::TempDir, Pool) {
118        let dir = tempfile::tempdir().unwrap();
119        let path = dir.path().join("koan.db");
120        // What startup does, once.
121        Database::open(&path).unwrap();
122        (dir, Pool::new(path))
123    }
124
125    #[test]
126    fn a_connection_comes_back_and_is_reused() {
127        let (_dir, pool) = pool();
128        {
129            let db = pool.get().unwrap();
130            db.conn.execute_batch("SELECT 1").unwrap();
131        }
132        assert_eq!(pool.idle.lock().len(), 1, "returned on drop");
133        {
134            let _db = pool.get().unwrap();
135            assert_eq!(pool.idle.lock().len(), 0, "handed back out");
136        }
137        assert_eq!(pool.idle.lock().len(), 1);
138    }
139
140    #[test]
141    fn concurrent_borrowers_get_their_own() {
142        let (_dir, pool) = pool();
143        let first = pool.get().unwrap();
144        let second = pool.get().unwrap();
145        first.conn.execute_batch("SELECT 1").unwrap();
146        second.conn.execute_batch("SELECT 1").unwrap();
147        drop(first);
148        drop(second);
149        assert_eq!(pool.idle.lock().len(), 2, "both kept for next time");
150    }
151
152    #[test]
153    fn idle_connections_are_capped() {
154        // A burst wider than the cap must not park connections for the rest of
155        // the session.
156        let (_dir, mut pool) = pool();
157        pool.keep = 2;
158        let handles: Vec<_> = (0..6).map(|_| pool.get().unwrap()).collect();
159        assert_eq!(pool.idle.lock().len(), 0, "all of them are out");
160        drop(handles);
161        assert_eq!(pool.idle.lock().len(), 2, "the rest closed on return");
162    }
163
164    #[test]
165    fn a_pooled_connection_sees_what_another_wrote() {
166        // WAL readers are per-connection snapshots; a stale one would serve a
167        // library that is missing whatever just landed.
168        let (_dir, pool) = pool();
169        {
170            let db = pool.get().unwrap();
171            db.conn
172                .execute_batch("CREATE TABLE probe (v INTEGER); INSERT INTO probe VALUES (7)")
173                .unwrap();
174        }
175        let db = pool.get().unwrap();
176        let v: i64 = db
177            .conn
178            .query_row("SELECT v FROM probe", [], |r| r.get(0))
179            .unwrap();
180        assert_eq!(v, 7);
181    }
182}