reliar-inbox 0.1.0

Transactional inbox deduplication: InboxStore/InboxHandler contracts, claim/complete/fail/purge semantics (no storage or transport dependency).
Documentation

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 flightclaim 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_attemptsInboxStore::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 termed rather than naked or acked — 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.

# 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 for the frozen signatures and ADR 0042 for the design.

License

MIT