use serde::{Deserialize, Serialize};
pub trait Decision: Send + Sync {
type Ctx;
type Action;
fn decide(ctx: &Self::Ctx) -> Self::Action;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PoolDecisionCtx {
pub current_members: u32,
pub last_scaled_at: i64,
pub now: i64,
pub min_size: u32,
pub max_size: u32,
pub desired_size: u32,
pub cooldown_secs: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PoolDecisionDemo {
NoOp,
Spawn { count: u32 },
ReapExcess { count: u32 },
}
#[derive(Debug, Default, Copy, Clone)]
pub struct PoolDecisionDemoImpl;
impl Decision for PoolDecisionDemoImpl {
type Ctx = PoolDecisionCtx;
type Action = PoolDecisionDemo;
fn decide(ctx: &Self::Ctx) -> Self::Action {
let elapsed = ctx.now.saturating_sub(ctx.last_scaled_at);
if elapsed < ctx.cooldown_secs {
return PoolDecisionDemo::NoOp;
}
let desired = ctx.desired_size.clamp(ctx.min_size, ctx.max_size);
if ctx.current_members < desired {
PoolDecisionDemo::Spawn {
count: desired - ctx.current_members,
}
} else if ctx.current_members > desired {
PoolDecisionDemo::ReapExcess {
count: ctx.current_members - desired,
}
} else {
PoolDecisionDemo::NoOp
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(current: u32, desired: u32) -> PoolDecisionCtx {
PoolDecisionCtx {
current_members: current,
last_scaled_at: 0,
now: 10_000, min_size: 1,
max_size: 10,
desired_size: desired,
cooldown_secs: 60,
}
}
#[test]
fn at_desired_size_is_noop() {
assert_eq!(
PoolDecisionDemoImpl::decide(&ctx(3, 3)),
PoolDecisionDemo::NoOp
);
}
#[test]
fn below_desired_spawns_delta() {
assert_eq!(
PoolDecisionDemoImpl::decide(&ctx(2, 5)),
PoolDecisionDemo::Spawn { count: 3 }
);
}
#[test]
fn above_desired_reaps_delta() {
assert_eq!(
PoolDecisionDemoImpl::decide(&ctx(8, 5)),
PoolDecisionDemo::ReapExcess { count: 3 }
);
}
#[test]
fn desired_clamped_by_policy_bounds() {
let to_max = PoolDecisionCtx {
current_members: 5,
desired_size: 20,
..ctx(5, 20)
};
assert_eq!(
PoolDecisionDemoImpl::decide(&to_max),
PoolDecisionDemo::Spawn { count: 5 },
"clamped to max"
);
let to_min = PoolDecisionCtx {
current_members: 5,
min_size: 2,
desired_size: 0,
..ctx(5, 0)
};
assert_eq!(
PoolDecisionDemoImpl::decide(&to_min),
PoolDecisionDemo::ReapExcess { count: 3 },
"clamped to min"
);
}
#[test]
fn within_cooldown_is_noop_even_when_undersized() {
let c = PoolDecisionCtx {
current_members: 1,
last_scaled_at: 9_990,
now: 10_000,
..ctx(1, 5)
};
assert_eq!(
PoolDecisionDemoImpl::decide(&c),
PoolDecisionDemo::NoOp,
"cooldown gate suppresses scaling"
);
}
#[test]
fn determinism_law() {
for (cur, des) in [(0u32, 3u32), (3, 3), (7, 3)] {
let c = ctx(cur, des);
crate::testing::assert_deterministic(|| PoolDecisionDemoImpl::decide(&c));
}
}
#[test]
fn generic_consumer_pattern() {
fn run_one<D: Decision>(c: &D::Ctx) -> D::Action {
D::decide(c)
}
assert_eq!(
run_one::<PoolDecisionDemoImpl>(&ctx(2, 5)),
PoolDecisionDemo::Spawn { count: 3 }
);
}
#[test]
fn ctx_and_action_serde_roundtrip() {
let c = ctx(2, 5);
let back: PoolDecisionCtx =
serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
assert_eq!(c, back);
let a = PoolDecisionDemo::Spawn { count: 7 };
let back_a: PoolDecisionDemo =
serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
assert_eq!(a, back_a);
}
}