use serde::{Deserialize, Serialize};
use crate::api::AdmissionPolicy;
use crate::config::ByteSize;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SizingQuery {
pub since: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sizing {
pub since: u64,
pub until: u64,
pub margin: u32,
pub sample_interval: u64,
pub min_jobs: u32,
pub memory_max: Option<u64>,
#[serde(default)]
pub admission: AdmissionPolicy,
pub profiles: Vec<ProfileSizing>,
#[serde(default)]
pub auto: bool,
#[serde(default)]
pub changes: Vec<SizingChange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_plan: Option<AutoPlan>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoPlan {
pub every: u64,
pub interval: u64,
#[serde(default)]
pub raise_interval: u64,
pub max_step: u32,
pub next: Vec<SizingChange>,
pub held: Vec<SizingHold>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocked: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SizingHold {
pub profile: String,
pub changed: u64,
pub until: u64,
#[serde(default)]
pub lower_until: u64,
}
impl SizingHold {
pub fn lowered_after(&self) -> u64 {
self.lower_until.max(self.until)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SizingChange {
pub at: u64,
pub profile: String,
pub from: Resize,
pub to: Resize,
pub reason: String,
#[serde(default)]
pub uncertain: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSizing {
pub name: String,
pub memory: u64,
pub cpus: u32,
pub slots: u32,
pub peaks: Peaks,
pub wait: Spread,
#[serde(default)]
pub starts: Starts,
pub busy: u32,
#[serde(default)]
pub wants_slots: bool,
pub advice: Advice,
pub jobs: Vec<JobSizing>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Starts {
pub warm: u32,
pub warm_p50: u64,
pub cold: u32,
pub cold_p50: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobSizing {
pub repository: String,
pub workflow: Option<String>,
pub name: String,
pub peaks: Peaks,
pub wait: Spread,
pub advice: Advice,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Peaks {
pub jobs: u64,
pub at_cap: u64,
pub memory: Spread,
pub cpu: Spread,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Spread {
pub p50: u64,
pub p95: u64,
pub p99: u64,
pub max: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum Advice {
TooFew {
jobs: u64,
},
Keep {
memory: u64,
},
Lower {
memory: u64,
slots: u32,
},
Raise {
memory: u64,
slots: u32,
},
SliceTooSmall {
memory: u64,
},
}
impl Advice {
pub fn change(&self) -> Option<Resize> {
match *self {
Advice::Lower { memory, slots } | Advice::Raise { memory, slots } => Some(Resize {
memory,
slots,
cpus: None,
}),
Advice::TooFew { .. } | Advice::Keep { .. } | Advice::SliceTooSmall { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resize {
pub memory: u64,
pub slots: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<u32>,
}
impl AutoPlan {
pub fn rules(&self) -> String {
if self.raise_interval == 0 || self.raise_interval >= self.interval {
return format!(
"looks every {} and changes a profile at most once every {}, moving its memory by at most {}% at a \
time",
period(self.every),
period(self.interval),
self.max_step
);
}
format!(
"looks every {}, raises a profile at most once every {} and lowers it at most once every {}, moving its \
memory and CPUs by at most {}% at a time",
period(self.every),
period(self.raise_interval),
period(self.interval),
self.max_step
)
}
}
pub fn period(seconds: u64) -> String {
let plural = |count: u64, unit: &str| {
if count == 1 {
format!("{count} {unit}")
} else {
format!("{count} {unit}s")
}
};
match seconds {
0 => "0 s".to_owned(),
_ if seconds.is_multiple_of(3600) => plural(seconds / 3600, "hour"),
_ if seconds.is_multiple_of(60) => plural(seconds / 60, "minute"),
_ => format!("{seconds} s"),
}
}
impl SizingChange {
pub fn line(&self) -> String {
let memory = if self.from.memory == self.to.memory {
format!("memory {}", ByteSize(self.to.memory))
} else {
format!(
"memory {} → {}",
ByteSize(self.from.memory),
ByteSize(self.to.memory)
)
};
let slots = if self.from.slots == self.to.slots {
format!("slots {}", self.to.slots)
} else {
format!("slots {} → {}", self.from.slots, self.to.slots)
};
let cpus = match (self.from.cpus, self.to.cpus) {
(Some(from), Some(to)) if from != to => format!(", cpus {from} → {to}"),
_ => String::new(),
};
if self.uncertain {
format!("{memory}, {slots}{cpus} (may not have been written)")
} else {
format!("{memory}, {slots}{cpus}")
}
}
}
impl Sizing {
pub fn advice_line(&self, profile: &ProfileSizing) -> String {
let line = self.memory_line(profile);
if self.admission == AdmissionPolicy::Measured
&& profile.wants_slots
&& !matches!(profile.advice, Advice::TooFew { .. })
{
let then = if self.auto {
"auto sizing adds one at a time while every profile's slots fit the slice at their jobs' peaks"
} else {
"measured admission reserves predicted peaks, not caps, so more slots may fit: raise slots by hand"
};
format!(
"{line}; jobs waited {}s for a slot (p95) and slots were busy {}%: {then}",
profile.wait.p95, profile.busy
)
} else {
line
}
}
pub(crate) fn memory_line(&self, profile: &ProfileSizing) -> String {
let size = |bytes: u64| ByteSize(bytes).to_string();
let peaks = &profile.peaks;
let need = format!("its largest peak + {}%", self.margin);
let at_cap = format!(
"{} of {} jobs ran out of memory at their cap",
peaks.at_cap, peaks.jobs
);
match profile.advice {
Advice::TooFew { jobs } => format!(
"{jobs} jobs, too few to advise on (it takes {}); largest peak {} of a {} cap",
self.min_jobs,
size(peaks.memory.max),
size(profile.memory)
),
Advice::Keep { .. } if peaks.at_cap > 0 => format!(
"keep {}: {at_cap}, so they may need more than they show",
size(profile.memory)
),
Advice::Keep { memory } => {
format!("keep {}: {need} is {}", size(profile.memory), size(memory))
}
Advice::Lower { memory, slots } => {
let share = profile.memory * u64::from(profile.slots);
if self.admission == AdmissionPolicy::Measured {
return format!(
"lower memory to {} from {}, {need}",
size(memory),
size(profile.memory)
);
}
let then = if slots > profile.slots {
format!("{slots} slots fit in the same {}", size(share))
} else {
format!(
"frees {} of the budget",
size(share - memory * u64::from(slots))
)
};
format!(
"lower memory to {} from {}, {need}; {then}",
size(memory),
size(profile.memory)
)
}
Advice::Raise { memory, slots } => {
let why = if peaks.at_cap > 0 {
at_cap
} else {
format!("{need} is above the {} cap", size(profile.memory))
};
let slots = if slots < profile.slots {
format!("; {slots} of its {} slots fit the slice", profile.slots)
} else {
String::new()
};
format!("raise memory to {}: {why}{slots}", size(memory))
}
Advice::SliceTooSmall { memory } => format!(
"needs {memory_needed} ({need}), more than the slice holds for one slot: raise the slice's \
MemoryMax first",
memory_needed = size(memory)
),
}
}
}
#[cfg(all(test, any(feature = "host", feature = "client")))]
mod tests;