diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b5233..94a6437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/install.md b/docs/install.md index 1d9a01f..79b1d0a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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