runnerd_core/api/jobs.rs
1//! Job history as clients read it: one record per GitHub job a runner of this daemon ran, its timeline, steps,
2//! metrics and log size; the queries that list them; sample series for graphs; per-repository aggregates; cache
3//! sizes. Times are seconds since the Unix epoch, sizes are bytes, CPU is in millicores (1000 = one core busy).
4
5use serde::{Deserialize, Serialize};
6
7mod facts;
8
9pub use facts::{
10 JobTitle, Milestone, Outcome, OutcomeKind, Phase, PhaseKind, RunComparison, SampleUsage,
11 job_title,
12};
13
14/// Where a job is in its life, as this daemon saw it.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum JobState {
18 /// Waiting for a runner.
19 Queued,
20 /// A runner of this daemon took it.
21 Running,
22 /// GitHub reports it completed; `conclusion` says how.
23 Completed,
24 /// Its runner's container is gone and GitHub did not report the job completed in time.
25 Lost,
26 /// A runner of another machine took it, or it ended before any runner did, as when a newer push cancelled it.
27 /// One no runner took has no `started_at`, except in records of versions that kept the start GitHub gives it.
28 Elsewhere,
29}
30
31impl JobState {
32 /// Whether the job can still change.
33 pub fn is_final(self) -> bool {
34 matches!(
35 self,
36 JobState::Completed | JobState::Lost | JobState::Elsewhere
37 )
38 }
39}
40
41/// A job without its steps: what lists and events carry.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct JobSummary {
44 /// GitHub's job ID, also its check run ID.
45 pub id: u64,
46 /// `owner/name` on GitHub.
47 pub repository: String,
48 /// The workflow run it belongs to.
49 pub run_id: u64,
50 /// 1 for the first attempt of the run, higher for re-runs.
51 pub run_attempt: u32,
52 /// The workflow's name.
53 pub workflow: Option<String>,
54 /// The job's name in the workflow.
55 pub name: String,
56 /// The branch the run is for.
57 pub branch: Option<String>,
58 /// The job's page on GitHub.
59 pub url: Option<String>,
60 /// The workflow's `runs-on` labels.
61 pub labels: Vec<String>,
62 /// Where the job is.
63 pub state: JobState,
64 /// GitHub's conclusion once completed: `success`, `failure`, `cancelled`, `skipped`, `timed_out`, ….
65 pub conclusion: Option<String>,
66 /// The runner that ran it.
67 pub runner: Option<JobRunner>,
68 /// When each phase started.
69 pub timeline: Timeline,
70 /// CPU, memory and network use while it ran.
71 pub metrics: JobMetrics,
72 /// How much of its log is stored.
73 pub log: LogSummary,
74}
75
76/// `GET /v1/jobs/{id}`: a job with its steps.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Job {
79 /// Everything but the steps.
80 #[serde(flatten)]
81 pub summary: JobSummary,
82 /// The steps as GitHub numbers them, including `Set up job` and `Complete job`.
83 pub steps: Vec<Step>,
84 /// Its record, for a failed job of a profile that records failures.
85 #[serde(default)]
86 pub record: Option<super::RecordNote>,
87 /// How its runner was admitted; `None` for one started before runnerd recorded it.
88 #[serde(default)]
89 pub admission: Option<super::Admission>,
90}
91
92impl Job {
93 /// The lines across its memory and CPU charts, as every client labels them: what admission reserved of the
94 /// budget, when the record has it, and the cap its container ran with, or for a record without it, `profile`'s
95 /// limit as configured now. A reservation of the whole cap is one line.
96 pub fn chart_lines(&self, profile: Option<&super::ProfileStatus>) -> ChartLines {
97 let limits = self.limits(profile);
98 let admission = self.admission.as_ref();
99 let name = profile.map_or("profile", |profile| profile.name.as_str());
100 ChartLines {
101 memory: chart_lines(
102 limits.memory,
103 admission.map(|admission| admission.reserved),
104 |value| crate::config::ByteSize(value).to_string(),
105 name,
106 ),
107 cpu: chart_lines(
108 limits.cpu,
109 admission
110 .and_then(|admission| admission.cpu)
111 .map(|cpu| cpu.reserved),
112 super::cpus,
113 name,
114 ),
115 }
116 }
117
118 /// What its container may use of each resource, as every client scales its use against: the cap it ran with,
119 /// or, for a record without it, `profile`'s limit as configured now. Each resource falls back on its own, so a
120 /// reload that changes the profile never replaces a cap the record has.
121 pub fn limits(&self, profile: Option<&super::ProfileStatus>) -> Limits {
122 let runner = self.summary.runner.as_ref();
123 let limit = |cap: Option<u64>, now: Option<u64>| match (cap, now) {
124 (Some(value), _) => Some(Limit {
125 value,
126 source: LimitSource::Cap,
127 }),
128 (None, Some(value)) => Some(Limit {
129 value,
130 source: LimitSource::ProfileNow,
131 }),
132 (None, None) => None,
133 };
134 Limits {
135 memory: limit(
136 runner.and_then(|runner| runner.memory_cap),
137 profile.map(|profile| profile.memory),
138 ),
139 cpu: limit(
140 runner.and_then(|runner| runner.cpu_cap),
141 profile.map(|profile| u64::from(profile.cpus) * 1000),
142 ),
143 }
144 }
145}
146
147/// What a job's container may use, from `Job::limits`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct Limits {
150 /// In bytes.
151 pub memory: Option<Limit>,
152 /// In millicores.
153 pub cpu: Option<Limit>,
154}
155
156impl Limits {
157 /// The limits as one clause, such as `capped at 6G and 4 CPUs`, `capped at 6G; profile now 4 CPUs` or `profile
158 /// now 6G and 4 CPUs`; `None` without either.
159 pub fn line(&self) -> Option<String> {
160 let memory = self.memory.map(|limit| {
161 (
162 limit.source,
163 crate::config::ByteSize(limit.value).to_string(),
164 )
165 });
166 let cpu = self
167 .cpu
168 .map(|limit| (limit.source, super::cpus(limit.value)));
169 let clause = |source: LimitSource, figures: &str| match source {
170 LimitSource::Cap => format!("capped at {figures}"),
171 LimitSource::ProfileNow => format!("profile now {figures}"),
172 };
173 match (memory, cpu) {
174 (Some((memory_source, memory)), Some((cpu_source, cpu)))
175 if memory_source == cpu_source =>
176 {
177 Some(clause(memory_source, &format!("{memory} and {cpu}")))
178 }
179 (Some((memory_source, memory)), Some((cpu_source, cpu))) => Some(format!(
180 "{}; {}",
181 clause(memory_source, &memory),
182 clause(cpu_source, &cpu)
183 )),
184 (Some((source, figure)), None) | (None, Some((source, figure))) => {
185 Some(clause(source, &figure))
186 }
187 (None, None) => None,
188 }
189 }
190}
191
192/// One resource's limit for a job's container.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct Limit {
195 /// Bytes or millicores.
196 pub value: u64,
197 /// Where it comes from.
198 pub source: LimitSource,
199}
200
201/// Where a job's limit comes from.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum LimitSource {
204 /// The cap its container ran with.
205 Cap,
206 /// Its profile's limit in the config now, for a record without its container's cap; a reload since may have
207 /// changed it.
208 ProfileNow,
209}
210
211/// One resource's lines: `value` writes a figure, such as `8.5G` or `3.6 CPUs`.
212fn chart_lines(
213 limit: Option<Limit>,
214 reserved: Option<u64>,
215 value: fn(u64) -> String,
216 profile: &str,
217) -> Vec<ChartLine> {
218 let line = |figure, kind, label: &str| ChartLine {
219 value: figure,
220 kind,
221 label: format!("{label} {}", value(figure)),
222 };
223 let mut lines = vec![];
224 match limit {
225 Some(Limit {
226 value: cap,
227 source: LimitSource::Cap,
228 }) if reserved == Some(cap) => {
229 return vec![line(cap, ChartLineKind::CapAndReserved, "cap and reserved")];
230 }
231 Some(Limit {
232 value: cap,
233 source: LimitSource::Cap,
234 }) => lines.push(line(cap, ChartLineKind::Cap, "cap")),
235 Some(Limit {
236 value: now,
237 source: LimitSource::ProfileNow,
238 }) => lines.push(line(
239 now,
240 ChartLineKind::ProfileNow,
241 &format!("{profile} now"),
242 )),
243 None => {}
244 }
245 if let Some(reserved) = reserved {
246 lines.push(line(reserved, ChartLineKind::Reserved, "reserved"));
247 }
248 lines
249}
250
251/// The lines across a job's memory and CPU charts, from `Job::chart_lines`.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct ChartLines {
254 /// In bytes.
255 pub memory: Vec<ChartLine>,
256 /// In millicores.
257 pub cpu: Vec<ChartLine>,
258}
259
260/// A line across a job's memory or CPU chart.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct ChartLine {
263 /// Bytes or millicores.
264 pub value: u64,
265 /// What it stands for.
266 pub kind: ChartLineKind,
267 /// Its label, such as `cap 8.5G` or `reserved 3.6 CPUs`.
268 pub label: String,
269}
270
271/// What a line across a job's chart stands for.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum ChartLineKind {
274 /// The most its container may use: its cap.
275 Cap,
276 /// Its profile's limit in the config now, for a record without its container's cap; a reload since may have
277 /// changed it.
278 ProfileNow,
279 /// What admission counted against the budget for its runner.
280 Reserved,
281 /// A reservation of the whole cap, as admission by caps makes.
282 CapAndReserved,
283}
284
285/// The runner a job ran on.
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct JobRunner {
288 /// The runner's name, also its container's.
289 pub name: String,
290 /// The profile it ran in.
291 pub profile: String,
292 /// The slot it ran in, 1-based.
293 pub slot: usize,
294 /// Its container's memory cap in bytes, which a reload since may have changed in the profile. `None` in records
295 /// from before runnerd kept it.
296 pub memory_cap: Option<u64>,
297 /// Its container's CPU limit in millicores, the unit of the CPU use in `Sample` and `JobMetrics`. `None` in
298 /// records from before runnerd kept it, or when Docker reports no limit.
299 pub cpu_cap: Option<u64>,
300}
301
302/// A job's phases, in the order they happen. A runner's container starts before GitHub gives it a job.
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
304pub struct Timeline {
305 /// GitHub queued the job.
306 pub queued_at: u64,
307 /// The container of the runner that took it started.
308 pub container_started_at: Option<u64>,
309 /// The runner started the job.
310 pub started_at: Option<u64>,
311 /// The job finished, by GitHub's clock; or when its container was found gone.
312 pub finished_at: Option<u64>,
313 /// The container and the runner's registration were removed.
314 pub cleaned_at: Option<u64>,
315}
316
317impl Timeline {
318 /// When the runner that took it began to start for it: its container's start, or the job's queueing when that
319 /// container waited for jobs before, as a warm runner does. `None` until a runner took it.
320 pub fn runner_from(&self) -> Option<u64> {
321 self.container_started_at
322 .map(|started| started.max(self.queued_at))
323 }
324}
325
326/// What a job used, over the samples taken while it ran (every few seconds). `None` until a sample exists.
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328pub struct JobMetrics {
329 /// CPU time, in milliseconds.
330 pub cpu_time: Option<u64>,
331 /// The highest CPU use of a sample interval, in millicores.
332 pub cpu_peak: Option<u64>,
333 /// CPU time over wall time, in millicores.
334 pub cpu_average: Option<u64>,
335 /// The highest memory use without page cache.
336 pub memory_peak: Option<u64>,
337 /// The average memory use without page cache.
338 pub memory_average: Option<u64>,
339 /// Bytes the container received.
340 pub network_received: Option<u64>,
341 /// Bytes the container sent.
342 pub network_sent: Option<u64>,
343 /// Of the bytes received, those the Docker Hub mirror served.
344 pub mirror_bytes: u64,
345 /// What the shared build cache served it and took from it.
346 #[serde(default)]
347 pub build_cache: BuildCacheCounts,
348 /// Times the kernel held its container's sockets' receive windows small because the container's memory was
349 /// under pressure (`memory.events` `sock_throttled`): reclaim went badly, mostly because the host was short of
350 /// memory, and its downloads slowed. `None` when the kernel does not count it, or in records from before.
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub sock_throttled: Option<u64>,
353}
354
355/// What the shared build cache served one runner and took from it (ADR 0052), counted by the cache itself.
356#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
357pub struct BuildCacheCounts {
358 /// Lookups that found an entry.
359 pub hits: u64,
360 /// Lookups that found none.
361 pub misses: u64,
362 /// Bytes of the entries it served.
363 pub read_bytes: u64,
364 /// Bytes of the entries it stored.
365 pub written_bytes: u64,
366 /// Entries it was sent and did not store: the job may not write (a pull request, another branch), or the
367 /// cache was full.
368 pub refused: u64,
369 /// Whether the job could write: a push, scheduled or manually started run of a branch that may write.
370 pub writer: bool,
371}
372
373impl BuildCacheCounts {
374 /// Hits and misses.
375 pub fn lookups(&self) -> u64 {
376 self.hits + self.misses
377 }
378
379 /// Hits in percent of lookups, `None` without any.
380 pub fn hit_percent(&self) -> Option<u64> {
381 let lookups = self.lookups();
382 (lookups > 0).then(|| self.hits * 100 / lookups)
383 }
384
385 /// Adds `other`'s counts; `writer` stays.
386 pub fn add(&mut self, other: &BuildCacheCounts) {
387 self.hits += other.hits;
388 self.misses += other.misses;
389 self.read_bytes += other.read_bytes;
390 self.written_bytes += other.written_bytes;
391 self.refused += other.refused;
392 }
393}
394
395/// A job's stored log.
396#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
397pub struct LogSummary {
398 /// Lines stored.
399 pub lines: u64,
400 /// Bytes stored.
401 pub bytes: u64,
402 /// Set once the log reached `history.log_max` and later lines were dropped.
403 pub truncated: bool,
404}
405
406/// One step of a job, as GitHub reports it.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408pub struct Step {
409 /// GitHub's step number; numbers may skip.
410 pub number: u32,
411 /// The name GitHub shows, such as `Set up job` or `Run docker run --rm hello-world`.
412 pub name: String,
413 /// `pending`, `queued`, `in_progress` or `completed`.
414 pub status: String,
415 /// Once completed: `success`, `failure`, `skipped`, `cancelled`, ….
416 pub conclusion: Option<String>,
417 /// When it started.
418 pub started_at: Option<u64>,
419 /// When it completed.
420 pub completed_at: Option<u64>,
421}
422
423/// `GET /v1/jobs` query parameters. Jobs come newest first (by ID, which grows with time).
424#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
425pub struct JobQuery {
426 /// Only this `owner/name`.
427 pub repository: Option<String>,
428 /// Only jobs in this state.
429 pub state: Option<JobState>,
430 /// Only jobs with this conclusion, such as `failure`.
431 pub conclusion: Option<String>,
432 /// Only jobs of the workflow with this name, such as `CI`.
433 pub workflow: Option<String>,
434 /// Only jobs with this name in their workflow, such as `build`.
435 pub name: Option<String>,
436 /// Only jobs queued at or after this time.
437 pub since: Option<u64>,
438 /// Only jobs queued before this time.
439 pub until: Option<u64>,
440 /// Only jobs with a smaller ID: the `next` of the previous page.
441 pub before: Option<u64>,
442 /// At most this many jobs; default 50, at most 500.
443 pub limit: Option<u32>,
444}
445
446/// `GET /v1/jobs`: one page of jobs.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct JobPage {
449 /// Newest first.
450 pub jobs: Vec<JobSummary>,
451 /// Pass as `before` for the next page; `None` on the last page.
452 pub next: Option<u64>,
453}
454
455/// `GET /v1/jobs/{id}/logs` query parameters. The response is `text/plain`, one log line per line.
456#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
457pub struct LogQuery {
458 /// Start at this line, 0-based.
459 pub from: Option<u64>,
460 /// Keep the response open and send lines as they are stored, until the job finishes.
461 #[serde(default)]
462 pub follow: bool,
463}
464
465/// One sample of a container's use.
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct Sample {
468 /// When it was taken.
469 pub at: u64,
470 /// CPU use since the previous sample, in millicores.
471 pub cpu: u64,
472 /// Memory use without page cache.
473 pub memory: u64,
474 /// Bytes received since the container started.
475 pub network_received: u64,
476 /// Bytes sent since the container started.
477 pub network_sent: u64,
478}
479
480/// `GET /v1/jobs/{id}/samples`: what the job's container used over time, from its start to its removal. Samples
481/// older than `history.samples` are deleted; the job's metrics stay.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct SampleSeries {
484 /// The job.
485 pub job: u64,
486 /// Seconds between samples.
487 pub interval: u64,
488 /// Oldest first.
489 pub samples: Vec<Sample>,
490}
491
492/// One sample of the whole slice runners run in.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494pub struct HostSample {
495 /// When it was taken.
496 pub at: u64,
497 /// CPU use of the slice since the previous sample, in millicores.
498 pub cpu: u64,
499 /// Memory use of the slice less its inactive page cache, as `docker stats` counts it: a conservative number,
500 /// since active page cache still counts, which the high-water mark of measured admission compares to
501 /// `MemoryMax`.
502 pub memory: u64,
503}
504
505/// The host's memory at one moment, beside the slice's: what admission fits new runners in besides `MemoryMax`
506/// (ADR 0055). Read every few seconds; not kept in the history.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct HostMemory {
509 /// When it was read.
510 pub at: u64,
511 /// `MemTotal` in `/proc/meminfo`, in bytes.
512 pub total: u64,
513 /// `MemAvailable`: what the host could give without swapping, page cache it can drop included, in bytes.
514 pub available: u64,
515 /// The slice's memory without page cache and reclaimable slab: what the runners hold that `MemAvailable` leaves
516 /// out.
517 pub slice: u64,
518 /// `/proc/pressure/memory`'s `some` line: the share of time at least one task waited for memory. `None` on a
519 /// kernel without pressure stall information.
520 pub some: Option<Pressure>,
521 /// Its `full` line: the share of time every task that could run waited for memory.
522 pub full: Option<Pressure>,
523 /// The programs outside the slice that hold the most memory, largest first: at most a few, none under a floor.
524 /// Read less often than the rest. Empty when none is that large, and from a daemon before it said.
525 #[serde(default, skip_serializing_if = "Vec::is_empty")]
526 pub holders: Vec<Holder>,
527}
528
529impl HostMemory {
530 /// What the host holds outside the slice's runners, in bytes: its other programs, tmpfs and the kernel, without
531 /// page cache it can drop.
532 pub fn outside(&self) -> u64 {
533 self.total
534 .saturating_sub(self.available)
535 .saturating_sub(self.slice)
536 }
537
538 /// What holds the host's memory outside the slice, as the clients say it: `programs outside CI hold 30G` and,
539 /// with `holders`, `, most java 9.3G and qemu-system-x86 5.8G`.
540 pub fn outside_line(&self) -> String {
541 let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
542 let line = format!("programs outside CI hold {}", size(self.outside()));
543 let named: Vec<String> = self
544 .holders
545 .iter()
546 .map(|holder| format!("{} {}", holder.name, size(holder.memory)))
547 .collect();
548 match named.as_slice() {
549 [] => line,
550 [one] => format!("{line}, most {one}"),
551 [rest @ .., last] => format!("{line}, most {} and {last}", rest.join(", ")),
552 }
553 }
554}
555
556/// A program outside the slice and the memory its processes hold.
557#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub struct Holder {
559 /// Its processes' name, as `/proc/<pid>/comm` says it: never a command line, argument or path.
560 pub name: String,
561 /// What its processes hold that the host can't drop as page cache (`RssAnon` and `RssShmem`), in bytes.
562 pub memory: u64,
563}
564
565/// One line of a pressure stall file, in hundredths of a percent: 250 is 2.5% of the time.
566#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
567pub struct Pressure {
568 /// Over the last 10 seconds.
569 pub avg10: u32,
570 /// Over the last 60 seconds.
571 pub avg60: u32,
572}
573
574/// `GET /v1/host/samples` query parameters.
575#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
576pub struct HostSampleQuery {
577 /// Only samples taken at or after this time; default the last hour, at most the last 24 hours.
578 pub since: Option<u64>,
579}
580
581/// `GET /v1/host/samples`: the slice's use over time, oldest first.
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct HostSamples {
584 /// Seconds between samples.
585 pub interval: u64,
586 /// Oldest first.
587 pub samples: Vec<HostSample>,
588}
589
590/// How `GET /v1/stats` groups jobs in time.
591#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "kebab-case")]
593pub enum Bucket {
594 /// By UTC hour.
595 Hour,
596 /// By UTC day.
597 #[default]
598 Day,
599}
600
601/// `GET /v1/activity` query parameters.
602#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
603pub struct ActivityQuery {
604 /// The start of the window; default 24 hours ago. It ends now.
605 pub since: Option<u64>,
606}
607
608/// `GET /v1/activity`: every job runnerd's runners started or finished in a window, by when that happened rather
609/// than when the job was queued, so a job that waited or ran long counts in the window it started or ended in.
610#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
611pub struct Activity {
612 /// The start of the window.
613 pub since: u64,
614 /// When it was read: the end of the window.
615 pub until: u64,
616 /// Each job that started in the window, oldest start first.
617 pub started: Vec<Started>,
618 /// The jobs that finished in the window: completed, or lost with their runner.
619 pub finished: u64,
620 /// Of those, the ones that failed, timed out or were lost, as `GET /v1/stats` counts failures.
621 pub failed: u64,
622 /// Of the failed, the ones their runner failed rather than their own steps: lost with their runner, or failed
623 /// after the kernel killed a process of theirs for memory at their cap.
624 #[serde(default)]
625 pub runner_failed: u64,
626}
627
628impl Activity {
629 /// The 95th percentile of the waits of the jobs that started, by nearest rank as sizing's are; `None` when none
630 /// started.
631 pub fn wait_p95(&self) -> Option<u64> {
632 let mut waits: Vec<u64> = self.started.iter().map(|start| start.waited).collect();
633 waits.sort_unstable();
634 let rank = (waits.len() * 95).div_ceil(100);
635 waits.get(rank.checked_sub(1)?).copied()
636 }
637
638 /// The longest wait of the jobs that started in each of the 24 hours before `until`, oldest hour first; 0 for
639 /// an hour without a start. Counted back from `until`, so the hours are the day the other figures count.
640 pub fn wait_hours(&self) -> [u64; 24] {
641 const HOUR: u64 = 3600;
642 let since = self.until.saturating_sub(24 * HOUR);
643 let mut hours = [0u64; 24];
644 for start in self
645 .started
646 .iter()
647 .filter(|start| (since..=self.until).contains(&start.at))
648 {
649 // A start exactly 24 hours ago is in the oldest hour.
650 let back =
651 usize::try_from((self.until - start.at) / HOUR).map_or(23, |back| back.min(23));
652 hours[23 - back] = hours[23 - back].max(start.waited);
653 }
654 hours
655 }
656}
657
658/// A job that started: when, and how long it had waited since it was queued.
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
660pub struct Started {
661 /// When it started.
662 pub at: u64,
663 /// Seconds it waited.
664 pub waited: u64,
665}
666
667/// `GET /v1/stats` query parameters.
668#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
669pub struct StatsQuery {
670 /// Only this `owner/name`.
671 pub repository: Option<String>,
672 /// Only jobs queued at or after this time; default 30 days ago. At most 744 buckets before `until` (or now)
673 /// are counted: 31 days of hours, two years of days.
674 pub since: Option<u64>,
675 /// Only jobs queued before this time.
676 pub until: Option<u64>,
677 /// The bucket size; default a day.
678 #[serde(default)]
679 pub bucket: Bucket,
680}
681
682/// `GET /v1/stats`: finished jobs per repository and time bucket, for trends. Sums, so clients can average.
683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct Stats {
685 /// The bucket size.
686 pub bucket: Bucket,
687 /// By bucket start, then repository.
688 pub rows: Vec<StatsRow>,
689}
690
691/// The finished jobs of one repository in one bucket.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
693pub struct StatsRow {
694 /// The bucket's start.
695 pub start: u64,
696 /// `owner/name`.
697 pub repository: String,
698 /// Jobs completed or lost.
699 pub jobs: u64,
700 /// Of those, concluded `success`.
701 pub succeeded: u64,
702 /// Of those, concluded `failure` or `timed_out`, or lost.
703 pub failed: u64,
704 /// Of those, concluded `cancelled`.
705 pub cancelled: u64,
706 /// Seconds from queued to started, summed.
707 pub queue_seconds: u64,
708 /// Seconds from started to finished, summed.
709 pub run_seconds: u64,
710 /// CPU time, in milliseconds, summed.
711 pub cpu_time: u64,
712 /// The highest memory peak of a job.
713 pub memory_peak: u64,
714 /// Bytes received, summed.
715 pub network_received: u64,
716 /// Bytes from the mirror, summed.
717 pub mirror_bytes: u64,
718}
719
720/// What a cache volume holds.
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[serde(rename_all = "kebab-case", tag = "kind")]
723pub enum CacheKind {
724 /// A profile's read-only snapshot.
725 Snapshot {
726 /// The profile.
727 profile: String,
728 },
729 /// A slot's layer changes and Docker Engine data.
730 Slot {
731 /// The profile.
732 profile: String,
733 /// 1-based.
734 slot: usize,
735 },
736 /// The pnpm store all slots share.
737 Pnpm,
738 /// The Maven repository all slots share.
739 Maven,
740 /// The Docker Hub mirror's storage.
741 Mirror,
742 /// The build cache every runner shares: a directory of the daemon's, not a Docker volume.
743 Build,
744}
745
746/// One cache volume and its size.
747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
748pub struct CacheVolume {
749 /// The Docker volume.
750 pub name: String,
751 /// What it holds.
752 #[serde(flatten)]
753 pub kind: CacheKind,
754 /// Its size at `measured_at`.
755 pub bytes: u64,
756 /// The size above which runnerd empties it, or for a snapshot starts it over at its next reseed, if it has one.
757 pub limit: Option<u64>,
758}
759
760/// `GET /v1/caches`: every cache volume of the configured profiles, as last measured. Docker measures volumes
761/// slowly, so runnerd measures them at start and whenever no runner runs after jobs ended.
762#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
763pub struct Caches {
764 /// When the sizes were measured; `None` before the first measurement.
765 pub measured_at: Option<u64>,
766 /// By name.
767 pub volumes: Vec<CacheVolume>,
768 /// The shared build cache now, when it is on.
769 #[serde(default)]
770 pub build: Option<BuildCacheStats>,
771}
772
773/// The shared build cache (ADR 0052): its size now, and what it served the jobs of the last 7 days.
774#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
775pub struct BuildCacheStats {
776 /// The entries' bytes.
777 pub bytes: u64,
778 /// How many entries it holds.
779 pub entries: u64,
780 /// `[caches.build] max_size`.
781 pub limit: u64,
782 /// The runners of the last 7 days, summed.
783 pub week: BuildCacheCounts,
784}
785
786/// `GET /v1/caches/history` query parameters.
787#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
788pub struct CacheHistoryQuery {
789 /// Only measurements at or after this time; default 30 days ago.
790 pub since: Option<u64>,
791}
792
793/// One volume's size at one measurement.
794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795pub struct CacheSize {
796 /// When it was measured.
797 pub at: u64,
798 /// The Docker volume.
799 pub name: String,
800 /// Its size.
801 pub bytes: u64,
802}
803
804/// `GET /v1/caches/history`: every measurement, oldest first.
805#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
806pub struct CacheHistory {
807 /// Oldest first.
808 pub sizes: Vec<CacheSize>,
809}