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
263#[inline]
264const fn pressure_requests_abort(level: PressureLevel) -> bool {
265 level as u8 >= PressureLevel::Hard as u8
266}
267
268#[inline]
269fn publish_pressure(level: PressureLevel) {
270 CURRENT_PRESSURE.store(level as u8, Ordering::Relaxed);
271 ABORT_REQUESTED.store(pressure_requests_abort(level), Ordering::SeqCst);
272}
273
274pub fn start_guard(eviction_callback: Arc<dyn Fn(PressureLevel) + Send + Sync>) {
279 if cfg!(test) {
292 return;
293 }
294 if GUARD_RUNNING.swap(true, Ordering::SeqCst) {
295 return;
296 }
297 std::thread::Builder::new()
298 .name("memory-guard".into())
299 .spawn(move || {
300 const CALM_TICKS_BEFORE_BACKOFF: u64 = 5;
307 const IDLE_POLL_SECS: u64 = 15;
308 let mut poll_secs = 3u64;
309 let mut calm_ticks = 0u64;
310
311 if let Some(snap) = MemorySnapshot::capture() {
314 publish_pressure(snap.pressure_level);
315 if snap.pressure_level >= PressureLevel::Soft {
316 eviction_callback(snap.pressure_level);
317 }
318 }
319
320 loop {
321 std::thread::sleep(std::time::Duration::from_secs(poll_secs));
322 let Some(snap) = MemorySnapshot::capture() else {
323 continue;
324 };
325
326 publish_pressure(snap.pressure_level);
327
328 if snap.pressure_level == PressureLevel::Critical {
329 tracing::error!(
330 "[memory_guard] CRITICAL: RSS={:.0}MB ({:.1}% of {:.0}GB) — \
331 aggressive eviction to prevent OS OOM kill",
332 snap.rss_bytes as f64 / 1_048_576.0,
333 snap.rss_percent,
334 snap.system_ram_bytes as f64 / 1_073_741_824.0,
335 );
336 (eviction_callback)(PressureLevel::Critical);
337 jemalloc_purge();
338
339 for attempt in 1..=3 {
340 std::thread::sleep(std::time::Duration::from_secs(2));
341 (eviction_callback)(PressureLevel::Critical);
342 jemalloc_purge();
343 if let Some(recheck) = MemorySnapshot::capture() {
344 if recheck.pressure_level < PressureLevel::Hard {
345 tracing::info!(
346 "[memory_guard] eviction attempt {attempt} succeeded — \
347 RSS={:.0}MB, pressure={:?}",
348 recheck.rss_bytes as f64 / 1_048_576.0,
349 recheck.pressure_level,
350 );
351 break;
352 }
353 tracing::error!(
354 "[memory_guard] eviction attempt {attempt}/3 — still {:?} \
355 (RSS={:.0}MB)",
356 recheck.pressure_level,
357 recheck.rss_bytes as f64 / 1_048_576.0,
358 );
359 }
360 }
361 }
362
363 if snap.pressure_level >= PressureLevel::Soft {
364 poll_secs = 1;
365 calm_ticks = 0;
366 tracing::warn!(
367 "[memory_guard] pressure={:?} RSS={:.0}MB limit={:.0}MB ({:.1}% of {:.0}GB)",
368 snap.pressure_level,
369 snap.rss_bytes as f64 / 1_048_576.0,
370 snap.rss_limit_bytes as f64 / 1_048_576.0,
371 snap.rss_percent,
372 snap.system_ram_bytes as f64 / 1_073_741_824.0,
373 );
374 (eviction_callback)(snap.pressure_level);
375
376 if snap.pressure_level >= PressureLevel::Hard {
377 jemalloc_purge();
378 }
379 } else {
380 calm_ticks = calm_ticks.saturating_add(1);
381 poll_secs = if calm_ticks >= CALM_TICKS_BEFORE_BACKOFF {
382 IDLE_POLL_SECS
383 } else {
384 3
385 };
386 }
387 }
388 })
389 .ok();
390}
391
392pub fn force_purge() {
394 jemalloc_purge();
395 tracing::info!("[memory_guard] force_purge completed");
396}
397
398#[cfg(target_os = "linux")]
401fn linux_rss() -> Option<u64> {
402 linux_rss_for_pid(std::process::id())
403}
404
405#[cfg(target_os = "linux")]
406fn linux_rss_for_pid(pid: u32) -> Option<u64> {
407 let path = format!("/proc/{pid}/status");
408 let status = std::fs::read_to_string(path).ok()?;
409 for line in status.lines() {
410 if let Some(val) = line.strip_prefix("VmRSS:") {
411 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
412 return Some(kb * 1024);
413 }
414 }
415 None
416}
417
418#[cfg(target_os = "linux")]
419fn linux_memtotal() -> Option<u64> {
420 let info = std::fs::read_to_string("/proc/meminfo").ok()?;
421 for line in info.lines() {
422 if let Some(val) = line.strip_prefix("MemTotal:") {
423 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
424 return Some(kb * 1024);
425 }
426 }
427 None
428}
429
430#[cfg(target_os = "macos")]
431#[allow(deprecated, clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
432fn macos_rss() -> Option<u64> {
433 use std::mem;
434 let mut info: libc::mach_task_basic_info_data_t = unsafe { mem::zeroed() };
437 let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
438 / mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
439 let kr = unsafe {
443 libc::task_info(
444 libc::mach_task_self(),
445 libc::MACH_TASK_BASIC_INFO,
446 std::ptr::from_mut(&mut info).cast::<i32>(),
447 std::ptr::from_mut(&mut count),
448 )
449 };
450 if kr == libc::KERN_SUCCESS {
451 Some(info.resident_size)
452 } else {
453 None
454 }
455}
456
457#[cfg(target_os = "macos")]
458fn macos_rss_for_pid(pid: u32) -> Option<u64> {
459 let output = std::process::Command::new("ps")
462 .args(["-o", "rss=", "-p", &pid.to_string()])
463 .output()
464 .ok()?;
465 if !output.status.success() {
466 return None;
467 }
468 let text = String::from_utf8_lossy(&output.stdout);
469 let kb: u64 = text.trim().parse().ok()?;
470 Some(kb * 1024)
471}
472
473#[cfg(target_os = "macos")]
474#[allow(clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
475fn macos_memsize() -> Option<u64> {
476 use std::mem;
477 let mut memsize: u64 = 0;
478 let mut len = mem::size_of::<u64>();
479 let name = b"hw.memsize\0";
480 let ret = unsafe {
483 libc::sysctlbyname(
484 name.as_ptr().cast(),
485 std::ptr::from_mut(&mut memsize).cast::<libc::c_void>(),
486 std::ptr::from_mut(&mut len),
487 std::ptr::null_mut(),
488 0,
489 )
490 };
491 if ret == 0 { Some(memsize) } else { None }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn rss_returns_some_on_supported_os() {
500 if cfg!(any(target_os = "linux", target_os = "macos")) {
501 let rss = get_rss_bytes();
502 assert!(rss.is_some(), "RSS should be readable");
503 assert!(rss.unwrap() > 0, "RSS should be > 0");
504 }
505 }
506
507 #[test]
508 fn system_ram_returns_some_on_supported_os() {
509 if cfg!(any(target_os = "linux", target_os = "macos")) {
510 let ram = get_system_ram_bytes();
511 assert!(ram.is_some(), "System RAM should be readable");
512 assert!(ram.unwrap() > 1_000_000, "System RAM should be > 1MB");
513 }
514 }
515
516 #[test]
517 fn snapshot_captures_correctly() {
518 if cfg!(any(target_os = "linux", target_os = "macos")) {
519 let snap = MemorySnapshot::capture();
520 assert!(snap.is_some());
521 let s = snap.unwrap();
522 assert!(s.rss_bytes > 0);
523 assert!(s.system_ram_bytes > s.rss_bytes);
524 assert!(s.rss_percent > 0.0 && s.rss_percent < 100.0);
525 }
526 }
527
528 #[test]
529 fn peak_rss_tracks_maximum() {
530 PEAK_RSS.store(0, Ordering::Relaxed);
531 PEAK_RSS.fetch_max(100, Ordering::Relaxed);
532 PEAK_RSS.fetch_max(50, Ordering::Relaxed);
533 assert_eq!(PEAK_RSS.load(Ordering::Relaxed), 100);
534 }
535
536 #[test]
537 fn pressure_level_roundtrip() {
538 for level in [
539 PressureLevel::Normal,
540 PressureLevel::Soft,
541 PressureLevel::Medium,
542 PressureLevel::Hard,
543 PressureLevel::Critical,
544 ] {
545 assert_eq!(PressureLevel::from_u8(level as u8), level);
546 }
547 }
548
549 #[test]
550 fn hard_pressure_requests_abort_immediately() {
551 assert!(!pressure_requests_abort(PressureLevel::Normal));
552 assert!(!pressure_requests_abort(PressureLevel::Soft));
553 assert!(!pressure_requests_abort(PressureLevel::Medium));
554 assert!(pressure_requests_abort(PressureLevel::Hard));
555 assert!(pressure_requests_abort(PressureLevel::Critical));
556 }
557
558 #[test]
559 fn atomic_pressure_defaults_to_normal() {
560 assert_eq!(current_pressure(), PressureLevel::Normal);
561 }
562
563 #[test]
564 fn start_guard_is_noop_under_test() {
565 let fired = Arc::new(AtomicBool::new(false));
571 let fired_cb = fired.clone();
572 start_guard(Arc::new(move |_| fired_cb.store(true, Ordering::SeqCst)));
573
574 assert!(
575 !GUARD_RUNNING.load(Ordering::Relaxed),
576 "guardian thread must not start under cfg!(test)"
577 );
578 assert_eq!(current_pressure(), PressureLevel::Normal);
579 assert!(!abort_requested());
580 assert!(
581 !fired.load(Ordering::Relaxed),
582 "eviction callback must never fire in tests"
583 );
584 }
585
586 #[test]
587 fn rss_for_own_pid_matches_self() {
588 if cfg!(any(target_os = "linux", target_os = "macos")) {
589 let self_rss = get_rss_bytes().unwrap();
590 let pid_rss = get_rss_bytes_for_pid(std::process::id()).unwrap();
591 let ratio = self_rss as f64 / pid_rss as f64;
592 assert!(
593 (0.5..2.0).contains(&ratio),
594 "self RSS ({self_rss}) and pid-based RSS ({pid_rss}) should be within 2x"
595 );
596 }
597 }
598
599 #[test]
600 fn rss_for_dead_pid_returns_none() {
601 let dead_pid = 999_999_999u32;
602 assert!(get_rss_bytes_for_pid(dead_pid).is_none());
603 }
604
605 #[test]
606 fn capture_for_pid_falls_back_on_dead_pid() {
607 if cfg!(any(target_os = "linux", target_os = "macos")) {
608 let snap = MemorySnapshot::capture_for_pid(999_999_999);
609 assert!(snap.is_some(), "should fall back to self RSS");
610 }
611 }
612}