use core::time::Duration;
use super::Sample;
use super::strategy::{Concurrency, ConcurrencyStrategy, Strategy};
pub trait WorkerPool {
fn spawn_one(&mut self);
fn retire_one(&mut self);
fn live(&self) -> usize;
}
enum ControllerKind {
Fixed(usize),
Adaptive(Strategy),
}
pub struct ConcurrencyController {
kind: ControllerKind,
target: usize,
}
impl core::fmt::Debug for ConcurrencyController {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("ConcurrencyController")
.field("target", &self.target)
.field(
"kind",
&match self.kind {
ControllerKind::Fixed(_) => "fixed",
ControllerKind::Adaptive(_) => "adaptive",
},
)
.finish()
}
}
impl ConcurrencyController {
#[must_use]
pub fn new(concurrency: Concurrency) -> Self {
let target = concurrency.initial();
let kind = match concurrency {
Concurrency::Fixed(n) => ControllerKind::Fixed(n),
Concurrency::Adaptive(strategy) => ControllerKind::Adaptive(strategy),
};
Self { kind, target }
}
#[must_use]
pub fn target(&self) -> usize {
self.target
}
#[must_use]
pub fn window(&self) -> Option<Duration> {
match &self.kind {
ControllerKind::Fixed(_) => None,
ControllerKind::Adaptive(strategy) => Some(strategy.window()),
}
}
#[must_use]
pub fn is_adaptive(&self) -> bool {
matches!(self.kind, ControllerKind::Adaptive(_))
}
pub fn observe(&mut self, sample: Sample) -> usize {
self.target = match &mut self.kind {
ControllerKind::Fixed(n) => *n,
ControllerKind::Adaptive(strategy) => strategy.next(sample),
};
self.target
}
pub fn reconcile<P: WorkerPool>(&self, pool: &mut P) {
while pool.live() < self.target {
pool.spawn_one();
}
while pool.live() > self.target {
pool.retire_one();
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::super::strategy::Preset;
use super::*;
#[derive(Default)]
struct CountingPool {
live: usize,
spawned: usize,
retired: usize,
}
impl WorkerPool for CountingPool {
fn spawn_one(&mut self) {
self.live += 1;
self.spawned += 1;
}
fn retire_one(&mut self) {
self.live -= 1;
self.retired += 1;
}
fn live(&self) -> usize {
self.live
}
}
#[test]
fn fixed_controller_holds_its_cap() {
let mut controller = ConcurrencyController::new(Concurrency::fixed(25));
assert_eq!(controller.target(), 25);
assert!(!controller.is_adaptive());
assert_eq!(controller.window(), None);
assert_eq!(controller.observe(Sample::seed(99)), 25);
}
#[test]
fn reconcile_spawns_up_to_target() {
let controller = ConcurrencyController::new(Concurrency::fixed(8));
let mut pool = CountingPool::default();
controller.reconcile(&mut pool);
assert_eq!(pool.live(), 8);
assert_eq!(pool.spawned, 8);
assert_eq!(pool.retired, 0);
}
#[test]
fn reconcile_retires_down_to_target() {
let controller = ConcurrencyController::new(Concurrency::fixed(3));
let mut pool = CountingPool {
live: 10,
..CountingPool::default()
};
controller.reconcile(&mut pool);
assert_eq!(pool.live(), 3);
assert_eq!(pool.retired, 7);
}
#[test]
fn adaptive_controller_reports_window() {
let controller =
ConcurrencyController::new(Concurrency::from_preset(Preset::Gradient).unwrap());
assert!(controller.is_adaptive());
assert_eq!(controller.window(), Some(Duration::from_millis(150)));
}
use super::super::Concurrency as _Concurrency;
use super::super::sim::CrestModel;
fn drive(concurrency: _Concurrency, model: &CrestModel, windows: usize) -> (usize, f64) {
let mut controller = ConcurrencyController::new(concurrency);
let mut current = controller.target();
let mut total = 0.0;
for _ in 0..windows {
let sample = model.sample(current);
total += sample.throughput;
current = controller.observe(sample);
}
(current, total)
}
#[test]
fn hillclimb_converges_to_the_crest() {
let model = CrestModel::new(8);
let knob = Concurrency::builder()
.hillclimb()
.start(16)
.bounds(1, 64)
.build()
.unwrap();
let (final_target, _) = drive(knob, &model, 300);
assert!(
(6..=10).contains(&final_target),
"converged near 8, got {final_target}"
);
}
#[test]
fn gradient_converges_to_the_crest() {
let model = CrestModel::new(8);
let knob = Concurrency::builder()
.gradient()
.start(2)
.bounds(1, 64)
.build()
.unwrap();
let (final_target, _) = drive(knob, &model, 300);
assert!(
(6..=12).contains(&final_target),
"converged near 8, got {final_target}"
);
}
#[test]
fn gate_rejects_sub_cov_steps() {
let mut controller =
ConcurrencyController::new(Concurrency::from_preset(Preset::HillClimb).unwrap());
let start = controller.target();
for tick in 0..50 {
let mut sample = Sample::seed(controller.target());
sample.throughput = 1_000.0 + if tick % 2 == 0 { 5.0 } else { -5.0 };
sample.cov = 0.5;
let target = controller.observe(sample);
assert!(
(start - 1..=start + 1).contains(&target),
"noise drifted the target to {target} from start {start}"
);
}
}
#[test]
fn adaptive_beats_a_misset_fixed() {
let model = CrestModel::new(8);
let adaptive = Concurrency::builder()
.hillclimb()
.start(16)
.bounds(1, 64)
.build()
.unwrap();
let (_, adaptive_total) = drive(adaptive, &model, 300);
let (_, fixed_total) = drive(Concurrency::fixed(32), &model, 300);
assert!(
adaptive_total > fixed_total,
"adaptive {adaptive_total:.0} must beat mis-set Fixed(32) {fixed_total:.0}"
);
}
}