ifc_lite_server/mem_policy.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Memory-budget policy: the readers and the resolver that decide the
6//! admission byte budget at startup.
7//!
8//! The byte-weighted admission semaphore (see [`crate::admission`]) is the
9//! load-bearing bound against OOM from concurrent large uploads, but it only
10//! engages when the budget is non-zero. Previously the budget was derived from
11//! the cgroup limit alone, so a bare VM / non-containerized deployment (no
12//! readable cgroup ceiling) resolved to `0` and silently degraded to a bare
13//! CPU-concurrency count - the exact failure the admission work set out to
14//! fix. This module adds a physical-RAM fallback so the memory gate stays
15//! active without a cgroup, and keeps the three ceilings + the 70% math in one
16//! place the resolver tests exercise.
17//!
18//! No `sysinfo`/libc dependency: the readers parse `/proc` directly, matching
19//! [`crate::admission::resident_bytes_now`]; the release matrix includes a musl
20//! target where a heavy cross-platform crate is a known build risk.
21
22/// Fraction of the tightest readable memory ceiling to admit, leaving headroom
23/// for the allocator, the OS page cache, and non-parse allocations.
24const BUDGET_PCT_OF_CEILING: u64 = 70;
25
26/// Container memory limit from cgroups (v2 then v1), if readable.
27///
28/// `None` when unlimited or unreadable (non-Linux, or a bare host outside a
29/// cgroup). Moved here from `admission` so all memory-ceiling readers live
30/// beside the resolver.
31pub fn cgroup_memory_limit_bytes() -> Option<u64> {
32 for path in [
33 "/sys/fs/cgroup/memory.max",
34 "/sys/fs/cgroup/memory/memory.limit_in_bytes",
35 ] {
36 if let Ok(raw) = std::fs::read_to_string(path) {
37 match parse_cgroup_limit(&raw) {
38 CgroupLimit::Unlimited => return None,
39 CgroupLimit::Bytes(v) => return Some(v),
40 CgroupLimit::Invalid => {} // unreadable value: try the next path
41 }
42 }
43 }
44 None
45}
46
47/// Parsed outcome of one cgroup memory-limit file. `Unlimited` (`max`, or a
48/// v1 huge sentinel) stops the search - the ceiling is genuinely uncapped, so we
49/// must NOT fall back to another file. `Invalid` lets the caller try the next.
50#[derive(Debug, PartialEq, Eq)]
51enum CgroupLimit {
52 Unlimited,
53 Bytes(u64),
54 Invalid,
55}
56
57fn parse_cgroup_limit(raw: &str) -> CgroupLimit {
58 let raw = raw.trim();
59 if raw == "max" {
60 return CgroupLimit::Unlimited;
61 }
62 match raw.parse::<u64>() {
63 // cgroup v1 reports a huge sentinel (~i64::MAX) when unlimited.
64 Ok(v) if v >= (1 << 60) => CgroupLimit::Unlimited,
65 Ok(v) if v > 0 => CgroupLimit::Bytes(v),
66 _ => CgroupLimit::Invalid,
67 }
68}
69
70/// Total physical RAM in bytes from `/proc/meminfo` `MemTotal`, if readable.
71///
72/// `None` on non-Linux and on any parse failure (never panics). This is the
73/// fallback ceiling when no cgroup limit is set, so a bare Linux VM still gets
74/// a bounded memory gate instead of an unbounded one. NOTE: `MemTotal` is
75/// HOST-wide - in a container with no cgroup limit this over-states the
76/// container-usable RAM (see OPERATIONS.md).
77pub fn total_physical_memory_bytes() -> Option<u64> {
78 #[cfg(target_os = "linux")]
79 {
80 Some(parse_meminfo_memtotal_kb(&std::fs::read_to_string("/proc/meminfo").ok()?)? * 1024)
81 }
82 #[cfg(not(target_os = "linux"))]
83 {
84 None
85 }
86}
87
88/// Extract `MemTotal` (in kB) from `/proc/meminfo` content, or `None`. Pure so
89/// the parse - the only place a wrong line/unit would size every bare-VM budget
90/// wrong - is unit-tested without a live `/proc`.
91// Only called from the Linux branch of `total_physical_memory_bytes`; the unit
92// test exercises it on every target, but the non-Linux *binary* never calls it.
93#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
94fn parse_meminfo_memtotal_kb(meminfo: &str) -> Option<u64> {
95 // `MemTotal: 16305200 kB` - must match MemTotal, not MemFree/MemAvailable.
96 meminfo
97 .lines()
98 .find_map(|line| line.strip_prefix("MemTotal:"))?
99 .split_whitespace()
100 .next()?
101 .parse::<u64>()
102 .ok()
103}
104
105/// Resolve the admission memory budget (MB) from the three inputs, in one place
106/// the tests exercise so it can never silently regress to 0-when-a-ceiling-is-
107/// readable.
108///
109/// Precedence:
110/// 1. An explicit `IFC_MEM_BUDGET_MB` wins outright. `Some(0)` is honored as a
111/// deliberate opt-out (memory gate off) - it is NOT treated as "unset".
112/// 2. Otherwise, 70% of the tightest readable ceiling: `min(cgroup, physical)`.
113/// Using the tighter of the two keeps a small cgroup from being overridden
114/// by a large host, and lets physical RAM bound a bare VM with no cgroup.
115/// 3. Otherwise `0` (gate off) - only when neither ceiling is readable
116/// (non-Linux dev, or `/proc` unavailable), which the caller logs loudly.
117pub fn resolve_mem_budget_mb(
118 explicit_mb: Option<usize>,
119 cgroup_bytes: Option<u64>,
120 physical_bytes: Option<u64>,
121) -> usize {
122 if let Some(mb) = explicit_mb {
123 return mb;
124 }
125 match [cgroup_bytes, physical_bytes].into_iter().flatten().min() {
126 Some(ceiling) => (ceiling / (1024 * 1024) * BUDGET_PCT_OF_CEILING / 100) as usize,
127 None => 0,
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 const GB: u64 = 1024 * 1024 * 1024;
136
137 #[test]
138 fn parse_cgroup_limit_handles_max_sentinel_and_values() {
139 assert_eq!(parse_cgroup_limit("max\n"), CgroupLimit::Unlimited);
140 // cgroup v1 unlimited sentinel (~i64::MAX, >= 1<<60).
141 assert_eq!(parse_cgroup_limit("9223372036854771712\n"), CgroupLimit::Unlimited);
142 assert_eq!(parse_cgroup_limit(" 2147483648 "), CgroupLimit::Bytes(2_147_483_648));
143 assert_eq!(parse_cgroup_limit("0"), CgroupLimit::Invalid);
144 assert_eq!(parse_cgroup_limit("garbage"), CgroupLimit::Invalid);
145 assert_eq!(parse_cgroup_limit(""), CgroupLimit::Invalid);
146 }
147
148 /// The v1 "unlimited" sentinel check (`v >= 1 << 60`) must trigger AT the
149 /// boundary itself, not just for values far above it — a value exactly at
150 /// `1 << 60` is exactly what a `>` (instead of `>=`) comparison would miss.
151 #[test]
152 fn parse_cgroup_limit_treats_exactly_one_shl_60_as_unlimited() {
153 let boundary = (1u64 << 60).to_string();
154 assert_eq!(parse_cgroup_limit(&boundary), CgroupLimit::Unlimited);
155 // One byte below the boundary must still be a normal, finite limit.
156 let just_below = ((1u64 << 60) - 1).to_string();
157 assert_eq!(
158 parse_cgroup_limit(&just_below),
159 CgroupLimit::Bytes((1u64 << 60) - 1)
160 );
161 }
162
163 #[test]
164 fn parse_meminfo_picks_memtotal_in_kb_only() {
165 let meminfo = "MemTotal: 16305200 kB\n\
166 MemFree: 1234567 kB\n\
167 MemAvailable: 9876543 kB\n";
168 assert_eq!(parse_meminfo_memtotal_kb(meminfo), Some(16_305_200));
169 // Must NOT match MemFree/MemAvailable, and tolerate missing/garbage.
170 assert_eq!(parse_meminfo_memtotal_kb("MemFree: 100 kB\n"), None);
171 assert_eq!(parse_meminfo_memtotal_kb("MemTotal: notanumber kB"), None);
172 assert_eq!(parse_meminfo_memtotal_kb(""), None);
173 }
174
175
176 #[test]
177 fn explicit_budget_wins_over_ceilings() {
178 assert_eq!(
179 resolve_mem_budget_mb(Some(4096), Some(10 * GB), Some(32 * GB)),
180 4096
181 );
182 }
183
184 #[test]
185 fn explicit_zero_is_honored_as_opt_out() {
186 // Some(0) is a deliberate "turn the gate off", not "unset" - it must
187 // NOT fall through to a ceiling-derived budget.
188 assert_eq!(resolve_mem_budget_mb(Some(0), Some(10 * GB), Some(32 * GB)), 0);
189 }
190
191 #[test]
192 fn uses_tightest_readable_ceiling() {
193 // 70% of the tighter (cgroup) ceiling, not the host.
194 assert_eq!(
195 resolve_mem_budget_mb(None, Some(10 * GB), Some(32 * GB)),
196 10 * 1024 * 70 / 100,
197 );
198 }
199
200 #[test]
201 fn physical_ram_bounds_a_bare_vm_with_no_cgroup() {
202 // The load-bearing fix: no cgroup, but readable physical RAM -> a
203 // NON-ZERO budget, so the memory gate stays active on a bare VM.
204 let mb = resolve_mem_budget_mb(None, None, Some(16 * GB));
205 assert_eq!(mb, 16 * 1024 * 70 / 100);
206 assert!(mb > 0);
207 }
208
209 #[test]
210 fn no_readable_ceiling_disables_the_gate() {
211 assert_eq!(resolve_mem_budget_mb(None, None, None), 0);
212 }
213
214 #[cfg(target_os = "linux")]
215 #[test]
216 fn physical_memory_is_readable_on_linux() {
217 // On any Linux host (incl. CI) /proc/meminfo yields a positive total.
218 let total = total_physical_memory_bytes();
219 assert!(total.is_some_and(|b| b > 0));
220 }
221}