use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::SystemTime;
use futures_util::StreamExt;
use tracing::{info, warn};
use super::admission::Admitting;
use super::{Engine, Occupant, RunningJobs, Shutdown};
use crate::config::Admission;
use crate::docker::DockerApi;
use crate::github::{self, Deleted, GithubApi, QueuedJob, Runner};
use crate::observe::metrics::SAMPLE_INTERVAL;
use crate::scheduler::{Decision, Profile, Repository, RunnerSlot, Slot, plan};
use crate::time;
pub(super) const CONCURRENT_REQUESTS: usize = 4;
type Answers<T> = Vec<(String, Result<T, github::Error>)>;
struct Listings {
runners: Answers<Vec<Runner>>,
queued: Answers<Vec<QueuedJob>>,
}
async fn list<G: GithubApi>(github: Arc<G>, runners: Vec<String>, queued: Vec<String>) -> Listings {
let github = &*github;
let runners = futures_util::stream::iter(runners)
.map(|repository| async move {
let answer = github.runners(&repository).await;
(repository, answer)
})
.buffered(CONCURRENT_REQUESTS)
.collect()
.await;
let queued = futures_util::stream::iter(queued)
.map(|repository| async move {
let answer = github.queued_jobs(&repository).await;
(repository, answer)
})
.buffered(CONCURRENT_REQUESTS)
.collect()
.await;
Listings { runners, queued }
}
impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
pub(super) async fn tick(&mut self, mode: Shutdown) {
self.collect_reseeds().await;
self.collect_trim().await;
self.collect_recordings().await;
self.collect_finished().await;
self.collect_shells().await;
self.kill_cancelled().await;
let runners = self
.repositories
.keys()
.chain(&self.draining)
.cloned()
.collect();
let queued = match mode {
Shutdown::No => self
.config
.repositories
.iter()
.map(|repository| repository.name.clone())
.collect(),
Shutdown::Graceful | Shutdown::Force => Vec::new(),
};
let listings = self
.serving(list(self.github.clone(), runners, queued))
.await;
self.apply_runners(listings.runners).await;
if mode == Shutdown::No {
self.apply_queued(listings.queued);
} else {
for state in self.repositories.values_mut() {
state.listed = false;
}
}
self.take_found();
self.follow_jobs().await;
self.count_mirror_bytes().await;
if mode == Shutdown::No {
self.pull_images().await;
self.refresh_predictor().await;
self.rereserve().await;
}
let (decisions, mut admitting) = match mode {
Shutdown::No => self.plan(),
Shutdown::Graceful | Shutdown::Force => (
self.occupied()
.filter(|(_, occupant)| mode == Shutdown::Force || !occupant.busy)
.map(|(slot, _)| Decision::Stop { slot })
.collect(),
self.admitting(&mut [], None),
),
};
self.note_high_water(&admitting);
for decision in decisions {
match decision {
Decision::Stop { slot } => {
let jobs = match mode {
Shutdown::Force => RunningJobs::Kill,
Shutdown::No | Shutdown::Graceful => RunningJobs::Keep,
};
self.stop(&slot, jobs).await;
}
Decision::Start {
slot,
repository,
memory,
} => {
let admission = admitting.admit(&repository, memory);
self.start(&slot, &repository, admission).await;
}
}
}
self.idle_caches(mode).await;
if self.records.listed {
self.expire_records().await;
} else {
self.list_records().await;
}
self.prune();
}
fn plan(&self) -> (Vec<Decision>, Admitting) {
let taken: BTreeSet<u64> = self
.occupied()
.filter_map(|(_, occupant)| occupant.job)
.collect();
let mut repositories: Vec<Repository> = self
.config
.repositories
.iter()
.map(|repository| {
let state = &self.repositories[&repository.name];
Repository {
name: repository.name.clone(),
profile: repository.profile.clone(),
labels: repository.labels.clone(),
paused: self.control.is_paused(&repository.name),
held_until: state.held_until,
queued: state
.queued
.iter()
.filter(|job| !taken.contains(&job.id))
.cloned()
.collect(),
runner_memory: None,
}
})
.collect();
let profiles = self
.config
.profiles
.iter()
.map(|(name, profile)| {
let slots = &self.slots[name];
let mut free = (profile.slots as usize)
.saturating_sub(slots.iter().filter(|slot| slot.is_some()).count());
let slots = slots
.iter()
.map(|slot| match slot {
None if free > 0 => {
free -= 1;
Slot::Free
}
None => Slot::Closed,
Some(occupant) => Slot::Runner(RunnerSlot {
repository: occupant.repository.clone(),
busy: occupant.busy,
started_at: occupant.started_at,
}),
})
.collect();
let profile = Profile {
memory: profile.memory.0,
preparing: self.is_reseeding(name) || self.awaits_image(&profile.image),
slots,
};
(name.clone(), profile)
})
.collect();
let free_memory = self
.memory_max
.map(|max| max.saturating_sub(self.memory_in_use()));
let admitting = self.admitting(&mut repositories, free_memory);
let mut decisions = plan(&repositories, &profiles, admitting.free, SystemTime::now());
decisions.extend(
self.occupied()
.filter(|(slot, occupant)| {
!occupant.busy
&& occupant.job.is_none()
&& !self.config.repositories.iter().any(|repository| {
repository.name == occupant.repository
&& repository.profile == slot.profile
})
})
.map(|(slot, _)| Decision::Stop { slot }),
);
let stops = self.over_budget(&decisions);
decisions.extend(stops);
(decisions, admitting)
}
pub(super) fn memory_in_use(&self) -> u64 {
let runners: u64 = self
.occupied()
.map(|(_, occupant)| self.takes(occupant))
.sum();
let reseeds: u64 = self.reseed_memory.values().sum();
runners + reseeds + self.shell_memory() + self.mirror_memory_in_use()
}
pub(super) fn takes(&self, occupant: &Occupant) -> u64 {
match self.config.host.admission {
Admission::Caps => occupant.memory,
Admission::Measured => {
let now = time::now_ms();
let fresh = 3 * u64::try_from(SAMPLE_INTERVAL.as_millis()).unwrap_or(u64::MAX);
let slice_sampled = self
.store
.latest_host_sample()
.is_some_and(|sample| sample.at.saturating_mul(1000) + fresh >= now);
let own = self.store.latest_sample(&occupant.runner_name);
let sampled_at = own
.as_ref()
.map_or_else(|| time::ms(occupant.started_at), |sample| sample.at);
if !slice_sampled || sampled_at + fresh < now {
occupant.memory
} else {
occupant
.reserved()
.max(own.map_or(0, |sample| sample.memory))
}
}
}
}
pub(super) fn mirror_memory_in_use(&self) -> u64 {
let mirror = &self.config.caches.mirror;
let configured = if mirror.enabled { mirror.memory.0 } else { 0 };
let running = if self.mirror_up {
self.mirror_memory
} else {
0
};
configured.max(running)
}
fn apply_queued(&mut self, listings: Answers<Vec<QueuedJob>>) {
for (repository, answer) in listings {
let mut listed = false;
let queued = match answer {
Ok(queued) => {
self.rate_limit_logged = false;
listed = true;
queued
}
Err(github::Error::RateLimited { until }) => {
if !self.rate_limit_logged {
warn!(
"GitHub's rate limit is used up; polling again in {}s",
until
.duration_since(SystemTime::now())
.unwrap_or_default()
.as_secs()
);
self.rate_limit_logged = true;
}
Vec::new()
}
Err(error) => {
warn!("{repository}: cannot list queued jobs: {error}");
Vec::new()
}
};
if let Some(state) = self.repositories.get_mut(&repository) {
state.queued = queued;
state.listed = listed;
}
}
}
async fn apply_runners(&mut self, listings: Answers<Vec<Runner>>) {
for (repository, answer) in listings {
let mut left = false;
let runners = match answer {
Ok(runners) => runners,
Err(github::Error::RateLimited { .. }) => continue,
Err(error) => {
warn!("{repository}: cannot list runners: {error}");
continue;
}
};
for runner in runners {
let held = self
.slots
.values_mut()
.flatten()
.flatten()
.find(|occupant| occupant.runner_id == runner.id);
match held {
Some(occupant) => {
occupant.busy = runner.busy;
left = true;
}
None if !runner.name.starts_with(&self.runner_prefix) => {}
None => match self.github.delete_runner(&repository, runner.id).await {
Ok(Deleted::Deleted) => {
info!(
"{repository}: deleted {}, which had no container",
runner.name
);
}
Ok(Deleted::NotFound) => {}
Ok(Deleted::Busy) => {
left = true;
if self.busy_orphans.insert(runner.id) {
warn!(
"{repository}: {} has no container, but GitHub says it runs a job; \
deleting it once GitHub lets go",
runner.name
);
}
}
Err(error) => {
left = true;
warn!("{repository}: cannot delete {}: {error}", runner.name);
}
},
}
}
if !left {
self.draining.remove(&repository);
}
}
}
}