use std::num::NonZeroU32;
use std::time::Duration;
use serde::Deserialize;
use crate::error::ServerError;
use crate::worker::supervisor::SupervisionPolicy;
use super::config_error;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct WorkerSupervisionConfig {
pub restart_backoff_initial_ms: Option<u64>,
pub restart_backoff_max_ms: Option<u64>,
pub restart_backoff_multiplier: Option<u32>,
pub restart_window_ms: Option<u64>,
pub max_restarts_per_window: Option<u32>,
pub stop_grace_ms: Option<u64>,
}
const REQUIRED_KEYS: &[&str] = &[
"restart_backoff_initial_ms",
"restart_backoff_max_ms",
"restart_backoff_multiplier",
"restart_window_ms",
"max_restarts_per_window",
"stop_grace_ms",
];
impl WorkerSupervisionConfig {
fn missing(self) -> Vec<&'static str> {
let present = [
self.restart_backoff_initial_ms.is_some(),
self.restart_backoff_max_ms.is_some(),
self.restart_backoff_multiplier.is_some(),
self.restart_window_ms.is_some(),
self.max_restarts_per_window.is_some(),
self.stop_grace_ms.is_some(),
];
REQUIRED_KEYS
.iter()
.zip(present)
.filter_map(|(key, present)| (!present).then_some(*key))
.collect()
}
pub fn resolve(self) -> Result<Option<SupervisionPolicy>, ServerError> {
let missing = self.missing();
if missing.len() == REQUIRED_KEYS.len() {
return Ok(None);
}
if !missing.is_empty() {
return config_error(format!(
"[worker_supervision] is incomplete: it is missing {} — the section has no \
defaults, so write every key or remove the section entirely",
missing.join(", ")
));
}
let initial = Self::required_duration(self.restart_backoff_initial_ms, REQUIRED_KEYS[0])?;
let max = Self::required_duration(self.restart_backoff_max_ms, REQUIRED_KEYS[1])?;
let multiplier = Self::required_factor(self.restart_backoff_multiplier, REQUIRED_KEYS[2])?;
let window = Self::required_duration(self.restart_window_ms, REQUIRED_KEYS[3])?;
let budget = Self::required_factor(self.max_restarts_per_window, REQUIRED_KEYS[4])?;
let grace = Self::required_duration(self.stop_grace_ms, REQUIRED_KEYS[5])?;
if max < initial {
return config_error(format!(
"worker_supervision.restart_backoff_max_ms ({}) must be at least \
restart_backoff_initial_ms ({})",
max.as_millis(),
initial.as_millis()
));
}
Ok(Some(SupervisionPolicy {
restart_backoff_initial: initial,
restart_backoff_max: max,
restart_backoff_multiplier: multiplier,
restart_window: window,
max_restarts_per_window: budget,
stop_grace: grace,
}))
}
fn required_duration(value: Option<u64>, key: &'static str) -> Result<Duration, ServerError> {
match value {
Some(millis) if millis > 0 => Ok(Duration::from_millis(millis)),
_ => config_error(format!(
"worker_supervision.{key} must be greater than zero milliseconds"
)),
}
}
fn required_factor(value: Option<u32>, key: &'static str) -> Result<NonZeroU32, ServerError> {
value.and_then(NonZeroU32::new).map_or_else(
|| {
config_error(format!(
"worker_supervision.{key} must be greater than zero"
))
},
Ok,
)
}
}
#[cfg(test)]
mod tests {
use super::{REQUIRED_KEYS, WorkerSupervisionConfig};
fn complete() -> WorkerSupervisionConfig {
WorkerSupervisionConfig {
restart_backoff_initial_ms: Some(100),
restart_backoff_max_ms: Some(5_000),
restart_backoff_multiplier: Some(2),
restart_window_ms: Some(60_000),
max_restarts_per_window: Some(5),
stop_grace_ms: Some(2_000),
}
}
#[test]
fn an_absent_section_commissions_nothing_and_is_not_an_error()
-> Result<(), Box<dyn std::error::Error>> {
assert_eq!(WorkerSupervisionConfig::default().resolve()?, None);
Ok(())
}
#[test]
fn a_complete_section_resolves_every_value() -> Result<(), Box<dyn std::error::Error>> {
let policy = complete()
.resolve()?
.ok_or("a complete section must resolve")?;
assert_eq!(policy.restart_backoff_initial.as_millis(), 100);
assert_eq!(policy.restart_backoff_max.as_millis(), 5_000);
assert_eq!(policy.restart_backoff_multiplier.get(), 2);
assert_eq!(policy.restart_window.as_millis(), 60_000);
assert_eq!(policy.max_restarts_per_window.get(), 5);
assert_eq!(policy.stop_grace.as_millis(), 2_000);
Ok(())
}
#[test]
fn a_partial_section_is_refused_and_names_every_missing_key()
-> Result<(), Box<dyn std::error::Error>> {
let partial = WorkerSupervisionConfig {
restart_backoff_initial_ms: Some(100),
..WorkerSupervisionConfig::default()
};
let error = partial
.resolve()
.err()
.ok_or("a partial section must be refused")?
.to_string();
for key in &REQUIRED_KEYS[1..] {
assert!(
error.contains(key),
"the refusal must name `{key}`: {error}"
);
}
assert!(
!error.contains(REQUIRED_KEYS[0]),
"the refusal must not name a key that IS present: {error}"
);
Ok(())
}
#[test]
fn a_zero_interval_is_refused_by_name() -> Result<(), Box<dyn std::error::Error>> {
for (mutate, key) in [
(
(|config: &mut WorkerSupervisionConfig| config.restart_backoff_initial_ms = Some(0))
as fn(&mut WorkerSupervisionConfig),
"restart_backoff_initial_ms",
),
(
|config: &mut WorkerSupervisionConfig| config.restart_window_ms = Some(0),
"restart_window_ms",
),
(
|config: &mut WorkerSupervisionConfig| config.stop_grace_ms = Some(0),
"stop_grace_ms",
),
(
|config: &mut WorkerSupervisionConfig| config.restart_backoff_multiplier = Some(0),
"restart_backoff_multiplier",
),
(
|config: &mut WorkerSupervisionConfig| config.max_restarts_per_window = Some(0),
"max_restarts_per_window",
),
] {
let mut config = complete();
mutate(&mut config);
let error = config
.resolve()
.err()
.ok_or("a zero value must be refused")?
.to_string();
assert!(
error.contains(key),
"the refusal must name `{key}`: {error}"
);
}
Ok(())
}
#[test]
fn a_ceiling_below_the_initial_backoff_is_refused() -> Result<(), Box<dyn std::error::Error>> {
let mut config = complete();
config.restart_backoff_max_ms = Some(10);
let error = config
.resolve()
.err()
.ok_or("a ceiling below the initial delay must be refused")?
.to_string();
assert!(error.contains("restart_backoff_max_ms"));
assert!(error.contains("restart_backoff_initial_ms"));
Ok(())
}
}