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};
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use super::connection::{Database, DbError};
26
27pub struct Pool {
28 path: PathBuf,
29 idle: parking_lot::Mutex<Vec<Database>>,
30 keep: usize,
31 /// Whether the schema has been applied in this process.
32 schema: AtomicBool,
33 /// Held while applying it, so two threads arriving at once do it once.
34 applying: parking_lot::Mutex<()>,
35}
36
37/// How many idle connections to hold on to.
38///
39/// Comfortably more than the concurrency anything here actually reaches — a
40/// handful of front-end reads alongside the configured download workers — so
41/// the steady state never opens one, while a burst still cannot park hundreds.
42const KEEP_IDLE: usize = 32;
43
44/// The process's pool for the default library.
45///
46/// One database, opened once, shared by everything that reads it: the front
47/// ends, the downloader, the background tasks. Callers with their own path —
48/// tests, mostly — build their own.
49pub fn shared() -> &'static Pool {
50 static POOL: std::sync::OnceLock<Pool> = std::sync::OnceLock::new();
51 POOL.get_or_init(|| Pool::new(crate::config::db_path()))
52}
53
54impl Pool {
55 /// The schema must already be applied — see [`Pool::get`].
56 pub fn new(path: PathBuf) -> Self {
57 Self {
58 path,
59 idle: parking_lot::Mutex::new(Vec::new()),
60 keep: KEEP_IDLE,
61 schema: AtomicBool::new(false),
62 applying: parking_lot::Mutex::new(()),
63 }
64 }
65
66 /// Apply the schema, once, before handing out the first connection.
67 ///
68 /// So the pool is safe as the only way anything reaches the database.
69 /// Pooled connections open with `open_existing`, which does no DDL — fine
70 /// for a running app that opened the library at startup, and wrong for a
71 /// one-shot command on a machine that has never run koan. Doing it here
72 /// rather than relying on a caller having done it first removes the
73 /// invariant instead of documenting it.
74 fn ensure_schema(&self) -> Result<(), DbError> {
75 if self.schema.load(Ordering::Acquire) {
76 return Ok(());
77 }
78 let _applying = self.applying.lock();
79 if self.schema.load(Ordering::Acquire) {
80 return Ok(());
81 }
82 Database::open(&self.path)?;
83 self.schema.store(true, Ordering::Release);
84 Ok(())
85 }
86
87 /// Borrow a connection, opening one only if none are free.
88 ///
89 /// `open_existing`, so this never re-runs the DDL or checkpoints: the
90 /// schema is applied once at startup, before any pool exists.
91 pub fn get(&self) -> Result<Handle<'_>, DbError> {
92 self.ensure_schema()?;
93 let pooled = self.idle.lock().pop();
94 let db = match pooled {
95 Some(db) => db,
96 None => Database::open_existing(&self.path)?,
97 };
98 Ok(Handle {
99 db: Some(db),
100 pool: self,
101 })
102 }
103
104 pub fn path(&self) -> &Path {
105 &self.path
106 }
107
108 fn put_back(&self, db: Database) {
109 let mut idle = self.idle.lock();
110 if idle.len() < self.keep {
111 idle.push(db);
112 }
113 // Otherwise it closes here, which is the point of the cap.
114 }
115}
116
117/// A borrowed connection, returned to the pool when it goes out of scope.
118///
119/// Derefs to `Database` so callers reach `.conn` exactly as they did when this
120/// was an owned connection they had opened themselves.
121pub struct Handle<'a> {
122 db: Option<Database>,
123 pool: &'a Pool,
124}
125
126impl Deref for Handle<'_> {
127 type Target = Database;
128
129 fn deref(&self) -> &Database {
130 self.db.as_ref().expect("a handle holds its connection")
131 }
132}
133
134impl Drop for Handle<'_> {
135 fn drop(&mut self) {
136 if let Some(db) = self.db.take() {
137 self.pool.put_back(db);
138 }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 /// A pool over an empty directory — nothing has opened this database, which
147 /// is the state a one-shot command starts from.
148 fn pool() -> (tempfile::TempDir, Pool) {
149 let dir = tempfile::tempdir().unwrap();
150 let path = dir.path().join("koan.db");
151 (dir, Pool::new(path))
152 }
153
154 #[test]
155 fn the_first_connection_applies_the_schema() {
156 // A one-shot command on a machine that has never run koan reaches the
157 // database through here and nowhere else.
158 let (_dir, pool) = pool();
159 let db = pool.get().unwrap();
160 let tracks: i64 = db
161 .conn
162 .query_row("SELECT count(*) FROM tracks", [], |r| r.get(0))
163 .expect("the schema should be there");
164 assert_eq!(tracks, 0);
165 }
166
167 #[test]
168 fn a_connection_comes_back_and_is_reused() {
169 let (_dir, pool) = pool();
170 {
171 let db = pool.get().unwrap();
172 db.conn.execute_batch("SELECT 1").unwrap();
173 }
174 assert_eq!(pool.idle.lock().len(), 1, "returned on drop");
175 {
176 let _db = pool.get().unwrap();
177 assert_eq!(pool.idle.lock().len(), 0, "handed back out");
178 }
179 assert_eq!(pool.idle.lock().len(), 1);
180 }
181
182 #[test]
183 fn concurrent_borrowers_get_their_own() {
184 let (_dir, pool) = pool();
185 let first = pool.get().unwrap();
186 let second = pool.get().unwrap();
187 first.conn.execute_batch("SELECT 1").unwrap();
188 second.conn.execute_batch("SELECT 1").unwrap();
189 drop(first);
190 drop(second);
191 assert_eq!(pool.idle.lock().len(), 2, "both kept for next time");
192 }
193
194 #[test]
195 fn idle_connections_are_capped() {
196 // A burst wider than the cap must not park connections for the rest of
197 // the session.
198 let (_dir, mut pool) = pool();
199 pool.keep = 2;
200 let handles: Vec<_> = (0..6).map(|_| pool.get().unwrap()).collect();
201 assert_eq!(pool.idle.lock().len(), 0, "all of them are out");
202 drop(handles);
203 assert_eq!(pool.idle.lock().len(), 2, "the rest closed on return");
204 }
205
206 #[test]
207 fn a_pooled_connection_sees_what_another_wrote() {
208 // WAL readers are per-connection snapshots; a stale one would serve a
209 // library that is missing whatever just landed.
210 let (_dir, pool) = pool();
211 {
212 let db = pool.get().unwrap();
213 db.conn
214 .execute_batch("CREATE TABLE probe (v INTEGER); INSERT INTO probe VALUES (7)")
215 .unwrap();
216 }
217 let db = pool.get().unwrap();
218 let v: i64 = db
219 .conn
220 .query_row("SELECT v FROM probe", [], |r| r.get(0))
221 .unwrap();
222 assert_eq!(v, 7);
223 }
224}