perf(db): one logical container per item, indexed; no whole-table reads
Listings matched children on four columns at once (parent_id, album_id, season_id, series_id) because Jellyfin's ParentId is the storage parent, not the logical one. The OR defeated the planner into a full scan, and it was wrong: every episode carries its series id, so a series listed all its episodes beside its seasons (on both the browse and Downloads surfaces). - Migration 027 adds items.container_id, a VIRTUAL generated column (episode -> season/series/parent, season -> series, track -> album, else parent) indexed with (sort_name, name), so a listing is one ordered index range and every write path is covered untouched. - Containers never cached (an episode that arrived via Next Up) get placeholders named from the child's own fields, in the migration and on every cache write, so offline navigation stays series -> season. - The six queries that built the set of every downloaded item in a CTE (get_item, latest, recently played, search, favourites, by-person, Downloads) now check availability per row with one shared predicate. - PRAGMA optimize at open gives the planner statistics. Benchmark (~110k items, desktop): series listing ~80 ms -> <1 ms; migration 027 upgrades that database in ~0.1 s (0.2 s on the Fairphone). Tests first: a series listing its episodes, and the Downloads series drill, both failed before the change.
This commit is contained in:
@@ -65,6 +65,18 @@ impl Database {
|
||||
let conn = Arc::new(Mutex::new(conn));
|
||||
Self::migrate_connection(&conn, MIGRATIONS)?;
|
||||
|
||||
// Planner statistics. Without them SQLite guesses between indexes, and
|
||||
// guessed badly for the listing query (see 08-database-design.md →
|
||||
// "Listing query shape"). `optimize` only analyses what is missing or
|
||||
// stale; `analysis_limit` bounds each table's scan so this stays in the
|
||||
// milliseconds on a large catalogue. Failure is not fatal.
|
||||
if let Err(e) = conn
|
||||
.lock_safe()
|
||||
.execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
|
||||
{
|
||||
error!("PRAGMA optimize failed: {}", e);
|
||||
}
|
||||
|
||||
// Readers open after migrations, so they only ever see the final schema.
|
||||
let readers = (0..READER_CONNECTIONS)
|
||||
.map(|_| Self::open_reader(path))
|
||||
@@ -1000,6 +1012,55 @@ mod tests {
|
||||
assert!(busy_timeout > 0, "expected a busy timeout");
|
||||
}
|
||||
|
||||
/// The planner gets statistics: the app used to never run `ANALYZE`, so
|
||||
/// SQLite guessed between indexes — and for the listing query guessed the
|
||||
/// `server_id` index, which every row shares, turning an index lookup into
|
||||
/// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
|
||||
/// whatever statistics are missing or stale, bounded by `analysis_limit`.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[test]
|
||||
fn open_gives_the_planner_statistics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("jellytau.db");
|
||||
{
|
||||
let db = Database::open(&path).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..2000 {
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
|
||||
[format!("i{i}")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
let analysed: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(analysed, 1, "the planner has no statistics");
|
||||
let items_stats: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(items_stats > 0, "no statistics for items");
|
||||
}
|
||||
|
||||
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
|
||||
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
|
||||
/// `--ignored`.
|
||||
|
||||
Reference in New Issue
Block a user