1use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::thread::JoinHandle;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use qk_config::Workspace;
25use serde::{Deserialize, Serialize};
26
27use crate::{paths, remote, warm};
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Expectation {
32 pub millis: u64,
33 pub cores: u64,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub memory: Option<u64>,
38}
39
40const RUNS: usize = 5;
42const KEPT_FOR: u64 = 30 * 24 * 60 * 60 * 1000;
44const TASKS: usize = 10_000;
46const BYTES: usize = 4 * 1024 * 1024 - 64 * 1024;
49
50#[derive(Serialize, Deserialize)]
51struct Record {
52 version: u32,
53 name: String,
55 tasks: BTreeMap<String, Runs>,
56}
57
58#[derive(Serialize, Deserialize)]
59struct Runs {
60 updated: u64,
62 runs: Vec<Expectation>,
64}
65
66pub fn expect(runs: &[Expectation]) -> Option<Expectation> {
70 let memory = runs.iter().filter_map(|run| run.memory).max();
71 let mut sorted = runs.to_vec();
72 sorted.sort_by_key(|run| run.millis);
73 let median = *sorted.get(sorted.len() / 2)?;
74 Some(Expectation { memory, ..median })
75}
76
77pub(crate) fn usable(bytes: &[u8], branch: &str) -> bool {
79 parse(bytes, branch).is_some()
80}
81
82pub(crate) fn read(bytes: &[u8], branch: &str) -> BTreeMap<String, Expectation> {
86 let Some(record) = parse(bytes, branch) else {
87 return BTreeMap::new();
88 };
89 record
90 .tasks
91 .into_iter()
92 .filter_map(|(id, runs)| {
93 let expected = expect(&runs.runs)?;
94 (expected.cores > 0).then_some((id, expected))
95 })
96 .collect()
97}
98
99fn parse(bytes: &[u8], branch: &str) -> Option<Record> {
100 let record: Record = serde_json::from_slice(bytes).ok()?;
101 (record.version == 1 && record.name == remote::expectations_name(branch)).then_some(record)
102}
103
104pub(crate) fn merge(
108 base: Option<(&[u8], &str)>,
109 branch: &str,
110 runs: &BTreeMap<String, Expectation>,
111 now: u64,
112) -> Vec<u8> {
113 let mut tasks = base
114 .and_then(|(bytes, saved_for)| parse(bytes, saved_for))
115 .map(|record| record.tasks)
116 .unwrap_or_default();
117 for (id, run) in runs {
118 let kept = tasks.entry(id.clone()).or_insert(Runs {
119 updated: now,
120 runs: Vec::new(),
121 });
122 kept.updated = now;
123 kept.runs.insert(0, *run);
124 kept.runs.truncate(RUNS);
125 }
126 tasks.retain(|_, kept| kept.updated.saturating_add(KEPT_FOR) >= now);
127 let mut order: Vec<(u64, String)> = tasks
129 .iter()
130 .map(|(id, kept)| (kept.updated, id.clone()))
131 .collect();
132 order.sort_unstable_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
133 for (_, id) in order.drain(TASKS.min(order.len())..) {
134 tasks.remove(&id);
135 }
136 let mut record = Record {
137 version: 1,
138 name: remote::expectations_name(branch),
139 tasks,
140 };
141 loop {
142 let bytes = serde_json::to_vec(&record).expect("expectations serialize");
143 if bytes.len() <= BYTES || record.tasks.is_empty() {
144 return bytes;
145 }
146 let drop = order.len().div_ceil(10);
148 for (_, id) in order.drain(order.len() - drop..) {
149 record.tasks.remove(&id);
150 }
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157enum Sharing {
158 ReadWrite,
160 Read,
162 Off,
164}
165
166fn sharing(environment: &BTreeMap<OsString, OsString>) -> Result<Sharing, String> {
168 let value = environment
169 .get(std::ffi::OsStr::new("QK_REMOTE_EXPECTATIONS"))
170 .and_then(|value| value.to_str())
171 .filter(|value| !value.is_empty());
172 match value {
173 None | Some("read-write") => Ok(Sharing::ReadWrite),
174 Some("read" | "read-only") => Ok(Sharing::Read),
175 Some("off") => Ok(Sharing::Off),
176 Some(other) => Err(format!(
177 "unknown QK_REMOTE_EXPECTATIONS {other:?}; use read-write, read or off"
178 )),
179 }
180}
181
182fn remote(
185 workspace: &Workspace,
186 environment: &BTreeMap<OsString, OsString>,
187) -> Option<Arc<remote::Remote>> {
188 let (s3, server) = crate::remote_settings(workspace);
189 remote::configure(s3, server, environment)
190 .ok()
191 .flatten()
192 .map(Arc::new)
193}
194
195pub struct Fetching(Option<(JoinHandle<Expected>, Arc<AtomicBool>)>);
200
201type Expected = BTreeMap<String, Expectation>;
203
204impl Fetching {
205 pub fn wait(mut self) -> BTreeMap<String, Expectation> {
207 self.0
208 .take()
209 .and_then(|(fetch, _)| fetch.join().ok())
210 .unwrap_or_default()
211 }
212}
213
214impl Drop for Fetching {
215 fn drop(&mut self) {
216 if let Some((_, wanted)) = self.0.take() {
217 wanted.store(false, Ordering::Relaxed);
218 }
219 }
220}
221
222pub fn fetch(workspace: &Workspace, environment: &BTreeMap<OsString, OsString>) -> Fetching {
227 match sharing(environment) {
228 Ok(Sharing::Off) => return Fetching(None),
229 Ok(_) => {}
230 Err(reason) => {
231 qk_executor::status!("qk: remote task expectations disabled: {reason}");
232 return Fetching(None);
233 }
234 }
235 let Some(remote) = remote(workspace, environment) else {
236 return Fetching(None);
237 };
238 let root = paths::cache_location(workspace);
239 let branches = warm::branches(workspace);
240 let wanted = Arc::new(AtomicBool::new(true));
241 let fetch = std::thread::spawn({
242 let wanted = wanted.clone();
243 move || match remote.fetch_usable_expectations(&root, &branches) {
244 Ok(Some((bytes, branch))) => read(&bytes, &branch),
245 Ok(None) => BTreeMap::new(),
246 Err(error) => {
247 if wanted.load(Ordering::Relaxed) {
248 qk_executor::status!("qk: remote task expectations unavailable ({error:#})");
249 }
250 BTreeMap::new()
251 }
252 }
253 });
254 Fetching(Some((fetch, wanted)))
255}
256
257pub fn share(
262 workspace: &Workspace,
263 environment: &BTreeMap<OsString, OsString>,
264 runs: BTreeMap<String, Expectation>,
265) {
266 if runs.is_empty() || sharing(environment) != Ok(Sharing::ReadWrite) {
267 return;
268 }
269 let Some(remote) =
270 remote(workspace, environment).filter(|remote| remote.mode == remote::Mode::ReadWrite)
271 else {
272 return;
273 };
274 let branches = warm::branches(workspace);
276 if warm::current_branch(workspace).is_none() {
277 return;
278 }
279 let root = paths::cache_location(workspace);
280 if let Err(error) = std::fs::create_dir_all(root.join("tmp")) {
281 qk_executor::status!("qk: remote cache upload failed for task expectations: {error}");
282 return;
283 }
284 let now = SystemTime::now()
285 .duration_since(UNIX_EPOCH)
286 .map_or(0, |duration| duration.as_millis() as u64);
287 remote.upload_expectations(&root, branches, runs, now);
288 for failure in remote.finish() {
289 qk_executor::status!("qk: remote cache upload failed for {failure}");
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn run(millis: u64, memory: Option<u64>) -> Expectation {
298 Expectation {
299 millis,
300 cores: 2,
301 memory,
302 }
303 }
304
305 #[test]
306 fn a_task_expected_on_no_core_is_left_out() {
307 let run = |cores| Expectation {
308 millis: 1_000,
309 cores,
310 memory: None,
311 };
312 let runs: BTreeMap<String, Expectation> = [
313 ("app:none".to_owned(), run(0)),
314 ("app:one".to_owned(), run(1)),
315 ]
316 .into();
317 let record = merge(None, "main", &runs, 1);
318 let expected = read(&record, "main");
319 assert!(!expected.contains_key("app:none"));
320 assert_eq!(expected["app:one"].cores, 1);
321 }
322
323 #[test]
324 fn a_record_stays_within_what_a_store_server_accepts() {
325 let run = Expectation {
326 millis: 1_000,
327 cores: 1,
328 memory: Some(1 << 30),
329 };
330 let long = "x".repeat(600);
331 let runs: BTreeMap<String, Expectation> = (0..TASKS)
332 .map(|index| (format!("project-{index:05}-{long}:test"), run))
333 .collect();
334 let first = merge(None, "main", &runs, 1);
335 assert!(first.len() <= BYTES, "{}", first.len());
336 let newest: BTreeMap<String, Expectation> = [("newest:test".to_owned(), run)].into();
338 let second = merge(Some((&first, "main")), "main", &newest, 2);
339 assert!(second.len() <= BYTES);
340 let record = parse(&second, "main").unwrap();
341 assert!(record.tasks.contains_key("newest:test"));
342 assert!(record.tasks.len() < TASKS);
343 }
344
345 #[test]
346 fn the_variable_narrows_what_a_run_does_with_them() {
347 let with = |value: &str| {
348 let environment = [("QK_REMOTE_EXPECTATIONS".into(), value.into())]
349 .into_iter()
350 .collect();
351 sharing(&environment)
352 };
353 assert_eq!(sharing(&BTreeMap::new()), Ok(Sharing::ReadWrite));
354 assert_eq!(with(""), Ok(Sharing::ReadWrite));
355 assert_eq!(with("read-write"), Ok(Sharing::ReadWrite));
356 assert_eq!(with("read"), Ok(Sharing::Read));
357 assert_eq!(with("read-only"), Ok(Sharing::Read));
358 assert_eq!(with("off"), Ok(Sharing::Off));
359 assert!(with("no").is_err());
360 }
361
362 #[test]
363 fn expects_the_median_time_and_the_most_memory() {
364 let runs = [run(30, None), run(10, Some(5)), run(20, Some(9))];
365 assert_eq!(
366 expect(&runs),
367 Some(Expectation {
368 millis: 20,
369 cores: 2,
370 memory: Some(9)
371 })
372 );
373 assert_eq!(expect(&[]), None);
374 }
375
376 #[test]
377 fn a_merge_keeps_the_tasks_a_run_did_not_run() {
378 let first = merge(
379 None,
380 "main",
381 &[
382 ("app:a".to_owned(), run(10, None)),
383 ("app:b".to_owned(), run(5, None)),
384 ]
385 .into_iter()
386 .collect(),
387 1_000,
388 );
389 let second = merge(
391 Some((&first, "main")),
392 "feature",
393 &[("app:a".to_owned(), run(40, None))].into_iter().collect(),
394 2_000,
395 );
396 let read = read(&second, "feature");
397 assert_eq!(read["app:a"].millis, 40);
398 assert_eq!(read["app:b"].millis, 5);
399 assert!(super::read(&second, "main").is_empty());
401 let mut record = second;
403 for millis in [1, 2, 3, 4, 5] {
404 record = merge(
405 Some((&record, "feature")),
406 "feature",
407 &[("app:a".to_owned(), run(millis, None))]
408 .into_iter()
409 .collect(),
410 3_000,
411 );
412 }
413 assert_eq!(super::read(&record, "feature")["app:a"].millis, 3);
414 let later = merge(
416 Some((&record, "feature")),
417 "feature",
418 &[("app:a".to_owned(), run(1, None))].into_iter().collect(),
419 3_000 + KEPT_FOR + 1,
420 );
421 let kept = super::read(&later, "feature");
422 assert!(kept.contains_key("app:a") && !kept.contains_key("app:b"));
423 }
424}