reliar-outbox 0.8.0

Storage-agnostic transactional outbox: OutboxStore/Publisher contracts, retry policy, settings and dispatcher (no storage or transport dependency).
Documentation
//! `reliar-outbox` is the storage-agnostic transactional outbox: the [`OutboxStore`]/
//! [`OutboxDeadLetters`] capability traits (plus `reliar_core::Publisher`, re-exported here for
//! convenience), the request and result types that cross their boundary, a pure [`RetryPolicy`],
//! the feature's [`OutboxSettings`], and the [`OutboxMetrics`] hook.
//!
//! **The object names the guarantee — there is no facade type joining the two** (ADR 0036
//! amendment B). A caller enqueues durably by calling [`OutboxEnqueue::enqueue`] on the
//! provider store, in its own transaction: the message becomes visible when that transaction
//! commits and is published later by an [`OutboxDispatcher`], at-least-once, with the duplicate
//! windows below. A caller that instead wants to send now, with no Reliar durability at all, calls
//! the transport's own [`Publisher::publish`] directly — `reliar-outbox` never wraps it. Nothing
//! decides between the two at runtime, and no setting can.
//!
//! This slice ships the traits and types a provider builds against, a host configures, and the
//! [`OutboxDispatcher`] worker loop. Store behaviour is proven only against real Postgres (ADR
//! 0043): this crate constructs no store of its own, so the example below is a compiled generic
//! call shape, never invoked — `reliar-store-postgres`'s own rustdoc has the runnable version
//! over `PgPool::connect`.
//!
//! ```
//! use reliar_core::{Message, Publisher, SerializedEnvelope};
//! 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;
//! # }
//! #
//! /// The durable path: any provider store implementing `OutboxEnqueue<Tx>` enqueues in the
//! /// caller's own transaction — no facade type in between (ADR 0036 amendment B). A bare
//! /// message becomes an envelope with default metadata and a freshly rooted conversation;
//! /// published later by an `OutboxDispatcher`. Use `enqueue_envelope` with `Envelope::builder(..)`
//! /// instead when an id must propagate from an inbound request.
//! async fn enqueue_order<S, Tx>(store: &S, tx: &mut Tx, body: OrderCreated) -> Result<(), S::Error>
//! where
//!     S: OutboxEnqueue<Tx>,
//! {
//!     store.enqueue(tx, body).await?;
//!     Ok(())
//! }
//!
//! /// The bypass path needs no store: any `reliar_core::Publisher` — a real transport, or a
//! /// harness-local stand-in — sends now, with none of the outbox's guarantees.
//! async fn publish_now<P: Publisher>(
//!     publisher: &P,
//!     envelope: &SerializedEnvelope,
//! ) -> Result<(), P::Error> {
//!     publisher.publish(envelope).await
//! }
//! ```
//!
//! # Guarantees
//!
//! - **Durable at-least-once publication. Never exactly-once.** Duplicate delivery is expected
//!   and must be handled by an idempotent consumer. Three distinct windows produce a duplicate,
//!   and all three are unavoidable:
//!   1. **The crash window:** a publish reaches the broker, the worker crashes before
//!      `complete` persists, the lease expires, and another worker republishes the same message.
//!   2. **The slow-batch window:** no crash at all — a worker claims a large 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 rejected by the `locked_by` guard.
//!   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 outcome — success or failure — is the same
//!      duplicate risk as the other two windows, just triggered by shutdown instead of a lease.
//! - **No ordering by default.** [`Ordering::Unordered`] (the default) guarantees **nothing**
//!   about order — not globally, not per `conversation_id`, not per aggregate, not
//!   approximately. `SKIP LOCKED`, concurrent publishing, per-message backoff and multiple
//!   workers each reorder freely (ADR 0013). [`Ordering::PerKey`] is a configuration error in
//!   this release — see [`Ordering::validate`].
//! - **Pure retry.** [`RetryPolicy`] is I/O-free and clock-free: it returns a [`core::time::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).
//! - **Calling the transport publisher directly bypasses the outbox.** A call to a transport's
//!   [`Publisher::publish`] carries **none** of the above: one attempt, no retry, no backoff, no
//!   dead state, no duplicate window — only as much retry as the transport itself performs, and
//!   no relationship to any transaction the caller has open. Use [`OutboxEnqueue::enqueue`]/
//!   [`OutboxEnqueue::enqueue_envelope`] for the durable path instead (ADR 0036 amendment B).
//!   See `docs/guides/outbox-enqueue-and-publish.md` for the full comparison.

#![cfg_attr(docsrs, feature(doc_cfg))]
#![forbid(unsafe_code)]
#![warn(missing_docs)]

mod claim_token;
mod dispatcher;
mod duration_serde;
mod enqueue;
mod error;
mod metrics;
mod ordering;
mod record;
mod record_id;
mod retry;
mod settings;
mod store;
mod worker;

pub use claim_token::ClaimToken;
pub use dispatcher::{DefaultRetry, DispatchError, OutboxDispatcher, OutboxDispatcherBuilder};
pub use enqueue::OutboxEnqueue;
pub use error::ConfigError;
pub use metrics::{NoopMetrics, OutboxMetrics};
pub use ordering::Ordering;
pub use record::{OutboxRecord, OutboxRecordBuilder, OutboxState};
pub use record_id::OutboxRecordId;
/// Re-exported from `reliar-core` (ADR 0032): a store author's or a publisher's `Classify`
/// bound, a publish/store failure's `FailureKind`, the `Publisher` capability trait, and the
/// shared `SettingsError` all live in core now. New code should name `reliar_core::` directly;
/// this re-export keeps existing `use reliar_outbox::{…}` imports one line.
pub use reliar_core::{Classify, FailureKind, Publisher, SettingsError};
pub use retry::{ExponentialBackoff, RetryPolicy};
pub use settings::{DispatcherSettings, OutboxSettings, RetentionSettings};
pub use store::{
    AcquireRequest, AcquiredBatch, CompletedRecord, DeadCursor, DeadLetterPage, DeadQuery,
    DeadReason, FailedRecord, FailureOutcome, OutboxDeadLetters, OutboxStats, OutboxStore,
    PoisonedRow, PurgeReport, PurgeRequest, RecordRef,
};
pub use worker::WorkerId;

#[cfg(doctest)]
mod readme_doctests;