From c9b3048a2051c652688ab76609a3d038bdc05126 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Tue, 6 Oct 2026 01:51:56 -0400 Subject: [PATCH] perf(database): bound the read pool's mapping and cache by measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #283 read as "64 MB of mmap and up to 32 MB of page cache per pool", and it is — but on a 50 000-track library neither is what the process's memory is: the RSS of the database mapping is 46 MB whether the bound is 64 MB or 16 MB, because a mapping costs what the working set touches. Quartering it and halving the page cache moved no query either: 1 172 → 1 197 ms for the whole track list, 106 → 124 ms for the album list, 6 → 8 ms for an FTS search, all inside the run-to-run spread. Kept, because a bound that costs nothing measurable is worth having on a phone: five read connections' worth of mapping is 320 MB of address space at 64 MB each against 80 MB here, and #52 was a low-memory kill. Closes #283 --- backend/database/database.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/database/database.go b/backend/database/database.go index a82fa72..893eea8 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -54,9 +54,19 @@ type DB struct { // form is silently ignored, which is why WAL was never actually on. const ( writeDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" - readDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + + // The read pool's own page cache and mapping bound, sized by + // measurement rather than by appetite (#283). On a 50 000-track + // library, halving the cache and quartering the mapping moved no + // query — 1 172 → 1 197 ms for the whole track list, 106 → 124 ms + // for the album list, 6 → 8 ms for an FTS search, all inside the + // run-to-run spread — and the RSS of the database mapping was 46 MB + // under both settings, because a mapping's cost is what the working + // set touches, not the bound. The bound is what matters on a + // phone, where five connections' worth of address space is the + // thing the low-memory killer reads (cf. #52). + readDSNParams = "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + "&_pragma=query_only(true)&_pragma=synchronous(NORMAL)" + - "&_pragma=cache_size(-8000)&_pragma=mmap_size(67108864)" + "&_pragma=cache_size(-2000)&_pragma=mmap_size(16777216)" // readPoolConns bounds concurrent read connections. A handful is // plenty for interactive search + art/lookup fan-out and keeps WAL // reader overhead small. @@ -354,8 +364,8 @@ func applyPRAGMAs(ctx context.Context, db *sql.DB) error { pragmas := []string{ "PRAGMA foreign_keys = ON", "PRAGMA synchronous = NORMAL", - "PRAGMA cache_size = -8000", - "PRAGMA mmap_size = 67108864", + "PRAGMA cache_size = -2000", + "PRAGMA mmap_size = 16777216", } for _, pragma := range pragmas {