Written while scoping #18. CONTEXT.md pins the ubiquitous language (Series, Reader, Bookmark, Progress, Latest Chapter, Poll) that the schema split and the ordering rule are argued in; the four ADRs record the decisions that follow from it, starting with Postgres over SQLite. Refs #18
2.4 KiB
Postgres replaces SQLite as the primary datastore
Status: accepted
The project is moving from a single-reader tracker to a service published to a community,
so we replaced modernc.org/sqlite with Postgres (jackc/pgx/v5, still pure Go, so
CGO_ENABLED=0 and the distroless image are unaffected). The deciding reason is future
supportability — managed hosting, a datastore that survives the app outgrowing one box —
not concurrency, which was measured and found to be a non-issue.
Considered options
Stay on SQLite. Benchmarked against the real store at 1,500 rows (≈50 readers × 30
series): ~9,700 upserts/sec single-writer, plateauing at ~780/sec under 8–50 concurrent
writers, with List() holding 90–103 calls/sec under continuous write load. Projected
real load at 50 readers is ~0.02 writes/sec — roughly four and a half orders of magnitude
of headroom. SetMaxOpenConns(1) serialises writes but was shown not to starve reads;
an apparent read collapse traced to row count and per-row scanning, not lock contention.
SQLite would have worked. It was rejected for where the project is going, not for what
it does today.
Postgres. Chosen. Migrating is cheapest now — 29 rows in one table — and gets materially harder once there are live readers and a multi-tenant schema.
Consequences
- Every statement in
internal/storeis rewritten:?→$N,IS NOT→IS DISTINCT FROM(this one is load-bearing; it implements theupdated_atordering rule),pragma_table_info→information_schema.columns,INTEGER/REAL→bigint/double precision,favoriteint-as-bool →boolean. - ~75 tests currently get a free isolated database from
t.TempDir(). They now need a live server, which makes Docker a hard prerequisite forgo test ./.... This is the permanent cost of the decision and the main reason it was close. - Backups get worse, not better:
VACUUM INTOproduced one self-contained file; restoring now meanspg_dump/pg_restore, a role, and a password. - A second stateful container joins the VPS alongside the existing headless-shell.
- Postgres does not address the real scaling limit. At batch 14 per 10-minute tick the poller checks at most 84 series/hour; 50 readers × 30 series is 1,500 bookmarks, an 18-hour sweep against a configured 1-hour cooldown. That ceiling is an outbound fetch budget and is fixed by deduplicating polls per Series, not by the datastore.