7a0c190ebe
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
41 lines
2.4 KiB
Markdown
41 lines
2.4 KiB
Markdown
# 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/store` is rewritten: `?` → `$N`, `IS NOT` →
|
||
`IS DISTINCT FROM` (this one is load-bearing; it implements the `updated_at`
|
||
ordering rule), `pragma_table_info` → `information_schema.columns`,
|
||
`INTEGER`/`REAL` → `bigint`/`double precision`, `favorite` int-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 for `go test ./...`. This is the
|
||
permanent cost of the decision and the main reason it was close.
|
||
- Backups get worse, not better: `VACUUM INTO` produced one self-contained file;
|
||
restoring now means `pg_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.
|