perf(db): reads no longer wait behind writes; pages answer from cache

A series page took about a second to show its seasons on a phone, every
visit, although they were cached. Three things stacked up:

- One SQLite connection behind one mutex served the whole app, so every
  read queued behind every write. The database now has one owner: a
  writer thread for writes and a pool of read-only WAL connections for
  reads. synchronous = NORMAL and a busy timeout on every connection.
- The listing query built the set of every available item in the
  database before filtering to the parent (~80 ms on a desktop for a
  100k-item cache), then fetched user data one row at a time. It now
  checks availability per row, uses the hierarchy indexes (1.5 ms on
  the same benchmark) and batches the user-data lookup.
- A cache read that missed the 100 ms fast path was set aside until the
  server answered. It is now raced against the server; whichever answers
  first with content wins.

On the Fairphone, Frasier's season and episode lists now come from
cache in 34-133 ms (was 600-1030 ms waiting on the server).

Fixes found on the way, each with a test that failed first:
- sync_queue_mutation could return another mutation's row id: the id
  came from a second trip to the shared connection. insert() reads it in
  the same job.
- save_to_cache switched foreign keys off on the shared connection
  across its awaits, so concurrent writes ran unchecked. The toggle now
  lives inside one writer job, and a page is one transaction instead of
  one commit per row.

