reliar-inbox
Transactional deduplication of inbound messages, keyed (scope, message_id): the InboxStore/
InboxHandler capability traits, the claim/outcome types that cross their boundary, the
InboxDeadLetters operator surface, and the InboxPurgeRequest/Report retention shapes
(ADR 0042, Amendments A and B).
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 InboxStore against its own transaction type; this crate
never depends on one.
The guarantee
Effectively-once for effects that live in the caller's transaction. At-least-once for everything else. No exactly-once claim, no ordering promise. A bounded-retry dead state exists so a poison message stops being redelivered forever — it changes no guarantee above; it only bounds recorded retries.
A message a broker redelivers — because the outbox that produced it publishes at-least-once, or
because a consumer's ack_wait expired on a slow handler — must not re-run its handler's
database effects. InboxStore::claim answers that question inside the caller's own
transaction, so the handler's writes and the inbox's completion commit together or not at all:
- Crash before commit — the transaction is gone with the rollback, including the claim row. The redelivery re-runs the handler in full.
- Crash after commit, before ack — the redelivery's
claimsees the row already completed (InboxClaim::AlreadyCompleted) and performs no write: roll back and ack, no re-run. - A duplicate arrives while the first attempt is still in flight —
claimreportsInboxClaim::InProgressimmediately (a transaction advisory lock, not a blocking insert): roll back andnakwith a delay, never enter the handler. - Commit without calling
complete— a caller that drivesclaimdirectly and skipscomplete(or commits aftercompleteitself errored) leaves a committed, uncompleted row, indistinguishable from oneInboxStore::failcreated: the next redelivery'sclaimanswersClaimed { attempt: 1 }again and the handler re-runs.InboxStore::processcannot hit this — it always callscompletebefore returningProcessed— but a caller drivingclaim/completeitself must callcompletebefore its own commit. - A handler keeps failing past
max_attempts—InboxStore::failbounds recorded retries atomically with the increment: the row goes dead (dead_atset), the nextclaimanswersInboxClaim::Deadwithout running the handler, and the message istermed rather thannaked oracked — it stops bouncing and becomes operator evidence inInboxDeadLetters.
An effect the handler causes outside the database (an email sent, a card charged) is not undone by a rollback and is not deduplicated by anything here — that duplicate is the caller's to prevent, same as any other at-least-once boundary in Reliar.
Quickstart
InboxStore::process is the happy path in one call — claim, branch, run the handler, complete.
Store behaviour is proven only against real Postgres (ADR 0043) — this crate ships no store test
double of any kind — so the shape below is a compiled generic function that is never called:
it type-checks the real call shape and the real bounds against any provider (e.g.
PostgresInboxStore against sqlx::Transaction<'_, Postgres>), which is exercised in
reliar-store-postgres's tests.
# use ;
#
;
async
process never commits and never calls InboxStore::fail — it holds only &mut Tx, and
fail needs a different connection than the transaction being rolled back. The caller owns the
transaction and the ack; the six outcomes and what each one owes are on InboxStore::process's
own rustdoc.
Dead letters
InboxDeadLetters::list_dead/retry_dead/purge_dead are the operator surface over rows that
reached max_attempts — separate from InboxStore because no Reliar code path calls them.
retry_dead only clears dead_at and resets attempts; it does not cause a redelivery —
that requires a broker-side replay or republish.
Retention
InboxPurgeRequest deletes completed rows past completed_retention (default 7 days),
opt-in incomplete rows carrying recorded failures past incomplete_retention (default None),
and opt-in dead rows past dead_retention (default None) — the last two are deliberately loud
defaults: those rows are the operator's only record of a handler that kept failing. A completed
row may only be deleted once no redelivery of that message can still arrive — size the retention
against your consumer's max_deliver × ack_wait plus backoff, and anything the producing side can
still republish inside the outbox's duplicate window.
PostgreSQL 18+
reliar-store-postgres's PostgresInboxStore requires PostgreSQL 18 or newer — see that crate's
README for the server-version guard.
See docs/architecture/inbox-contract.md
for the frozen signatures and ADR 0042
for the design.
License
MIT