1use std::path::{Path, PathBuf};
41
42pub const IMPORT_BYTES_PER_OBJECT: u64 = 512;
48
49pub const IMPORT_FIXED_BYTES: u64 = 512 * 1024 * 1024;
53
54pub const MARGIN_PERCENT: u64 = 20;
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum LimitSource {
61 Box,
63 Cgroup(PathBuf),
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct MemoryLimit {
70 pub bytes: u64,
71 pub source: LimitSource,
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct CgroupMemory {
77 pub max: u64,
79 pub current: u64,
81 pub reclaimable: u64,
84}
85
86impl CgroupMemory {
87 pub fn available(&self) -> u64 {
90 self.max
91 .saturating_sub(self.current)
92 .saturating_add(self.reclaimable)
93 .min(self.max)
94 }
95}
96
97impl MemoryLimit {
98 pub fn read() -> Option<MemoryLimit> {
101 Self::read_from(Path::new("/proc/meminfo"), own_cgroup_dir().as_deref())
102 }
103
104 pub fn read_from(meminfo: &Path, cgroup_dir: Option<&Path>) -> Option<MemoryLimit> {
106 let total = std::fs::read_to_string(meminfo)
107 .ok()
108 .and_then(|t| crate::dashboard::probe::parse_meminfo(&t))
109 .map(|(total, _)| total)
110 .filter(|t| *t > 0);
111 let cgroup = cgroup_dir.and_then(tightest_cgroup);
112 match (total, cgroup) {
113 (Some(total), Some((dir, c))) if c.max < total => Some(MemoryLimit {
114 bytes: c.max,
115 source: LimitSource::Cgroup(dir),
116 }),
117 (None, Some((dir, c))) => Some(MemoryLimit {
118 bytes: c.max,
119 source: LimitSource::Cgroup(dir),
120 }),
121 (Some(total), _) => Some(MemoryLimit {
122 bytes: total,
123 source: LimitSource::Box,
124 }),
125 (None, None) => None,
126 }
127 }
128
129 pub fn cgroup_dir(&self) -> Option<&Path> {
131 match &self.source {
132 LimitSource::Cgroup(dir) => Some(dir),
133 LimitSource::Box => None,
134 }
135 }
136
137 pub fn words(&self) -> String {
139 let source = match self.source {
140 LimitSource::Box => "this box's MemTotal",
141 LimitSource::Cgroup(_) => "this server's cgroup memory.max",
142 };
143 format!("{} ({source})", crate::budget::human(self.bytes))
144 }
145}
146
147pub fn own_cgroup_dir() -> Option<PathBuf> {
149 let own = std::fs::read_to_string("/proc/self/cgroup").ok()?;
150 let rel = own
151 .lines()
152 .find_map(|line| line.strip_prefix("0::"))?
153 .trim()
154 .trim_start_matches('/');
155 Some(Path::new("/sys/fs/cgroup").join(rel))
156}
157
158pub fn tightest_cgroup(leaf: &Path) -> Option<(PathBuf, CgroupMemory)> {
163 let mut best: Option<(PathBuf, CgroupMemory)> = None;
164 let mut dir = Some(leaf);
165 while let Some(d) = dir {
166 if !d.join("memory.max").exists() {
167 break;
168 }
169 if let Some(c) = cgroup_memory_in(d) {
170 if best.as_ref().is_none_or(|(_, b)| c.max < b.max) {
171 best = Some((d.to_path_buf(), c));
172 }
173 }
174 dir = d.parent();
175 }
176 best
177}
178
179pub fn cgroup_memory_in(dir: &Path) -> Option<CgroupMemory> {
182 let max = std::fs::read_to_string(dir.join("memory.max")).ok()?;
183 let max: u64 = match max.trim() {
184 "max" => return None,
185 n => n.parse().ok()?,
186 };
187 let current: u64 = std::fs::read_to_string(dir.join("memory.current"))
188 .ok()?
189 .trim()
190 .parse()
191 .ok()?;
192 let stat = std::fs::read_to_string(dir.join("memory.stat")).unwrap_or_default();
193 let field = |name: &str| -> u64 {
194 stat.lines()
195 .find_map(|l| l.strip_prefix(name).and_then(|r| r.strip_prefix(' ')))
196 .and_then(|v| v.trim().parse().ok())
197 .unwrap_or(0)
198 };
199 let reclaimable = (field("active_file") + field("inactive_file"))
200 .saturating_sub(field("file_dirty") + field("file_writeback"));
201 Some(CgroupMemory {
202 max,
203 current,
204 reclaimable,
205 })
206}
207
208pub fn cgroup_meminfo(c: &CgroupMemory) -> String {
212 format!(
213 "MemTotal: {} kB\nMemAvailable: {} kB\n",
214 c.max / 1024,
215 c.available() / 1024
216 )
217}
218
219pub const fn import_need(objects: u64) -> u64 {
221 IMPORT_FIXED_BYTES + objects * IMPORT_BYTES_PER_OBJECT
222}
223
224pub const fn import_ceiling(limit: u64) -> u64 {
226 limit - limit / 100 * MARGIN_PERCENT
227}
228
229pub fn import_room(path: &str, objects: u64, limit: &MemoryLimit) -> Result<(), String> {
234 let need = import_need(objects);
235 let ceiling = import_ceiling(limit.bytes);
236 if need <= ceiling {
237 return Ok(());
238 }
239 Err(format!(
240 "[import-memory] {path}: the fetched pack has {objects} objects, and indexing it needs \
241 about {} of memory; this server may use {}, and an import keeps {MARGIN_PERCENT} % of \
242 that free, so at most {}. Nothing was imported and the repository was not kept. A \
243 server with more memory imports it.",
244 crate::budget::human(need),
245 limit.words(),
246 crate::budget::human(ceiling),
247 ))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 const GIB: u64 = 1024 * 1024 * 1024;
255 const LINUX_OBJECTS: u64 = 11_988_094;
257
258 fn fake_box(dir: &Path, total: u64) -> PathBuf {
259 let p = dir.join("meminfo");
260 std::fs::write(
261 &p,
262 format!(
263 "MemTotal: {} kB\nMemFree: 1 kB\nMemAvailable: {} kB\n",
264 total / 1024,
265 total / 1024
266 ),
267 )
268 .unwrap();
269 p
270 }
271
272 fn fake_cgroup(dir: &Path, max: &str, current: u64, stat: &str) -> PathBuf {
273 let cg = dir.join("cg");
274 std::fs::create_dir_all(&cg).unwrap();
275 std::fs::write(cg.join("memory.max"), format!("{max}\n")).unwrap();
276 std::fs::write(cg.join("memory.current"), format!("{current}\n")).unwrap();
277 std::fs::write(cg.join("memory.stat"), stat).unwrap();
278 cg
279 }
280
281 #[test]
283 fn a_cgroup_limit_below_the_box_is_the_limit() {
284 let d = tempfile::tempdir().unwrap();
285 let meminfo = fake_box(d.path(), 500 * GIB);
286 let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
287 let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).expect("a limit");
288 assert_eq!(
289 limit.bytes,
290 6 * GIB,
291 "a 6 GiB cgroup on a 500 GiB host may use 6 GiB: {limit:?}"
292 );
293 assert_eq!(limit.source, LimitSource::Cgroup(cg.clone()));
294 assert!(
295 limit.words().contains("cgroup memory.max"),
296 "{}",
297 limit.words()
298 );
299 }
300
301 #[test]
303 fn without_a_cgroup_limit_the_box_is_the_limit() {
304 let d = tempfile::tempdir().unwrap();
305 let meminfo = fake_box(d.path(), 16 * GIB);
306 let cg = fake_cgroup(d.path(), "max", GIB, "");
307 assert_eq!(
308 MemoryLimit::read_from(&meminfo, Some(&cg)),
309 Some(MemoryLimit {
310 bytes: 16 * GIB,
311 source: LimitSource::Box
312 })
313 );
314 let loose = fake_cgroup(d.path(), &(64 * GIB).to_string(), GIB, "");
315 assert_eq!(
316 MemoryLimit::read_from(&meminfo, Some(&loose)).map(|l| l.bytes),
317 Some(16 * GIB)
318 );
319 assert_eq!(
320 MemoryLimit::read_from(&meminfo, None).map(|l| l.source),
321 Some(LimitSource::Box)
322 );
323 }
324
325 #[test]
327 fn linux_fits_the_mini_with_the_margin_and_the_model_covers_the_measurement() {
328 for (measured, what) in [
331 (5172 * 1024 * 1024, "the Mini VM's anonymous peak"),
332 (6_238_872 * 1024, "the anon-rss of the OOM kill"),
333 ] {
334 assert!(
335 import_need(LINUX_OBJECTS) >= measured,
336 "the budget for linux ({}) is below {what} ({})",
337 crate::budget::human(import_need(LINUX_OBJECTS)),
338 crate::budget::human(measured)
339 );
340 }
341 let mini = MemoryLimit {
342 bytes: 16 * GIB,
343 source: LimitSource::Box,
344 };
345 assert_eq!(import_room("alice/linux", LINUX_OBJECTS, &mini), Ok(()));
346 }
347
348 #[test]
351 fn linux_in_a_6g_cgroup_is_refused_by_name_before_the_index() {
352 let d = tempfile::tempdir().unwrap();
353 let meminfo = fake_box(d.path(), 500 * GIB);
354 let cg = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "");
355 let limit = MemoryLimit::read_from(&meminfo, Some(&cg)).unwrap();
356 let err =
357 import_room("alice/linux", LINUX_OBJECTS, &limit).expect_err("must not fit 6 GiB");
358 for word in [
359 "[import-memory]",
360 "alice/linux",
361 "11988094 objects",
362 "6.0G",
363 "cgroup memory.max",
364 "4.8G",
365 ] {
366 assert!(err.contains(word), "the refusal must say {word:?}: {err}");
367 }
368 assert_eq!(import_room("alice/small", 100_000, &limit), Ok(()));
370 }
371
372 #[test]
377 fn a_parent_cgroups_limit_is_the_limit() {
378 let d = tempfile::tempdir().unwrap();
379 let meminfo = fake_box(d.path(), 500 * GIB);
380 let slice = fake_cgroup(d.path(), &(6 * GIB).to_string(), GIB, "anon 1\n");
381 let leaf = fake_cgroup(&slice, "max", GIB, "anon 1\n");
382 let limit = MemoryLimit::read_from(&meminfo, Some(&leaf)).expect("a limit");
383 assert_eq!(limit.bytes, 6 * GIB, "the slice's MemoryMax");
384 assert_eq!(
385 limit.cgroup_dir(),
386 Some(slice.as_path()),
387 "named as the slice's"
388 );
389 assert!(import_room("alice/linux", LINUX_OBJECTS, &limit).is_err());
390 let tight = fake_cgroup(&slice.join("cg"), &(2 * GIB).to_string(), GIB, "anon 1\n");
392 assert_eq!(
393 MemoryLimit::read_from(&meminfo, Some(&tight))
394 .unwrap()
395 .bytes,
396 2 * GIB
397 );
398 }
399
400 #[test]
402 fn a_4g_box_refuses_linux() {
403 let twin = MemoryLimit {
404 bytes: 4 * GIB,
405 source: LimitSource::Box,
406 };
407 let err = import_room("alice/linux", LINUX_OBJECTS, &twin).unwrap_err();
408 assert!(err.contains("MemTotal"), "{err}");
409 }
410
411 #[test]
414 fn the_cgroup_meminfo_is_the_cgroups_memory() {
415 let d = tempfile::tempdir().unwrap();
416 let cg = fake_cgroup(
417 d.path(),
418 &(6 * GIB).to_string(),
419 6 * GIB - 100 * 1024 * 1024,
420 "anon 5000000000\nactive_file 209715200\ninactive_file 104857600\nfile_dirty 104857600\nfile_writeback 0\n",
421 );
422 let c = cgroup_memory_in(&cg).expect("a limited cgroup");
423 assert_eq!(c.available(), 300 * 1024 * 1024);
425 let text = cgroup_meminfo(&c);
426 assert_eq!(
427 crate::dashboard::probe::parse_meminfo(&text),
428 Some((6 * GIB, 300 * 1024 * 1024)),
429 "{text}"
430 );
431 assert_eq!(cgroup_memory_in(&fake_cgroup(d.path(), "max", 1, "")), None);
432 }
433
434 }