Files
2026-04-19 13:58:49 -04:00

346 lines
11 KiB
Rust

//! Memory profiling tests for cass.
//!
//! These tests verify that repeated operations don't leak memory.
//!
//! IMPORTANT: Run with --test-threads=1 to avoid measurement interference:
//! cargo test --test memory_tests --release -- --nocapture --test-threads=1
//!
//! For detailed profiling, use heaptrack:
//! heaptrack cargo test --test memory_tests --release
//! heaptrack_gui heaptrack.*.zst
use coding_agent_search::connectors::{NormalizedConversation, NormalizedMessage};
use coding_agent_search::indexer::persist::persist_conversation;
use coding_agent_search::search::query::{FieldMask, SearchClient, SearchFilters};
use coding_agent_search::search::tantivy::{TantivyIndex, index_dir};
use coding_agent_search::storage::sqlite::SqliteStorage;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;
/// These tests use RSS-based assertions and should not run concurrently.
static MEMORY_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn memory_test_guard() -> std::sync::MutexGuard<'static, ()> {
MEMORY_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// Generate a sample conversation for testing.
fn sample_conv(i: i64, msgs: i64) -> NormalizedConversation {
let mut messages = Vec::new();
for m in 0..msgs {
messages.push(NormalizedMessage {
idx: m,
role: if m % 2 == 0 { "user" } else { "agent" }.into(),
author: None,
created_at: Some(1_700_000_000_000 + (i * 10 + m)),
content: format!(
"conversation {i} message {m} lorem ipsum dolor sit amet \
consectetur adipiscing elit sed do eiusmod tempor"
),
extra: serde_json::json!({}),
snippets: Vec::new(),
invocations: Vec::new(),
});
}
NormalizedConversation {
agent_slug: "memory-test-agent".into(),
external_id: Some(format!("mem-conv-{i}")),
title: Some(format!("Memory Test Conversation {i}")),
workspace: Some(PathBuf::from("/tmp/workspace")),
source_path: PathBuf::from(format!("/tmp/memory-test/conv-{i}.jsonl")),
started_at: Some(1_700_000_000_000),
ended_at: Some(1_700_000_000_000 + msgs),
metadata: serde_json::json!({ "memory_test": true, "i": i }),
messages,
}
}
/// Set up a test index with sample data.
fn setup_test_index(conv_count: i64, msgs_per_conv: i64) -> (TempDir, SearchClient) {
let temp = TempDir::new().expect("create tempdir");
let data_dir = temp.path().to_path_buf();
let db_path = data_dir.join("memory_test.db");
let index_path = index_dir(&data_dir).expect("index path");
let storage = SqliteStorage::open(&db_path).expect("open db");
let mut t_index = TantivyIndex::open_or_create(&index_path).unwrap();
for i in 0..conv_count {
let conv = sample_conv(i, msgs_per_conv);
persist_conversation(&storage, &mut t_index, &conv).expect("persist");
}
t_index.commit().unwrap();
let client = SearchClient::open(&index_path, Some(&db_path))
.expect("open client")
.expect("client available");
(temp, client)
}
/// Get current process memory usage (resident set size).
/// Returns 0 on unsupported platforms.
fn get_process_memory_bytes() -> usize {
#[cfg(target_os = "linux")]
{
// Read /proc/self/statm: VmSize VmRSS VmShared ...
// Second field is RSS in pages
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm")
&& let Some(rss_pages) = statm.split_whitespace().nth(1)
&& let Ok(pages) = rss_pages.parse::<usize>()
{
return pages * 4096; // Assume 4KB pages
}
0
}
#[cfg(target_os = "macos")]
{
use std::process::Command;
// Use ps to get RSS in KB
Command::new("ps")
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
.output()
.ok()
.and_then(|output| {
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<usize>()
.ok()
})
.map(|rss_kb| rss_kb * 1024)
.unwrap_or(0)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
0
}
}
/// Test that repeated searches don't leak memory.
///
/// This test runs many searches and verifies that memory usage doesn't
/// grow unboundedly. Some growth is acceptable due to caching.
#[test]
#[ignore = "RSS profiling test; run manually with `cargo test --release --test memory_tests -- --nocapture --test-threads=1`"]
fn test_search_memory_no_leak() {
let _guard = memory_test_guard();
// Create index with 100 conversations
let (_tmp, client) = setup_test_index(100, 10);
let filters = SearchFilters::default();
// Warm up - run a few searches to initialize caches
for _ in 0..10 {
let _ = client.search("lorem", filters.clone(), 20, 0, FieldMask::FULL);
}
// Get baseline memory after warmup
let baseline = get_process_memory_bytes();
// Skip test on platforms where we can't measure memory
if baseline == 0 {
println!("Skipping memory test - platform doesn't support memory measurement");
return;
}
// Run many searches
for i in 0..500 {
let query = if i % 3 == 0 {
"lorem"
} else if i % 3 == 1 {
"ipsum"
} else {
"dolor"
};
let _ = client.search(query, filters.clone(), 20, 0, FieldMask::FULL);
}
let after = get_process_memory_bytes();
let growth = after.saturating_sub(baseline);
// Allow up to 50MB growth (for caches, etc.)
// This is generous but catches true leaks
let max_allowed_growth = 50 * 1024 * 1024; // 50MB
println!(
"Memory: baseline={:.2}MB, after={:.2}MB, growth={:.2}MB",
baseline as f64 / 1_048_576.0,
after as f64 / 1_048_576.0,
growth as f64 / 1_048_576.0
);
assert!(
growth < max_allowed_growth,
"Memory grew by {:.2}MB during search loop (max allowed: {:.2}MB). \
This may indicate a memory leak.",
growth as f64 / 1_048_576.0,
max_allowed_growth as f64 / 1_048_576.0
);
}
/// Test that repeated indexing operations don't leak memory.
#[test]
#[ignore = "RSS profiling test; run manually with `cargo test --release --test memory_tests -- --nocapture --test-threads=1`"]
fn test_indexing_memory_no_leak() {
let _guard = memory_test_guard();
let temp = TempDir::new().expect("tempdir");
let data_dir = temp.path().to_path_buf();
let db_path = data_dir.join("memory_index_test.db");
let index_path = index_dir(&data_dir).expect("index path");
let storage = SqliteStorage::open(&db_path).expect("open db");
let mut t_index = TantivyIndex::open_or_create(&index_path).unwrap();
// Warm up
for i in 0..5 {
let conv = sample_conv(i, 5);
persist_conversation(&storage, &mut t_index, &conv).expect("persist");
}
t_index.commit().unwrap();
let baseline = get_process_memory_bytes();
if baseline == 0 {
println!("Skipping memory test - platform doesn't support memory measurement");
return;
}
// Index many conversations
for i in 5..105 {
let conv = sample_conv(i, 10);
persist_conversation(&storage, &mut t_index, &conv).expect("persist");
// Commit periodically
if i % 20 == 0 {
t_index.commit().unwrap();
}
}
t_index.commit().unwrap();
let after = get_process_memory_bytes();
let growth = after.saturating_sub(baseline);
// Allow up to 100MB growth for indexing (more data = more legitimate memory use)
let max_allowed_growth = 100 * 1024 * 1024; // 100MB
println!(
"Indexing memory: baseline={:.2}MB, after={:.2}MB, growth={:.2}MB",
baseline as f64 / 1_048_576.0,
after as f64 / 1_048_576.0,
growth as f64 / 1_048_576.0
);
assert!(
growth < max_allowed_growth,
"Memory grew by {:.2}MB during indexing (max allowed: {:.2}MB). \
This may indicate a memory leak.",
growth as f64 / 1_048_576.0,
max_allowed_growth as f64 / 1_048_576.0
);
}
/// Test that vector search operations don't leak memory.
#[test]
#[ignore = "RSS profiling test; run manually with `cargo test --release --test memory_tests -- --nocapture --test-threads=1`"]
fn test_vector_search_memory_no_leak() {
let _guard = memory_test_guard();
use coding_agent_search::search::vector_index::{Quantization, SemanticDocId, VectorIndex};
let dimension = 384;
let count = 10_000;
fn normalize_in_place(vec: &mut [f32]) {
let norm_sq: f32 = vec.iter().map(|v| v * v).sum();
let norm = norm_sq.sqrt();
if norm > 0.0 {
for v in vec {
*v /= norm;
}
}
}
// Build on-disk index (FSVI) so memory measurements reflect real behavior.
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("mem.fsvi");
let mut writer = VectorIndex::create_with_revision(
&path,
"test-embedder",
"rev1",
dimension,
Quantization::F16,
)
.expect("create writer");
let mut vec_buf = vec![0.0f32; dimension];
for idx in 0..count {
for (d, slot) in vec_buf.iter_mut().enumerate() {
*slot = ((idx + d * 31) % 997) as f32 / 997.0;
}
normalize_in_place(&mut vec_buf);
let doc_id = SemanticDocId {
message_id: idx as u64,
chunk_idx: 0,
agent_id: (idx % 8) as u32,
workspace_id: 1,
source_id: 1,
role: 1,
created_at_ms: idx as i64,
content_hash: None,
}
.to_doc_id_string();
writer
.write_record(&doc_id, &vec_buf)
.expect("write_record");
}
writer.finish().expect("finish");
let index = VectorIndex::open(&path).expect("open");
// Generate query vector
let mut query: Vec<f32> = (0..dimension).map(|d| (d % 17) as f32 / 17.0).collect();
normalize_in_place(&mut query);
// Warm up
for _ in 0..10 {
let _ = index.search_top_k(&query, 25, None);
}
let baseline = get_process_memory_bytes();
if baseline == 0 {
println!("Skipping memory test - platform doesn't support memory measurement");
return;
}
// Run many vector searches
for _ in 0..500 {
let _ = index.search_top_k(&query, 25, None);
}
let after = get_process_memory_bytes();
let growth = after.saturating_sub(baseline);
// Allow up to 20MB growth (vector search should be very memory-stable)
let max_allowed_growth = 20 * 1024 * 1024; // 20MB
println!(
"Vector search memory: baseline={:.2}MB, after={:.2}MB, growth={:.2}MB",
baseline as f64 / 1_048_576.0,
after as f64 / 1_048_576.0,
growth as f64 / 1_048_576.0
);
assert!(
growth < max_allowed_growth,
"Memory grew by {:.2}MB during vector search loop (max allowed: {:.2}MB). \
This may indicate a memory leak.",
growth as f64 / 1_048_576.0,
max_allowed_growth as f64 / 1_048_576.0
);
}