Skip to main content

feature_flag/
hot_reload.rs

1//! File-backed hot reload for [`FlagEvaluator`].
2//!
3//! Polls a JSON file at a configurable cadence, comparing modified times.
4//! When the mtime advances we re-parse + validate + `swap()` the evaluator.
5//!
6//! Polling (rather than fanotify / kqueue / `notify` crate) is intentional:
7//! no platform-specific code, no extra dependency tree, and the reload
8//! cadence for feature flags is typically minutes, not milliseconds.
9
10use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13
14use tokio::fs;
15use tokio::sync::Notify;
16use tokio::task::JoinHandle;
17use tokio::time::sleep;
18
19use crate::error::FeatureFlagError;
20use crate::evaluator::FlagEvaluator;
21use crate::model::FlagSet;
22
23/// Polling file watcher that keeps a [`FlagEvaluator`] in sync with a JSON file.
24pub struct HotReloader {
25    handle: JoinHandle<()>,
26    stop: Arc<Notify>,
27}
28
29impl HotReloader {
30    /// Spawn the watcher. The current contents of `path` are parsed and
31    /// installed before the first interval tick.
32    pub async fn spawn(
33        path: impl Into<PathBuf>,
34        evaluator: FlagEvaluator,
35        interval: Duration,
36    ) -> Result<Self, FeatureFlagError> {
37        let path = path.into();
38
39        // Initial load — fail fast if the file is unreadable / invalid.
40        let raw = fs::read_to_string(&path)
41            .await
42            .map_err(|err| FeatureFlagError::Io {
43                path: path.clone(),
44                source: err,
45            })?;
46        let flagset = FlagSet::from_json(&raw)?;
47        evaluator.swap(flagset);
48
49        let stop = Arc::new(Notify::new());
50        let stop_clone = stop.clone();
51        let handle = tokio::spawn(async move {
52            run_loop(path, evaluator, interval, stop_clone).await;
53        });
54
55        Ok(Self { handle, stop })
56    }
57
58    /// Ask the watcher to stop. Awaits the underlying task.
59    pub async fn shutdown(self) {
60        self.stop.notify_waiters();
61        // If the task has already finished (because the loop exited), we just
62        // join. Any panic is bubbled.
63        let _ = self.handle.await;
64    }
65}
66
67async fn run_loop(path: PathBuf, evaluator: FlagEvaluator, interval: Duration, stop: Arc<Notify>) {
68    let mut last_modified: Option<SystemTime> = read_modified(&path).await;
69
70    loop {
71        tokio::select! {
72            () = sleep(interval) => {}
73            () = stop.notified() => return,
74        }
75
76        let current = read_modified(&path).await;
77        if current.is_some() && current != last_modified {
78            last_modified = current;
79            match fs::read_to_string(&path).await {
80                Ok(raw) => match FlagSet::from_json(&raw) {
81                    Ok(flagset) => evaluator.swap(flagset),
82                    Err(err) => {
83                        // We don't fail the loop — a broken push shouldn't
84                        // kill the watcher. Stick with the previously good set.
85                        eprintln!("feature-flag hot-reload: parse error from {path:?}: {err}");
86                    }
87                },
88                Err(err) => {
89                    eprintln!("feature-flag hot-reload: read error from {path:?}: {err}");
90                }
91            }
92        }
93    }
94}
95
96async fn read_modified(path: &PathBuf) -> Option<SystemTime> {
97    fs::metadata(path)
98        .await
99        .ok()
100        .and_then(|m| m.modified().ok())
101}