Skip to content

Read SQLite through a separate read-only pool - #603

Merged
andrew merged 2 commits into
git-pkgs:mainfrom
simonchrz:perf/sqlite-read-pool
Oct 25, 2026
Merged

andrew merged 2 commits into
git-pkgs:mainfrom
simonchrz:perf/sqlite-read-pool

Conversation

@simonchrz

Copy link
Copy Markdown
Contributor

SQLite ran on one connection (SetMaxOpenConns(1)), so every request lookup (GetCachedArtifact, GetMetadataCache, ...) waited for whatever else held it: the per-minute GetCacheStats and GetEcosystemStats from the metrics updater, the dashboard and analytics pages, eviction scans and all writes. At 300k artifacts GetEcosystemStats takes 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, QueryRow and their Context forms use the reader.
  • Exec, Beginx/BeginTxx, Conn, Preparex and everything else on the embedded *sqlx.DB use the writer. Reads inside a transaction go through the tx, so they stay on the writer and see the transaction's own writes.

No query in the package writes through Get/Select/Query (no RETURNING is 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=5000 did apply. The schema has no foreign keys, so foreign_keys is 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. Close closes 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-connection routes reads to the writer, as before.

one connection read pool
GetCachedArtifact from parallel goroutines 25.0 µs/op 12.7 µs/op
GetCachedArtifact while stats run in a loop, mean 60 ms 28 µs
same, slowest lookup 493-508 ms 0.35-8.9 ms

The 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_size on 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):

  • a write through the reader pool and an INSERT ... RETURNING through Get both fail and leave no row;
  • holding every connection of both pools at once, each has the expected busy_timeout, journal_mode, synchronous/query_only;
  • a lookup completes while a reader connection is held by an open read and the writer has an open transaction, and does not see the uncommitted row (with one shared connection this test fails within 5 s rather than hanging);
  • a write is visible to the next read on the other pool;
  • :memory: and Postgres keep one pool; Close closes both pools.

go test ./... -count=1, go test -race for internal/database, internal/handler and internal/server, the Postgres job's packages against postgres:17, and golangci-lint pass.

To check:

  • Reads now see committed data from another connection instead of the same one. I found nothing that reads outside a transaction and expects that transaction's uncommitted writes (that would have deadlocked on one connection).
  • Long reads keep WAL frames from being checkpointed until they finish, so the WAL can grow during a stats or eviction scan.
  • Each reader has its own page cache (default 2 MB), up to 16 MB more than before.

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.
@simonchrz

Copy link
Copy Markdown
Contributor Author

I ran a load test against internal/database in a Linux container (2 CPUs, 1 GB). It used the same test file on main and on this branch, with a database of 300k artifacts. Each run lasted 60 s with:

  • 8 lookup loops, each lookup also recording a hit (hits batched every 1 s);
  • 200 new artifacts per second;
  • the stats queries every 10 s.

Same load, 4,500 lookups/s, two runs each (they agreed closely):

main this branch
lookups reached 3,537/s (can't keep up) 4,477/s
lookup p99.9 170 ms 1.3 ms
lookup max ~500 ms 10–22 ms
writes 161/s 164/s
write max ~405 ms ~200 ms
WAL max 44 MB 48 MB
errors 0 0

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.

@andrew
andrew merged commit 91d0df7 into git-pkgs:main Oct 25, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants