chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless-run keeps a floor of free disk, shedding caches before a run
crates/knotless-run/src/caches.rs+102−0
| @@ -40,6 +40,11 @@ | ||
//! newer, so the base never goes back to an older tip. Each base made so | ||
//! is marked with that tip's generation, in [`MARKS`]. | ||
//! | ||
//! The disk the caches are on is shared, with the site on the same host | ||
//! among others, so the runner keeps a floor of it free: below it, before | ||
//! a run starts, [`Caches::shed`] removes every kept run's layers and | ||
//! every base no run reads, and the runs after start afresh. | ||
//! | ||
//! Under the root: `bases/<cache>/<generation>`, `runs/<run>/<n>/` with | ||
//! each layer's `upper` and the `work` directory overlayfs needs beside | ||
//! it, `kept/<run>/`, a kept run's [`KEPT`] and its `<n>/upper`s, and | ||
| @@ -77,8 +82,13 @@ pub const KEPT_AGE: u64 = 3 * 24 * 60 * 60; | ||
/// The most runs whose layers are kept at once. | ||
pub const KEPT_MAX: usize = 16; | ||
| ||
/// Says the bytes free on the disk holding a path. | ||
pub type Space = Box<dyn Fn(&Path) -> io::Result<u64> + Send + Sync>; | ||
| ||
pub struct Caches { | ||
root: PathBuf, | ||
/// The bytes free on the disk the caches are on: [`free`], but in tests. | ||
space: Space, | ||
/// This runner's start, so its runs' names are not an earlier one's. | ||
boot: u128, | ||
runs: AtomicU64, | ||
| @@ -187,6 +197,16 @@ impl Caches { | ||
/// layers kept of the runs `kept` names, whose checks a push may yet | ||
/// take, and of no other. | ||
pub fn open(root: &Path, alive: &[String], kept: &[String]) -> Result<Arc<Caches>> { | ||
Caches::open_with(root, alive, kept, Box::new(free)) | ||
} | ||
| ||
/// [`Caches::open`], told the bytes free on its disk by `space`. | ||
pub fn open_with( | ||
root: &Path, | ||
alive: &[String], | ||
kept: &[String], | ||
space: Space, | ||
) -> Result<Arc<Caches>> { | ||
let (bases_dir, runs_dir) = (root.join("bases"), root.join("runs")); | ||
std::fs::create_dir_all(&bases_dir).context("making the caches' directory")?; | ||
std::fs::create_dir_all(&runs_dir)?; | ||
| @@ -250,6 +270,7 @@ impl Caches { | ||
.as_millis(); | ||
Ok(Arc::new(Caches { | ||
root: root.to_path_buf(), | ||
space, | ||
boot, | ||
runs: AtomicU64::new(0), | ||
bases: Mutex::new(bases), | ||
| @@ -278,6 +299,42 @@ impl Caches { | ||
Ok(()) | ||
} | ||
| ||
/// The bytes free on the disk the caches are on. | ||
pub fn free(&self) -> io::Result<u64> { | ||
(self.space)(&self.root) | ||
} | ||
| ||
/// Makes room on the disk, as it runs short: removes the layers kept | ||
/// of every run, which a push would only have saved a run's work | ||
/// with, and every cache's base no run reads, as [`Caches::drop_repo`] | ||
/// drops them, for the next run of each to start afresh. Says how | ||
/// many bases and kept runs went. | ||
pub fn shed(&self) -> Result<(usize, usize)> { | ||
let mut runs = 0; | ||
{ | ||
let _kept = self.kept.lock().unwrap(); | ||
for run in std::fs::read_dir(self.root.join("kept"))? { | ||
remove(&run?.path())?; | ||
runs += 1; | ||
} | ||
} | ||
let mut bases = self.bases.lock().unwrap(); | ||
let mut gone = 0; | ||
let current: Vec<(String, u64)> = bases.current.clone().into_iter().collect(); | ||
for (name, generation) in current { | ||
if bases.readers.contains_key(&(name.clone(), generation)) { | ||
continue; | ||
} | ||
bases.current.remove(&name); | ||
bases.marks.remove(&name); | ||
for generation in bases.unused(&name) { | ||
remove(&self.base(&name, generation))?; | ||
gone += 1; | ||
} | ||
} | ||
Ok((gone, runs)) | ||
} | ||
| ||
/// Where the layers kept of the run `run` are. | ||
fn kept_dir(&self, run: &str) -> PathBuf { | ||
self.root.join("kept").join(keys::ref_hash(run)) | ||
| @@ -600,6 +657,12 @@ impl Drop for Lease { | ||
} | ||
} | ||
| ||
/// The bytes free to the runner on the disk holding `path`. | ||
pub fn free(path: &Path) -> io::Result<u64> { | ||
let stat = rustix::fs::statvfs(path)?; | ||
Ok(stat.f_bavail.saturating_mul(stat.f_frsize)) | ||
} | ||
| ||
/// The seconds since the epoch. | ||
fn now() -> u64 { | ||
let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH); | ||
| @@ -1122,4 +1185,43 @@ mod tests { | ||
caches.forgetting(&[], 1 << 30, late).unwrap(); | ||
assert_eq!(kept_of(root.path()), [keys::ref_hash("wanted")]); | ||
} | ||
| ||
#[tokio::test] | ||
async fn short_of_disk_it_sheds_what_no_run_reads() { | ||
let root = tempfile::tempdir().unwrap(); | ||
// The disk as statvfs tells it, of somewhere real. | ||
assert!(free(root.path()).unwrap() > 0); | ||
assert!(free(&root.path().join("gone")).is_err()); | ||
let space: Space = Box::new(|_| Ok(7)); | ||
let caches = Caches::open_with(root.path(), &[], &[], space).unwrap(); | ||
assert_eq!(caches.free().unwrap(), 7); | ||
let wanted = names(&[("read", "/a"), ("unread", "/b")]); | ||
// Bases for both caches, and a pull's run kept. | ||
let main = caches.lease(&wanted).unwrap(); | ||
write(&main.layers[0].upper.join("x"), "x"); | ||
write(&main.layers[1].upper.join("y"), "y"); | ||
main.promote(1 << 30).await.unwrap(); | ||
let pull = caches.lease(&wanted).unwrap(); | ||
write(&pull.layers[0].upper.join("z"), "z"); | ||
pull.keep("pull-run", 1 << 30).await.unwrap(); | ||
assert!(caches.kept_dir("pull-run").exists()); | ||
// A run reads one cache's base as the disk runs short. | ||
let reading = caches.lease(&names(&[("read", "/a")])).unwrap(); | ||
let (read, unread) = { | ||
let all = caches.lease(&wanted).unwrap(); | ||
(all.layers[0].lower.clone(), all.layers[1].lower.clone()) | ||
}; | ||
assert_eq!(caches.shed().unwrap(), (1, 1)); | ||
assert!(!caches.kept_dir("pull-run").exists()); | ||
assert!(!unread.exists()); | ||
assert!(read.exists()); | ||
// The next run of the one shed starts afresh; the one read goes | ||
// on from its base. | ||
let next = caches.lease(&wanted).unwrap(); | ||
assert_eq!(next.layers[0].lower, read); | ||
assert!(listing(&next.layers[1].lower).is_empty()); | ||
drop((next, reading)); | ||
// Nothing left to shed but the bases no run reads now. | ||
assert_eq!(caches.shed().unwrap(), (2, 0)); | ||
} | ||
} | ||
crates/knotless-run/src/lib.rs+123−2
| @@ -116,7 +116,7 @@ use tokio::sync::{Notify, Semaphore}; | ||
use turns::{Class, Rank, Turns, Whose}; | ||
use workflow::{Superseded, Workflow}; | ||
| ||
const USAGE: &str = "usage: knotless-run [--jetstream <wss://host>] [--executor docker|shell] [--image <image>] [--secrets <file>] [--secrets-key <file>] [--state <file>] [--work <dir>] [--step-timeout <secs>] [--run-timeout <secs>] [--cpus <n>] [--memory <GiB>] [--pids <n>] [--network <name>] [--runtime <name>] [--trusted-runtime <name>] [--trust <did>] [--resolv <file>] [--jobs <n>] [--pull-jobs <n>] [--owner-jobs <n>] [--allowance <minutes>] [--private-allowance <minutes>] [--cache-max <GiB>] [--caches <dir>] [--paused <file>] [--keep-checks <days>] [--sweep <secs>] [--stop-wait <secs>] [--poll <secs>] [--pulls] [--pulls-only <handle>/<name>] [--unmetered <did>] [--queue [--batch <n>] [--depth 1|2] [--notice <secs>]] [--discover] [<handle>/<name>...]"; | ||
const USAGE: &str = "usage: knotless-run [--jetstream <wss://host>] [--executor docker|shell] [--image <image>] [--secrets <file>] [--secrets-key <file>] [--state <file>] [--work <dir>] [--step-timeout <secs>] [--run-timeout <secs>] [--cpus <n>] [--memory <GiB>] [--pids <n>] [--network <name>] [--runtime <name>] [--trusted-runtime <name>] [--trust <did>] [--resolv <file>] [--jobs <n>] [--pull-jobs <n>] [--owner-jobs <n>] [--allowance <minutes>] [--private-allowance <minutes>] [--cache-max <GiB>] [--disk-floor <GiB>] [--caches <dir>] [--paused <file>] [--keep-checks <days>] [--sweep <secs>] [--stop-wait <secs>] [--poll <secs>] [--pulls] [--pulls-only <handle>/<name>] [--unmetered <did>] [--queue [--batch <n>] [--depth 1|2] [--notice <secs>]] [--discover] [<handle>/<name>...]"; | ||
| ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Options { | ||
| @@ -179,6 +179,11 @@ pub struct Options { | ||
/// The bytes a cache's base may grow to before it is dropped, to | ||
/// start afresh at the next run. | ||
pub cache_max: u64, | ||
/// The bytes kept free on the disk the caches are on: below them, a | ||
/// run sheds the caches no run reads before it starts, and waits for | ||
/// room if that is not enough, so that what else the disk holds, as | ||
/// the site on the same host, never runs out. 0 keeps none. | ||
pub disk_floor: u64, | ||
/// Where the caches' bases, and each run's layers over them, are kept: | ||
/// a path the docker daemon knows by the same name. Defaults to one in | ||
/// the cache directory. | ||
| @@ -246,6 +251,7 @@ impl Options { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 16 << 30, | ||
disk_floor: 20 << 30, | ||
caches: None, | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(15 * 60), | ||
| @@ -401,6 +407,12 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
.map_err(|_| "--cache-max takes GiB".to_string())?; | ||
opts.cache_max = gib << 30; | ||
} | ||
"--disk-floor" => { | ||
let gib: u64 = value()? | ||
.parse() | ||
.map_err(|_| "--disk-floor takes GiB".to_string())?; | ||
opts.disk_floor = gib << 30; | ||
} | ||
"--jobs" => opts.jobs = count(flag, "runs", value()?)?, | ||
"--pull-jobs" => opts.pull_jobs = count(flag, "runs", value()?)?, | ||
"--owner-jobs" => opts.owner_jobs = count(flag, "runs", value()?)?, | ||
| @@ -617,6 +629,58 @@ fn waited(past: Option<u64>, took: Duration) -> String { | ||
} | ||
} | ||
| ||
/// How often a run waiting for room on the disk looks again. | ||
const ROOM_EVERY: Duration = Duration::from_secs(30); | ||
| ||
/// Waits until the disk the caches are on has `floor` bytes free, looking | ||
/// each `every`: below it, the caches no run reads are shed first, once, | ||
/// and then the runs going are waited for, as they end and free theirs. | ||
/// Says what the run's log says of it: nothing when there was room. | ||
async fn room(caches: &Arc<caches::Caches>, floor: u64, every: Duration) -> String { | ||
let (began, mut said, mut shed) = (tokio::time::Instant::now(), String::new(), false); | ||
loop { | ||
let free = match caches.free() { | ||
Ok(free) => free, | ||
Err(err) => { | ||
telemetry::failed("reading the disk's free space", &err.into()); | ||
return said; | ||
} | ||
}; | ||
if free >= floor { | ||
if began.elapsed() >= every { | ||
let took = began.elapsed().as_secs(); | ||
said += &format!("==> this one waited {took}s for room on the disk\n"); | ||
} | ||
return said; | ||
} | ||
let gib = |bytes: u64| bytes as f64 / (1u64 << 30) as f64; | ||
let short = format!( | ||
"{:.1} GiB free on the disk, under the runner's floor of {:.1} GiB", | ||
gib(free), | ||
gib(floor) | ||
); | ||
if shed { | ||
telemetry::short_of_disk(&short, false); | ||
tokio::time::sleep(every).await; | ||
continue; | ||
} | ||
shed = true; | ||
telemetry::short_of_disk(&short, true); | ||
let caches = caches.clone(); | ||
let shedding = tokio::task::spawn_blocking(move || caches.shed()).await; | ||
let (bases, kept) = match shedding.map_err(anyhow::Error::from).and_then(|r| r) { | ||
Ok(gone) => gone, | ||
Err(err) => { | ||
telemetry::failed("shedding caches", &err); | ||
(0, 0) | ||
} | ||
}; | ||
said += &format!( | ||
"==> {short}: shed {bases} caches' bases no run read and {kept} kept runs' layers, so this one starts afresh\n" | ||
); | ||
} | ||
} | ||
| ||
/// Today, as days since the epoch. | ||
fn today() -> u64 { | ||
let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH); | ||
| @@ -2246,10 +2310,14 @@ impl Runner { | ||
let began = std::time::Instant::now(); | ||
let turn = until_cancelled(cancelled, self.turns.clone().take(rank, whose, Vec::new())); | ||
let turn = turn.await?; | ||
let mut said = waited(over, began.elapsed()); | ||
// Holding its job, so that no other run starts meanwhile. | ||
let room = room(&self.caches, self.opts.disk_floor, ROOM_EVERY); | ||
said += &until_cancelled(cancelled, room).await?; | ||
Some(Held { | ||
on_ref: kept, | ||
job: turn, | ||
said: waited(over, began.elapsed()), | ||
said, | ||
}) | ||
} | ||
| ||
| @@ -3768,6 +3836,53 @@ mod tests { | ||
| ||
// On paused time, so the 5ms tick always comes before the 30ms pass: | ||
// on a busy host both could be due at once, and select! take either. | ||
#[tokio::test(start_paused = true)] | ||
async fn a_run_waits_for_room_on_the_disk() { | ||
use std::sync::atomic::AtomicI64; | ||
let root = tempfile::tempdir().unwrap(); | ||
// The bytes free, as the test sets them, under 0 unreadable; and | ||
// how many looks find 5 first, as runs going hold the rest. | ||
let (free, short) = (Arc::new(AtomicI64::new(10)), Arc::new(AtomicI64::new(0))); | ||
let (told, shorted) = (free.clone(), short.clone()); | ||
let space: caches::Space = Box::new(move |_| { | ||
if shorted.fetch_sub(1, Ordering::SeqCst) > 0 { | ||
return Ok(5); | ||
} | ||
match told.load(Ordering::SeqCst) { | ||
n if n < 0 => Err(std::io::Error::other("no disk")), | ||
n => Ok(n as u64), | ||
} | ||
}); | ||
let caches = caches::Caches::open_with(root.path(), &[], &[], space).unwrap(); | ||
let every = Duration::from_secs(30); | ||
// Room enough, or no floor: nothing to say. | ||
assert_eq!(room(&caches, 10, every).await, ""); | ||
assert_eq!(room(&caches, 0, every).await, ""); | ||
// A disk it cannot read holds no run back. | ||
free.store(-1, Ordering::SeqCst); | ||
assert_eq!(room(&caches, 10, every).await, ""); | ||
free.store(20, Ordering::SeqCst); | ||
// Short, it sheds the caches, and room made so, says so alone. | ||
short.store(1, Ordering::SeqCst); | ||
let said = room(&caches, 10, every).await; | ||
assert!(said.contains("under the runner's floor"), "{said}"); | ||
assert!(said.contains("shed 0 caches' bases"), "{said}"); | ||
assert!(!said.contains("waited"), "{said}"); | ||
// Short after shedding, it waits for the runs going to free some: | ||
// a look before the shed, one after, and one each 30s. | ||
short.store(4, Ordering::SeqCst); | ||
let said = room(&caches, 10, every).await; | ||
assert!( | ||
said.ends_with("waited 90s for room on the disk\n"), | ||
"{said}" | ||
); | ||
// Caches it cannot shed are logged, and waited past. | ||
std::fs::remove_dir_all(root.path().join("kept")).unwrap(); | ||
short.store(2, Ordering::SeqCst); | ||
let said = room(&caches, 10, every).await; | ||
assert!(said.contains("shed 0 caches' bases"), "{said}"); | ||
} | ||
| ||
#[tokio::test(start_paused = true)] | ||
async fn a_long_pass_is_watched() { | ||
let stage = Stage::default(); | ||
| @@ -4088,6 +4203,12 @@ mod tests { | ||
args("--cache-max x a/b").unwrap_err(), | ||
"--cache-max takes GiB" | ||
); | ||
assert_eq!(Options::new(Vec::new()).disk_floor, 20 << 30); | ||
assert_eq!(args("--disk-floor 0 a/b").unwrap().disk_floor, 0); | ||
assert_eq!( | ||
args("--disk-floor x a/b").unwrap_err(), | ||
"--disk-floor takes GiB" | ||
); | ||
assert_eq!(args("--jobs 2 a/b").unwrap().jobs, 2); | ||
// Pulls take half the jobs, or as many as asked, up to all of them. | ||
assert_eq!(opts.pull_share(), 2); | ||
crates/knotless-run/src/telemetry.rs+17−0
| @@ -148,6 +148,21 @@ pub(crate) fn long_pass(label: &str, stage: &str, took: Duration) { | ||
telemetry::note(Level::WARN, "pass running long", &fields); | ||
} | ||
| ||
/// Logs that the disk is `short` of the runner's floor, as a run sheds | ||
/// caches, or as it waits for room once it has. | ||
pub(crate) fn short_of_disk(short: &str, shedding: bool) { | ||
let fields = Fields { | ||
reason: Some(short), | ||
..Fields::default() | ||
}; | ||
let message = if shedding { | ||
"disk short: shedding caches" | ||
} else { | ||
"disk short: waiting for room" | ||
}; | ||
telemetry::note(Level::WARN, message, &fields); | ||
} | ||
| ||
/// Logs `message` about a batch of `pulls` pulls into `branch` of | ||
/// `label`, at `tip`, and why, if said. | ||
pub(crate) fn batch( | ||
| @@ -287,6 +302,8 @@ mod tests { | ||
watching(&["a/b".into()], "did:plc:x"); | ||
found("did:plc:x/b"); | ||
left("did:plc:x/b"); | ||
short_of_disk("1.0 GiB free", true); | ||
short_of_disk("1.0 GiB free", false); | ||
} | ||
| ||
#[test] | ||
crates/knotless-run/tests/run.rs+7−0
| @@ -167,6 +167,7 @@ fn runner_opts(runner: &Sandbox, owner: &Sandbox, js: &Jetstream) -> knotless_ru | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
| @@ -782,6 +783,7 @@ fn what_goes_wrong_is_reported() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: None, | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
| @@ -988,6 +990,7 @@ fn a_deploy_is_never_cut_off() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
| @@ -1129,6 +1132,7 @@ fn a_stop_drops_what_has_not_started() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
| @@ -1398,6 +1402,7 @@ fn a_failing_pds_wedges_nothing() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(1), | ||
stop_wait: Duration::from_secs(2), | ||
| @@ -2135,6 +2140,7 @@ fn a_merged_pull_passes_its_push() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
| @@ -3675,6 +3681,7 @@ fn a_workflow_asked_for_by_name() { | ||
unmetered: Vec::new(), | ||
trust: Vec::new(), | ||
cache_max: 1 << 30, | ||
disk_floor: 0, | ||
caches: Some(runner.path("caches")), | ||
sweep: Duration::from_secs(600), | ||
stop_wait: Duration::from_secs(60), | ||
deploy/README.md+4−1
| @@ -454,7 +454,10 @@ three times in all. It reads every repository again every `--sweep` | ||
left. A base over `--cache-max` GiB (16) once made is dropped, for the | ||
next run to start afresh. Disk, then: each base once, each run going's | ||
own writes on top (a test run's build, a few GB), and up to | ||
`--cache-max` GiB of kept layers. The host needs overlayfs over ext4 | ||
`--cache-max` GiB of kept layers. Below `--disk-floor` GiB free (20), | ||
a run sheds the bases no run reads and the kept layers before it starts, | ||
and waits for room if that is not enough, so the site on the same disk | ||
keeps room to write. The host needs overlayfs over ext4 | ||
with user xattrs, as Linux 5.11 and up has. The docker daemon mounts | ||
the overlays by the host's paths, one more reason `KNOTLESS_RUN_DATA` is | ||
the same path inside and out. The first start after the switch from | ||
docs/design/runner.md+22−0
| @@ -686,6 +686,28 @@ warms the rest. | ||
On start, the runner removes its leftover overlays and half-made bases, | ||
and sweeps no old base while an old overlay is still mounted. | ||
| ||
### Room on the disk | ||
| ||
The caches share their disk with whatever else the host runs. On | ||
knotless.dev's own host that includes the site's repositories and index. | ||
On 2026-10-31 a full disk took the site down. So the runner keeps | ||
`--disk-floor` free on the disk `--caches` is on: 20 GiB by default. That | ||
is about two of the largest bases the host has held, so one run's build | ||
can land on the floor without the site running out. | ||
| ||
Before a run starts, holding its job so no other run starts meanwhile, the | ||
runner checks the free space. When it is below the floor, the runner sheds | ||
first: every kept run's layers, and every cache's base no run reads. Those | ||
caches' next runs start afresh, as after a dropped base. If that is not | ||
enough, the run waits, checking again every 30 seconds, until the runs | ||
going end and free their layers. It stays queued, and can be cancelled as | ||
usual. Its log says what was shed and how long it waited. The runner logs | ||
`disk short` at warn each time it looks and finds the disk short. A disk | ||
it cannot read holds nothing back. | ||
| ||
The floor holds only at a run's start. A run already going can still fill | ||
the disk. | ||
| ||
### Where pushes run elsewhere | ||
| ||
A repository named with `--pulls-only` has its pushes run by another | ||
docs/reference/cli.md+1−0
| @@ -421,6 +421,7 @@ add` make it one, and invite it, which `--discover` reads. | ||
| `--stop-wait <secs>` | 900 | how long a stop waits for runs going before it cancels them; 0 waits not at all | | ||
| `--poll <secs>` | 10 | how often a private repository's space is asked whether anything was written there, which Jetstream never carries; while `KNOTLESS_APPVIEW`'s appview passes on its writes, no more often than once a minute (see [Private repositories](../design/runner.md#private-repositories)) | | ||
| `--cache-max <GiB>` | 16 | a whole number of GiB: the size at which a cache's new base is not used, to start afresh, and the most kept layers may take in all | | ||
| `--disk-floor <GiB>` | 20 | a whole number of GiB kept free on the disk `--caches` is on: below it, a run about to start first sheds every cache's base no run reads and every kept run's layers, then waits for room, looking every 30s; 0 keeps no floor (see [Room on the disk](../design/runner.md#room-on-the-disk)) | | ||
| `--caches <dir>` | `<cache dir>/run/caches` | where caches' bases and runs' layers are kept; a path the docker daemon sees under the same name | | ||
| `--secrets <file>` | | the [secrets file](workflows.md#secrets-file) | | ||
| `--secrets-key <file>` | `secrets-key.json` beside the state file | where the runner keeps the key owners seal secrets to, made if missing, and published as its `dev.knotless.runnerKey` record (see [The runner's key](../design/runner.md#the-runners-key)) | | ||
docs/reference/runner-internals.md+14−0
| @@ -59,6 +59,20 @@ kept directories not wanted or broken, are removed, and the newest | ||
generation of each cache is the current one. A base named `knotless-cache-<hash>`, as runners named them before a cache's name held its repository's, is removed. Marks of a base no longer | ||
current are let go. | ||
| ||
## Free space | ||
| ||
The free space is `statvfs(2)` of `--caches`: the blocks available to an | ||
unprivileged writer (`f_bavail`) times the fragment size (`f_frsize`). | ||
Below `--disk-floor`, a run about to start removes every `kept/*` | ||
directory and, for each cache whose current base no run reads, that base | ||
and its older generations no run reads, forgetting the cache's mark. It | ||
does this once for each run that finds the disk short. The run's log | ||
starts `==> <n> GiB free on the disk, under the runner's floor of <n> | ||
GiB: shed <n> caches' bases no run read and <n> kept runs' layers`, then | ||
says `==> this one waited <n>s for room on the disk` if it waited 30s or | ||
more. The runner logs `disk short: shedding caches` and `disk short: | ||
waiting for room` at warn, with the free space in `reason`. | ||
| ||
## What a pass logs | ||
| ||
Each pass over a repository, as its door rings, logs `pass ended` with | ||