reliar-store-postgres
Reliar's PostgreSQL provider for the transactional outbox: the schema, the explicit migrate()
API, and PostgresOutboxStore — the only crate in the workspace where an sqlx/Postgres type
appears. Requires PostgreSQL 18 or later — a hard requirement, with no older-version fallback,
checked at connect() and at migrate() (ADR 0041).
MSRV 1.94, set by sqlx 0.9 — six releases above the workspace floor of 1.88. Provider
crates may carry their driver's MSRV; the pure reliar-core/reliar-outbox crates stay on 1.88
(ADR 0025).
Guarantees this store honours
PostgresOutboxStore implements reliar-outbox's contract, so its two headline guarantees are
reliar-outbox's, not restated differently here — see
reliar-outbox's README
for the full text. In short:
- Durable at-least-once publication, never exactly-once. A consumer built on Reliar must be
idempotent. Three windows produce a duplicate and all three are unavoidable in this release:
the crash window (a publish reaches the broker, the worker crashes before
completepersists, the lease expires, and another worker republishes), the slow-batch window (a batch outlives its lease while the worker is still healthily publishing, so a second worker reclaims and republishes the tail), and the drain window (cancellation drains in-flight publishes for at mostdrain_timeout; one still unresolved at the timeout is released rather than awaited further, carrying the same duplicate risk, just triggered by shutdown). - No ordering by default.
Ordering::Unordered(the only value this release supports) guarantees nothing about order — not globally, not perconversation_id, not per aggregate, not even approximately:acquire'sSKIP LOCKEDclaim, concurrent publishing, per-message backoff and multiple dispatcher instances each reorder freely.
Features
| Feature | Default | Enables |
|---|---|---|
json |
on | PostgresOutboxStore<JsonSerializer>'s default type parameter and the new/with_settings convenience constructors (forwards reliar-core/json). Not hard-enabled: a deployment supplying its own Serializer should not pull in serde_json. Under --no-default-features, [PostgresOutboxStore::connect] is the only constructor. |
serde |
off | serde::Serialize/Deserialize on PostgresOutboxSettings, #[serde(default, deny_unknown_fields)] so a typo'd config key is a hard error, durations as integer milliseconds (statement_timeout_ms). serde itself is always a dependency regardless of this feature — it also drives the crate's private MetadataRest JSONB contract (ADR 0012), which is not feature-gated. |
Additive, checked with cargo hack check --feature-powerset.
search_path setup
Every Reliar object lives in one configurable schema, reliar by default, with unprefixed
table names (outbox). sqlx::query! checks SQL at compile time, so every identifier in every
statement is a static, unqualified literal — the schema is resolved at connection time through
search_path, never compiled in (ADR 0017).
-
Put
reliarfirst on the connection URL the host passes to its own pool:postgres://user:pw@host/app?options=-c%20search_path%3Dreliar,public -
Behind a transaction-mode pooler — any pooler that drops startup
optionsneeds a server-side default instead, which every pooler mode honours (verify which yours does:PgDog, the pooler the suite tests against, passes them through):ALTER ROLE app SET search_path = reliar, public; -
PostgresOutboxStore::connect/newverify, once at construction, that the unqualified nameoutboxresolves to the configured schema. An unresolvable name or a mismatch is a construction error naming the configured schema, the observedsearch_path, and theALTER ROLEremedy — never a surprise failure on the firstacquire. A same-named table found in another schema on the path is logged as atracing::warn!. -
migrate()does not depend on the caller'ssearch_path— it creates the schema itself and setssearch_pathon its own dedicated connection before running the migration files, so it works even against a pool whose URL never set one (ADR 0018).
Usage
#
# ;
#
# async
What this crate ships
migrations/0001_outbox.sql— the full v0.1 schema, every constraint/index explicitly named (pk_/ck_/ix_).migrations/0002_outbox_claimable_index.sql/0003_drop_ix_outbox_pending.sql— replaceix_outbox_pendingwithix_outbox_claimable(ADR 0040 §2): a claim now moves a leased row'savailable_atpast its lease end, so the index the claim scans needsavailable_atin its own key rather than filteringlocked_untilfrom the heap.0002is a forward-only,-- no-transactionCREATE INDEX CONCURRENTLY;0003refuses to drop the old index unless the new one exists and is valid. If aCONCURRENTLYbuild is interrupted mid-run it leaves an invalid index —migrate()'s rustdoc carries the recovery step (DROP INDEX CONCURRENTLY ix_outbox_claimable;, then re-runmigrate()).migrate(&pool, MigrateOptions)— isolated bookkeeping in<schema>._migrations, never the shared_sqlx_migrations; serializes concurrent callers with its own poll-based advisory lock (notsqlx::migrate's blocking one, which would deadlock against0002'sCREATE INDEX CONCURRENTLY).MigrateOptions::schema/PostgresOutboxSettings::schemaaccept only a lowercase identifier ([a-z_][a-z0-9_$]*, at most 63 bytes) — PostgreSQL folds an unquoted identifier to lowercase, so an uppercase name is rejected rather than silently resolving inconsistently.PostgresOutboxStore::connect/new/with_settings— fail-fast startupsearch_pathverification.reliar_outbox::OutboxEnqueue::enqueue/enqueue_envelope— the transactional write path; import the trait to call either.- The full
OutboxStoreimpl:acquire(the single-statementFOR UPDATE SKIP LOCKEDclaim, with poisoned-row handling — an undecodable row is moved to dead withDeadReason::Undecodableand reported, never a panic, the rest of the batch still delivers), worker-guardedcomplete/fail/release/extend_lease, boundedpurge(one pass, threeLIMIT-capped statements), andstats. - The full
OutboxDeadLettersimpl:list_dead(keyset-paginated,ORDER BY sequence),retry_dead,purge_dead. - Per-variant
ClassifyforPostgresStoreError/EnqueueError, including SQLSTATE-class-based classification ofDatabaseerrors and42P01→NotMigratedon every operational path.
Testing
Real-Postgres integration tests live in tests/ (skill testcontainers): one ephemeral
postgres:18-alpine container per test binary, or DATABASE_URL when set (CI's service
container), with one isolated database per test, migrated via the crate's own public migrate().
Run with Docker available:
.sqlx/ offline cache
DATABASE_URL=postgres://user:pw@localhost/db?options=-csearch_pathDreliar,public \
CI builds with SQLX_OFFLINE=true and runs cargo sqlx prepare --check against a freshly
migrated database.
License
MIT — see the workspace LICENSE.