pulses 0.2.0

A robust, high-performance background job processing library for Rust.
Documentation
//! Runtime configuration and validation.

use std::time::Duration;

/// Hard upper bound for any channel/queue capacity, guarding against
/// accidental multi-gigabyte allocations from a bad config value.
pub const MAX_CAPACITY: usize = 1 << 20;

/// Exponential-backoff-with-jitter parameters shared by the broker reader,
/// the ack retrier, and per-message handler retries.
#[derive(Debug, Clone)]
pub struct BackoffConfig {
    /// Delay for the first retry.
    pub initial: Duration,
    /// Ceiling the delay grows towards.
    pub max: Duration,
    /// Multiplier applied per attempt (must be `>= 1.0`).
    pub factor: f64,
    /// Fractional jitter in `[0.0, 1.0]`; the delay is randomized within
    /// `±jitter` of the computed base.
    pub jitter: f64,
}

impl Default for BackoffConfig {
    fn default() -> Self {
        Self { initial: Duration::from_millis(50), max: Duration::from_secs(5), factor: 2.0, jitter: 0.2 }
    }
}

/// Top-level runtime tuning.
#[derive(Debug, Clone)]
pub struct AppConfig {
    /// Bounded mailbox size for each handler pool. Doubles as the back-pressure
    /// signal: when a pool's mailbox fills, the reader stops polling.
    pub handler_queue_capacity: usize,
    /// Maximum messages processed concurrently *per handler* (further capped by
    /// each handler's [`MAX_IN_FLIGHT`](crate::Handler::MAX_IN_FLIGHT)).
    pub max_in_flight_per_handler: usize,
    /// Capacity of the queue holding ack tokens whose acknowledgement failed
    /// and is awaiting retry.
    pub ack_retry_queue_capacity: usize,
    /// Maximum attempts the ack retrier makes before giving up on a token.
    pub ack_max_attempts: u32,
    /// Maximum handler attempts (initial + retries) before a message is
    /// dead-lettered.
    pub handler_max_attempts: u32,
    /// How often the reclaimer scans for abandoned (pending, idle) messages.
    pub reclaim_interval: Duration,
    /// Sleep inserted after an empty poll, protecting against a hot loop when a
    /// broker is configured for non-blocking reads.
    pub poll_idle_sleep: Duration,
    /// Backoff parameters for transient broker failures and handler retries.
    pub backoff: BackoffConfig,
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            handler_queue_capacity: 1_024,
            max_in_flight_per_handler: 256,
            ack_retry_queue_capacity: 1_024,
            ack_max_attempts: 8,
            handler_max_attempts: 5,
            reclaim_interval: Duration::from_secs(30),
            poll_idle_sleep: Duration::from_millis(50),
            backoff: BackoffConfig::default(),
        }
    }
}

/// Reasons [`AppConfig::validate`] can reject a configuration.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
    /// A capacity/attempt field was below its minimum of 1.
    #[error("`{field}` must be at least 1")]
    TooSmall {
        /// The offending field name.
        field: &'static str,
    },
    /// A capacity field exceeded [`MAX_CAPACITY`].
    #[error("`{field}` ({value}) exceeds the maximum of {max}")]
    TooLarge {
        /// The offending field name.
        field: &'static str,
        /// The supplied value.
        value: usize,
        /// The enforced maximum.
        max: usize,
    },
    /// `backoff.factor` was less than 1.0.
    #[error("`backoff.factor` must be >= 1.0 (got {0})")]
    BackoffFactor(f64),
    /// `backoff.jitter` was outside `[0.0, 1.0]`.
    #[error("`backoff.jitter` must be within [0.0, 1.0] (got {0})")]
    BackoffJitter(f64),
    /// `backoff.initial` was greater than `backoff.max`.
    #[error("`backoff.initial` must be <= `backoff.max`")]
    BackoffRange,
}

impl AppConfig {
    /// Validate every field, returning the first problem found.
    pub fn validate(&self) -> Result<(), ConfigError> {
        check_capacity("handler_queue_capacity", self.handler_queue_capacity)?;
        check_capacity("max_in_flight_per_handler", self.max_in_flight_per_handler)?;
        check_capacity("ack_retry_queue_capacity", self.ack_retry_queue_capacity)?;

        if self.ack_max_attempts < 1 {
            return Err(ConfigError::TooSmall { field: "ack_max_attempts" });
        }
        if self.handler_max_attempts < 1 {
            return Err(ConfigError::TooSmall { field: "handler_max_attempts" });
        }

        if self.backoff.factor.is_nan() || self.backoff.factor < 1.0 {
            return Err(ConfigError::BackoffFactor(self.backoff.factor));
        }
        if !(0.0..=1.0).contains(&self.backoff.jitter) {
            return Err(ConfigError::BackoffJitter(self.backoff.jitter));
        }
        if self.backoff.initial > self.backoff.max {
            return Err(ConfigError::BackoffRange);
        }

        Ok(())
    }
}

fn check_capacity(field: &'static str, value: usize) -> Result<(), ConfigError> {
    if value < 1 {
        return Err(ConfigError::TooSmall { field });
    }
    if value > MAX_CAPACITY {
        return Err(ConfigError::TooLarge { field, value, max: MAX_CAPACITY });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_config_is_valid() {
        assert!(AppConfig::default().validate().is_ok());
    }

    #[test]
    fn rejects_zero_capacity() {
        let config = AppConfig { handler_queue_capacity: 0, ..AppConfig::default() };
        assert_eq!(config.validate(), Err(ConfigError::TooSmall { field: "handler_queue_capacity" }));
    }

    #[test]
    fn rejects_oversized_capacity() {
        let config = AppConfig { ack_retry_queue_capacity: MAX_CAPACITY + 1, ..AppConfig::default() };
        assert!(matches!(config.validate(), Err(ConfigError::TooLarge { .. })));
    }

    #[test]
    fn rejects_bad_backoff() {
        let bad_factor =
            AppConfig { backoff: BackoffConfig { factor: 0.5, ..BackoffConfig::default() }, ..AppConfig::default() };
        assert_eq!(bad_factor.validate(), Err(ConfigError::BackoffFactor(0.5)));

        let bad_range = AppConfig {
            backoff: BackoffConfig {
                initial: Duration::from_secs(10),
                max: Duration::from_secs(1),
                ..BackoffConfig::default()
            },
            ..AppConfig::default()
        };
        assert_eq!(bad_range.validate(), Err(ConfigError::BackoffRange));
    }

    #[test]
    fn rejects_zero_attempts() {
        let config = AppConfig { handler_max_attempts: 0, ..AppConfig::default() };
        assert_eq!(config.validate(), Err(ConfigError::TooSmall { field: "handler_max_attempts" }));
    }
}