Files
mytheclipse/crates/mytheclipse-config/src/dynamic.rs
T
asepharyana 19941156b4 fix(ci): gate event crate doctest behind mem feature and apply rustfmt
The crate-root doctest used InMemoryEventBus/TypedEventBus (gated behind
the 'mem' feature), so `cargo test -p mytheclipse-event --no-default-features
--features amqp|nats` failed to compile the doctest (E0432). The example is
now no_run with the mem-dependent imports inside a #[cfg(feature="mem")]
main, so it compiles (and runs) when mem is on and degrades to an empty main
when off.

Also apply rustfmt to mytheclipse-config/dynamic.rs and
mytheclipse-storage/{gcs,s3}.rs to satisfy cargo fmt --all --check.
2026-08-28 22:41:06 +07:00

195 lines
6.5 KiB
Rust

//! Runtime configuration hot-reload (feature `hot-reload`).
//!
//! [`DynamicConfig`] holds a typed configuration value behind an `RwLock`,
//! optionally watching a set of source files with `notify` and re-running a
//! caller-supplied reload closure whenever they change. Subscribers can await
//! a [`tokio::sync::broadcast::Receiver`] to react to a successful reload.
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::broadcast;
use crate::{Config, ConfigError};
/// A hot-reloadable, thread-safe configuration handle.
pub struct DynamicConfig<T> {
inner: Arc<RwLock<T>>,
tx: broadcast::Sender<()>,
}
impl<T: Config + Clone> Clone for DynamicConfig<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
tx: self.tx.clone(),
}
}
}
impl<T: Config + Clone> DynamicConfig<T> {
/// Wraps an already-loaded value with no file watching.
pub fn new(initial: T) -> Self {
let (tx, _rx) = broadcast::channel(16);
Self {
inner: Arc::new(RwLock::new(initial)),
tx,
}
}
/// Returns a clone of the current configuration snapshot.
pub fn get(&self) -> T {
self.inner
.read()
.expect("mytheclipse-config: RwLock poisoned")
.clone()
}
/// Replaces the current value and notifies subscribers.
pub fn set(&self, new: T) {
*self
.inner
.write()
.expect("mytheclipse-config: RwLock poisoned") = new;
let _ = self.tx.send(());
}
/// Subscribes to change notifications (fired after every successful
/// [`DynamicConfig::set`] or file-triggered reload).
pub fn subscribe(&self) -> broadcast::Receiver<()> {
self.tx.subscribe()
}
/// Loads the initial value via `reload`, then watches `paths` for
/// modifications and re-runs `reload` on each change (debounced), atomically
/// swapping in the result on success. A failed reload is logged via
/// `tracing::error!` and the previous value is retained.
///
/// The underlying OS file watcher lives on a dedicated background thread
/// for the lifetime of the process; there is currently no explicit
/// "unwatch" — construct one `DynamicConfig` per watched file set.
pub fn watch_files<F>(paths: Vec<PathBuf>, reload: F) -> Result<Self, ConfigError>
where
F: Fn() -> Result<T, ConfigError> + Send + Sync + 'static,
{
Self::watch_files_debounced(paths, reload, Duration::from_millis(50))
}
/// Same as [`DynamicConfig::watch_files`] with an explicit debounce
/// window (the minimum time between two applied reloads).
pub fn watch_files_debounced<F>(
paths: Vec<PathBuf>,
reload: F,
debounce: Duration,
) -> Result<Self, ConfigError>
where
F: Fn() -> Result<T, ConfigError> + Send + Sync + 'static,
{
let initial = reload()?;
let config = Self::new(initial);
let inner = Arc::clone(&config.inner);
let tx = config.tx.clone();
let (raw_tx, raw_rx) = std::sync::mpsc::channel();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res| {
let _ = raw_tx.send(res);
})
.map_err(|e| ConfigError::Watch(e.to_string()))?;
for path in &paths {
watcher
.watch(path, RecursiveMode::NonRecursive)
.map_err(|e| ConfigError::Watch(e.to_string()))?;
}
std::thread::Builder::new()
.name("mytheclipse-config-watch".into())
.spawn(move || {
// Keep the watcher alive for the life of this thread.
let _watcher = watcher;
let mut last_applied = Instant::now() - debounce;
for event in raw_rx {
if event.is_err() {
continue;
}
if last_applied.elapsed() < debounce {
continue;
}
match reload() {
Ok(new) => {
*inner.write().expect("mytheclipse-config: RwLock poisoned") = new;
let _ = tx.send(());
last_applied = Instant::now();
}
Err(e) => {
tracing::error!("mytheclipse-config: hot-reload failed: {e}");
}
}
}
})
.map_err(|e| ConfigError::Watch(e.to_string()))?;
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ConfigLoader;
use serde::Deserialize;
use std::io::Write;
#[derive(Debug, Deserialize, Clone, PartialEq)]
struct Cfg {
value: u32,
}
#[test]
fn set_and_get_roundtrip() {
let cfg = DynamicConfig::new(Cfg { value: 1 });
assert_eq!(cfg.get(), Cfg { value: 1 });
cfg.set(Cfg { value: 2 });
assert_eq!(cfg.get(), Cfg { value: 2 });
}
#[tokio::test]
async fn subscribe_receives_change_notification() {
let cfg = DynamicConfig::new(Cfg { value: 1 });
let mut rx = cfg.subscribe();
cfg.set(Cfg { value: 9 });
rx.recv().await.expect("change notification");
assert_eq!(cfg.get().value, 9);
}
#[tokio::test]
async fn watch_files_reloads_on_change() {
let mut file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
write!(file, r#"{{"value": 1}}"#).unwrap();
let path = file.path().to_path_buf();
let reload_path = path.clone();
let cfg = DynamicConfig::<Cfg>::watch_files_debounced(
vec![path.clone()],
move || {
ConfigLoader::new()
.merge_file(&reload_path)
.and_then(|l| l.build())
},
Duration::from_millis(10),
)
.expect("watch setup");
assert_eq!(cfg.get().value, 1);
let mut rx = cfg.subscribe();
// Rewrite the file to trigger a reload.
std::fs::write(&path, r#"{"value": 42}"#).unwrap();
// Wait (with a generous timeout) for the watcher thread to notice.
let result = tokio::time::timeout(Duration::from_secs(5), rx.recv()).await;
assert!(result.is_ok(), "expected a reload notification within 5s");
assert_eq!(cfg.get().value, 42);
}
}