mbx 1.0.0

A build cache for Rust projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! What mbx has saved on this machine, accumulated across builds.
//!
//! A single build's statistics say what just happened; they cannot say what
//! switching to mbx has been worth, because the compilations it skipped and the
//! directories it collected are spread over months. This module keeps a running
//! total beside the store and turns it into one line after a build.
//!
//! ```text
//! <store>/savings/v1/tally.json   the totals
//! <store>/savings/v1/tally.lock   held while they are updated
//! ```
//!
//! The tally is bookkeeping, not cache content: it is never an input to a
//! cache key, and the collector never walks it. Losing it costs a number in a
//! message, so every failure here is logged and forgotten rather than raised.

use crate::config::SavingsStyle;
use crate::util::{format_duration, write_atomic};
use bytesize::ByteSize;
use eyre::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

pub(crate) const TALLY_FILE: &str = "savings/v1/tally.json";
const TALLY_LOCK: &str = "savings/v1/tally.lock";
const TALLY_VERSION: u8 = 1;

/// Running totals for one machine.
///
/// `serde(default)` rather than `deny_unknown_fields`: a tally written by a
/// newer mbx must degrade to a smaller number, never to an error that costs a
/// build its savings line.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct Tally {
    pub version: u8,
    /// When counting started, so a total can say what it spans.
    pub since_secs: u64,
    pub builds: u64,
    pub cached_compilations: u64,
    pub avoided_compiler_ns: u64,
    pub reflinked_bytes: u64,
    pub freed_target_bytes: u64,
    pub freed_store_bytes: u64,
    /// Bytes the user asked to have removed: a confirmed `target/` migration,
    /// or `mbx cache remove`. Kept apart from the collection counters because
    /// every line about those brags that nobody had to do anything -- which is
    /// exactly untrue of a removal somebody confirmed.
    pub freed_requested_bytes: u64,
    /// Counters a newer mbx wrote that this one does not know about.
    ///
    /// Every record rewrites the whole file, so without this an older binary
    /// would silently drop a newer one's totals while leaving its version
    /// number in place. Carried through untouched instead.
    #[serde(flatten)]
    unknown: BTreeMap<String, serde_json::Value>,
}

/// What one command contributed.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Delta {
    pub builds: u64,
    pub cached_compilations: u64,
    pub avoided_compiler_ns: u64,
    pub reflinked_bytes: u64,
    pub freed_target_bytes: u64,
    pub freed_store_bytes: u64,
    pub freed_requested_bytes: u64,
}

impl Delta {
    fn is_empty(&self) -> bool {
        *self == Self::default()
    }
}

/// What this one command did, for the lines that describe the run rather than
/// the lifetime.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SessionFacts {
    pub hits: u64,
    pub avoided_compiler_ns: u64,
}

fn tally_path(store: &Path) -> PathBuf {
    store.join(TALLY_FILE)
}

/// Add `delta` to the stored totals and return the result.
///
/// Runs after cargo has exited, so the lock it takes and the two small files it
/// touches are not on anything the user is waiting for. Concurrent commands
/// serialize on the lock the same way sweeps do.
pub(crate) fn record(store: &Path, delta: &Delta) -> Result<Tally> {
    let lock_path = store.join(TALLY_LOCK);
    std::fs::create_dir_all(
        lock_path
            .parent()
            .expect("the tally lock path has a parent"),
    )
    .wrap_err_with(|| format!("failed to create {}", lock_path.display()))?;
    let mut lock = fslock::LockFile::open(&lock_path)?;
    lock.lock()?;

    let path = tally_path(store);
    let mut tally = read(&path);
    if tally.version == 0 {
        tally.version = TALLY_VERSION;
        tally.since_secs = now_secs();
    }
    tally.builds = tally.builds.saturating_add(delta.builds);
    tally.cached_compilations = tally
        .cached_compilations
        .saturating_add(delta.cached_compilations);
    tally.avoided_compiler_ns = tally
        .avoided_compiler_ns
        .saturating_add(delta.avoided_compiler_ns);
    tally.reflinked_bytes = tally.reflinked_bytes.saturating_add(delta.reflinked_bytes);
    tally.freed_target_bytes = tally
        .freed_target_bytes
        .saturating_add(delta.freed_target_bytes);
    tally.freed_store_bytes = tally
        .freed_store_bytes
        .saturating_add(delta.freed_store_bytes);
    tally.freed_requested_bytes = tally
        .freed_requested_bytes
        .saturating_add(delta.freed_requested_bytes);

    let mut contents = serde_json::to_vec_pretty(&tally)?;
    contents.push(b'\n');
    write_atomic(&path, &contents)?;
    Ok(tally)
}

