Skip to main content

config_lib/
hot_reload.rs

1//! Configuration Hot Reloading System
2//!
3//! Enterprise-grade hot reloading with:
4//! - File watching for automatic updates
5//! - Arc swapping for zero-downtime updates
6//! - Change notifications and callbacks
7//! - Thread-safe concurrent access
8//! - Graceful error handling and fallback
9
10use crate::config::Config;
11use crate::error::{Error, Result};
12use std::path::{Path, PathBuf};
13use std::sync::mpsc::{self, Receiver, Sender};
14use std::sync::{Arc, RwLock};
15use std::thread;
16use std::time::{Duration, SystemTime};
17
18/// Configuration change event types.
19///
20/// **Stability:** `ConfigChangeEvent` is `#[non_exhaustive]` so the
21/// v1.x SemVer contract can add new variants (e.g. `Renamed`,
22/// `PermissionDenied`) in MINOR releases without breaking user code.
23/// Callers must use a wildcard arm when pattern-matching.
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum ConfigChangeEvent {
27    /// Configuration successfully reloaded
28    Reloaded {
29        /// Path to the configuration file that was reloaded
30        path: PathBuf,
31        /// Timestamp when the reload completed
32        timestamp: SystemTime,
33    },
34    /// Configuration reload failed
35    ReloadFailed {
36        /// Path to the configuration file that failed to reload
37        path: PathBuf,
38        /// Error message describing what went wrong
39        error: String,
40        /// Timestamp when the error occurred
41        timestamp: SystemTime,
42    },
43    /// Configuration file was modified
44    FileModified {
45        /// Path to the configuration file that was modified
46        path: PathBuf,
47        /// Timestamp when the modification was detected
48        timestamp: SystemTime,
49    },
50    /// Configuration file was deleted
51    FileDeleted {
52        /// Path to the configuration file that was deleted
53        path: PathBuf,
54        /// Timestamp when the deletion was detected
55        timestamp: SystemTime,
56    },
57}
58
59/// Hot-reloadable configuration container
60pub struct HotReloadConfig {
61    /// Current configuration (thread-safe)
62    current: Arc<RwLock<Config>>,
63    /// File path being watched
64    file_path: PathBuf,
65    /// Last known modification time
66    last_modified: SystemTime,
67    /// Event sender for notifications
68    event_sender: Option<Sender<ConfigChangeEvent>>,
69    /// Polling interval for file changes
70    poll_interval: Duration,
71}
72
73impl HotReloadConfig {
74    /// Create a new hot-reloadable configuration from a file
75    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
76        let path = path.as_ref().to_path_buf();
77        let config = Config::from_file(&path)?;
78
79        let last_modified = std::fs::metadata(&path)
80            .map_err(|e| Error::io(path.display().to_string(), e))?
81            .modified()
82            .map_err(|e| Error::io(path.display().to_string(), e))?;
83
84        Ok(Self {
85            current: Arc::new(RwLock::new(config)),
86            file_path: path,
87            last_modified,
88            event_sender: None,
89            poll_interval: Duration::from_secs(1), // Default 1 second polling
90        })
91    }
92
93    /// Set the polling interval for file change detection
94    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
95        self.poll_interval = interval;
96        self
97    }
98
99    /// Enable change notifications
100    pub fn with_change_notifications(mut self) -> (Self, Receiver<ConfigChangeEvent>) {
101        let (sender, receiver) = mpsc::channel();
102        self.event_sender = Some(sender);
103        (self, receiver)
104    }
105
106    /// Get a thread-safe reference to the current configuration
107    pub fn config(&self) -> Arc<RwLock<Config>> {
108        Arc::clone(&self.current)
109    }
110
111    /// Get a read-only snapshot of the current configuration
112    pub fn snapshot(&self) -> Result<Config> {
113        let _config = self
114            .current
115            .read()
116            .map_err(|_| Error::concurrency("Failed to acquire read lock".to_string()))?;
117
118        // Create a deep copy of the config
119        // Since Config doesn't implement Clone, we'll serialize and deserialize
120        let _content = std::fs::read_to_string(&self.file_path)
121            .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
122
123        Config::from_file(&self.file_path)
124    }
125
126    /// Manually trigger a reload
127    pub fn reload(&mut self) -> Result<bool> {
128        let metadata = std::fs::metadata(&self.file_path)
129            .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
130
131        let modified = metadata
132            .modified()
133            .map_err(|e| Error::io(self.file_path.display().to_string(), e))?;
134
135        if modified <= self.last_modified {
136            return Ok(false); // No changes
137        }
138
139        match Config::from_file(&self.file_path) {
140            Ok(new_config) => {
141                // Atomic swap of configuration
142                {
143                    let mut config = self.current.write().map_err(|_| {
144                        Error::concurrency("Failed to acquire write lock".to_string())
145                    })?;
146                    *config = new_config;
147                }
148
149                self.last_modified = modified;
150
151                // Send notification if enabled
152                if let Some(ref sender) = self.event_sender {
153                    let _ = sender.send(ConfigChangeEvent::Reloaded {
154                        path: self.file_path.clone(),
155                        timestamp: SystemTime::now(),
156                    });
157                }
158
159                Ok(true)
160            }
161            Err(e) => {
162                // Send error notification if enabled
163                if let Some(ref sender) = self.event_sender {
164                    let _ = sender.send(ConfigChangeEvent::ReloadFailed {
165                        path: self.file_path.clone(),
166                        error: e.to_string(),
167                        timestamp: SystemTime::now(),
168                    });
169                }
170                Err(e)
171            }
172        }
173    }
174
175    /// Start automatic hot reloading in a background thread
176    pub fn start_watching(self) -> HotReloadHandle {
177        let (stop_sender, stop_receiver) = mpsc::channel();
178        let config_clone = Arc::clone(&self.current);
179        let file_path = self.file_path.clone();
180        let event_sender = self.event_sender.clone();
181        let poll_interval = self.poll_interval;
182        let mut last_modified = self.last_modified;
183
184        let handle = thread::spawn(move || {
185            loop {
186                // Check for stop signal
187                if stop_receiver.try_recv().is_ok() {
188                    break;
189                }
190
191                // Check for file changes
192                if let Ok(metadata) = std::fs::metadata(&file_path) {
193                    if let Ok(modified) = metadata.modified() {
194                        if modified > last_modified {
195                            // File was modified, send notification
196                            if let Some(ref sender) = event_sender {
197                                let _ = sender.send(ConfigChangeEvent::FileModified {
198                                    path: file_path.clone(),
199                                    timestamp: SystemTime::now(),
200                                });
201                            }
202
203                            // Attempt to reload
204                            match Config::from_file(&file_path) {
205                                Ok(new_config) => {
206                                    // Atomic swap
207                                    if let Ok(mut config) = config_clone.write() {
208                                        *config = new_config;
209                                        last_modified = modified;
210
211                                        // Send success notification
212                                        if let Some(ref sender) = event_sender {
213                                            let _ = sender.send(ConfigChangeEvent::Reloaded {
214                                                path: file_path.clone(),
215                                                timestamp: SystemTime::now(),
216                                            });
217                                        }
218                                    }
219                                }
220                                Err(e) => {
221                                    // Send error notification
222                                    if let Some(ref sender) = event_sender {
223                                        let _ = sender.send(ConfigChangeEvent::ReloadFailed {
224                                            path: file_path.clone(),
225                                            error: e.to_string(),
226                                            timestamp: SystemTime::now(),
227                                        });
228                                    }
229                                }
230                            }
231                        }
232                    }
233                }
234
235                thread::sleep(poll_interval);
236            }
237        });
238
239        HotReloadHandle {
240            handle: Some(handle),
241            stop_sender,
242        }
243    }
244
245    /// Get the file path being watched
246    pub fn file_path(&self) -> &Path {
247        &self.file_path
248    }
249
250    /// Get the last modification time
251    pub fn last_modified(&self) -> SystemTime {
252        self.last_modified
253    }
254}
255
256/// Handle for controlling hot reload background thread
257pub struct HotReloadHandle {
258    handle: Option<thread::JoinHandle<()>>,
259    stop_sender: Sender<()>,
260}
261
262impl HotReloadHandle {
263    /// Stop the background watching thread
264    pub fn stop(mut self) -> Result<()> {
265        if self.stop_sender.send(()).is_err() {
266            return Err(Error::concurrency("Failed to send stop signal".to_string()));
267        }
268
269        if let Some(handle) = self.handle.take() {
270            handle
271                .join()
272                .map_err(|_| Error::concurrency("Failed to join background thread".to_string()))?;
273        }
274
275        Ok(())
276    }
277}
278
279impl Drop for HotReloadHandle {
280    fn drop(&mut self) {
281        let _ = self.stop_sender.send(());
282        if let Some(handle) = self.handle.take() {
283            let _ = handle.join();
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::fs::File;
292    use std::io::Write;
293    use tempfile::TempDir;
294
295    #[test]
296    fn test_hot_reload_basic() {
297        let temp_dir = TempDir::new().unwrap();
298        let config_path = temp_dir.path().join("test.conf");
299
300        // Create initial config file
301        let mut file = File::create(&config_path).unwrap();
302        writeln!(file, "key=value1").unwrap();
303        file.flush().unwrap();
304        drop(file);
305
306        // Create hot reload config
307        let mut hot_config = HotReloadConfig::from_file(&config_path).unwrap();
308
309        // Read initial value
310        {
311            let config = hot_config.config();
312            let config_read = config.read().unwrap();
313            assert_eq!(
314                config_read.get("key").unwrap().as_string().unwrap(),
315                "value1"
316            );
317        }
318
319        // Wait a bit to ensure different modification time
320        thread::sleep(Duration::from_millis(10));
321
322        // Update config file
323        let mut file = File::create(&config_path).unwrap();
324        writeln!(file, "key=value2").unwrap();
325        file.flush().unwrap();
326        drop(file);
327
328        // Manual reload
329        let reloaded = hot_config.reload().unwrap();
330        assert!(reloaded);
331
332        // Verify new value
333        {
334            let config = hot_config.config();
335            let config_read = config.read().unwrap();
336            assert_eq!(
337                config_read.get("key").unwrap().as_string().unwrap(),
338                "value2"
339            );
340        }
341    }
342
343    #[test]
344    fn test_hot_reload_notifications() {
345        let temp_dir = TempDir::new().unwrap();
346        let config_path = temp_dir.path().join("test.conf");
347
348        // Create initial config file
349        let mut file = File::create(&config_path).unwrap();
350        writeln!(file, "key=value1").unwrap();
351        file.flush().unwrap();
352        drop(file);
353
354        // Create hot reload config with notifications
355        let (mut hot_config, receiver) = HotReloadConfig::from_file(&config_path)
356            .unwrap()
357            .with_change_notifications();
358
359        // Wait a bit
360        thread::sleep(Duration::from_millis(10));
361
362        // Update config file
363        let mut file = File::create(&config_path).unwrap();
364        writeln!(file, "key=value2").unwrap();
365        file.flush().unwrap();
366        drop(file);
367
368        // Manual reload should trigger notification
369        hot_config.reload().unwrap();
370
371        // Check for notification
372        let event = receiver.try_recv().unwrap();
373        match event {
374            ConfigChangeEvent::Reloaded { path, .. } => {
375                assert_eq!(path, config_path);
376            }
377            _ => panic!("Expected Reloaded event"),
378        }
379    }
380
381    #[test]
382    fn test_automatic_watching() {
383        let temp_dir = TempDir::new().unwrap();
384        let config_path = temp_dir.path().join("test.conf");
385
386        // Create initial config file
387        let mut file = File::create(&config_path).unwrap();
388        writeln!(file, "key=value1").unwrap();
389        file.flush().unwrap();
390        drop(file);
391
392        // Create hot reload config with fast polling
393        let (hot_config, receiver) = HotReloadConfig::from_file(&config_path)
394            .unwrap()
395            .with_poll_interval(Duration::from_millis(50))
396            .with_change_notifications();
397
398        let config_ref = hot_config.config();
399        let handle = hot_config.start_watching();
400
401        // Wait a bit
402        thread::sleep(Duration::from_millis(100));
403
404        // Update config file
405        let mut file = File::create(&config_path).unwrap();
406        writeln!(file, "key=value2").unwrap();
407        file.flush().unwrap();
408        drop(file);
409
410        // Wait for automatic reload
411        thread::sleep(Duration::from_millis(200));
412
413        // Check that config was updated
414        {
415            let config_read = config_ref.read().unwrap();
416            assert_eq!(
417                config_read.get("key").unwrap().as_string().unwrap(),
418                "value2"
419            );
420        }
421
422        // Check for notifications
423        let mut received_events = Vec::new();
424        while let Ok(event) = receiver.try_recv() {
425            received_events.push(event);
426        }
427
428        assert!(!received_events.is_empty());
429
430        // Should have received at least a Reloaded event
431        let has_reloaded = received_events
432            .iter()
433            .any(|event| matches!(event, ConfigChangeEvent::Reloaded { .. }));
434        assert!(has_reloaded);
435
436        // Stop watching
437        handle.stop().unwrap();
438    }
439}