use std::collections::BTreeMap;
use crate::api::{self, Advice, Peaks, Spread};
use crate::config::{Admission, Config};
use crate::observe::metrics::SAMPLE_INTERVAL;
pub(crate) use crate::api::GRANULE;
const AT_CAP: u64 = 95;
const WORTH_LOWERING: u64 = 10;
const WAITED: u64 = 60;
const BUSY: u32 = 50;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ran {
pub repository: String,
pub workflow: Option<String>,
pub name: String,
pub profile: String,
pub memory_cap: Option<u64>,
pub cpu_cap: Option<u64>,
pub memory_peak: u64,
pub cpu_peak: u64,
pub cpu_average: Option<u64>,
pub queued_at: u64,
pub started_at: Option<u64>,
pub runner_started_at: u64,
pub finished_at: Option<u64>,
pub oom_killed: bool,
pub hit_cap: Option<bool>,
pub measure: Measure,
pub succeeded: bool,
pub reserved: Option<u64>,
pub host_waited: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Measure {
WithActiveFile,
WithoutPageCache,
}
impl Ran {
pub fn met_cap(&self, cap: u64) -> bool {
self.oom_killed
|| self.hit_cap.unwrap_or_else(|| {
self.memory_peak.saturating_mul(100) >= cap.saturating_mul(AT_CAP)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Held {
pub profile: String,
pub started_at: u64,
pub removed_at: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct History {
pub jobs: Vec<Ran>,
pub cancelled: Vec<Ran>,
pub runners: Vec<Held>,
}
#[derive(Debug, Clone, Copy)]
pub struct Window {
pub since: u64,
pub now: u64,
pub sample_interval: u64,
}
impl Window {
pub fn new(since: Option<u64>, now: u64) -> Window {
Window {
since: since
.unwrap_or_else(|| now.saturating_sub(30 * 24 * 3600))
.min(now)
.saturating_mul(1000),
now: now.saturating_mul(1000),
sample_interval: SAMPLE_INTERVAL.as_secs(),
}
}
}
pub fn advise(
config: &Config,
memory_max: Option<u64>,
history: &History,
window: Window,
) -> api::Sizing {
let since = first_runner(history, window.since);
let caps = config.memory_caps().map_or(u64::MAX, |caps| caps.0);
let mirror = config.mirror_memory();
let rules = &config.sizing;
let profiles = config
.profiles
.iter()
.map(|(name, profile)| {
let jobs: Vec<&Ran> = history
.jobs
.iter()
.filter(|job| job.profile == *name)
.collect();
let share = profile.memory.0.saturating_mul(u64::from(profile.slots));
let busy = busy(history, name, profile.slots, since, window.now);
let mut keys: BTreeMap<(&str, Option<&str>, &str), Vec<&Ran>> = BTreeMap::new();
for job in &jobs {
keys.entry((&job.repository, job.workflow.as_deref(), &job.name))
.or_default()
.push(job);
}
let wait = waits(&jobs);
let starts = starts(&jobs);
let sized = Sized {
cap: profile.memory.0,
slots: profile.slots,
share,
admission: config.host.admission,
room: memory_max.map(|max| match config.host.admission {
Admission::Caps => max.saturating_sub(caps.saturating_sub(share)),
Admission::Measured => max.saturating_sub(mirror),
}),
wants_slots: wait.p95 >= WAITED || busy >= BUSY,
margin: rules.margin,
min_jobs: rules.min_jobs,
};
let job_sizings: Vec<api::JobSizing> = keys
.into_iter()
.map(|((repository, workflow, job_name), runs)| {
let peaks = peaks(&runs);
let wait = waits(&runs);
api::JobSizing {
repository: repository.to_owned(),
workflow: workflow.map(str::to_owned),
name: job_name.to_owned(),
advice: sized.advice(&peaks, wait.p95 >= WAITED || busy >= BUSY),
peaks,
wait,
}
})
.collect();
let peaks = peaks(&jobs);
api::ProfileSizing {
name: name.clone(),
memory: profile.memory.0,
cpus: profile.cpus,
slots: profile.slots,
advice: sized.advice(&peaks, sized.wants_slots),
peaks,
wait,
starts,
busy,
wants_slots: sized.wants_slots,
jobs: job_sizings,
}
})
.collect();
api::Sizing {
since: since / 1000,
until: window.now / 1000,
margin: rules.margin,
sample_interval: window.sample_interval,
min_jobs: rules.min_jobs,
memory_max,
admission: config.host.admission.policy(),
profiles,
auto: config.sizing.auto,
changes: Vec::new(),
auto_plan: None,
}
}
fn first_runner(history: &History, start: u64) -> u64 {
history
.runners
.iter()
.map(|runner| runner.started_at)
.min()
.map_or(start, |first| first.max(start))
}
fn busy(history: &History, profile: &str, slots: u32, since: u64, now: u64) -> u32 {
let span = now.saturating_sub(since).max(1);
let held: u64 = history
.runners
.iter()
.filter(|runner| runner.profile == profile)
.map(|runner| {
let end = runner.removed_at.unwrap_or(now).min(now);
end.saturating_sub(runner.started_at.max(since))
})
.sum();
u32::try_from(
(held.saturating_mul(100) / span.saturating_mul(u64::from(slots).max(1))).min(100),
)
.unwrap_or(100)
}
struct Sized {
cap: u64,
slots: u32,
share: u64,
admission: Admission,
room: Option<u64>,
wants_slots: bool,
margin: u32,
min_jobs: u32,
}
impl Sized {
fn need(&self, peaks: &Peaks) -> u64 {
api::need(peaks.memory.max, self.margin)
}
fn advice(&self, peaks: &Peaks, wants_slots: bool) -> Advice {
if peaks.jobs < u64::from(self.min_jobs) {
return Advice::TooFew { jobs: peaks.jobs };
}
let need = self.need(peaks);
let fit = |memory: u64| u32::try_from(memory / need).unwrap_or(u32::MAX);
if need > self.cap {
let slots = match (self.admission, self.room) {
(Admission::Caps, Some(room)) => fit(room).min(self.slots),
(Admission::Measured, Some(room)) if room < need => 0,
(_, _) => self.slots,
};
if slots == 0 {
return Advice::SliceTooSmall { memory: need };
}
return Advice::Raise {
memory: need,
slots,
};
}
if peaks.at_cap == 0 && self.cap - need >= self.cap * WORTH_LOWERING / 100 {
return Advice::Lower {
memory: need,
slots: if wants_slots && self.admission == Admission::Caps {
fit(self.share).max(self.slots)
} else {
self.slots
},
};
}
Advice::Keep { memory: need }
}
}
fn peaks(jobs: &[&Ran]) -> Peaks {
let at_cap = jobs
.iter()
.filter(|job| job.memory_cap.is_some_and(|cap| job.met_cap(cap)))
.count();
Peaks {
jobs: jobs.len() as u64,
at_cap: at_cap as u64,
memory: spread(jobs.iter().map(|job| job.memory_peak).collect()),
cpu: spread(jobs.iter().map(|job| job.cpu_peak).collect()),
}
}
fn waits(jobs: &[&Ran]) -> Spread {
spread(
jobs.iter()
.filter_map(|job| {
job.started_at.map(|at| {
at.saturating_sub(job.queued_at)
.saturating_sub(job.host_waited)
/ 1000
})
})
.collect(),
)
}
fn starts(jobs: &[&Ran]) -> api::Starts {
let (warm, cold): (Vec<&Ran>, Vec<&Ran>) = jobs
.iter()
.filter(|job| job.started_at.is_some())
.partition(|job| job.runner_started_at < job.queued_at);
let count = |jobs: &[&Ran]| u32::try_from(jobs.len()).unwrap_or(u32::MAX);
api::Starts {
warm: count(&warm),
warm_p50: waits(&warm).p50,
cold: count(&cold),
cold_p50: waits(&cold).p50,
}
}
pub(crate) fn spread(mut values: Vec<u64>) -> Spread {
values.sort_unstable();
let rank = |percent: usize| {
let index = (values.len() * percent).div_ceil(100).saturating_sub(1);
values.get(index).copied().unwrap_or(0)
};
Spread {
p50: rank(50),
p95: rank(95),
p99: rank(99),
max: values.last().copied().unwrap_or(0),
}
}
pub mod auto;
#[cfg(test)]
mod tests;