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.
Requirements
Requirements: PostgreSQL 18 or later. Reliar does not check the server version; behaviour on
older servers is undefined. Neither a store constructor nor migrate() probes it — the schema
uses uuidv7() and uuid_extract_timestamp(), so migrate() on an older server fails inside the
first migration with PostgreSQL's own function uuidv7() does not exist (ADR 0047 Amendment B).
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::with_serializer 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). This is the only remedy for a pooler that dropsoptions; there is no library-side alternative:ALTER ROLE app SET search_path = reliar, public; -
Reliar does not verify this at startup (ADR 0047). Constructing a store issues no query; an unresolvable
search_pathsurfaces at the first store call asPostgresOutboxError::NotMigrated/PostgresInboxError::NotMigrated, whose message names both themigrate()and theALTER ROLEremedy. Reliar never setssearch_pathon a pool it does not own — not at construction, not per call. -
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()). Historical:0002built the index withINCLUDE (locked_until, expires_at);0015/0016reduced it toINCLUDE (expires_at)oncelocked_untilitself was dropped (ADR 0050).migrations/0015_outbox_claimable_index_without_locked_until.sql/0016_drop_outbox_locked_until.sql— drop thelocked_untilcolumn andck_outbox_lease(ADR 0050): the lease islocked_by(who) +claim_token(which claim) +available_at(until when), andavailable_atis the only lease clock. Same two-file create-concurrently-then-swap shape as0012/0014— an index depends on itsINCLUDEcolumns exactly as on its key columns, so a plainDROP COLUMNwould silently dropix_outbox_claimablealong with it.migrations/0005_outbox_record_id.sql…0010_outbox_primary_key_swap.sql— the outbox row gains its ownid(OutboxRecordId,DEFAULT uuidv7(),pk_outbox) separate from the client-mintedmessage_idit used to share one column with (ADR 0044).0005renames the oldidcolumn tomessage_idand adds the new, nullableid;0006backfills it (id := message_id, restartable — see its own doc comment for the batched escape hatch on a large table) and validates theNOT NULLcheck;0007/0008/0009buildix_outbox_id/ix_outbox_message_id/ix_outbox_dead_cursorCONCURRENTLY;0010promotesix_outbox_idtopk_outbox, requiresmessage_idNOT NULL, and drops the two indexesix_outbox_dead_cursorsupersedes (ix_outbox_dead,ix_outbox_dead_at) — each concurrent build's failure mode and recovery step (DROP INDEX CONCURRENTLY <name>;, then re-runmigrate()) is inmigrate()'s rustdoc. Runmigrate()before starting 0.7.0 — seedocs/guides/postgres.mdfor the rolling-upgrade procedure.migrations/0012_outbox_claimable_index_on_id.sql/0013_outbox_ordering_key_index_on_id.sql/0014_drop_outbox_sequence.sql— drop thesequencecolumn (ADR 0049 Amendment A):sequence's only job was breaking a tie inORDER BY available_at, sequence, andid(unique,NOT NULL,pk_outbox) already breaks it.0012/0013buildix_outbox_claimable/ix_outbox_ordering_keyre-keyed onidunder transient names (..._id)CONCURRENTLY;0014is an ordinary transactional migration that refuses to dropsequenceand its indexes unless both transient indexes exist and are valid, then dropssequenceand its indexes and renames the transient ones to the permanent names. If aCONCURRENTLYbuild is interrupted mid-run it leaves an invalid index and0014refuses to proceed, naming both indexes in its error —migrate()'s rustdoc carries the recovery step (DROP INDEX CONCURRENTLY ix_outbox_claimable_id;and/orix_outbox_ordering_key_id, 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::schemaaccepts 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::new/with_settings/with_serializerandPostgresInboxStore::new/with_settings— synchronous, no I/O (ADR 0047): a store can be built inside aOnceLock, aDefaultimpl, or a synchronousmain().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),claim_token-fencedcomplete/fail/release/extend_lease(ADR 0046 Amendment A), boundedpurge(one pass, threeLIMIT-capped statements), andstats. - The full
OutboxDeadLettersimpl:list_dead(keyset-paginated by(dead_at, id), the row's ownOutboxRecordId),retry_dead,purge_dead. - Per-variant
ClassifyforPostgresOutboxError/EnqueueError, including SQLSTATE-class-based classification ofDatabaseerrors and42P01→NotMigratedon every operational path. migrations/0004_inbox.sqlandPostgresInboxStore— the transactional inbox (ADR 0042 + Amendments A/B; inbox contractdocs/architecture/inbox-contract.md), implementingreliar_inbox::InboxStore+InboxDeadLettersagainstsqlx::Transaction<'_, Postgres>. A separate type fromPostgresOutboxStoresharing the same schema, the samemigrate()and the same_migrationsbookkeeping table:claim/completerun in the caller's own transaction (the whole of the inbox's guarantee — a handler's business write and the claim/completion commit or roll back together);fail/find/purge/InboxDeadLettersrun on this store's own pool.claim's in-flight guard is a two-argumentpg_try_advisory_xact_lock, released by the caller's own commit or rollback — never a blocking wait, so a redelivery racing an in-flight claim getsInProgressimmediately instead of stalling. The row carries a client-mintedUUIDv7id(pk_inbox) plus the envelope's trace columns (message_type/conversation_id/correlation_id/causation_id), keyed for dedup by the unique indexix_inbox_scope_message_id.fail's singleINSERT … ON CONFLICT DO UPDATEbounds recorded failures atPostgresInboxSettings::max_attempts(default 10) and setsdead_atatomically with the increment — a poison message stops being redelivered and its row becomes operator evidence inInboxDeadLetters::list_dead/retry_dead/purge_dead.PostgresInboxSettingsmirrorsPostgresOutboxSettings'sstatement_timeoutknob plus its ownmax_attempts;PostgresInboxErrorisClassify, reusing the crate's SQLSTATE classification table.
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.