koan_core/db/connection.rs
1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::path::Path;
4use std::rc::Rc;
5
6use rusqlite::functions::FunctionFlags;
7use rusqlite::{Connection, OpenFlags};
8use thiserror::Error;
9
10use super::schema;
11use crate::config;
12
13#[derive(Debug, Error)]
14pub enum DbError {
15 #[error("sqlite error: {0}")]
16 Sqlite(#[from] rusqlite::Error),
17 #[error("io error: {0}")]
18 Io(#[from] std::io::Error),
19 /// A bulk delete looked like a mount failure rather than an intentional
20 /// deletion, so it was refused. The library is untouched.
21 #[error("refused unsafe bulk delete: {0}")]
22 UnsafeBulkDelete(String),
23 /// A track was offered with neither a path nor a server id: nothing to
24 /// play it from, and nothing to know it by again.
25 #[error("a track needs a path or a server id")]
26 NoSource,
27}
28
29/// Wrapper around a SQLite connection with koan's schema applied.
30pub struct Database {
31 pub conn: Connection,
32}
33
34impl Database {
35 /// Open (or create) a database at the given path, applying the schema and
36 /// pending migrations.
37 ///
38 /// This is the once-per-process path: it creates the parent directory,
39 /// tightens file permissions, checkpoints the WAL and brings the schema up
40 /// to date. Anything opening a connection per request wants
41 /// [`Database::open_existing`] instead.
42 pub fn open(path: &Path) -> Result<Self, DbError> {
43 if let Some(parent) = path.parent() {
44 std::fs::create_dir_all(parent)?;
45 }
46
47 let conn = Connection::open(path)?;
48
49 #[cfg(unix)]
50 {
51 use std::os::unix::fs::PermissionsExt;
52 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
53 }
54
55 configure(&conn)?;
56
57 // Attempt a passive WAL checkpoint on open. This is non-blocking — it
58 // moves WAL pages back to the main DB file only if no readers/writers
59 // are active, preventing unbounded WAL growth across sessions.
60 let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
61
62 schema::create_tables(&conn)?;
63
64 // The planner picks between indexes by guessing how many rows each one
65 // will yield, and with no statistics it guesses the same number for all
66 // of them. That is how a partial index on the column a query filters by
67 // loses to an index that merely happens to supply the ORDER BY. Cheap
68 // after the first run, but an empty table never has statistics, so it
69 // asks for the write lock on every open: skipped while a writer holds
70 // it, and `optimize` runs again after every scan and sync.
71 let _ = without_waiting(&conn, |conn| conn.execute_batch("PRAGMA optimize"));
72
73 Ok(Self { conn })
74 }
75
76 /// Apply this build's schema and migrations to a snapshot of the database
77 /// at `path`, to learn whether it would open. The original is only read,
78 /// so this is safe beside a server that has it open: it is how a deploy
79 /// finds a migration that fails on the real library before replacing the
80 /// running version.
81 pub fn check_upgrade(path: &Path) -> Result<(), DbError> {
82 let snapshot = std::env::temp_dir().join(format!("koan-check-{}.db", std::process::id()));
83 let _ = std::fs::remove_file(&snapshot);
84 let result = (|| {
85 let source = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
86 source.execute("VACUUM INTO ?1", [snapshot.to_string_lossy()])?;
87 drop(source);
88 Self::open(&snapshot).map(drop)
89 })();
90 for suffix in ["", "-wal", "-shm"] {
91 let mut file = snapshot.clone().into_os_string();
92 file.push(suffix);
93 let _ = std::fs::remove_file(file);
94 }
95 result
96 }
97
98 /// Open an additional connection to a database whose schema is already
99 /// applied — pragmas only, no DDL, no checkpoint, no permission syscall.
100 ///
101 /// Callers are responsible for having run [`Database::open`] at least once
102 /// against the same path first.
103 pub fn open_existing(path: &Path) -> Result<Self, DbError> {
104 let conn = Connection::open(path)?;
105 configure(&conn)?;
106 Ok(Self { conn })
107 }
108
109 /// Open the default database at the standard data directory.
110 pub fn open_default() -> Result<Self, DbError> {
111 Self::open(&config::db_path())
112 }
113
114 /// Refresh the planner's statistics.
115 ///
116 /// Worth calling wherever the library changes size in bulk — a scan, a
117 /// remote sync — because the statistics gathered when the process started
118 /// describe a library that no longer exists, and the planner will keep
119 /// choosing for it. A no-op when nothing has moved far enough to matter.
120 pub fn optimize(&self) {
121 if let Err(e) = self.conn.execute_batch("PRAGMA optimize") {
122 log::debug!("PRAGMA optimize failed: {e}");
123 }
124 }
125}
126
127/// Long enough to outlast a scan chunk: a writer that gives up mid-scan
128/// silently loses favourites, queue state and play counts.
129const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
130
131/// Run `write` without waiting for the write lock, which fails it with
132/// `SQLITE_BUSY` while a scan or sync holds the lock.
133///
134/// For bookkeeping a request should not wait on: a key's last use, a share's
135/// visit count. Skipping one costs a stale number; waiting costs the request.
136pub fn without_waiting<T>(
137 conn: &Connection,
138 write: impl FnOnce(&Connection) -> rusqlite::Result<T>,
139) -> rusqlite::Result<T> {
140 conn.busy_timeout(std::time::Duration::ZERO)?;
141 let result = write(conn);
142 conn.busy_timeout(BUSY_TIMEOUT)?;
143 result
144}
145
146/// Connection-scoped pragmas. Every connection needs these; none of them touch
147/// the file on disk, so they are cheap enough to repeat per connection.
148fn configure(conn: &Connection) -> Result<(), DbError> {
149 // WAL mode for concurrent reads + single writer.
150 conn.pragma_update(None, "journal_mode", "wal")?;
151 conn.pragma_update(None, "foreign_keys", "on")?;
152 conn.busy_timeout(BUSY_TIMEOUT)?;
153 // Slightly faster at the cost of durability on power loss (acceptable for a media DB).
154 conn.pragma_update(None, "synchronous", "normal")?;
155 // Map the whole library. A library this size fits well inside this, so
156 // reads become dereferences into a mapped region rather than syscalls —
157 // which is the useful sense in which a database can be "in memory". The
158 // page cache was already holding it; this stops copying it out per read.
159 conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
160 // 32 MiB of pages, per connection. Negative means KiB rather than pages,
161 // so the figure does not change meaning with the page size.
162 conn.pragma_update(None, "cache_size", -32_000i64)?;
163 // Sorts and intermediate tables in memory. FTS and the ORDER BYs behind
164 // every library listing make temporary tables constantly.
165 conn.pragma_update(None, "temp_store", "memory")?;
166 // How many rows `PRAGMA optimize` samples per index. Bounded, so gathering
167 // statistics stays a fraction of a second on a library of any size; the
168 // planner needs the shape of the distribution, not an exact count.
169 conn.pragma_update(None, "analysis_limit", 400i64)?;
170 // Past rusqlite's default of 16, which the per-request reads alone exceed.
171 conn.set_prepared_statement_cache_capacity(128);
172 // The WAL keeps its high-water mark on disk after a checkpoint. A sync
173 // grows it; this lets it shrink back.
174 conn.pragma_update(None, "journal_size_limit", 67_108_864i64)?;
175 // Here rather than with the schema: `open_existing` skips the DDL, and a
176 // connection without this collation fails every ORDER BY that uses it.
177 register_library_collation(conn)?;
178 register_shuffle_function(conn)?;
179 register_fold_function(conn)?;
180 Ok(())
181}
182
183/// `koan_fold(name)` — a name as matching compares it; see
184/// `queries::sources::fold`. The triggers that keep `artists.name_key` and
185/// `albums.title_key` call it, so a row has its key however it was written.
186pub(crate) fn register_fold_function(conn: &Connection) -> rusqlite::Result<()> {
187 conn.create_scalar_function(
188 "koan_fold",
189 1,
190 FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
191 |ctx| {
192 Ok(ctx
193 .get::<Option<String>>(0)?
194 .map(|s| super::queries::sources::fold(&s)))
195 },
196 )
197}
198
199/// `koan_shuffle(id, seed)` — a stable pseudo-random ordering key.
200///
201/// A shuffled listing that is read a page at a time cannot shuffle in the
202/// client: page two would be drawn from a different shuffle than page one, and
203/// records would repeat or vanish as you scrolled. Ordering by a hash of the
204/// row id and a seed gives one order that every page of the same seed agrees
205/// on, and a new seed gives a different one.
206///
207/// Registered next to the collation, and for the same reason: a connection
208/// without it fails the query outright rather than sorting some other way.
209pub(crate) fn register_shuffle_function(conn: &Connection) -> rusqlite::Result<()> {
210 conn.create_scalar_function(
211 "koan_shuffle",
212 2,
213 FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
214 |ctx| {
215 let id = ctx.get::<i64>(0)? as u64;
216 let seed = ctx.get::<i64>(1)? as u64;
217 Ok(splitmix64(id ^ splitmix64(seed)) as i64)
218 },
219 )
220}
221
222/// SplitMix64. Cheap, and it scatters consecutive ids — which matters, because
223/// a library's ids are consecutive in the order it was scanned.
224fn splitmix64(x: u64) -> u64 {
225 let mut z = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
226 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
227 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
228 z ^ (z >> 31)
229}
230
231/// A collation for names the way a person reads them.
232///
233/// SQLite's default is a byte comparison, which sorts every capital before
234/// every lowercase and every accented letter after the whole ASCII range — so
235/// an artist list ran `Zebra`, then `aphex twin`, and put `Âme` at the end
236/// where nobody would look for it.
237///
238/// Case is folded, accents are folded onto their base letter (`Âme` sorts with
239/// `Ame`), and runs of digits compare by value so `Track 2` precedes
240/// `Track 10`. Ties fall back to the raw bytes, so two names that differ only
241/// in case or accent still have a stable order rather than being treated as
242/// equal.
243///
244/// Registered by `configure`, so every connection has it — a query using
245/// `COLLATE LIBRARY` on a connection that skipped this fails outright rather
246/// than quietly sorting some other way.
247pub(crate) fn register_library_collation(conn: &Connection) -> rusqlite::Result<()> {
248 conn.create_collation("LIBRARY", |a, b| {
249 cached_sort_key(a).cmp(&cached_sort_key(b)).then(a.cmp(b))
250 })
251}
252
253thread_local! {
254 /// Sort keys, kept for the life of the thread.
255 ///
256 /// A collation sees the same name once per level of the sort — around two
257 /// dozen times in a five-thousand-row list — and building a key means an
258 /// NFD pass and a `Vec` of freshly allocated `String`s. Cached, each name is
259 /// folded once per thread instead of once per comparison.
260 static SORT_KEYS: RefCell<HashMap<Box<str>, Rc<[Chunk]>>> = RefCell::new(HashMap::new());
261}
262
263fn cached_sort_key(s: &str) -> Rc<[Chunk]> {
264 SORT_KEYS.with_borrow_mut(|cache| {
265 if let Some(key) = cache.get(s) {
266 return Rc::clone(key);
267 }
268 // A library's worth of names is tens of thousands of entries. Anything
269 // beyond that is a query sorting something other than names, and it
270 // should not grow this without bound.
271 if cache.len() >= 50_000 {
272 cache.clear();
273 }
274 let key: Rc<[Chunk]> = sort_key(s).into();
275 cache.insert(s.into(), Rc::clone(&key));
276 key
277 })
278}
279
280/// One comparable chunk of a name: either a run of digits, as a number, or a
281/// run of folded characters.
282#[derive(PartialEq, Eq, PartialOrd, Ord)]
283enum Chunk {
284 Number(u128),
285 Text(String),
286}
287
288fn sort_key(s: &str) -> Vec<Chunk> {
289 use unicode_normalization::UnicodeNormalization;
290
291 // NFD splits an accented letter into its base plus a combining mark; dropping
292 // the marks leaves the base letter to sort on.
293 let folded: String = s
294 .nfd()
295 .filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
296 .flat_map(char::to_lowercase)
297 .collect();
298
299 let mut chunks = Vec::new();
300 let mut rest = folded.as_str();
301 while !rest.is_empty() {
302 let digits = rest
303 .find(|c: char| !c.is_ascii_digit())
304 .unwrap_or(rest.len());
305 if digits > 0 && rest.starts_with(|c: char| c.is_ascii_digit()) {
306 // Absurdly long digit runs are not numbers anyone sorts by.
307 match rest[..digits].parse::<u128>() {
308 Ok(n) => chunks.push(Chunk::Number(n)),
309 Err(_) => chunks.push(Chunk::Text(rest[..digits].to_string())),
310 }
311 rest = &rest[digits..];
312 continue;
313 }
314 let text = rest
315 .find(|c: char| c.is_ascii_digit())
316 .unwrap_or(rest.len())
317 .max(1);
318 chunks.push(Chunk::Text(rest[..text].to_string()));
319 rest = &rest[text..];
320 }
321 chunks
322}
323
324#[cfg(test)]
325mod collation_tests {
326 use super::*;
327
328 fn sorted(names: &[&str]) -> Vec<String> {
329 let conn = Connection::open_in_memory().unwrap();
330 crate::db::schema::create_tables(&conn).unwrap();
331 conn.execute_batch("CREATE TABLE t (name TEXT)").unwrap();
332 for n in names {
333 conn.execute("INSERT INTO t VALUES (?1)", [n]).unwrap();
334 }
335 let mut stmt = conn
336 .prepare("SELECT name FROM t ORDER BY name COLLATE LIBRARY")
337 .unwrap();
338 let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
339 rows.map(Result::unwrap).collect()
340 }
341
342 #[test]
343 fn lowercase_does_not_sort_after_everything() {
344 assert_eq!(
345 sorted(&["Zebra", "aphex twin", "Boards of Canada"]),
346 ["aphex twin", "Boards of Canada", "Zebra"]
347 );
348 }
349
350 #[test]
351 fn accents_sort_with_their_base_letter() {
352 // Byte order puts every non-ASCII name after `z`, which is where nobody
353 // looks for Âme.
354 assert_eq!(
355 sorted(&["Zomby", "Âme", "Alva Noto"]),
356 ["Alva Noto", "Âme", "Zomby"]
357 );
358 }
359
360 #[test]
361 fn digit_runs_compare_as_numbers() {
362 assert_eq!(
363 sorted(&["Track 10", "Track 2", "Track 1"]),
364 ["Track 1", "Track 2", "Track 10"]
365 );
366 }
367
368 #[test]
369 fn names_differing_only_in_case_keep_a_stable_order() {
370 // Folding must not make them equal, or the order flips between runs.
371 assert_eq!(
372 sorted(&["kraftwerk", "Kraftwerk"]),
373 ["Kraftwerk", "kraftwerk"]
374 );
375 }
376}
377
378#[cfg(test)]
379mod check_upgrade_tests {
380 use super::*;
381
382 #[test]
383 fn leaves_the_original_untouched() {
384 let dir = tempfile::tempdir().unwrap();
385 let path = dir.path().join("koan.db");
386 let conn = Connection::open(&path).unwrap();
387 conn.execute_batch("CREATE TABLE marker (x); INSERT INTO marker VALUES (1);")
388 .unwrap();
389 drop(conn);
390
391 Database::check_upgrade(&path).unwrap();
392
393 let conn = Connection::open(&path).unwrap();
394 let tables: i64 = conn
395 .query_row(
396 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'",
397 [],
398 |r| r.get(0),
399 )
400 .unwrap();
401 assert_eq!(
402 tables, 1,
403 "the schema went into the snapshot, not the original"
404 );
405 }
406
407 #[test]
408 fn a_missing_database_fails() {
409 let dir = tempfile::tempdir().unwrap();
410 assert!(Database::check_upgrade(&dir.path().join("absent.db")).is_err());
411 }
412}