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
@@ -224,10 +224,11 @@ them:
the current user — local path, direct play, item id as media source (a download
names no source, so the server served its default, which carries the item's id)
— and `HybridRepository::get_playback_info` consults it **first**.
- **A slow cache read is waited for, never discarded.** The cache is one SQLite
connection behind one mutex, so any write in progress (the catalog sync that
starts at every launch, a download finishing) pushes a read past the 100 ms fast
path. `get_items`, the library list, genres and playlist items used to discard
- **A slow cache read is waited for, never discarded.** Reads no longer queue
behind writes (see [Connection ownership](08-database-design.md#connection-ownership)),
but a big query, a cold page cache or a busy reader pool can still push a read
past the 100 ms fast path. Such a read is raced against the server rather
than set aside (see [03-data-flow.md](03-data-flow.md)). `get_items`, the library list, genres and playlist items used to discard
such a read, wait for the server, and — offline — return its error over data on
disk; "More info" on a downloaded show failed that way. They now start the read
with `cache_try` (which keeps it running) and `settle` on it when the server