177 lines
8.3 KiB
Markdown
177 lines
8.3 KiB
Markdown
|
|
# ADR 0001: Embedded SQLite rather than a client/server database
|
|||
|
|
|
|||
|
|
- **Status:** accepted
|
|||
|
|
- **Date:** 2026-10-04
|
|||
|
|
- **Deciders:** platform team
|
|||
|
|
- **Applies to:** all deployments of the current major version
|
|||
|
|
|
|||
|
|
## Context
|
|||
|
|
|
|||
|
|
go-bananas stores themes. A theme is a slug, some metadata and a JSON document of
|
|||
|
|
design tokens, typically 10–60 kB. A useful instance has tens to a few thousand of
|
|||
|
|
them.
|
|||
|
|
|
|||
|
|
The workload shape is:
|
|||
|
|
|
|||
|
|
- **Reads:** constant and bursty. Every page view in every consuming application
|
|||
|
|
requests CSS. A service with ten consumer applications and a few hundred users
|
|||
|
|
can easily see thousands of stylesheet requests a minute, and 95%+ of them are
|
|||
|
|
the same handful of bytes served from a render cache.
|
|||
|
|
- **Writes:** rare. A theme is edited by a human. A busy week might be fifty writes.
|
|||
|
|
- **Total size:** a few megabytes. Even ten thousand large themes would fit in
|
|||
|
|
fifty.
|
|||
|
|
|
|||
|
|
The conventional answer for a service with this shape is PostgreSQL. That answer is
|
|||
|
|
right for almost every service and wrong for this one, for reasons worth writing
|
|||
|
|
down rather than rediscovering in a year.
|
|||
|
|
|
|||
|
|
## Decision
|
|||
|
|
|
|||
|
|
Use SQLite through `modernc.org/sqlite`, a pure-Go translation, on a local volume.
|
|||
|
|
|
|||
|
|
## Rationale
|
|||
|
|
|
|||
|
|
### What a client/server database would add
|
|||
|
|
|
|||
|
|
For this dataset, Postgres buys nothing and costs:
|
|||
|
|
|
|||
|
|
- **A network hop per query.** A local SQLite read is microseconds; a query over a
|
|||
|
|
pod network is a millisecond and a tail-latency distribution with a long right
|
|||
|
|
tail. For a stylesheet endpoint on the critical path of every page view, that
|
|||
|
|
tail is the difference between a fast site and a janky one.
|
|||
|
|
- **A connection pool to get wrong.** Every deployment needs its own tuning, and the
|
|||
|
|
failure mode of under-tuning is a saturated pool under load.
|
|||
|
|
- **Credentials to create, distribute, rotate and revoke.** Another secret in
|
|||
|
|
Kubernetes, another thing to get wrong.
|
|||
|
|
- **A schema migration process.** For a dataset that fits in a few megabytes, this
|
|||
|
|
is a disproportionate amount of ceremony.
|
|||
|
|
- **A second thing to operate.** Backups, upgrades, monitoring, incident response,
|
|||
|
|
for a component whose whole job is to hold some JSON.
|
|||
|
|
- **A second failure mode.** The database becoming unavailable is now the most
|
|||
|
|
likely way the theme service goes down.
|
|||
|
|
|
|||
|
|
### What SQLite gives up
|
|||
|
|
|
|||
|
|
Honesty requires stating the costs:
|
|||
|
|
|
|||
|
|
- **One writer at a time.** Correct at this write rate. A theme edit is a
|
|||
|
|
millisecond-scale transaction; even a hundred concurrent editors would not
|
|||
|
|
saturate it.
|
|||
|
|
- **Single-node.** This is the real constraint. See Scaling below.
|
|||
|
|
- **No network access.** A consuming application cannot query the database directly.
|
|||
|
|
It does not need to; the runtime API is the interface.
|
|||
|
|
|
|||
|
|
### Why this particular configuration is fast
|
|||
|
|
|
|||
|
|
The default SQLite configuration is not fast. These settings are what make it fast
|
|||
|
|
enough that the question does not arise:
|
|||
|
|
|
|||
|
|
| Setting | Effect |
|
|||
|
|
| --- | --- |
|
|||
|
|
| `journal_mode=WAL` | Readers never block the writer and the writer never blocks readers. This is the single most important setting. |
|
|||
|
|
| `synchronous=NORMAL` | In WAL mode this is durable across an application crash and risks only the last transactions on an operating-system crash. A lost publish is re-doable; an outage is not. |
|
|||
|
|
| `cache_size=-16000` | A 16 MiB page cache, which holds the entire dataset. |
|
|||
|
|
| `mmap_size=268435456` | The file is read through the page cache directly, removing a copy from the read path. |
|
|||
|
|
| `temp_store=MEMORY` | Sort and group-by spill stays in memory. |
|
|||
|
|
| Max 8 connections | SQLite serialises writers. A large pool adds contention, not throughput. This is counter-intuitive and correct. |
|
|||
|
|
| In-memory render cache | The steady-state runtime request performs **no database access at all**. |
|
|||
|
|
|
|||
|
|
The render cache is the decisive optimisation. Rendering a theme traverses its whole
|
|||
|
|
token document; serving the cached string is one map lookup. Because the cache key
|
|||
|
|
includes the theme version, publishing invalidates exactly one theme's entries and
|
|||
|
|
disturbs nothing else. The database is consulted only on a cache miss, which is a
|
|||
|
|
publish rather than a read.
|
|||
|
|
|
|||
|
|
### Why `modernc.org/sqlite` rather than `mattn/go-sqlite3`
|
|||
|
|
|
|||
|
|
`mattn/go-sqlite3` is a cgo binding to the C library. It is faster, and it means:
|
|||
|
|
|
|||
|
|
- `CGO_ENABLED=1`, so the release image needs a C toolchain at build time and libc
|
|||
|
|
at run time, and cross-compilation needs a cross-C toolchain;
|
|||
|
|
- a statically linked binary is impossible, so the image carries a C library whose
|
|||
|
|
CVEs need tracking;
|
|||
|
|
- `go install` from source fails on a machine without a compiler.
|
|||
|
|
|
|||
|
|
`modernc.org/sqlite` is a transpiled Go implementation: no cgo, a fully static
|
|||
|
|
binary, and cross-compilation is `GOOS=linux GOARCH=arm64` and nothing else. The
|
|||
|
|
performance difference is irrelevant here because the hot path is the in-memory
|
|||
|
|
render cache, not SQLite.
|
|||
|
|
|
|||
|
|
## Alternatives considered
|
|||
|
|
|
|||
|
|
**PostgreSQL.** The conventional choice. Rejected on the grounds above. It remains
|
|||
|
|
the right answer if the product changes shape — see Scaling.
|
|||
|
|
|
|||
|
|
**Redis.** Excellent for this read-heavy workload, and a good fit for a render
|
|||
|
|
cache. But it is another service to operate for a cache that is already
|
|||
|
|
process-local and loses nothing on restart, because the source of truth is the
|
|||
|
|
database. Rejected as unnecessary operational surface. Worth revisiting if a
|
|||
|
|
many-node deployment needs a shared cache; the render cache is an interface, so
|
|||
|
|
that change is contained.
|
|||
|
|
|
|||
|
|
**bbolt.** Faster than SQLite for pure key/value workloads and simpler. Rejected
|
|||
|
|
because the schema needs indexes for listing, filtering and sorting, and because
|
|||
|
|
SQLite's query planner handles the "a few hundred rows filtered by status and
|
|||
|
|
ordered by date" shape without the ceremony of managing bbolt buckets by hand.
|
|||
|
|
|
|||
|
|
**A JSON file per theme on disk.** No dependency at all. Rejected because concurrent
|
|||
|
|
writes need locking, and because every listing requires reading and parsing every
|
|||
|
|
file. SQLite is a better version of this idea that is already written.
|
|||
|
|
|
|||
|
|
**An in-memory store with write-through to object storage.** Attractive for scale.
|
|||
|
|
Rejected for v1: it removes the transactional guarantee that makes version history
|
|||
|
|
and rollback correct, and it adds an object-storage dependency. The write path
|
|||
|
|
here is not hot enough to justify losing the invariant.
|
|||
|
|
|
|||
|
|
## Scaling
|
|||
|
|
|
|||
|
|
SQLite on a local volume is single-node. Two shapes are supported:
|
|||
|
|
|
|||
|
|
1. **One replica, one volume.** The recommended production shape. Horizontally
|
|||
|
|
scalable only in the sense that the service is so cheap that one replica serves
|
|||
|
|
a very large number of consumers.
|
|||
|
|
|
|||
|
|
2. **Many replicas, a shared filesystem.** Works when the storage class supports
|
|||
|
|
concurrent mounts — NFS, CephFS, or a ReadWriteMany block device. WAL mode
|
|||
|
|
requires the filesystem to support shared memory mapping, which these do. The
|
|||
|
|
Helm chart warns when a ReadWriteOnce volume is combined with more than one
|
|||
|
|
replica, because that combination cannot work.
|
|||
|
|
|
|||
|
|
If the workload ever outgrows both — genuinely concurrent multi-writer access across
|
|||
|
|
nodes — the migration is to Postgres. It is contained: `internal/store` is the only
|
|||
|
|
package that writes SQL, and its interface is defined by the methods it exposes
|
|||
|
|
rather than by SQLite's features.
|
|||
|
|
|
|||
|
|
## Consequences
|
|||
|
|
|
|||
|
|
**Good**
|
|||
|
|
|
|||
|
|
- One less thing to operate, back up, monitor and secure.
|
|||
|
|
- No credential to leak.
|
|||
|
|
- A static binary and a small image with no libc.
|
|||
|
|
- A backup is `cp` of one file. There is no consistent-snapshot dance.
|
|||
|
|
- The render cache can be process-local because there is only one process.
|
|||
|
|
|
|||
|
|
**Bad**
|
|||
|
|
|
|||
|
|
- Cannot scale horizontally past one node without a shared filesystem.
|
|||
|
|
- A shared-filesystem deployment is slower than local disk, and WAL over NFS is
|
|||
|
|
sensitive to latency. Measure before choosing it.
|
|||
|
|
- Single-writer means a pathological write loop would queue. Nothing in the
|
|||
|
|
application can produce one.
|
|||
|
|
|
|||
|
|
**Neutral**
|
|||
|
|
|
|||
|
|
- The `store` package has a wider interface than a repository might otherwise need,
|
|||
|
|
because it must express listing, filtering and sorting. Moving to another engine
|
|||
|
|
means reimplementing that surface.
|
|||
|
|
|
|||
|
|
## Revisit when
|
|||
|
|
|
|||
|
|
- A single instance cannot serve the load, and profiling shows the database rather
|
|||
|
|
than the render cache is the bottleneck.
|
|||
|
|
- Concurrent writers become a real requirement, for example if themes can be edited
|
|||
|
|
by an automated pipeline from several deployments.
|
|||
|
|
- The dataset outgrows a few hundred megabytes, at which point the time to back up
|
|||
|
|
by copying a file becomes its own problem.
|