Skip to content

Commit 50bd4fa

Browse files
committed
Indexing: Dir stats queries bypass INDEXING mutex
`get_dir_stats` and `get_dir_stats_batch` now use `ReadPool` for lock-free reads instead of locking the `INDEXING` mutex. This was the last read-only path going through the mutex. - Rewrite module-level `get_dir_stats`/`get_dir_stats_batch` to use `get_read_pool()` + `pool.with_conn()`, calling `store::resolve_path` and `IndexStore` queries directly - Remove both methods from `IndexManager` — no longer needed - The `INDEXING` mutex now purely guards lifecycle transitions (start, stop, clear, status)
1 parent 44abfd1 commit 50bd4fa

2 files changed

Lines changed: 61 additions & 79 deletions

File tree

apps/desktop/src-tauri/src/indexing/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
107107

108108
**Enrichment uses integer-keyed batch lookup**: Instead of N individual `resolve_path()` calls (one per directory in the listing), `enrich_entries_with_index` resolves the parent directory once, queries `list_child_dir_ids_and_names(parent_id)` for all child dir IDs, then `get_dir_stats_batch_by_ids()`. Two indexed queries total instead of N. Falls back to individual path resolution for edge cases (for example, mixed-parent entries).
109109

110-
**IPC boundary stays path-based**: Frontend sends filesystem paths, backend resolves path→ID internally via `store::resolve_path()`. No frontend changes needed.
110+
**IPC boundary stays path-based**: Frontend sends filesystem paths, backend resolves path→ID internally via `store::resolve_path()`. No frontend changes needed. IPC dir stats queries (`get_dir_stats`, `get_dir_stats_batch`) use `ReadPool` for lock-free reads, same as enrichment.
111111

112112
**Physical sizes (`st_blocks * 512`)**: More meaningful for disk usage than logical size. May overcount ~10-20% for APFS clones (shared blocks). Volume usage bar uses `statfs()` for true totals.
113113

@@ -153,7 +153,7 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
153153

154154
**Scan cancellation leaves partial data**: By design. `scan_completed_at` not set in meta, so next startup detects incomplete scan and runs fresh. No cleanup needed.
155155

156-
**`ReadPool` replaces `INDEXING` lock for all read-only DB access**: Enrichment (`enrich_entries_with_index` in `enrichment.rs`), verification Phase 1 (`verify_affected_dirs` in `event_loop.rs`), and background verification dir-stat reads all use `get_read_pool()` + `pool.with_conn()` — thread-local SQLite connections with no lock contention. The `INDEXING` mutex now guards only lifecycle transitions and IPC commands needing the `IndexManager`'s read connection. `with_conn` uses `thread_local!` storage, so callers must not have `.await` points between obtaining the pool and completing the closure (async task migration would break thread affinity).
156+
**`ReadPool` replaces `INDEXING` lock for all read-only DB access**: Enrichment (`enrich_entries_with_index` in `enrichment.rs`), verification Phase 1 (`verify_affected_dirs` in `event_loop.rs`), background verification dir-stat reads, and IPC dir stats queries (`get_dir_stats`, `get_dir_stats_batch` in `mod.rs`) all use `get_read_pool()` + `pool.with_conn()` — thread-local SQLite connections with no lock contention. The `INDEXING` mutex now guards only lifecycle transitions (start, stop, clear, status). `with_conn` uses `thread_local!` storage, so callers must not have `.await` points between obtaining the pool and completing the closure (async task migration would break thread affinity).
157157

158158
**Progress events use `tauri::async_runtime::spawn`**: Not `tokio::spawn`, because indexing can start from Tauri's synchronous `setup()` hook where no Tokio runtime context exists.
159159

apps/desktop/src-tauri/src/indexing/mod.rs

Lines changed: 59 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use std::sync::LazyLock;
2727
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2828
use std::time::Duration;
2929

30-
use enrichment::{READ_POOL, ReadPool};
30+
use enrichment::{READ_POOL, ReadPool, get_read_pool};
3131
use event_loop::{
3232
JOURNAL_GAP_THRESHOLD, ReplayConfig, WATCHER_CHANNEL_CAPACITY, run_live_event_loop, run_replay_event_loop,
3333
};
@@ -116,8 +116,7 @@ use events::emit_rescan_notification;
116116

117117
/// Central coordinator for the drive indexing system.
118118
///
119-
/// Owns the SQLite store (reads), the writer thread (writes), the path resolver
120-
/// (LRU-cached path→ID mapping) and the scanner handle.
119+
/// Owns the SQLite store (reads), the writer thread (writes), and the scanner handle.
121120
/// Accessed by module-level functions that lock the `INDEXING` static.
122121
pub struct IndexManager {
123122
/// Volume ID (for example, "root" for /)
@@ -746,70 +745,6 @@ impl IndexManager {
746745
})
747746
}
748747

