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 (SRS §19–§26).
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 (§22) — 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 (RELIAR-26, M2). 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 (§22.1) — 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 (§26.1) — 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 (§22.2, 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 §3 for the frozen signatures.
Quickstart: enqueue vs publish
The object names the guarantee — there is no facade type joining the two (decision #37, 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 (decision #42). A transport's own Publisher::publish sends now, with no Reliar
guarantee at all — call it directly, with no wrapper in between.
# use ;
# use ;
#
# ;
#
# ;
#
#
# 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,CompletedMessage,FailedMessage,FailureOutcome,DeadReason,MessageRef,PurgeRequest/PurgeReport,OutboxStats,DeadQuery/DeadLetterPage,PoisonedRow), plusOutboxRecordand its builder. 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.- Behind the
test-supportfeature:InMemoryOutboxStore,RecordingPublisher,ScriptedPublisher,RecordingMetrics— reused by provider crates andexamples/.
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: a row M2 abandons after lease stays leased (just no longer counted or renewed by this worker) until that lease elapses. |
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 (§26.1). |
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 |
|---|---|---|
test-support |
no | The in-memory fakes above. |
serde |
no | Serialize/Deserialize on the settings types, #[serde(default, deny_unknown_fields)], durations as integer milliseconds. |
metrics |
no | A metrics-facade adapter for OutboxMetrics (empty until an adapter ships). |
Testing
cargo test -p reliar-outbox --all-features. Every test lives in tests/ against the public API:
in-memory fakes drive dispatcher behaviour (retry, dead, cancellation, concurrency bounds) under
#[tokio::test(start_paused = true)], with no wall-clock sleeps. benches/outbox_throughput.rs
(criterion, cargo bench -p reliar-outbox --features test-support) covers the dispatcher's own
claim → publish → complete overhead.