Skip to main content

actionqueue_runtime/
config.rs

1//! Runtime configuration for the ActionQueue dispatch loop.
2
3use std::num::NonZeroUsize;
4use std::path::PathBuf;
5use std::time::Duration;
6
7/// Configuration for the ActionQueue runtime.
8#[derive(Debug, Clone)]
9pub struct RuntimeConfig {
10    /// Directory for WAL and snapshot storage.
11    pub data_dir: PathBuf,
12    /// Backoff strategy for retry delay computation.
13    pub backoff_strategy: BackoffStrategyConfig,
14    /// Maximum number of concurrently executing runs.
15    pub dispatch_concurrency: NonZeroUsize,
16    /// Lease timeout in seconds for dispatched runs.
17    pub lease_timeout_secs: u64,
18    /// Interval between scheduler ticks.
19    pub tick_interval: Duration,
20    /// Number of WAL events between automatic snapshot writes.
21    ///
22    /// When `Some(n)`, the dispatch loop writes a snapshot every `n` WAL
23    /// events. When `None`, automatic snapshot writing is disabled.
24    pub snapshot_event_threshold: Option<u64>,
25}
26
27/// Backoff strategy configuration.
28#[derive(Debug, Clone)]
29pub enum BackoffStrategyConfig {
30    /// Fixed interval between retries.
31    Fixed {
32        /// The constant delay between retries.
33        interval: Duration,
34    },
35    /// Exponential backoff with a maximum cap.
36    Exponential {
37        /// Base delay for the first retry.
38        base: Duration,
39        /// Maximum delay cap.
40        max: Duration,
41    },
42}
43
44impl Default for RuntimeConfig {
45    fn default() -> Self {
46        Self {
47            data_dir: PathBuf::from("data"),
48            backoff_strategy: BackoffStrategyConfig::Fixed { interval: Duration::from_secs(5) },
49            dispatch_concurrency: NonZeroUsize::new(4).expect("4 is non-zero"),
50            lease_timeout_secs: 300,
51            tick_interval: Duration::from_millis(100),
52            snapshot_event_threshold: Some(10_000),
53        }
54    }
55}
56
57/// Errors that can occur during configuration validation.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ConfigError {
60    /// The data directory path is empty.
61    EmptyDataDir,
62    /// The tick interval is zero.
63    ZeroTickInterval,
64    /// The exponential backoff base exceeds the max.
65    BackoffBaseExceedsMax,
66    /// The snapshot event threshold is zero.
67    ZeroSnapshotEventThreshold,
68    /// The lease timeout is too low for heartbeat semantics.
69    LeaseTimeoutTooLow,
70    /// The tick interval exceeds the maximum (60 seconds).
71    TickIntervalTooHigh,
72    /// The lease timeout exceeds the maximum (24 hours).
73    LeaseTimeoutTooHigh,
74}
75
76impl std::fmt::Display for ConfigError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            ConfigError::EmptyDataDir => write!(f, "data_dir must not be empty"),
80            ConfigError::ZeroTickInterval => write!(f, "tick_interval must be greater than zero"),
81            ConfigError::BackoffBaseExceedsMax => {
82                write!(f, "exponential backoff base must not exceed max")
83            }
84            ConfigError::ZeroSnapshotEventThreshold => {
85                write!(f, "snapshot_event_threshold must be >= 1 when provided")
86            }
87            ConfigError::LeaseTimeoutTooLow => {
88                write!(f, "lease_timeout_secs must be >= 3 for correct heartbeat semantics")
89            }
90            ConfigError::TickIntervalTooHigh => {
91                write!(f, "tick_interval must not exceed 60 seconds")
92            }
93            ConfigError::LeaseTimeoutTooHigh => {
94                write!(f, "lease_timeout_secs must not exceed 86400 (24 hours)")
95            }
96        }
97    }
98}
99
100impl std::error::Error for ConfigError {}
101
102impl RuntimeConfig {
103    /// Validates this configuration.
104    pub fn validate(&self) -> Result<(), ConfigError> {
105        if self.data_dir.as_os_str().is_empty() {
106            return Err(ConfigError::EmptyDataDir);
107        }
108        if self.tick_interval.is_zero() {
109            return Err(ConfigError::ZeroTickInterval);
110        }
111        if self.tick_interval > Duration::from_secs(60) {
112            return Err(ConfigError::TickIntervalTooHigh);
113        }
114        if let BackoffStrategyConfig::Exponential { base, max } = &self.backoff_strategy {
115            if base > max {
116                return Err(ConfigError::BackoffBaseExceedsMax);
117            }
118        }
119        if self.snapshot_event_threshold == Some(0) {
120            return Err(ConfigError::ZeroSnapshotEventThreshold);
121        }
122        if self.lease_timeout_secs < 3 {
123            return Err(ConfigError::LeaseTimeoutTooLow);
124        }
125        if self.lease_timeout_secs > 86_400 {
126            return Err(ConfigError::LeaseTimeoutTooHigh);
127        }
128        Ok(())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn default_config_validates() {
138        let config = RuntimeConfig::default();
139        assert!(config.validate().is_ok());
140    }
141
142    #[test]
143    fn empty_data_dir_is_rejected() {
144        let config = RuntimeConfig { data_dir: PathBuf::from(""), ..RuntimeConfig::default() };
145        assert_eq!(config.validate(), Err(ConfigError::EmptyDataDir));
146    }
147
148    #[test]
149    fn zero_tick_interval_is_rejected() {
150        let config = RuntimeConfig { tick_interval: Duration::ZERO, ..RuntimeConfig::default() };
151        assert_eq!(config.validate(), Err(ConfigError::ZeroTickInterval));
152    }
153
154    #[test]
155    fn zero_snapshot_event_threshold_is_rejected() {
156        let config =
157            RuntimeConfig { snapshot_event_threshold: Some(0), ..RuntimeConfig::default() };
158        assert_eq!(config.validate(), Err(ConfigError::ZeroSnapshotEventThreshold));
159    }
160
161    #[test]
162    fn none_snapshot_event_threshold_validates() {
163        let config = RuntimeConfig { snapshot_event_threshold: None, ..RuntimeConfig::default() };
164        assert!(config.validate().is_ok());
165    }
166
167    #[test]
168    fn lease_timeout_below_minimum_is_rejected() {
169        let config = RuntimeConfig { lease_timeout_secs: 2, ..RuntimeConfig::default() };
170        assert_eq!(config.validate(), Err(ConfigError::LeaseTimeoutTooLow));
171    }
172
173    #[test]
174    fn lease_timeout_at_minimum_validates() {
175        let config = RuntimeConfig { lease_timeout_secs: 3, ..RuntimeConfig::default() };
176        assert!(config.validate().is_ok());
177    }
178
179    #[test]
180    fn tick_interval_exceeding_maximum_is_rejected() {
181        let config =
182            RuntimeConfig { tick_interval: Duration::from_secs(61), ..RuntimeConfig::default() };
183        assert_eq!(config.validate(), Err(ConfigError::TickIntervalTooHigh));
184    }
185
186    #[test]
187    fn tick_interval_at_maximum_validates() {
188        let config =
189            RuntimeConfig { tick_interval: Duration::from_secs(60), ..RuntimeConfig::default() };
190        assert!(config.validate().is_ok());
191    }
192
193    #[test]
194    fn lease_timeout_exceeding_maximum_is_rejected() {
195        let config = RuntimeConfig { lease_timeout_secs: 86_401, ..RuntimeConfig::default() };
196        assert_eq!(config.validate(), Err(ConfigError::LeaseTimeoutTooHigh));
197    }
198
199    #[test]
200    fn lease_timeout_at_maximum_validates() {
201        let config = RuntimeConfig { lease_timeout_secs: 86_400, ..RuntimeConfig::default() };
202        assert!(config.validate().is_ok());
203    }
204
205    #[test]
206    fn exponential_base_exceeding_max_is_rejected() {
207        let config = RuntimeConfig {
208            backoff_strategy: BackoffStrategyConfig::Exponential {
209                base: Duration::from_secs(100),
210                max: Duration::from_secs(10),
211            },
212            ..RuntimeConfig::default()
213        };
214        assert_eq!(config.validate(), Err(ConfigError::BackoffBaseExceedsMax));
215    }
216}