use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};
use super::{
CPU_PRIORITY_AFTER, Decision, IDLE_TIMEOUT, Profile, Repository, RunnerSlot, Slot, SlotRef,
plan,
};
use crate::github::QueuedJob;
const GIB: u64 = 1 << 30;
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000)
}
fn job(id: u64, labels: &[&str]) -> QueuedJob {
QueuedJob {
id,
run_id: id,
name: format!("job {id}"),
labels: labels.iter().map(|label| (*label).to_owned()).collect(),
..QueuedJob::default()
}
}
fn repository(name: &str, profile: &str, queued: Vec<QueuedJob>) -> Repository {
Repository {
name: name.to_owned(),
profile: profile.to_owned(),
labels: vec![
"self-hosted".to_owned(),
"linux".to_owned(),
profile.to_owned(),
],
paused: false,
held_until: None,
queued,
runner_memory: None,
runner_cpu: None,
cpu_waiting_since: None,
}
}
fn profiles(slots: Vec<Slot>) -> BTreeMap<String, Profile> {
BTreeMap::from([(
"app".to_owned(),
Profile {
memory: 4 * GIB,
cpus: 2000,
preparing: false,
slots,
},
)])
}
fn idle_runner(repository: &str, started_at: SystemTime) -> Slot {
Slot::Runner(RunnerSlot {
repository: repository.to_owned(),
busy: false,
started_at,
})
}
fn starts(decisions: &[Decision]) -> Vec<(usize, &str)> {
decisions
.iter()
.filter_map(|decision| match decision {
Decision::Start {
slot, repository, ..
} => Some((slot.index, repository.as_str())),
Decision::Stop { .. } | Decision::HeldForCpu { .. } => None,
})
.collect()
}
#[test]
fn two_repositories_sharing_two_slots_start_two_of_three_jobs_oldest_first() {
let repositories = [
repository("o/a", "app", vec![job(3, &["self-hosted", "app"])]),
repository(
"o/b",
"app",
vec![job(1, &["self-hosted", "app"]), job(2, &["app"])],
),
];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free, Slot::Free]),
None,
None,
now(),
);
assert_eq!(starts(&decisions), [(0, "o/b"), (1, "o/b")]);
}
#[test]
fn a_job_whose_labels_match_no_repository_never_starts() {
let repositories = [repository(
"o/a",
"app",
vec![job(1, &["ubuntu-latest"]), job(2, &["self-hosted", "gpu"])],
)];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free]),
None,
None,
now(),
);
assert_eq!(decisions, []);
}
#[test]
fn labels_match_regardless_of_case() {
let repositories = [repository(
"o/a",
"app",
vec![job(1, &["Self-Hosted", "APP"])],
)];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free]),
None,
None,
now(),
);
assert_eq!(starts(&decisions), [(0, "o/a")]);
}
#[test]
fn a_full_budget_starts_nothing() {
let repositories = [repository("o/a", "app", vec![job(1, &["app"])])];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free]),
Some(4 * GIB - 1),
None,
now(),
);
assert_eq!(decisions, []);
}
#[test]
fn the_budget_caps_starts_within_one_plan() {
let repositories = [repository(
"o/a",
"app",
vec![job(1, &["app"]), job(2, &["app"])],
)];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free, Slot::Free]),
Some(5 * GIB),
None,
now(),
);
assert_eq!(starts(&decisions), [(0, "o/a")]);
}
#[test]
fn runners_that_reserve_their_predicted_peak_start_more_within_the_same_budget() {
let repositories = [Repository {
runner_memory: Some(2 * GIB),
..repository(
"o/a",
"app",
vec![job(1, &["app"]), job(2, &["app"]), job(3, &["app"])],
)
}];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free, Slot::Free, Slot::Free]),
Some(5 * GIB),
None,
now(),
);
assert_eq!(starts(&decisions), [(0, "o/a"), (1, "o/a")]);
assert!(
decisions.iter().all(
|decision| matches!(decision, Decision::Start { memory, .. } if *memory == 2 * GIB)
),
"{decisions:?}"
);
}
#[test]
fn a_runner_still_waiting_for_its_job_is_not_started_twice() {
let repositories = [repository("o/a", "app", vec![job(1, &["app"])])];
let decisions = plan(
&repositories,
&profiles(vec![idle_runner("o/a", now()), Slot::Free]),
None,
None,
now(),
);
assert_eq!(decisions, []);
}
#[test]
fn an_idle_runner_stops_only_after_its_timeout_with_nothing_queued() {
let profiles_with = |started_at| profiles(vec![idle_runner("o/a", started_at)]);
let nothing_queued = [repository("o/a", "app", Vec::new())];
let just_started = now() - IDLE_TIMEOUT + Duration::from_secs(1);
assert_eq!(
plan(
¬hing_queued,
&profiles_with(just_started),
None,
None,
now()
),
[]
);
let long_ago = now() - IDLE_TIMEOUT;
let stop = Decision::Stop {
slot: SlotRef {
profile: "app".to_owned(),
index: 0,
},
};
assert_eq!(
plan(¬hing_queued, &profiles_with(long_ago), None, None, now()),
[stop]
);
let still_queued = [repository("o/a", "app", vec![job(1, &["app"])])];
assert_eq!(
plan(&still_queued, &profiles_with(long_ago), None, None, now()),
[]
);
}
#[test]
fn paused_held_and_reseeding_start_nothing() {
let queued = || vec![job(1, &["app"])];
let mut paused = repository("o/a", "app", queued());
paused.paused = true;
let mut held = repository("o/a", "app", queued());
held.held_until = Some(now() + Duration::from_secs(1));
for repositories in [[paused], [held]] {
assert_eq!(
plan(
&repositories,
&profiles(vec![Slot::Free]),
None,
None,
now()
),
[]
);
}
let mut reseeding = profiles(vec![Slot::Free]);
reseeding.get_mut("app").unwrap().preparing = true;
let repositories = [repository("o/a", "app", queued())];
assert_eq!(plan(&repositories, &reseeding, None, None, now()), []);
}
#[test]
fn a_job_with_a_slot_and_memory_but_no_cpu_waits_and_its_repository_is_held_for_cpu() {
let repositories = [
Repository {
runner_cpu: Some(1500),
..repository(
"o/a",
"app",
vec![job(1, &["app"]), job(2, &["app"]), job(3, &["app"])],
)
},
Repository {
runner_cpu: Some(500),
..repository("o/b", "app", vec![job(4, &["app"])])
},
];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free; 4]),
None,
Some(4000),
now(),
);
assert_eq!(starts(&decisions), [(0, "o/a"), (1, "o/a"), (2, "o/b")]);
assert!(
decisions.contains(&Decision::HeldForCpu {
repository: "o/a".to_owned()
}),
"o/a is held for CPU"
);
}
#[test]
fn a_runner_at_its_profiles_cpus_that_do_not_fit_holds_its_repository_once() {
let repositories = [repository(
"o/a",
"app",
vec![job(1, &["app"]), job(2, &["app"])],
)];
let decisions = plan(
&repositories,
&profiles(vec![Slot::Free, Slot::Free]),
None,
Some(1000),
now(),
);
assert_eq!(
decisions,
[Decision::HeldForCpu {
repository: "o/a".to_owned()
}]
);
}
fn heavy_job_beside_a_stream_of_light_ones(ticks: u64) -> (Option<u64>, usize) {
const TICK: Duration = Duration::from_secs(15);
let mut running: Vec<(&str, u64, u64)> = (1..=4).map(|end| ("o/light", 1000, end)).collect();
let mut waiting_since = None;
let mut light_after_limit = 0;
for tick in 1..=ticks {
let at = now() + TICK * u32::try_from(tick).unwrap();
running.retain(|(_, _, end)| *end > tick);
let in_use: u64 = running.iter().map(|(_, cpu, _)| cpu).sum();
let slots: Vec<Slot> = (0..10)
.map(|index| match running.get(index) {
Some((repository, ..)) => Slot::Runner(RunnerSlot {
repository: (*repository).to_owned(),
busy: true,
started_at: now(),
}),
None => Slot::Free,
})
.collect();
let heavy_queued = !running
.iter()
.any(|(repository, ..)| *repository == "o/heavy");
let repositories = [
Repository {
runner_cpu: Some(4000),
cpu_waiting_since: waiting_since,
..repository(
"o/heavy",
"app",
if heavy_queued {
vec![job(1, &["app"])]
} else {
Vec::new()
},
)
},
Repository {
runner_cpu: Some(1000),
..repository("o/light", "app", vec![job(1 + tick, &["app"])])
},
];
let decisions = plan(
&repositories,
&profiles(slots),
None,
Some(4000 - in_use),
at,
);
for (_, repository) in starts(&decisions) {
if repository == "o/heavy" {
return (Some(tick), light_after_limit);
}
if waiting_since
.is_some_and(|since| at.duration_since(since).unwrap() >= CPU_PRIORITY_AFTER)
{
light_after_limit += 1;
}
running.push(("o/light", 1000, tick + 4));
}
let held = decisions.contains(&Decision::HeldForCpu {
repository: "o/heavy".to_owned(),
});
waiting_since = if held {
waiting_since.or(Some(at))
} else {
None
};
}
(None, light_after_limit)
}
#[test]
fn a_job_held_for_cpu_long_enough_starts_once_younger_jobs_stop_taking_the_cpu_it_waits_for() {
let (started, light_after_limit) = heavy_job_beside_a_stream_of_light_ones(40);
let started = started.expect("o/heavy's job starts");
assert!(started <= 1 + 8 + 4, "started at tick {started}");
assert_eq!(light_after_limit, 0, "no light job started past the limit");
}