reliar-outbox 0.6.0

Storage-agnostic transactional outbox: OutboxStore/Publisher contracts, retry policy, settings and dispatcher (no storage or transport dependency).
Documentation
//! Configuration errors.

use core::fmt;
use std::time::Duration;

use crate::ordering::Ordering;

/// A dispatcher configuration that cannot be started. Returned by
/// `OutboxDispatcherBuilder::build` — **never a panic**.
///
/// [`Ordering::validate`] and [`crate::ExponentialBackoff::validate`] surface the same variants
/// independently of a dispatcher, so each rejection is unit-testable on its own.
///
/// ```
/// use reliar_outbox::{ConfigError, ExponentialBackoff};
///
/// let err = ExponentialBackoff::default().max_attempts(0).validate().unwrap_err();
/// assert_eq!(err, ConfigError::ZeroMaxAttempts);
/// ```
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ConfigError {
    /// `max_in_flight` was zero: no publish task could ever run.
    ZeroInFlight,

    /// `batch_size` was zero: `acquire` would only ever be asked for zero rows, so nothing
    /// would ever be claimed.
    ZeroBatchSize,

    /// `poll_interval` or `idle_poll_interval` was zero: with nothing to wait on, the claim loop
    /// would poll the store at CPU speed, and — since `outcome_retry_interval` is derived from
    /// `poll_interval` — a zero `poll_interval` would also re-enable the outcome-write CPU spin
    /// `outcome_retry_interval`'s floor otherwise prevents.
    ZeroPollInterval {
        /// The zero-valued field, e.g. `"poll_interval"` or `"idle_poll_interval"`.
        field: &'static str,
    },

    /// `store_timeout` was not comfortably shorter than half the lease-renewal period
    /// (`store_timeout >= lease / 2`). The dispatcher's outcome-write retry races directly
    /// against the lease-renewal tick inside `run`'s own `select!`, so a `store_timeout` this
    /// long would let a single hung outcome-write attempt occupy the whole gap between two
    /// renewal ticks, starving renewal for a batch that a shorter `store_timeout` would have let
    /// interrupt in time.
    StoreTimeoutTooLong {
        /// The configured client-side bound on every `OutboxStore` call.
        store_timeout: Duration,
        /// The configured claim lease — renewal ticks every `lease / 2`.
        lease: Duration,
    },

    /// `lease` was not longer than `publish_timeout`: a healthy, in-budget publish could still
    /// lose its lease before completing.
    LeaseTooShort {
        /// The configured claim lease.
        lease: Duration,
        /// The configured per-publish timeout.
        publish_timeout: Duration,
    },

    /// The selected [`Ordering`] is not implemented yet.
    UnsupportedOrdering {
        /// The rejected ordering strategy.
        ordering: Ordering,
        /// The release that will implement it, e.g. `"0.2"`.
        available_in: &'static str,
    },

    /// `ExponentialBackoff::jitter` was outside `[0.0, 1.0)`.
    InvalidJitter {
        /// The rejected value.
        value: f64,
    },

    /// `ExponentialBackoff::max_attempts` was zero: every failure would be immediately dead.
    ZeroMaxAttempts,

    /// `ExponentialBackoff::base` was zero: the first retry would be scheduled immediately.
    ZeroRetryBase,

    /// A custom `RetryPolicy` was supplied via `OutboxDispatcherBuilder::retry_policy` **and**
    /// `DispatcherSettings::retry` is not [`crate::ExponentialBackoff::default`] — one of the two
    /// would otherwise be silently ignored.
    RetryPolicyConflict,
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ZeroInFlight => f.write_str("max_in_flight must be greater than zero"),
            Self::ZeroBatchSize => f.write_str("batch_size must be greater than zero"),
            Self::ZeroPollInterval { field } => write!(f, "{field} must be greater than zero"),
            Self::StoreTimeoutTooLong { store_timeout, lease } => write!(
                f,
                "store_timeout ({store_timeout:?}) must be shorter than half the lease \
                 ({lease:?}) — a hung outcome write must not be able to occupy a whole \
                 renewal-tick gap"
            ),
            Self::LeaseTooShort {
                lease,
                publish_timeout,
            } => write!(
                f,
                "lease ({lease:?}) must be longer than publish_timeout ({publish_timeout:?})"
            ),
            Self::UnsupportedOrdering {
                ordering,
                available_in,
            } => write!(
                f,
                "{ordering:?} ordering is not implemented until {available_in}"
            ),
            Self::InvalidJitter { value } => {
                write!(f, "jitter {value} must be in the range [0.0, 1.0)")
            }
            Self::ZeroMaxAttempts => f.write_str("max_attempts must be at least 1"),
            Self::ZeroRetryBase => f.write_str("base delay must be greater than zero"),
            Self::RetryPolicyConflict => f.write_str(
                "a custom retry policy was supplied together with a non-default                  DispatcherSettings::retry; leave settings.retry at its default or feed it to                  the custom policy yourself",
            ),
        }
    }
}

impl std::error::Error for ConfigError {}