use std::collections::VecDeque;
use std::time::Duration;
use std::time::Instant;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ScreenshotPacerConfig {
pub min_interval: Duration,
pub slow_threshold: Duration,
pub slow_min_interval: Duration,
pub circuit_threshold: Duration,
pub circuit_hold: Duration,
pub rolling_window: usize,
}
impl Default for ScreenshotPacerConfig {
fn default() -> Self {
Self {
min_interval: Duration::from_millis(100),
slow_threshold: Duration::from_millis(800),
slow_min_interval: Duration::from_millis(1500),
circuit_threshold: Duration::from_millis(1500),
circuit_hold: Duration::from_secs(3),
rolling_window: 8,
}
}
}
#[derive(Debug)]
pub struct ScreenshotPacer {
config: ScreenshotPacerConfig,
last_call_end: Option<Instant>,
recent_walls: VecDeque<Duration>,
circuit_open_until: Option<Instant>,
monitor: Option<smix_sim_health::SimHealthMonitor>,
}
impl ScreenshotPacer {
pub fn new(config: ScreenshotPacerConfig) -> Self {
Self {
config,
last_call_end: None,
recent_walls: VecDeque::new(),
circuit_open_until: None,
monitor: None,
}
}
pub fn set_monitor(&mut self, monitor: smix_sim_health::SimHealthMonitor) {
self.monitor = Some(monitor);
}
pub fn compute_wait(&mut self) -> Result<Duration, Duration> {
let now = Instant::now();
if let Some(until) = self.circuit_open_until {
if now < until {
return Err(until - now);
}
self.circuit_open_until = None;
}
let floor = if self.in_slow_path() {
self.config.slow_min_interval
} else {
self.config.min_interval
};
let wait = match self.last_call_end {
Some(last_end) => {
let elapsed = now.saturating_duration_since(last_end);
if elapsed >= floor {
Duration::ZERO
} else {
floor - elapsed
}
}
None => Duration::ZERO,
};
Ok(wait)
}
pub fn record(&mut self, wall: Duration, failed: bool) {
if failed || wall >= self.config.circuit_threshold {
self.circuit_open_until = Some(Instant::now() + self.config.circuit_hold);
}
while self.recent_walls.len() >= self.config.rolling_window {
self.recent_walls.pop_front();
}
self.recent_walls.push_back(wall);
self.last_call_end = Some(Instant::now());
if let Some(m) = &self.monitor {
m.record_screenshot(wall, failed);
}
}
fn in_slow_path(&self) -> bool {
self.recent_walls
.iter()
.any(|d| *d >= self.config.slow_threshold)
}
pub fn config(&self) -> &ScreenshotPacerConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
fn small_cfg() -> ScreenshotPacerConfig {
ScreenshotPacerConfig {
min_interval: Duration::from_millis(10),
slow_threshold: Duration::from_millis(100),
slow_min_interval: Duration::from_millis(50),
circuit_threshold: Duration::from_millis(200),
circuit_hold: Duration::from_millis(100),
rolling_window: 4,
}
}
#[test]
fn first_call_no_wait() {
let mut p = ScreenshotPacer::new(small_cfg());
assert_eq!(p.compute_wait().unwrap(), Duration::ZERO);
}
#[test]
fn back_to_back_gets_paced() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(20), false);
let w = p.compute_wait().unwrap();
assert!(
w > Duration::from_millis(1) && w <= Duration::from_millis(10),
"wait was {w:?}, expected ~10ms floor"
);
}
#[test]
fn slow_recent_lifts_floor() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(150), false);
let w = p.compute_wait().unwrap();
assert!(
w > Duration::from_millis(30) && w <= Duration::from_millis(50),
"wait was {w:?}, expected ~50ms slow floor"
);
}
#[test]
fn circuit_trips_on_very_slow() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(300), false); let err = p.compute_wait().unwrap_err();
assert!(err > Duration::from_millis(0) && err <= Duration::from_millis(100));
}
#[test]
fn circuit_trips_on_failure() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(50), true);
assert!(p.compute_wait().is_err());
}
#[test]
fn circuit_expires() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(50), true);
assert!(p.compute_wait().is_err());
std::thread::sleep(Duration::from_millis(110));
assert!(p.compute_wait().is_ok());
}
#[test]
fn rolling_window_evicts_old_slow_samples() {
let mut p = ScreenshotPacer::new(small_cfg());
p.record(Duration::from_millis(150), false); assert!(p.in_slow_path());
for _ in 0..4 {
p.record(Duration::from_millis(20), false);
}
assert!(!p.in_slow_path());
}
#[test]
fn monitor_receives_observations() {
let mut p = ScreenshotPacer::new(small_cfg());
let m = smix_sim_health::SimHealthMonitor::new(
smix_sim_health::SimHealthConfig::default(),
);
p.set_monitor(m.clone());
m.record_health_ok();
for _ in 0..10 {
p.record(Duration::from_millis(2000), false);
}
assert_eq!(m.state(), smix_sim_health::SimHealthState::Degraded);
}
}