# 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 `claim` sees 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** — `claim` reports
`InboxClaim::InProgress` immediately (a transaction advisory lock, not a blocking insert): roll
back and `nak` with a delay, never enter the handler.
- **Commit without calling `complete`** — a caller that drives `claim` directly and skips
`complete` (or commits after `complete` itself errored) leaves a committed, uncompleted row,
indistinguishable from one `InboxStore::fail` created: the next redelivery's `claim` answers
`Claimed { attempt: 1 }` again and the handler re-runs. `InboxStore::process` cannot hit this —
it always calls `complete` before returning `Processed` — but a caller driving `claim`/`complete`
itself must call `complete` before its own commit.
- **A handler keeps failing past `max_attempts`** — `InboxStore::fail` bounds *recorded* retries
atomically with the increment: the row goes dead (`dead_at` set), the next `claim` answers
`InboxClaim::Dead` without running the handler, and the message is `term`ed rather than `nak`ed
or `ack`ed — it stops bouncing and becomes operator evidence in `InboxDeadLetters`.
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.
```rust
# use reliar_inbox::{InboxHandler, InboxMessage, InboxOutcome, InboxProcessError, InboxScope, InboxStore};
#
struct RecordOrder;
impl<Tx: Send> InboxHandler<Tx> for RecordOrder {
type Output = ();
type Error = std::convert::Infallible;
async fn handle(&self, _tx: &mut Tx) -> Result<Self::Output, Self::Error> {
// Every write here shares the caller's transaction with the inbox completion.
Ok(())
}
}
async fn consume<Tx: Send, S: InboxStore<Tx>>(
store: &S,
tx: &mut Tx,
scope: &InboxScope,
message: InboxMessage<'_>,
) -> Result<(), InboxProcessError<S::Error, std::convert::Infallible>> {
match store.process(tx, scope, message, &RecordOrder).await? {
InboxOutcome::Processed(()) => {} // then commit tx and ack
_ => {} // AlreadyCompleted, InProgress or Dead: roll back, drop tx
}
Ok(())
}
```
`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`](https://github.com/sisaio/sisa-reliar/blob/main/docs/architecture/inbox-contract.md)
for the frozen signatures and [ADR 0042](https://github.com/sisaio/sisa-reliar/blob/main/docs/decisions/0042-inbox-transactional-dedup.md)
for the design.
## License
MIT