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
7/// Where a job is in its life, as this daemon saw it.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum JobState {
11    /// Waiting for a runner.
12    Queued,
13    /// A runner of this daemon took it.
14    Running,
15    /// GitHub reports it completed; `conclusion` says how.
16    Completed,
17    /// Its runner's container is gone and GitHub did not report the job completed in time.
18    Lost,
19    /// A runner of another machine took it, or it was cancelled before any runner did.
20    Elsewhere,
21}
22
23impl JobState {
24    /// Whether the job can still change.
25    pub fn is_final(self) -> bool {
26        matches!(
27            self,
28            JobState::Completed | JobState::Lost | JobState::Elsewhere
29        )
30    }
31}
32
33/// A job without its steps: what lists and events carry.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct JobSummary {
36    /// GitHub's job ID, also its check run ID.
37    pub id: u64,
38    /// `owner/name` on GitHub.
39    pub repository: String,
40    /// The workflow run it belongs to.
41    pub run_id: u64,
42    /// 1 for the first attempt of the run, higher for re-runs.
43    pub run_attempt: u32,
44    /// The workflow's name.
45    pub workflow: Option<String>,
46    /// The job's name in the workflow.
47    pub name: String,
48    /// The branch the run is for.
49    pub branch: Option<String>,
50    /// The job's page on GitHub.
51    pub url: Option<String>,
52    /// The workflow's `runs-on` labels.
53    pub labels: Vec<String>,
54    /// Where the job is.
55    pub state: JobState,
56    /// GitHub's conclusion once completed: `success`, `failure`, `cancelled`, `skipped`, `timed_out`, ….
57    pub conclusion: Option<String>,
58    /// The runner that ran it.
59    pub runner: Option<JobRunner>,
60    /// When each phase started.
61    pub timeline: Timeline,
62    /// CPU, memory and network use while it ran.
63    pub metrics: JobMetrics,
64    /// How much of its log is stored.
65    pub log: LogSummary,
66}
67
68/// `GET /v1/jobs/{id}`: a job with its steps.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Job {
71    /// Everything but the steps.
72    #[serde(flatten)]
73    pub summary: JobSummary,
74    /// The steps as GitHub numbers them, including `Set up job` and `Complete job`.
75    pub steps: Vec<Step>,
76    /// Its record, for a failed job of a profile that records failures.
77    #[serde(default)]
78    pub record: Option<super::RecordNote>,
79    /// How its runner was admitted; `None` for one started before runnerd recorded it.
80    #[serde(default)]
81    pub admission: Option<super::Admission>,
82}
83
84/// The runner a job ran on.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct JobRunner {
87    /// The runner's name, also its container's.
88    pub name: String,
89    /// The profile it ran in.
90    pub profile: String,
91    /// The slot it ran in, 1-based.
92    pub slot: usize,
93    /// Its container's memory cap in bytes, which a reload since may have changed in the profile. `None` in records
94    /// from before runnerd kept it.
95    pub memory_cap: Option<u64>,
96    /// Its container's CPU limit in millicores, the unit of the CPU use in `Sample` and `JobMetrics`. `None` in
97    /// records from before runnerd kept it, or when Docker reports no limit.
98    pub cpu_cap: Option<u64>,
99}
100
101/// A job's phases, in the order they happen. A runner's container starts before GitHub gives it a job.
102#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Timeline {
104    /// GitHub queued the job.
105    pub queued_at: u64,
106    /// The container of the runner that took it started.
107    pub container_started_at: Option<u64>,
108    /// The runner started the job.
109    pub started_at: Option<u64>,
110    /// The job finished, by GitHub's clock; or when its container was found gone.
111    pub finished_at: Option<u64>,
112    /// The container and the runner's registration were removed.
113    pub cleaned_at: Option<u64>,
114}
115
116impl Timeline {
117    /// When the runner that took it began to start for it: its container's start, or the job's queueing when that
118    /// container waited for jobs before, as a warm runner does. `None` until a runner took it.
119    pub fn runner_from(&self) -> Option<u64> {
120        self.container_started_at
121            .map(|started| started.max(self.queued_at))
122    }
123}
124
125/// What a job used, over the samples taken while it ran (every few seconds). `None` until a sample exists.
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127pub struct JobMetrics {
128    /// CPU time, in milliseconds.
129    pub cpu_time: Option<u64>,
130    /// The highest CPU use of a sample interval, in millicores.
131    pub cpu_peak: Option<u64>,
132    /// CPU time over wall time, in millicores.
133    pub cpu_average: Option<u64>,
134    /// The highest memory use without page cache.
135    pub memory_peak: Option<u64>,
136    /// The average memory use without page cache.
137    pub memory_average: Option<u64>,
138    /// Bytes the container received.
139    pub network_received: Option<u64>,
140    /// Bytes the container sent.
141    pub network_sent: Option<u64>,
142    /// Of the bytes received, those the Docker Hub mirror served.
143    pub mirror_bytes: u64,
144    /// What the shared build cache served it and took from it.
145    #[serde(default)]
146    pub build_cache: BuildCacheCounts,
147    /// Times the kernel held its container's sockets' receive windows small because the container's memory was
148    /// under pressure (`memory.events` `sock_throttled`): reclaim went badly, mostly because the host was short of
149    /// memory, and its downloads slowed. `None` when the kernel does not count it, or in records from before.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub sock_throttled: Option<u64>,
152}
153
154/// What the shared build cache served one runner and took from it (ADR 0052), counted by the cache itself.
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156pub struct BuildCacheCounts {
157    /// Lookups that found an entry.
158    pub hits: u64,
159    /// Lookups that found none.
160    pub misses: u64,
161    /// Bytes of the entries it served.
162    pub read_bytes: u64,
163    /// Bytes of the entries it stored.
164    pub written_bytes: u64,
165    /// Entries it was sent and did not store: the job may not write (a pull request, another branch), or the
166    /// cache was full.
167    pub refused: u64,
168    /// Whether the job could write: a push, scheduled or manually started run of a branch that may write.
169    pub writer: bool,
170}
171
172impl BuildCacheCounts {
173    /// Hits and misses.
174    pub fn lookups(&self) -> u64 {
175        self.hits + self.misses
176    }
177
178    /// Hits in percent of lookups, `None` without any.
179    pub fn hit_percent(&self) -> Option<u64> {
180        let lookups = self.lookups();
181        (lookups > 0).then(|| self.hits * 100 / lookups)
182    }
183
184    /// Adds `other`'s counts; `writer` stays.
185    pub fn add(&mut self, other: &BuildCacheCounts) {
186        self.hits += other.hits;
187        self.misses += other.misses;
188        self.read_bytes += other.read_bytes;
189        self.written_bytes += other.written_bytes;
190        self.refused += other.refused;
191    }
192}
193
194/// A job's stored log.
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
196pub struct LogSummary {
197    /// Lines stored.
198    pub lines: u64,
199    /// Bytes stored.
200    pub bytes: u64,
201    /// Set once the log reached `history.log_max` and later lines were dropped.
202    pub truncated: bool,
203}
204
205/// One step of a job, as GitHub reports it.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct Step {
208    /// GitHub's step number; numbers may skip.
209    pub number: u32,
210    /// The name GitHub shows, such as `Set up job` or `Run docker run --rm hello-world`.
211    pub name: String,
212    /// `pending`, `queued`, `in_progress` or `completed`.
213    pub status: String,
214    /// Once completed: `success`, `failure`, `skipped`, `cancelled`, ….
215    pub conclusion: Option<String>,
216    /// When it started.
217    pub started_at: Option<u64>,
218    /// When it completed.
219    pub completed_at: Option<u64>,
220}
221
222/// `GET /v1/jobs` query parameters. Jobs come newest first (by ID, which grows with time).
223#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
224pub struct JobQuery {
225    /// Only this `owner/name`.
226    pub repository: Option<String>,
227    /// Only jobs in this state.
228    pub state: Option<JobState>,
229    /// Only jobs with this conclusion, such as `failure`.
230    pub conclusion: Option<String>,
231    /// Only jobs of the workflow with this name, such as `CI`.
232    pub workflow: Option<String>,
233    /// Only jobs with this name in their workflow, such as `build`.
234    pub name: Option<String>,
235    /// Only jobs queued at or after this time.
236    pub since: Option<u64>,
237    /// Only jobs queued before this time.
238    pub until: Option<u64>,
239    /// Only jobs with a smaller ID: the `next` of the previous page.
240    pub before: Option<u64>,
241    /// At most this many jobs; default 50, at most 500.
242    pub limit: Option<u32>,
243}
244
245/// `GET /v1/jobs`: one page of jobs.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct JobPage {
248    /// Newest first.
249    pub jobs: Vec<JobSummary>,
250    /// Pass as `before` for the next page; `None` on the last page.
251    pub next: Option<u64>,
252}
253
254/// `GET /v1/jobs/{id}/logs` query parameters. The response is `text/plain`, one log line per line.
255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256pub struct LogQuery {
257    /// Start at this line, 0-based.
258    pub from: Option<u64>,
259    /// Keep the response open and send lines as they are stored, until the job finishes.
260    #[serde(default)]
261    pub follow: bool,
262}
263
264/// One sample of a container's use.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266pub struct Sample {
267    /// When it was taken.
268    pub at: u64,
269    /// CPU use since the previous sample, in millicores.
270    pub cpu: u64,
271    /// Memory use without page cache.
272    pub memory: u64,
273    /// Bytes received since the container started.
274    pub network_received: u64,
275    /// Bytes sent since the container started.
276    pub network_sent: u64,
277}
278
279/// `GET /v1/jobs/{id}/samples`: what the job's container used over time, from its start to its removal. Samples
280/// older than `history.samples` are deleted; the job's metrics stay.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct SampleSeries {
283    /// The job.
284    pub job: u64,
285    /// Seconds between samples.
286    pub interval: u64,
287    /// Oldest first.
288    pub samples: Vec<Sample>,
289}
290
291/// One sample of the whole slice runners run in.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
293pub struct HostSample {
294    /// When it was taken.
295    pub at: u64,
296    /// CPU use of the slice since the previous sample, in millicores.
297    pub cpu: u64,
298    /// Memory use of the slice less its inactive page cache, as `docker stats` counts it: a conservative number,
299    /// since active page cache still counts, which the high-water mark of measured admission compares to
300    /// `MemoryMax`.
301    pub memory: u64,
302}
303
304/// The host's memory at one moment, beside the slice's: what admission fits new runners in besides `MemoryMax`
305/// (ADR 0055). Read every few seconds; not kept in the history.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
307pub struct HostMemory {
308    /// When it was read.
309    pub at: u64,
310    /// `MemTotal` in `/proc/meminfo`, in bytes.
311    pub total: u64,
312    /// `MemAvailable`: what the host could give without swapping, page cache it can drop included, in bytes.
313    pub available: u64,
314    /// The slice's memory without page cache and reclaimable slab: what the runners hold that `MemAvailable` leaves
315    /// out.
316    pub slice: u64,
317    /// `/proc/pressure/memory`'s `some` line: the share of time at least one task waited for memory. `None` on a
318    /// kernel without pressure stall information.
319    pub some: Option<Pressure>,
320    /// Its `full` line: the share of time every task that could run waited for memory.
321    pub full: Option<Pressure>,
322}
323
324/// One line of a pressure stall file, in hundredths of a percent: 250 is 2.5% of the time.
325#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
326pub struct Pressure {
327    /// Over the last 10 seconds.
328    pub avg10: u32,
329    /// Over the last 60 seconds.
330    pub avg60: u32,
331}
332
333/// `GET /v1/host/samples` query parameters.
334#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
335pub struct HostSampleQuery {
336    /// Only samples taken at or after this time; default the last hour, at most the last 24 hours.
337    pub since: Option<u64>,
338}
339
340/// `GET /v1/host/samples`: the slice's use over time, oldest first.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct HostSamples {
343    /// Seconds between samples.
344    pub interval: u64,
345    /// Oldest first.
346    pub samples: Vec<HostSample>,
347}
348
349/// How `GET /v1/stats` groups jobs in time.
350#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "kebab-case")]
352pub enum Bucket {
353    /// By UTC hour.
354    Hour,
355    /// By UTC day.
356    #[default]
357    Day,
358}
359
360/// `GET /v1/activity` query parameters.
361#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
362pub struct ActivityQuery {
363    /// The start of the window; default 24 hours ago. It ends now.
364    pub since: Option<u64>,
365}
366
367/// `GET /v1/activity`: every job runnerd's runners started or finished in a window, by when that happened rather
368/// than when the job was queued, so a job that waited or ran long counts in the window it started or ended in.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370pub struct Activity {
371    /// The start of the window.
372    pub since: u64,
373    /// When it was read: the end of the window.
374    pub until: u64,
375    /// Each job that started in the window, oldest start first.
376    pub started: Vec<Started>,
377    /// The jobs that finished in the window: completed, or lost with their runner.
378    pub finished: u64,
379    /// Of those, the ones that failed, timed out or were lost, as `GET /v1/stats` counts failures.
380    pub failed: u64,
381}
382
383impl Activity {
384    /// The 95th percentile of the waits of the jobs that started, by nearest rank as sizing's are; `None` when none
385    /// started.
386    pub fn wait_p95(&self) -> Option<u64> {
387        let mut waits: Vec<u64> = self.started.iter().map(|start| start.waited).collect();
388        waits.sort_unstable();
389        let rank = (waits.len() * 95).div_ceil(100);
390        waits.get(rank.checked_sub(1)?).copied()
391    }
392
393    /// The longest wait of the jobs that started in each of the 24 hours before `until`, oldest hour first; 0 for
394    /// an hour without a start. Counted back from `until`, so the hours are the day the other figures count.
395    pub fn wait_hours(&self) -> [u64; 24] {
396        const HOUR: u64 = 3600;
397        let since = self.until.saturating_sub(24 * HOUR);
398        let mut hours = [0u64; 24];
399        for start in self
400            .started
401            .iter()
402            .filter(|start| (since..=self.until).contains(&start.at))
403        {
404            // A start exactly 24 hours ago is in the oldest hour.
405            let back =
406                usize::try_from((self.until - start.at) / HOUR).map_or(23, |back| back.min(23));
407            hours[23 - back] = hours[23 - back].max(start.waited);
408        }
409        hours
410    }
411}
412
413/// A job that started: when, and how long it had waited since it was queued.
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
415pub struct Started {
416    /// When it started.
417    pub at: u64,
418    /// Seconds it waited.
419    pub waited: u64,
420}
421
422/// `GET /v1/stats` query parameters.
423#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
424pub struct StatsQuery {
425    /// Only this `owner/name`.
426    pub repository: Option<String>,
427    /// Only jobs queued at or after this time; default 30 days ago. At most 744 buckets before `until` (or now)
428    /// are counted: 31 days of hours, two years of days.
429    pub since: Option<u64>,
430    /// Only jobs queued before this time.
431    pub until: Option<u64>,
432    /// The bucket size; default a day.
433    #[serde(default)]
434    pub bucket: Bucket,
435}
436
437/// `GET /v1/stats`: finished jobs per repository and time bucket, for trends. Sums, so clients can average.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct Stats {
440    /// The bucket size.
441    pub bucket: Bucket,
442    /// By bucket start, then repository.
443    pub rows: Vec<StatsRow>,
444}
445
446/// The finished jobs of one repository in one bucket.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448pub struct StatsRow {
449    /// The bucket's start.
450    pub start: u64,
451    /// `owner/name`.
452    pub repository: String,
453    /// Jobs completed or lost.
454    pub jobs: u64,
455    /// Of those, concluded `success`.
456    pub succeeded: u64,
457    /// Of those, concluded `failure` or `timed_out`, or lost.
458    pub failed: u64,
459    /// Of those, concluded `cancelled`.
460    pub cancelled: u64,
461    /// Seconds from queued to started, summed.
462    pub queue_seconds: u64,
463    /// Seconds from started to finished, summed.
464    pub run_seconds: u64,
465    /// CPU time, in milliseconds, summed.
466    pub cpu_time: u64,
467    /// The highest memory peak of a job.
468    pub memory_peak: u64,
469    /// Bytes received, summed.
470    pub network_received: u64,
471    /// Bytes from the mirror, summed.
472    pub mirror_bytes: u64,
473}
474
475/// What a cache volume holds.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "kebab-case", tag = "kind")]
478pub enum CacheKind {
479    /// A profile's read-only snapshot.
480    Snapshot {
481        /// The profile.
482        profile: String,
483    },
484    /// A slot's layer changes and Docker Engine data.
485    Slot {
486        /// The profile.
487        profile: String,
488        /// 1-based.
489        slot: usize,
490    },
491    /// The pnpm store all slots share.
492    Pnpm,
493    /// The Maven repository all slots share.
494    Maven,
495    /// The Docker Hub mirror's storage.
496    Mirror,
497    /// The build cache every runner shares: a directory of the daemon's, not a Docker volume.
498    Build,
499}
500
501/// One cache volume and its size.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct CacheVolume {
504    /// The Docker volume.
505    pub name: String,
506    /// What it holds.
507    #[serde(flatten)]
508    pub kind: CacheKind,
509    /// Its size at `measured_at`.
510    pub bytes: u64,
511    /// The size above which runnerd empties it, or for a snapshot starts it over at its next reseed, if it has one.
512    pub limit: Option<u64>,
513}
514
515/// `GET /v1/caches`: every cache volume of the configured profiles, as last measured. Docker measures volumes
516/// slowly, so runnerd measures them at start and whenever no runner runs after jobs ended.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct Caches {
519    /// When the sizes were measured; `None` before the first measurement.
520    pub measured_at: Option<u64>,
521    /// By name.
522    pub volumes: Vec<CacheVolume>,
523    /// The shared build cache now, when it is on.
524    #[serde(default)]
525    pub build: Option<BuildCacheStats>,
526}
527
528/// The shared build cache (ADR 0052): its size now, and what it served the jobs of the last 7 days.
529#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
530pub struct BuildCacheStats {
531    /// The entries' bytes.
532    pub bytes: u64,
533    /// How many entries it holds.
534    pub entries: u64,
535    /// `[caches.build] max_size`.
536    pub limit: u64,
537    /// The runners of the last 7 days, summed.
538    pub week: BuildCacheCounts,
539}
540
541/// `GET /v1/caches/history` query parameters.
542#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
543pub struct CacheHistoryQuery {
544    /// Only measurements at or after this time; default 30 days ago.
545    pub since: Option<u64>,
546}
547
548/// One volume's size at one measurement.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct CacheSize {
551    /// When it was measured.
552    pub at: u64,
553    /// The Docker volume.
554    pub name: String,
555    /// Its size.
556    pub bytes: u64,
557}
558
559/// `GET /v1/caches/history`: every measurement, oldest first.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct CacheHistory {
562    /// Oldest first.
563    pub sizes: Vec<CacheSize>,
564}