rust_web_server/disk_guard/
mod.rs1use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::{Mutex, OnceLock};
20use std::time::{Duration, Instant};
21
22pub const RECHECK: Duration = Duration::from_secs(5);
24
25pub const RWS_CONFIG_MIN_FREE_DISK: &str = "RWS_CONFIG_MIN_FREE_DISK";
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29enum Floor {
30 Off,
31 Bytes(u64),
32 Percent(u64),
33 Default,
35}
36
37fn floor() -> Floor {
38 static FLOOR: OnceLock<Floor> = OnceLock::new();
39 *FLOOR.get_or_init(|| {
40 let value = std::env::var(RWS_CONFIG_MIN_FREE_DISK).unwrap_or_default();
41 if cfg!(test) && value.trim().is_empty() {
43 return Floor::Off;
44 }
45 parse_floor(&value).unwrap_or_else(|| {
46 eprintln!("{RWS_CONFIG_MIN_FREE_DISK}: not a size or a percentage; using the default (1%, at least 1G)");
47 Floor::Default
48 })
49 })
50}
51
52fn parse_floor(value: &str) -> Option<Floor> {
53 let value = value.trim();
54 if value.is_empty() {
55 return Some(Floor::Default);
56 }
57 if let Some(percent) = value.strip_suffix('%') {
58 let percent: u64 = percent.trim().parse().ok().filter(|p| *p <= 100)?;
59 return Some(if percent == 0 { Floor::Off } else { Floor::Percent(percent) });
60 }
61 let bytes = parse_size(value)?;
62 Some(if bytes == 0 { Floor::Off } else { Floor::Bytes(bytes) })
63}
64
65pub(crate) fn parse_size(value: &str) -> Option<u64> {
67 let value = value.trim();
68 let (digits, multiplier) = match value.as_bytes().last()? {
69 b'k' | b'K' => (&value[..value.len() - 1], 1 << 10),
70 b'm' | b'M' => (&value[..value.len() - 1], 1 << 20),
71 b'g' | b'G' => (&value[..value.len() - 1], 1 << 30),
72 _ => (value, 1),
73 };
74 digits.trim().parse::<u64>().ok()?.checked_mul(multiplier)
75}
76
77fn required(floor: Floor, total: u64) -> u64 {
79 match floor {
80 Floor::Off => 0,
81 Floor::Bytes(bytes) => bytes,
82 Floor::Percent(percent) => total / 100 * percent,
83 Floor::Default => (total / 100).max(1 << 30).min(total / 2),
84 }
85}
86
87#[cfg(all(unix, any(feature = "http1", feature = "http2")))]
90fn space(dir: &Path) -> Option<(u64, u64)> {
91 use std::os::unix::ffi::OsStrExt;
92 let path = std::ffi::CString::new(dir.as_os_str().as_bytes()).ok()?;
93 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
96 if unsafe { libc::statvfs(path.as_ptr(), &mut stat) } != 0 {
97 return None;
98 }
99 let block = stat.f_frsize as u64;
100 Some((stat.f_bavail as u64 * block, stat.f_blocks as u64 * block))
101}
102
103#[cfg(not(all(unix, any(feature = "http1", feature = "http2"))))]
104fn space(_dir: &Path) -> Option<(u64, u64)> {
105 None
106}
107
108struct Reading {
109 at: Instant,
110 free: u64,
111 total: u64,
112 low: bool,
113}
114
115fn readings() -> &'static Mutex<HashMap<PathBuf, Reading>> {
116 static READINGS: OnceLock<Mutex<HashMap<PathBuf, Reading>>> = OnceLock::new();
117 READINGS.get_or_init(Mutex::default)
118}
119
120fn directory_of(file: &Path) -> PathBuf {
122 if file.is_dir() {
123 return file.to_path_buf();
124 }
125 match file.parent() {
126 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
127 _ => PathBuf::from("."),
128 }
129}
130
131pub fn has_room(file: &Path) -> bool {
134 has_room_with(file, floor())
135}
136
137fn has_room_with(file: &Path, floor: Floor) -> bool {
138 if floor == Floor::Off {
139 return true;
140 }
141 let dir = directory_of(file);
142 let mut readings = readings().lock().unwrap_or_else(|e| e.into_inner());
143 if let Some(reading) = readings.get(&dir) {
144 if reading.at.elapsed() < RECHECK && !cfg!(test) {
145 return !reading.low;
146 }
147 }
148 let Some((free, total)) = space(&dir) else { return true };
149 let low = free < required(floor, total);
150 let was_low = readings.get(&dir).is_some_and(|r| r.low);
151 if low && !was_low {
152 eprintln!(
153 "disk: {} has {} MiB free, under the {} MiB {RWS_CONFIG_MIN_FREE_DISK} floor; rws stops writing there until space is freed",
154 dir.display(),
155 free >> 20,
156 required(floor, total) >> 20
157 );
158 } else if !low && was_low {
159 eprintln!("disk: {} has {} MiB free again; rws writes there again", dir.display(), free >> 20);
160 }
161 readings.insert(dir, Reading { at: Instant::now(), free, total, low });
162 !low
163}
164
165static DROPPED_LOG_LINES: AtomicU64 = AtomicU64::new(0);
166static SKIPPED_CACHE_WRITES: AtomicU64 = AtomicU64::new(0);
167
168pub(crate) fn dropped_log_lines(lines: u64) {
170 DROPPED_LOG_LINES.fetch_add(lines, Ordering::Relaxed);
171}
172
173pub(crate) fn skipped_cache_write() {
175 SKIPPED_CACHE_WRITES.fetch_add(1, Ordering::Relaxed);
176}
177
178pub fn status() -> Vec<(String, u64, u64, bool)> {
181 let readings = readings().lock().unwrap_or_else(|e| e.into_inner());
182 let mut out: Vec<_> = readings.iter().map(|(dir, r)| (dir.display().to_string(), r.free, r.total, r.low)).collect();
183 out.sort();
184 out
185}
186
187pub(crate) fn prometheus_text() -> String {
190 let status = status();
191 if status.is_empty() {
192 return String::new();
193 }
194 let label = |dir: &str| dir.replace('\\', "\\\\").replace('"', "\\\"");
195 let mut out = String::from("# HELP rws_disk_free_bytes Free space where rws writes (access log, disk cache)\n# TYPE rws_disk_free_bytes gauge\n");
196 for (dir, free, _, _) in &status {
197 out.push_str(&format!("rws_disk_free_bytes{{dir=\"{}\"}} {free}\n", label(dir)));
198 }
199 out.push_str(&format!("# HELP rws_disk_low 1 while a directory is under {RWS_CONFIG_MIN_FREE_DISK} and rws isn't writing there\n# TYPE rws_disk_low gauge\n"));
200 for (dir, _, _, low) in &status {
201 out.push_str(&format!("rws_disk_low{{dir=\"{}\"}} {}\n", label(dir), *low as u8));
202 }
203 out.push_str("# HELP rws_access_log_dropped_lines_total Access-log lines not written because the disk was low\n# TYPE rws_access_log_dropped_lines_total counter\n");
204 out.push_str(&format!("rws_access_log_dropped_lines_total {}\n", DROPPED_LOG_LINES.load(Ordering::Relaxed)));
205 out.push_str("# HELP rws_cache_skipped_writes_total Disk-cache entries not stored because the disk was low\n# TYPE rws_cache_skipped_writes_total counter\n");
206 out.push_str(&format!("rws_cache_skipped_writes_total {}\n", SKIPPED_CACHE_WRITES.load(Ordering::Relaxed)));
207 out
208}
209
210#[cfg(test)]
211mod tests;