use std::time::Duration;
#[derive(Clone, Debug)]
pub struct Cadences {
ordered: Vec<Duration>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum CadenceParseError {
Empty,
BadToken(String),
}
impl std::fmt::Display for CadenceParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "empty cadence list"),
Self::BadToken(s) => write!(f, "not a duration: '{s}' (try '10s', '1m', '1h')"),
}
}
}
impl std::error::Error for CadenceParseError {}
#[derive(Debug, PartialEq, Eq)]
pub enum CadenceTreeError {
BelowBase { cadence: Duration, base: Duration },
NotMultiple { cadence: Duration, base: Duration },
}
impl std::fmt::Display for CadenceTreeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BelowBase { cadence, base } => write!(
f,
"cadence {cadence:?} is smaller than base interval {base:?}"
),
Self::NotMultiple { cadence, base } => write!(
f,
"cadence {cadence:?} is not an integer multiple of base {base:?}"
),
}
}
}
impl std::error::Error for CadenceTreeError {}
impl Cadences {
pub fn new(cadences: &[Duration]) -> Result<Self, CadenceParseError> {
let mut seen = std::collections::HashSet::new();
let mut ordered = Vec::with_capacity(cadences.len());
for c in cadences {
if seen.insert(*c) {
ordered.push(*c);
}
}
if ordered.is_empty() {
return Err(CadenceParseError::Empty);
}
Ok(Self { ordered })
}
pub fn defaults() -> Self {
Self::new(&[
Duration::from_secs(1),
Duration::from_secs(10),
Duration::from_secs(30),
Duration::from_secs(60),
Duration::from_secs(300),
])
.expect("static default cadences are valid")
}
pub fn parse(s: &str) -> Result<Self, CadenceParseError> {
let mut cadences = Vec::new();
for token in s.split(',') {
let t = token.trim();
if t.is_empty() {
continue;
}
cadences.push(parse_duration(t).map_err(|_| CadenceParseError::BadToken(t.into()))?);
}
Self::new(&cadences)
}
pub fn iter(&self) -> impl Iterator<Item = Duration> + '_ {
self.ordered.iter().copied()
}
pub fn len(&self) -> usize {
self.ordered.len()
}
pub fn is_empty(&self) -> bool {
self.ordered.is_empty()
}
pub fn smallest(&self) -> Duration {
self.ordered.iter().copied().min().unwrap_or_default()
}
pub fn largest(&self) -> Duration {
self.ordered.iter().copied().max().unwrap_or_default()
}
}
pub fn parse_duration(s: &str) -> Result<Duration, ()> {
let s = s.trim();
if let Some(n) = s.strip_suffix("ms") {
return n
.trim()
.parse::<u64>()
.map(Duration::from_millis)
.map_err(|_| ());
}
if let Some(n) = s.strip_suffix('s') {
return n
.trim()
.parse::<u64>()
.map(Duration::from_secs)
.map_err(|_| ());
}
if let Some(n) = s.strip_suffix('m') {
return n
.trim()
.parse::<u64>()
.map(|v| Duration::from_secs(v * 60))
.map_err(|_| ());
}
if let Some(n) = s.strip_suffix('h') {
return n
.trim()
.parse::<u64>()
.map(|v| Duration::from_secs(v * 3600))
.map_err(|_| ());
}
s.parse::<u64>().map(Duration::from_secs).map_err(|_| ())
}
pub const DEFAULT_MAX_FAN_IN: u32 = 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CadenceLayer {
pub interval: Duration,
pub hidden: bool,
}
#[derive(Clone, Debug)]
pub struct CadenceTree {
declared: Cadences,
layers: Vec<CadenceLayer>,
max_fan_in: u32,
}
impl CadenceTree {
pub fn plan_validated(
declared: Cadences,
max_fan_in: u32,
base_interval: Duration,
) -> Result<Self, CadenceTreeError> {
for c in declared.iter() {
if c < base_interval {
return Err(CadenceTreeError::BelowBase {
cadence: c,
base: base_interval,
});
}
if base_interval.as_nanos() == 0 || c.as_nanos() % base_interval.as_nanos() != 0 {
return Err(CadenceTreeError::NotMultiple {
cadence: c,
base: base_interval,
});
}
}
Ok(Self::plan(declared, max_fan_in))
}
pub fn plan(declared: Cadences, max_fan_in: u32) -> Self {
let mut sorted: Vec<Duration> = declared.iter().collect();
sorted.sort_unstable();
sorted.dedup();
let declared_set: std::collections::HashSet<Duration> = sorted.iter().copied().collect();
let mut layers: Vec<Duration> = sorted.clone();
synthesize_intermediates(&mut layers, max_fan_in);
let realized: Vec<CadenceLayer> = layers
.iter()
.map(|d| CadenceLayer {
interval: *d,
hidden: !declared_set.contains(d),
})
.collect();
log_realized_tree(&realized, max_fan_in);
Self {
declared,
layers: realized,
max_fan_in,
}
}
pub fn plan_default(declared: Cadences) -> Self {
Self::plan(declared, DEFAULT_MAX_FAN_IN)
}
pub fn declared(&self) -> &Cadences {
&self.declared
}
pub fn layers(&self) -> &[CadenceLayer] {
&self.layers
}
pub fn max_fan_in(&self) -> u32 {
self.max_fan_in
}
pub fn hidden(&self) -> impl Iterator<Item = Duration> + '_ {
self.layers.iter().filter(|l| l.hidden).map(|l| l.interval)
}
pub fn align_to_declared(&self, preferred: Duration) -> Option<Duration> {
let mut declared_sorted: Vec<Duration> = self.declared.iter().collect();
declared_sorted.sort_unstable();
if declared_sorted.is_empty() {
return None;
}
declared_sorted
.iter()
.copied()
.find(|&d| d >= preferred)
.or_else(|| declared_sorted.last().copied())
}
}
fn synthesize_intermediates(
layers: &mut Vec<Duration>,
k: u32,
) -> Vec<(Duration, Duration, Duration)> {
let mut inserted: Vec<(Duration, Duration, Duration)> = Vec::new();
if k < 2 || layers.len() < 2 {
return inserted;
}
let k_f = k as f64;
let max_rounds = 8;
for _ in 0..max_rounds {
let mut changed = false;
let mut i = 0;
while i + 1 < layers.len() {
let a = layers[i];
let b = layers[i + 1];
let ratio = b.as_secs_f64() / a.as_secs_f64().max(f64::EPSILON);
if ratio <= k_f {
i += 1;
continue;
}
let n_steps = (ratio.ln() / k_f.ln()).ceil().max(1.0) as u32;
let n_inserts = n_steps.saturating_sub(1).max(1);
let step_ratio = ratio.powf(1.0 / (n_inserts as f64 + 1.0));
let mut new_intervals: Vec<Duration> = Vec::with_capacity(n_inserts as usize);
for j in 1..=n_inserts {
let raw_secs = a.as_secs_f64() * step_ratio.powi(j as i32);
let nice = nicest_duration(raw_secs);
if nice > a
&& nice < b
&& !new_intervals.contains(&nice)
&& !layers[..=i].contains(&nice)
&& !layers[i + 1..].contains(&nice)
{
new_intervals.push(nice);
inserted.push((a, b, nice));
}
}
if new_intervals.is_empty() {
let mid_secs = (a.as_secs_f64() * step_ratio).round().max(1.0) as u64;
let mid = Duration::from_secs(mid_secs);
if mid > a && mid < b {
new_intervals.push(mid);
inserted.push((a, b, mid));
}
}
if !new_intervals.is_empty() {
let insert_at = i + 1;
for (off, d) in new_intervals.iter().enumerate() {
layers.insert(insert_at + off, *d);
}
changed = true;
continue;
}
i += 1;
}
if !changed {
break;
}
}
layers.sort_unstable();
layers.dedup();
inserted
}
const NICE_SECONDS: &[u64] = &[
1,
2,
5,
10,
15,
20,
30,
45,
60,
2 * 60,
5 * 60,
10 * 60,
15 * 60,
20 * 60,
30 * 60,
45 * 60,
3600,
2 * 3600,
3 * 3600,
4 * 3600,
6 * 3600,
8 * 3600,
12 * 3600,
24 * 3600,
2 * 86_400,
7 * 86_400,
];
fn nicest_duration(secs: f64) -> Duration {
if !secs.is_finite() || secs <= 0.0 {
return Duration::from_secs(1);
}
let target = secs.ln();
let best = NICE_SECONDS
.iter()
.min_by(|a, b| {
let da = ((**a as f64).ln() - target).abs();
let db = ((**b as f64).ln() - target).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
.unwrap_or_else(|| secs.round() as u64);
Duration::from_secs(best)
}
pub fn format_duration_short(d: Duration) -> String {
let total = d.as_secs();
if total == 0 {
let ms = d.subsec_millis();
return if ms == 0 {
"0s".into()
} else {
format!("{ms}ms")
};
}
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
match (h, m, s) {
(h, 0, 0) if h > 0 => format!("{h}h"),
(h, m, 0) if h > 0 => format!("{h}h{m}m"),
(0, m, 0) if m > 0 => format!("{m}m"),
(0, 0, s) => format!("{s}s"),
(0, m, s) if m > 0 => format!("{m}m{s}s"),
(h, m, s) => format!("{h}h{m}m{s}s"),
}
}
fn log_realized_tree(realized: &[CadenceLayer], max_fan_in: u32) {
let cadences_str = realized
.iter()
.map(|l| {
let s = format_duration_short(l.interval);
if l.hidden { format!("({s})") } else { s }
})
.collect::<Vec<_>>()
.join(", ");
crate::diag::info(&format!(
"metrics: cadences: [{cadences_str}] / {max_fan_in}"
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cadence_parse_normal() {
let c = Cadences::parse("10s,1m,10m,1h").unwrap();
let got: Vec<_> = c.iter().collect();
assert_eq!(
got,
vec![
Duration::from_secs(10),
Duration::from_secs(60),
Duration::from_secs(600),
Duration::from_secs(3600),
]
);
}
#[test]
fn cadence_parse_whitespace_and_units() {
let c = Cadences::parse(" 30s , 5m,2h ").unwrap();
let got: Vec<_> = c.iter().collect();
assert_eq!(got[0], Duration::from_secs(30));
assert_eq!(got[1], Duration::from_secs(300));
assert_eq!(got[2], Duration::from_secs(7200));
}
#[test]
fn cadence_parse_accepts_sub_second() {
let c = Cadences::parse("500ms,1s").unwrap();
let got: Vec<_> = c.iter().collect();
assert_eq!(got[0], Duration::from_millis(500));
assert_eq!(got[1], Duration::from_secs(1));
}
#[test]
fn cadence_tree_plan_validated_rejects_below_base() {
let c = Cadences::new(&[Duration::from_millis(500), Duration::from_secs(1)]).unwrap();
let err =
CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap_err();
assert!(matches!(err, CadenceTreeError::BelowBase { .. }));
}
#[test]
fn cadence_tree_plan_validated_rejects_non_multiple() {
let c = Cadences::new(&[Duration::from_millis(1500)]).unwrap();
let err =
CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap_err();
assert!(matches!(err, CadenceTreeError::NotMultiple { .. }));
}
#[test]
fn align_to_declared_picks_smallest_above_preferred() {
let c = Cadences::parse("5s,10s,1m,5m").unwrap();
let tree = CadenceTree::plan(c, DEFAULT_MAX_FAN_IN);
assert_eq!(
tree.align_to_declared(Duration::from_secs(1)),
Some(Duration::from_secs(5))
);
assert_eq!(
tree.align_to_declared(Duration::from_secs(10)),
Some(Duration::from_secs(10))
);
assert_eq!(
tree.align_to_declared(Duration::from_secs(30)),
Some(Duration::from_secs(60))
);
assert_eq!(
tree.align_to_declared(Duration::from_secs(3600)),
Some(Duration::from_secs(300))
);
}
#[test]
fn cadence_tree_plan_validated_accepts_exact_multiple() {
let c = Cadences::new(&[Duration::from_secs(1), Duration::from_secs(10)]).unwrap();
let tree =
CadenceTree::plan_validated(c, DEFAULT_MAX_FAN_IN, Duration::from_secs(1)).unwrap();
assert_eq!(tree.layers().len(), 2);
}
#[test]
fn cadence_tree_inserts_hidden_for_large_ratio() {
let tree = CadenceTree::plan(
Cadences::parse("10s,1m,10m,10h").unwrap(),
DEFAULT_MAX_FAN_IN,
);
let layers: Vec<Duration> = tree.layers().iter().map(|l| l.interval).collect();
for d in [10, 60, 600, 36000].iter().map(|s| Duration::from_secs(*s)) {
assert!(layers.contains(&d));
}
assert!(tree.hidden().count() >= 1);
}
}