use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tracing::{debug, info, warn};
static GLOBAL_TRACKER: OnceLock<Mutex<Option<MemoryReportTracker>>> = OnceLock::new();
static SAMPLER_STOP: AtomicBool = AtomicBool::new(false);
const SAMPLE_INTERVAL: Duration = Duration::from_millis(25);
pub fn init_tracker(tracker: MemoryReportTracker) {
if GLOBAL_TRACKER.set(Mutex::new(Some(tracker))).is_err() {
return;
}
let spawned = std::thread::Builder::new()
.name("mem-report-sampler".to_string())
.spawn(|| {
while !SAMPLER_STOP.load(Ordering::Relaxed) {
std::thread::sleep(SAMPLE_INTERVAL);
sample_rss();
}
});
if let Err(error) = spawned {
warn!("Memory report sampler thread failed to start: {error}");
}
}
pub fn observe_rss(phase: &str) {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let rss = current_rss_bytes();
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
t.observe_rss(rss);
}
debug!("Memory observation: phase={}, rss={} bytes", phase, rss);
}
}
fn sample_rss() {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let rss = current_rss_bytes();
if rss == 0 {
return;
}
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
t.sample(rss);
}
}
}
pub fn enter_phase(name: &str) {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
t.enter_phase(name);
}
}
}
pub fn exit_phase() {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
t.exit_phase();
}
}
}
pub fn close_phase(name: &str) {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
if t.active_phase() == Some(name) {
t.exit_phase();
}
}
}
}
pub struct PhaseScope {
name: &'static str,
}
impl PhaseScope {
pub fn enter(name: &'static str) -> Self {
enter_phase(name);
Self { name }
}
}
impl Drop for PhaseScope {
fn drop(&mut self) {
close_phase(self.name);
}
}
pub fn record_phase(name: &str, peak_rss_bytes: u64, sample_count: u64) {
if let Some(tracker) = GLOBAL_TRACKER.get() {
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(t) = guard.as_mut() {
t.record_phase(name, peak_rss_bytes, sample_count);
}
}
}
pub fn shutdown() {
SAMPLER_STOP.store(true, Ordering::Relaxed);
if let Some(tracker) = GLOBAL_TRACKER.get() {
let mut guard = tracker.lock().unwrap_or_else(|p| p.into_inner());
if let Some(mut t) = guard.take() {
if let Err(e) = t.write_report() {
warn!("Failed to write memory report on shutdown: {}", e);
}
}
}
}
pub const MEMORY_REPORT_ENV: &str = "LEINDEX_MEMORY_REPORT";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryReport {
pub version: u32,
pub uptime_secs: f64,
pub peak_rss_bytes: u64,
pub timestamp_secs: u64,
pub phases: Vec<PhaseSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseSummary {
pub name: String,
pub peak_rss_bytes: u64,
pub sample_count: u64,
}
pub struct MemoryReportTracker {
path: PathBuf,
start: std::time::Instant,
peak_rss_bytes: u64,
phases: Vec<PhaseSummary>,
active: Option<String>,
active_peak: u64,
active_samples: u64,
written: bool,
}
impl MemoryReportTracker {
pub fn new(path: PathBuf) -> Self {
debug!("Memory report will be written to {}", path.display());
Self {
path,
start: std::time::Instant::now(),
peak_rss_bytes: 0,
phases: Vec::new(),
active: None,
active_peak: 0,
active_samples: 0,
written: false,
}
}
pub fn observe_rss(&mut self, rss_bytes: u64) {
if rss_bytes > self.peak_rss_bytes {
self.peak_rss_bytes = rss_bytes;
}
self.sample(rss_bytes);
}
pub fn sample(&mut self, rss_bytes: u64) {
if rss_bytes > self.peak_rss_bytes {
self.peak_rss_bytes = rss_bytes;
}
if self.active.is_some() {
self.active_samples += 1;
if rss_bytes > self.active_peak {
self.active_peak = rss_bytes;
}
}
}
pub fn active_phase(&self) -> Option<&str> {
self.active.as_deref()
}
#[cfg(test)]
fn close_phase_for_test(&mut self, name: &str) {
if self.active_phase() == Some(name) {
self.exit_phase();
}
}
pub fn enter_phase(&mut self, name: impl Into<String>) {
self.exit_phase();
let rss = current_rss_bytes();
if rss > self.peak_rss_bytes {
self.peak_rss_bytes = rss;
}
self.active = Some(name.into());
self.active_peak = rss;
self.active_samples = 1;
}
pub fn exit_phase(&mut self) {
let Some(name) = self.active.take() else {
return;
};
let samples = self.active_samples;
let peak = self.active_peak;
self.active_samples = 0;
self.active_peak = 0;
self.push_summary(name, peak, samples.max(1));
}
pub fn record_phase(
&mut self,
name: impl Into<String>,
peak_rss_bytes: u64,
sample_count: u64,
) {
if peak_rss_bytes > self.peak_rss_bytes {
self.peak_rss_bytes = peak_rss_bytes;
}
self.push_summary(name.into(), peak_rss_bytes, sample_count);
}
fn push_summary(&mut self, name: String, peak_rss_bytes: u64, sample_count: u64) {
self.phases.push(PhaseSummary {
name,
peak_rss_bytes,
sample_count,
});
}
pub fn write_report(&mut self) -> std::io::Result<()> {
if self.written {
return Ok(());
}
self.written = true;
let report = MemoryReport {
version: 1,
uptime_secs: self.start.elapsed().as_secs_f64(),
peak_rss_bytes: self.peak_rss_bytes,
timestamp_secs: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
phases: self.phases.clone(),
};
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let json = serde_json::to_string_pretty(&report)?;
std::fs::write(&self.path, json)?;
info!("Memory report written to {}", self.path.display());
Ok(())
}
}
impl Drop for MemoryReportTracker {
fn drop(&mut self) {
if let Err(e) = self.write_report() {
warn!("Failed to write memory report: {}", e);
}
}
}
pub fn resolve_report_path(cli_flag: Option<&Path>) -> Option<PathBuf> {
if let Some(p) = cli_flag {
return Some(p.to_path_buf());
}
std::env::var(MEMORY_REPORT_ENV)
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub fn current_rss_bytes() -> u64 {
#[cfg(target_os = "linux")]
{
read_rss_procfs_bytes().unwrap_or(0)
}
#[cfg(not(target_os = "linux"))]
{
read_rss_sysinfo_bytes().unwrap_or(0)
}
}
#[cfg(target_os = "linux")]
fn read_rss_procfs_bytes() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
for line in status.lines() {
if line.starts_with("VmRSS:") {
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
return Some(kb * 1024);
}
}
None
}
#[cfg(not(target_os = "linux"))]
fn read_rss_sysinfo_bytes() -> Option<u64> {
use sysinfo::System;
let mut sys = System::new();
let pid = sysinfo::Pid::from(std::process::id() as usize);
let pid_list = [pid];
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&pid_list), true);
sys.process(pid).map(|p| p.memory())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn test_resolve_report_path_none_when_unset() {
let _guard = env_lock();
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
assert!(resolve_report_path(None).is_none());
}
#[test]
fn test_resolve_report_path_from_flag() {
let path = Path::new("/tmp/test-report.json");
let result = resolve_report_path(Some(path));
assert_eq!(result, Some(PathBuf::from("/tmp/test-report.json")));
}
#[test]
fn test_resolve_report_path_from_env() {
let _guard = env_lock();
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
unsafe { std::env::set_var(MEMORY_REPORT_ENV, "/tmp/env-report.json") };
let result = resolve_report_path(None);
assert_eq!(result, Some(PathBuf::from("/tmp/env-report.json")));
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
}
#[test]
fn test_flag_takes_precedence_over_env() {
let _guard = env_lock();
unsafe { std::env::set_var(MEMORY_REPORT_ENV, "/tmp/env-report.json") };
let flag_path = Path::new("/tmp/flag-report.json");
let result = resolve_report_path(Some(flag_path));
assert_eq!(result, Some(PathBuf::from("/tmp/flag-report.json")));
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
}
#[test]
fn test_empty_env_var_ignored() {
let _guard = env_lock();
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
unsafe { std::env::set_var(MEMORY_REPORT_ENV, "") };
let result = resolve_report_path(None);
assert!(
result.is_none(),
"empty env var should be ignored, got {:?}",
result
);
unsafe { std::env::remove_var(MEMORY_REPORT_ENV) };
}
#[test]
fn test_tracker_writes_report() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.observe_rss(100_000_000);
tracker.record_phase("index", 150_000_000, 42);
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
assert_eq!(report.version, 1);
assert_eq!(report.peak_rss_bytes, 150_000_000);
assert_eq!(report.phases.len(), 1);
assert_eq!(report.phases[0].name, "index");
assert_eq!(report.phases[0].peak_rss_bytes, 150_000_000);
assert_eq!(report.phases[0].sample_count, 42);
assert!(report.uptime_secs >= 0.0);
assert!(report.timestamp_secs > 0);
}
#[test]
fn test_tracker_drop_writes_report() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("drop-report.json");
{
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.observe_rss(50_000_000);
}
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
assert_eq!(report.peak_rss_bytes, 50_000_000);
}
#[test]
fn test_report_is_compact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("compact-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.observe_rss(100_000_000);
tracker.record_phase("index", 150_000_000, 42);
tracker.record_phase("query", 120_000_000, 10);
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(
contents.len() < 2048,
"Report should be compact, got {} bytes",
contents.len()
);
}
#[test]
fn test_double_write_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("idempotent-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.observe_rss(100_000_000);
tracker.write_report().unwrap();
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
assert_eq!(report.peak_rss_bytes, 100_000_000);
}
#[test]
fn test_phase_entries_accumulate_in_enter_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("phases-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.enter_phase("scan");
tracker.sample(10_000_000);
tracker.sample(30_000_000);
tracker.enter_phase("parse");
tracker.sample(20_000_000);
tracker.exit_phase();
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
let names: Vec<&str> = report.phases.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["scan", "parse"]);
assert!(report.phases[0].peak_rss_bytes >= 30_000_000);
assert!(report.phases[1].peak_rss_bytes >= 20_000_000);
assert!(report.phases[0].sample_count >= 2);
assert!(report.phases[1].sample_count >= 1);
assert!(report.peak_rss_bytes >= report.phases[0].peak_rss_bytes);
assert!(report.peak_rss_bytes >= report.phases[1].peak_rss_bytes);
}
#[test]
fn test_record_phase_values_are_reported_verbatim() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("verbatim-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.record_phase("index", 150_000_000, 7);
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
assert_eq!(report.phases.len(), 1);
assert_eq!(report.phases[0].peak_rss_bytes, 150_000_000);
assert_eq!(report.phases[0].sample_count, 7);
}
#[test]
fn test_observations_outside_a_phase_do_not_invent_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no-phase-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.observe_rss(40_000_000);
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
assert!(
report.phases.is_empty(),
"startup observations must not be attributed to a phase that never ran"
);
assert_eq!(report.peak_rss_bytes, 40_000_000);
}
#[test]
fn test_next_enter_closes_the_previous_phase() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("handoff-report.json");
let mut tracker = MemoryReportTracker::new(path.clone());
tracker.enter_phase("lexical");
tracker.enter_phase("persist");
tracker.exit_phase();
tracker.write_report().unwrap();
let contents = fs::read_to_string(&path).unwrap();
let report: MemoryReport = serde_json::from_str(&contents).unwrap();
let names: Vec<&str> = report.phases.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
vec!["lexical", "persist"],
"a handoff must close the previous phase exactly once"
);
}
#[test]
fn test_close_phase_ignores_a_phase_that_already_handed_off() {
let mut tracker = MemoryReportTracker::new(PathBuf::from("/tmp/unused-report.json"));
tracker.enter_phase("lexical");
tracker.enter_phase("persist");
tracker.close_phase_for_test("lexical");
assert_eq!(tracker.active_phase(), Some("persist"));
tracker.exit_phase();
let names: Vec<&str> = tracker.phases.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["lexical", "persist"]);
}
#[test]
fn test_current_rss_bytes_is_reasonable() {
let rss = current_rss_bytes();
assert!(rss > 0, "RSS should be positive, got {}", rss);
assert!(
rss < 10_000_000_000,
"RSS should be less than 10 GB, got {}",
rss
);
}
}