# 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)?;