/// Read the tally, treating anything unreadable as a fresh start.
///
/// A tally that cannot be parsed has already lost its history, and refusing to
/// count from here would lose every future build's as well.
fn read(path: &Path) -> Tally {
    std::fs::read(path)
        .ok()
        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        .unwrap_or_default()
}

/// The stored totals for `store`, or an empty tally when there are none yet.
pub(crate) fn read_tally(store: &Path) -> Tally {
    read(&tally_path(store))
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|since| since.as_secs())
        .unwrap_or_default()
}

/// Thresholds below which a number is not worth a line of the user's attention.
const MIN_SESSION_HITS: u64 = 5;
const MIN_SESSION_AVOIDED: Duration = Duration::from_secs(30);
const MIN_LIFETIME_AVOIDED: Duration = Duration::from_secs(30 * 60);
const MIN_FREED_BYTES: u64 = 1024 * 1024 * 1024;
const MIN_FREED_TARGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
const MIN_REFLINKED_BYTES: u64 = 1024 * 1024 * 1024;
const MIN_CACHED_COMPILATIONS: u64 = 250;

/// One `format!` per line, with the locals in scope: a pool of tellings reads
/// as copy to be edited, not as code, so every telling gets exactly one line.
macro_rules! tellings {
    ($($line:literal),+ $(,)?) => { vec![$(format!($line)),+] };
}

/// One fact worth telling, in every voice it can be told in.
struct Candidate {
    /// The mascot's variants: a cache box in a monocle, deadpan, mentioning
    /// the chores exactly once. One is chosen at random per build.
    cheeky: Vec<String>,
    /// The same fact in the register of the `mbx[cache]:` and `mbx[gc]:` lines.
    plain: String,
}

