use super::{Held, History, Measure, Ran, Window, advise};
use crate::api::{Advice, ProfileSizing, Sizing, Starts};
use crate::config::{ByteSize, Config, resize_profile};
const G: u64 = 1 << 30;
const M: u64 = 1 << 20;
const HOUR: u64 = 3600 * 1000;
const NOW: u64 = 1_800_000_000_000;
const CONFIG: &str = r#"
[host]
slice = "runnerd.slice"
isolation = "sysbox"
[caches]
mirror = { enabled = true, memory = "512M", cpus = 1, ttl = "168h" }
pnpm = { max_size = "5G" }
maven = { max_size = "5G" }
[profiles.jvm]
image_stage = "jvm"
cpus = 8
memory = "9G"
slots = 2
[profiles.node]
image_stage = "node"
cpus = 8
memory = "7G"
slots = 2
[[repositories]]
name = "halyard/ledger"
profile = "jvm"
labels = ["jvm"]
[[repositories]]
name = "halyard/storefront"
profile = "node"
labels = ["node"]
"#;
fn config() -> Config {
Config::parse(CONFIG).unwrap()
}
fn job(repository: &str, name: &str, profile: &str, peak: u64, wait_secs: u64) -> Ran {
let cap = if profile == "jvm" { 9 * G } else { 7 * G };
Ran {
repository: repository.to_owned(),
workflow: Some("CI".to_owned()),
name: name.to_owned(),
profile: profile.to_owned(),
memory_cap: Some(cap),
cpu_cap: Some(8000),
memory_peak: peak,
cpu_peak: 6000,
cpu_average: Some(4000),
queued_at: NOW - 2 * HOUR,
started_at: Some(NOW - 2 * HOUR + wait_secs * 1000),
runner_started_at: NOW - 2 * HOUR,
finished_at: Some(NOW - HOUR),
oom_killed: false,
reserved: None,
host_waited: 0,
hit_cap: None,
own_cap: None,
measure: Measure::WithoutPageCache,
succeeded: true,
failed: false,
run_id: 0,
branch: Some("main".to_owned()),
}
}
fn oom(run: Ran) -> Ran {
Ran {
own_cap: Some(true),
..run
}
}
fn builds(count: u64, low: u64, high: u64, wait_secs: u64) -> Vec<Ran> {
(0..count)
.map(|i| {
let peak = low + (high - low) * i / (count - 1).max(1);
job("halyard/storefront", "build", "node", peak, wait_secs)
})
.collect()
}
fn sizing(history: &History) -> Sizing {
advise(
&config(),
Some(33 * G),
history,
Window {
since: NOW - 30 * 24 * HOUR,
now: NOW,
sample_interval: 2,
},
)
}
fn profile<'sizing>(sizing: &'sizing Sizing, name: &str) -> &'sizing ProfileSizing {
sizing.profiles.iter().find(|p| p.name == name).unwrap()
}
#[test]
fn a_cap_far_above_the_peaks_is_lowered_and_waiting_jobs_get_the_slots_that_fit() {
let history = History {
jobs: builds(100, 1200 * M, 2150 * M, 300),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
};
let node = profile(&sizing(&history), "node").clone();
assert_eq!(node.peaks.jobs, 60, "the job's last 60 runs");
assert_eq!(
node.advice,
Advice::Lower {
memory: 3328 * M,
slots: 4
}
);
}
#[test]
fn small_peaks_keep_a_gigabyte_of_page_cache_room_under_the_cap() {
let history = History {
jobs: builds(20, 300 * M, 300 * M, 5),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
};
assert_eq!(
profile(&sizing(&history), "node").advice,
Advice::Lower {
memory: 1536 * M,
slots: 2
}
);
}
#[test]
fn a_lowered_cap_keeps_its_slots_when_no_job_waited_for_one() {
let history = History {
jobs: builds(20, 1000 * M, 2000 * M, 5),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
};
assert_eq!(
profile(&sizing(&history), "node").advice,
Advice::Lower {
memory: 3 * G,
slots: 2
}
);
}
#[test]
fn a_rare_heavy_job_keeps_the_cap_above_its_largest_peak() {
let mut jobs = builds(99, 1000 * M, 1500 * M, 0);
jobs.extend((0..3).map(|_| job("halyard/storefront", "e2e", "node", 6 * G, 0)));
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let node = profile(&sizing, "node");
assert_eq!(node.advice, Advice::Keep { memory: 7 * G });
let e2e = node.jobs.iter().find(|j| j.name == "e2e").unwrap();
assert_eq!(e2e.advice, Advice::TooFew { jobs: 3 }, "3 runs are too few");
}
#[test]
fn jobs_that_reach_their_cap_raise_it_within_what_the_slice_holds() {
let jobs = (0..12)
.map(|i| {
oom(job(
"halyard/ledger",
"test",
"jvm",
8 * G + (800 + i) * M,
10,
))
})
.collect();
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let jvm = profile(&sizing, "jvm");
assert_eq!(jvm.peaks.at_cap, 12, "every run met 9G");
assert_eq!(
jvm.advice,
Advice::Raise {
memory: 10752 * M,
slots: 1
}
);
}
#[test]
fn too_few_jobs_give_no_advice_but_still_show_their_peaks() {
let sizing = sizing(&History {
jobs: builds(4, G, 2 * G, 0),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let node = profile(&sizing, "node");
assert_eq!(node.advice, Advice::TooFew { jobs: 4 });
assert_eq!(node.peaks.memory.max, 2 * G);
assert_eq!(profile(&sizing, "jvm").advice, Advice::TooFew { jobs: 0 });
}
#[test]
fn percentiles_are_nearest_rank_over_the_jobs_peaks() {
let jobs = (1..=200)
.map(|i| {
let name = format!("build {}", i % 4);
job("halyard/storefront", &name, "node", i * M, i)
})
.collect();
let node = profile(
&sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
}),
"node",
)
.clone();
assert_eq!(
(
node.peaks.memory.p50,
node.peaks.memory.p95,
node.peaks.memory.p99,
node.peaks.memory.max
),
(100 * M, 190 * M, 198 * M, 200 * M)
);
assert_eq!((node.wait.p50, node.wait.p95), (100, 190));
}
#[test]
fn jobs_a_warm_runner_took_are_told_apart_with_their_waits() {
let warm = |wait| Ran {
runner_started_at: NOW - 3 * HOUR,
..job("halyard/storefront", "build", "node", G, wait)
};
let jobs = vec![
warm(1),
warm(2),
warm(3),
job("halyard/storefront", "build", "node", G, 20),
job("halyard/storefront", "build", "node", G, 30),
];
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
assert_eq!(
profile(&sizing, "node").starts,
Starts {
warm: 3,
warm_p50: 2,
cold: 2,
cold_p50: 20,
}
);
}
#[test]
fn busy_is_the_share_of_slot_time_runners_held_since_the_first_runner() {
let runners = vec![
Held {
profile: "jvm".to_owned(),
started_at: NOW - 10 * HOUR,
removed_at: Some(NOW - 7 * HOUR),
},
Held {
profile: "jvm".to_owned(),
started_at: NOW - 2 * HOUR,
removed_at: None,
},
];
let sizing = sizing(&History {
jobs: Vec::new(),
cancelled: Vec::new(),
runners,
..History::default()
});
assert_eq!(sizing.since, (NOW - 10 * HOUR) / 1000);
assert_eq!(profile(&sizing, "jvm").busy, 25);
assert_eq!(profile(&sizing, "node").busy, 0);
}
#[test]
fn each_advised_change_written_into_the_config_still_fits_the_slice() {
for high in [300 * M, 2 * G, 5 * G, 6800 * M, 8 * G, 12 * G] {
for wait in [0, 600] {
let mut jobs = builds(30, 200 * M, high.min(7 * G), wait);
jobs.extend((0..30).map(|i| job("halyard/ledger", "test", "jvm", high + i * M, wait)));
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
for profile in &sizing.profiles {
let Some(change) = profile.advice.change() else {
continue;
};
let text = resize_profile(CONFIG, &profile.name, change).unwrap();
let (_, checked) = Config::check(&text, |_| Ok(Some(ByteSize(33 * G))));
assert!(
checked.valid,
"{}'s advice for peaks up to {high}, waits of {wait}s: {}",
profile.name, checked.message
);
}
}
}
}
#[test]
fn a_job_killed_for_memory_keeps_its_cap_however_low_its_sampled_peaks() {
let mut jobs = builds(20, 2 * G, 2 * G, 0);
jobs[0].oom_killed = true;
jobs[0].hit_cap = Some(true);
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let node = profile(&sizing, "node");
assert_eq!(node.peaks.at_cap, 1);
assert!(
matches!(node.advice, Advice::Keep { .. }),
"the cap stays: {:?}",
node.advice
);
}
#[test]
fn runs_killed_at_an_older_smaller_cap_keep_the_raised_cap() {
let jobs = (0..20)
.map(|_| Ran {
memory_cap: Some(4 * G),
..job("halyard/storefront", "build", "node", 4 * G - 50 * M, 0)
})
.collect();
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let node = profile(&sizing, "node");
assert_eq!(node.peaks.at_cap, 20);
assert!(
matches!(node.advice, Advice::Keep { .. }),
"the cap stays: {:?}",
node.advice
);
assert_eq!(
node.jobs[0].advice.change(),
None,
"nor is the job's lowered"
);
}
#[test]
fn the_heaviest_runs_of_a_job_with_hundreds_of_runs_keep_the_cap_they_need() {
let mut jobs = builds(198, 2 * G, 2 * G, 0);
jobs.extend(builds(2, 6 * G, 6 * G, 0));
let sizing = sizing(&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
});
let node = profile(&sizing, "node");
assert_eq!(
node.peaks.memory.p95,
2 * G,
"the p95 of its last 60 runs is shown as it is"
);
assert_eq!(node.advice, Advice::Keep { memory: 7 * G });
assert_eq!(node.jobs[0].advice, Advice::Keep { memory: 7 * G });
}
#[test]
fn jobs_that_need_more_than_the_slice_holds_for_one_slot_ask_for_a_larger_slice() {
let jobs = (0..12)
.map(|_| oom(job("halyard/ledger", "test", "jvm", 9 * G, 0)))
.collect();
let sizing = advise(
&config(),
Some(9 * G),
&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
},
Window::new(None, NOW / 1000),
);
let jvm = profile(&sizing, "jvm");
assert_eq!(jvm.advice, Advice::SliceTooSmall { memory: 10752 * M });
assert_eq!(jvm.advice.change(), None);
assert!(
sizing.advice_line(jvm).contains("MemoryMax"),
"the line says the slice has to grow: {}",
sizing.advice_line(jvm)
);
}
#[test]
fn a_start_in_the_future_reads_from_now() {
let window = Window::new(Some(u64::MAX / 10), NOW / 1000);
assert_eq!(window.since, window.now);
assert!(
i64::try_from(window.since).is_ok(),
"SQLite binds it as an integer"
);
}
fn measured() -> String {
CONFIG
.replace(
"isolation = \"sysbox\"",
"isolation = \"sysbox\"\nadmission = \"measured\"",
)
.replace("memory = \"7G\"\nslots = 2", "memory = \"5.5G\"\nslots = 4")
}
fn measured_sizing(history: &History, memory_max: u64) -> Sizing {
advise(
&Config::parse(&measured()).unwrap(),
Some(memory_max),
history,
Window::new(None, NOW / 1000),
)
}
fn capped_builds(count: u64, peak: u64, wait_secs: u64) -> Vec<Ran> {
(0..count)
.map(|_| Ran {
memory_cap: Some(5632 * M),
..job("halyard/storefront", "build", "node", peak, wait_secs)
})
.collect()
}
#[test]
fn applying_memory_advice_under_measured_admission_keeps_the_profiles_slots() {
let sizing = measured_sizing(
&History {
jobs: capped_builds(20, 5400 * M, 0)
.into_iter()
.map(oom)
.collect(),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
},
33 * G,
);
let node = profile(&sizing, "node");
assert_eq!(
node.advice,
Advice::Raise {
memory: 6656 * M,
slots: 4
}
);
let text = resize_profile(&measured(), "node", node.advice.change().unwrap()).unwrap();
let (_, checked) = Config::check(&text, |_| Ok(Some(ByteSize(33 * G))));
assert!(checked.valid, "the raise applies: {}", checked.message);
assert!(
!sizing.advice_line(node).contains("slots fit"),
"the line does not talk of cutting slots: {}",
sizing.advice_line(node)
);
}
#[test]
fn under_measured_admission_a_raise_is_refused_only_when_one_runner_and_the_mirror_overflow_the_slice()
{
let sizing = measured_sizing(
&History {
jobs: capped_builds(20, 5400 * M, 0)
.into_iter()
.map(oom)
.collect(),
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
},
6656 * M,
);
assert_eq!(
profile(&sizing, "node").advice,
Advice::SliceTooSmall { memory: 6656 * M }
);
}
#[test]
fn under_measured_admission_waiting_jobs_are_told_about_slots_without_changing_them() {
let jobs = (0..20)
.map(|_| Ran {
memory_cap: Some(5632 * M),
..job("halyard/storefront", "build", "node", 2 * G, 300)
})
.collect();
let sizing = measured_sizing(
&History {
jobs,
cancelled: Vec::new(),
runners: Vec::new(),
..History::default()
},
33 * G,
);
let node = profile(&sizing, "node");
assert_eq!(
node.advice,
Advice::Lower {
memory: 3 * G,
slots: 4
}
);
let line = sizing.advice_line(node);
assert!(
line.contains("slots"),
"the line suggests more slots: {line}"
);
let auto = Sizing {
auto: true,
..sizing.clone()
};
let line = auto.advice_line(profile(&auto, "node"));
assert!(
line.contains("auto sizing adds one") && !line.contains("by hand"),
"with auto sizing on, the line says what it does: {line}"
);
}
mod auto {
use std::collections::BTreeMap;
use super::{
CONFIG, G, HOUR, History, M, NOW, Ran, Window, builds, capped_builds, job, measured, oom,
};
use crate::api::{Resize, SizingChange};
use crate::config::Config;
use crate::sizing::auto::{Context, changes};
fn on(text: &str, rules: &str) -> Config {
let interval = if rules.lines().any(|line| line.starts_with("interval")) {
""
} else {
"interval = \"24h\"\n"
};
let step = if rules.lines().any(|line| line.starts_with("max_step")) {
""
} else {
"max_step = 25\n"
};
Config::parse(&format!(
"{text}\n[sizing]\nauto = true\n{interval}{step}{rules}"
))
.unwrap()
}
fn window() -> Window {
Window {
since: NOW - 30 * 24 * HOUR,
now: NOW,
sample_interval: 2,
}
}
fn history(jobs: Vec<Ran>) -> History {
History {
jobs,
..History::default()
}
}
fn due(
config: &Config,
history: &History,
memory_max: u64,
last: &BTreeMap<String, u64>,
) -> Vec<SizingChange> {
changes(
config,
history,
Context {
memory_max: Some(memory_max),
cpus: Some(32_000),
window: window(),
last,
cpus_set: &[],
running: &BTreeMap::new(),
},
)
}
fn resized(changes: &[SizingChange]) -> Vec<(&str, Resize)> {
changes
.iter()
.map(|change| (change.profile.as_str(), change.to))
.collect()
}
#[test]
fn auto_sizing_lowers_an_oversized_profile_once_it_has_enough_runs() {
let config = on(CONFIG, "");
let few = history(builds(9, 1000 * M, 2000 * M, 5));
assert_eq!(due(&config, &few, 33 * G, &BTreeMap::new()), Vec::new());
let enough = history(builds(10, 1000 * M, 2000 * M, 5));
let lowered = due(&config, &enough, 33 * G, &BTreeMap::new());
assert_eq!(
resized(&lowered),
vec![(
"node",
Resize {
memory: 5376 * M,
slots: 2,
cpus: None
}
)]
);
assert_eq!(
lowered[0].from,
Resize {
memory: 7 * G,
slots: 2,
cpus: None
},
"the change keeps what it replaced, to undo it"
);
assert_eq!(lowered[0].at, NOW / 1000);
}
#[test]
fn a_step_short_of_the_advice_says_the_memory_and_slots_it_writes_and_what_it_goes_toward() {
let config = on(CONFIG, "");
let waited = history(builds(10, 1000 * M, 2000 * M, 300));
let changed = due(&config, &waited, 33 * G, &BTreeMap::new());
let [change] = &changed[..] else {
panic!("one change: {changed:?}")
};
assert_eq!((change.to.memory, change.to.slots), (5376 * M, 2));
assert!(
change.reason.starts_with("lower memory to 5.25G from 7G")
&& change.reason.contains("a step toward 3G")
&& !change.reason.contains("4 slots"),
"the reason is the change's, not the advice's: {}",
change.reason
);
}
#[test]
fn auto_sizing_is_off_unless_the_config_turns_it_on() {
let config = Config::parse(CONFIG).unwrap();
let enough = history(builds(10, 1000 * M, 2000 * M, 5));
assert_eq!(due(&config, &enough, 33 * G, &BTreeMap::new()), Vec::new());
}
#[test]
fn a_profile_lowered_step_by_step_stops_at_its_min_memory() {
let config = on(
&CONFIG.replace("memory = \"7G\"", "memory = \"7G\"\nmin_memory = \"6G\""),
"max_step = 100",
);
let enough = history(builds(10, 1000 * M, 2000 * M, 5));
assert_eq!(
resized(&due(&config, &enough, 33 * G, &BTreeMap::new())),
vec![(
"node",
Resize {
memory: 6 * G,
slots: 2,
cpus: None
}
)]
);
}
#[test]
fn waits_the_interval_between_changes() {
let config = on(CONFIG, "interval = \"12h\"");
let enough = history(builds(10, 1000 * M, 2000 * M, 5));
let changed =
|hours_ago: u64| BTreeMap::from([("node".to_owned(), (NOW - hours_ago * HOUR) / 1000)]);
assert_eq!(due(&config, &enough, 33 * G, &changed(11)), Vec::new());
let long = on(CONFIG, "interval = \"60d\"");
assert_eq!(
due(&long, &enough, 33 * G, &changed(45 * 24)),
Vec::new(),
"an interval longer than the advice's 30 days still holds"
);
assert_eq!(
resized(&due(&config, &enough, 33 * G, &changed(13))),
vec![(
"node",
Resize {
memory: 5376 * M,
slots: 2,
cpus: None
}
)]
);
}
fn at_jvm_cap() -> History {
history(
(0..10)
.map(|_| oom(job("halyard/ledger", "test", "jvm", 9 * G, 5)))
.collect(),
)
}
#[test]
fn never_raises_past_memory_max() {
let config = on(CONFIG, "");
assert_eq!(
due(&config, &at_jvm_cap(), 24 * G, &BTreeMap::new()),
Vec::new()
);
assert_eq!(
resized(&due(&config, &at_jvm_cap(), 33 * G, &BTreeMap::new())),
vec![(
"jvm",
Resize {
memory: 10752 * M,
slots: 1,
cpus: None
}
)]
);
}
#[test]
fn a_raise_stops_at_the_profiles_max_memory() {
let config = on(
&CONFIG.replace("memory = \"9G\"", "memory = \"9G\"\nmax_memory = \"10G\""),
"",
);
assert_eq!(
resized(&due(&config, &at_jvm_cap(), 36 * G, &BTreeMap::new())),
vec![(
"jvm",
Resize {
memory: 10 * G,
slots: 2,
cpus: None
}
)]
);
}
#[test]
fn a_raise_that_takes_a_slot_away_waits_while_every_slot_runs() {
let config = on(CONFIG, "");
let running = BTreeMap::from([("jvm".to_owned(), 2)]);
let raised = changes(
&config,
&at_jvm_cap(),
Context {
memory_max: Some(33 * G),
cpus: Some(32_000),
window: window(),
last: &BTreeMap::new(),
cpus_set: &[],
running: &running,
},
);
assert_eq!(raised, Vec::new());
}
#[test]
fn never_lowers_a_profile_a_cancelled_run_was_killed_for_memory_in() {
let config = on(CONFIG, "");
let mut killed = history(builds(10, 1000 * M, 2000 * M, 5));
killed.cancelled.push(Ran {
oom_killed: true,
succeeded: false,
..job("halyard/storefront", "build", "node", 0, 5)
});
assert_eq!(due(&config, &killed, 33 * G, &BTreeMap::new()), Vec::new());
}
#[test]
fn two_profiles_never_both_take_a_slot_only_one_fits() {
let text = r#"
[host]
slice = "runnerd.slice"
isolation = "sysbox"
admission = "measured"
[caches]
mirror = { enabled = true, memory = "1G", cpus = 1, ttl = "168h" }
pnpm = { max_size = "5G" }
maven = { max_size = "5G" }
[profiles.jvm]
image_stage = "jvm"
cpus = 4
memory = "3G"
slots = 2
max_slots = 3
[profiles.node]
image_stage = "node"
cpus = 4
memory = "3G"
slots = 2
max_slots = 3
[[repositories]]
name = "halyard/ledger"
profile = "jvm"
labels = ["jvm"]
[[repositories]]
name = "halyard/storefront"
profile = "node"
labels = ["node"]
"#;
let config = on(text, "");
let waited = |repository: &str, profile: &str| -> Vec<Ran> {
(0..10)
.map(|_| Ran {
memory_cap: Some(3 * G),
..job(repository, "build", profile, 2 * G, 300)
})
.collect()
};
let mut jobs = waited("halyard/ledger", "jvm");
jobs.extend(waited("halyard/storefront", "node"));
let raised = due(&config, &history(jobs), 11 * G, &BTreeMap::new());
assert_eq!(
raised
.iter()
.filter(|change| change.to.slots > change.from.slots)
.count(),
1,
"one profile gets the third slot: {raised:?}"
);
}
#[test]
fn under_measured_admission_waiting_jobs_get_one_more_slot_up_to_max_slots_while_their_peaks_fit()
{
let text = measured().replace("slots = 4", "slots = 4\nmax_slots = 5");
let config = on(&text, "");
let waited = history(capped_builds(10, 4500 * M, 300));
assert_eq!(due(&config, &waited, 40 * G, &BTreeMap::new()), Vec::new());
let raised = due(&config, &waited, 41 * G, &BTreeMap::new());
assert_eq!(
resized(&raised),
vec![(
"node",
Resize {
memory: 5632 * M,
slots: 5,
cpus: None
}
)]
);
let at_max = on(
&measured().replace("slots = 4", "slots = 4\nmax_slots = 4"),
"",
);
assert_eq!(due(&at_max, &waited, 64 * G, &BTreeMap::new()), Vec::new());
}
#[test]
fn waits_while_the_host_was_short_of_memory_ask_for_no_more_slots() {
let text = measured().replace("slots = 4", "slots = 4\nmax_slots = 5");
let config = on(&text, "");
let held = history(
capped_builds(10, 4500 * M, 300)
.into_iter()
.map(|build| Ran {
host_waited: 300_000,
..build
})
.collect(),
);
assert_eq!(due(&config, &held, 64 * G, &BTreeMap::new()), Vec::new());
}
#[test]
fn without_max_slots_waiting_jobs_get_a_slot_only_while_every_profiles_peaks_fit_the_slice() {
let config = on(&measured(), "");
let waited = history(capped_builds(10, 4500 * M, 300));
assert_eq!(
due(&config, &waited, 40 * G, &BTreeMap::new()),
Vec::new(),
"a fifth slot does not fit 40G"
);
assert_eq!(
resized(&due(&config, &waited, 41 * G, &BTreeMap::new())),
vec![(
"node",
Resize {
memory: 5632 * M,
slots: 5,
cpus: None
}
)]
);
let unlimited = changes(
&config,
&waited,
Context {
memory_max: None,
cpus: Some(32_000),
window: window(),
last: &BTreeMap::new(),
cpus_set: &[],
running: &BTreeMap::new(),
},
);
assert_eq!(
unlimited,
Vec::new(),
"without max_slots or a MemoryMax nothing bounds slots"
);
}
#[test]
fn under_caps_waiting_jobs_get_a_slot_while_every_cap_fits_the_slice() {
let config = on(CONFIG, "");
let waited = history(builds(10, 5632 * M, 6 * G, 300));
assert_eq!(due(&config, &waited, 39 * G, &BTreeMap::new()), Vec::new());
assert_eq!(
resized(&due(&config, &waited, 40 * G, &BTreeMap::new())),
vec![(
"node",
Resize {
memory: 7 * G,
slots: 3,
cpus: None
}
)]
);
}
#[test]
fn a_young_profile_whose_jobs_wait_gets_a_slot_before_it_has_min_jobs_runs() {
let config = on(&measured(), "");
let waited = history(capped_builds(3, 4500 * M, 300));
let raised = due(&config, &waited, 64 * G, &BTreeMap::new());
assert_eq!(
resized(&raised),
vec![(
"node",
Resize {
memory: 5632 * M,
slots: 5,
cpus: None
}
)]
);
let two = history(capped_builds(2, 4500 * M, 300));
assert_eq!(
due(&config, &two, 64 * G, &BTreeMap::new()),
Vec::new(),
"two runs are too few even to add a slot"
);
}
#[test]
fn under_measured_admission_a_lowering_that_adds_a_slot_says_why_both() {
let config = on(&measured(), "");
let waited = history(capped_builds(10, 2 * G, 300));
let changed = due(&config, &waited, 64 * G, &BTreeMap::new());
let [change] = &changed[..] else {
panic!("one change: {changed:?}")
};
assert!(
change.to.memory < change.from.memory && change.to.slots == 5,
"{change:?}"
);
assert!(
change.reason.contains("lower memory") && change.reason.contains("waited 300s"),
"the reason says both: {}",
change.reason
);
}
fn node_changed(hours_ago: u64) -> BTreeMap<String, u64> {
BTreeMap::from([("node".to_owned(), (NOW - hours_ago * HOUR) / 1000)])
}
#[test]
fn a_raise_comes_after_the_raise_interval_while_a_lowering_waits_the_whole_interval() {
let config = on(&measured(), "");
let waited = history(
capped_builds(10, 4500 * M, 300)
.into_iter()
.map(|build| Ran {
queued_at: NOW - HOUR / 2,
started_at: Some(NOW - HOUR / 2 + 300_000),
..build
})
.collect(),
);
let changed_minutes_ago =
|minutes: u64| BTreeMap::from([("node".to_owned(), (NOW - minutes * 60_000) / 1000)]);
assert_eq!(
due(&config, &waited, 64 * G, &changed_minutes_ago(59)),
Vec::new(),
"the hour's raise interval is not over"
);
let raised = due(&config, &waited, 64 * G, &changed_minutes_ago(61));
assert_eq!(raised.first().map(|change| change.to.slots), Some(5));
let light = history(capped_builds(10, 2 * G, 5));
assert_eq!(due(&config, &light, 64 * G, &node_changed(3)), Vec::new());
assert_eq!(
due(&config, &light, 64 * G, &node_changed(25))
.first()
.map(|change| change.to.memory < 5632 * M),
Some(true),
"lowered once the interval is over"
);
}
#[test]
fn a_raise_reads_only_the_waits_since_the_profiles_last_change() {
let config = on(&measured(), "");
let mut jobs = capped_builds(10, 4500 * M, 300);
jobs.extend((0..3).map(|_| Ran {
queued_at: NOW - HOUR,
started_at: Some(NOW - HOUR + 2000),
..capped_builds(1, 4500 * M, 0).remove(0)
}));
let changed = BTreeMap::from([("node".to_owned(), (NOW - 90 * 60_000) / 1000)]);
assert_eq!(due(&config, &history(jobs), 64 * G, &changed), Vec::new());
}
fn cpu_budget(rules: &str) -> Config {
on(
&CONFIG.replace(
"isolation = \"sysbox\"",
"isolation = \"sysbox\"\ncpu_overcommit = 1.0",
),
rules,
)
}
fn due_in(config: &Config, history: &History, memory_max: u64, cpus: u64) -> Vec<SizingChange> {
changes(
config,
history,
Context {
memory_max: Some(memory_max),
cpus: Some(cpus),
window: window(),
last: &BTreeMap::new(),
cpus_set: &[],
running: &BTreeMap::new(),
},
)
}
#[test]
fn under_caps_a_waiting_profile_gets_no_slot_the_cpu_budget_cannot_hold() {
let waited = history(builds(10, 5632 * M, 6 * G, 300));
assert_eq!(due_in(&cpu_budget(""), &waited, 64 * G, 32_000), Vec::new());
assert_eq!(
resized(&due_in(&cpu_budget(""), &waited, 64 * G, 40_000)),
vec![(
"node",
Resize {
memory: 7 * G,
slots: 3,
cpus: None
}
)],
"40 CPUs hold one more slot of 8"
);
}
#[test]
fn under_caps_a_lowering_keeps_its_memory_but_adds_only_the_slots_the_cpu_budget_holds() {
let waited = history(builds(100, 1200 * M, 2150 * M, 300));
let config = cpu_budget("max_step = 100");
let full = due_in(&config, &waited, 64 * G, 32_000);
assert_eq!(
full.iter()
.map(|change| (change.to.memory < change.from.memory, change.to.slots))
.collect::<Vec<_>>(),
[(true, 2)],
"{full:?}"
);
let room = due_in(&config, &waited, 64 * G, 40_000);
assert_eq!(
room.iter()
.map(|change| change.to.slots)
.collect::<Vec<_>>(),
[3]
);
}
#[test]
fn two_profiles_never_both_take_the_cpus_only_one_slot_fits() {
let mut jobs: Vec<Ran> = (0..10)
.map(|_| job("halyard/ledger", "test", "jvm", 7 * G, 300))
.collect();
jobs.extend(builds(10, 5632 * M, 6 * G, 300));
let raised = due_in(&cpu_budget(""), &history(jobs), 64 * G, 40_000);
assert_eq!(
raised.iter().map(|change| change.to.slots).sum::<u32>(),
3,
"one profile gets the slot 8 more CPUs hold: {raised:?}"
);
}
}
mod cpus;
mod memory;