use std::convert::Infallible;
use std::time::{Duration, Instant};
use tokio::sync::broadcast::Receiver;
use tokio::sync::broadcast::error::RecvError;
use super::RunpodClient;
use crate::events::Event;
use crate::exec::{Executor, quote};
use crate::runs::{RunError, RunRecord, SnapshotReason, request_snapshot};
use crate::system::{self, Disk, SystemSample};
use crate::train::{OUTPUT_DIR, SNAPSHOT_REQUEST};
pub const WARN_PERCENT: u64 = 85;
pub const WARN_STEP: u64 = 5;
pub const ACT_PERCENT: u64 = 92;
const CHECKPOINT_ROOM_TENTHS: u64 = 15;
pub const VOLUME_USABLE_PERCENT: u64 = 94;
pub const VOLUME_SIZE_FILE: &str = ".pod/volume_gb";
pub const DISK_PROBE_EVERY: Duration = Duration::from_secs(60);
pub const WATCHDOG_ACT_PERCENT: u64 = 97;
pub const GROW_WINDOW: Duration = Duration::from_secs(120);
const UNMEASURED_WARN: u32 = 3;
const GROW_MIN_GB: u32 = 50;
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
const GB: u64 = 1_000_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Usage {
pub used_bytes: u64,
pub capacity_bytes: u64,
}
impl Usage {
#[must_use]
pub fn of_disk(disk: &Disk) -> Self {
Self {
used_bytes: disk.used_bytes,
capacity_bytes: disk.used_bytes.saturating_add(disk.available_bytes),
}
}
#[must_use]
pub fn of_volume(used_bytes: u64, size_gb: u32) -> Self {
Self {
used_bytes,
capacity_bytes: u64::from(size_gb).saturating_mul(GB / 100 * VOLUME_USABLE_PERCENT),
}
}
#[must_use]
pub fn free_bytes(&self) -> u64 {
self.capacity_bytes.saturating_sub(self.used_bytes)
}
fn at_least(&self, percent: u64) -> bool {
self.capacity_bytes > 0
&& u128::from(self.used_bytes) * 100
>= u128::from(percent) * u128::from(self.capacity_bytes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Critical {
Full,
NoRoomForCheckpoint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Assessment {
pub warn: Option<u64>,
pub act: Option<Critical>,
}
#[must_use]
pub fn assess(usage: Usage, checkpoint_bytes: Option<u64>, warned: Option<u64>) -> Assessment {
let level = (0..=(100 - WARN_PERCENT) / WARN_STEP)
.map(|step| WARN_PERCENT + step * WARN_STEP)
.take_while(|percent| usage.at_least(*percent))
.last();
let warn = level.filter(|level| warned.is_none_or(|warned| *level > warned));
let no_room = checkpoint_bytes.is_some_and(|checkpoint| {
u128::from(usage.free_bytes()) * 10
< u128::from(checkpoint) * u128::from(CHECKPOINT_ROOM_TENTHS)
});
let act = if usage.at_least(ACT_PERCENT) {
Some(Critical::Full)
} else if usage.capacity_bytes > 0 && no_room {
Some(Critical::NoRoomForCheckpoint)
} else {
None
};
Assessment { warn, act }
}
#[must_use]
pub fn grown_size(current_gb: u32, max_gb: u32) -> Option<u32> {
let wanted = current_gb
.saturating_add(current_gb.div_ceil(2))
.max(current_gb.saturating_add(GROW_MIN_GB));
let size = wanted.min(max_gb);
(size > current_gb).then_some(size)
}
#[must_use]
pub fn disk_probe_script(run_dir: &str, volume_dir: Option<&str>) -> String {
let output = quote(&format!("{run_dir}/{OUTPUT_DIR}"));
let volume = volume_dir.map_or_else(String::new, |volume| {
format!(
"part volume\n$limit du -sk -- {} 2>/dev/null\n",
quote(volume)
)
});
format!(
"part() {{ printf '\\n@%s\\n' \"$1\"; }}\n\
limit=\n\
command -v timeout >/dev/null 2>&1 && limit='timeout -k 5 50'\n\
part checkpoint\n\
last=$(ls -1d -- {output}/checkpoint-* 2>/dev/null | sed 's/.*checkpoint-//' | sort -n | tail -n 1)\n\
[ -n \"$last\" ] && $limit du -sk -- {output}/checkpoint-\"$last\" 2>/dev/null\n\
{volume}exit 0\n"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DiskProbe {
pub checkpoint_bytes: Option<u64>,
pub volume_used_bytes: Option<u64>,
}
#[must_use]
pub fn parse_disk_probe(output: &str) -> DiskProbe {
let parts = system::parts(output);
let kib = |name: &str| {
parts
.get(name)
.and_then(|lines| system::first_number::<u64>(lines))
.and_then(|kib| kib.checked_mul(1024))
};
DiskProbe {
checkpoint_bytes: kib("checkpoint"),
volume_used_bytes: kib("volume"),
}
}
#[derive(Debug, Clone, Copy)]
pub struct VolumeDisk<'a> {
pub id: &'a str,
pub mount: &'a str,
pub max_gb: Option<u32>,
}
#[derive(Debug, Clone, Copy)]
struct Growing {
to_gb: u32,
since: Instant,
}
enum GrowCheck {
Grown(u32),
Waiting,
Failed(&'static str),
}
pub struct DiskWatch<'a, E> {
executor: &'a E,
client: &'a RunpodClient,
run: &'a RunRecord,
volume: Option<VolumeDisk<'a>>,
size_gb: Option<u32>,
handed_gb: Option<u32>,
probe: DiskProbe,
probed_at: Option<Instant>,
unmeasured: u32,
warned: Option<u64>,
growing: Option<Growing>,
grow_window: Duration,
cannot_grow: bool,
said_no_snapshot: bool,
idle: bool,
}
impl<'a, E: Executor> DiskWatch<'a, E> {
#[must_use]
pub fn new(
executor: &'a E,
client: &'a RunpodClient,
run: &'a RunRecord,
volume: Option<VolumeDisk<'a>>,
) -> Self {
Self {
executor,
client,
run,
volume,
size_gb: None,
handed_gb: None,
probe: DiskProbe::default(),
probed_at: None,
unmeasured: 0,
warned: None,
growing: None,
grow_window: GROW_WINDOW,
cannot_grow: false,
said_no_snapshot: false,
idle: false,
}
}
#[must_use]
pub fn with_grow_window(mut self, window: Duration) -> Self {
self.grow_window = window;
self
}
#[must_use]
pub fn idle(mut self) -> Self {
self.idle = true;
self
}
pub async fn run(mut self, mut events: Receiver<Event>) -> Infallible {
loop {
match events.recv().await {
Ok(Event::System(sample)) => self.sampled(&sample).await,
Ok(_) | Err(RecvError::Lagged(_)) => {},
Err(RecvError::Closed) => break,
}
}
std::future::pending().await
}
async fn sampled(&mut self, sample: &SystemSample) {
if self.idle {
return;
}
if self.volume.is_some() && self.size_gb.is_none() {
self.read_size().await;
}
self.probe_if_due().await;
if let Some(usage) = self.usage(sample) {
self.check(usage, self.probe.checkpoint_bytes).await;
}
}
fn usage(&self, sample: &SystemSample) -> Option<Usage> {
if self.volume.is_some() {
return Some(Usage::of_volume(
self.probe.volume_used_bytes?,
self.size_gb?,
));
}
sample
.run_disk()
.filter(|disk| !disk.shared)
.map(Usage::of_disk)
}
async fn probe_if_due(&mut self) {
if self
.probed_at
.is_some_and(|at| at.elapsed() < DISK_PROBE_EVERY)
{
return;
}
self.probed_at = Some(Instant::now());
if self.volume.is_some() {
self.read_size().await;
}
let script =
disk_probe_script(&self.run.remote_dir, self.volume.map(|volume| volume.mount));
let answer = match tokio::time::timeout(PROBE_TIMEOUT, self.executor.probe(&script)).await {
Ok(answer) => answer
.map(|output| parse_disk_probe(&String::from_utf8_lossy(&output)))
.map_err(|error| error.to_string()),
Err(_) => Err("no answer in time".to_string()),
};
self.probed(answer);
}
fn probed(&mut self, answer: Result<DiskProbe, String>) {
let why = match answer {
Ok(found) => {
self.probe.checkpoint_bytes = found.checkpoint_bytes;
if self.volume.is_none() || found.volume_used_bytes.is_some() {
self.probe.volume_used_bytes = found.volume_used_bytes;
self.unmeasured = 0;
return;
}
"du gave no size".to_string()
},
Err(error) => error,
};
tracing::debug!("cannot measure the pod's disk: {why}");
self.unmeasured_again(&why);
}
fn unmeasured_again(&mut self, why: &str) {
self.unmeasured = self.unmeasured.saturating_add(1);
if self.volume.is_none() || self.unmeasured != UNMEASURED_WARN {
return;
}
tracing::warn!(
"cannot measure the network volume {UNMEASURED_WARN} times in a row ({why}): \
the disk policy uses its last known use until it can"
);
}
async fn fetch_size(&self, id: &str) -> Result<u32, String> {
match self.client.get_network_volume(id).await {
Ok(Some(found)) if found.size > 0 => Ok(found.size),
Ok(Some(_)) => Err("Runpod gave no size".to_string()),
Ok(None) => Err("not found".to_string()),
Err(error) => Err(error.to_string()),
}
}
pub async fn read_size(&mut self) -> Option<u32> {
let volume = self.volume?;
let size = self
.fetch_size(volume.id)
.await
.inspect_err(|error| {
tracing::debug!("cannot read network volume {}: {error}", volume.id);
})
.ok()?;
self.size_gb = Some(size);
let handed = self.growing.map_or(size, |growing| size.max(growing.to_gb));
self.hand_size(handed).await;
Some(size)
}
async fn hand_size(&mut self, size: u32) {
if self.handed_gb == Some(size) {
return;
}
let path = format!("{}/{VOLUME_SIZE_FILE}", self.run.remote_dir);
match self.executor.put_file(&path, &size.to_string()).await {
Ok(()) => self.handed_gb = Some(size),
Err(error) => tracing::debug!("cannot hand the volume size to the watchdog: {error}"),
}
}
pub async fn check(&mut self, usage: Usage, checkpoint_bytes: Option<u64>) {
if self.idle {
return;
}
if self.growing.is_some() {
self.check_grow().await;
return;
}
let assessment = assess(usage, checkpoint_bytes, self.warned);
if let Some(percent) = assessment.warn {
self.warn_full(percent, usage);
}
let Some(critical) = assessment.act else {
return;
};
if self.resized_elsewhere().await {
return;
}
let why = match critical {
Critical::Full => format!("the pod's {} is {ACT_PERCENT}% full", self.disk_name()),
Critical::NoRoomForCheckpoint => format!(
"the pod's {} has {} free, too little for the next checkpoint",
self.disk_name(),
gb(usage.free_bytes())
),
};
if !self.grow(&why).await {
self.stop(&why).await;
}
}
fn warn_full(&mut self, percent: u64, usage: Usage) {
self.warned = Some(percent);
tracing::warn!(
"the pod's {} is {percent}% full ({} free)",
self.disk_name(),
gb(usage.free_bytes())
);
}
async fn resized_elsewhere(&mut self) -> bool {
let known = self.size_gb;
self.volume.is_some()
&& self
.read_size()
.await
.is_some_and(|size| Some(size) != known)
}
fn disk_name(&self) -> &'static str {
if self.volume.is_some() {
"network volume"
} else {
"disk"
}
}
async fn grow(&mut self, why: &str) -> bool {
let (Some(volume), Some(size)) = (self.volume, self.size_gb) else {
return false;
};
let Some(to_gb) = volume.max_gb.and_then(|max| grown_size(size, max)) else {
return false;
};
if self.cannot_grow {
return false;
}
let resized = self.client.resize_network_volume(volume.id, to_gb).await;
let note = match &resized {
Ok(_) => format!(
"{why}: growing network volume {} from {size} to {to_gb} GB (max_volume_gb); \
a volume never shrinks back: it stays billed at {to_gb} GB after the run, \
for every pod using it",
volume.id
),
Err(error) => format!("cannot grow network volume {}: {error}", volume.id),
};
tracing::warn!("{note}");
let Ok(answer) = resized else {
self.cannot_grow = true;
return false;
};
self.growing = Some(Growing {
to_gb,
since: Instant::now(),
});
self.hand_size(to_gb).await;
if answer.size >= to_gb {
self.grown(answer.size);
}
true
}
fn grown(&mut self, size_gb: u32) {
self.growing = None;
self.size_gb = Some(size_gb);
self.warned = None;
}
async fn check_grow(&mut self) {
let (Some(growing), Some(volume)) = (self.growing, self.volume) else {
return;
};
let waited = growing.since.elapsed();
let check = match self.fetch_size(volume.id).await {
Ok(size) if size >= growing.to_gb => GrowCheck::Grown(size),
Ok(_) if waited >= self.grow_window => {
GrowCheck::Failed("the network volume did not grow")
},
Err(_) if waited >= self.grow_window.saturating_mul(3) => {
GrowCheck::Failed("the size of the network volume cannot be read after its grow")
},
Ok(_) | Err(_) => GrowCheck::Waiting,
};
match check {
GrowCheck::Grown(size) => {
self.grown(size);
tracing::warn!("network volume {} grew to {size} GB", volume.id);
},
GrowCheck::Failed(why) => {
self.growing = None;
self.stop(why).await;
},
GrowCheck::Waiting => {},
}
}
async fn stop(&mut self, why: &str) {
if self.run.snapshots {
self.ask_snapshot(why).await;
} else if !self.said_no_snapshot {
self.said_no_snapshot = true;
tracing::warn!("{why}, but {}", RunError::NoSnapshots(self.run.id.clone()));
}
}
async fn ask_snapshot(&mut self, why: &str) {
let path = format!("{}/{SNAPSHOT_REQUEST}", self.run.remote_dir);
let asked = self
.executor
.read_from(&path, 0, 64)
.await
.is_ok_and(|content| !content.iter().all(u8::is_ascii_whitespace));
let note = if asked {
self.idle = true;
format!("{why}; a snapshot was already asked for")
} else {
match request_snapshot(self.executor, self.run, SnapshotReason::Disk).await {
Ok(()) => {
self.idle = true;
format!("{why}: the job is stopped with a snapshot")
},
Err(error) => format!("{why}, but the snapshot cannot be asked for: {error}"),
}
};
tracing::warn!("{note}");
}
}
#[must_use]
pub fn export_room_warning(container_disk_gb: u32, need_bytes: u64) -> Option<String> {
let disk = u128::from(container_disk_gb).saturating_mul(u128::from(GB));
(u128::from(need_bytes).saturating_mul(10) > disk.saturating_mul(9)).then(|| {
format!(
"the export needs about {} of disk for the merged model and the GGUF files, \
beside the run: container_disk_gb = {container_disk_gb} may be too small",
gb(need_bytes)
)
})
}
fn gb(bytes: u64) -> String {
format!("{:.1} GB", system::float(bytes) / system::float(GB))
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1024 * 1024 * 1024;
fn usage(percent: u64) -> Usage {
Usage {
used_bytes: percent * GIB,
capacity_bytes: 100 * GIB,
}
}
#[test]
fn an_export_warns_when_it_would_fill_the_container_disk() {
assert_eq!(export_room_warning(50, 44 * GB), None);
assert_eq!(
export_room_warning(50, 46 * GB).as_deref(),
Some(
"the export needs about 46.0 GB of disk for the merged model and the GGUF \
files, beside the run: container_disk_gb = 50 may be too small"
)
);
}
#[test]
fn warnings_start_at_85_percent_then_come_every_5() {
assert_eq!(assess(usage(84), None, None), Assessment::default());
assert_eq!(assess(usage(85), None, None).warn, Some(85));
assert_eq!(assess(usage(89), None, Some(85)).warn, None);
assert_eq!(assess(usage(90), None, Some(85)).warn, Some(90));
assert_eq!(assess(usage(97), None, Some(85)).warn, Some(95));
assert_eq!(assess(usage(100), None, Some(95)).warn, Some(100));
assert_eq!(assess(usage(100), None, Some(100)).warn, None);
}
#[test]
fn the_disk_is_acted_on_at_92_percent() {
assert_eq!(assess(usage(91), None, None).act, None);
assert_eq!(assess(usage(92), None, None).act, Some(Critical::Full));
let under = Usage {
used_bytes: 9199,
capacity_bytes: 10_000,
};
assert_eq!(assess(under, None, None).act, None);
let empty = Usage {
used_bytes: 0,
capacity_bytes: 0,
};
assert_eq!(assess(empty, Some(GIB), None), Assessment::default());
}
#[test]
fn the_next_checkpoint_must_fit_one_and_a_half_times() {
assert_eq!(assess(usage(60), Some(26 * GIB), None).act, None);
assert_eq!(
assess(usage(60), Some(27 * GIB), None).act,
Some(Critical::NoRoomForCheckpoint)
);
assert_eq!(assess(usage(60), None, None).act, None);
}
#[test]
fn a_volume_counts_94_percent_of_its_size() {
let volume = Usage::of_volume(9_400_000_000, 10);
assert_eq!(volume.capacity_bytes, 9_400_000_000);
assert_eq!(volume.free_bytes(), 0);
assert_eq!(assess(volume, None, None).act, Some(Critical::Full));
let disk = Usage::of_disk(&Disk {
mount: "/".into(),
fstype: None,
shared: false,
size_bytes: 100,
used_bytes: 40,
available_bytes: 50,
});
assert_eq!((disk.used_bytes, disk.capacity_bytes), (40, 90));
}
#[test]
fn the_watchdog_acts_at_the_same_share() {
let script = crate::runpod::watchdog_script();
assert!(script.contains(&format!("\nDISK_ACT={WATCHDOG_ACT_PERCENT}\n")));
assert!(script.contains(&format!("\nVOLUME_USABLE={VOLUME_USABLE_PERCENT}\n")));
assert!(script.contains(&format!(
"pod_number {}",
VOLUME_SIZE_FILE.trim_start_matches(".pod/")
)));
}
#[test]
fn a_volume_grows_by_half_at_least_50_gb_up_to_its_cap() {
assert_eq!(grown_size(200, 1000), Some(300));
assert_eq!(grown_size(20, 1000), Some(70));
assert_eq!(grown_size(200, 250), Some(250));
assert_eq!(grown_size(250, 250), None);
assert_eq!(grown_size(300, 250), None);
}
#[test]
fn the_disk_probe_reads_the_newest_checkpoint_and_the_volume() {
let found =
parse_disk_probe("\n@checkpoint\n2048\t/r/output/checkpoint-20\n\n@volume\n10\t/v\n");
assert_eq!(
found,
DiskProbe {
checkpoint_bytes: Some(2048 * 1024),
volume_used_bytes: Some(10 * 1024),
}
);
assert_eq!(parse_disk_probe("\n@checkpoint\n"), DiskProbe::default());
let script = disk_probe_script("/w/it's", None);
assert!(
script.contains("'/w/it'\\''s/output'/checkpoint-*"),
"{script}"
);
assert!(!script.contains("@volume") && !script.contains("part volume"));
let script = disk_probe_script("/w", Some("/workspace/data"));
assert!(
script.contains("part volume\n$limit du -sk -- '/workspace/data'"),
"{script}"
);
}
#[test]
fn the_disk_probe_finds_the_newest_checkpoint_by_step() -> Result<(), Box<dyn std::error::Error>>
{
let dir = tempfile::tempdir()?;
let run = dir.path().join("run");
for (step, bytes) in [(9, 1), (10, 1024 * 1024), (100, 64 * 1024)] {
let checkpoint = run.join(format!("output/checkpoint-{step}"));
std::fs::create_dir_all(&checkpoint)?;
std::fs::write(checkpoint.join("weights"), vec![1u8; bytes])?;
}
let run = run.to_str().ok_or("path")?;
let output = std::process::Command::new("sh")
.args(["-c", &disk_probe_script(run, Some(run))])
.output()?;
assert!(output.status.success());
let found = parse_disk_probe(&String::from_utf8_lossy(&output.stdout));
let checkpoint = found.checkpoint_bytes.ok_or("no checkpoint")?;
assert!(
(64 * 1024..512 * 1024).contains(&checkpoint),
"{checkpoint}"
);
assert!(
found
.volume_used_bytes
.is_some_and(|used| used >= 12 * 1024)
);
Ok(())
}
}