Repository navigation
Read SQLite through a separate read-only pool - #603
Conversation
SQLite used one connection for everything, so request lookups queued behind the per-minute stats queries, dashboard pages and eviction scans. Writes keep the single connection. Get, Select, Query and QueryRow now go to a pool of query_only connections (GOMAXPROCS, 2 to 8), which WAL mode lets run alongside the writer. A write sent to that pool fails. Pragmas move into the DSN so every connection gets them. Postgres and in-memory SQLite keep one pool for both.
isMemoryPath missed URIs such as file::memory:?cache=shared, whose readers would each have got an empty database. New tests open a database left in rollback-journal mode and run writes, hit flushes, lookups and stats scans concurrently.
|
I ran a load test against
Same load, 4,500 lookups/s, two runs each (they agreed closely):
At 2,000 and 8,000 lookups/s the picture is the same. The WAL was checkpointed to 0 on close in every run. Unthrottled, the branch did 29k lookups/s against 19.5k on main. New artifacts then nearly stopped: 3/s on the branch against 112/s on main, and the WAL grew to about 500 MB. The cause is the hit flush. It writes all hits since the last flush in one transaction, here about 29k UPDATEs, which holds the single writer. Without hits, the branch did 48k lookups/s with all 200 writes/s and a 13 MB WAL. At equal load both versions flush the same number of hits, so this only shows up at lookup rates a proxy on 2 CPUs won't reach once HTTP and storage are involved. I also tried flushing hits in transactions of 500. Unthrottled it only reached 29 writes/s, and at 4,500 lookups/s it was worse: 151 writes/s, write p99 36 ms instead of 9 ms, and a 64 MB WAL. So it isn't in this PR. |
SQLite ran on one connection (
SetMaxOpenConns(1)), so every request lookup (GetCachedArtifact,GetMetadataCache, ...) waited for whatever else held it: the per-minuteGetCacheStatsandGetEcosystemStatsfrom the metrics updater, the dashboard and analytics pages, eviction scans and all writes. At 300k artifactsGetEcosystemStatstakes about 600 ms, and a lookup that arrives during it waits that long.Writes keep the single connection. Reads now go to a separate pool of read-only connections,
min(max(GOMAXPROCS, 2), 8)of them, which WAL mode lets run alongside the writer.Routing is by method on
DB:Get,Select,Query,QueryRowand theirContextforms use the reader.Exec,Beginx/BeginTxx,Conn,Preparexand everything else on the embedded*sqlx.DBuse the writer. Reads inside a transaction go through thetx, so they stay on the writer and see the transaction's own writes.No query in the package writes through
Get/Select/Query(noRETURNINGis used anywhere). The reader connections are opened with_query_only=1, so if one is added later it fails with "attempt to write a readonly database" instead of running on the wrong connection.Pragmas moved into the DSN so each connection gets them, including one database/sql reopens: writer
_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL, readers_busy_timeout=5000&_query_only=1. modernc.org/sqlite v1.60 accepts these mattn-style keys, so the old?_busy_timeout=5000did apply. The schema has no foreign keys, soforeign_keysis not set, as before. Open pings the writer first so WAL is on before a reader opens the file.Postgres keeps one pool for both.
:memory:SQLite does too, since a second connection would see its own empty database.Closecloses the readers, then the writer, so the writer checkpoints the WAL as the last connection.Benchmarks (
internal/database/read_pool_bench_test.go, 300k artifacts, 100k versions, 30k packages, Apple M5 Pro, GOMAXPROCS 18,-count=3).one-connectionroutes reads to the writer, as before.GetCachedArtifactfrom parallel goroutinesGetCachedArtifactwhile stats run in a loop, meanThe parallel case doesn't scale further because random lookups over the whole table mostly miss each connection's 2 MB page cache and read pages with a syscall.
mmap_sizeon the readers took it to about 8 µs/op in a quick try; I left that out of this PR.Tests (
internal/database/read_pool_test.go):INSERT ... RETURNINGthroughGetboth fail and leave no row;busy_timeout,journal_mode,synchronous/query_only;:memory:and Postgres keep one pool;Closecloses both pools.go test ./... -count=1,go test -raceforinternal/database,internal/handlerandinternal/server, the Postgres job's packages against postgres:17, and golangci-lint pass.To check: