ADR-0004: PostgreSQL metadata and S3-compatible objects, behind the existing seams¶
- Status: accepted
- Date: 2026-10-17
- Amends: ADR-0002 (migration path, steps 2 and 3) and realizes the storage choices of ADR-0001 §3, §5 and §10
Context¶
ADR-0002 shipped the engine on SQLite and a filesystem store and promised PostgreSQL and S3 later, "extracting the trait from the concrete surface". A network server needs both: several processes and hosts on one repository, no local disk as the source of truth. The guarantees must not weaken — in particular the stream compare-and-swap with policy evaluated on facts read inside the committing transaction (it relies on a stable view while the transaction runs), and garbage collection that cannot delete an object a writer has just re-stored.
Decisions¶
- One SQL, two dialects — not a domain trait. ADR-0002 expected a trait extracted from the surface. The
surface is over a hundred methods on
Tx; a trait over them would be written twice for no gain. Instead there is a thin seam below them (meta/db.rs):Conn::execute/querywith typedParam/Row, and every query is written once in the common subset of SQLite and PostgreSQL (?Nplaceholders rewritten to$N,ON CONFLICT,RETURNING id,BIGINTonly,lower(x) LIKE lower(?)where SQLite's case-insensitiveLIKEis relied upon). Differences live in the migrations, in how a transaction is opened, and in oneFOR UPDATE SKIP LOCKED. The domain API (Meta,Tx) did not change. - Writers are serialized by an advisory lock, under
READ COMMITTED. SQLite'sBEGIN IMMEDIATEgives "the one writer, reading the latest state". PostgreSQL's equivalent ispg_advisory_xact_lock(key(schema))as the first statement of a write transaction. The isolation level matters:REPEATABLE READ/SERIALIZABLEwould pin the snapshot at that first statement — before the lock is granted — and read stale data; atREAD COMMITTEDevery statement after the lock sees the previous writer's commit. Reads useREPEATABLE READ READ ONLY(a snapshot, no locks). Rejected: serializable transactions with retry (the engine's single-writer reasoning would turn into abort storms), and a row lock on the stream row (policy facts also live in other tables). The stream CAS is still a conditionalUPDATE, so it stays correct even if some writer skipped the lock. A lock wait is bounded (lock_timeout, 30 s, like SQLite'sbusy_timeout) and expires as a retriable error. - Failure is never silent. A lost connection is
MetaError::ConnectionLost, flagged unknown outcome when it strikes duringCOMMIT(the engine's answer is the existing "ask by operation id"). A statement error swallowed by the closure turns into a rollback, because PostgreSQL would otherwise commit nothing while the caller sees success. A connection that was in a transaction when its thread unwound is discarded, never reused. - One schema per repository. The schema is the unit of isolation and of the lock key;
search_pathis set to that schema alone. Migrations run in one transaction under another advisory lock, so any number of starting processes is safe, and a newer schema is refused.Repository::opennever creates a schema (a dropped schema or a wrong URL must not silently become an empty repository); onlyinitdoes. - Byte-wise text, 64-bit integers. PostgreSQL text columns are
COLLATE "C"and integersBIGINT, so ordering, range scans (id >= p AND id < p || 'g') and arithmetic equal SQLite's. Sequences (BIGSERIAL) can leave gaps after a rollback; order and uniqueness hold because writers are serialized. - Numbered migrations in
migrations/{sqlite,postgres}/, compiled in withinclude_str!; a test fails if a file is not listed or the backends disagree. Schema 2 adds thejobstable ("PostgreSQL-backed queue", ADR-0001 §10) for both backends. - Object store: conditional create, server-side refresh, hash on every read. A hand-written SigV4 client over
ureq+ rustls (no SDK, no async runtime in the object path) creates objects withIf-None-Match: *; for an existing object it verifies, and refreshes the age withCopyObjectonto itself withREPLACEmetadata. A service that ignores the conditional header rewrites identical bytes, which is equivalent. Retries only for transient failures; every other non-success is an error. ObjectLockinstead of pretending S3 is atomic.put/refresh hold it shared,delete_if_older_thanholds it exclusive across check and delete.InProcessLock(tests),FileLock(one host, SQLite metadata) andPostgresAdvisoryLock(session-level advisory locks on a dedicated connection: a crashed holder is released by the server; no leases to expire). Rejected: relying onLastModifiedalone (a writer between check and delete loses its object — reproduced ins3_gc_cannot_delete_an_object_a_writer_just_reput), conditional deletes (not available on general-purpose buckets), versioned buckets as a safety net (an assumption about the operator).- Secrets are never in the repository.
storagein.ag/config.jsonnames environment variables; a literal URL with a password is refused. Credentials are static environment credentials; no credential-provider chain. - A pre-storage
agfails loudly.formatstays 1 and the section is additive, which means an older binary ignores it.inittherefore occupies.ag/meta.db(PostgreSQL) /.ag/store(S3) with a placeholder so the old binary cannot create an empty local repository next to the real one. ag backupdoes not half-work. With PostgreSQL metadata it refuses before writing anything and points atpg_dump/PITR plusag verify; with S3 objects it downloads and verifies them into a plain local repository.
Consequences¶
- ✅ The same engine and the same test suites run on SQLite+files, PostgreSQL, S3 and combinations
(
AG_TEST_POSTGRES_URL,AG_TEST_S3_ENDPOINT;AG_REQUIRE_BACKENDS=1turns a missing server into a failure). - ✅ Several hosts can work on one repository; writers of different repositories never contend.
- ⚠️ Writes to one repository stay serialized (as with SQLite); the unit of scaling is the repository.
- ⚠️ SQLite metadata with S3 objects is single-host; cross-host needs PostgreSQL metadata (the lock).
- ⚠️ No tool yet moves an existing repository between backends, and PostgreSQL behind a transaction-pooling proxy is unsupported (session state is used).
- ⚠️ Verified against PostgreSQL 16, a real S3 server (SeaweedFS) and moto; not yet against Amazon S3 or MinIO themselves (see the operations guide).
- ⚠️ The
migrations/directory sits outside the crate, socargo packageforag-storageneeds the files copied or symlinked in before publishing.