Skip to main content

runnerd_core/api/
storage.rs

1//! What the disk runnerd keeps means as every client shows it: what each cache volume holds and what its limit does,
2//! what the build cache did for a job and over a week, how full the records are and what became of a job's record,
3//! and how much of a job's log is stored. Plain derivations of the API's types; clients only lay them out.
4
5use super::{
6    BuildCacheCounts, BuildCacheStats, CacheKind, CacheVolume, LogSummary, RecordLimits,
7    RecordNote, RecordRemoval, Records, Tone, period,
8};
9use crate::config::ByteSize;
10
11/// The share of `[records] max_total` past which the records are near it, in percent: new ones then remove the
12/// oldest to fit.
13pub const RECORDS_NEAR_FULL: u64 = 80;
14
15/// Whether records taking `bytes` are near `max_total` (`RECORDS_NEAR_FULL`).
16pub fn records_near_full(bytes: u64, max_total: u64) -> bool {
17    u128::from(bytes) * 100 >= u128::from(max_total) * u128::from(RECORDS_NEAR_FULL)
18}
19
20impl CacheKind {
21    /// What it holds, as every client names it: `jvm snapshot`, `jvm slot 2 layer`, `pnpm store, shared`, `Maven
22    /// repository, shared`, `Docker Hub mirror` or `build cache, shared`.
23    pub fn description(&self) -> String {
24        match self {
25            CacheKind::Snapshot { profile } => format!("{profile} snapshot"),
26            CacheKind::Slot { profile, slot } => format!("{profile} slot {slot} layer"),
27            CacheKind::Pnpm => "pnpm store, shared".to_owned(),
28            CacheKind::Maven => "Maven repository, shared".to_owned(),
29            CacheKind::Mirror => "Docker Hub mirror".to_owned(),
30            CacheKind::Build => "build cache, shared".to_owned(),
31        }
32    }
33
34    /// The profile it belongs to; `None` for a shared one.
35    pub fn profile(&self) -> Option<&str> {
36        match self {
37            CacheKind::Snapshot { profile } | CacheKind::Slot { profile, .. } => Some(profile),
38            CacheKind::Pnpm | CacheKind::Maven | CacheKind::Mirror | CacheKind::Build => None,
39        }
40    }
41}
42
43/// A cache volume against its limit, from `CacheVolume::limit_status`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CacheLimit {
46    /// In bytes.
47    pub limit: u64,
48    /// Whether the volume is past it, so runnerd acts on it.
49    pub over: bool,
50    /// What runnerd does at it: `emptied above 5G`, `trimmed to 20G`, or for a snapshot `started over at its next
51    /// reseed above 30G`.
52    pub label: String,
53}
54
55impl CacheLimit {
56    /// What its size means: past the limit is worth a look.
57    pub fn tone(&self) -> Tone {
58        if self.over { Tone::Warn } else { Tone::Neutral }
59    }
60}
61
62impl CacheVolume {
63    /// Its size against its limit, when it has one.
64    pub fn limit_status(&self) -> Option<CacheLimit> {
65        let limit = self.limit?;
66        let size = ByteSize(limit);
67        let label = match self.kind {
68            CacheKind::Build => format!("trimmed to {size}"),
69            CacheKind::Snapshot { .. } => format!("started over at its next reseed above {size}"),
70            CacheKind::Slot { .. } | CacheKind::Pnpm | CacheKind::Maven | CacheKind::Mirror => {
71                format!("emptied above {size}")
72            }
73        };
74        Some(CacheLimit {
75            limit,
76            over: self.bytes > limit,
77            label,
78        })
79    }
80}
81
82/// What the build cache did for one job, from `BuildCacheCounts::activity`.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct BuildCacheActivity {
85    /// The counts.
86    pub counts: BuildCacheCounts,
87}
88
89impl BuildCacheCounts {
90    /// What it did, for a job that used it at all: looked an entry up, stored one, or was refused one. `None` for
91    /// a job that never used it.
92    pub fn activity(&self) -> Option<BuildCacheActivity> {
93        (self.lookups() > 0 || self.written_bytes > 0 || self.refused > 0)
94            .then_some(BuildCacheActivity { counts: *self })
95    }
96}
97
98impl BuildCacheActivity {
99    /// Its lookups: `3 of 4 lookups hit (75%)`, or `no lookups`.
100    pub fn lookups_line(&self) -> String {
101        let counts = &self.counts;
102        match counts.hit_percent() {
103            Some(percent) => format!(
104                "{} of {} lookups hit ({percent}%)",
105                counts.hits,
106                counts.lookups()
107            ),
108            None => "no lookups".to_owned(),
109        }
110    }
111
112    /// What it served: `1.5G served`.
113    pub fn served_line(&self) -> String {
114        format!("{} served", ByteSize(self.counts.read_bytes))
115    }
116
117    /// What it stored: `2G stored`, or `read only` for a job that may not write.
118    pub fn stored_line(&self) -> String {
119        if self.counts.writer {
120            format!("{} stored", ByteSize(self.counts.written_bytes))
121        } else {
122            "read only".to_owned()
123        }
124    }
125
126    /// The uploads it refused, when it refused any: `3 uploads refused`.
127    pub fn refused_line(&self) -> Option<String> {
128        (self.counts.refused > 0).then(|| format!("{} uploads refused", self.counts.refused))
129    }
130
131    /// All of it in a line: `3 of 4 lookups hit (75%), 1.5G served, 2G stored, 3 uploads refused`.
132    pub fn line(&self) -> String {
133        let mut parts = vec![self.lookups_line(), self.served_line(), self.stored_line()];
134        parts.extend(self.refused_line());
135        parts.join(", ")
136    }
137}
138
139impl BuildCacheStats {
140    /// Its entries and the last 7 days' jobs: `120 entries; 75% of 400 lookups hit in 7 days, 3G served, 1G
141    /// stored, 2 uploads refused`, `120 entries; no lookups in 7 days, 1G stored`, or `120 entries; no lookups in 7
142    /// days`.
143    pub fn week_line(&self) -> String {
144        let week = &self.week;
145        let mut parts = match week.hit_percent() {
146            Some(percent) => vec![
147                format!("{percent}% of {} lookups hit in 7 days", week.lookups()),
148                format!("{} served", ByteSize(week.read_bytes)),
149            ],
150            None => vec!["no lookups in 7 days".to_owned()],
151        };
152        if week.activity().is_some() {
153            parts.push(format!("{} stored", ByteSize(week.written_bytes)));
154        }
155        if week.refused > 0 {
156            parts.push(format!("{} uploads refused", week.refused));
157        }
158        format!("{} entries; {}", self.entries, parts.join(", "))
159    }
160}
161
162impl RecordLimits {
163    /// Whether records taking `bytes` are near `max_total` (`records_near_full`).
164    pub fn near_full(&self, bytes: u64) -> bool {
165        records_near_full(bytes, self.max_total)
166    }
167}
168
169impl Records {
170    /// How many there are and how much they take of their limits, and when near `max_total`, `Records::pressure`:
171    /// `3 of 20 records, 1.2G of 10G, each kept 7 days; near max_total, so new ones remove the oldest`.
172    pub fn summary(&self) -> String {
173        match self.pressure() {
174            Some(pressure) => format!("{}; {pressure}", self.usage()),
175            None => self.usage(),
176        }
177    }
178
179    /// How many there are and how much they take of their limits: `3 of 20 records, 1.2G of 10G, each kept 7 days`.
180    pub fn usage(&self) -> String {
181        let limits = &self.limits;
182        // `[records] max_age` is in days more often than not.
183        let age = match limits.max_age / 86_400 {
184            1 if limits.max_age == 86_400 => "1 day".to_owned(),
185            days if days > 1 && limits.max_age.is_multiple_of(86_400) => format!("{days} days"),
186            _ => period(limits.max_age),
187        };
188        format!(
189            "{} of {} records, {} of {}, each kept {age}",
190            self.records.len(),
191            limits.max_count,
192            ByteSize(self.bytes()),
193            ByteSize(limits.max_total),
194        )
195    }
196
197    /// What being near `max_total` means, when they are: `near max_total, so new ones remove the oldest`.
198    pub fn pressure(&self) -> Option<&'static str> {
199        self.limits
200            .near_full(self.bytes())
201            .then_some("near max_total, so new ones remove the oldest")
202    }
203
204    /// What the summary means: near `max_total` is worth a look.
205    pub fn summary_tone(&self) -> Tone {
206        if self.limits.near_full(self.bytes()) {
207            Tone::Warn
208        } else {
209            Tone::Neutral
210        }
211    }
212
213    /// Why there are none: `none kept: a profile with record_failures = true keeps each failed job's runner as a
214    /// record, to open a shell where it failed`.
215    pub fn empty_reason() -> &'static str {
216        "none kept: a profile with record_failures = true keeps each failed job's runner as a record, to open a \
217         shell where it failed"
218    }
219
220    /// Whether a shell is open in `job`'s record.
221    pub fn shell_open(&self, job: u64) -> bool {
222        self.shells.contains(&job)
223    }
224}
225
226/// What became of a failed job's record, from `RecordNote::state`.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum RecordState<'note> {
229    /// None was made.
230    Skipped {
231        /// Why, such as too little free disk.
232        why: &'note str,
233    },
234    /// It was made, then removed.
235    Removed {
236        /// When.
237        at: u64,
238        /// Why, when known.
239        by: Option<RecordRemoval>,
240    },
241    /// It is kept: a shell can open in it.
242    Kept {
243        /// Its size when made, when known.
244        bytes: Option<u64>,
245    },
246}
247
248impl RecordNote {
249    /// What became of the record: not made, made and removed, or kept, in that order of precedence.
250    pub fn state(&self) -> RecordState<'_> {
251        match (&self.skipped, self.removed_at) {
252            (Some(why), _) => RecordState::Skipped { why },
253            (None, Some(at)) => RecordState::Removed {
254                at,
255                by: self.removed_by,
256            },
257            (None, None) => RecordState::Kept { bytes: self.bytes },
258        }
259    }
260
261    /// The command that opens a shell in `job`'s record: `runnerctl shell 42`.
262    pub fn shell_command(job: u64) -> String {
263        format!("runnerctl shell {job}")
264    }
265}
266
267impl RecordState<'_> {
268    /// Its words, before when it was removed for a removed one: `not kept: too little free disk`, `removed past
269    /// max_age`, or `record kept, 2G`.
270    pub fn line(&self) -> String {
271        match self {
272            RecordState::Skipped { why } => format!("not kept: {why}"),
273            RecordState::Removed { by, .. } => {
274                by.map_or_else(|| "removed".to_owned(), |by| by.to_string())
275            }
276            RecordState::Kept { bytes: Some(bytes) } => {
277                format!("record kept, {}", ByteSize(*bytes))
278            }
279            RecordState::Kept { bytes: None } => "record kept".to_owned(),
280        }
281    }
282}
283
284impl LogSummary {
285    /// How much of the log is stored: `120 lines, 4M`, and `, cut at history.log_max` once later lines were
286    /// dropped.
287    pub fn description(&self) -> String {
288        let line = format!("{} lines, {}", self.lines, ByteSize(self.bytes));
289        if self.truncated {
290            format!("{line}, cut at history.log_max")
291        } else {
292            line
293        }
294    }
295}
296
297#[cfg(test)]
298mod tests;