use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use futures_util::StreamExt;
use tokio::sync::mpsc;
use tracing::warn;
use super::tick::CONCURRENT_REQUESTS;
use super::{Engine, Occupant};
use crate::docker::DockerApi;
use crate::github::{self, GithubApi, JobDetails};
use crate::observe::capture::{self, Captured, Parser};
use crate::observe::metrics::Tracked;
use crate::scheduler::SlotRef;
use crate::scheduler::warm::Attempt;
use crate::store::{LogPosition, RunnerRecord, Store};
use crate::time;
pub(super) const LOST_AFTER: Duration = Duration::from_mins(15);
const CAPTURE_DRAIN: Duration = Duration::from_secs(10);
const PRUNE_EVERY: Duration = Duration::from_secs(3600);
#[derive(Debug, Clone)]
pub(super) struct Closing {
repository: String,
since: tokio::time::Instant,
since_ms: u64,
}
impl Closing {
pub(super) fn now(repository: &str) -> Closing {
Closing {
repository: repository.to_owned(),
since: tokio::time::Instant::now(),
since_ms: time::now_ms(),
}
}
}
#[derive(Debug)]
pub(super) struct Found {
runner: String,
job: u64,
run_id: u64,
}
async fn read_jobs<G: GithubApi>(
github: Arc<G>,
jobs: Vec<(String, u64)>,
) -> Vec<(String, u64, Result<JobDetails, github::Error>)> {
let github = &*github;
futures_util::stream::iter(jobs)
.map(|(repository, id)| async move {
let answer = github.job(&repository, id).await;
(repository, id, answer)
})
.buffered(CONCURRENT_REQUESTS)
.collect()
.await
}
impl<G: GithubApi + 'static, D: DockerApi + 'static> Engine<G, D> {
pub(super) async fn observe(&mut self, slot: &SlotRef, occupant: &Occupant) {
let state = match self.docker.inspect(&occupant.container).await {
Ok(state) => state,
Err(error) => {
warn!("cannot inspect {}: {error}", occupant.runner_name);
return;
}
};
let started_at = state
.started_at
.unwrap_or_else(|| time::ms(occupant.started_at));
self.store.runner_started(RunnerRecord {
name: occupant.runner_name.clone(),
repository: occupant.repository.clone(),
profile: slot.profile.clone(),
slot: slot.index + 1,
container: occupant.container.clone(),
started_at,
memory_cap: state.memory,
cpu_cap: state.cpus,
admission: occupant.admission,
});
if let Some(pid) = state.pid {
self.tracked.send_modify(|tracked| {
tracked.push(Tracked {
runner: occupant.runner_name.clone(),
container: occupant.container.clone(),
pid,
});
});
}
if let Some(address) = &state.address {
self.leases
.start(address, &occupant.runner_name, started_at);
}
let position = match self.store.runner_job(&occupant.runner_name).await {
Ok(Some(job)) => self
.store
.log_position(job)
.await
.unwrap_or(LogPosition { page: 1, offset: 0 }),
_ => LogPosition { page: 1, offset: 0 },
};
let task = tokio::spawn(capture_job(
self.docker.clone(),
self.store.clone(),
occupant.runner_name.clone(),
occupant.container.clone(),
position,
self.config.history.log_max.0,
self.found.0.clone(),
));
if let Some(old) = self.captures.insert(occupant.runner_name.clone(), task) {
old.abort();
}
}
pub(super) fn take_found(&mut self) {
while let Ok(found) = self.found.1.try_recv() {
self.queued_jobs.remove(&found.job);
let Some(occupant) = self
.slots
.values_mut()
.flatten()
.flatten()
.find(|occupant| occupant.runner_name == found.runner)
else {
continue;
};
occupant.job = Some(found.job);
let repository = occupant.repository.clone();
let attempt = self.latest_attempt(found.run_id);
self.note_job(&repository, attempt, found.job, None, time::now_ms());
}
}
fn record_listed(&mut self, taken: &BTreeSet<u64>) {
for repository in &self.config.repositories {
let Some(state) = self.repositories.get(&repository.name) else {
continue;
};
if !state.listed {
continue;
}
for job in &state.queued {
if crate::scheduler::matches(job, &repository.labels) && !taken.contains(&job.id) {
self.store.queued(&repository.name, job);
self.queued_jobs
.entry(job.id)
.or_insert_with(|| repository.name.clone());
}
}
}
}
pub(super) async fn follow_jobs(&mut self) {
let taken: BTreeSet<u64> = self
.occupied()
.filter_map(|(_, occupant)| occupant.job)
.collect();
self.record_listed(&taken);
let left: Vec<(String, u64)> = self
.queued_jobs
.iter()
.filter(|(id, repository)| {
!taken.contains(*id)
&& self.repositories.get(*repository).is_some_and(|state| {
state.listed && !state.queued.iter().any(|job| job.id == **id)
})
})
.map(|(id, repository)| (repository.clone(), *id))
.collect();
for (repository, id, answer) in self.serving(read_jobs(self.github.clone(), left)).await {
match answer {
Ok(details) if details.status == "queued" || details.status == "waiting" => {}
Ok(details)
if details.status != "completed"
&& details
.runner_name
.as_ref()
.is_some_and(|name| name.starts_with(&self.runner_prefix)) => {}
Ok(details) => {
self.queued_jobs.remove(&id);
self.store.elsewhere(details);
}
Err(error) => warn!("{repository}: cannot read job {id}: {error}"),
}
}
let ended = self
.ended_uploads
.values()
.map(|ended| (ended.repository.clone(), ended.job));
let running: Vec<(String, u64)> = self
.occupied()
.filter_map(|(_, occupant)| Some((occupant.repository.clone(), occupant.job?)))
.chain(ended)
.collect();
let mut described = Vec::new();
for (repository, id, answer) in self.serving(read_jobs(self.github.clone(), running)).await
{
match answer {
Ok(details) => {
self.place_job(
&repository,
Attempt {
run_id: details.run_id,
run_attempt: details.run_attempt,
},
id,
details.workflow_name.as_deref(),
);
self.note_workflow(details.run_id, details.workflow_name.as_deref());
described.push((repository, id, details.clone()));
self.store.details(details);
}
Err(error) => warn!("{repository}: cannot read job {id}: {error}"),
}
}
let undecided = self.undecided(&described);
self.decide_writes(undecided).await;
self.detach_ended();
let closing: Vec<(String, u64)> = self
.closing
.iter()
.map(|(id, closing)| (closing.repository.clone(), *id))
.collect();
for (repository, id, answer) in self.serving(read_jobs(self.github.clone(), closing)).await
{
let Some(Closing {
since, since_ms, ..
}) = self.closing.get(&id).cloned()
else {
continue;
};
match answer {
Ok(details) if details.status == "completed" => {
self.closing.remove(&id);
self.store.details(details);
}
Ok(_) | Err(_) if since.elapsed() >= LOST_AFTER => {
warn!("{repository}: job {id} lost: GitHub did not report it completed");
self.closing.remove(&id);
self.store.lost(id, since_ms);
}
Ok(details) => self.store.details(details),
Err(error) => warn!("{repository}: cannot read job {id}: {error}"),
}
}
}
pub(super) fn prune(&mut self) {
let now = SystemTime::now();
if self.last_prune.is_some_and(|last| {
now.duration_since(last)
.is_ok_and(|since| since < PRUNE_EVERY)
}) {
return;
}
self.last_prune = Some(now);
drop(self.store.prune(time::ms(now), &self.config.history));
}
pub(super) async fn end_capture(&mut self, runner: &str) {
self.take_found();
if let Some(mut task) = self.captures.remove(runner)
&& tokio::time::timeout(CAPTURE_DRAIN, &mut task)
.await
.is_err()
{
task.abort();
}
self.take_found();
}
pub(super) fn forget(&mut self, occupant: &Occupant) {
let job = self
.occupied()
.find(|(_, candidate)| candidate.runner_name == occupant.runner_name)
.and_then(|(_, candidate)| candidate.job)
.or(occupant.job);
self.detach_build_cache(occupant, job);
let now = SystemTime::now();
self.store
.runner_removed(&occupant.runner_name, time::ms(now));
self.tracked.send_modify(|tracked| {
tracked.retain(|container| container.runner != occupant.runner_name);
});
self.leases.end(&occupant.runner_name, time::ms(now));
if let Some(job) = job {
self.closing.insert(job, Closing::now(&occupant.repository));
}
}
}
async fn capture_job<D: DockerApi>(
docker: Arc<D>,
store: Store,
runner: String,
container: String,
position: LogPosition,
max_bytes: u64,
found: mpsc::UnboundedSender<Found>,
) {
let mut output = match docker.exec(&container, capture::command(position)).await {
Ok(output) => output,
Err(error) => {
warn!("{runner}: cannot capture its job and log: {error}");
return;
}
};
let mut parser = Parser::default();
let mut job = None;
let mut sent = 0;
while let Some(chunk) = output.next().await {
let Ok(chunk) = chunk else { break };
for captured in parser.feed(&chunk) {
match captured {
Captured::Job { id, run_id } => {
if job.is_none() {
job = Some(id);
store.claimed(id, run_id, &runner, time::now_ms());
let _ = found.send(Found {
runner: runner.clone(),
job: id,
run_id,
});
if let Ok(Some(stored)) = store.job(id).await {
if stored.summary.log.truncated {
return;
}
sent = stored.summary.log.bytes;
}
}
}
Captured::Lines { lines, position } => {
if let Some(job) = job {
sent += lines.iter().map(|line| line.len() as u64 + 1).sum::<u64>();
store.log(job, lines, position, max_bytes).await;
if sent > max_bytes {
return;
}
}
}
}
}
}
}