pub(crate) mod core_loop;
pub mod gate_bus;
pub mod handle;
pub mod histogram_runner;
pub mod launch;
pub mod log_runner;
pub mod multi_runner;
pub mod runner;
pub mod stats;
pub mod summary_runner;
pub use core_loop::GateContext;
pub use gate_bus::{
strict_eval, AfterOpDir, AfterSpec, GateBus, GateEdge, GateReceiver, InitialState,
SubscriptionSpec, WhileSpec,
};
use std::time::Duration;
use crate::config::{DynamicLabelStrategy, OnSinkError, SpikeStrategy};
use crate::util::splitmix64;
#[derive(Debug, Clone)]
pub struct GapWindow {
pub every: Duration,
pub duration: Duration,
}
#[derive(Debug, Clone)]
pub struct BurstWindow {
pub every: Duration,
pub duration: Duration,
pub multiplier: f64,
}
pub fn is_in_burst(elapsed: Duration, burst: &BurstWindow) -> Option<f64> {
let every_secs = burst.every.as_secs_f64();
let duration_secs = burst.duration.as_secs_f64();
let cycle_pos = elapsed.as_secs_f64() % every_secs;
if cycle_pos < duration_secs {
Some(burst.multiplier)
} else {
None
}
}
pub fn time_until_burst_end(elapsed: Duration, burst: &BurstWindow) -> Duration {
let every_secs = burst.every.as_secs_f64();
let duration_secs = burst.duration.as_secs_f64();
let cycle_pos = elapsed.as_secs_f64() % every_secs;
let remaining_secs = duration_secs - cycle_pos;
if remaining_secs <= 0.0 {
Duration::ZERO
} else {
Duration::from_secs_f64(remaining_secs)
}
}
pub fn is_in_gap(elapsed: Duration, gap: &GapWindow) -> bool {
let every_secs = gap.every.as_secs_f64();
let duration_secs = gap.duration.as_secs_f64();
let cycle_pos = elapsed.as_secs_f64() % every_secs;
cycle_pos >= every_secs - duration_secs
}
#[derive(Debug, Clone)]
pub struct CardinalitySpikeWindow {
pub label: String,
pub every: Duration,
pub duration: Duration,
pub cardinality: u64,
pub strategy: SpikeStrategy,
pub prefix: String,
pub seed: u64,
}
impl CardinalitySpikeWindow {
pub fn label_value_for_tick(&self, tick: u64) -> String {
let index = tick % self.cardinality;
match self.strategy {
SpikeStrategy::Counter => {
format!("{}{}", self.prefix, index)
}
SpikeStrategy::Random => {
let mixed = splitmix64(self.seed ^ index);
format!("{}{:016x}", self.prefix, mixed)
}
}
}
}
#[derive(Debug, Clone)]
pub struct DynamicLabel {
pub key: String,
pub prefix: String,
pub cardinality: u64,
pub values: Vec<String>,
}
impl DynamicLabel {
pub fn label_value_for_tick(&self, tick: u64) -> String {
let index = tick % self.cardinality;
if self.values.is_empty() {
format!("{}{}", self.prefix, index)
} else {
self.values[index as usize].clone()
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ParsedSchedule {
pub total_duration: Option<Duration>,
pub gap_window: Option<GapWindow>,
pub burst_window: Option<BurstWindow>,
pub spike_windows: Vec<CardinalitySpikeWindow>,
pub dynamic_labels: Vec<DynamicLabel>,
pub on_sink_error: OnSinkError,
pub name: String,
}
impl ParsedSchedule {
pub fn from_base_config(
config: &crate::config::BaseScheduleConfig,
) -> Result<Self, crate::SondaError> {
use crate::config::validate::parse_duration;
let total_duration: Option<Duration> =
config.duration.as_deref().map(parse_duration).transpose()?;
let gap_window: Option<GapWindow> = config
.gaps
.as_ref()
.map(|g| -> Result<GapWindow, crate::SondaError> {
Ok(GapWindow {
every: parse_duration(&g.every)?,
duration: parse_duration(&g.r#for)?,
})
})
.transpose()?;
let burst_window: Option<BurstWindow> = config
.bursts
.as_ref()
.map(|b| -> Result<BurstWindow, crate::SondaError> {
Ok(BurstWindow {
every: parse_duration(&b.every)?,
duration: parse_duration(&b.r#for)?,
multiplier: b.multiplier,
})
})
.transpose()?;
let spike_windows: Vec<CardinalitySpikeWindow> = config
.cardinality_spikes
.as_ref()
.map(|spikes| {
spikes
.iter()
.map(|s| {
Ok(CardinalitySpikeWindow {
label: s.label.clone(),
every: parse_duration(&s.every)?,
duration: parse_duration(&s.r#for)?,
cardinality: s.cardinality,
strategy: s.strategy,
prefix: s.prefix.clone().unwrap_or_else(|| format!("{}_", s.label)),
seed: s.seed.unwrap_or(0),
})
})
.collect::<Result<Vec<_>, crate::SondaError>>()
})
.transpose()?
.unwrap_or_default();
let dynamic_labels: Vec<DynamicLabel> = config
.dynamic_labels
.as_ref()
.map(|dls| {
dls.iter()
.map(|dl| match &dl.strategy {
DynamicLabelStrategy::Counter {
prefix,
cardinality,
} => DynamicLabel {
key: dl.key.clone(),
prefix: prefix.clone().unwrap_or_else(|| format!("{}_", dl.key)),
cardinality: *cardinality,
values: Vec::new(),
},
DynamicLabelStrategy::ValuesList { values } => DynamicLabel {
key: dl.key.clone(),
prefix: String::new(),
cardinality: values.len() as u64,
values: values.clone(),
},
})
.collect()
})
.unwrap_or_default();
Ok(Self {
total_duration,
gap_window,
burst_window,
spike_windows,
dynamic_labels,
on_sink_error: config.on_sink_error,
name: config.name.clone(),
})
}
}
pub fn is_in_spike(elapsed: Duration, spike: &CardinalitySpikeWindow) -> bool {
let every_secs = spike.every.as_secs_f64();
let duration_secs = spike.duration.as_secs_f64();
let cycle_pos = elapsed.as_secs_f64() % every_secs;
cycle_pos < duration_secs
}
pub fn time_until_gap_end(elapsed: Duration, gap: &GapWindow) -> Duration {
let every_secs = gap.every.as_secs_f64();
let cycle_pos = elapsed.as_secs_f64() % every_secs;
let remaining_secs = every_secs - cycle_pos;
if remaining_secs <= 0.0 {
Duration::ZERO
} else {
Duration::from_secs_f64(remaining_secs)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gap(every_secs: u64, duration_secs: u64) -> GapWindow {
GapWindow {
every: Duration::from_secs(every_secs),
duration: Duration::from_secs(duration_secs),
}
}
#[test]
fn is_in_gap_at_zero_is_false() {
let g = gap(10, 2);
assert!(!is_in_gap(Duration::from_secs(0), &g));
}
#[test]
fn is_in_gap_at_8_5s_is_true() {
let g = gap(10, 2);
assert!(is_in_gap(Duration::from_millis(8500), &g));
}
#[test]
fn is_in_gap_at_exact_gap_start_is_true() {
let g = gap(10, 2);
assert!(is_in_gap(Duration::from_secs(8), &g));
}
#[test]
fn is_in_gap_at_10s_new_cycle_is_false() {
let g = gap(10, 2);
assert!(!is_in_gap(Duration::from_secs(10), &g));
}
#[test]
fn is_in_gap_at_18_5s_second_cycle_is_true() {
let g = gap(10, 2);
assert!(is_in_gap(Duration::from_millis(18500), &g));
}
#[test]
fn is_in_gap_at_20s_third_cycle_start_is_false() {
let g = gap(10, 2);
assert!(!is_in_gap(Duration::from_secs(20), &g));
}
#[test]
fn is_in_gap_at_5s_is_false() {
let g = gap(10, 2);
assert!(!is_in_gap(Duration::from_secs(5), &g));
}
#[test]
fn is_in_gap_sub_millisecond_gap_duration() {
let g = GapWindow {
every: Duration::from_secs(10),
duration: Duration::from_millis(1),
};
assert!(is_in_gap(Duration::from_millis(9999), &g));
assert!(!is_in_gap(Duration::from_secs(5), &g));
}
#[test]
fn is_in_gap_minute_scale_cycle() {
let g = GapWindow {
every: Duration::from_secs(120),
duration: Duration::from_secs(20),
};
assert!(!is_in_gap(Duration::from_secs(0), &g));
assert!(!is_in_gap(Duration::from_secs(50), &g));
assert!(!is_in_gap(Duration::from_secs(99), &g));
assert!(is_in_gap(Duration::from_secs(100), &g));
assert!(is_in_gap(Duration::from_secs(110), &g));
assert!(is_in_gap(Duration::from_secs(119), &g));
assert!(!is_in_gap(Duration::from_secs(120), &g));
}
#[test]
fn time_until_gap_end_at_9s_returns_1s() {
let g = gap(10, 2);
let remaining = time_until_gap_end(Duration::from_secs(9), &g);
let diff = (remaining.as_secs_f64() - 1.0).abs();
assert!(
diff < 0.001,
"expected ~1s remaining, got {remaining:?} (diff={diff})"
);
}
#[test]
fn time_until_gap_end_at_gap_start_returns_gap_duration() {
let g = gap(10, 2);
let remaining = time_until_gap_end(Duration::from_secs(8), &g);
let diff = (remaining.as_secs_f64() - 2.0).abs();
assert!(
diff < 0.001,
"expected ~2s remaining, got {remaining:?} (diff={diff})"
);
}
#[test]
fn time_until_gap_end_near_cycle_boundary_is_non_negative() {
let g = gap(10, 2);
let remaining = time_until_gap_end(Duration::from_millis(9999), &g);
assert!(
remaining >= Duration::ZERO,
"remaining must never be negative"
);
assert!(
remaining.as_millis() <= 2,
"expected ~1ms, got {remaining:?}"
);
}
#[test]
fn time_until_gap_end_second_cycle_at_18s() {
let g = gap(10, 2);
let remaining = time_until_gap_end(Duration::from_secs(18), &g);
let diff = (remaining.as_secs_f64() - 2.0).abs();
assert!(
diff < 0.001,
"expected ~2s remaining in second cycle, got {remaining:?}"
);
}
#[test]
fn rate_1000_yields_1ms_interval() {
let interval = Duration::from_secs_f64(1.0 / 1000.0);
assert_eq!(interval.as_millis(), 1);
}
#[test]
fn rate_1_yields_1s_interval() {
let interval = Duration::from_secs_f64(1.0 / 1.0);
assert_eq!(interval.as_secs(), 1);
}
#[test]
fn rate_0_5_yields_2s_interval() {
let interval = Duration::from_secs_f64(1.0 / 0.5);
assert_eq!(interval.as_secs(), 2);
}
#[test]
fn gap_window_is_cloneable() {
let g = gap(10, 2);
let cloned = g.clone();
assert_eq!(cloned.every, Duration::from_secs(10));
assert_eq!(cloned.duration, Duration::from_secs(2));
}
#[test]
fn gap_window_is_debuggable() {
let g = gap(10, 2);
let s = format!("{g:?}");
assert!(s.contains("GapWindow"), "Debug output must name the struct");
}
#[test]
fn burst_window_is_cloneable() {
let b = BurstWindow {
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
multiplier: 5.0,
};
let cloned = b.clone();
assert_eq!(cloned.every, Duration::from_secs(10));
assert_eq!(cloned.duration, Duration::from_secs(2));
assert_eq!(cloned.multiplier, 5.0);
}
#[test]
fn burst_window_is_debuggable() {
let b = BurstWindow {
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
multiplier: 5.0,
};
let s = format!("{b:?}");
assert!(
s.contains("BurstWindow"),
"Debug output must name the struct"
);
}
fn burst(every_secs: u64, duration_secs: u64, multiplier: f64) -> BurstWindow {
BurstWindow {
every: Duration::from_secs(every_secs),
duration: Duration::from_secs(duration_secs),
multiplier,
}
}
#[test]
fn is_in_burst_at_zero_is_some_multiplier() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::ZERO, &b);
assert_eq!(
result,
Some(5.0),
"at elapsed=0s the burst occupies [0, duration) so should be Some"
);
}
#[test]
fn is_in_burst_at_0_5s_returns_some_multiplier() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_millis(500), &b);
assert_eq!(
result,
Some(5.0),
"at 0.5s (cycle_pos=0.5 < duration=2) burst must be active"
);
}
#[test]
fn is_in_burst_at_burst_end_boundary_returns_none() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_secs(2), &b);
assert!(
result.is_none(),
"at elapsed=2.0s (cycle_pos=2.0 == duration) burst must be None"
);
}
#[test]
fn is_in_burst_at_2_5s_returns_none() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_millis(2500), &b);
assert!(
result.is_none(),
"at 2.5s (cycle_pos=2.5 > duration=2) burst must be None"
);
}
#[test]
fn is_in_burst_at_5s_is_none() {
let b = burst(10, 2, 5.0);
assert!(is_in_burst(Duration::from_secs(5), &b).is_none());
}
#[test]
fn is_in_burst_at_9_5s_is_none() {
let b = burst(10, 2, 5.0);
assert!(is_in_burst(Duration::from_millis(9500), &b).is_none());
}
#[test]
fn is_in_burst_at_10s_second_cycle_start_is_some() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_secs(10), &b);
assert_eq!(
result,
Some(5.0),
"at 10s (start of cycle 2) burst must be active again"
);
}
#[test]
fn is_in_burst_at_10_5s_second_cycle_is_some() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_millis(10500), &b);
assert_eq!(result, Some(5.0));
}
#[test]
fn is_in_burst_at_12_5s_second_cycle_is_none() {
let b = burst(10, 2, 5.0);
let result = is_in_burst(Duration::from_millis(12500), &b);
assert!(result.is_none());
}
#[test]
fn is_in_burst_returns_correct_multiplier_value() {
let b = burst(10, 2, 10.0);
let result = is_in_burst(Duration::from_millis(500), &b);
assert_eq!(result, Some(10.0), "multiplier must equal configured value");
}
#[test]
fn is_in_burst_with_multiplier_one_returns_some() {
let b = burst(10, 2, 1.0);
let result = is_in_burst(Duration::from_millis(500), &b);
assert_eq!(result, Some(1.0));
}
#[test]
fn time_until_burst_end_at_zero_returns_burst_duration() {
let b = burst(10, 2, 5.0);
let remaining = time_until_burst_end(Duration::ZERO, &b);
let diff = (remaining.as_secs_f64() - 2.0).abs();
assert!(
diff < 0.001,
"at elapsed=0 expected ~2s remaining, got {remaining:?}"
);
}
#[test]
fn time_until_burst_end_at_0_5s_returns_1_5s() {
let b = burst(10, 2, 5.0);
let remaining = time_until_burst_end(Duration::from_millis(500), &b);
let diff = (remaining.as_secs_f64() - 1.5).abs();
assert!(
diff < 0.001,
"at 0.5s expected ~1.5s remaining, got {remaining:?}"
);
}
#[test]
fn time_until_burst_end_at_1_9s_returns_0_1s() {
let b = burst(10, 2, 5.0);
let remaining = time_until_burst_end(Duration::from_millis(1900), &b);
let diff = (remaining.as_secs_f64() - 0.1).abs();
assert!(
diff < 0.005,
"at 1.9s expected ~0.1s remaining, got {remaining:?}"
);
}
#[test]
fn time_until_burst_end_at_exact_boundary_is_non_negative() {
let b = burst(10, 2, 5.0);
let remaining = time_until_burst_end(Duration::from_secs(2), &b);
assert!(
remaining >= Duration::ZERO,
"remaining must never be negative, got {remaining:?}"
);
}
#[test]
fn time_until_burst_end_second_cycle_at_10_5s_returns_1_5s() {
let b = burst(10, 2, 5.0);
let remaining = time_until_burst_end(Duration::from_millis(10500), &b);
let diff = (remaining.as_secs_f64() - 1.5).abs();
assert!(
diff < 0.001,
"in second cycle at 10.5s expected ~1.5s remaining, got {remaining:?}"
);
}
fn spike(every_secs: u64, duration_secs: u64, cardinality: u64) -> CardinalitySpikeWindow {
CardinalitySpikeWindow {
label: "pod_name".to_string(),
every: Duration::from_secs(every_secs),
duration: Duration::from_secs(duration_secs),
cardinality,
strategy: SpikeStrategy::Counter,
prefix: "pod-".to_string(),
seed: 0,
}
}
#[test]
fn is_in_spike_at_zero_is_true() {
let s = spike(10, 2, 100);
assert!(is_in_spike(Duration::ZERO, &s));
}
#[test]
fn is_in_spike_at_0_5s_is_true() {
let s = spike(10, 2, 100);
assert!(is_in_spike(Duration::from_millis(500), &s));
}
#[test]
fn is_in_spike_at_spike_end_boundary_is_false() {
let s = spike(10, 2, 100);
assert!(!is_in_spike(Duration::from_secs(2), &s));
}
#[test]
fn is_in_spike_at_5s_is_false() {
let s = spike(10, 2, 100);
assert!(!is_in_spike(Duration::from_secs(5), &s));
}
#[test]
fn is_in_spike_at_10s_second_cycle_start_is_true() {
let s = spike(10, 2, 100);
assert!(is_in_spike(Duration::from_secs(10), &s));
}
#[test]
fn is_in_spike_at_12_5s_second_cycle_is_false() {
let s = spike(10, 2, 100);
assert!(!is_in_spike(Duration::from_millis(12500), &s));
}
#[test]
fn label_value_counter_at_tick_zero() {
let s = spike(10, 2, 100);
assert_eq!(s.label_value_for_tick(0), "pod-0");
}
#[test]
fn label_value_counter_wraps_at_cardinality() {
let s = spike(10, 2, 3);
assert_eq!(s.label_value_for_tick(0), "pod-0");
assert_eq!(s.label_value_for_tick(1), "pod-1");
assert_eq!(s.label_value_for_tick(2), "pod-2");
assert_eq!(s.label_value_for_tick(3), "pod-0");
assert_eq!(s.label_value_for_tick(4), "pod-1");
}
#[test]
fn label_value_counter_cardinality_one() {
let s = spike(10, 2, 1);
assert_eq!(s.label_value_for_tick(0), "pod-0");
assert_eq!(s.label_value_for_tick(999), "pod-0");
}
#[test]
fn label_value_random_is_deterministic() {
let s = CardinalitySpikeWindow {
label: "error_msg".to_string(),
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
cardinality: 1000,
strategy: SpikeStrategy::Random,
prefix: "err-".to_string(),
seed: 42,
};
assert_eq!(
s.label_value_for_tick(0),
"err-bdd732262feb6e95",
"tick 0 must produce the known anchored value"
);
assert_eq!(
s.label_value_for_tick(1),
"err-ba69ec90eb4fef88",
"tick 1 must produce the known anchored value"
);
assert_eq!(s.label_value_for_tick(0), s.label_value_for_tick(0));
}
#[test]
fn label_value_random_differs_across_ticks() {
let s = CardinalitySpikeWindow {
label: "error_msg".to_string(),
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
cardinality: 1000,
strategy: SpikeStrategy::Random,
prefix: "".to_string(),
seed: 42,
};
let v0 = s.label_value_for_tick(0);
let v1 = s.label_value_for_tick(1);
assert_ne!(v0, v1, "different ticks should produce different values");
}
#[test]
fn label_value_random_starts_with_prefix() {
let s = CardinalitySpikeWindow {
label: "error_msg".to_string(),
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
cardinality: 1000,
strategy: SpikeStrategy::Random,
prefix: "err-".to_string(),
seed: 42,
};
assert!(s.label_value_for_tick(0).starts_with("err-"));
}
#[test]
fn label_value_random_respects_cardinality() {
let s = CardinalitySpikeWindow {
label: "error_msg".to_string(),
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
cardinality: 1000,
strategy: SpikeStrategy::Random,
prefix: "err-".to_string(),
seed: 42,
};
assert_eq!(
s.label_value_for_tick(0),
s.label_value_for_tick(1000),
"tick 0 and tick 1000 must produce the same value (cardinality=1000)"
);
assert_eq!(
s.label_value_for_tick(1),
s.label_value_for_tick(1001),
"tick 1 and tick 1001 must produce the same value (cardinality=1000)"
);
}
#[test]
fn label_value_random_cardinality_one() {
let s = CardinalitySpikeWindow {
label: "x".to_string(),
every: Duration::from_secs(10),
duration: Duration::from_secs(2),
cardinality: 1,
strategy: SpikeStrategy::Random,
prefix: "v-".to_string(),
seed: 99,
};
assert_eq!(
s.label_value_for_tick(0),
s.label_value_for_tick(999),
"cardinality=1 must always return the same value"
);
}
#[test]
fn spike_window_is_cloneable() {
let s = spike(10, 2, 100);
let cloned = s.clone();
assert_eq!(cloned.label, "pod_name");
assert_eq!(cloned.every, Duration::from_secs(10));
assert_eq!(cloned.cardinality, 100);
}
#[test]
fn spike_window_is_debuggable() {
let s = spike(10, 2, 100);
let debug = format!("{s:?}");
assert!(debug.contains("CardinalitySpikeWindow"));
}
fn base_config() -> crate::config::BaseScheduleConfig {
crate::config::BaseScheduleConfig {
name: "test".to_string(),
rate: 10.0,
duration: None,
gaps: None,
bursts: None,
cardinality_spikes: None,
dynamic_labels: None,
labels: None,
sink: crate::sink::SinkConfig::Stdout,
phase_offset: None,
clock_group: None,
clock_group_is_auto: None,
jitter: None,
jitter_seed: None,
on_sink_error: crate::OnSinkError::Warn,
}
}
#[test]
fn parsed_schedule_no_optionals() {
let cfg = base_config();
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
assert!(parsed.total_duration.is_none());
assert!(parsed.gap_window.is_none());
assert!(parsed.burst_window.is_none());
assert!(parsed.spike_windows.is_empty());
}
#[test]
fn parsed_schedule_with_duration() {
let mut cfg = base_config();
cfg.duration = Some("30s".to_string());
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
assert_eq!(parsed.total_duration, Some(Duration::from_secs(30)));
}
#[test]
fn parsed_schedule_with_gaps() {
let mut cfg = base_config();
cfg.gaps = Some(crate::config::GapConfig {
every: "60s".to_string(),
r#for: "10s".to_string(),
});
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
let gap = parsed.gap_window.unwrap();
assert_eq!(gap.every, Duration::from_secs(60));
assert_eq!(gap.duration, Duration::from_secs(10));
}
#[test]
fn parsed_schedule_with_bursts() {
let mut cfg = base_config();
cfg.bursts = Some(crate::config::BurstConfig {
every: "10s".to_string(),
r#for: "2s".to_string(),
multiplier: 5.0,
});
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
let burst = parsed.burst_window.unwrap();
assert_eq!(burst.every, Duration::from_secs(10));
assert_eq!(burst.duration, Duration::from_secs(2));
assert!((burst.multiplier - 5.0).abs() < f64::EPSILON);
}
#[test]
fn parsed_schedule_spike_defaults_prefix_and_seed() {
let mut cfg = base_config();
cfg.cardinality_spikes = Some(vec![crate::config::CardinalitySpikeConfig {
label: "pod_name".to_string(),
every: "2m".to_string(),
r#for: "30s".to_string(),
cardinality: 50,
strategy: crate::config::SpikeStrategy::Counter,
prefix: None,
seed: None,
}]);
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
assert_eq!(parsed.spike_windows.len(), 1);
let sw = &parsed.spike_windows[0];
assert_eq!(
sw.prefix, "pod_name_",
"prefix defaults to label + underscore"
);
assert_eq!(sw.seed, 0, "seed defaults to 0");
assert_eq!(sw.every, Duration::from_secs(120));
assert_eq!(sw.duration, Duration::from_secs(30));
}
#[test]
fn parsed_schedule_spike_custom_prefix_and_seed() {
let mut cfg = base_config();
cfg.cardinality_spikes = Some(vec![crate::config::CardinalitySpikeConfig {
label: "host".to_string(),
every: "1m".to_string(),
r#for: "10s".to_string(),
cardinality: 10,
strategy: crate::config::SpikeStrategy::Random,
prefix: Some("srv-".to_string()),
seed: Some(42),
}]);
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
let sw = &parsed.spike_windows[0];
assert_eq!(sw.prefix, "srv-");
assert_eq!(sw.seed, 42);
}
#[test]
fn parsed_schedule_empty_spikes_vec() {
let mut cfg = base_config();
cfg.cardinality_spikes = Some(vec![]);
let parsed = ParsedSchedule::from_base_config(&cfg).unwrap();
assert!(parsed.spike_windows.is_empty());
}
#[test]
fn parsed_schedule_invalid_duration_returns_error() {
let mut cfg = base_config();
cfg.duration = Some("not_a_duration".to_string());
assert!(ParsedSchedule::from_base_config(&cfg).is_err());
}
#[test]
fn splitmix64_produces_known_output() {
assert_eq!(
super::splitmix64(42),
0xbdd732262feb6e95,
"splitmix64(42) must return the known constant"
);
assert_eq!(
super::splitmix64(0),
0xe220a8397b1dcdaf,
"splitmix64(0) must return the known constant"
);
assert_ne!(super::splitmix64(0), super::splitmix64(1));
}
#[test]
fn dynamic_label_counter_tick_zero_returns_first_value() {
let dl = DynamicLabel {
key: "hostname".to_string(),
prefix: "host-".to_string(),
cardinality: 10,
values: Vec::new(),
};
assert_eq!(dl.label_value_for_tick(0), "host-0");
}
#[test]
fn dynamic_label_counter_wraps_at_cardinality() {
let dl = DynamicLabel {
key: "hostname".to_string(),
prefix: "host-".to_string(),
cardinality: 3,
values: Vec::new(),
};
assert_eq!(dl.label_value_for_tick(0), "host-0");
assert_eq!(dl.label_value_for_tick(1), "host-1");
assert_eq!(dl.label_value_for_tick(2), "host-2");
assert_eq!(dl.label_value_for_tick(3), "host-0");
assert_eq!(dl.label_value_for_tick(4), "host-1");
}
#[test]
fn dynamic_label_counter_cardinality_one() {
let dl = DynamicLabel {
key: "pod".to_string(),
prefix: "pod-".to_string(),
cardinality: 1,
values: Vec::new(),
};
assert_eq!(dl.label_value_for_tick(0), "pod-0");
assert_eq!(dl.label_value_for_tick(1), "pod-0");
assert_eq!(dl.label_value_for_tick(999), "pod-0");
}
#[test]
fn dynamic_label_counter_empty_prefix() {
let dl = DynamicLabel {
key: "zone".to_string(),
prefix: String::new(),
cardinality: 5,
values: Vec::new(),
};
assert_eq!(dl.label_value_for_tick(0), "0");
assert_eq!(dl.label_value_for_tick(4), "4");
assert_eq!(dl.label_value_for_tick(5), "0");
}
#[test]
fn dynamic_label_counter_large_tick() {
let dl = DynamicLabel {
key: "host".to_string(),
prefix: "h-".to_string(),
cardinality: 10,
values: Vec::new(),
};
assert_eq!(dl.label_value_for_tick(1_000_000), "h-0");
assert_eq!(dl.label_value_for_tick(1_000_007), "h-7");
}
#[test]
fn dynamic_label_values_tick_zero_returns_first_value() {
let dl = DynamicLabel {
key: "region".to_string(),
prefix: String::new(),
cardinality: 3,
values: vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
};
assert_eq!(dl.label_value_for_tick(0), "alpha");
}
#[test]
fn dynamic_label_values_wraps_at_list_length() {
let dl = DynamicLabel {
key: "region".to_string(),
prefix: String::new(),
cardinality: 3,
values: vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()],
};
assert_eq!(dl.label_value_for_tick(0), "alpha");
assert_eq!(dl.label_value_for_tick(1), "beta");
assert_eq!(dl.label_value_for_tick(2), "gamma");
assert_eq!(dl.label_value_for_tick(3), "alpha");
assert_eq!(dl.label_value_for_tick(4), "beta");
}
#[test]
fn dynamic_label_values_single_element() {
let dl = DynamicLabel {
key: "env".to_string(),
prefix: String::new(),
cardinality: 1,
values: vec!["prod".to_string()],
};
assert_eq!(dl.label_value_for_tick(0), "prod");
assert_eq!(dl.label_value_for_tick(100), "prod");
}
#[test]
fn dynamic_label_counter_is_deterministic() {
let dl = DynamicLabel {
key: "host".to_string(),
prefix: "host-".to_string(),
cardinality: 10,
values: Vec::new(),
};
for tick in 0..100 {
assert_eq!(dl.label_value_for_tick(tick), dl.label_value_for_tick(tick));
}
}
#[test]
fn dynamic_label_counter_respects_cardinality_ceiling() {
let dl = DynamicLabel {
key: "host".to_string(),
prefix: "host-".to_string(),
cardinality: 5,
values: Vec::new(),
};
let mut seen = std::collections::HashSet::new();
for tick in 0..1000 {
seen.insert(dl.label_value_for_tick(tick));
}
assert_eq!(
seen.len(),
5,
"counter with cardinality=5 must produce exactly 5 distinct values, got {}",
seen.len()
);
}
#[test]
fn dynamic_label_values_respects_cardinality_ceiling() {
let dl = DynamicLabel {
key: "env".to_string(),
prefix: String::new(),
cardinality: 3,
values: vec!["a".to_string(), "b".to_string(), "c".to_string()],
};
let mut seen = std::collections::HashSet::new();
for tick in 0..1000 {
seen.insert(dl.label_value_for_tick(tick));
}
assert_eq!(
seen.len(),
3,
"values list with 3 elements must produce exactly 3 distinct values, got {}",
seen.len()
);
}
#[test]
fn dynamic_label_is_cloneable() {
let dl = DynamicLabel {
key: "host".to_string(),
prefix: "host-".to_string(),
cardinality: 10,
values: Vec::new(),
};
let cloned = dl.clone();
assert_eq!(cloned.key, "host");
assert_eq!(cloned.cardinality, 10);
}
#[test]
fn dynamic_label_is_debuggable() {
let dl = DynamicLabel {
key: "host".to_string(),
prefix: "host-".to_string(),
cardinality: 10,
values: Vec::new(),
};
let debug = format!("{dl:?}");
assert!(debug.contains("DynamicLabel"));
}
#[test]
fn parsed_schedule_parses_dynamic_labels_counter() {
let mut config = base_config();
config.dynamic_labels = Some(vec![crate::config::DynamicLabelConfig {
key: "hostname".to_string(),
strategy: crate::config::DynamicLabelStrategy::Counter {
prefix: Some("host-".to_string()),
cardinality: 10,
},
}]);
let schedule = ParsedSchedule::from_base_config(&config).expect("must parse");
assert_eq!(schedule.dynamic_labels.len(), 1);
assert_eq!(schedule.dynamic_labels[0].key, "hostname");
assert_eq!(schedule.dynamic_labels[0].prefix, "host-");
assert_eq!(schedule.dynamic_labels[0].cardinality, 10);
assert!(schedule.dynamic_labels[0].values.is_empty());
}
#[test]
fn parsed_schedule_parses_dynamic_labels_values_list() {
let mut config = base_config();
config.dynamic_labels = Some(vec![crate::config::DynamicLabelConfig {
key: "region".to_string(),
strategy: crate::config::DynamicLabelStrategy::ValuesList {
values: vec!["us-east".to_string(), "eu-west".to_string()],
},
}]);
let schedule = ParsedSchedule::from_base_config(&config).expect("must parse");
assert_eq!(schedule.dynamic_labels.len(), 1);
assert_eq!(schedule.dynamic_labels[0].key, "region");
assert_eq!(schedule.dynamic_labels[0].cardinality, 2);
assert_eq!(
schedule.dynamic_labels[0].values,
vec!["us-east", "eu-west"]
);
}
#[test]
fn parsed_schedule_no_dynamic_labels_produces_empty_vec() {
let config = base_config();
let schedule = ParsedSchedule::from_base_config(&config).expect("must parse");
assert!(schedule.dynamic_labels.is_empty());
}
#[test]
fn parsed_schedule_counter_default_prefix() {
let mut config = base_config();
config.dynamic_labels = Some(vec![crate::config::DynamicLabelConfig {
key: "pod".to_string(),
strategy: crate::config::DynamicLabelStrategy::Counter {
prefix: None,
cardinality: 5,
},
}]);
let schedule = ParsedSchedule::from_base_config(&config).expect("must parse");
assert_eq!(
schedule.dynamic_labels[0].prefix, "pod_",
"default prefix must be key + underscore"
);
}
}