Also: thumbnail LRU touches no longer block the lookup; unused
tokio-rusqlite dropped. Design and invariants in
docs/architecture/08-database-design.md (Connection ownership, Listing
query shape) and 03-data-flow.md.
This commit is contained in:
2026-09-24 03:58:04 +02:00
parent 1fb5f070c8
commit 21f24dd998
12 changed files with 1722 additions and 751 deletions
+266 -26
View File
@@ -17,9 +17,27 @@ use rusqlite::{Connection, Result as SqliteResult};
pub use db_service::{DatabaseService, RusqliteService};
use schema::MIGRATIONS;
/// Database connection wrapper with thread-safe access
/// How long a connection retries a locked database before erroring — covers a
/// reader meeting a WAL checkpoint, or the writer meeting a reader's snapshot.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// How many read-only connections serve queries alongside the writer. Reads
/// are short; a few cover a library page's parallel fetches plus background
/// work without holding many file handles.
const READER_CONNECTIONS: usize = 3;
/// The database: opened once at startup and owned for the life of the app.
///
/// All access goes through [`Database::service`], which hands out clones of one
/// [`RusqliteService`] — a writer thread plus a pool of read-only connections
/// (see `db_service`). Nothing else opens the database file.
pub struct Database {
/// The read-write connection. Owned by the service's writer thread; kept
/// here only for migrations (which run before that thread starts taking
/// work) and for tests.
#[cfg_attr(not(test), allow(dead_code))]
conn: Arc<Mutex<Connection>>,
service: RusqliteService,
path: PathBuf,
}
@@ -36,18 +54,36 @@ impl Database {
// Enable foreign keys
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
// Enable WAL mode for better concurrent access
// WAL lets the reader connections run alongside the writer.
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
// In WAL mode NORMAL is corruption-safe and skips the fsync FULL pays on
// every commit (a power cut can lose the last commits, an app crash
// cannot). On Android flash that fsync dominated every small write.
conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
conn.busy_timeout(BUSY_TIMEOUT)?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
let conn = Arc::new(Mutex::new(conn));
Self::migrate_connection(&conn, MIGRATIONS)?;
// Readers open after migrations, so they only ever see the final schema.
let readers = (0..READER_CONNECTIONS)
.map(|_| Self::open_reader(path))
.collect::<SqliteResult<Vec<_>>>()?;
Ok(Self {
service: RusqliteService::with_readers(Arc::clone(&conn), readers),
conn,
path: path.clone(),
};
})
}
// Run migrations
db.migrate()?;
Ok(db)
/// A connection that can only read. `query_only` makes an accidental write
/// routed to the pool fail loudly instead of racing the writer.
fn open_reader(path: &PathBuf) -> SqliteResult<Connection> {
let conn = Connection::open(path)?;
conn.busy_timeout(BUSY_TIMEOUT)?;
conn.execute_batch("PRAGMA query_only = ON;")?;
Ok(conn)
}
/// Open an in-memory database (for testing)
@@ -58,15 +94,16 @@ impl Database {
// Enable foreign keys
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
let conn = Arc::new(Mutex::new(conn));
Self::migrate_connection(&conn, MIGRATIONS)?;
// An in-memory database cannot be shared between connections, so this
// one has no reader pool: reads go through the writer.
Ok(Self {
service: RusqliteService::new(Arc::clone(&conn)),
conn,
path: PathBuf::from(":memory:"),
};
// Run migrations
db.migrate()?;
Ok(db)
})
}
/// Get connection (for testing)
@@ -75,11 +112,19 @@ impl Database {
Arc::clone(&self.conn)
}
/// Run all pending migrations.
/// Re-run all migrations against an open database (tests only; `open` runs
/// them before the service starts).
#[cfg(test)]
pub fn migrate(&self) -> SqliteResult<()> {
self.migrate_with(MIGRATIONS)
}
/// Test seam: inject a failing migration. See [`Self::migrate_connection`].
#[cfg(test)]
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
Self::migrate_connection(&self.conn, migrations)
}
/// Apply `migrations` in order, skipping ones `_migrations` already records.
///
/// **Each migration is one transaction, and the `_migrations` row is written
@@ -96,12 +141,16 @@ impl Database {
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
///
/// Split out from [`Self::migrate`] so tests can inject a failing migration.
/// Runs on the bare connection, before the writer thread takes it, so
/// tests can also inject a failing migration through `migrate_with`.
///
/// TRACES: UR-002 | DR-012 | UT-014
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
fn migrate_connection(
conn: &Mutex<Connection>,
migrations: &[(&str, &str)],
) -> SqliteResult<()> {
info!("Starting database migrations...");
let conn = self.conn.lock_safe();
let conn = conn.lock_safe();
// Create migrations table if it doesn't exist
debug!("Creating _migrations table if it doesn't exist...");
@@ -160,12 +209,10 @@ impl Database {
Ok(())
}
/// Get a database service for async-safe operations
///
/// This wraps all blocking database operations in spawn_blocking to prevent
/// freezing the async runtime.
/// A handle to the database service. Cheap: every call returns a clone of
/// the same writer thread and reader pool.
pub fn service(&self) -> RusqliteService {
RusqliteService::new(Arc::clone(&self.conn))
self.service.clone()
}
/// Get the database file path
@@ -880,4 +927,197 @@ mod tests {
assert_eq!(user_id, "user2");
assert_eq!(username, "recent_user");
}
/// A read must not queue behind a long write.
///
/// The database is in WAL mode precisely so readers can run alongside a
/// writer, but every query used to go through one connection behind one
/// mutex — so a big catalog-cache transaction stalled every library page,
/// thumbnail lookup and settings read in the app until it committed.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reads_do_not_wait_for_an_in_flight_write() {
use crate::storage::db_service::{DatabaseService, Query};
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
let service = db.service();
let writer = service.clone();
let write = tokio::spawn(async move {
writer
.transaction(|_tx| {
std::thread::sleep(Duration::from_millis(600));
Ok(())
})
.await
});
// Let the write take the connection first.
tokio::time::sleep(Duration::from_millis(100)).await;
let started = Instant::now();
let servers: i64 = service
.query_one(Query::new("SELECT COUNT(*) FROM servers"), |r| r.get(0))
.await
.unwrap();
let waited = started.elapsed();
assert_eq!(servers, 0);
assert!(
waited < Duration::from_millis(250),
"a read waited {waited:?} for an unrelated write to commit"
);
write.await.unwrap().unwrap();
}
/// Commit cost: in WAL mode `synchronous = NORMAL` is corruption-safe and
/// skips the per-commit fsync that FULL (the default) pays — on Android
/// flash that is the dominant cost of every small write. A busy timeout
/// lets the reader connections ride out a checkpoint instead of failing.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn open_configures_wal_for_interactive_use() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
let synchronous: i64 = conn
.query_row("PRAGMA synchronous", [], |r| r.get(0))
.unwrap();
let busy_timeout: i64 = conn
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
.unwrap();
assert_eq!(mode, "wal");
assert_eq!(synchronous, 1, "expected synchronous = NORMAL");
assert!(busy_timeout > 0, "expected a busy timeout");
}
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
/// `--ignored`.
#[test]
#[ignore]
fn write_bench_database() {
let Ok(path) = std::env::var("JELLYTAU_BENCH_DB") else {
return;
};
let _ = std::fs::remove_file(&path);
let db = Database::open(&PathBuf::from(&path)).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
conn.execute_batch(
"BEGIN;
INSERT INTO servers (id, name, url) VALUES ('srv', 'S', 'http://s');
INSERT INTO users (id, server_id, username) VALUES ('u', 'srv', 'u');
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('tv', 'srv', 'TV', 'tvshows');
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('music', 'srv', 'Music', 'music');",
)
.unwrap();
let now = "2026-09-23T00:00:00Z";
let mut item = conn
.prepare(
"INSERT INTO items (id, server_id, library_id, parent_id, name, sort_name, item_type,
series_id, season_id, album_id, synced_at)
VALUES (?1, 'srv', ?2, ?3, ?4, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.unwrap();
let none: Option<String> = None;
for s in 0..300 {
let series = format!("series-{s}");
item.execute(rusqlite::params![
series,
"tv",
none,
format!("Show {s}"),
"Series",
none,
none,
none,
now
])
.unwrap();
for n in 0..11 {
let season = format!("{series}-s{n}");
item.execute(rusqlite::params![
season,
"tv",
series,
format!("Season {n}"),
"Season",
series,
none,
none,
now
])
.unwrap();
for e in 0..24 {
let ep = format!("{season}-e{e}");
item.execute(rusqlite::params![
ep,
"tv",
season,
format!("A Title {e}"),
"Episode",
series,
season,
none,
now
])
.unwrap();
conn.execute(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('u', ?1, 5)",
[&ep],
)
.unwrap();
}
}
}
for a in 0..2000 {
let album = format!("album-{a}");
item.execute(rusqlite::params![
album,
"music",
none,
format!("Album {a}"),
"MusicAlbum",
none,
none,
none,
now
])
.unwrap();
for t in 0..12 {
item.execute(rusqlite::params![
format!("{album}-t{t}"),
"music",
album,
format!("Track {t}"),
"Audio",
none,
none,
album,
now
])
.unwrap();
}
}
for d in 0..400 {
conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, 'u', '/x', 'completed')",
[format!("series-{}-s1-e{}", d % 300, d % 24)],
)
.unwrap();
}
drop(item);
// No ANALYZE: the app never runs it, so the planner works without stats.
conn.execute_batch("COMMIT;").unwrap();
}
}