749-
/// Look up recursive stats for a single directory.
750-
///
751-
/// Resolves the path to an entry ID via `store::resolve_path`,
752-
/// then fetches `dir_stats` by integer ID.
753-
pub fn get_dir_stats(&self, path: &str) -> Result<Option<DirStats>, String> {
754-
let normalized = firmlinks::normalize_path(path);
755-
let conn = self.store.read_conn();
756-
757-
let entry_id =
758-
match store::resolve_path(conn, &normalized).map_err(|e| format!("Failed to resolve path: {e}"))? {
759-
Some(id) => id,
760-
None => return Ok(None),
761-
};
762-
763-
let stats =
764-
IndexStore::get_dir_stats_by_id(conn, entry_id).map_err(|e| format!("Failed to get dir stats: {e}"))?;
765-
766-
Ok(stats.map(|s| DirStats {
767-
path: normalized,
768-
recursive_size: s.recursive_size,
769-
recursive_file_count: s.recursive_file_count,
770-
recursive_dir_count: s.recursive_dir_count,
771-
}))
772-
}
773-
774-
/// Batch lookup of dir_stats for multiple paths.
775-
///
776-
/// Resolves each path to an entry ID via `store::resolve_path`,
777-
/// then batch-fetches `dir_stats` by integer IDs.
778-
pub fn get_dir_stats_batch(&self, paths: &[String]) -> Result<Vec<Option<DirStats>>, String> {
779-
let conn = self.store.read_conn();
780-
781-
let mut results = Vec::with_capacity(paths.len());
782-
let mut id_to_idx: Vec<(i64, usize, String)> = Vec::new();
783-
784-
for (i, path) in paths.iter().enumerate() {
785-
let normalized = firmlinks::normalize_path(path);
786-
match store::resolve_path(conn, &normalized).map_err(|e| format!("Failed to resolve path: {e}"))? {
787-
Some(id) => {
788-
id_to_idx.push((id, i, normalized));
789-
results.push(None); // Placeholder, filled below
790-
}
791-
None => results.push(None),
792-
}
793-
}
794-
795-
if !id_to_idx.is_empty() {
796-
let ids: Vec<i64> = id_to_idx.iter().map(|(id, _, _)| *id).collect();
797-
let stats_batch = IndexStore::get_dir_stats_batch_by_ids(conn, &ids)
798-
.map_err(|e| format!("Failed to get dir stats batch: {e}"))?;
799-
800-
for ((_, idx, normalized), stats_opt) in id_to_idx.into_iter().zip(stats_batch) {
801-
results[idx] = stats_opt.map(|s| DirStats {
802-
path: normalized,
803-
recursive_size: s.recursive_size,
804-
recursive_file_count: s.recursive_file_count,
805-
recursive_dir_count: s.recursive_dir_count,
806-
});
807-
}
808-
}
809-
810-
Ok(results)
811-
}
812-
813748
/// Return the DB file path for this index.
814749
pub fn db_path(&self) -> &Path {
815750
self.store.db_path()
@@ -1106,20 +1041,67 @@ pub fn get_debug_status() -> Result<IndexDebugStatusResponse, String> {
11061041

11071042
/// Look up recursive stats for a single directory.
11081043
pub fn get_dir_stats(path: &str) -> Result<Option<DirStats>, String> {
1109-
let guard = INDEXING.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
1110-
match &*guard {
1111-
IndexPhase::Running(mgr) => mgr.get_dir_stats(path),
1112-
_ => Err("Indexing not initialized".to_string()),
1113-
}
1044+
let pool = get_read_pool().ok_or_else(|| "Indexing not initialized".to_string())?;
1045+
let normalized = firmlinks::normalize_path(path);
1046+
1047+
pool.with_conn(|conn| {
1048+
let entry_id = match store::resolve_path(conn, &normalized)
1049+
.map_err(|e| format!("Couldn't resolve path: {e}"))?
1050+
{
1051+
Some(id) => id,
1052+
None => return Ok(None),
1053+
};
1054+
1055+
let stats = IndexStore::get_dir_stats_by_id(conn, entry_id)
1056+
.map_err(|e| format!("Couldn't get dir stats: {e}"))?;
1057+
1058+
Ok(stats.map(|s| DirStats {
1059+
path: normalized.clone(),
1060+
recursive_size: s.recursive_size,
1061+
recursive_file_count: s.recursive_file_count,
1062+
recursive_dir_count: s.recursive_dir_count,
1063+
}))
1064+
})?
11141065
}
11151066

11161067
/// Batch lookup of dir_stats for multiple paths.
11171068
pub fn get_dir_stats_batch(paths: &[String]) -> Result<Vec<Option<DirStats>>, String> {
1118-
let guard = INDEXING.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
1119-
match &*guard {
1120-
IndexPhase::Running(mgr) => mgr.get_dir_stats_batch(paths),
1121-
_ => Err("Indexing not initialized".to_string()),
1122-
}
1069+
let pool = get_read_pool().ok_or_else(|| "Indexing not initialized".to_string())?;
1070+
1071+
pool.with_conn(|conn| {
1072+
let mut results = Vec::with_capacity(paths.len());
1073+
let mut id_to_idx: Vec<(i64, usize, String)> = Vec::new();
1074+
1075+
for (i, path) in paths.iter().enumerate() {
1076+
let normalized = firmlinks::normalize_path(path);
1077+
match store::resolve_path(conn, &normalized)
1078+
.map_err(|e| format!("Couldn't resolve path: {e}"))?
1079+
{
1080+
Some(id) => {
1081+
id_to_idx.push((id, i, normalized));
1082+
results.push(None);
1083+
}
1084+
None => results.push(None),
1085+
}
1086+
}
1087+
1088+
if !id_to_idx.is_empty() {
1089+
let ids: Vec<i64> = id_to_idx.iter().map(|(id, _, _)| *id).collect();
1090+
let stats_batch = IndexStore::get_dir_stats_batch_by_ids(conn, &ids)
1091+
.map_err(|e| format!("Couldn't get dir stats batch: {e}"))?;
1092+
1093+
for ((_, idx, normalized), stats_opt) in id_to_idx.into_iter().zip(stats_batch) {
1094+
results[idx] = stats_opt.map(|s| DirStats {
1095+
path: normalized,
1096+
recursive_size: s.recursive_size,
1097+
recursive_file_count: s.recursive_file_count,
1098+
recursive_dir_count: s.recursive_dir_count,
1099+
});
1100+
}
1101+
}
1102+
1103+
Ok(results)
1104+
})?
11231105
}
11241106

11251107
/// Force a fresh full scan (for debug/manual trigger).

0 commit comments

Comments
 (0)