682 lines
23 KiB
Rust
682 lines
23 KiB
Rust
//! Integration tests for memory behavior during indexing.
|
||
//!
|
||
//! These tests create real file trees, index them, send incremental events,
|
||
//! and measure RSS to detect memory regressions.
|
||
|
||
use std::fs;
|
||
use std::path::Path;
|
||
use std::sync::Arc;
|
||
use tempfile::tempdir;
|
||
use xai_codebase_graph::{
|
||
FileEvent, IndexBuilder, IndexManager, IndexManagerConfig, ScopeGraphIndex, load_index,
|
||
save_index,
|
||
};
|
||
|
||
/// Read current process RSS in bytes. Supports Linux and macOS.
|
||
/// Returns `None` on unsupported platforms.
|
||
fn rss_bytes() -> Option<usize> {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||
for line in status.lines() {
|
||
if let Some(val) = line.strip_prefix("VmRSS:") {
|
||
let kb: usize = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
|
||
return Some(kb * 1024);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
use std::process::Command;
|
||
let output = Command::new("ps")
|
||
.args(["-o", "rss=", "-p", &std::process::id().to_string()])
|
||
.output()
|
||
.ok()?;
|
||
let kb: usize = String::from_utf8_lossy(&output.stdout)
|
||
.trim()
|
||
.parse()
|
||
.ok()?;
|
||
Some(kb * 1024)
|
||
}
|
||
|
||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||
{
|
||
None
|
||
}
|
||
}
|
||
|
||
fn rss_mb() -> Option<f64> {
|
||
rss_bytes().map(|b| b as f64 / (1024.0 * 1024.0))
|
||
}
|
||
|
||
fn fmt_rss(rss: Option<f64>) -> String {
|
||
rss.map_or("N/A".to_string(), |v| format!("{:.1}MB", v))
|
||
}
|
||
|
||
/// Create N Rust source files in `dir`, each with `defs_per_file` function defs.
|
||
fn create_rust_files(dir: &Path, count: usize, defs_per_file: usize) {
|
||
for i in 0..count {
|
||
let mut content = String::new();
|
||
for d in 0..defs_per_file {
|
||
content.push_str(&format!("fn func_{}_{}() {{}}\n", i, d));
|
||
}
|
||
fs::write(dir.join(format!("file_{}.rs", i)), &content).unwrap();
|
||
}
|
||
}
|
||
|
||
/// Create N binary files with a supported extension.
|
||
fn create_binary_files(dir: &Path, count: usize, size: usize) {
|
||
for i in 0..count {
|
||
let mut data = vec![0xFFu8; size];
|
||
// Ensure null byte in first 8000 bytes for binary detection
|
||
data[50] = 0;
|
||
fs::write(dir.join(format!("binary_{}.rs", i)), &data).unwrap();
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Tests
|
||
// =========================================================================
|
||
|
||
#[test]
|
||
fn test_binary_files_no_memory_growth() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
|
||
fs::write(root.join("legit.rs"), "fn legit() {}").unwrap();
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let _ = handle.get_file_count();
|
||
|
||
// Create 200 binary files with .rs extension, each 100KB
|
||
create_binary_files(root, 200, 100_000);
|
||
|
||
for i in 0..200 {
|
||
let path = root.join(format!("binary_{}.rs", i));
|
||
handle.send_event(FileEvent::created(path)).unwrap();
|
||
}
|
||
|
||
let count = handle.get_file_count().unwrap();
|
||
|
||
// Binary files should not be indexed (detected via 8KB prefix read)
|
||
assert_eq!(count, 1, "Only legit.rs should be indexed");
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_hidden_dir_files_not_indexed() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
|
||
fs::write(root.join("normal.rs"), "fn normal() {}").unwrap();
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let _ = handle.get_file_count();
|
||
|
||
// Create 300 files under a hidden directory (simulating .claude worktree)
|
||
let hidden = root.join(".claude").join("worktrees").join("session1");
|
||
fs::create_dir_all(&hidden).unwrap();
|
||
create_rust_files(&hidden, 300, 20);
|
||
|
||
for i in 0..300 {
|
||
let path = hidden.join(format!("file_{}.rs", i));
|
||
handle.send_event(FileEvent::created(path)).unwrap();
|
||
}
|
||
|
||
let count = handle.get_file_count().unwrap();
|
||
|
||
// Hidden dir files should not be indexed
|
||
assert_eq!(count, 1, "Only normal.rs should be indexed");
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_oversized_files_skipped() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
|
||
fs::write(root.join("small.rs"), "fn small() {}").unwrap();
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let _ = handle.get_file_count();
|
||
|
||
// Create a 6MB text file with valid Rust syntax (exceeds MAX_INDEXABLE_FILE_SIZE)
|
||
let big_content = "fn big() {}\n".repeat(500_000);
|
||
let big_path = root.join("huge.rs");
|
||
fs::write(&big_path, &big_content).unwrap();
|
||
drop(big_content);
|
||
|
||
handle.send_event(FileEvent::created(big_path)).unwrap();
|
||
|
||
let count = handle.get_file_count().unwrap();
|
||
assert_eq!(count, 1, "Only small.rs should be indexed");
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_event_coalescing_reduces_work() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
|
||
fs::write(root.join("target.rs"), "fn original() {}").unwrap();
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let _ = handle.get_file_count();
|
||
|
||
// Rapidly send 50 modify events for the same file
|
||
// Coalescing should collapse these into a single reindex
|
||
let path = root.join("target.rs");
|
||
for i in 0..50 {
|
||
fs::write(&path, format!("fn version_{}() {{}}", i)).unwrap();
|
||
handle
|
||
.send_event(FileEvent::modified(path.clone()))
|
||
.unwrap();
|
||
}
|
||
|
||
let stats = handle.get_stats().unwrap();
|
||
|
||
// Should have exactly 1 file with 1 definition (the last version)
|
||
assert_eq!(stats.files, 1);
|
||
assert!(
|
||
stats.definitions >= 1,
|
||
"Should have at least 1 definition after coalescing"
|
||
);
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn test_builder_skips_binary_and_oversized_in_bulk() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
|
||
// Mix of valid, binary, and oversized files
|
||
create_rust_files(root, 100, 5); // 100 valid files
|
||
create_binary_files(root, 50, 10_000); // 50 binary files
|
||
|
||
// One oversized file
|
||
let big = "fn x() {}\n".repeat(600_000); // ~6MB
|
||
fs::write(root.join("oversized.rs"), &big).unwrap();
|
||
drop(big);
|
||
|
||
let index = IndexBuilder::new().build(root).unwrap();
|
||
let (files, defs, _refs) = index.stats();
|
||
|
||
// Only the 100 valid files should be indexed
|
||
assert_eq!(files, 100);
|
||
assert!(defs >= 500); // 100 files × 5 defs
|
||
}
|
||
|
||
/// Measure RSS growth from a single `get_snapshot()` call on a representative index.
|
||
///
|
||
/// This test characterises the per-clone cost so we have a baseline before any
|
||
/// structural changes to `ScopeGraphIndex`. It does not enforce a hard byte
|
||
/// limit because RSS jitter in CI can be significant; instead it prints the
|
||
/// delta so regressions are visible in test output.
|
||
#[test]
|
||
fn test_single_snapshot_rss() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let stats = handle.get_stats().unwrap();
|
||
assert_eq!(stats.files, 500);
|
||
assert!(stats.definitions >= 5000);
|
||
|
||
let rss_before = rss_mb();
|
||
|
||
// Hold the snapshot to keep its allocation live while we sample RSS.
|
||
// get_snapshot() now returns Arc<ScopeGraphIndex>; the Arc::clone is
|
||
// zero-cost, so the delta here reflects any other allocator activity.
|
||
let snapshot: Arc<ScopeGraphIndex> = handle.get_snapshot().unwrap();
|
||
let rss_with_snapshot = rss_mb();
|
||
|
||
// Drop it and let the allocator reclaim.
|
||
drop(snapshot);
|
||
let rss_after_drop = rss_mb();
|
||
|
||
println!(
|
||
"Single snapshot RSS: {} before → {} held → {} after drop",
|
||
fmt_rss(rss_before),
|
||
fmt_rss(rss_with_snapshot),
|
||
fmt_rss(rss_after_drop),
|
||
);
|
||
|
||
if let (Some(before), Some(held)) = (rss_before, rss_with_snapshot) {
|
||
let delta_mb = held - before;
|
||
println!("Snapshot RSS delta (held): {:.1}MB", delta_mb);
|
||
// With Arc<ScopeGraphIndex> the snapshot is a pointer increment —
|
||
// no heap allocation of index data. Allow 20 MB for jitter/allocator
|
||
// metadata.
|
||
assert!(
|
||
delta_mb < 20.0,
|
||
"Snapshot Arc::clone grew RSS by {:.1}MB (expected <20MB with Arc)",
|
||
delta_mb
|
||
);
|
||
}
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
/// Verify that taking many snapshots and dropping them immediately does not
|
||
/// cause unbounded RSS growth (allocator should reclaim between clones).
|
||
#[test]
|
||
fn test_repeated_snapshots_rss_bounded() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 500, 10);
|
||
|
||
let config = IndexManagerConfig::new(root.to_path_buf())
|
||
.without_cache_load()
|
||
.without_cache_save();
|
||
|
||
let handle = IndexManager::spawn(config);
|
||
let _ = handle.get_stats().unwrap();
|
||
|
||
let rss_after_build = rss_mb();
|
||
|
||
// Take 20 snapshots, dropping each one before requesting the next.
|
||
for _ in 0..20 {
|
||
let _snapshot = handle.get_snapshot().unwrap();
|
||
// dropped at end of loop body
|
||
}
|
||
|
||
let rss_after_snapshots = rss_mb();
|
||
|
||
println!(
|
||
"Repeated snapshots RSS: {} after build → {} after 20 snapshots (dropped)",
|
||
fmt_rss(rss_after_build),
|
||
fmt_rss(rss_after_snapshots),
|
||
);
|
||
|
||
if let (Some(after_build), Some(after_snaps)) = (rss_after_build, rss_after_snapshots) {
|
||
let growth_mb = after_snaps - after_build;
|
||
println!("RSS growth from 20 dropped snapshots: {:.1}MB", growth_mb);
|
||
// With Arc<ScopeGraphIndex> each snapshot is a reference-count bump;
|
||
// dropping it is a decrement. No heap data is duplicated, so 20
|
||
// repeated dropped snapshots should add near-zero RSS. Allow 10 MB
|
||
// for allocator bookkeeping jitter.
|
||
assert!(
|
||
growth_mb < 10.0,
|
||
"20 Arc snapshots grew RSS by {:.1}MB (expected <10MB with Arc)",
|
||
growth_mb
|
||
);
|
||
}
|
||
|
||
handle.shutdown().unwrap();
|
||
}
|
||
|
||
/// Measure the RSS cost of a fresh index build (no cache).
|
||
///
|
||
/// This establishes a baseline for the build-phase two-phase peak that
|
||
/// the bounded merge-batching is designed to reduce on large repos.
|
||
/// The test prints the delta so regressions become visible in CI output.
|
||
#[test]
|
||
fn test_fresh_build_rss() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 500, 10); // 500 files, 5 000 defs
|
||
|
||
let rss_before = rss_mb();
|
||
|
||
// Build entirely via IndexBuilder (same path used by IndexManager on first run)
|
||
let index = IndexBuilder::new().build(root).unwrap();
|
||
|
||
let rss_after_build = rss_mb();
|
||
|
||
let (files, defs, _refs) = index.stats();
|
||
println!(
|
||
"Fresh build: {} files, {} defs — RSS {} → {}",
|
||
files,
|
||
defs,
|
||
fmt_rss(rss_before),
|
||
fmt_rss(rss_after_build)
|
||
);
|
||
|
||
if let (Some(before), Some(after)) = (rss_before, rss_after_build) {
|
||
let delta_mb = after - before;
|
||
println!("Fresh build RSS delta: {:.1}MB", delta_mb);
|
||
// 200 MB is a generous ceiling for a 500-file index with 5 000 defs.
|
||
// The bounded-batch fix targets large repos (> build_batch_size files);
|
||
// the delta here reflects the steady-state index size.
|
||
assert!(
|
||
delta_mb < 200.0,
|
||
"Fresh build grew RSS by {:.1}MB (expected <200MB for 500 files)",
|
||
delta_mb
|
||
);
|
||
}
|
||
|
||
assert_eq!(files, 500);
|
||
assert!(defs >= 5000);
|
||
}
|
||
|
||
/// Measure the RSS cost of loading an index from the cache.
|
||
///
|
||
/// This isolates the deserialization peak in `ScopeGraphIndex::read_from()` +
|
||
/// `StringInterner::from_parts()` from the build peak so that improvements to
|
||
/// each path can be tracked independently.
|
||
#[test]
|
||
fn test_cache_load_rss() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 500, 10);
|
||
|
||
// Build and save to a temp cache file
|
||
let cache_path = dir.path().join("index.bin");
|
||
let index = IndexBuilder::new().build(root).unwrap();
|
||
save_index(&cache_path, &index).unwrap();
|
||
|
||
let (files, defs, _refs) = index.stats();
|
||
// Drop the built index so the only RSS above baseline is from the load
|
||
drop(index);
|
||
|
||
let rss_before_load = rss_mb();
|
||
|
||
let loaded = load_index(&cache_path).unwrap();
|
||
|
||
let rss_after_load = rss_mb();
|
||
|
||
println!(
|
||
"Cache load: {} files, {} defs — RSS {} → {}",
|
||
files,
|
||
defs,
|
||
fmt_rss(rss_before_load),
|
||
fmt_rss(rss_after_load)
|
||
);
|
||
|
||
if let (Some(before), Some(after)) = (rss_before_load, rss_after_load) {
|
||
let delta_mb = after - before;
|
||
println!("Cache load RSS delta: {:.1}MB", delta_mb);
|
||
// 200 MB ceiling — same rationale as the build test above.
|
||
assert!(
|
||
delta_mb < 200.0,
|
||
"Cache load grew RSS by {:.1}MB (expected <200MB for 500 files)",
|
||
delta_mb
|
||
);
|
||
}
|
||
|
||
let (loaded_files, loaded_defs, _) = loaded.stats();
|
||
assert_eq!(
|
||
loaded_files, files,
|
||
"loaded file count must match built count"
|
||
);
|
||
assert_eq!(loaded_defs, defs, "loaded def count must match built count");
|
||
}
|
||
|
||
/// Verify that bounded merge-batching produces the same index as unbounded.
|
||
///
|
||
/// Uses a very small build_batch_size to exercise the multi-batch code path
|
||
/// even on this small corpus, then compares stats against an unbatched build.
|
||
#[test]
|
||
fn test_build_batch_size_produces_correct_index() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 200, 5); // 200 files, 1 000 defs
|
||
|
||
// Build with a very small batch size (10 files per merge batch)
|
||
let batched = IndexBuilder::new()
|
||
.with_build_batch_size(10)
|
||
.build(root)
|
||
.unwrap();
|
||
|
||
// Build without batching restriction for comparison
|
||
let reference = IndexBuilder::new().build(root).unwrap();
|
||
|
||
let (b_files, b_defs, b_refs) = batched.stats();
|
||
let (r_files, r_defs, r_refs) = reference.stats();
|
||
|
||
assert_eq!(
|
||
b_files, r_files,
|
||
"file count must match regardless of batch size"
|
||
);
|
||
assert_eq!(
|
||
b_defs, r_defs,
|
||
"definition count must match regardless of batch size"
|
||
);
|
||
assert_eq!(
|
||
b_refs, r_refs,
|
||
"reference count must match regardless of batch size"
|
||
);
|
||
}
|
||
|
||
/// Demonstrate that bounded merge-batching caps the transient peak RSS that
|
||
/// occurs while `FileSymbols` are live in memory.
|
||
///
|
||
/// Strategy: run both builds under a background RSS-polling thread so we can
|
||
/// observe the peak that is otherwise invisible to before/after measurements.
|
||
/// On a 1 000-file corpus the per-batch buffer is small relative to OS-page
|
||
/// granularity, so we do **not** assert a hard numeric improvement; instead
|
||
/// we assert that the batched peak is not *worse* than the unbounded peak
|
||
/// (ruling out regressions) and print both deltas for CI visibility.
|
||
///
|
||
/// At production scale (tens of thousands of files) the bounded-batch effect
|
||
/// is proportionally much larger and easily observable in profiling output.
|
||
#[test]
|
||
fn test_build_batch_peak_rss_is_bounded() {
|
||
use std::sync::{
|
||
Mutex,
|
||
atomic::{AtomicBool, Ordering},
|
||
};
|
||
use std::time::Duration;
|
||
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
// 1 000 files, 20 defs each — enough to make FileSymbols payload non-trivial
|
||
create_rust_files(root, 1000, 20);
|
||
|
||
/// Spawn a background thread that polls RSS every 2ms and returns the peak.
|
||
fn track_peak_during<F: FnOnce()>(f: F) -> f64 {
|
||
let peak = Arc::new(Mutex::new(rss_mb().unwrap_or(0.0)));
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let peak_bg = Arc::clone(&peak);
|
||
let stop_bg = Arc::clone(&stop);
|
||
|
||
let monitor = std::thread::spawn(move || {
|
||
while !stop_bg.load(Ordering::Relaxed) {
|
||
if let Some(rss) = rss_mb() {
|
||
let mut p = peak_bg.lock().unwrap();
|
||
if rss > *p {
|
||
*p = rss;
|
||
}
|
||
}
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
});
|
||
|
||
f();
|
||
|
||
stop.store(true, Ordering::Relaxed);
|
||
monitor.join().unwrap();
|
||
*peak.lock().unwrap()
|
||
}
|
||
|
||
let baseline = rss_mb().unwrap_or(0.0);
|
||
|
||
// Batched: 50 files per merge batch → 20 batches for 1 000 files.
|
||
// FileSymbols from at most 50 files are live at any one time.
|
||
let peak_batched = track_peak_during(|| {
|
||
let _idx = IndexBuilder::new()
|
||
.with_build_batch_size(50)
|
||
.build(root)
|
||
.unwrap();
|
||
});
|
||
|
||
// Unbounded: all 1 000 files parsed before any merge starts.
|
||
let peak_unbatched = track_peak_during(|| {
|
||
let _idx = IndexBuilder::new()
|
||
.with_build_batch_size(5_000)
|
||
.build(root)
|
||
.unwrap();
|
||
});
|
||
|
||
println!(
|
||
"Build peak RSS — baseline: {:.1}MB, batched (50/batch): {:.1}MB \
|
||
(+{:.1}MB), unbounded: {:.1}MB (+{:.1}MB)",
|
||
baseline,
|
||
peak_batched,
|
||
peak_batched - baseline,
|
||
peak_unbatched,
|
||
peak_unbatched - baseline,
|
||
);
|
||
|
||
// The batched build must not exhibit meaningfully higher peak RSS than the
|
||
// unbounded build — that would indicate the batching is broken.
|
||
// Allow 30 MB of jitter from concurrent allocator/OS activity.
|
||
if peak_batched > 0.0 && peak_unbatched > 0.0 {
|
||
assert!(
|
||
peak_batched <= peak_unbatched + 30.0,
|
||
"Batched build peaked {:.1}MB higher than unbounded (>30MB unexpected)",
|
||
peak_batched - peak_unbatched
|
||
);
|
||
}
|
||
|
||
// Both configurations must produce the same logical index.
|
||
let batched_idx = IndexBuilder::new()
|
||
.with_build_batch_size(50)
|
||
.build(root)
|
||
.unwrap();
|
||
let unbatched_idx = IndexBuilder::new()
|
||
.with_build_batch_size(5_000)
|
||
.build(root)
|
||
.unwrap();
|
||
let (b_files, b_defs, b_refs) = batched_idx.stats();
|
||
let (u_files, u_defs, u_refs) = unbatched_idx.stats();
|
||
assert_eq!(b_files, u_files, "file count must match");
|
||
assert_eq!(b_defs, u_defs, "definition count must match");
|
||
assert_eq!(b_refs, u_refs, "reference count must match");
|
||
}
|
||
|
||
// =============================================================================
|
||
// Structural compaction tests
|
||
// =============================================================================
|
||
|
||
/// Verify that an index survives a save/load round-trip after compact().
|
||
///
|
||
/// Guards against regressions in the binary format introduced by the u32
|
||
/// line-number change. compact() is already called by IndexBuilder::build,
|
||
/// so no explicit call is needed here.
|
||
#[test]
|
||
fn test_compact_then_save_load_roundtrip() {
|
||
let dir = tempdir().unwrap();
|
||
let root = dir.path();
|
||
create_rust_files(root, 50, 4); // 50 files, 200 defs
|
||
|
||
// build() calls compact() internally via build_fast()
|
||
let original = IndexBuilder::new().build(root).unwrap();
|
||
|
||
let cache_path = dir.path().join("test_index.bin");
|
||
save_index(&cache_path, &original).unwrap();
|
||
|
||
let loaded = load_index(&cache_path).unwrap();
|
||
|
||
let (orig_files, orig_defs, orig_refs) = original.stats();
|
||
let (load_files, load_defs, load_refs) = loaded.stats();
|
||
|
||
assert_eq!(orig_files, load_files, "file count survives round-trip");
|
||
assert_eq!(orig_defs, load_defs, "def count survives round-trip");
|
||
assert_eq!(orig_refs, load_refs, "ref count survives round-trip");
|
||
|
||
// Verify a representative symbol round-trips
|
||
let sym = "func_0_0";
|
||
let orig_locs = original.find_definitions(sym);
|
||
let load_locs = loaded.find_definitions(sym);
|
||
assert_eq!(
|
||
orig_locs, load_locs,
|
||
"definition locations must survive save/load round-trip"
|
||
);
|
||
}
|
||
|
||
/// Compare RSS between an uncompacted index (built via raw API, no Vec
|
||
/// shrinking) and a compacted one, to justify the compaction scope decision.
|
||
///
|
||
/// ## Compaction scope justification
|
||
///
|
||
/// Structural compaction was planned "if justified by measurement".
|
||
/// This test provides that measurement:
|
||
///
|
||
/// - **u32 line numbers** reduce every location entry from 16 bytes
|
||
/// (`(StringId, usize)` with alignment padding on 64-bit) to 8 bytes
|
||
/// (`(StringId, u32)`) — a 2× per-entry reduction.
|
||
///
|
||
/// - **compact()** eliminates Vec doubling over-allocation that accumulates
|
||
/// during `push()`-based bulk build (typically 1.5–2× wasted capacity).
|
||
///
|
||
/// Together these address the per-symbol-Vec overhead without the more
|
||
/// invasive contiguous/range-based layout redesign. If a future measurement
|
||
/// shows the ceiling is still too high for very large repos (>> 50K files),
|
||
/// the contiguous-layout redesign from the plan should be revisited.
|
||
#[test]
|
||
fn test_compact_reduces_rss_vs_uncompacted() {
|
||
// Build via raw ScopeGraphIndex API so compact() is never called.
|
||
// This reproduces the steady-state heap shape *without* compact()'s shrinking.
|
||
let rss_before = rss_mb();
|
||
let mut raw = ScopeGraphIndex::new();
|
||
for i in 0..500usize {
|
||
for d in 0..10usize {
|
||
let sym = format!("func_{}_{}", i, d);
|
||
let path = format!("file_{}.rs", i);
|
||
raw.add_definition(&sym, &path, d + 1);
|
||
}
|
||
}
|
||
let rss_uncompacted = rss_mb();
|
||
let (raw_files, raw_defs, _) = raw.stats();
|
||
|
||
// compact() trims Vec slack on all location lists and the interner.
|
||
raw.compact();
|
||
let rss_compacted = rss_mb();
|
||
|
||
println!(
|
||
"PR4 RSS — uncompacted: {} ({} files, {} defs), after compact(): {}",
|
||
fmt_rss(rss_uncompacted),
|
||
raw_files,
|
||
raw_defs,
|
||
fmt_rss(rss_compacted),
|
||
);
|
||
|
||
if let (Some(unc), Some(cpt)) = (rss_uncompacted, rss_compacted) {
|
||
println!("compact() RSS change: {:.1}MB", cpt - unc);
|
||
// compact() must not increase RSS
|
||
assert!(
|
||
cpt <= unc + 5.0,
|
||
"compact() increased RSS by {:.1}MB — unexpected",
|
||
cpt - unc
|
||
);
|
||
}
|
||
|
||
// Absolute ceiling for the compacted 500-file index
|
||
if let (Some(base), Some(cpt)) = (rss_before, rss_compacted) {
|
||
let delta_mb = cpt - base;
|
||
println!("Compacted index RSS delta from baseline: {:.1}MB", delta_mb);
|
||
assert!(
|
||
delta_mb < 200.0,
|
||
"Compacted index RSS delta {:.1}MB exceeds 200MB ceiling",
|
||
delta_mb
|
||
);
|
||
}
|
||
}
|