Skip to main content

laminardb/
watcher.rs

1//! File system watcher for automatic config hot-reload.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crossfire::{mpsc, MTx};
8use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
9use tracing::{debug, error, info, warn};
10
11fn file_content_hash(path: &std::path::Path) -> Option<u64> {
12    use std::hash::{Hash, Hasher};
13    let bytes = std::fs::read(path).ok()?;
14    let mut hasher = std::collections::hash_map::DefaultHasher::new();
15    bytes.hash(&mut hasher);
16    Some(hasher.finish())
17}
18
19use crate::config;
20use crate::http::AppState;
21use crate::reload;
22
23/// Watch the config file and trigger reload on changes. Runs until aborted.
24pub async fn watch_config(config_path: PathBuf, state: Arc<AppState>, debounce: Duration) {
25    let (tx, rx) = mpsc::bounded_async::<()>(16);
26    let blocking_tx: MTx<_> = tx.clone().into_blocking();
27
28    // Canonicalize the config path for reliable comparison
29    let canonical = match config_path.canonicalize() {
30        Ok(p) => p,
31        Err(e) => {
32            warn!(
33                "Could not canonicalize config path '{}': {e} — watcher disabled",
34                config_path.display()
35            );
36            return;
37        }
38    };
39
40    // Watch the parent directory (handles atomic saves: write-tmp + rename)
41    let watch_dir = match canonical.parent() {
42        Some(p) => p.to_path_buf(),
43        None => {
44            warn!("Config file has no parent directory — watcher disabled");
45            return;
46        }
47    };
48
49    let target = canonical.clone();
50    let mut watcher: RecommendedWatcher =
51        match notify::recommended_watcher(move |result: Result<Event, notify::Error>| {
52            match result {
53                Ok(event) => {
54                    let dominated = event.paths.iter().any(|p| {
55                        // Compare canonical paths to handle symlinks/relative paths
56                        p.canonicalize().ok().as_ref() == Some(&target)
57                    });
58                    if dominated {
59                        let _ = blocking_tx.send(());
60                    }
61                }
62                Err(e) => {
63                    warn!("File watcher error: {e}");
64                }
65            }
66        }) {
67            Ok(w) => w,
68            Err(e) => {
69                error!("Failed to create file watcher: {e} — hot reload disabled");
70                return;
71            }
72        };
73
74    if let Err(e) = watcher.watch(&watch_dir, RecursiveMode::NonRecursive) {
75        error!(
76            "Failed to watch directory '{}': {e} — hot reload disabled",
77            watch_dir.display()
78        );
79        return;
80    }
81
82    info!("Watching config file '{}' for changes", canonical.display());
83
84    // Track content hash to skip spurious inotify events (Docker overlay mounts)
85    let mut last_hash = file_content_hash(&canonical);
86
87    // Keep the watcher alive and process debounced events
88    loop {
89        // Wait for first notification
90        if rx.recv().await.is_err() {
91            debug!("Watcher channel closed, exiting");
92            return;
93        }
94
95        // Debounce: sleep then drain any queued notifications
96        tokio::time::sleep(debounce).await;
97        while rx.try_recv().is_ok() {}
98
99        let current_hash = file_content_hash(&canonical);
100        if current_hash == last_hash {
101            debug!("File event but content unchanged, skipping");
102            continue;
103        }
104
105        info!("Config file change detected, reloading...");
106
107        last_hash = current_hash;
108
109        // Load new config
110        let new_config = match config::load_config(&canonical) {
111            Ok(c) => c,
112            Err(e) => {
113                warn!("Failed to load config on file change: {e}");
114                continue;
115            }
116        };
117
118        // Acquire reload guard
119        let _guard = match state.reload_guard.try_acquire() {
120            Some(g) => g,
121            None => {
122                debug!("Another reload in progress, skipping file-triggered reload");
123                continue;
124            }
125        };
126
127        // Diff against current config. Scope the read guard tightly so
128        // the compiler knows it's out of scope before the next `.await`
129        // — `parking_lot`'s guard is `!Send` and would fail the
130        // `tokio::spawn` Send bound otherwise.
131        let diff = {
132            let current = state.current_config.read();
133            reload::diff_configs(&current, &new_config)
134        };
135
136        if diff.is_empty() {
137            for w in &diff.warnings {
138                warn!("Config reload warning: {w}");
139            }
140            if diff.warnings.is_empty() {
141                debug!("No reloadable changes detected");
142            }
143            continue;
144        }
145
146        // Apply the diff
147        let result = reload::apply_reload(&state.db, &diff).await;
148
149        // Update metrics
150        state.server_metrics.reload_total.inc();
151
152        if result.success {
153            let mut current = state.current_config.write();
154            *current = new_config;
155            info!(
156                "File-triggered reload complete: {} ops applied",
157                result.applied.len()
158            );
159        } else {
160            warn!(
161                "File-triggered reload partial failure: {} applied, {} failed",
162                result.applied.len(),
163                result.failed.len()
164            );
165        }
166
167        for w in &result.warnings {
168            warn!("Reload warning: {w}");
169        }
170    }
171}