Skip to main content

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