Files
mangaBookmark/docs/adr/0001-postgresql-over-sqlite.md
T
sulthan 7a0c190ebe docs: record the domain model and the Postgres/OAuth ADRs
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
2026-08-08 06:43:38 +07:00

2.4 KiB
Raw Blame History

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.