Skip to main content

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