feature-flag 0.1.0

Server-side feature flag evaluation for async Rust: targeting rules, sticky percentage rollouts, hot reload, zero RNG.
Documentation
//! File-backed hot reload for [`FlagEvaluator`].
//!
//! Polls a JSON file at a configurable cadence, comparing modified times.
//! When the mtime advances we re-parse + validate + `swap()` the evaluator.
//!
//! Polling (rather than fanotify / kqueue / `notify` crate) is intentional:
//! no platform-specific code, no extra dependency tree, and the reload
//! cadence for feature flags is typically minutes, not milliseconds.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime};

use tokio::fs;
use tokio::sync::Notify;
use tokio::task::JoinHandle;
use tokio::time::sleep;

use crate::error::FeatureFlagError;
use crate::evaluator::FlagEvaluator;
use crate::model::FlagSet;

/// Polling file watcher that keeps a [`FlagEvaluator`] in sync with a JSON file.
pub struct HotReloader {
    handle: JoinHandle<()>,
    stop: Arc<Notify>,
}

impl HotReloader {
    /// Spawn the watcher. The current contents of `path` are parsed and
    /// installed before the first interval tick.
    pub async fn spawn(
        path: impl Into<PathBuf>,
        evaluator: FlagEvaluator,
        interval: Duration,
    ) -> Result<Self, FeatureFlagError> {
        let path = path.into();

        // Initial load — fail fast if the file is unreadable / invalid.
        let raw = fs::read_to_string(&path)
            .await
            .map_err(|err| FeatureFlagError::Io {
                path: path.clone(),
                source: err,
            })?;
        let flagset = FlagSet::from_json(&raw)?;
        evaluator.swap(flagset);

        let stop = Arc::new(Notify::new());
        let stop_clone = stop.clone();
        let handle = tokio::spawn(async move {
            run_loop(path, evaluator, interval, stop_clone).await;
        });

        Ok(Self { handle, stop })
    }

    /// Ask the watcher to stop. Awaits the underlying task.
    pub async fn shutdown(self) {
        self.stop.notify_waiters();
        // If the task has already finished (because the loop exited), we just
        // join. Any panic is bubbled.
        let _ = self.handle.await;
    }
}

async fn run_loop(path: PathBuf, evaluator: FlagEvaluator, interval: Duration, stop: Arc<Notify>) {
    let mut last_modified: Option<SystemTime> = read_modified(&path).await;

    loop {
        tokio::select! {
            () = sleep(interval) => {}
            () = stop.notified() => return,
        }

        let current = read_modified(&path).await;
        if current.is_some() && current != last_modified {
            last_modified = current;
            match fs::read_to_string(&path).await {
                Ok(raw) => match FlagSet::from_json(&raw) {
                    Ok(flagset) => evaluator.swap(flagset),
                    Err(err) => {
                        // We don't fail the loop — a broken push shouldn't
                        // kill the watcher. Stick with the previously good set.
                        eprintln!("feature-flag hot-reload: parse error from {path:?}: {err}");
                    }
                },
                Err(err) => {
                    eprintln!("feature-flag hot-reload: read error from {path:?}: {err}");
                }
            }
        }
    }
}

async fn read_modified(path: &PathBuf) -> Option<SystemTime> {
    fs::metadata(path)
        .await
        .ok()
        .and_then(|m| m.modified().ok())
}