ADR 0014: PostgreSQL is chosen by the URL’s scheme, over one set of queries
Status
Accepted.
Context
SQLite across processes is safe on one local disk and not across hosts. The
role split (ADR 0007) lets acme, admin and
worker run as separate processes, but only as separate processes on one
filesystem, so the deployment page promised PostgreSQL for as long as it did
not exist.
Two earlier decisions had already paid for most of it. ADR
0003 keeps the pool private to
crates/store/, so SQL and the dialect it is written in live in one crate.
ADR 0004 chose UUID v7 partly because a v4 primary
key costs PostgreSQL a page split and a full-page WAL write per row. What was
left was a real fork: two pool types, two row types, two parameter syntaxes,
and around 350 bind sites.
The obvious answers were both bad. sqlx::Any cannot carry a Uuid at all —
its type set is Null/Bool/SmallInt/Integer/BigInt/Real/Double/
Text/Blob — and it does not translate SQL. Writing every statement twice
doubles the SQL and guarantees the two copies drift, which is the one failure
mode nothing here would catch: a query used by an operator listing can be wrong
for months.
What made a single set of queries possible is that almost nothing in this
schema is dialect-specific to begin with. Every timestamp is an epoch-second
integer, so there is no strftime, julianday, datetime() or date type
anywhere. There is no CAST, no || concatenation, no LIKE, no
GROUP_CONCAT, no IFNULL, no CTE and no window function. RETURNING,
ON CONFLICT … DO NOTHING, DO UPDATE … excluded.*, partial unique indexes
and bound LIMIT/OFFSET are already spelled the way both accept.
Decision
- The scheme of
database.urlpicks the backend, atDatabase::open.sqlite:creates the file;postgres:/postgresql:expects the database to exist, because creating one is an operator’s act and not something a server does to a cluster it was pointed at. Any other scheme is refused by name. - One set of queries, in
crates/store/src/sql.rs. A statement is asql::Querycarrying its SQL and aVec<Value>until asql::Execsays which driver is on the other end.sql::Rowhides which row came back andsql::Builderreplacessqlx::QueryBuilder. Nothing outside that module names either driver. - Statements keep
?and the seam rewrites to$1…$n. Writing$nin the source would have worked on both — sqlx’s SQLite driver parses a$Nmarker and binds argumentN— but three things here build SQL by concatenation: thelive_certificate!predicate spliced into the middle of three statements, theIN (?, ?, …)lists expanded per element, and theformat!ed fragments injob::claim_nextandjob::settle. Every number would then be a hand-maintained constant. One rewrite at the edge cannot drift. - An absent value carries the type it would have had. SQLite has no typed
null; PostgreSQL sends a type OID per parameter and refuses
column "eab_kid" is of type uuid but expression is of type bigint. Every bind site knows the type statically, soValue::Null(NullKind)costs nothing and is declared nowhere twice. - Three things fork, and each asks
Dialect. The identifier search (json_each/json_extract/instragainstjsonb_array_elements/->>/strpos); the unique-violation matchers; and the_sqlx_migrationsprobe, which asked by reading the table and swallowing the error, where on PostgreSQL a failed statement aborts the surrounding transaction. Nothing else may fork without a line here. strpos, neverposition(needle in haystack). It takes its arguments the other way round, so onepush_bindsequence would bind the two dialects in different orders — a wrong answer rather than an error.- Two migration sets, both append-only.
migrations/for SQLite, frozen since 0.1.0;migrations-postgres/from its own first release. The PostgreSQL set is not a transcription: the SQLite files carry three table rebuilds that exist only because SQLite cannot add aCHECK, aUNIQUEor a foreign key to an existing table, plus a text-to-blob id conversion, and no PostgreSQL deployment has that history to replay. Every declared width is transcribed literally. - A unique constraint PostgreSQL must name is named in the migration. SQLite reports the offending columns and gives sqlx no constraint name; PostgreSQL reports the constraint and never the columns. A matcher passes both spellings, so the index name is part of the schema rather than whatever the server happened to generate.
- A database is one backend or the other, and
acme-proxy transferis the way across. Not a dual-write mode and not a sync: an offline copy of every row, refused unless the target is migrated and empty. It exists because the order row is a certificate’s only record — a deployment that moved to PostgreSQL by starting empty would leave every certificate it had issued impossible to revoke, which is the outcomelive_certificates_refusalexists to prevent. The copy is driven by a declared column manifest rather than by reading the source’s shape, because the seam decodes into a known Rust type and “read this column as whatever it is” would mean deciding at runtime whether SQLite’s untyped BLOB is abyteaor auuid. The manifest’s own hazard — a column added to the schema and forgotten here — is answered the way ADR 0003 answers it for a table rebuild: by introspecting the live schema and refusing a manifest that has drifted. - The database URL is redacted wherever it is printed. A DSN carries
user:password@; the startup log line and theSIGHUPrefusal both go throughlogfields::redact_url.
Consequences
- One binary and one container image serve both, and a deployment moves from
SQLite to PostgreSQL by changing one key and running
acme-proxy transfer. What that command cannot check is that the source is stopped, so it says so in its prompt: a copy taken while a worker is issuing is a torn snapshot that looks exactly like a good one. Txno longer derefs toSqliteConnection.tx.conn()is what&mut *txwas, andJobQueue::enqueue_intakes asql::Exec— the one SQLite-typed signature that had leaked outsidecrates/store/.- A declared
VARCHAR(n)is now enforced. SQLite ignores the width, which is hownonces.valuestayedVARCHAR(36)after the nonce became a 43-character token; on PostgreSQL that would have rejected every nonce the server mints. The width pins indb.rsare what keep the two honest. sqlx/postgresbrings RustCrypto (sha2,hmac,md-5,stringprep) for SCRAM-SHA-256. That is a second crypto stack in the graph, which ADR 0009 argues against; its clause is narrowed rather than worked around, because there is no configuration of the driver that avoids it. TLS stays onring(tls-rustls-ring), and sqlx builds itsClientConfigwithbuilder_with_providerrather thaninstall_default, so the “nothing installs process-global state” rule is untouched.- PostgreSQL gives sqlx an advisory migration lock, which SQLite does not. The one-owner rule in ADR 0003 is therefore belt and braces there rather than load-bearing — it stays, because the rule is about which process may own the schema, not only about the race.
- The coverage floor cannot see this backend: a dialect arm not taken is not an
uncovered line. That is what the CI job and its
REQUIREguard are for.
Enforced by
- The whole
acme-proxy-storesuite, on both backends. Every test there callsDatabase::connect_for_test(), which is PostgreSQL whenTEST_POSTGRES_URLnames one — the coverage that foundMAX(x, 0), SQLite’s scalar two-argument max, in a path no dialect-specific test would have singled out.connect_in_memory()means SQLite, and is the opt-out for the seven tests that are about SQLite. tests/postgres.rs, which runs the dialect-sensitive paths against both backends, andpostgres_is_available_when_it_is_required, which fails rather than skips whenACME_PROXY_REQUIRE_POSTGRESis set.transfer::tests::the_manifest_names_every_columnand…every_table, against the live schema on whichever backend is running, plusa_database_survives_a_round_trip_through_the_other_backend, which seeds all fifteen tables and compares values after a copy out and back.- The
postgresjob in.github/workflows/ci.yml, which sets that variable and also runsrolesandreload— several processes over one database, which is the deployment this exists for. sql::tests::numbering_is_contiguous_from_oneand the literal-skipping cases beside it.production_code_never_reaches_the_raw_poolandonly_the_schema_owners_apply_migrations(tests/layering.rs), unchanged.declared_token_widths_match_random_token,declared_issuer_widths_match_the_issuer_idandevery_id_column_is_declared_a_blob(crates/store/src/db.rs).logfields::tests, for the redaction, andreload::tests::a_refusal_over_a_dsn_keeps_the_host_and_drops_the_password.