#[allow(clippy::wildcard_imports)]
use super::*;
use rayon::prelude::*;
pub(super) const PARALLEL_MIN_FILES: usize = 32;
const MAX_BATCH_FILES: usize = 500;
const MIN_BATCH_FILES: usize = 1;
const EST_TRANSIENT_PER_FILE: u64 = 256 * 1024;
fn effective_batch_size(max_files: usize) -> usize {
crate::core::memory_guard::adaptive_batch_size(
MIN_BATCH_FILES,
max_files.clamp(1, MAX_BATCH_FILES),
EST_TRANSIENT_PER_FILE,
)
}
const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024;
struct PreparedChunk {
chunk: CodeChunk,
lowered: Vec<String>,
}
struct PreparedFile {
rel: String,
state: IndexedFileState,
chunks: Vec<PreparedChunk>,
}
fn parallel_build_must_stop(what: &str, files_done: usize) -> bool {
if crate::core::memory_guard::abort_requested() {
tracing::warn!(
"[{what}: aborting parallel build after {files_done} files due to critical memory pressure]"
);
return true;
}
if crate::core::memory_guard::is_under_pressure() {
tracing::warn!(
"[{what}: stopping parallel build after {files_done} files due to memory pressure]"
);
return true;
}
false
}
fn prepare_chunk(mut chunk: CodeChunk) -> PreparedChunk {
let enriched = enrich_for_bm25(&chunk);
let tokens = tokenize(&enriched);
let lowered: Vec<String> = tokens.iter().map(|t| t.to_lowercase()).collect();
let token_count = tokens.len();
const SNIPPET_LINES: usize = 10;
if chunk.content.lines().nth(SNIPPET_LINES).is_some() {
chunk.content = chunk
.content
.lines()
.take(SNIPPET_LINES)
.collect::<Vec<_>>()
.join("\n");
chunk.content.shrink_to_fit();
}
PreparedChunk {
chunk: CodeChunk {
token_count,
tokens: Vec::new(),
..chunk
},
lowered,
}
}
fn prepare_file(
root: &Path,
rel: &str,
content_hint: &HashMap<String, String>,
) -> Option<PreparedFile> {
if crate::core::memory_guard::abort_requested() {
return None;
}
let abs = root.join(rel);
let state = IndexedFileState::from_path(&abs)?;
if state.size_bytes > MAX_FILE_SIZE_BYTES {
return None;
}
let cache_state = crate::core::content_cache::FileState {
mtime_ms: state.mtime_ms,
size_bytes: state.size_bytes,
};
let content: std::borrow::Cow<'_, str> = if crate::core::extractors::is_binary_document(&abs) {
match std::fs::read(&abs) {
Ok(bytes) => {
let text = crate::core::extractors::extract(&abs, &bytes).text;
if text.is_empty() {
return None;
}
std::borrow::Cow::Owned(text)
}
Err(_) => return None,
}
} else if let Some(cached) = content_hint.get(rel) {
std::borrow::Cow::Borrowed(cached.as_str())
} else if let Some(arc) = crate::core::content_cache::get(&abs, cache_state) {
std::borrow::Cow::Owned(arc.to_string())
} else {
match std::fs::read_to_string(&abs) {
Ok(c) => {
crate::core::content_cache::insert(
&abs,
cache_state,
std::sync::Arc::from(c.as_str()),
);
std::borrow::Cow::Owned(c)
}
Err(_) => return None,
}
};
if crate::core::memory_guard::abort_requested() {
return None;
}
let mut chunks = extract_chunks(rel, &content);
chunks.sort_by(|a, b| {
a.start_line
.cmp(&b.start_line)
.then_with(|| a.end_line.cmp(&b.end_line))
.then_with(|| a.symbol_name.cmp(&b.symbol_name))
});
Some(PreparedFile {
rel: rel.to_string(),
state,
chunks: chunks.into_iter().map(prepare_chunk).collect(),
})
}
fn prepare_incremental_file(
root: &Path,
prev: &BM25Index,
old_by_file: &HashMap<String, Vec<CodeChunk>>,
content_hint: &HashMap<String, String>,
rel: &str,
) -> Option<PreparedFile> {
if crate::core::memory_guard::abort_requested() {
return None;
}
let abs = root.join(rel);
let state = IndexedFileState::from_path(&abs)?;
let unchanged = prev.files.get(rel).is_some_and(|old| *old == state);
if unchanged
&& let Some(chunks) = old_by_file.get(rel)
&& chunks.first().is_some_and(|c| !c.content.is_empty())
{
return Some(PreparedFile {
rel: rel.to_string(),
state,
chunks: chunks.iter().cloned().map(prepare_chunk).collect(),
});
}
prepare_file(root, rel, content_hint)
}
impl BM25Index {
pub(crate) fn build_parallel(
root: &Path,
content_hint: &HashMap<String, String>,
files: &[String],
) -> Self {
Self::build_parallel_batched(root, content_hint, files, MAX_BATCH_FILES)
}
pub(super) fn build_parallel_batched(
root: &Path,
content_hint: &HashMap<String, String>,
files: &[String],
max_batch_size: usize,
) -> Self {
let mut index = Self::new();
let max_batch_size = max_batch_size.clamp(1, MAX_BATCH_FILES);
let root_key = root.to_string_lossy().to_string();
let total = files.len() as u64;
crate::core::index_progress::report_bm25(&root_key, 0, total);
let mut files_done = 0;
while files_done < files.len() {
if parallel_build_must_stop("bm25", files_done) {
break;
}
let adaptive_size = effective_batch_size(max_batch_size);
let batch_end = (files_done + adaptive_size).min(files.len());
let prepared: Vec<Option<PreparedFile>> = files[files_done..batch_end]
.par_iter()
.map(|rel| prepare_file(root, rel, content_hint))
.collect();
for pf in prepared.into_iter().flatten() {
for pc in pf.chunks {
index.add_prepared(pc);
}
index.files.insert(pf.rel, pf.state);
}
files_done = batch_end;
crate::core::index_progress::report_bm25(&root_key, files_done as u64, total);
crate::core::memory_guard::jemalloc_purge();
}
index.finalize();
index
}
pub(crate) fn rebuild_incremental_parallel(
root: &Path,
prev: &BM25Index,
old_by_file: &HashMap<String, Vec<CodeChunk>>,
files: &[String],
) -> Self {
let empty_hint: HashMap<String, String> = HashMap::new();
let mut index = Self::new();
let root_key = root.to_string_lossy().to_string();
let total = files.len() as u64;
crate::core::index_progress::report_bm25(&root_key, 0, total);
let mut files_done = 0;
while files_done < files.len() {
if parallel_build_must_stop("bm25-incr", files_done) {
break;
}
let batch_size = effective_batch_size(MAX_BATCH_FILES);
let batch_end = (files_done + batch_size).min(files.len());
let prepared: Vec<Option<PreparedFile>> = files[files_done..batch_end]
.par_iter()
.map(|rel| prepare_incremental_file(root, prev, old_by_file, &empty_hint, rel))
.collect();
for pf in prepared.into_iter().flatten() {
for pc in pf.chunks {
index.add_prepared(pc);
}
index.files.insert(pf.rel, pf.state);
}
files_done = batch_end;
crate::core::index_progress::report_bm25(&root_key, files_done as u64, total);
crate::core::memory_guard::jemalloc_purge();
}
index.finalize();
index
}
pub(crate) fn build_sequential(
root: &Path,
content_hint: &HashMap<String, String>,
files: &[String],
) -> Self {
let mut index = Self::new();
let mut cache_hits = 0usize;
let root_key = root.to_string_lossy().to_string();
let total = files.len() as u64;
crate::core::index_progress::report_bm25(&root_key, 0, total);
for (i, rel) in files.iter().enumerate() {
if i.is_multiple_of(16) || i + 1 == files.len() {
crate::core::index_progress::report_bm25(&root_key, (i + 1) as u64, total);
}
if i.is_multiple_of(500) && crate::core::memory_guard::is_under_pressure() {
tracing::warn!(
"[bm25: stopping build at file {i}/{} due to memory pressure]",
files.len()
);
break;
}
if crate::core::memory_guard::abort_requested() {
tracing::warn!("[bm25: aborting build due to critical memory pressure]");
break;
}
let abs = root.join(rel);
let Some(state) = IndexedFileState::from_path(&abs) else {
continue;
};
if state.size_bytes > MAX_FILE_SIZE_BYTES {
continue;
}
let cache_state = crate::core::content_cache::FileState {
mtime_ms: state.mtime_ms,
size_bytes: state.size_bytes,
};
let content = if crate::core::extractors::is_binary_document(&abs) {
match std::fs::read(&abs) {
Ok(bytes) => {
let text = crate::core::extractors::extract(&abs, &bytes).text;
if text.is_empty() {
continue;
}
std::borrow::Cow::Owned(text)
}
Err(_) => continue,
}
} else if let Some(cached) = content_hint.get(rel) {
cache_hits += 1;
std::borrow::Cow::Borrowed(cached.as_str())
} else if let Some(arc) = crate::core::content_cache::get(&abs, cache_state) {
cache_hits += 1;
std::borrow::Cow::Owned(arc.to_string())
} else {
match std::fs::read_to_string(&abs) {
Ok(c) => {
crate::core::content_cache::insert(
&abs,
cache_state,
std::sync::Arc::from(c.as_str()),
);
std::borrow::Cow::Owned(c)
}
Err(_) => continue,
}
};
let mut chunks = extract_chunks(rel, &content);
chunks.sort_by(|a, b| {
a.start_line
.cmp(&b.start_line)
.then_with(|| a.end_line.cmp(&b.end_line))
.then_with(|| a.symbol_name.cmp(&b.symbol_name))
});
for chunk in chunks {
index.add_chunk(chunk);
}
index.files.insert(rel.clone(), state);
}
if cache_hits > 0 {
tracing::info!(
"[bm25: reused {cache_hits}/{} file contents from graph scan cache]",
files.len()
);
}
index.finalize();
index
}
fn add_prepared(&mut self, prepared: PreparedChunk) {
let idx = self.chunks.len();
for lower in &prepared.lowered {
let postings = self.inverted.entry(lower.clone()).or_default();
if postings.last().map(|(last_idx, _)| *last_idx) != Some(idx) {
*self.doc_freqs.entry(lower.clone()).or_insert(0) += 1;
}
postings.push((idx, 1.0));
}
self.chunks.push(prepared.chunk);
}
}