Skip to main content

koan_core/db/
pool.rs

1//! Database connections, opened once and kept.
2//!
3//! Opening one costs a permissions syscall, a schema check and a WAL
4//! checkpoint before a single row comes back, and while downloads are writing
5//! the checkpoint contends with them.
6//!
7//! A pool rather than one shared connection, because rusqlite's `Connection` is
8//! `Send` but not `Sync`: sharing one means a mutex, and a mutex means every
9//! read waits for every other. SQLite in WAL mode reads concurrently across
10//! connections, so the way to keep that is to have several and hand them out.
11//!
12//! A connection is opened only when every existing one is busy, so the pool
13//! grows to whatever concurrency actually happens. What it *keeps* is capped:
14//! queueing a thousand tracks runs as many transfers at once as
15//! `download_workers` allows and no more, but a burst wider than the cap should
16//! not leave a thousand connections parked for the rest of the session, each
17//! holding its own page cache. Past the cap a returned connection is closed
18//! rather than kept.
19
20use std::ops::Deref;
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
23
24use super::connection::{Database, DbError};
25
26pub struct Pool {
27    path: PathBuf,
28    idle: parking_lot::Mutex<Vec<Database>>,
29    keep: usize,
30    /// Whether the schema has been applied in this process.
31    schema: AtomicBool,
32    /// Held while applying it, so two threads arriving at once do it once.
33    applying: parking_lot::Mutex<()>,
34    /// Connections opened so far.
35    opened: AtomicUsize,
36}
37
38/// How many idle connections to hold on to.
39///
40/// Comfortably more than the concurrency anything here actually reaches — a
41/// handful of front-end reads alongside the configured download workers — so
42/// the steady state never opens one, while a burst still cannot park hundreds.
43const KEEP_IDLE: usize = 32;
44
45/// The process's pool for the default library.
46///
47/// One database, opened once, shared by everything that reads it: the front
48/// ends, the downloader, the background tasks. Callers with their own path —
49/// tests, mostly — build their own.
50pub fn shared() -> &'static Pool {
51    static POOL: std::sync::OnceLock<Pool> = std::sync::OnceLock::new();
52    POOL.get_or_init(|| Pool::new(crate::config::db_path()))
53}
54
55/// Run when a connection finds the database migrated past this build. A
56/// server sets it to stop serving; an app leaves it unset and the error
57/// reaches whatever asked.
58static OUTDATED: std::sync::OnceLock<Box<dyn Fn() + Send + Sync>> = std::sync::OnceLock::new();
59
60/// What to do once the database is found migrated past this build, as it is
61/// when a newer koan sharing it has upgraded it. Set once.
62pub fn on_outdated(f: impl Fn() + Send + Sync + 'static) {
63    let _ = OUTDATED.set(Box::new(f));
64}
65
66/// Whether this build still understands the database `conn` reads, checked
67/// each time a connection is handed out.
68///
69/// Read from the database rather than cached: the migration that ends it is
70/// another process's, and its commit reaches this one through the file and
71/// nothing else. `user_version` is an integer in the header page, which a
72/// read already holds in cache, so this is a few microseconds.
73pub fn understood(conn: &rusqlite::Connection) -> Result<(), DbError> {
74    let found: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
75    if found <= super::schema::SCHEMA_VERSION {
76        return Ok(());
77    }
78    log::error!("the database is at schema version {found}: a newer koan has upgraded it");
79    if let Some(f) = OUTDATED.get() {
80        f();
81    }
82    Err(DbError::Outdated(found))
83}
84
85impl Pool {
86    /// The schema must already be applied — see [`Pool::get`].
87    pub fn new(path: PathBuf) -> Self {
88        Self {
89            path,
90            idle: parking_lot::Mutex::new(Vec::new()),
91            keep: KEEP_IDLE,
92            schema: AtomicBool::new(false),
93            applying: parking_lot::Mutex::new(()),
94            opened: AtomicUsize::new(0),
95        }
96    }
97
98    /// Apply the schema, once, before handing out the first connection.
99    ///
100    /// So the pool is safe as the only way anything reaches the database.
101    /// Pooled connections open with `open_existing`, which does no DDL — fine
102    /// for a running app that opened the library at startup, and wrong for a
103    /// one-shot command on a machine that has never run koan. Doing it here
104    /// rather than relying on a caller having done it first removes the
105    /// invariant instead of documenting it.
106    fn ensure_schema(&self) -> Result<(), DbError> {
107        if self.schema.load(Ordering::Acquire) {
108            return Ok(());
109        }
110        let _applying = self.applying.lock();
111        if self.schema.load(Ordering::Acquire) {
112            return Ok(());
113        }
114        Database::open(&self.path)?;
115        self.schema.store(true, Ordering::Release);
116        Ok(())
117    }
118
119    /// Borrow a connection, opening one only if none are free.
120    ///
121    /// `open_existing`, so this never re-runs the DDL or checkpoints: the
122    /// schema is applied once at startup, before any pool exists.
123    pub fn get(&self) -> Result<Handle<'_>, DbError> {
124        Ok(Handle {
125            db: Some(self.take()?),
126            pool: self,
127        })
128    }
129
130    /// A connection the caller holds by value and hands back with
131    /// [`Pool::put_back`], for one that has to outlive a borrow of the pool —
132    /// moved onto a blocking task, say. One never handed back is closed when
133    /// dropped.
134    pub fn take(&self) -> Result<Database, DbError> {
135        self.ensure_schema()?;
136        let pooled = self.idle.lock().pop();
137        let db = match pooled {
138            Some(db) => db,
139            None => {
140                self.opened.fetch_add(1, Ordering::Relaxed);
141                Database::open_existing(&self.path)?
142            }
143        };
144        understood(&db.conn)?;
145        Ok(db)
146    }
147
148    pub fn path(&self) -> &Path {
149        &self.path
150    }
151
152    /// How many connections this pool has opened.
153    pub fn opened(&self) -> usize {
154        self.opened.load(Ordering::Relaxed)
155    }
156
157    pub fn put_back(&self, db: Database) {
158        let mut idle = self.idle.lock();
159        if idle.len() < self.keep {
160            idle.push(db);
161        }
162        // Otherwise it closes here, which is the point of the cap.
163    }
164}
165
166/// A borrowed connection, returned to the pool when it goes out of scope.
167///
168/// Derefs to `Database`, so callers reach `.conn` as on an owned connection.
169pub struct Handle<'a> {
170    db: Option<Database>,
171    pool: &'a Pool,
172}
173
174impl Deref for Handle<'_> {
175    type Target = Database;
176
177    fn deref(&self) -> &Database {
178        self.db.as_ref().expect("a handle holds its connection")
179    }
180}
181
182impl Drop for Handle<'_> {
183    fn drop(&mut self) {
184        if let Some(db) = self.db.take() {
185            self.pool.put_back(db);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    /// A pool over an empty directory — nothing has opened this database, which
195    /// is the state a one-shot command starts from.
196    fn pool() -> (tempfile::TempDir, Pool) {
197        let dir = tempfile::tempdir().unwrap();
198        let path = dir.path().join("koan.db");
199        (dir, Pool::new(path))
200    }
201
202    #[test]
203    fn the_first_connection_applies_the_schema() {
204        // A one-shot command on a machine that has never run koan reaches the
205        // database through here and nowhere else.
206        let (_dir, pool) = pool();
207        let db = pool.get().unwrap();
208        let tracks: i64 = db
209            .conn
210            .query_row("SELECT count(*) FROM tracks", [], |r| r.get(0))
211            .expect("the schema should be there");
212        assert_eq!(tracks, 0);
213    }
214
215    #[test]
216    fn a_connection_comes_back_and_is_reused() {
217        let (_dir, pool) = pool();
218        {
219            let db = pool.get().unwrap();
220            db.conn.execute_batch("SELECT 1").unwrap();
221        }
222        assert_eq!(pool.idle.lock().len(), 1, "returned on drop");
223        {
224            let _db = pool.get().unwrap();
225            assert_eq!(pool.idle.lock().len(), 0, "handed back out");
226        }
227        assert_eq!(pool.idle.lock().len(), 1);
228    }
229
230    #[test]
231    fn concurrent_borrowers_get_their_own() {
232        let (_dir, pool) = pool();
233        let first = pool.get().unwrap();
234        let second = pool.get().unwrap();
235        first.conn.execute_batch("SELECT 1").unwrap();
236        second.conn.execute_batch("SELECT 1").unwrap();
237        drop(first);
238        drop(second);
239        assert_eq!(pool.idle.lock().len(), 2, "both kept for next time");
240    }
241
242    #[test]
243    fn idle_connections_are_capped() {
244        // A burst wider than the cap must not park connections for the rest of
245        // the session.
246        let (_dir, mut pool) = pool();
247        pool.keep = 2;
248        let handles: Vec<_> = (0..6).map(|_| pool.get().unwrap()).collect();
249        assert_eq!(pool.idle.lock().len(), 0, "all of them are out");
250        drop(handles);
251        assert_eq!(pool.idle.lock().len(), 2, "the rest closed on return");
252    }
253
254    #[test]
255    fn a_pooled_connection_sees_what_another_wrote() {
256        // WAL readers are per-connection snapshots; a stale one would serve a
257        // library that is missing whatever just landed.
258        let (_dir, pool) = pool();
259        {
260            let db = pool.get().unwrap();
261            db.conn
262                .execute_batch("CREATE TABLE probe (v INTEGER); INSERT INTO probe VALUES (7)")
263                .unwrap();
264        }
265        let db = pool.get().unwrap();
266        let v: i64 = db
267            .conn
268            .query_row("SELECT v FROM probe", [], |r| r.get(0))
269            .unwrap();
270        assert_eq!(v, 7);
271    }
272}
273
274#[cfg(test)]
275mod outdated_tests {
276    use super::*;
277
278    /// A connection handed out after another process migrated the database
279    /// past this build is refused, pooled or new.
280    #[test]
281    fn a_database_migrated_past_this_build_is_refused_at_checkout() {
282        let dir = tempfile::tempdir().unwrap();
283        let path = dir.path().join("koan.db");
284        let pool = Pool::new(path.clone());
285        drop(pool.get().unwrap());
286        let newer = rusqlite::Connection::open(&path).unwrap();
287        newer
288            .pragma_update(None, "user_version", crate::db::schema::SCHEMA_VERSION + 1)
289            .unwrap();
290        assert!(matches!(pool.take(), Err(DbError::Outdated(_))), "pooled");
291        assert!(matches!(pool.take(), Err(DbError::Outdated(_))), "new");
292    }
293}