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}