1use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
10
11static PEAK_RSS: AtomicU64 = AtomicU64::new(0);
12static GUARD_RUNNING: AtomicBool = AtomicBool::new(false);
13static ABORT_REQUESTED: AtomicBool = AtomicBool::new(false);
14static CURRENT_PRESSURE: AtomicU8 = AtomicU8::new(0);
15
16pub fn get_rss_bytes() -> Option<u64> {
18 #[cfg(target_os = "linux")]
19 {
20 linux_rss()
21 }
22 #[cfg(target_os = "macos")]
23 {
24 macos_rss()
25 }
26 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
27 {
28 None
29 }
30}
31
32pub fn get_rss_bytes_for_pid(pid: u32) -> Option<u64> {
34 #[cfg(target_os = "linux")]
35 {
36 linux_rss_for_pid(pid)
37 }
38 #[cfg(target_os = "macos")]
39 {
40 macos_rss_for_pid(pid)
41 }
42 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
43 {
44 let _ = pid;
45 None
46 }
47}
48
49pub fn get_system_ram_bytes() -> Option<u64> {
51 #[cfg(target_os = "linux")]
52 {
53 linux_memtotal()
54 }
55 #[cfg(target_os = "macos")]
56 {
57 macos_memsize()
58 }
59 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
60 {
61 None
62 }
63}
64
65pub fn rss_limit_bytes() -> Option<u64> {
67 let sys_ram = get_system_ram_bytes()?;
68 let cfg = super::config::Config::load();
69 let pct = super::config::MemoryGuardConfig::effective(&cfg).max_ram_percent;
70 Some(sys_ram / 100 * u64::from(pct))
71}
72
73pub(crate) fn adaptive_batch_size(
78 min_files: usize,
79 max_files: usize,
80 estimated_bytes_per_file: u64,
81) -> usize {
82 let headroom = match (rss_limit_bytes(), get_rss_bytes()) {
83 (Some(limit), Some(rss)) => hard_headroom_bytes(limit, rss),
84 _ => return max_files.max(1),
85 };
86 batch_size_for_headroom(headroom, min_files, max_files, estimated_bytes_per_file)
87}
88
89fn hard_headroom_bytes(base_limit: u64, rss: u64) -> u64 {
90 base_limit
91 .saturating_mul(3)
92 .saturating_div(2)
93 .saturating_sub(rss)
94}
95
96fn batch_size_for_headroom(
97 headroom_bytes: u64,
98 min_files: usize,
99 max_files: usize,
100 estimated_bytes_per_file: u64,
101) -> usize {
102 let min_files = min_files.max(1);
103 let max_files = max_files.max(min_files);
104 let estimate = estimated_bytes_per_file.max(1);
105 let by_headroom = (headroom_bytes / estimate).min(max_files as u64) as usize;
106 by_headroom.clamp(min_files, max_files)
107}
108
109#[cfg(test)]
110mod adaptive_batch_tests {
111 use super::{batch_size_for_headroom, hard_headroom_bytes};
112
113 #[test]
114 fn hard_headroom_uses_guardian_hard_threshold() {
115 assert_eq!(hard_headroom_bytes(1_000, 1_100), 400);
116 assert_eq!(hard_headroom_bytes(1_000, 1_500), 0);
117 assert_eq!(hard_headroom_bytes(1_000, 2_000), 0);
118 }
119
120 #[test]
121 fn batch_size_tracks_headroom_and_bounds() {
122 assert_eq!(batch_size_for_headroom(1_000, 1, 500, 100), 10);
123 assert_eq!(batch_size_for_headroom(0, 1, 500, 100), 1);
124 assert_eq!(batch_size_for_headroom(100_000, 1, 500, 100), 500);
125 }
126
127 #[test]
128 fn batch_size_sanitizes_zero_bounds_and_estimate() {
129 assert_eq!(batch_size_for_headroom(0, 0, 0, 0), 1);
130 }
131}
132
133pub fn peak_rss_bytes() -> u64 {
135 PEAK_RSS.load(Ordering::Relaxed)
136}
137
138#[derive(Debug, Clone, serde::Serialize)]
140pub struct MemorySnapshot {
141 pub rss_bytes: u64,
142 pub peak_rss_bytes: u64,
143 pub system_ram_bytes: u64,
144 pub rss_limit_bytes: u64,
145 pub rss_percent: f64,
146 pub pressure_level: PressureLevel,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
150#[serde(rename_all = "lowercase")]
151#[repr(u8)]
152pub enum PressureLevel {
153 Normal = 0,
154 Soft = 1,
155 Medium = 2,
156 Hard = 3,
157 Critical = 4,
158}
159
160impl PressureLevel {
161 fn from_u8(v: u8) -> Self {
162 match v {
163 1 => Self::Soft,
164 2 => Self::Medium,
165 3 => Self::Hard,
166 4 => Self::Critical,
167 _ => Self::Normal,
168 }
169 }
170}
171
172impl MemorySnapshot {
173 pub fn capture() -> Option<Self> {
175 Self::capture_impl(get_rss_bytes()?)
176 }
177
178 pub fn capture_for_pid(pid: u32) -> Option<Self> {
181 let rss = get_rss_bytes_for_pid(pid).or_else(get_rss_bytes)?;
182 Self::capture_impl(rss)
183 }
184
185 fn capture_impl(rss: u64) -> Option<Self> {
186 let sys = get_system_ram_bytes()?;
187 let limit = rss_limit_bytes()?;
188 let pct = if sys > 0 {
189 (rss as f64 / sys as f64) * 100.0
190 } else {
191 0.0
192 };
193
194 PEAK_RSS.fetch_max(rss, Ordering::Relaxed);
195
196 let cfg = super::config::Config::load();
197 let guard_cfg = super::config::MemoryGuardConfig::effective(&cfg);
198 let base = f64::from(guard_cfg.max_ram_percent);
199
200 let level = if pct > base * 2.0 {
206 PressureLevel::Critical
207 } else if pct > base * 1.5 {
208 PressureLevel::Hard
209 } else if pct > base * 1.2 {
210 PressureLevel::Medium
211 } else if pct > base {
212 PressureLevel::Soft
213 } else {
214 PressureLevel::Normal
215 };
216
217 Some(Self {
218 rss_bytes: rss,
219 peak_rss_bytes: PEAK_RSS.load(Ordering::Relaxed),
220 system_ram_bytes: sys,
221 rss_limit_bytes: limit,
222 rss_percent: pct,
223 pressure_level: level,
224 })
225 }
226}
227
228pub fn jemalloc_purge() {
232 #[cfg(all(feature = "jemalloc", not(windows)))]
233 {
234 use tikv_jemalloc_ctl::raw;
235 let purge_mib = b"arena.4096.purge\0";
236 unsafe {
240 if let Err(e) = raw::write(purge_mib, 0u64) {
241 tracing::debug!("[memory_guard] jemalloc purge failed: {e}");
242 }
243 }
244 }
245}
246
247pub fn abort_requested() -> bool {
249 ABORT_REQUESTED.load(Ordering::Relaxed)
250}
251
252pub fn is_under_pressure() -> bool {
255 current_pressure() >= PressureLevel::Soft
256}
257
258pub fn current_pressure() -> PressureLevel {
260 PressureLevel::from_u8(CURRENT_PRESSURE.load(Ordering::Relaxed))
261}
262
263pub fn start_guard(eviction_callback: Arc<dyn Fn(PressureLevel) + Send + Sync>) {
268 if cfg!(test) {
281 return;
282 }
283 if GUARD_RUNNING.swap(true, Ordering::SeqCst) {
284 return;
285 }
286 std::thread::Builder::new()
287 .name("memory-guard".into())
288 .spawn(move || {
289 const CALM_TICKS_BEFORE_BACKOFF: u64 = 5;
296 const IDLE_POLL_SECS: u64 = 15;
297 let mut poll_secs = 3u64;
298 let mut calm_ticks = 0u64;
299
300 if let Some(snap) = MemorySnapshot::capture() {
303 CURRENT_PRESSURE.store(snap.pressure_level as u8, Ordering::Relaxed);
304 if snap.pressure_level >= PressureLevel::Soft {
305 eviction_callback(snap.pressure_level);
306 }
307 }
308
309 loop {
310 std::thread::sleep(std::time::Duration::from_secs(poll_secs));
311 let Some(snap) = MemorySnapshot::capture() else {
312 continue;
313 };
314
315 CURRENT_PRESSURE.store(snap.pressure_level as u8, Ordering::Relaxed);
316
317 if snap.pressure_level == PressureLevel::Critical {
318 tracing::error!(
319 "[memory_guard] CRITICAL: RSS={:.0}MB ({:.1}% of {:.0}GB) — \
320 aggressive eviction to prevent OS OOM kill",
321 snap.rss_bytes as f64 / 1_048_576.0,
322 snap.rss_percent,
323 snap.system_ram_bytes as f64 / 1_073_741_824.0,
324 );
325 ABORT_REQUESTED.store(true, Ordering::SeqCst);
326 (eviction_callback)(PressureLevel::Critical);
327 jemalloc_purge();
328
329 for attempt in 1..=3 {
330 std::thread::sleep(std::time::Duration::from_secs(2));
331 (eviction_callback)(PressureLevel::Critical);
332 jemalloc_purge();
333 if let Some(recheck) = MemorySnapshot::capture() {
334 if recheck.pressure_level < PressureLevel::Hard {
335 tracing::info!(
336 "[memory_guard] eviction attempt {attempt} succeeded — \
337 RSS={:.0}MB, pressure={:?}",
338 recheck.rss_bytes as f64 / 1_048_576.0,
339 recheck.pressure_level,
340 );
341 break;
342 }
343 tracing::error!(
344 "[memory_guard] eviction attempt {attempt}/3 — still {:?} \
345 (RSS={:.0}MB)",
346 recheck.pressure_level,
347 recheck.rss_bytes as f64 / 1_048_576.0,
348 );
349 }
350 }
351 }
352
353 if snap.pressure_level >= PressureLevel::Soft {
354 poll_secs = 1;
355 calm_ticks = 0;
356 ABORT_REQUESTED
357 .store(snap.pressure_level >= PressureLevel::Hard, Ordering::SeqCst);
358 tracing::warn!(
359 "[memory_guard] pressure={:?} RSS={:.0}MB limit={:.0}MB ({:.1}% of {:.0}GB)",
360 snap.pressure_level,
361 snap.rss_bytes as f64 / 1_048_576.0,
362 snap.rss_limit_bytes as f64 / 1_048_576.0,
363 snap.rss_percent,
364 snap.system_ram_bytes as f64 / 1_073_741_824.0,
365 );
366 (eviction_callback)(snap.pressure_level);
367
368 if snap.pressure_level >= PressureLevel::Hard {
369 jemalloc_purge();
370 }
371 } else {
372 calm_ticks = calm_ticks.saturating_add(1);
373 poll_secs = if calm_ticks >= CALM_TICKS_BEFORE_BACKOFF {
374 IDLE_POLL_SECS
375 } else {
376 3
377 };
378 if ABORT_REQUESTED.load(Ordering::Relaxed) {
379 ABORT_REQUESTED.store(false, Ordering::SeqCst);
380 tracing::info!("[memory_guard] pressure normalized, clearing abort flag");
381 }
382 }
383 }
384 })
385 .ok();
386}
387
388pub fn force_purge() {
390 jemalloc_purge();
391 tracing::info!("[memory_guard] force_purge completed");
392}
393
394#[cfg(target_os = "linux")]
397fn linux_rss() -> Option<u64> {
398 linux_rss_for_pid(std::process::id())
399}
400
401#[cfg(target_os = "linux")]
402fn linux_rss_for_pid(pid: u32) -> Option<u64> {
403 let path = format!("/proc/{pid}/status");
404 let status = std::fs::read_to_string(path).ok()?;
405 for line in status.lines() {
406 if let Some(val) = line.strip_prefix("VmRSS:") {
407 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
408 return Some(kb * 1024);
409 }
410 }
411 None
412}
413
414#[cfg(target_os = "linux")]
415fn linux_memtotal() -> Option<u64> {
416 let info = std::fs::read_to_string("/proc/meminfo").ok()?;
417 for line in info.lines() {
418 if let Some(val) = line.strip_prefix("MemTotal:") {
419 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
420 return Some(kb * 1024);
421 }
422 }
423 None
424}
425
426#[cfg(target_os = "macos")]
427#[allow(deprecated, clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
428fn macos_rss() -> Option<u64> {
429 use std::mem;
430 let mut info: libc::mach_task_basic_info_data_t = unsafe { mem::zeroed() };
433 let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
434 / mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
435 let kr = unsafe {
439 libc::task_info(
440 libc::mach_task_self(),
441 libc::MACH_TASK_BASIC_INFO,
442 std::ptr::from_mut(&mut info).cast::<i32>(),
443 std::ptr::from_mut(&mut count),
444 )
445 };
446 if kr == libc::KERN_SUCCESS {
447 Some(info.resident_size)
448 } else {
449 None
450 }
451}
452
453#[cfg(target_os = "macos")]
454fn macos_rss_for_pid(pid: u32) -> Option<u64> {
455 let output = std::process::Command::new("ps")
458 .args(["-o", "rss=", "-p", &pid.to_string()])
459 .output()
460 .ok()?;
461 if !output.status.success() {
462 return None;
463 }
464 let text = String::from_utf8_lossy(&output.stdout);
465 let kb: u64 = text.trim().parse().ok()?;
466 Some(kb * 1024)
467}
468
469#[cfg(target_os = "macos")]
470#[allow(clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
471fn macos_memsize() -> Option<u64> {
472 use std::mem;
473 let mut memsize: u64 = 0;
474 let mut len = mem::size_of::<u64>();
475 let name = b"hw.memsize\0";
476 let ret = unsafe {
479 libc::sysctlbyname(
480 name.as_ptr().cast(),
481 std::ptr::from_mut(&mut memsize).cast::<libc::c_void>(),
482 std::ptr::from_mut(&mut len),
483 std::ptr::null_mut(),
484 0,
485 )
486 };
487 if ret == 0 { Some(memsize) } else { None }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn rss_returns_some_on_supported_os() {
496 if cfg!(any(target_os = "linux", target_os = "macos")) {
497 let rss = get_rss_bytes();
498 assert!(rss.is_some(), "RSS should be readable");
499 assert!(rss.unwrap() > 0, "RSS should be > 0");
500 }
501 }
502
503 #[test]
504 fn system_ram_returns_some_on_supported_os() {
505 if cfg!(any(target_os = "linux", target_os = "macos")) {
506 let ram = get_system_ram_bytes();
507 assert!(ram.is_some(), "System RAM should be readable");
508 assert!(ram.unwrap() > 1_000_000, "System RAM should be > 1MB");
509 }
510 }
511
512 #[test]
513 fn snapshot_captures_correctly() {
514 if cfg!(any(target_os = "linux", target_os = "macos")) {
515 let snap = MemorySnapshot::capture();
516 assert!(snap.is_some());
517 let s = snap.unwrap();
518 assert!(s.rss_bytes > 0);
519 assert!(s.system_ram_bytes > s.rss_bytes);
520 assert!(s.rss_percent > 0.0 && s.rss_percent < 100.0);
521 }
522 }
523
524 #[test]
525 fn peak_rss_tracks_maximum() {
526 PEAK_RSS.store(0, Ordering::Relaxed);
527 PEAK_RSS.fetch_max(100, Ordering::Relaxed);
528 PEAK_RSS.fetch_max(50, Ordering::Relaxed);
529 assert_eq!(PEAK_RSS.load(Ordering::Relaxed), 100);
530 }
531
532 #[test]
533 fn pressure_level_roundtrip() {
534 for level in [
535 PressureLevel::Normal,
536 PressureLevel::Soft,
537 PressureLevel::Medium,
538 PressureLevel::Hard,
539 PressureLevel::Critical,
540 ] {
541 assert_eq!(PressureLevel::from_u8(level as u8), level);
542 }
543 }
544
545 #[test]
546 fn atomic_pressure_defaults_to_normal() {
547 assert_eq!(current_pressure(), PressureLevel::Normal);
548 }
549
550 #[test]
551 fn start_guard_is_noop_under_test() {
552 let fired = Arc::new(AtomicBool::new(false));
558 let fired_cb = fired.clone();
559 start_guard(Arc::new(move |_| fired_cb.store(true, Ordering::SeqCst)));
560
561 assert!(
562 !GUARD_RUNNING.load(Ordering::Relaxed),
563 "guardian thread must not start under cfg!(test)"
564 );
565 assert_eq!(current_pressure(), PressureLevel::Normal);
566 assert!(!abort_requested());
567 assert!(
568 !fired.load(Ordering::Relaxed),
569 "eviction callback must never fire in tests"
570 );
571 }
572
573 #[test]
574 fn rss_for_own_pid_matches_self() {
575 if cfg!(any(target_os = "linux", target_os = "macos")) {
576 let self_rss = get_rss_bytes().unwrap();
577 let pid_rss = get_rss_bytes_for_pid(std::process::id()).unwrap();
578 let ratio = self_rss as f64 / pid_rss as f64;
579 assert!(
580 (0.5..2.0).contains(&ratio),
581 "self RSS ({self_rss}) and pid-based RSS ({pid_rss}) should be within 2x"
582 );
583 }
584 }
585
586 #[test]
587 fn rss_for_dead_pid_returns_none() {
588 let dead_pid = 999_999_999u32;
589 assert!(get_rss_bytes_for_pid(dead_pid).is_none());
590 }
591
592 #[test]
593 fn capture_for_pid_falls_back_on_dead_pid() {
594 if cfg!(any(target_os = "linux", target_os = "macos")) {
595 let snap = MemorySnapshot::capture_for_pid(999_999_999);
596 assert!(snap.is_some(), "should fall back to self RSS");
597 }
598 }
599}