Skip to content

Commit ba5a538

Browse files
committed
Indexing: reserve the UNIQUE-conflict WARN for the case that's actually actionable
The index scan logged a WARN for every batch that skipped a row on the `(parent_id, name_folded)` UNIQUE constraint, even a single skip. But a few skips per scan is expected dedup (one dir reachable by two walk paths via a firmlink/symlink, or case/NFD sibling pairs on case-sensitive or cross-OS-synced trees), not anything to act on. That made the WARN noise and trained the eye to ignore it, defeating the point of WARN. Recalibrate so WARN means "do something": - Per-batch skips drop to DEBUG (keeping the 3-row sample for diagnosis under `RUST_LOG=cmdr_lib::indexing::writer=debug`). - `AccumulatorMaps` gains an `entries_skipped` tally; `handle_compute_all_aggregates` summarizes it once per scan via the new pure `classify_skip_severity`: silent when nothing skipped, DEBUG for sparse dedup, and WARN only when the skip ratio looks like two writers racing on one DB (≥50 skips AND >1% of the scan's rows). That racing case is the constraint's whole reason for being (a 1.83 TB ghost size was traced to it), and it's the one a reader should investigate. Ratio over per-batch absolute count because the racing signature is a large *fraction* of rows skipped sustained across the scan, while a giant directory of genuine collisions could trip an absolute per-batch threshold falsely. The absolute floor keeps a tiny tree with a couple sibling collisions from warning. Normal scans now log nothing here.
1 parent 5eae213 commit ba5a538

2 files changed

Lines changed: 103 additions & 2 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ The key UX win: showing directory sizes in file listings. Design history is in g
2222
- **store.rs** -- SQLite schema (integer-keyed entries with `name_folded` column on all platforms, `inode` column for hardlink dedup, `dir_stats` by entry_id, `meta`), `platform_case` collation, read queries, DB open/migrate. `resolve_component` always queries by `(parent_id, name_folded)` using the `idx_parent_name_folded` composite **UNIQUE** index. On Linux/Windows, `normalize_for_comparison()` is the identity function, so `name_folded = name` and the index behaves identically to a `(parent_id, name)` index. Schema version check: mismatch triggers drop+rebuild. `has_sized_entry_for_inode()` checks if another entry with the same inode already has non-NULL sizes; `find_entry_by_inode()` returns the first row with a given inode (used by the live event loop's rename pre-pass). Both path-keyed (backward compat) and integer-keyed APIs.
2323
- **metadata.rs** -- `MetadataSnapshot` struct and `extract_metadata()` function. Single location for all platform-specific metadata extraction (logical/physical size, mtime, inode, nlink). Used by scanner, reconciler, verifier, and event_loop. Symlinks get `None` everywhere. Files get sizes + inode + nlink. Directories get inode but no sizes/nlink. The inode is what the live event loop's rename pre-pass matches against to detect dir renames in place.
2424
- **memory_watchdog.rs** -- Background task monitoring resident memory via `mach_task_info` (macOS). Warns at 8 GB, stops indexing at 16 GB, emits `index-memory-warning` event to frontend. No-op stub on non-macOS. Started from `start_indexing()`.
25-
- **writer.rs** -- Single writer thread, owns the write connection, processes `WriteMessage` channel (bounded `sync_channel`, 20K capacity, backpressure via blocking). `WRITER_GENERATION: AtomicU64` (initialized to 1) bumped on every mutation (`InsertEntriesV2`, `UpsertEntryV2`, `MoveEntryV2`, `DeleteEntryById`, `DeleteSubtreeById`, `TruncateData`) for search index staleness detection. Priority: `UpdateDirStats` before `InsertEntries`. `Flush` variant + async `flush()` method let callers wait for all prior writes to commit. Has both integer-keyed variants (`InsertEntriesV2`, `UpsertEntryV2`, `MoveEntryV2`, `DeleteEntryById`, `DeleteSubtreeById`, `PropagateDeltaById`) and path-keyed backward-compat variants. `MoveEntryV2 { entry_id, new_parent_id, new_name }` updates an entry's `(parent_id, name, name_folded)` in place, preserving its `id` and (for directories) `dir_stats`. If a different entry already occupies the destination `(parent_id, name_folded)` (rename-with-overwrite, or a concurrent upsert racing ahead of the move message), the handler deletes the conflicting row first (subtree-aware, with delta propagation) so the move never fails the UNIQUE constraint; the on-disk truth after a rename is that the moved entry owns the destination name. Same-parent renames don't change ancestor totals; cross-parent moves subtract the entry's contribution from the old ancestor chain and add it to the new one (and recompute the OR-aggregated `recursive_has_symlinks` flag on both chains). The integer-keyed delete/subtree-delete handlers auto-propagate negative deltas via the `parent_id` chain (same pattern as the path-keyed variants). `propagate_delta_by_id` walks the parent chain using `get_parent_id` lookups. `UpsertEntryV2` auto-propagates deltas on both insert and update: on insert, propagates the full size (+file_count or +dir_count); on update, reads the old entry first and propagates only the size difference. This means callers never need a separate `PropagateDeltaById` for upserted entries. For new directories, also initializes a zero-valued `dir_stats` row so enrichment always has a row. Maintains `AccumulatorMaps` during `InsertEntriesV2` processing (two HashMaps: direct children stats and child dir relationships + an `entries_inserted` counter), cleared on `TruncateData`. On `ComputeAllAggregates`, passes accumulated maps to `aggregator::compute_all_aggregates_with_maps()` to skip expensive full-table-scan SQL queries. On `ComputePartialAggregates { hot_paths }` (mid-scan), `handle_compute_partial_aggregates` borrows the same maps **read-only** (no clear, no mutation, no generation bump), no-ops on empty maps with no SQL fallback, delegates the math to `aggregator::compute_partial_aggregates`, writes a depth-capped (`PARTIAL_AGG_MAX_DEPTH = 3`) subset of `dir_stats` rows, and emits `index-dir-updated { paths: ["/"] }` when an `AppHandle` is present. Accepts an optional `AppHandle` at spawn time to emit `index-aggregation-progress` events during aggregation (phase, current, total). `IndexWriter::try_send` is a non-blocking send (`Ok(true)` enqueued / `Ok(false)` channel full, dropped / `Err` writer gone) with `queue_depth()` accessor over the channel-depth atomic; the bump/undo accounting lives in the extracted `try_send_with_depth` free function (undoes the bump on both `Full` and `Disconnected` so the depth never drifts). Also emits `saving_entries` phase progress during `InsertEntriesV2` processing when the expected total is set via `set_expected_total_entries()` (an `Arc<AtomicU64>` shared between the writer thread and the `IndexWriter` handle). No index drop/recreate dance. The `idx_parent_name_folded` composite index uses binary collation and stays present during scans.
25+
- **writer.rs** -- Single writer thread, owns the write connection, processes `WriteMessage` channel (bounded `sync_channel`, 20K capacity, backpressure via blocking). `WRITER_GENERATION: AtomicU64` (initialized to 1) bumped on every mutation (`InsertEntriesV2`, `UpsertEntryV2`, `MoveEntryV2`, `DeleteEntryById`, `DeleteSubtreeById`, `TruncateData`) for search index staleness detection. Priority: `UpdateDirStats` before `InsertEntries`. `Flush` variant + async `flush()` method let callers wait for all prior writes to commit. Has both integer-keyed variants (`InsertEntriesV2`, `UpsertEntryV2`, `MoveEntryV2`, `DeleteEntryById`, `DeleteSubtreeById`, `PropagateDeltaById`) and path-keyed backward-compat variants. `MoveEntryV2 { entry_id, new_parent_id, new_name }` updates an entry's `(parent_id, name, name_folded)` in place, preserving its `id` and (for directories) `dir_stats`. If a different entry already occupies the destination `(parent_id, name_folded)` (rename-with-overwrite, or a concurrent upsert racing ahead of the move message), the handler deletes the conflicting row first (subtree-aware, with delta propagation) so the move never fails the UNIQUE constraint; the on-disk truth after a rename is that the moved entry owns the destination name. Same-parent renames don't change ancestor totals; cross-parent moves subtract the entry's contribution from the old ancestor chain and add it to the new one (and recompute the OR-aggregated `recursive_has_symlinks` flag on both chains). The integer-keyed delete/subtree-delete handlers auto-propagate negative deltas via the `parent_id` chain (same pattern as the path-keyed variants). `propagate_delta_by_id` walks the parent chain using `get_parent_id` lookups. `UpsertEntryV2` auto-propagates deltas on both insert and update: on insert, propagates the full size (+file_count or +dir_count); on update, reads the old entry first and propagates only the size difference. This means callers never need a separate `PropagateDeltaById` for upserted entries. For new directories, also initializes a zero-valued `dir_stats` row so enrichment always has a row. Maintains `AccumulatorMaps` during `InsertEntriesV2` processing (two HashMaps: direct children stats and child dir relationships + `entries_inserted` and `entries_skipped` counters), cleared on `TruncateData`. A per-batch `INSERT OR IGNORE` UNIQUE-conflict skip is logged at DEBUG only (with a 3-row sample) and tallied into `entries_skipped`; `handle_compute_all_aggregates` summarizes the scan-wide tally once via `classify_skip_severity` (none → silent, sparse dedup → DEBUG, racing-writer ratio (≥50 skips and >1% of rows) → WARN), so normal scans log nothing and only the actionable double-write case warns. On `ComputeAllAggregates`, passes accumulated maps to `aggregator::compute_all_aggregates_with_maps()` to skip expensive full-table-scan SQL queries. On `ComputePartialAggregates { hot_paths }` (mid-scan), `handle_compute_partial_aggregates` borrows the same maps **read-only** (no clear, no mutation, no generation bump), no-ops on empty maps with no SQL fallback, delegates the math to `aggregator::compute_partial_aggregates`, writes a depth-capped (`PARTIAL_AGG_MAX_DEPTH = 3`) subset of `dir_stats` rows, and emits `index-dir-updated { paths: ["/"] }` when an `AppHandle` is present. Accepts an optional `AppHandle` at spawn time to emit `index-aggregation-progress` events during aggregation (phase, current, total). `IndexWriter::try_send` is a non-blocking send (`Ok(true)` enqueued / `Ok(false)` channel full, dropped / `Err` writer gone) with `queue_depth()` accessor over the channel-depth atomic; the bump/undo accounting lives in the extracted `try_send_with_depth` free function (undoes the bump on both `Full` and `Disconnected` so the depth never drifts). Also emits `saving_entries` phase progress during `InsertEntriesV2` processing when the expected total is set via `set_expected_total_entries()` (an `Arc<AtomicU64>` shared between the writer thread and the `IndexWriter` handle). No index drop/recreate dance. The `idx_parent_name_folded` composite index uses binary collation and stays present during scans.
2626
- **scanner.rs** -- jwalk-based parallel directory walker. `scan_volume()` for full scan, `scan_subtree()` for targeted subtree rescans (used by post-replay background verification). Uses `ScanContext` (from store.rs) to assign integer IDs and parent IDs during the walk: maintains a `HashMap<PathBuf, i64>` mapping directory paths to assigned IDs, with IDs allocated from the shared `Arc<AtomicI64>` counter owned by `IndexWriter`. The scan root is mapped to `ROOT_ID` (1). Sends `InsertEntriesV2(Vec<EntryRow>)` batches to the writer. Platform-specific exclusion filters via `should_exclude` (`pub(super)`), the single exclusion gate for all code paths (scanner, reconciler, event_loop verification, per-navigation verifier). E2E scan restriction: when `CMDR_E2E_START_PATH` is set, `should_exclude` restricts scanning to only the fixture path, its children, and ancestors. Everything else is excluded (critical for Docker E2E performance). `default_exclusions()` is `#[cfg(test)]` only. Physical sizes (`st_blocks * 512`). Hardlink inode dedup: files with `nlink > 1` are tracked in a `HashSet<u64>` by inode; only the first link's size is counted, subsequent links get `size = None`. Files with `nlink == 1` (vast majority) skip the set entirely. All files store `inode` in `EntryRow.inode` (from `MetadataExt::ino()` on Unix, `None` on non-Unix). Directories and symlinks get `inode: None`.
2727
- **aggregator.rs** -- Dir stats computation. Bottom-up after full scan (O(N) single pass), per-subtree after subtree rescans, incremental delta propagation up ancestor chain for watcher events. Two entry points for full aggregation: `compute_all_aggregates_reported` (loads maps from SQL) and `compute_all_aggregates_with_maps` (accepts pre-built maps from the writer). Both accept an `on_progress: &mut dyn FnMut(AggregationProgress)` callback and delegate to `compute_and_write()` for the shared topological sort + bottom-up computation + batch write. Progress is reported at phase transitions and every ~1% during compute/write loops. `AggregationPhase` enum: `SavingEntries` (flushing writer channel), `LoadingDirectories`, `Sorting`, `Computing`, `Writing`. The composite indexes use binary collation so there's no per-scan index rebuild phase. `compute_partial_aggregates` is the mid-scan variant: it derives the dir list and parent relations from the borrowed accumulator maps (no SQL `load_all_directory_ids` scan), computes each dir's depth from the scan root via a memoized walk (`depth(ROOT_ID) = 0` is the explicit base case; unreachable dirs get `usize::MAX` so the depth cap never writes them), reuses the same `topological_sort_bottom_up` + `compute_bottom_up` over **all** dirs, and writes only dirs at `depth ≤ max_depth` plus each resolvable hot-path dir and its direct children. `backfill_missing_dir_stats` is a catch-up pass that finds directories without `dir_stats` rows and computes their stats bottom-up; triggered after reconciler replay and cold-start replay via `BackfillMissingDirStats` writer message.
2828
- **watcher.rs** -- Drive-level filesystem watcher. macOS: FSEvents via `cmdr-fsevent-stream` with event IDs and `sinceWhen` replay. Linux: `notify` crate (inotify backend) with recursive watching and synthetic event counter. Other platforms: stub. `supports_event_replay()` lets callers branch on whether journal replay is available.

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,10 @@ struct AccumulatorMaps {
550550
child_dirs: HashMap<i64, Vec<i64>>,
551551
/// Running count of entries inserted so far (for flushing progress).
552552
entries_inserted: u64,
553+
/// Running count of rows the scan skipped on a UNIQUE `(parent_id,
554+
/// name_folded)` conflict (`INSERT OR IGNORE`). Summarized once per scan at
555+
/// `ComputeAllAggregates`; see `classify_skip_severity`.
556+
entries_skipped: u64,
553557
}
554558

555559
impl AccumulatorMaps {
@@ -558,6 +562,7 @@ impl AccumulatorMaps {
558562
direct_stats: HashMap::new(),
559563
child_dirs: HashMap::new(),
560564
entries_inserted: 0,
565+
entries_skipped: 0,
561566
}
562567
}
563568

@@ -586,6 +591,42 @@ impl AccumulatorMaps {
586591
self.direct_stats.clear();
587592
self.child_dirs.clear();
588593
self.entries_inserted = 0;
594+
self.entries_skipped = 0;
595+
}
596+
}
597+
598+
/// Log severity for the count of rows a full scan skipped on a UNIQUE
599+
/// `(parent_id, name_folded)` conflict (the `INSERT OR IGNORE` path).
600+
#[derive(Debug, PartialEq, Eq)]
601+
enum SkipSeverity {
602+
/// Nothing skipped: log nothing.
603+
None,
604+
/// Sparse skips, expected dedup (one dir reachable by two walk paths via a
605+
/// firmlink/symlink, or case/NFD sibling pairs on case-sensitive or
606+
/// cross-OS-synced trees). Not actionable: log at DEBUG.
607+
Benign,
608+
/// A large fraction of the scan skipped: the signature of two writer threads
609+
/// racing on one DB (the constraint's reason for being, a 1.83 TB ghost size
610+
/// was traced to exactly that). Actionable: log at WARN.
611+
Suspicious,
612+
}
613+
614+
/// Classify a full scan's accumulated UNIQUE-conflict skips. The absolute floor
615+
/// keeps a tiny tree with a couple genuine sibling collisions from tripping the
616+
/// warning; the ratio separates a handful of dedup hits in a multi-million-row
617+
/// scan from a racing writer (whose loser duplicates a large fraction of rows).
618+
fn classify_skip_severity(inserted: u64, skipped: u64) -> SkipSeverity {
619+
const MIN_SUSPICIOUS_SKIPS: u64 = 50;
620+
const SUSPICIOUS_SKIP_RATIO: f64 = 0.01;
621+
if skipped == 0 {
622+
return SkipSeverity::None;
623+
}
624+
let total = inserted + skipped;
625+
let ratio = skipped as f64 / total as f64;
626+
if skipped >= MIN_SUSPICIOUS_SKIPS && ratio > SUSPICIOUS_SKIP_RATIO {
627+
SkipSeverity::Suspicious
628+
} else {
629+
SkipSeverity::Benign
589630
}
590631
}
591632

@@ -945,12 +986,19 @@ fn handle_insert_entries_v2(
945986
// inflates `dir_stats` with phantom bytes (the constraint comment that
946987
// called out "1.83 TB ghost size on a 994 GB volume" is exactly this
947988
// failure mode).
989+
//
990+
// A per-batch skip is logged at DEBUG only (with a sample for diagnosis): a
991+
// few skips per scan is expected dedup and not actionable. The accumulated
992+
// count is summarized once per scan at `ComputeAllAggregates`, which escalates
993+
// to WARN only when the skip ratio looks like a racing writer. See
994+
// `classify_skip_severity`.
948995
match IndexStore::insert_entries_v2_batch(conn, &entries) {
949996
Ok(inserted) => {
950997
let skipped_count = inserted.iter().filter(|landed| !**landed).count();
951998
if skipped_count == 0 {
952999
accumulator.accumulate(&entries);
9531000
} else {
1001+
accumulator.entries_skipped += skipped_count as u64;
9541002
accumulator.accumulate(
9551003
entries
9561004
.iter()
@@ -969,7 +1017,7 @@ fn handle_insert_entries_v2(
9691017
})
9701018
.take(3)
9711019
.collect();
972-
log::warn!(
1020+
log::debug!(
9731021
"Index writer: {skipped_count} of {batch_size} skipped due to UNIQUE conflict on (parent_id, name_folded); sample: {samples:?}",
9741022
batch_size = pluralize_with(count as u64, "entry", "entries")
9751023
);
@@ -1602,6 +1650,23 @@ fn handle_compute_all_aggregates(
16021650
&mut on_progress,
16031651
)
16041652
};
1653+
// Summarize the scan's UNIQUE-conflict skips once, here, instead of WARNing
1654+
// per offending batch. Sparse skips are expected dedup; only a racing-writer
1655+
// ratio is worth a WARN. Read before `clear()`.
1656+
let inserted = accumulator.entries_inserted;
1657+
let skipped = accumulator.entries_skipped;
1658+
match classify_skip_severity(inserted, skipped) {
1659+
SkipSeverity::None => {}
1660+
SkipSeverity::Benign => log::debug!(
1661+
"Index scan: {skipped} of {total} entries skipped on UNIQUE conflict (expected dedup: firmlinks, case/NFD siblings)",
1662+
total = inserted + skipped,
1663+
),
1664+
SkipSeverity::Suspicious => log::warn!(
1665+
"Index scan: {skipped} of {total} entries skipped on UNIQUE conflict ({pct:.1}%); a high ratio can mean two writers raced on one DB",
1666+
total = inserted + skipped,
1667+
pct = skipped as f64 / (inserted + skipped) as f64 * 100.0,
1668+
),
1669+
}
16051670
// Maps are consumed; clear to free memory.
16061671
// Reset expected_total so subtree-scan inserts don't emit
16071672
// spurious saving_entries progress events after the full scan.
@@ -1927,6 +1992,42 @@ mod tests {
19271992
IndexStore::open(db_path).expect("failed to open read store")
19281993
}
19291994

1995+
// ── Skip-severity classification ─────────────────────────────────
1996+
1997+
#[test]
1998+
fn skip_severity_none_when_nothing_skipped() {
1999+
assert_eq!(classify_skip_severity(5_000_000, 0), SkipSeverity::None);
2000+
}
2001+
2002+
#[test]
2003+
fn skip_severity_benign_for_sparse_dedup() {
2004+
// A handful of firmlink double-visits / case-NFD siblings in a big scan: expected, not actionable.
2005+
assert_eq!(classify_skip_severity(5_000_000, 3), SkipSeverity::Benign);
2006+
assert_eq!(classify_skip_severity(5_000_000, 49), SkipSeverity::Benign);
2007+
}
2008+
2009+
#[test]
2010+
fn skip_severity_benign_when_below_absolute_floor_even_at_high_ratio() {
2011+
// Tiny tree with a couple genuine sibling collisions: high ratio but few skips, stay quiet.
2012+
assert_eq!(classify_skip_severity(20, 10), SkipSeverity::Benign);
2013+
}
2014+
2015+
#[test]
2016+
fn skip_severity_suspicious_for_racing_writer_signature() {
2017+
// Two writers racing on one DB: the loser's inserts all conflict, so a large fraction skips.
2018+
assert_eq!(classify_skip_severity(5_000_000, 5_000_000), SkipSeverity::Suspicious);
2019+
// Just over both gates: 100 skips and >1% of the scan (100 / 9100 ≈ 1.1%).
2020+
assert_eq!(classify_skip_severity(9_000, 100), SkipSeverity::Suspicious);
2021+
// Exactly 1% does not trip it (the ratio gate is strict `>`): 100 / 10000.
2022+
assert_eq!(classify_skip_severity(9_900, 100), SkipSeverity::Benign);
2023+
}
2024+
2025+
#[test]
2026+
fn skip_severity_benign_when_over_floor_but_under_ratio() {
2027+
// 50 skips clears the floor but is a vanishing fraction of a 5M scan: still benign.
2028+
assert_eq!(classify_skip_severity(5_000_000, 50), SkipSeverity::Benign);
2029+
}
2030+
19302031
// ── Basic lifecycle tests ────────────────────────────────────────
19312032

19322033
#[test]

0 commit comments

Comments
 (0)