/// The facts this machine has earned the right to mention.
///
/// Every line carries a number this machine actually produced -- a line that
/// could have been written before the build ran is an advertisement, not a
/// report. Thresholds keep small numbers quiet.
fn facts_worth_telling(tally: &Tally, facts: &SessionFacts) -> Vec<Candidate> {
    let freed = tally
        .freed_target_bytes
        .saturating_add(tally.freed_store_bytes);
    let mut eligible = Vec::new();

    if facts.hits >= MIN_SESSION_HITS
        && facts.avoided_compiler_ns >= duration_ns(MIN_SESSION_AVOIDED)
    {
        let hits = facts.hits;
        let dur = nanos(facts.avoided_compiler_ns);
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: served {hits} compilations from cache; rustc showed up ready to do {dur} of work and was sent home",
                "mbx[savings]: {hits} compilations you did not wait for. the {dur} is yours now. spend it wisely.",
                "mbx[savings]: this build borrowed {hits} compilations from an earlier one. rustc was not informed. ({dur} saved)",
                "mbx[savings]: {hits} compilations arrived precompiled. somewhere a fan is not spinning. ({dur} saved)",
                "mbx[savings]: cargo asked for {hits} compilations; mbx had them filed under already-done. ({dur} saved)",
                "mbx[savings]: {hits} compilations replayed from cache. rustc's services were not required. ({dur} saved)",
                "mbx[savings]: intercepted {hits} compilations before rustc could get attached. ({dur} saved)",
                "mbx[savings]: rustc had {dur} of work planned. mbx had {hits} of its answers already.",
                "mbx[savings]: {hits} compilations answered from memory. rustc can finish its coffee. ({dur} saved)",
                "mbx[savings]: rustc arrived to find {hits} compilations already done. awkward. ({dur} saved)",
                "mbx[savings]: quietly substituted {hits} cached compilations. nobody noticed. that is the job. ({dur} saved)",
                "mbx[savings]: {hits} compilations in, zero compiled. {dur} returned to circulation.",
            ],
            plain: format!(
                "mbx[savings]: {hits} compilations restored from cache this build, avoiding {dur} of compiler time"
            ),
        });
    }
    if tally.avoided_compiler_ns >= duration_ns(MIN_LIFETIME_AVOIDED) {
        let dur = nanos(tally.avoided_compiler_ns);
        let over = builds(tally.builds);
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: {dur} of compiling skipped across {over}. rustc suspects nothing.",
                "mbx[savings]: {dur} refunded over {over}. no receipt necessary.",
                "mbx[savings]: rustc believes it compiled everything. it is down {dur} across {over}. let it believe.",
                "mbx[savings]: has saved {dur} across {over}. this line is the only thanks it needs.",
                "mbx[savings]: across {over}, rustc has been excused from {dur} of its duties.",
                "mbx[savings]: {dur} not spent compiling, over {over}. what you did instead is between you and the terminal.",
                "mbx[savings]: the running total stands at {dur} across {over}. nobody is counting except the box.",
                "mbx[savings]: {dur} of compilation avoided across {over}. imagine the fan noise. now stop.",
                "mbx[savings]: {dur} saved across {over}. put it toward something with a progress bar.",
                "mbx[savings]: {dur} of rustc's calendar cleared across {over}.",
                "mbx[savings]: {dur} unspent across {over}. compounding, in a sense.",
            ],
            plain: format!("mbx[savings]: {dur} of compiler time avoided across {over}"),
        });
    }
    if freed >= MIN_FREED_BYTES {
        let size = iec(freed);
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: {size} of build debris binned so far. cargo clean remains unemployed.",
                "mbx[savings]: has quietly disposed of {size} of build leftovers. nobody saw anything.",
                "mbx[savings]: {size} reclaimed to date. the disk sends its regards.",
                "mbx[savings]: {size} of build debris has left the building.",
                "mbx[savings]: swept up {size} so far. the broom is content-addressed.",
                "mbx[savings]: {size} tidied away. you may continue not thinking about it.",
                "mbx[savings]: {size} reclaimed while you were busy compiling other things.",
                "mbx[savings]: {size} of old build output has been shown the door.",
                "mbx[savings]: disposed of {size} without being asked. that is rather the point.",
                "mbx[savings]: the sweep found {size} nobody would miss. nobody has.",
            ],
            plain: format!("mbx[savings]: {size} reclaimed by collection so far"),
        });
    }
    if tally.freed_target_bytes >= MIN_FREED_TARGET_BYTES {
        let size = iec(tally.freed_target_bytes);
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: {size} of target/ had outlived its checkouts. it has been dealt with.",
                "mbx[savings]: found {size} of target/ whose checkouts left long ago. the estate has been settled.",
                "mbx[savings]: your deleted worktrees left {size} behind. left. past tense.",
                "mbx[savings]: {size} of target/ belonged to checkouts that no longer exist. neither does it.",
                "mbx[savings]: cleaned up {size} after checkouts that left without saying goodbye.",
                "mbx[savings]: {size} of outputs had survived their checkouts. an unnatural state, now corrected.",
                "mbx[savings]: the checkouts are gone. their {size} has gone to join them.",
                "mbx[savings]: {size} of target/ was waiting for checkouts that are never coming back. it has stopped waiting.",
                "mbx[savings]: escorted {size} of ownerless outputs off the premises.",
                "mbx[savings]: worktrees come and go. their {size} now goes with them.",
            ],
            plain: format!("mbx[savings]: {size} of abandoned target directories reclaimed"),
        });
    }
    if tally.reflinked_bytes >= MIN_REFLINKED_BYTES {
        let size = iec(tally.reflinked_bytes);
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: every checkout believes it owns {size} of outputs. the disk keeps one copy and says nothing.",
                "mbx[savings]: {size} of outputs, one copy, several very confident checkouts.",
                "mbx[savings]: reflinked {size} into place. copying is for people with disk to spare.",
                "mbx[savings]: {size} of outputs exist once and appear everywhere. do not tell the checkouts.",
                "mbx[savings]: {size} of target/ is an elaborate illusion. the disk is in on it.",
                "mbx[savings]: lent the same {size} to every checkout at once. none of them has checked.",
                "mbx[savings]: {size} shared among checkouts that each believe they are an only child.",
                "mbx[savings]: {size} on loan to every checkout simultaneously. the paperwork is an inode.",
                "mbx[savings]: {size} of outputs materialized by reflink. the copy machine stays off.",
                "mbx[savings]: {size} in every checkout, {size} on disk. arithmetic declined to comment.",
            ],
            plain: format!("mbx[savings]: {size} of outputs reflinked rather than copied"),
        });
    }
    if tally.cached_compilations >= MIN_CACHED_COMPILATIONS {
        let count = tally.cached_compilations;
        eligible.push(Candidate {
            cheeky: tellings![
                "mbx[savings]: {count} compilations, each compiled once and served warm ever since",
                "mbx[savings]: {count} cache hits and counting. rustc thinks it has been busy.",
                "mbx[savings]: {count} compilations on file. the box remembers.",
                "mbx[savings]: {count} compilations old. not one compiled twice.",
                "mbx[savings]: {count} compilations served. the monocle stays on.",
                "mbx[savings]: {count} compilations dispensed from stock. inventory immaculate.",
                "mbx[savings]: {count} compilations, and the box has forgotten none of them.",
                "mbx[savings]: {count} compilations retrieved with a straight face.",
                "mbx[savings]: {count} compilations. rustc did each once; mbx did the rest of the showing up.",
                "mbx[savings]: {count} compilations served. ask rustc how many it remembers doing.",
            ],
            plain: format!("mbx[savings]: {count} compilations served from cache to date"),
        });
    }
    eligible
}

