use crate::cli::errors::detect_corruption;
use crate::cli::index_job::{IndexJobSnapshot, IndexJobState, JobPaths, JobStatus, new_job_id};
use crate::cli::leindex::{IndexStats, LeIndex};
use crate::cli::mcp::protocol::JsonRpcError;
use crate::cli::watcher::IndexWatcher;
use dirs;
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use tracing::{debug, info, warn};
mod index_jobs;
pub const DEFAULT_MAX_PROJECTS: usize = 5;
pub const STALE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
pub const INDEX_ATTEMPT_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
pub const AUTO_INDEX_WAIT_ENV: &str = "LEINDEX_AUTO_INDEX_WAIT_MS";
pub const DEFAULT_AUTO_INDEX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
pub fn auto_index_wait() -> std::time::Duration {
std::env::var(AUTO_INDEX_WAIT_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.map(std::time::Duration::from_millis)
.unwrap_or(DEFAULT_AUTO_INDEX_WAIT)
}
pub const WATCHER_ENABLE_ENV: &str = "LEINDEX_WATCHER";
pub fn watcher_enabled() -> bool {
match std::env::var(WATCHER_ENABLE_ENV) {
Ok(v) => matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
}
}
fn generation_has_indexed_nodes(storage_root: &Path, generation: u64) -> bool {
let stats_path = storage_root
.join("generations")
.join(generation.to_string())
.join("index_stats.json");
let Ok(json) = std::fs::read_to_string(&stats_path) else {
return false;
};
serde_json::from_str::<IndexStats>(&json)
.map(|stats| stats.indexed_nodes > 0)
.unwrap_or(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hydration {
None,
Graph,
Full,
}
pub struct ProjectRwLock {
inner: Mutex<LeIndex>,
}
impl ProjectRwLock {
pub fn new(leindex: LeIndex) -> Self {
Self {
inner: Mutex::new(leindex),
}
}
pub async fn read(&self) -> ProjectReadGuard<'_> {
ProjectReadGuard {
inner: self.inner.lock().await,
}
}
pub fn blocking_read(&self) -> ProjectReadGuard<'_> {
ProjectReadGuard {
inner: self.inner.blocking_lock(),
}
}
pub async fn write(&self) -> ProjectWriteGuard<'_> {
ProjectWriteGuard {
inner: self.inner.lock().await,
}
}
#[allow(clippy::result_unit_err)]
pub fn try_write(&self) -> Result<ProjectWriteGuard<'_>, ()> {
match self.inner.try_lock() {
Ok(guard) => Ok(ProjectWriteGuard { inner: guard }),
Err(_) => Err(()),
}
}
pub fn blocking_write(&self) -> ProjectWriteGuard<'_> {
ProjectWriteGuard {
inner: self.inner.blocking_lock(),
}
}
}
pub struct ProjectReadGuard<'a> {
inner: tokio::sync::MutexGuard<'a, LeIndex>,
}
impl std::ops::Deref for ProjectReadGuard<'_> {
type Target = LeIndex;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
pub struct ProjectWriteGuard<'a> {
inner: tokio::sync::MutexGuard<'a, LeIndex>,
}
impl std::ops::Deref for ProjectWriteGuard<'_> {
type Target = LeIndex;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl std::ops::DerefMut for ProjectWriteGuard<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
pub type ProjectHandle = Arc<ProjectRwLock>;
struct RefreshGuard {
registry: Arc<ProjectRegistry>,
path: PathBuf,
}
impl Drop for RefreshGuard {
fn drop(&mut self) {
if let Ok(mut map) = self.registry.incremental_refresh_guard.try_lock() {
map.insert(self.path.clone(), false);
}
}
}
pub struct ProjectRegistry {
pub(crate) projects: RwLock<HashMap<PathBuf, ProjectHandle>>,
lru_order: Mutex<VecDeque<PathBuf>>,
default_project: RwLock<Option<PathBuf>>,
index_slots: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
index_jobs: Mutex<HashMap<PathBuf, Arc<IndexJobState>>>,
max_projects: usize,
watchers: Mutex<HashMap<PathBuf, IndexWatcher>>,
stale_cache: RwLock<HashMap<PathBuf, (std::time::Instant, bool)>>,
failed_index_attempts: RwLock<HashMap<PathBuf, std::time::Instant>>,
incremental_refresh_guard: Mutex<HashMap<PathBuf, bool>>,
pub(crate) last_used: RwLock<HashMap<PathBuf, std::time::Instant>>,
one_shot: std::sync::atomic::AtomicBool,
prewarm_started: std::sync::atomic::AtomicBool,
hydration_flights: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
creation_flights: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
prewarming: std::sync::Mutex<std::collections::HashSet<PathBuf>>,
}
impl ProjectRegistry {
pub fn new(max_projects: usize) -> Self {
Self {
projects: RwLock::new(HashMap::new()),
lru_order: Mutex::new(VecDeque::new()),
default_project: RwLock::new(None),
index_slots: Mutex::new(HashMap::new()),
index_jobs: Mutex::new(HashMap::new()),
max_projects,
watchers: Mutex::new(HashMap::new()),
stale_cache: RwLock::new(HashMap::new()),
failed_index_attempts: RwLock::new(HashMap::new()),
incremental_refresh_guard: Mutex::new(HashMap::new()),
last_used: RwLock::new(HashMap::new()),
one_shot: std::sync::atomic::AtomicBool::new(false),
prewarm_started: std::sync::atomic::AtomicBool::new(false),
hydration_flights: Mutex::new(HashMap::new()),
creation_flights: Mutex::new(HashMap::new()),
prewarming: std::sync::Mutex::new(std::collections::HashSet::new()),
}
}
pub fn mark_one_shot(&self) {
self.one_shot
.store(true, std::sync::atomic::Ordering::Release);
}
pub fn is_one_shot(&self) -> bool {
self.one_shot.load(std::sync::atomic::Ordering::Acquire)
}
pub fn spawn_prewarm(self: &Arc<Self>) {
use std::sync::atomic::Ordering;
if self.is_one_shot() || self.prewarm_started.swap(true, Ordering::AcqRel) {
return;
}
self.spawn_prewarm_at(None);
}
pub fn disable_default_prewarm(&self) {
self.prewarm_started
.store(true, std::sync::atomic::Ordering::Release);
}
pub fn spawn_prewarm_at(self: &Arc<Self>, target: Option<PathBuf>) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
if self.is_one_shot() {
return;
}
let mode = crate::config::LeIndexConfig::load_cached()
.mcp
.prewarm
.clone();
let full = match mode.trim().to_ascii_lowercase().as_str() {
"off" | "false" | "none" | "0" => return,
"graph" => false,
_ => true,
};
if let Some(path) = &target {
let mut in_flight = self
.prewarming
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !in_flight.insert(path.clone()) {
return;
}
}
let registry = Arc::clone(self);
runtime.spawn(async move {
let requested = target
.as_ref()
.map(|path| path.to_string_lossy().into_owned());
registry.prewarm_project(requested.as_deref(), full).await;
if let Some(path) = &target {
registry
.prewarming
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(path);
}
});
}
async fn prewarm_project(self: &Arc<Self>, requested: Option<&str>, full: bool) {
let Ok(path) = self.resolve_path(requested).await else {
return;
};
let has_index = crate::cli::live_project::LiveProject::resolve(&path.to_string_lossy())
.is_ok_and(|live| live.catalog_db().is_file());
if !has_index {
debug!(project = %path.display(), "Prewarm skipped: project is not indexed");
return;
}
let started = std::time::Instant::now();
let level = if full {
Hydration::Full
} else {
Hydration::Graph
};
let ok = self.ensure_hydrated(requested, level).await;
info!(
project = %path.display(),
total_ms = started.elapsed().as_millis() as u64,
ok,
full,
"Prewarmed project"
);
}
pub async fn ensure_hydrated(
self: &Arc<Self>,
project_path: Option<&str>,
level: Hydration,
) -> bool {
let full = match level {
Hydration::None => return true,
Hydration::Graph => false,
Hydration::Full => true,
};
let Ok(handle) = self.get_or_load(project_path).await else {
return false;
};
if self.is_one_shot() {
return Self::ensure_hydrated_in_place(&handle, full).await;
}
let (root, indexed) = {
let idx = handle.read().await;
if idx.is_hydrated(full) {
return true;
}
(idx.project_path().to_path_buf(), idx.is_indexed())
};
if !indexed {
return false;
}
let flight = {
let mut flights = self.hydration_flights.lock().await;
Arc::clone(flights.entry(root.clone()).or_default())
};
let _flight = flight.lock().await;
if handle.read().await.is_hydrated(full) {
return true;
}
let generation = || {
crate::cli::leindex::resolve_existing_storage_path(&root).and_then(|storage| {
crate::storage::generation::lease::read_current_generation(&storage)
})
};
let before = generation();
let build_root = root.clone();
let built = tokio::task::spawn_blocking(move || {
let mut sibling = LeIndex::new(&build_root).ok()?;
let loaded = if full {
sibling.ensure_analysis_context_loaded()
} else {
sibling.ensure_pdg_loaded_graph_only()
};
loaded.ok()?;
Some(sibling)
})
.await
.ok()
.flatten();
let Some(sibling) = built else {
return false;
};
if generation() != before {
debug!(project = %root.display(), "Discarding off-lock hydration: a new generation was published");
return false;
}
handle.write().await.adopt_hydration(sibling, full);
handle.read().await.is_hydrated(full)
}
async fn ensure_hydrated_in_place(handle: &ProjectHandle, full: bool) -> bool {
let mut idx = handle.write().await;
if idx.is_hydrated(full) || !idx.is_indexed() {
return idx.is_hydrated(full);
}
let loaded = if full {
idx.ensure_analysis_context_loaded()
} else {
idx.ensure_pdg_loaded_graph_only()
};
loaded.is_ok() && idx.is_hydrated(full)
}
pub fn with_initial_project(max_projects: usize, leindex: LeIndex) -> Self {
let path = leindex.project_path().to_path_buf();
let handle: ProjectHandle = Arc::new(ProjectRwLock::new(leindex));
let mut map = HashMap::new();
map.insert(path.clone(), handle.clone());
let mut lru = VecDeque::new();
lru.push_back(path.clone());
let mut slots = HashMap::new();
slots.insert(path.clone(), Arc::new(Mutex::new(())));
let mut watchers = HashMap::new();
if watcher_enabled() {
if let Ok(w) = IndexWatcher::start(path.clone(), handle.clone()) {
watchers.insert(path.clone(), w);
}
}
let mut last_used = HashMap::new();
last_used.insert(path.clone(), std::time::Instant::now());
Self {
projects: RwLock::new(map),
lru_order: Mutex::new(lru),
default_project: RwLock::new(Some(path)),
index_slots: Mutex::new(slots),
index_jobs: Mutex::new(HashMap::new()),
max_projects,
watchers: Mutex::new(watchers),
stale_cache: RwLock::new(HashMap::new()),
failed_index_attempts: RwLock::new(HashMap::new()),
incremental_refresh_guard: Mutex::new(HashMap::new()),
last_used: RwLock::new(last_used),
one_shot: std::sync::atomic::AtomicBool::new(false),
prewarm_started: std::sync::atomic::AtomicBool::new(false),
hydration_flights: Mutex::new(HashMap::new()),
creation_flights: Mutex::new(HashMap::new()),
prewarming: std::sync::Mutex::new(std::collections::HashSet::new()),
}
}
pub async fn invalidate_stale_cache(&self, path: &Path) {
self.stale_cache.write().await.remove(path);
}
pub async fn get_or_load(
&self,
project_path: Option<&str>,
) -> Result<ProjectHandle, JsonRpcError> {
let canonical = self.resolve_path(project_path).await?;
{
let projects = self.projects.read().await;
if let Some(handle) = projects.get(&canonical) {
let advanced = {
let idx = handle.read().await;
let storage_root =
crate::cli::leindex::resolve_existing_storage_path(&canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
if idx.hydrated_generation().is_some_and(|hydrated| {
crate::storage::generation::lease::read_current_generation(&storage_root)
.is_some_and(|disk| disk > hydrated)
}) {
true
} else {
!idx.is_indexed()
&& crate::storage::generation::lease::read_current_generation(
&storage_root,
)
.is_some_and(|generation| {
generation > 0
&& generation_has_indexed_nodes(&storage_root, generation)
})
}
};
if advanced {
drop(projects);
warn!(
project = %canonical.display(),
"Persisted generation advanced past the hydrated snapshot; re-hydrating"
);
self.evict(&canonical).await;
return self.create_and_insert(canonical).await;
}
self.touch_lru(&canonical).await;
self.touch_last_used(&canonical).await;
return Ok(handle.clone());
}
}
self.create_and_insert(canonical).await
}
pub async fn get_or_create(
self: &Arc<Self>,
project_path: Option<&str>,
) -> Result<ProjectHandle, JsonRpcError> {
let handle = self.get_or_load(project_path).await?;
let canonical = {
let idx = handle.read().await;
idx.project_path().to_path_buf()
};
let (needs_index, needs_refresh) = {
let idx = handle.read().await;
let not_indexed = !idx.is_indexed();
let stale = if not_indexed {
false
} else {
let cache = self.stale_cache.read().await;
if let Some((ts, result)) = cache.get(&canonical) {
if ts.elapsed() < STALE_CACHE_TTL {
*result
} else {
drop(cache);
let fresh = idx.is_stale_fast();
self.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), fresh));
fresh
}
} else {
drop(cache);
let fresh = idx.is_stale_fast();
self.stale_cache
.write()
.await
.insert(canonical.clone(), (std::time::Instant::now(), fresh));
fresh
}
};
(not_indexed, stale)
};
if needs_index {
let skip_attempt = {
let cache = self.failed_index_attempts.read().await;
cache
.get(&canonical)
.is_some_and(|ts| ts.elapsed() < INDEX_ATTEMPT_COOLDOWN)
};
if skip_attempt {
debug!(
project = %canonical.display(),
"Skipping auto-index within cooldown after a recent failure"
);
} else if let Err(error) = self.auto_index(&handle, &canonical).await {
warn!(
project = %canonical.display(),
"Auto-index failed; serving project without a fresh index: {error}"
);
self.failed_index_attempts
.write()
.await
.insert(canonical.clone(), std::time::Instant::now());
}
} else if needs_refresh {
self.maybe_incremental_refresh(&handle, &canonical);
debug!(
"Index is stale; serving existing results while incremental refresh runs in background"
);
}
Ok(handle)
}
async fn auto_index(
self: &Arc<Self>,
handle: &ProjectHandle,
canonical: &Path,
) -> Result<(), JsonRpcError> {
if self.is_one_shot() {
return self.index_handle(handle, false, false).await.map(|_| ());
}
let path_string = canonical.to_string_lossy().into_owned();
let job = self.start_index_job(Some(&path_string), false, true);
match tokio::time::timeout(auto_index_wait(), job).await {
Ok(Ok(snapshot)) if snapshot.status == JobStatus::Failed => {
Err(JsonRpcError::indexing_failed(
snapshot
.last_error
.unwrap_or_else(|| "background index job failed".to_string()),
))
}
Ok(Ok(_)) => Ok(()),
Ok(Err(error)) => Err(error),
Err(_) => {
info!(
project = %canonical.display(),
wait_ms = auto_index_wait().as_millis() as u64,
"Cold index still running; continuing in the background"
);
Ok(())
}
}
}
pub fn maybe_incremental_refresh(
self: &Arc<Self>,
_handle: &ProjectHandle,
project_path: &Path,
) {
if self.one_shot.load(std::sync::atomic::Ordering::Acquire) {
return;
}
{
let guard = self.incremental_refresh_guard.try_lock();
match guard {
Ok(mut map) => {
if *map.get(project_path).unwrap_or(&false) {
return;
}
map.insert(project_path.to_path_buf(), true);
}
Err(_) => {
return;
}
}
}
let registry = Arc::clone(self);
let path = project_path.to_path_buf();
let path_string = path.to_string_lossy().into_owned();
tokio::spawn(async move {
let _guard = RefreshGuard {
registry: Arc::clone(®istry),
path: path.clone(),
};
debug!(
project = %path.display(),
"Starting background incremental refresh"
);
let result = match registry.get_or_load(Some(&path_string)).await {
Ok(handle) => registry.index_handle(&handle, false, true).await,
Err(error) => Err(error),
};
match result {
Ok(stats) => {
debug!(
project = %path.display(),
files_parsed = stats.files_parsed,
"Background incremental refresh completed"
);
registry.invalidate_stale_cache(&path).await;
}
Err(error) => {
warn!(
project = %path.display(),
error = %error,
"Background incremental refresh failed; existing index remains usable"
);
}
}
});
}
pub async fn index_project(
&self,
project_path: Option<&str>,
force_reindex: bool,
) -> Result<IndexStats, JsonRpcError> {
let handle = self.get_or_load(project_path).await?;
self.index_handle(&handle, force_reindex, false).await
}
pub async fn len(&self) -> usize {
self.projects.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.projects.read().await.is_empty()
}
pub async fn loaded_projects(&self) -> Vec<PathBuf> {
self.projects.read().await.keys().cloned().collect()
}
pub async fn evict(&self, path: &Path) {
let removed = {
let mut projects = self.projects.write().await;
match projects.remove(path) {
Some(handle) => {
if let Ok(mut idx) = handle.try_write() {
if let Err(e) = idx.close() {
warn!(
"Failed to close storage for evicted project {}: {}",
path.display(),
e
);
}
}
info!("Evicted project: {}", path.display());
true
}
None => false,
}
};
if removed {
self.cleanup_evicted(path).await;
}
}
pub(crate) async fn cleanup_evicted(&self, path: &Path) {
let mut lru = self.lru_order.lock().await;
lru.retain(|p| p != path);
drop(lru);
let mut slots = self.index_slots.lock().await;
slots.remove(path);
drop(slots);
let mut watchers = self.watchers.lock().await;
watchers.remove(path);
drop(watchers);
self.stale_cache.write().await.remove(path);
self.index_jobs.lock().await.remove(path);
self.incremental_refresh_guard.lock().await.remove(path);
self.last_used.write().await.remove(path);
}
pub async fn resolve_project_root(
&self,
project_path: Option<&str>,
) -> Result<PathBuf, JsonRpcError> {
self.resolve_path(project_path).await
}
async fn resolve_path(&self, project_path: Option<&str>) -> Result<PathBuf, JsonRpcError> {
let path = if let Some(raw) = project_path {
Path::new(raw).to_path_buf()
} else if let Some(designated) = self.default_project.read().await.clone() {
designated
} else if let Ok(cwd) = std::env::current_dir() {
cwd
} else {
return Err(JsonRpcError::invalid_params(
"No project_path provided, no startup --project given, and CWD is \
unavailable. Pass project_path on the first call.",
));
};
let canonical = path.canonicalize().map_err(|e| {
JsonRpcError::invalid_params(format!(
"Cannot resolve project_path '{}': {}",
path.display(),
e
))
})?;
if canonical.parent().is_none() {
return Err(JsonRpcError::invalid_params(
"Refusing to index root directory. Specify a project subdirectory.".to_string(),
));
}
if let Some(home_dir) = dirs::home_dir() {
let home_canonical = home_dir.canonicalize().unwrap_or(home_dir);
if canonical == home_canonical {
return Err(JsonRpcError::invalid_params(
"Refusing to index home directory. Specify a project subdirectory.".to_string(),
));
}
}
Ok(canonical)
}
async fn create_and_insert(&self, canonical: PathBuf) -> Result<ProjectHandle, JsonRpcError> {
{
let projects = self.projects.read().await;
if let Some(handle) = projects.get(&canonical) {
self.touch_lru(&canonical).await;
return Ok(handle.clone());
}
}
let flight = {
let mut flights = self.creation_flights.lock().await;
Arc::clone(flights.entry(canonical.clone()).or_default())
};
let _creation = flight.lock().await;
{
let projects = self.projects.read().await;
if let Some(handle) = projects.get(&canonical) {
self.touch_lru(&canonical).await;
return Ok(handle.clone());
}
}
self.evict_lru_if_needed().await;
let build_canonical = canonical.clone();
let leindex = tokio::task::spawn_blocking(move || Self::build_leindex(&build_canonical))
.await
.map_err(|e| JsonRpcError::internal_error(format!("project creation task: {e}")))?
.map_err(|e| JsonRpcError::init_failed(&canonical.display().to_string(), &e))?;
crate::cli::mcp::request_meta::record_hydrate_ms(0);
let handle: ProjectHandle = Arc::new(ProjectRwLock::new(leindex));
{
let mut projects = self.projects.write().await;
projects.insert(canonical.clone(), handle.clone());
}
self.touch_last_used(&canonical).await;
if watcher_enabled() {
let mut watchers = self.watchers.lock().await;
if !watchers.contains_key(&canonical) {
if let Ok(w) = IndexWatcher::start(canonical.clone(), handle.clone()) {
watchers.insert(canonical.clone(), w);
}
}
}
self.touch_lru(&canonical).await;
let mut slots = self.index_slots.lock().await;
slots
.entry(canonical.clone())
.or_insert_with(|| Arc::new(Mutex::new(())));
info!(
"Loaded project into registry: {} ({} total)",
canonical.display(),
self.projects.read().await.len()
);
Ok(handle)
}
async fn cached_index_stats(handle: &ProjectHandle) -> Option<IndexStats> {
let idx = handle.read().await;
if idx.is_indexed() && !idx.is_stale_fast() {
Some(idx.get_stats().clone())
} else {
None
}
}
fn build_leindex(canonical: &Path) -> Result<LeIndex, String> {
let mut leindex = LeIndex::new(canonical).map_err(|e| e.to_string())?;
let corruption =
detect_corruption(canonical).unwrap_or(crate::cli::errors::CorruptionStatus::Healthy);
if !corruption.is_usable() {
warn!(
"Corruption detected in {}: {}. Auto-repairing...",
canonical.display(),
corruption.message()
);
let storage_path = crate::cli::leindex::resolve_existing_storage_path(canonical)
.unwrap_or_else(|| canonical.join(".leindex"));
if restore_latest_generation(&storage_path) {
warn!(
"Rolled back {} to latest usable generation; preserved corrupt root artifact",
canonical.display()
);
}
let mut fresh = LeIndex::new(canonical).map_err(|e| {
format!(
"Original: {}. Preserving artifacts: {}",
corruption.message(),
e
)
})?;
fresh
.index_project(true)
.map_err(|e| format!("Auto-repair reindex failed: {e}"))?;
leindex = fresh;
}
Ok(leindex)
}
async fn index_handle(
&self,
handle: &ProjectHandle,
force_reindex: bool,
background: bool,
) -> Result<IndexStats, JsonRpcError> {
let project_path = {
let idx = handle.read().await;
idx.project_path().to_path_buf()
};
let slot = self.index_slot_for(&project_path).await;
let _slot_guard = slot.lock().await;
if !force_reindex {
if let Some(stats) = Self::cached_index_stats(handle).await {
return Ok(stats);
}
}
debug!(
"Indexing project (consolidated): {} force_reindex={}",
project_path.display(),
force_reindex
);
let previous_generation = crate::cli::index_freshness::load_health(
&crate::cli::leindex::resolve_existing_storage_path(&project_path)
.unwrap_or_else(|| project_path.join(".leindex")),
)
.map(|health| health.generation)
.unwrap_or(0);
let path_for_blocking = project_path.clone();
let indexing = tokio::task::spawn_blocking(move || {
let mut temp = LeIndex::new(&path_for_blocking).map_err(|e| {
JsonRpcError::init_failed(&path_for_blocking.display().to_string(), &e.to_string())
})?;
let run = |temp: &mut LeIndex| {
temp.index_project(force_reindex)
.map_err(|e| JsonRpcError::indexing_failed(format!("Indexing failed: {}", e)))
};
if background {
background_pool().install(|| run(&mut temp))?;
} else {
run(&mut temp)?;
}
Ok::<LeIndex, JsonRpcError>(temp)
});
tokio::pin!(indexing);
let mut resident_core_generation = previous_generation;
let indexing_result = loop {
tokio::select! {
result = &mut indexing => break result,
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
self.refresh_resident_core_if_published(
&project_path,
&mut resident_core_generation,
)
.await;
}
}
};
let temp = match indexing_result {
Ok(Ok(temp)) => temp,
Ok(Err(error)) => {
let core_published = self
.refresh_core_after_index_failure(&project_path, resident_core_generation)
.await;
let message = error.to_string();
if is_transient_storage_open_failure(&message) {
warn!(
project = %project_path.display(),
"Transient storage-open failure (lock contention); \
leaving generation status unchanged (not bricking): {message}"
);
} else {
mark_index_failure(&project_path, &message, core_published);
}
return Err(error);
}
Err(error) => {
let should_mark_failure = join_error_should_mark_failure(&error);
let cancelled = error.is_cancelled();
let error = JsonRpcError::internal_error(format!(
"{} error: {}",
if cancelled {
"Indexing task cancelled"
} else {
"Task join"
},
error
));
let core_published = self
.refresh_core_after_index_failure(&project_path, resident_core_generation)
.await;
if should_mark_failure {
mark_index_failure(&project_path, &error.to_string(), core_published);
} else {
warn!(
project = %project_path.display(),
"Indexing task was cancelled; leaving generation health untouched \
(cancellation is not an indexing failure): {}",
error.to_string()
);
}
return Err(error);
}
};
self.install_indexed_instance(handle, temp, &project_path)
.await?;
self.stale_cache.write().await.remove(&project_path);
self.failed_index_attempts
.write()
.await
.remove(&project_path);
let stats = {
let idx = handle.read().await;
idx.get_stats().clone()
};
Ok(stats)
}
async fn install_indexed_instance(
&self,
handle: &ProjectHandle,
temp: crate::cli::leindex::LeIndex,
project_path: &Path,
) -> Result<(), JsonRpcError> {
if !temp.last_index_coalesced {
let mut idx = handle.write().await;
*idx = temp;
return Ok(());
}
drop(temp);
self.force_refresh_loaded_from_active_generation(project_path)
.await
}
async fn index_slot_for(&self, path: &Path) -> Arc<Mutex<()>> {
let mut slots = self.index_slots.lock().await;
slots
.entry(path.to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
async fn touch_lru(&self, path: &Path) {
let mut lru = self.lru_order.lock().await;
lru.retain(|p| p != path);
lru.push_back(path.to_path_buf());
}
pub async fn set_default_path(&self, path: PathBuf) {
let canonical = path.canonicalize().unwrap_or_else(|err| {
warn!(
"Failed to canonicalize default project path {}: {}",
path.display(),
err
);
path.clone()
});
let mut default = self.default_project.write().await;
*default = Some(canonical);
}
pub async fn default_project_path(&self) -> Result<PathBuf, JsonRpcError> {
self.resolve_path(None).await
}
pub async fn try_get_loaded(&self, path: &Path) -> Option<ProjectHandle> {
self.projects.read().await.get(path).cloned()
}
pub async fn lease_generation(
&self,
project: &Path,
) -> Result<crate::storage::GenerationLease, crate::storage::LeaseError> {
use crate::storage::GenerationLease;
use crate::storage::LeaseError;
use crate::storage::generation::{read_current_generation, read_generation_manifest};
use std::sync::{Arc as StdArc, Mutex as StdMutex};
let storage_path = crate::cli::leindex::resolve_existing_storage_path(project)
.unwrap_or_else(|| project.join(".leindex"));
let cas_dir = storage_path.join("cas");
let store = StdArc::new(StdMutex::new(
crate::storage::CasStore::open(&cas_dir).map_err(|e| {
LeaseError::Io(std::io::Error::other(format!("cas open failed: {e}")))
})?,
));
let mut attempts = 0usize;
loop {
attempts += 1;
let generation = read_current_generation(&storage_path)
.ok_or_else(|| LeaseError::NoCurrentGeneration(project.display().to_string()))?;
let manifest = read_generation_manifest(&storage_path, generation)
.map_err(LeaseError::InvalidManifest)?;
match GenerationLease::acquire(StdArc::clone(&store), &manifest) {
Ok(lease) => return Ok(lease),
Err(LeaseError::GenerationPruned { .. }) if attempts < 3 => {
tracing::debug!(
project = %project.display(),
generation,
attempt = attempts,
"generation pruned during lease acquisition; retrying against CURRENT"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Err(error) => return Err(error),
}
}
}
async fn refresh_resident_core_if_published(&self, path: &Path, resident_generation: &mut u64) {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(path)
.unwrap_or_else(|| path.join(".leindex"));
let Some(health) = crate::cli::index_freshness::load_health(&storage_path) else {
return;
};
if health.phase != crate::cli::leindex::IndexPhase::Complete
|| health.generation <= *resident_generation
{
return;
}
match self.refresh_loaded_from_active_generation(path).await {
Ok(()) => *resident_generation = health.generation,
Err(error) => debug!(
project = %path.display(),
"Published core generation is waiting for resident hydration: {error}"
),
}
}
async fn refresh_loaded_from_active_generation(&self, path: &Path) -> Result<(), JsonRpcError> {
let Some(handle) = self.try_get_loaded(path).await else {
return Ok(());
};
tokio::task::spawn_blocking(move || {
let mut index = handle.blocking_write();
index.load_from_active_storage().map_err(|error| {
JsonRpcError::internal_error(format!(
"Failed to hydrate published core generation: {error:#}"
))
})
})
.await
.map_err(|error| {
JsonRpcError::internal_error(format!("Core hydration task failed: {error}"))
})?
}
async fn force_refresh_loaded_from_active_generation(
&self,
path: &Path,
) -> Result<(), JsonRpcError> {
let Some(handle) = self.try_get_loaded(path).await else {
return Ok(());
};
tokio::task::spawn_blocking(move || {
let mut index = handle.blocking_write();
index.force_reload_from_active_storage().map_err(|error| {
JsonRpcError::internal_error(format!(
"Failed to hydrate published core generation: {error:#}"
))
})
})
.await
.map_err(|error| {
JsonRpcError::internal_error(format!("Core hydration task failed: {error}"))
})?
}
async fn refresh_core_after_index_failure(
&self,
path: &Path,
previous_generation: u64,
) -> bool {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(path)
.unwrap_or_else(|| path.join(".leindex"));
let Some(health) = crate::cli::index_freshness::load_health(&storage_path) else {
return false;
};
if health.phase != crate::cli::leindex::IndexPhase::Complete
|| health.generation <= previous_generation
{
return false;
}
if let Err(refresh_error) = self.refresh_loaded_from_active_generation(path).await {
debug!(
project = %path.display(),
"Published core generation could not be made resident after indexing failure: {refresh_error}"
);
}
true
}
async fn evict_lru_if_needed(&self) {
let current_count = self.projects.read().await.len();
if current_count < self.max_projects && !self.over_heap_budget().await {
return;
}
let evict_path = {
let mut lru = self.lru_order.lock().await;
lru.pop_front()
};
if let Some(path) = evict_path {
let removed = {
let mut projects = self.projects.write().await;
match projects.remove(&path) {
Some(handle) => {
if let Ok(mut idx) = handle.try_write() {
if let Err(e) = idx.close() {
warn!(
"Failed to close storage for LRU-evicted project {}: {}",
path.display(),
e
);
}
}
true
}
None => false,
}
};
if removed {
self.cleanup_evicted(&path).await;
info!(
"Evicted LRU project: {} (capacity: {})",
path.display(),
self.max_projects
);
}
}
}
async fn over_heap_budget(&self) -> bool {
let budget_bytes = registry_heap_budget_bytes();
if budget_bytes == 0 {
return false;
}
let handles: Vec<ProjectHandle> = {
let projects = self.projects.read().await;
if projects.len() < 2 {
return false;
}
projects.values().cloned().collect()
};
let mut total: usize = 0;
for handle in &handles {
let idx = handle.read().await;
total = total.saturating_add(idx.search_engine.estimated_memory_bytes());
}
if total > budget_bytes {
warn!(
estimated_bytes = total,
budget_bytes, "registry heap budget exceeded; evicting LRU projects"
);
true
} else {
false
}
}
}
fn background_pool() -> &'static rayon::ThreadPool {
static POOL: std::sync::OnceLock<rayon::ThreadPool> = std::sync::OnceLock::new();
POOL.get_or_init(|| {
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(2);
rayon::ThreadPoolBuilder::new()
.num_threads((cores / 2).clamp(1, 2))
.thread_name(|i| format!("leindex-refresh-{i}"))
.start_handler(|_| lower_thread_priority())
.build()
.unwrap_or_else(|error| panic!("failed to build refresh pool: {error}"))
})
}
#[cfg(target_os = "linux")]
fn lower_thread_priority() {
unsafe {
let tid = libc::syscall(libc::SYS_gettid) as libc::id_t;
let _ = libc::setpriority(libc::PRIO_PROCESS, tid, 10);
}
}
#[cfg(not(target_os = "linux"))]
fn lower_thread_priority() {}
fn registry_heap_budget_bytes() -> usize {
let mb = std::env::var("LEINDEX_REGISTRY_MAX_HEAP_MB")
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(1536);
mb.saturating_mul(1024 * 1024)
}
fn restore_latest_generation(storage_path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(storage_path.join("generations")) else {
return false;
};
let mut generations = entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().to_str()?.parse::<u64>().ok())
.collect::<Vec<_>>();
generations.sort_unstable_by(|a, b| b.cmp(a));
for generation in generations {
let mirror = storage_path
.join("generations")
.join(generation.to_string())
.join("leindex.db");
let db_bytes = if mirror.is_file() {
std::fs::read(&mirror).ok()
} else {
crate::storage::generation::read_generation_manifest(storage_path, generation)
.ok()
.and_then(|manifest| {
manifest
.layers
.get(&crate::storage::generation::LayerKind::Db)
.copied()
})
.and_then(|hash| {
crate::storage::cas::CasStore::open(storage_path.join("cas"))
.ok()
.and_then(|cas| cas.get(&hash).ok())
})
};
let Some(db_bytes) = db_bytes else {
continue;
};
let target = storage_path.join("leindex.db");
if target.is_file() {
let backup = storage_path.join(format!("leindex.db.corrupt-{generation}"));
if std::fs::rename(&target, &backup).is_err() {
continue;
}
}
let next = storage_path.join("leindex.db.recovery.next");
if std::fs::write(&next, &db_bytes).is_err() || std::fs::rename(&next, &target).is_err() {
let _ = std::fs::remove_file(&next);
return false;
}
let _ = std::fs::remove_file(storage_path.join("leindex.db-wal"));
let _ = std::fs::remove_file(storage_path.join("leindex.db-shm"));
let _ = std::fs::write(storage_path.join("CURRENT"), format!("{generation}\n"));
return true;
}
false
}
fn is_transient_storage_open_failure(message: &str) -> bool {
message.contains("[transient:lock-contention]")
}
fn join_error_should_mark_failure(error: &tokio::task::JoinError) -> bool {
!error.is_cancelled()
}
fn mark_index_failure(project_path: &Path, message: &str, core_published: bool) {
let storage_path = crate::cli::leindex::resolve_existing_storage_path(project_path)
.unwrap_or_else(|| project_path.join(".leindex"));
let previous = crate::cli::index_freshness::load_health(&storage_path).unwrap_or_default();
let health = crate::cli::leindex::IndexHealth {
generation: previous.generation,
phase: previous.phase,
status: if core_published {
previous.status
} else {
crate::cli::leindex::ComponentStatus::Failed
},
head_oid: previous.head_oid,
tree_oid: previous.tree_oid,
indexed_file_count: previous.indexed_file_count,
dirty_file_count: previous.dirty_file_count,
changed_unindexed_count: previous.changed_unindexed_count,
indexed_at_unix_ms: previous.indexed_at_unix_ms,
last_failure_phase: Some(if core_published {
crate::cli::leindex::IndexPhase::Neural
} else {
previous.phase
}),
last_failure: Some(message.to_string()),
};
let _ = crate::cli::index_freshness::save_health(&storage_path, &health);
}
#[cfg(test)]
#[path = "registry_test.rs"]
mod tests;