reliar-outbox
The storage-agnostic transactional outbox: the OutboxStore/OutboxDeadLetters capability traits
(plus reliar_core::Publisher, re-exported here for convenience), the request/result types that
cross their boundary, a pure RetryPolicy, the feature's settings (OutboxSettings), the
OutboxMetrics hook, and the OutboxDispatcher worker loop.
MSRV 1.88, the workspace floor.
Depends only on reliar-core — no sqlx, no Postgres, no broker client. A provider crate
(reliar-store-postgres) implements the traits here; this crate never depends on one.
Guarantees
Durable at-least-once publication. Never exactly-once. Duplicate delivery is expected, and 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 outcome, the lease expires, and another worker republishes the same message. A crash is not the only waycompletenever lands:leaseis also the outcome-write retry budget. Acomplete/failcall that keeps failing or timing out is retried on every loop iteration, but only for up tolease— past that, the row is abandoned to its lease (dropped from tracking, no longer renewed) rather than retried forever, so a perfectly healthy worker with a persistently failingcompleteproduces exactly this same duplicate, no crash required. - The slow-batch window — no crash at all. A worker claims a batch under a lease
shorter than the batch takes to drain; the lease expires while the worker is still healthily
publishing, a second worker reclaims and republishes the tail, and the first worker's later
complete/failis rejected by thelocked_byguard. Lease renewal and a per-publish timeout make this rare, not impossible — in practice it is the common window. - The drain window — on cancellation,
run()drains in-flight publishes for at mostDispatcherSettings::drain_timeout. A publish still unresolved at the timeout is released rather than awaited further, and its eventual outcome — success or failure — carries the same duplicate risk as the other two, just triggered by shutdown instead of a lease.
No ordering by default. Ordering::Unordered (the only value this release supports) guarantees
nothing about order — not globally, not per conversation_id, not per aggregate, not even
approximately. SKIP LOCKED, concurrent publishing, per-message backoff and multiple dispatcher
instances each reorder freely (ADR 0013). Ordering::PerKey is a configuration error before
0.2.
Pure retry. RetryPolicy is I/O-free and clock-free — it returns a Duration, never a
timestamp. The store applies it as available_at = now() + delay in SQL, so a worker's clock skew
can never hot-loop a row or park it in the future (ADR 0009).
The library never reads the environment implicitly. Only OutboxSettings::from_env touches
std::env, and only when called (ADR 0019).
See docs/architecture/outbox.md
for the full delivery-path walkthrough and
docs/architecture/phase1-contract.md
for the frozen signatures.
Quickstart: enqueue vs publish
The object names the guarantee — there is no facade type joining the two (ADR
0036 amendment B). A provider store implements [OutboxEnqueue] directly: call store.enqueue
(a bare message) or store.enqueue_envelope (propagating ids from an inbound request) for the
durable path. A transport's own Publisher::publish sends now, with no Reliar
guarantee at all — call it directly, with no wrapper in between.
# use fmt;
# use ;
# use OutboxEnqueue;
#
# ;
#
# ;
#
# // The smallest honest stand-in for a transport (ADR 0043 A.4) — a real deployment uses
# // reliar-transport-nats's NatsPublisher instead.
# ;
#
# ;
#
#
#
#
// enqueue: durable, at-least-once — visible only once the caller's own transaction commits, and
// published later by an OutboxDispatcher. This is the real call shape against any provider (e.g.
// PostgresOutboxStore against sqlx::Transaction<'_, Postgres>) — compiled here, never invoked,
// since this crate is storage-agnostic and constructs no store of its own (ADR 0043); see
// reliar-store-postgres's tests for the same call against real Postgres. A bare message gets
// default metadata and a freshly rooted conversation; use enqueue_envelope + Envelope::builder(..)
// instead when an id must propagate from an inbound request.
async
#
# async
See docs/guides/outbox-enqueue-and-publish.md
for the full guarantee comparison and the
open-transaction warning around calling a transport publisher directly. An app that only ever
needs the bypass path — no durability, no dispatcher — can skip this crate entirely and use a
transport publisher (e.g. reliar-transport-nats's NatsPublisher) directly against
reliar-core alone.
What this crate ships
- The
OutboxStore/OutboxDeadLetterstraits and their request/result types (AcquireRequest,AcquiredBatch,CompletedRecord,FailedRecord,FailureOutcome,DeadReason,RecordRef,PurgeRequest/PurgeReport,OutboxStats,DeadQuery/DeadLetterPage,PoisonedRow), plusOutboxRecord/OutboxRecordIdand the record's builder. The row's own identity (OutboxRecordId,pk_outbox) is distinct from the envelope'sMessageIdit carries (ADR 0044) — every by-row operation and the dead-letter cursor key on the former. reliar_core::Publisher(re-exported here) +Classify/FailureKind— a publisher's error carries its own transient/permanent verdict; the dispatcher never guesses.RetryPolicyand the defaultExponentialBackoff.OutboxDispatcher/OutboxDispatcherBuilder/DispatchError— bounded-concurrency claim → publish → batchcomplete/fail, half-leaseextend_leaserenewal, astats_intervaltick feedingOutboxMetrics,tracingspans (reliar.outbox.claim/publish/retry/dead), and graceful cancellation viatokio_util::sync::CancellationToken.OutboxSettings/DispatcherSettings/RetentionSettings, eachDefault+ builder, with an opt-infrom_env("RELIAR_OUTBOX_").OutboxMetrics/NoopMetrics— a static-dispatch hook, no exporter dependency. Store behaviour is proven only against real Postgres (ADR 0043); this crate ships no test double at all — a publish failure in Reliar's own suite comes from a real broker (Toxiproxy intests/system), never a fake (ADR 0043 Amendment A).
Settings and environment variables
OutboxSettings::from_env("RELIAR_OUTBOX_") is opt-in — nothing in this crate reads the
environment implicitly (ADR 0019). It starts from Default, overrides only the variables
present, and returns Err for a present-but-unparseable or out-of-range value, never a silent
fallback.
DispatcherSettings (worker-loop tunables):
| Field | Env var | Default | Meaning |
|---|---|---|---|
batch_size |
RELIAR_OUTBOX_BATCH_SIZE |
100 |
Max rows one acquire statement claims. |
lease |
RELIAR_OUTBOX_LEASE_MS |
30000 (30 s) |
How long a claim holds its lease before it may be reclaimed. |
max_in_flight |
RELIAR_OUTBOX_MAX_IN_FLIGHT |
16 |
The claim gate's ceiling on rows this worker actively holds leased — not an absolute one: when a complete/fail write for a row can't land within one lease's worth of retrying, the outcome-write retry gives up on it rather than retrying forever, and the row is left to its lease — no longer counted or renewed by this worker — until that lease elapses and another worker reclaims it. |
publish_timeout |
RELIAR_OUTBOX_PUBLISH_TIMEOUT_MS |
10000 (10 s) |
How long one publish may run before it counts as a timeout (FailureKind::Transient). |
poll_interval |
RELIAR_OUTBOX_POLL_INTERVAL_MS |
500 |
Poll cadence after a non-empty claim; also seeds the outcome-write retry's pacing (capped at lease / 4) so a persistently fast-failing complete/fail cannot retry at CPU speed. Must be > 0 (ConfigError::ZeroPollInterval). |
idle_poll_interval |
RELIAR_OUTBOX_IDLE_POLL_INTERVAL_MS |
5000 (5 s) |
Poll cadence once a claim comes back empty. Must be > 0 (ConfigError::ZeroPollInterval). |
drain_timeout |
RELIAR_OUTBOX_DRAIN_TIMEOUT_MS |
30000 (30 s) |
Max time run() spends draining in-flight publishes after cancellation. |
store_timeout |
RELIAR_OUTBOX_STORE_TIMEOUT_MS |
10000 (10 s) |
Client-side bound on every OutboxStore call run makes — without it a hung statement makes drain_timeout unenforceable. Must be shorter than half the lease (store_timeout < lease / 2, ConfigError::StoreTimeoutTooLong) — the outcome-write retry races the lease-renewal tick, so a longer bound risks one hung complete/fail starving renewal for a whole tick. |
stats_interval |
RELIAR_OUTBOX_STATS_INTERVAL_MS |
15000 (15 s) |
How often stats() is polled for the pending/expired-pending/lag gauges. 0 disables the tick. |
ordering |
RELIAR_OUTBOX_ORDERING |
Unordered |
The publication ordering strategy — see the no-ordering guarantee above. |
retry.base |
RELIAR_OUTBOX_RETRY_BASE_MS |
1000 (1 s) |
ExponentialBackoff's base delay. |
retry.max_delay |
RELIAR_OUTBOX_RETRY_MAX_DELAY_MS |
300000 (5 min) |
ExponentialBackoff's delay cap. |
retry.max_attempts |
RELIAR_OUTBOX_RETRY_MAX_ATTEMPTS |
10 |
Attempts before a row goes dead with DeadReason::AttemptsExhausted. |
retry.jitter |
RELIAR_OUTBOX_RETRY_JITTER |
0.2 |
Delay multiplier spread, U(1 − jitter, 1 + jitter); must be in [0.0, 1.0). |
worker_id |
RELIAR_OUTBOX_WORKER_ID |
generated (pid:uuid7) |
Overrides the generated WorkerId — e.g. to embed a pod name. |
RetentionSettings (purge tunables):
| Field | Env var | Default | Meaning |
|---|---|---|---|
published_retention |
RELIAR_OUTBOX_PUBLISHED_RETENTION_MS |
604800000 (7 days) |
How long a published row is kept before purge deletes it. |
dead_retention |
RELIAR_OUTBOX_DEAD_RETENTION_MS |
unset (None) |
How long a dead row is kept before purge deletes it. None means dead rows are kept until an explicit purge — a default purge call deletes zero dead rows until this is set. |
purge_batch_size |
RELIAR_OUTBOX_PURGE_BATCH_SIZE |
1000 |
Max rows one purge pass deletes, per statement, per pass. |
The provider-specific RELIAR_STORE_POSTGRES_* variables (SCHEMA,
ENQUEUE_SETS_SEARCH_PATH, STATEMENT_TIMEOUT_MS) are documented in
docs/guides/postgres.md.
Features
| Feature | Default | Enables |
|---|---|---|
serde |
no | Serialize/Deserialize on the settings types, #[serde(default, deny_unknown_fields)], durations as integer milliseconds. |
Testing
cargo test -p reliar-outbox --all-features. Every test lives in tests/ against the public
API; this crate's own suite covers what needs no store — retry policy, settings, records, error
surfaces, bound witnesses. Store behaviour is proven only against real Postgres (ADR 0043): the
dispatcher's own paths (claim cadence, retry, dead, cancellation, concurrency bounds, lease
renewal, store faults) are exercised in crates/reliar-store-postgres/tests/postgres/, which is
the Postgres substrate host for this crate. A criterion bench measuring the dispatcher's claim →
publish → complete overhead runs against a real store, never a test double.
License
MIT — see the workspace LICENSE.