use std::collections::BTreeMap;
use crate::api::{Advice, ProfileSizing, Resize, SizingChange};
use crate::config::{Admission, Config, Profile};
use super::{GRANULE, History, Window, advise};
#[derive(Debug, Clone, Copy)]
pub struct Context<'run> {
pub memory_max: Option<u64>,
pub window: Window,
pub last: &'run BTreeMap<String, u64>,
pub running: &'run BTreeMap<String, u32>,
}
pub fn changes(config: &Config, history: &History, context: Context<'_>) -> Vec<SizingChange> {
let rules = &config.sizing;
if !rules.auto {
return Vec::new();
}
let window = context.window;
let sizing = advise(config, context.memory_max, history, window);
let now = window.now / 1000;
let interval = rules.interval.as_secs();
let mut budget = Budget {
caps: config.memory_caps().map_or(u64::MAX, |caps| caps.0),
slots: sizing
.profiles
.iter()
.map(|advised| (advised.name.clone(), advised.slots))
.collect(),
};
let mut changes = Vec::new();
for advised in &sizing.profiles {
let Some(profile) = config.profiles.get(&advised.name) else {
continue;
};
if context
.last
.get(&advised.name)
.is_some_and(|at| now < at.saturating_add(interval))
{
continue;
}
let from = Resize {
memory: profile.memory.0,
slots: profile.slots,
};
let mut reasons = Vec::new();
let memory = memory(
config,
profile,
advised.advice,
met_cap(history, &advised.name),
);
if memory != from.memory {
reasons.push(sizing.memory_line(advised));
}
let running = context.running.get(&advised.name).copied().unwrap_or(0);
let slots = match config.host.admission {
Admission::Caps => caps_slots(profile, advised, memory, &context, &budget, running),
Admission::Measured => {
let slots = measured_slots(config, &sizing.profiles, advised, &context, &budget);
if slots > from.slots {
reasons.push(format!(
"jobs waited {}s for a slot (p95) and slots were busy {}%; {slots} slots at their jobs' \
p95 peaks fit the slice beside the other profiles'",
advised.wait.p95, advised.busy
));
}
slots
}
};
let to = Resize { memory, slots };
if to == from || slots == 0 || !within(profile, from, to) {
continue;
}
budget.caps = budget
.caps
.saturating_sub(from.memory.saturating_mul(u64::from(from.slots)))
.saturating_add(to.memory.saturating_mul(u64::from(to.slots)));
budget.slots.insert(advised.name.clone(), to.slots);
changes.push(SizingChange {
at: now,
profile: advised.name.clone(),
from,
to,
reason: reasons.join("; "),
uncertain: false,
});
}
changes
}
struct Budget {
caps: u64,
slots: BTreeMap<String, u32>,
}
fn within(profile: &Profile, from: Resize, to: Resize) -> bool {
profile.min_memory.is_none_or(|min| to.memory >= min.0)
&& profile.max_memory.is_none_or(|max| to.memory <= max.0)
&& (to.slots <= from.slots || profile.max_slots.is_some_and(|max| to.slots <= max))
}
fn met_cap(history: &History, profile: &str) -> bool {
history
.jobs
.iter()
.chain(&history.cancelled)
.filter(|job| job.profile == profile)
.any(|job| job.oom_killed || job.memory_cap.is_some_and(|cap| job.met_cap(cap)))
}
fn memory(config: &Config, profile: &Profile, advice: Advice, met_cap: bool) -> u64 {
let current = profile.memory.0;
let step = current.saturating_mul(u64::from(config.sizing.max_step)) / 100;
match advice {
Advice::Lower { memory, .. } if !met_cap => {
let floor = current
.saturating_sub(step)
.div_ceil(GRANULE)
.saturating_mul(GRANULE);
let bound = profile.min_memory.map_or(0, |min| min.0);
memory.max(floor).max(bound).min(current)
}
Advice::Raise { memory, .. } => {
let ceiling = current.saturating_add(step) / GRANULE * GRANULE;
let bound = profile.max_memory.map_or(u64::MAX, |max| max.0);
memory.min(ceiling).min(bound).max(current)
}
_ => current,
}
}
fn caps_slots(
profile: &Profile,
advised: &ProfileSizing,
memory: u64,
context: &Context<'_>,
budget: &Budget,
running: u32,
) -> u32 {
let current = profile.slots;
let fit = |room: u64| u32::try_from(room / memory.max(1)).unwrap_or(u32::MAX);
if memory < profile.memory.0 && advised.wants_slots {
let share = profile.memory.0.saturating_mul(u64::from(current));
return fit(share)
.min(profile.max_slots.unwrap_or(current))
.max(current);
}
if memory > profile.memory.0 {
let Some(memory_max) = context.memory_max else {
return current;
};
let share = profile.memory.0.saturating_mul(u64::from(current));
let room = memory_max.saturating_sub(budget.caps.saturating_sub(share));
let slots = fit(room).min(current);
return if slots < running { 0 } else { slots };
}
current
}
fn measured_slots(
config: &Config,
profiles: &[ProfileSizing],
advised: &ProfileSizing,
context: &Context<'_>,
budget: &Budget,
) -> u32 {
let current = advised.slots;
let wants = advised.wants_slots && !matches!(advised.advice, Advice::TooFew { .. });
let Some(max_slots) = config
.profiles
.get(&advised.name)
.and_then(|profile| profile.max_slots)
.filter(|max| wants && *max > current)
else {
return current;
};
let Some(memory_max) = context.memory_max else {
return (current + 1).min(max_slots);
};
let peaks: u64 = profiles
.iter()
.map(|other| {
let peak = if matches!(other.advice, Advice::TooFew { .. }) {
other.memory
} else {
other.peaks.memory.p95
};
let slots = if other.name == advised.name {
current + 1
} else {
budget
.slots
.get(&other.name)
.copied()
.unwrap_or(other.slots)
};
peak.saturating_mul(u64::from(slots))
})
.fold(config.mirror_memory(), u64::saturating_add);
if peaks <= memory_max {
current + 1
} else {
current
}
}