use super::versioned::{ConfigDelta, ConfigVersion};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadOutcome {
Applied {
from_version: ConfigVersion,
to_version: ConfigVersion,
},
Ignored {
observed_version: ConfigVersion,
delta_to_version: ConfigVersion,
},
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum ReloadError {
#[error("stale delta: from `{delta_from}`, observed `{observed}`")]
StaleDelta {
delta_from: ConfigVersion,
observed: ConfigVersion,
},
#[error("validation rejected: {0}")]
Validation(String),
#[error("component error: {0}")]
Component(String),
}
pub trait Reloadable: Send + Sync {
type Config;
fn current_version(&self) -> ConfigVersion;
fn apply(&mut self, delta: &ConfigDelta<Self::Config>) -> Result<ReloadOutcome, ReloadError>;
}
pub fn classify_apply(
observed: ConfigVersion,
delta_from: ConfigVersion,
delta_to: ConfigVersion,
) -> Result<DeltaDecision, ReloadError> {
if delta_to <= observed {
return Ok(DeltaDecision::Ignore);
}
if delta_from != observed {
return Err(ReloadError::StaleDelta {
delta_from,
observed,
});
}
Ok(DeltaDecision::Apply)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaDecision {
Apply,
Ignore,
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
use std::sync::Arc;
#[derive(Default)]
struct Counter {
version: ConfigVersion,
value: Arc<Mutex<u32>>,
}
impl Reloadable for Counter {
type Config = u32;
fn current_version(&self) -> ConfigVersion {
self.version
}
fn apply(
&mut self,
delta: &ConfigDelta<Self::Config>,
) -> Result<ReloadOutcome, ReloadError> {
match classify_apply(self.version, delta.from_version, delta.to_version)? {
DeltaDecision::Ignore => Ok(ReloadOutcome::Ignored {
observed_version: self.version,
delta_to_version: delta.to_version,
}),
DeltaDecision::Apply => {
if delta.new_value == 0 {
return Err(ReloadError::Validation("value must be non-zero".into()));
}
*self.value.lock() = delta.new_value;
let from = self.version;
self.version = delta.to_version;
Ok(ReloadOutcome::Applied {
from_version: from,
to_version: delta.to_version,
})
}
}
}
}
#[test]
fn fresh_apply_succeeds_from_sentinel() {
let mut c = Counter::default();
let d = ConfigDelta::new(ConfigVersion::SENTINEL, 42);
let out = c.apply(&d).unwrap();
assert!(matches!(out, ReloadOutcome::Applied { .. }));
assert_eq!(c.current_version(), ConfigVersion(1));
assert_eq!(*c.value.lock(), 42);
}
#[test]
fn second_apply_advances_version() {
let mut c = Counter::default();
c.apply(&ConfigDelta::new(ConfigVersion::SENTINEL, 42))
.unwrap();
c.apply(&ConfigDelta::new(ConfigVersion(1), 99)).unwrap();
assert_eq!(c.current_version(), ConfigVersion(2));
assert_eq!(*c.value.lock(), 99);
}
#[test]
fn stale_delta_rejected() {
let mut c = Counter::default();
c.apply(&ConfigDelta::new(ConfigVersion::SENTINEL, 42))
.unwrap();
let stale = ConfigDelta::new(ConfigVersion(5), 7);
let err = c.apply(&stale).unwrap_err();
assert!(matches!(
err,
ReloadError::StaleDelta {
delta_from: ConfigVersion(5),
observed: ConfigVersion(1)
}
));
assert_eq!(*c.value.lock(), 42);
}
#[test]
fn replay_below_current_version_is_ignored() {
let mut c = Counter::default();
c.apply(&ConfigDelta::new(ConfigVersion::SENTINEL, 42))
.unwrap();
c.apply(&ConfigDelta::new(ConfigVersion(1), 99)).unwrap();
let stale_replay = ConfigDelta {
from_version: ConfigVersion::SENTINEL,
to_version: ConfigVersion(1),
new_value: 42,
};
let out = c.apply(&stale_replay).unwrap();
match out {
ReloadOutcome::Ignored {
observed_version,
delta_to_version,
} => {
assert_eq!(observed_version, ConfigVersion(2));
assert_eq!(delta_to_version, ConfigVersion(1));
}
other => panic!("expected Ignored, got {:?}", other),
}
assert_eq!(*c.value.lock(), 99);
}
#[test]
fn validation_failure_preserves_old_value() {
let mut c = Counter::default();
c.apply(&ConfigDelta::new(ConfigVersion::SENTINEL, 42))
.unwrap();
let bad = ConfigDelta::new(ConfigVersion(1), 0);
let err = c.apply(&bad).unwrap_err();
assert!(matches!(err, ReloadError::Validation(_)));
assert_eq!(*c.value.lock(), 42);
assert_eq!(c.current_version(), ConfigVersion(1));
}
#[test]
fn classify_apply_decisions() {
assert_eq!(
classify_apply(ConfigVersion(1), ConfigVersion(1), ConfigVersion(2)).unwrap(),
DeltaDecision::Apply
);
assert_eq!(
classify_apply(ConfigVersion(5), ConfigVersion(1), ConfigVersion(2)).unwrap(),
DeltaDecision::Ignore
);
assert_eq!(
classify_apply(ConfigVersion(2), ConfigVersion(1), ConfigVersion(2)).unwrap(),
DeltaDecision::Ignore
);
let err = classify_apply(ConfigVersion(3), ConfigVersion(1), ConfigVersion(4)).unwrap_err();
assert!(matches!(err, ReloadError::StaleDelta { .. }));
}
#[test]
fn dyn_dispatch_via_trait_object() {
let counter: Box<dyn Reloadable<Config = u32>> = Box::new(Counter::default());
assert_eq!(counter.current_version(), ConfigVersion::SENTINEL);
}
}