Skip to main content

worldinterface_host/
config.rs

1//! Host configuration.
2
3use std::num::NonZeroUsize;
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use actionqueue_runtime::config::{BackoffStrategyConfig, RuntimeConfig};
9use worldinterface_core::metrics::{MetricsRecorder, NoopMetricsRecorder};
10use worldinterface_flowspec::CompilerConfig;
11
12use crate::error::HostError;
13
14/// Configuration for the embedded WorldInterface host.
15#[derive(Clone)]
16pub struct HostConfig {
17    /// Directory for AQ WAL and snapshot files.
18    pub aq_data_dir: PathBuf,
19
20    /// Path to the SQLite ContextStore database file.
21    pub context_store_path: PathBuf,
22
23    /// How often the background tick loop runs (drives AQ dispatch + coordinator resume).
24    /// Default: 50ms.
25    pub tick_interval: Duration,
26
27    /// Maximum concurrent handler executions (maps to AQ's dispatch_concurrency).
28    /// Default: 4.
29    pub dispatch_concurrency: NonZeroUsize,
30
31    /// AQ lease timeout in seconds. Must be long enough for the longest connector
32    /// invocation. Default: 300 (5 minutes).
33    pub lease_timeout_secs: u64,
34
35    /// Compiler configuration for FlowSpec compilation.
36    pub compiler_config: CompilerConfig,
37
38    /// Graceful shutdown timeout. How long to wait for in-flight work to complete.
39    /// Default: 30 seconds.
40    pub shutdown_timeout: Duration,
41
42    /// Metrics recorder for observability.
43    /// Defaults to [`NoopMetricsRecorder`] (no-op) for embedded/test use.
44    pub metrics: Arc<dyn MetricsRecorder>,
45}
46
47impl Default for HostConfig {
48    fn default() -> Self {
49        Self {
50            aq_data_dir: PathBuf::from("data/aq"),
51            context_store_path: PathBuf::from("data/context.db"),
52            tick_interval: Duration::from_millis(50),
53            dispatch_concurrency: NonZeroUsize::new(4).unwrap(),
54            lease_timeout_secs: 300,
55            compiler_config: CompilerConfig::default(),
56            shutdown_timeout: Duration::from_secs(30),
57            metrics: Arc::new(NoopMetricsRecorder),
58        }
59    }
60}
61
62impl std::fmt::Debug for HostConfig {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("HostConfig")
65            .field("aq_data_dir", &self.aq_data_dir)
66            .field("context_store_path", &self.context_store_path)
67            .field("tick_interval", &self.tick_interval)
68            .field("dispatch_concurrency", &self.dispatch_concurrency)
69            .field("lease_timeout_secs", &self.lease_timeout_secs)
70            .field("compiler_config", &self.compiler_config)
71            .field("shutdown_timeout", &self.shutdown_timeout)
72            .finish()
73    }
74}
75
76impl HostConfig {
77    /// Validate the configuration.
78    pub fn validate(&self) -> Result<(), HostError> {
79        if self.aq_data_dir.as_os_str().is_empty() {
80            return Err(HostError::InvalidConfig("aq_data_dir must be non-empty".into()));
81        }
82        if self.context_store_path.as_os_str().is_empty() {
83            return Err(HostError::InvalidConfig("context_store_path must be non-empty".into()));
84        }
85        if self.tick_interval.is_zero() {
86            return Err(HostError::InvalidConfig("tick_interval must be > 0".into()));
87        }
88        if self.lease_timeout_secs < 30 {
89            return Err(HostError::InvalidConfig("lease_timeout_secs must be >= 30".into()));
90        }
91        if self.shutdown_timeout.is_zero() {
92            return Err(HostError::InvalidConfig("shutdown_timeout must be > 0".into()));
93        }
94        Ok(())
95    }
96
97    /// Build a RuntimeConfig for ActionQueue from this HostConfig.
98    pub(crate) fn to_runtime_config(&self) -> RuntimeConfig {
99        RuntimeConfig {
100            data_dir: self.aq_data_dir.clone(),
101            backoff_strategy: BackoffStrategyConfig::Fixed { interval: Duration::from_secs(5) },
102            dispatch_concurrency: self.dispatch_concurrency,
103            lease_timeout_secs: self.lease_timeout_secs,
104            tick_interval: self.tick_interval,
105            snapshot_event_threshold: Some(10_000),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn default_config_is_valid() {
116        assert!(HostConfig::default().validate().is_ok());
117    }
118
119    #[test]
120    fn rejects_zero_tick_interval() {
121        let config = HostConfig { tick_interval: Duration::ZERO, ..Default::default() };
122        let err = config.validate().unwrap_err();
123        assert!(matches!(err, HostError::InvalidConfig(_)));
124    }
125
126    #[test]
127    fn rejects_low_lease_timeout() {
128        let config = HostConfig { lease_timeout_secs: 10, ..Default::default() };
129        let err = config.validate().unwrap_err();
130        assert!(matches!(err, HostError::InvalidConfig(_)));
131    }
132
133    #[test]
134    fn rejects_zero_shutdown_timeout() {
135        let config = HostConfig { shutdown_timeout: Duration::ZERO, ..Default::default() };
136        let err = config.validate().unwrap_err();
137        assert!(matches!(err, HostError::InvalidConfig(_)));
138    }
139
140    #[test]
141    fn to_runtime_config_preserves_values() {
142        let config = HostConfig {
143            aq_data_dir: PathBuf::from("/tmp/aq"),
144            tick_interval: Duration::from_millis(100),
145            dispatch_concurrency: NonZeroUsize::new(8).unwrap(),
146            lease_timeout_secs: 600,
147            ..Default::default()
148        };
149        let rc = config.to_runtime_config();
150        assert_eq!(rc.data_dir, PathBuf::from("/tmp/aq"));
151        assert_eq!(rc.tick_interval, Duration::from_millis(100));
152        assert_eq!(rc.dispatch_concurrency, NonZeroUsize::new(8).unwrap());
153        assert_eq!(rc.lease_timeout_secs, 600);
154    }
155}