reliar-outbox 0.9.0

Storage-agnostic transactional outbox: OutboxStore/Publisher contracts, retry policy, settings and dispatcher (no storage or transport dependency).
Documentation
# 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**:

1. **The crash window** — a publish reaches the broker, the worker crashes before `complete`
   persists the outcome, the lease expires, and another worker republishes the same message. A
   crash is not the only way `complete` never lands: `lease` is also the outcome-write retry
   budget. A `complete`/`fail` call that keeps failing or timing out is retried on
   every loop iteration, but only for up to `lease` — 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 failing `complete` produces exactly this same duplicate,
   no crash required.
2. **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`/`fail` is fenced out by the row's `claim_token` (ADR 0046 A) — it affects zero rows.
   Lease renewal and a per-publish timeout make this rare, not impossible — in practice it is the
   common window.
3. **The drain window** — on cancellation, `run()` drains in-flight publishes for at most
   `DispatcherSettings::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`](https://github.com/sisaio/sisa-reliar/blob/main/docs/architecture/outbox.md)
for the full delivery-path walkthrough and
[`docs/architecture/phase1-contract.md`](https://github.com/sisaio/sisa-reliar/blob/main/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.

```rust
# use core::fmt;
# use reliar_core::{Classify, ContentType, Envelope, FailureKind, Message, Publisher, Publisher as _, SerializedEnvelope, Serializer};
# use reliar_outbox::OutboxEnqueue;
# #[derive(serde::Serialize, serde::Deserialize)]
# struct OrderCreated;
# impl Message for OrderCreated {
#     const TYPE: &'static str = "orders.created";
#     const VERSION: u16 = 1;
# }
# struct RawJson;
# impl Serializer for RawJson {
#     type Error = serde_json::Error;
#     fn content_type(&self) -> &ContentType { &ContentType::JSON }
#     fn serialize<T: Message>(&self, body: &T) -> Result<bytes::Bytes, Self::Error> {
#         serde_json::to_vec(body).map(bytes::Bytes::from)
#     }
#     fn deserialize<T: Message>(&self, bytes: &[u8]) -> Result<T, Self::Error> {
#         serde_json::from_slice(bytes)
#     }
# }
# // The smallest honest stand-in for a transport (ADR 0043 A.4) — a real deployment uses
# // reliar-transport-nats's NatsPublisher instead.
# struct NoopPublisher;
# #[derive(Debug)]
# struct NoopError;
# impl fmt::Display for NoopError {
#     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("unreachable") }
# }
# impl std::error::Error for NoopError {}
# impl Classify for NoopError {
#     fn kind(&self) -> FailureKind { FailureKind::Permanent }
# }
# impl Publisher for NoopPublisher {
#     type Error = NoopError;
#     // Explicit `impl Future` form: the body has no `.await` (conventions §3 reason (c)).
#     fn publish(&self, _envelope: &SerializedEnvelope) -> impl Future<Output = Result<(), Self::Error>> + Send {
#         async { Ok(()) }
#     }
# }
// 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 fn enqueue_order<Tx, S: OutboxEnqueue<Tx>>(store: &S, tx: &mut Tx) -> Result<(), S::Error> {
    store.enqueue(tx, OrderCreated).await?;
    Ok(())
}

# #[tokio::main(flavor = "current_thread")]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
// publish: bypasses the outbox entirely — sends now, through the transport, one attempt, no
// Reliar guarantee at all. The transport takes an already-serialized envelope, so the caller
// serializes once, exactly as it would for a bare transport publisher.
let publisher = NoopPublisher;
let envelope = Envelope::builder(OrderCreated).build();
let bytes = RawJson.serialize(&envelope.body)?;
let mut serialized = envelope.map_body(|_| bytes);
serialized.metadata.delivery.content_type = RawJson.content_type().clone();
publisher.publish(&serialized).await?;
# Ok(())
# }
```

See [`docs/guides/outbox-enqueue-and-publish.md`](https://github.com/sisaio/sisa-reliar/blob/main/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`/`OutboxDeadLetters` traits and their request/result types (`AcquireRequest`,
  `AcquiredBatch`, `FailedRecord`, `FailureOutcome`, `DeadReason`,
  `RecordRef`, `PurgeRequest`/`PurgeReport`, `OutboxStats`, `DeadQuery`/`DeadLetterPage`,
  `PoisonedRow`), plus `OutboxRecord`/`OutboxRecordId` and the record's builder. The row's own
  identity (`OutboxRecordId`, `pk_outbox`) is distinct from the envelope's `MessageId` it 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.
- `RetryPolicy` and the default `ExponentialBackoff`.
- `OutboxDispatcher`/`OutboxDispatcherBuilder`/`DispatchError` — bounded-concurrency claim →
  publish → batch `complete`/`fail`, half-lease `extend_lease` renewal, a `stats_interval` tick
  feeding `OutboxMetrics`, `tracing` spans (`reliar.outbox.claim`/`publish`/`retry`/`dead`), and
  graceful cancellation via `tokio_util::sync::CancellationToken`.
- `OutboxSettings`/`DispatcherSettings`/`RetentionSettings`, each `Default` + builder, with an
  opt-in `from_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 in `tests/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`](https://github.com/sisaio/sisa-reliar/blob/main/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`](https://github.com/sisaio/sisa-reliar/blob/main/LICENSE).