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
116/// What a job used, over the samples taken while it ran (every few seconds). `None` until a sample exists.
117#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct JobMetrics {
119 /// CPU time, in milliseconds.
120 pub cpu_time: Option<u64>,
121 /// The highest CPU use of a sample interval, in millicores.
122 pub cpu_peak: Option<u64>,
123 /// CPU time over wall time, in millicores.
124 pub cpu_average: Option<u64>,
125 /// The highest memory use without page cache.
126 pub memory_peak: Option<u64>,
127 /// The average memory use without page cache.
128 pub memory_average: Option<u64>,
129 /// Bytes the container received.
130 pub network_received: Option<u64>,
131 /// Bytes the container sent.
132 pub network_sent: Option<u64>,
133 /// Of the bytes received, those the Docker Hub mirror served.
134 pub mirror_bytes: u64,
135 /// What the shared build cache served it and took from it.
136 #[serde(default)]
137 pub build_cache: BuildCacheCounts,
138}
139
140/// What the shared build cache served one runner and took from it (ADR 0052), counted by the cache itself.
141#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct BuildCacheCounts {
143 /// Lookups that found an entry.
144 pub hits: u64,
145 /// Lookups that found none.
146 pub misses: u64,
147 /// Bytes of the entries it served.
148 pub read_bytes: u64,
149 /// Bytes of the entries it stored.
150 pub written_bytes: u64,
151 /// Entries it was sent and did not store: the job may not write (a pull request, another branch), or the
152 /// cache was full.
153 pub refused: u64,
154 /// Whether the job could write: a push, scheduled or manually started run of a branch that may write.
155 pub writer: bool,
156}
157
158impl BuildCacheCounts {
159 /// Hits and misses.
160 pub fn lookups(&self) -> u64 {
161 self.hits + self.misses
162 }
163
164 /// Hits in percent of lookups, `None` without any.
165 pub fn hit_percent(&self) -> Option<u64> {
166 let lookups = self.lookups();
167 (lookups > 0).then(|| self.hits * 100 / lookups)
168 }
169
170 /// Adds `other`'s counts; `writer` stays.
171 pub fn add(&mut self, other: &BuildCacheCounts) {
172 self.hits += other.hits;
173 self.misses += other.misses;
174 self.read_bytes += other.read_bytes;
175 self.written_bytes += other.written_bytes;
176 self.refused += other.refused;
177 }
178}
179
180/// A job's stored log.
181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
182pub struct LogSummary {
183 /// Lines stored.
184 pub lines: u64,
185 /// Bytes stored.
186 pub bytes: u64,
187 /// Set once the log reached `history.log_max` and later lines were dropped.
188 pub truncated: bool,
189}
190
191/// One step of a job, as GitHub reports it.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct Step {
194 /// GitHub's step number; numbers may skip.
195 pub number: u32,
196 /// The name GitHub shows, such as `Set up job` or `Run docker run --rm hello-world`.
197 pub name: String,
198 /// `pending`, `queued`, `in_progress` or `completed`.
199 pub status: String,
200 /// Once completed: `success`, `failure`, `skipped`, `cancelled`, ….
201 pub conclusion: Option<String>,
202 /// When it started.
203 pub started_at: Option<u64>,
204 /// When it completed.
205 pub completed_at: Option<u64>,
206}
207
208/// `GET /v1/jobs` query parameters. Jobs come newest first (by ID, which grows with time).
209#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
210pub struct JobQuery {
211 /// Only this `owner/name`.
212 pub repository: Option<String>,
213 /// Only jobs in this state.
214 pub state: Option<JobState>,
215 /// Only jobs with this conclusion, such as `failure`.
216 pub conclusion: Option<String>,
217 /// Only jobs of the workflow with this name, such as `CI`.
218 pub workflow: Option<String>,
219 /// Only jobs with this name in their workflow, such as `build`.
220 pub name: Option<String>,
221 /// Only jobs queued at or after this time.
222 pub since: Option<u64>,
223 /// Only jobs queued before this time.
224 pub until: Option<u64>,
225 /// Only jobs with a smaller ID: the `next` of the previous page.
226 pub before: Option<u64>,
227 /// At most this many jobs; default 50, at most 500.
228 pub limit: Option<u32>,
229}
230
231/// `GET /v1/jobs`: one page of jobs.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct JobPage {
234 /// Newest first.
235 pub jobs: Vec<JobSummary>,
236 /// Pass as `before` for the next page; `None` on the last page.
237 pub next: Option<u64>,
238}
239
240/// `GET /v1/jobs/{id}/logs` query parameters. The response is `text/plain`, one log line per line.
241#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
242pub struct LogQuery {
243 /// Start at this line, 0-based.
244 pub from: Option<u64>,
245 /// Keep the response open and send lines as they are stored, until the job finishes.
246 #[serde(default)]
247 pub follow: bool,
248}
249
250/// One sample of a container's use.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct Sample {
253 /// When it was taken.
254 pub at: u64,
255 /// CPU use since the previous sample, in millicores.
256 pub cpu: u64,
257 /// Memory use without page cache.
258 pub memory: u64,
259 /// Bytes received since the container started.
260 pub network_received: u64,
261 /// Bytes sent since the container started.
262 pub network_sent: u64,
263}
264
265/// `GET /v1/jobs/{id}/samples`: what the job's container used over time, from its start to its removal. Samples
266/// older than `history.samples` are deleted; the job's metrics stay.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct SampleSeries {
269 /// The job.
270 pub job: u64,
271 /// Seconds between samples.
272 pub interval: u64,
273 /// Oldest first.
274 pub samples: Vec<Sample>,
275}
276
277/// One sample of the whole slice runners run in.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct HostSample {
280 /// When it was taken.
281 pub at: u64,
282 /// CPU use of the slice since the previous sample, in millicores.
283 pub cpu: u64,
284 /// Memory use of the slice less its inactive page cache, as `docker stats` counts it: a conservative number,
285 /// since active page cache still counts, which the high-water mark of measured admission compares to
286 /// `MemoryMax`.
287 pub memory: u64,
288}
289
290/// `GET /v1/host/samples` query parameters.
291#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
292pub struct HostSampleQuery {
293 /// Only samples taken at or after this time; default the last hour, at most the last 24 hours.
294 pub since: Option<u64>,
295}
296
297/// `GET /v1/host/samples`: the slice's use over time, oldest first.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub struct HostSamples {
300 /// Seconds between samples.
301 pub interval: u64,
302 /// Oldest first.
303 pub samples: Vec<HostSample>,
304}
305
306/// How `GET /v1/stats` groups jobs in time.
307#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "kebab-case")]
309pub enum Bucket {
310 /// By UTC hour.
311 Hour,
312 /// By UTC day.
313 #[default]
314 Day,
315}
316
317/// `GET /v1/activity` query parameters.
318#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
319pub struct ActivityQuery {
320 /// The start of the window; default 24 hours ago. It ends now.
321 pub since: Option<u64>,
322}
323
324/// `GET /v1/activity`: every job runnerd's runners started or finished in a window, by when that happened rather
325/// than when the job was queued, so a job that waited or ran long counts in the window it started or ended in.
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327pub struct Activity {
328 /// The start of the window.
329 pub since: u64,
330 /// When it was read: the end of the window.
331 pub until: u64,
332 /// Each job that started in the window, oldest start first.
333 pub started: Vec<Started>,
334 /// The jobs that finished in the window: completed, or lost with their runner.
335 pub finished: u64,
336 /// Of those, the ones that failed, timed out or were lost, as `GET /v1/stats` counts failures.
337 pub failed: u64,
338}
339
340impl Activity {
341 /// The 95th percentile of the waits of the jobs that started, by nearest rank as sizing's are; `None` when none
342 /// started.
343 pub fn wait_p95(&self) -> Option<u64> {
344 let mut waits: Vec<u64> = self.started.iter().map(|start| start.waited).collect();
345 waits.sort_unstable();
346 let rank = (waits.len() * 95).div_ceil(100);
347 waits.get(rank.checked_sub(1)?).copied()
348 }
349
350 /// The longest wait of the jobs that started in each of the 24 hours before `until`, oldest hour first; 0 for
351 /// an hour without a start. Counted back from `until`, so the hours are the day the other figures count.
352 pub fn wait_hours(&self) -> [u64; 24] {
353 const HOUR: u64 = 3600;
354 let since = self.until.saturating_sub(24 * HOUR);
355 let mut hours = [0u64; 24];
356 for start in self
357 .started
358 .iter()
359 .filter(|start| (since..=self.until).contains(&start.at))
360 {
361 // A start exactly 24 hours ago is in the oldest hour.
362 let back =
363 usize::try_from((self.until - start.at) / HOUR).map_or(23, |back| back.min(23));
364 hours[23 - back] = hours[23 - back].max(start.waited);
365 }
366 hours
367 }
368}
369
370/// A job that started: when, and how long it had waited since it was queued.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372pub struct Started {
373 /// When it started.
374 pub at: u64,
375 /// Seconds it waited.
376 pub waited: u64,
377}
378
379/// `GET /v1/stats` query parameters.
380#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
381pub struct StatsQuery {
382 /// Only this `owner/name`.
383 pub repository: Option<String>,
384 /// Only jobs queued at or after this time; default 30 days ago. At most 744 buckets before `until` (or now)
385 /// are counted: 31 days of hours, two years of days.
386 pub since: Option<u64>,
387 /// Only jobs queued before this time.
388 pub until: Option<u64>,
389 /// The bucket size; default a day.
390 #[serde(default)]
391 pub bucket: Bucket,
392}
393
394/// `GET /v1/stats`: finished jobs per repository and time bucket, for trends. Sums, so clients can average.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct Stats {
397 /// The bucket size.
398 pub bucket: Bucket,
399 /// By bucket start, then repository.
400 pub rows: Vec<StatsRow>,
401}
402
403/// The finished jobs of one repository in one bucket.
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct StatsRow {
406 /// The bucket's start.
407 pub start: u64,
408 /// `owner/name`.
409 pub repository: String,
410 /// Jobs completed or lost.
411 pub jobs: u64,
412 /// Of those, concluded `success`.
413 pub succeeded: u64,
414 /// Of those, concluded `failure` or `timed_out`, or lost.
415 pub failed: u64,
416 /// Of those, concluded `cancelled`.
417 pub cancelled: u64,
418 /// Seconds from queued to started, summed.
419 pub queue_seconds: u64,
420 /// Seconds from started to finished, summed.
421 pub run_seconds: u64,
422 /// CPU time, in milliseconds, summed.
423 pub cpu_time: u64,
424 /// The highest memory peak of a job.
425 pub memory_peak: u64,
426 /// Bytes received, summed.
427 pub network_received: u64,
428 /// Bytes from the mirror, summed.
429 pub mirror_bytes: u64,
430}
431
432/// What a cache volume holds.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(rename_all = "kebab-case", tag = "kind")]
435pub enum CacheKind {
436 /// A profile's read-only snapshot.
437 Snapshot {
438 /// The profile.
439 profile: String,
440 },
441 /// A slot's layer changes and Docker Engine data.
442 Slot {
443 /// The profile.
444 profile: String,
445 /// 1-based.
446 slot: usize,
447 },
448 /// The pnpm store all slots share.
449 Pnpm,
450 /// The Maven repository all slots share.
451 Maven,
452 /// The Docker Hub mirror's storage.
453 Mirror,
454 /// The build cache every runner shares: a directory of the daemon's, not a Docker volume.
455 Build,
456}
457
458/// One cache volume and its size.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct CacheVolume {
461 /// The Docker volume.
462 pub name: String,
463 /// What it holds.
464 #[serde(flatten)]
465 pub kind: CacheKind,
466 /// Its size at `measured_at`.
467 pub bytes: u64,
468 /// The size above which runnerd empties it, or for a snapshot starts it over at its next reseed, if it has one.
469 pub limit: Option<u64>,
470}
471
472/// `GET /v1/caches`: every cache volume of the configured profiles, as last measured. Docker measures volumes
473/// slowly, so runnerd measures them at start and whenever no runner runs after jobs ended.
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct Caches {
476 /// When the sizes were measured; `None` before the first measurement.
477 pub measured_at: Option<u64>,
478 /// By name.
479 pub volumes: Vec<CacheVolume>,
480 /// The shared build cache now, when it is on.
481 #[serde(default)]
482 pub build: Option<BuildCacheStats>,
483}
484
485/// The shared build cache (ADR 0052): its size now, and what it served the jobs of the last 7 days.
486#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
487pub struct BuildCacheStats {
488 /// The entries' bytes.
489 pub bytes: u64,
490 /// How many entries it holds.
491 pub entries: u64,
492 /// `[caches.build] max_size`.
493 pub limit: u64,
494 /// The runners of the last 7 days, summed.
495 pub week: BuildCacheCounts,
496}
497
498/// `GET /v1/caches/history` query parameters.
499#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
500pub struct CacheHistoryQuery {
501 /// Only measurements at or after this time; default 30 days ago.
502 pub since: Option<u64>,
503}
504
505/// One volume's size at one measurement.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct CacheSize {
508 /// When it was measured.
509 pub at: u64,
510 /// The Docker volume.
511 pub name: String,
512 /// Its size.
513 pub bytes: u64,
514}
515
516/// `GET /v1/caches/history`: every measurement, oldest first.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct CacheHistory {
519 /// Oldest first.
520 pub sizes: Vec<CacheSize>,
521}