Document moving to PostgreSQL, and when it is worth it

The section leads with why SQLite is still the default rather than with how to
leave it: one file, no server, and a request path that barely touches it. Two
things change that, and both are recent — conversations and notes read and
write on every turn, and a second control plane is impossible while the store
is a file on one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5 committed 2026-09-25 16:29:42 +02:00
1 parent b6de469351
commit fa60b81f37
2 files changed
+90

No files matched your search

+44
View File
@@ -17,6 +17,50 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
### PostgreSQL
The store runs on PostgreSQL, chosen by what the address looks like: a path is
still SQLite, a `postgres://` URL is not, and `@` followed by a path reads the
address out of a file so a DSN carrying a password stays out of a configuration
that gets copied and pasted into issues. Every existing installation keeps
working untouched.
One set of queries rather than one per database, because the SQL here is almost
entirely portable and two copies would drift one query at a time. The rewrite
of placeholders is a wrapper around the handle rather than a change at sixty
call sites — which found six places reaching past it, including all three
manual transactions and two prepared statements, every one of which worked on
SQLite and would have failed on PostgreSQL.
Exactly one query is not the same sentence in both: the note search, MATCH
against `@@`. SQLite gets full-text search from an FTS5 table kept in step by
three triggers; PostgreSQL derives a tsvector from the row, so there is nothing
to keep in step and no trigger to forget. It uses the 'simple' configuration
rather than English — these are an agent's own notes, in whatever language it
chose, and stemming English rules over Turkish would quietly stop finding
things.
`llmgw keys move-store` copies an existing store rather than starting a fresh
one, because keys are stored hashed: a migration that drops them cannot
recreate them, and every person and every script would need a new one.
Testing it against a real database found three things nothing else would have.
`schema_migrations` is created in Go rather than in a migration file, so it
escaped the port and still said INTEGER — 64 bits in SQLite, 32 in PostgreSQL,
and a millisecond timestamp does not fit. The copy order followed the order the
migrations create tables in, and `users` is created after `api_keys` even
though `api_keys` references it. And `key_claims.sealed` is emptied the moment
a claim is used, so a claimed row holds an empty blob in a NOT NULL column — an
empty blob scans as a typed nil slice and a real NULL as an untyped one, a
difference pgx cannot see, so it arrived as NULL.
The driver is pgx, which is pure Go: the whole binary is built with
CGO_ENABLED=0 so that it runs on a node whose libc is older than the builder's,
and a cgo driver would take that away from every deployment, including the ones
staying on SQLite.
## v0.7.0 — 2026-09-25
What a cluster needs to be lived with rather than demonstrated: somewhere to
+46
View File
@@ -595,6 +595,52 @@ fleet means the measurement is missing, not that the fleet is idle. And
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
which is what a binary built without the nvml tag reports.
## PostgreSQL, when SQLite is no longer the right shape
SQLite is the default and stays the right answer for most installations: one
file, no server, and a request path that barely touches it — usage is written
in batches, budgets are counted in memory, authentication is cached.
Two things change that. Conversations and agent notes read and write on every
turn, which is the first per-request load this store has had; and SQLite takes
one writer at a time, so those turns serialise. And a second control plane is
impossible while the store is a file on one machine's disk.
The setting is the same one, and the address decides:
```yaml
auth:
store: "@/etc/llmgw/secrets/store-dsn"
```
A path is SQLite, a `postgres://` URL is PostgreSQL, and `@` followed by a path
reads the address out of that file — which is how a DSN carrying a password
stays out of a configuration that gets copied, diffed and pasted into issues.
Keep that file at `0600` and owned by the user llmgw runs as.
Moving an existing installation is a copy, not a fresh start. Keys are stored
hashed, so starting fresh means reissuing every one of them and being unable
to say which was which:
```
sudo systemctl stop llmgw-server
llmgw keys move-store --from /var/lib/llmgw/llmgw.db --to "@/etc/llmgw/secrets/store-dsn"
# point auth.store at the new address, then
sudo systemctl start llmgw-server
```
Stop the gateway first. The copy makes no attempt to follow a store being
written to: rows landing after the table they belong to has been passed would
simply be absent, and nothing would say so. The destination must be empty, and
the source is left untouched — so the way back is to point the configuration at
the file again.
On a machine that also runs the control plane, PostgreSQL's defaults assume it
owns the box. A gateway swapped out to give the database cache is a worse
outcome than a smaller cache, so cap it: `shared_buffers = 96MB` and
`max_connections = 40` are enough for a store whose working set is measured in
megabytes.
## Conversations across models
Off everywhere until a project asks. llmgw otherwise stores no message content