use std::collections::BTreeMap;
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::{SystemTime, UNIX_EPOCH};
use qk_config::Workspace;
use serde::{Deserialize, Serialize};
use crate::{paths, remote, warm};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Expectation {
pub millis: u64,
pub cores: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<u64>,
}
const RUNS: usize = 5;
const KEPT_FOR: u64 = 30 * 24 * 60 * 60 * 1000;
const TASKS: usize = 10_000;
const BYTES: usize = 4 * 1024 * 1024 - 64 * 1024;
#[derive(Serialize, Deserialize)]
struct Record {
version: u32,
name: String,
tasks: BTreeMap<String, Runs>,
}
#[derive(Serialize, Deserialize)]
struct Runs {
updated: u64,
runs: Vec<Expectation>,
}
pub fn expect(runs: &[Expectation]) -> Option<Expectation> {
let memory = runs.iter().filter_map(|run| run.memory).max();
let mut sorted = runs.to_vec();
sorted.sort_by_key(|run| run.millis);
let median = *sorted.get(sorted.len() / 2)?;
Some(Expectation { memory, ..median })
}
pub(crate) fn usable(bytes: &[u8], branch: &str) -> bool {
parse(bytes, branch).is_some()
}
pub(crate) fn read(bytes: &[u8], branch: &str) -> BTreeMap<String, Expectation> {
let Some(record) = parse(bytes, branch) else {
return BTreeMap::new();
};
record
.tasks
.into_iter()
.filter_map(|(id, runs)| {
let expected = expect(&runs.runs)?;
(expected.cores > 0).then_some((id, expected))
})
.collect()
}
fn parse(bytes: &[u8], branch: &str) -> Option<Record> {
let record: Record = serde_json::from_slice(bytes).ok()?;
(record.version == 1 && record.name == remote::expectations_name(branch)).then_some(record)
}
pub(crate) fn merge(
base: Option<(&[u8], &str)>,
branch: &str,
runs: &BTreeMap<String, Expectation>,
now: u64,
) -> Vec<u8> {
let mut tasks = base
.and_then(|(bytes, saved_for)| parse(bytes, saved_for))
.map(|record| record.tasks)
.unwrap_or_default();
for (id, run) in runs {
let kept = tasks.entry(id.clone()).or_insert(Runs {
updated: now,
runs: Vec::new(),
});
kept.updated = now;
kept.runs.insert(0, *run);
kept.runs.truncate(RUNS);
}
tasks.retain(|_, kept| kept.updated.saturating_add(KEPT_FOR) >= now);
let mut order: Vec<(u64, String)> = tasks
.iter()
.map(|(id, kept)| (kept.updated, id.clone()))
.collect();
order.sort_unstable_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
for (_, id) in order.drain(TASKS.min(order.len())..) {
tasks.remove(&id);
}
let mut record = Record {
version: 1,
name: remote::expectations_name(branch),
tasks,
};
loop {
let bytes = serde_json::to_vec(&record).expect("expectations serialize");
if bytes.len() <= BYTES || record.tasks.is_empty() {
return bytes;
}
let drop = order.len().div_ceil(10);
for (_, id) in order.drain(order.len() - drop..) {
record.tasks.remove(&id);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Sharing {
ReadWrite,
Read,
Off,
}
fn sharing(environment: &BTreeMap<OsString, OsString>) -> Result<Sharing, String> {
let value = environment
.get(std::ffi::OsStr::new("QK_REMOTE_EXPECTATIONS"))
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty());
match value {
None | Some("read-write") => Ok(Sharing::ReadWrite),
Some("read" | "read-only") => Ok(Sharing::Read),
Some("off") => Ok(Sharing::Off),
Some(other) => Err(format!(
"unknown QK_REMOTE_EXPECTATIONS {other:?}; use read-write, read or off"
)),
}
}
fn remote(
workspace: &Workspace,
environment: &BTreeMap<OsString, OsString>,
) -> Option<Arc<remote::Remote>> {
let (s3, server) = crate::remote_settings(workspace);
remote::configure(s3, server, environment)
.ok()
.flatten()
.map(Arc::new)
}
pub struct Fetching(Option<(JoinHandle<Expected>, Arc<AtomicBool>)>);
type Expected = BTreeMap<String, Expectation>;
impl Fetching {
pub fn wait(mut self) -> BTreeMap<String, Expectation> {
self.0
.take()
.and_then(|(fetch, _)| fetch.join().ok())
.unwrap_or_default()
}
}
impl Drop for Fetching {
fn drop(&mut self) {
if let Some((_, wanted)) = self.0.take() {
wanted.store(false, Ordering::Relaxed);
}
}
}
pub fn fetch(workspace: &Workspace, environment: &BTreeMap<OsString, OsString>) -> Fetching {
match sharing(environment) {
Ok(Sharing::Off) => return Fetching(None),
Ok(_) => {}
Err(reason) => {
qk_executor::status!("qk: remote task expectations disabled: {reason}");
return Fetching(None);
}
}
let Some(remote) = remote(workspace, environment) else {
return Fetching(None);
};
let root = paths::cache_location(workspace);
let branches = warm::branches(workspace);
let wanted = Arc::new(AtomicBool::new(true));
let fetch = std::thread::spawn({
let wanted = wanted.clone();
move || match remote.fetch_usable_expectations(&root, &branches) {
Ok(Some((bytes, branch))) => read(&bytes, &branch),
Ok(None) => BTreeMap::new(),
Err(error) => {
if wanted.load(Ordering::Relaxed) {
qk_executor::status!("qk: remote task expectations unavailable ({error:#})");
}
BTreeMap::new()
}
}
});
Fetching(Some((fetch, wanted)))
}
pub fn share(
workspace: &Workspace,
environment: &BTreeMap<OsString, OsString>,
runs: BTreeMap<String, Expectation>,
) {
if runs.is_empty() || sharing(environment) != Ok(Sharing::ReadWrite) {
return;
}
let Some(remote) =
remote(workspace, environment).filter(|remote| remote.mode == remote::Mode::ReadWrite)
else {
return;
};
let branches = warm::branches(workspace);
if warm::current_branch(workspace).is_none() {
return;
}
let root = paths::cache_location(workspace);
if let Err(error) = std::fs::create_dir_all(root.join("tmp")) {
qk_executor::status!("qk: remote cache upload failed for task expectations: {error}");
return;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis() as u64);
remote.upload_expectations(&root, branches, runs, now);
for failure in remote.finish() {
qk_executor::status!("qk: remote cache upload failed for {failure}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(millis: u64, memory: Option<u64>) -> Expectation {
Expectation {
millis,
cores: 2,
memory,
}
}
#[test]
fn a_task_expected_on_no_core_is_left_out() {
let run = |cores| Expectation {
millis: 1_000,
cores,
memory: None,
};
let runs: BTreeMap<String, Expectation> = [
("app:none".to_owned(), run(0)),
("app:one".to_owned(), run(1)),
]
.into();
let record = merge(None, "main", &runs, 1);
let expected = read(&record, "main");
assert!(!expected.contains_key("app:none"));
assert_eq!(expected["app:one"].cores, 1);
}
#[test]
fn a_record_stays_within_what_a_store_server_accepts() {
let run = Expectation {
millis: 1_000,
cores: 1,
memory: Some(1 << 30),
};
let long = "x".repeat(600);
let runs: BTreeMap<String, Expectation> = (0..TASKS)
.map(|index| (format!("project-{index:05}-{long}:test"), run))
.collect();
let first = merge(None, "main", &runs, 1);
assert!(first.len() <= BYTES, "{}", first.len());
let newest: BTreeMap<String, Expectation> = [("newest:test".to_owned(), run)].into();
let second = merge(Some((&first, "main")), "main", &newest, 2);
assert!(second.len() <= BYTES);
let record = parse(&second, "main").unwrap();
assert!(record.tasks.contains_key("newest:test"));
assert!(record.tasks.len() < TASKS);
}
#[test]
fn the_variable_narrows_what_a_run_does_with_them() {
let with = |value: &str| {
let environment = [("QK_REMOTE_EXPECTATIONS".into(), value.into())]
.into_iter()
.collect();
sharing(&environment)
};
assert_eq!(sharing(&BTreeMap::new()), Ok(Sharing::ReadWrite));
assert_eq!(with(""), Ok(Sharing::ReadWrite));
assert_eq!(with("read-write"), Ok(Sharing::ReadWrite));
assert_eq!(with("read"), Ok(Sharing::Read));
assert_eq!(with("read-only"), Ok(Sharing::Read));
assert_eq!(with("off"), Ok(Sharing::Off));
assert!(with("no").is_err());
}
#[test]
fn expects_the_median_time_and_the_most_memory() {
let runs = [run(30, None), run(10, Some(5)), run(20, Some(9))];
assert_eq!(
expect(&runs),
Some(Expectation {
millis: 20,
cores: 2,
memory: Some(9)
})
);
assert_eq!(expect(&[]), None);
}
#[test]
fn a_merge_keeps_the_tasks_a_run_did_not_run() {
let first = merge(
None,
"main",
&[
("app:a".to_owned(), run(10, None)),
("app:b".to_owned(), run(5, None)),
]
.into_iter()
.collect(),
1_000,
);
let second = merge(
Some((&first, "main")),
"feature",
&[("app:a".to_owned(), run(40, None))].into_iter().collect(),
2_000,
);
let read = read(&second, "feature");
assert_eq!(read["app:a"].millis, 40);
assert_eq!(read["app:b"].millis, 5);
assert!(super::read(&second, "main").is_empty());
let mut record = second;
for millis in [1, 2, 3, 4, 5] {
record = merge(
Some((&record, "feature")),
"feature",
&[("app:a".to_owned(), run(millis, None))]
.into_iter()
.collect(),
3_000,
);
}
assert_eq!(super::read(&record, "feature")["app:a"].millis, 3);
let later = merge(
Some((&record, "feature")),
"feature",
&[("app:a".to_owned(), run(1, None))].into_iter().collect(),
3_000 + KEPT_FOR + 1,
);
let kept = super::read(&later, "feature");
assert!(kept.contains_key("app:a") && !kept.contains_key("app:b"));
}
}