/// One line about what mbx has saved, or nothing worth saying yet.
///
/// Which fact appears -- and, in the default style, which telling of it -- is
/// random. A machine that qualifies for several facts should not recite them
/// in a fixed order, and a joke wears out at exactly the speed it repeats.
/// `pick` is injected so tests can reach every line; callers use [`quip`].
fn quip_choosing(
    tally: &Tally,
    facts: &SessionFacts,
    style: SavingsStyle,
    mut pick: impl FnMut(usize) -> usize,
) -> Option<String> {
    if style == SavingsStyle::Off {
        return None;
    }
    let mut eligible = facts_worth_telling(tally, facts);
    if eligible.is_empty() {
        return None;
    }
    let index = pick(eligible.len()) % eligible.len();
    let candidate = eligible.swap_remove(index);
    match style {
        SavingsStyle::Quips => {
            let mut variants = candidate.cheeky;
            let index = pick(variants.len()) % variants.len();
            Some(variants.swap_remove(index))
        }
        SavingsStyle::Plain => Some(candidate.plain),
        SavingsStyle::Off => unreachable!("handled above"),
    }
}

pub(crate) fn quip(tally: &Tally, facts: &SessionFacts, style: SavingsStyle) -> Option<String> {
    use rand::RngExt as _;
    quip_choosing(tally, facts, style, |len| rand::rng().random_range(0..len))
}

fn iec(bytes: u64) -> String {
    ByteSize::b(bytes).display().iec().to_string()
}

/// A duration the way a person would say it: "6h 14m", "2m 51s", "45s".
///
/// [`format_duration`] is for measurements and renders six hours as
/// `22440.00s`, which is no way to tell somebody good news. Two units carry
/// all the precision a brag needs.
fn nanos(nanoseconds: u64) -> String {
    let total = Duration::from_nanos(nanoseconds).as_secs();
    if total == 0 {
        return format_duration(Duration::from_nanos(nanoseconds));
    }
    let units = [(86_400, "d"), (3_600, "h"), (60, "m"), (1, "s")];
    // The two largest units that are actually present. A zero unit in between
    // must not spend a slot: "1d 0h" would hide real minutes behind it and
    // understate the number this line exists to state.
    let mut parts = Vec::new();
    let mut rest = total;
    for (size, suffix) in units {
        let amount = rest / size;
        rest %= size;
        if amount > 0 {
            parts.push(format!("{amount}{suffix}"));
            if parts.len() == 2 {
                break;
            }
        }
    }
    parts.join(" ")
}

/// "1 build" is not "1 builds", and the one machine that hits it would be the
/// machine of someone deciding whether to keep mbx.
fn builds(count: u64) -> String {
    if count == 1 {
        "1 build".to_string()
    } else {
        format!("{count} builds")
    }
}

fn duration_ns(duration: Duration) -> u64 {
    crate::util::duration_ns(duration)
}

/// Record `delta` without letting a bookkeeping failure reach the build.
///
/// Callers are past the point where anything can be done about an error: cargo
/// has exited and its status is the answer the user came for.
pub(crate) fn record_quietly(store: &Path, delta: &Delta) -> Option<Tally> {
    match record(store, delta) {
        Ok(tally) => Some(tally),
        Err(error) => {
            log::debug!("savings were not recorded: {error}");
            None
        }
    }
}

/// Record and, when there is something worth saying, return the line to say.
pub(crate) fn record_and_describe(
    store: &Path,
    delta: &Delta,
    facts: &SessionFacts,
    style: SavingsStyle,
) -> Option<String> {
    // A run that neither compiled nor collected anything moves no counter, so
    // it does not write -- not even to start the file. A sweep that came due
    // during `cargo build --help` did free bytes, and that delta is not empty,
    // so those are still counted.
    if delta.is_empty() && facts.hits == 0 {
        return None;
    }
    let tally = record_quietly(store, delta)?;
    quip(&tally, facts, style)
}

#[cfg(test)]
#[path = "savings_tests.rs"]
mod tests;