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:
+266
-26
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user