diff --git a/.github/workflows/pr-fast.yml b/.github/workflows/pr-fast.yml index d27934740..cae09c096 100644 --- a/.github/workflows/pr-fast.yml +++ b/.github/workflows/pr-fast.yml @@ -479,7 +479,7 @@ jobs: shared-key: pr-fast-sanity save-if: 'false' - - run: cargo doc --workspace --all-features --no-deps --locked + - run: cargo doc --workspace --all-features --no-deps --locked --document-private-items - run: cargo test --doc --workspace --all-features --locked # ───────────────────────────────────────────────────────────────────── diff --git a/crates/uffs-bench/src/host/mock.rs b/crates/uffs-bench/src/host/mock.rs index 83fd0826e..e6e359912 100644 --- a/crates/uffs-bench/src/host/mock.rs +++ b/crates/uffs-bench/src/host/mock.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! In-memory [`Host`] for deterministic, OS-independent unit tests. +//! In-memory [`super::Host`] for deterministic, OS-independent unit tests. //! //! [`MockHost`] keeps an in-memory filesystem, records every call in order (so //! tests can assert *snapshot-before-mutate* ordering and that the command diff --git a/crates/uffs-bench/src/stages.rs b/crates/uffs-bench/src/stages.rs index 63edf0b68..c545104c3 100644 --- a/crates/uffs-bench/src/stages.rs +++ b/crates/uffs-bench/src/stages.rs @@ -542,7 +542,7 @@ fn step_from_output(out: &ProcOutput, output_path: &Path, label: &str) -> StepRe /// Stage 1 — cross-tool head-to-head (run the harness). /// /// The daemon run-state restore (R1) is registered once, up front, in -/// [`crate::run`] — before the daemon is first killed — so it is not re-taken +/// [`crate::run()`] — before the daemon is first killed — so it is not re-taken /// per stage here (by stage time the as-found state is already gone). fn run_cross_tool( host: &dyn Host, @@ -558,7 +558,7 @@ fn run_cross_tool( /// Stage 2 — per-drive parity (+R2 cache backup when purging, run the script). /// -/// R1 daemon run-state is restored once via [`crate::run`] (see +/// R1 daemon run-state is restored once via [`crate::run()`] (see /// [`run_cross_tool`]); only the per-drive cache backup is stage-local. fn run_parity(host: &dyn Host, guard: &mut RunGuard<'_>, cfg: &StageCfg) -> Result { if cfg.drop_cache { diff --git a/crates/uffs-client/src/connect_platform.rs b/crates/uffs-client/src/connect_platform.rs index 5fa410fc4..7242ee7dd 100644 --- a/crates/uffs-client/src/connect_platform.rs +++ b/crates/uffs-client/src/connect_platform.rs @@ -5,8 +5,8 @@ //! [`crate::connect::UffsClient`] (async variant). //! //! Extracted from `connect.rs` for file-size policy compliance. -//! All items live on [`UffsClient`] via split `impl` blocks — no -//! public surface moves. Mirrors the sync-path split in +//! All items live on [`crate::connect::UffsClient`] via split `impl` blocks — +//! no public surface moves. Mirrors the sync-path split in //! `connect_sync_platform.rs`. use tokio::io::BufReader; diff --git a/crates/uffs-client/src/connect_sync.rs b/crates/uffs-client/src/connect_sync.rs index a564eaf68..956233a64 100644 --- a/crates/uffs-client/src/connect_sync.rs +++ b/crates/uffs-client/src/connect_sync.rs @@ -327,10 +327,11 @@ impl UffsClientSync { /// /// # Deadline /// - /// On Windows, arms the [`crate::windows_deadline::WindowsDeadlineGuard`] - /// before any I/O and disarms it on return (success or error). - /// Using a [`DisarmOnDrop`] guard makes the disarm robust against - /// early-return paths, including `?` bubbling from the read loop. + /// On Windows, arms the `WindowsDeadlineGuard` (in + /// `crate::windows_deadline`) before any I/O and disarms it on return + /// (success or error). Using a `DisarmOnDrop` guard makes the disarm + /// robust against early-return paths, including `?` bubbling from the + /// read loop. /// /// On Unix, the deadline is enforced by `SO_RCVTIMEO` / /// `SO_SNDTIMEO` set at connect time and needs no per-call logic. @@ -649,7 +650,8 @@ impl UffsClientSync { /// single `status` call, saving one full RPC round-trip per CLI /// invocation (~5–10 ms on Windows named pipes). Skippable via /// `UFFS_CLIENT_SKIP_HEALTH_CHECK=1` (see - /// [`deep_health_check_enabled`]). Cost: ~200–600 µs local IPC. + /// [`crate::daemon_ctl::deep_health_check_enabled`]). Cost: ~200–600 µs + /// local IPC. /// /// # Errors /// diff --git a/crates/uffs-client/src/daemon_child.rs b/crates/uffs-client/src/daemon_child.rs index 044dcbe9b..7b465d81d 100644 --- a/crates/uffs-client/src/daemon_child.rs +++ b/crates/uffs-client/src/daemon_child.rs @@ -113,7 +113,8 @@ impl DaemonChildHandle { } } - /// Returns the spawned daemon's PID, or `0` for [`Self::opaque`]. + /// Returns the spawned daemon's PID, or `0` for `Self::opaque` + /// (Windows-only). #[must_use] pub(crate) const fn pid(&self) -> u32 { self.pid @@ -126,8 +127,8 @@ impl DaemonChildHandle { /// surface as `101`, clap parse errors as `2`, graceful exit as `0`). /// * `Err(err)` — the poll itself failed (treat as unknown, keep retrying). /// - /// For [`Self::opaque`] handles this is a no-op and always returns - /// `Ok(None)`. + /// For `Self::opaque` (Windows-only) handles this is a no-op and always + /// returns `Ok(None)`. /// /// # Errors /// diff --git a/crates/uffs-core/Cargo.toml b/crates/uffs-core/Cargo.toml index 7beb1728a..14f4127f9 100644 --- a/crates/uffs-core/Cargo.toml +++ b/crates/uffs-core/Cargo.toml @@ -105,6 +105,14 @@ harness = false name = "search_benchmarks" harness = false +[[bench]] +name = "overlay_read" +harness = false + +[[bench]] +name = "apply_cost" +harness = false + # ───────────────────────────────────────────────────────────────────────────── # Lints (inherit from workspace) # ───────────────────────────────────────────────────────────────────────────── diff --git a/crates/uffs-core/benches/apply_cost.rs b/crates/uffs-core/benches/apply_cost.rs new file mode 100644 index 000000000..8ec1407c7 --- /dev/null +++ b/crates/uffs-core/benches/apply_cost.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Per-apply USN-patch cost — the incremental-index-maintenance perf guard. +//! +//! [`apply_usn_patch`] is the hot path the USN journal loop runs on every poll: +//! it mutates the record columns in O(changed), overlays the batch onto the +//! base ∪ delta trigram / extension / children indexes, and refreshes the +//! touched records' `path_len`. The whole point of the project is that this +//! cost scales with the **batch size**, not the **drive size** — a 256-change +//! poll on a 4-million-record drive must not re-pay an O(total) rebuild. +//! +//! This bench locks that in. The fixture is a ~500k-record drive; each subject +//! applies a representative batch to a **fresh clone** (clone excluded from the +//! timing via `iter_batched`), so what is measured is the apply alone. The +//! profiles span the realistic USN-poll shapes: +//! * `creates/256` — a typical settle-debounced poll batch +//! * `creates/4000` — a heavy burst (bulk extract / installer) +//! * `mixed/4000` — creates + deletes + file renames interleaved +//! * `deletes/4000` — the tombstone path +//! +//! It is a guard, not a target: a regression shows up as a profile's time +//! jumping with the **fixture** size (it should not — only +//! `compute_path_lengths` in the >50k fallback is O(total), and these batches +//! stay under that), or a batch's per-change cost ballooning. The numbers +//! replace the ad-hoc development timing the project carried while the overlay +//! was being built. +//! +//! Run with: `cargo bench --bench apply_cost` +//! +//! Reference baseline (Apple M-series, ~500k-record fixture): +//! +//! ```text +//! profile time/batch +//! creates/256 ~120 µs +//! creates/4000 ~1.9 ms +//! mixed/4000 ~2.4 ms +//! deletes/4000 ~1.1 ms +//! ``` + +// The bench binary links uffs-core's full dependency set but uses only a +// subset; this is a structural fact of compiling a bench inside the crate, not +// a code-quality lint to fix. +#![expect( + unused_crate_dependencies, + reason = "bench links uffs-core's full dependency set but uses only a subset" +)] + +use core::hint::black_box; + +use criterion::{BatchSize, Criterion, criterion_group, criterion_main}; +use uffs_core::compact::{ + ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex, IndexSource, apply_usn_patch, +}; +use uffs_core::compact_storage::ColumnStorage; +use uffs_core::trigram::TrigramIndex; +use uffs_mft::usn::FileChange; +use uffs_text::case_fold::CaseFold; + +/// Directories created directly under the fixture root. +const NUM_DIRS: usize = 2_000; +/// Files created in each directory; `NUM_DIRS * FILES_PER_DIR` ≈ 500k records, +/// a realistic multi-hundred-thousand-record drive. +const FILES_PER_DIR: usize = 250; +/// Extensions cycled across the fixture files, interned at ids 1..=5 (id 0 = no +/// extension). The leading const guarantees `EXTS` is never empty, so every +/// `index % EXTS.len()` below is a valid offset. +const EXTS: [&str; 5] = ["txt", "rs", "log", "json", "bin"]; + +/// The fixture extension for the `index`-th file, wrapping across [`EXTS`]. +/// Total (panic-free): `index % EXTS.len()` is always a valid offset, and the +/// const-asserted non-empty `EXTS` makes the `unwrap_or` fallback unreachable. +fn ext_at(index: usize) -> &'static str { + EXTS.get(index % EXTS.len()).copied().unwrap_or("bin") +} + +/// The 1-based extension id (interned offset into the drive's `ext_names`) for +/// the `index`-th file; 0 is reserved for "no extension". +fn ext_id_at(index: usize) -> u16 { + // index % EXTS.len() ∈ 0..5, so +1 ∈ 1..=5 — always fits u16. + u16::try_from(index % EXTS.len() + 1).unwrap_or(0) +} + +/// Append one record + its name bytes to the growing fixture columns. +fn push_file( + names: &mut Vec, + records: &mut Vec, + name: &str, + parent: u32, + is_dir: bool, + ext_id: u16, +) { + let name_offset = u32::try_from(names.len()).unwrap_or(u32::MAX); + names.extend_from_slice(name.as_bytes()); + records.push(CompactRecord { + name_offset, + flags: if is_dir { 0x10 } else { 0 }, + parent_idx: parent, + name_len: u16::try_from(name.len()).unwrap_or(u16::MAX), + extension_id: ext_id, + name_first_byte: name.as_bytes().first().copied().unwrap_or(0), + ..CompactRecord::default() + }); +} + +/// Build the base drive with `delta = None` (cold-load / post-compaction +/// state). The trigram base is left empty (apply overlays it via the delta +/// either way) to keep fixture setup fast; children + ext are real CSR builds. +fn build_drive() -> DriveCompactIndex { + let mut names: Vec = Vec::new(); + let mut records: Vec = Vec::new(); + + push_file(&mut names, &mut records, "C", u32::MAX, true, 0); + for dir in 0..NUM_DIRS { + push_file(&mut names, &mut records, &format!("dir{dir}"), 0, true, 0); + } + for dir in 0..NUM_DIRS { + let dir_idx = u32::try_from(1 + dir).unwrap_or(u32::MAX); + for file in 0..FILES_PER_DIR { + let name = format!("file{dir}_{file}.{}", ext_at(file)); + push_file( + &mut names, + &mut records, + &name, + dir_idx, + false, + ext_id_at(file), + ); + } + } + + let fold = CaseFold::default_table(); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + let frs_to_compact: Vec = (0..records.len()) + .map(|idx| u32::try_from(idx).unwrap_or(u32::MAX)) + .collect(); + let ext_names: Vec> = core::iter::once(Box::from("")) + .chain(EXTS.iter().map(|ext| Box::from(*ext))) + .collect(); + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: TrigramIndex::empty().into(), + children: children.into(), + ext_index: ext_index.into(), + fold, + ext_names, + source: IndexSource::MftFile(std::path::PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact, + delta: None, + } +} + +/// FRS of an existing base file record (directory `dir`, file `file`); frs == +/// idx in the base, so this also serves as a valid `parent_frs` for a create. +const fn file_frs(dir: usize, file: usize) -> u64 { + (1 + NUM_DIRS + dir * FILES_PER_DIR + file) as u64 +} + +/// `count` pure creates spread across the directories (new FRNs past the base). +fn creates(base_count: usize, count: usize) -> Vec { + (0..count) + .map(|idx| FileChange { + frs: ((base_count + idx) as u64).into(), + parent_frs: ((idx % NUM_DIRS) as u64 + 1).into(), + filename: format!("new{idx}.{}", ext_at(idx)), + created: true, + ..FileChange::default() + }) + .collect() +} + +/// `count` deletes of existing base file records (the tombstone path). +fn deletes(count: usize) -> Vec { + (0..count) + .map(|idx| FileChange { + frs: file_frs(idx % NUM_DIRS, idx % FILES_PER_DIR).into(), + deleted: true, + ..FileChange::default() + }) + .collect() +} + +/// `count` interleaved create / delete / file-rename changes — the realistic +/// installer/extract shape that exercises every apply branch in one batch. +fn mixed(base_count: usize, count: usize) -> Vec { + (0..count) + .map(|idx| { + let dir = idx % NUM_DIRS; + let file = idx % FILES_PER_DIR; + match idx % 3 { + 0 => FileChange { + frs: ((base_count + idx) as u64).into(), + parent_frs: (dir as u64 + 1).into(), + filename: format!("add{idx}.{}", ext_at(idx)), + created: true, + ..FileChange::default() + }, + 1 => FileChange { + frs: file_frs(dir, file).into(), + deleted: true, + ..FileChange::default() + }, + _ => FileChange { + frs: file_frs(dir, file).into(), + parent_frs: (dir as u64 + 1).into(), + filename: format!("renamed{idx}.{}", ext_at(idx)), + renamed: true, + ..FileChange::default() + }, + } + }) + .collect() +} + +/// Time `apply_usn_patch` for each batch profile against a fresh fixture clone. +fn bench_apply(crit: &mut Criterion) { + let base = build_drive(); + let base_count = base.records.len(); + + let profiles: [(&str, Vec); 4] = [ + ("creates/256", creates(base_count, 256)), + ("creates/4000", creates(base_count, 4_000)), + ("mixed/4000", mixed(base_count, 4_000)), + ("deletes/4000", deletes(4_000)), + ]; + + let mut group = crit.benchmark_group("apply_cost"); + for (name, batch) in &profiles { + group.bench_function(*name, |bencher| { + bencher.iter_batched( + || base.clone(), + |mut drive| black_box(apply_usn_patch(&mut drive, black_box(batch))), + BatchSize::SmallInput, + ); + }); + } + group.finish(); +} + +criterion_group!(benches, bench_apply); +criterion_main!(benches); diff --git a/crates/uffs-core/benches/overlay_read.rs b/crates/uffs-core/benches/overlay_read.rs new file mode 100644 index 000000000..ff723eeae --- /dev/null +++ b/crates/uffs-core/benches/overlay_read.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Read-path overhead of the Phase-4 base ∪ delta overlay +//! (incremental-index-maintenance). +//! +//! `children_of` / `records_with_ext` return a **borrowed** base slice with +//! zero allocation when the delta is empty (freshly compacted), but when a +//! delta is present they sorted-merge base ∪ delta and validate each candidate +//! against the live records, allocating a `Vec`. This bench measures that +//! overhead directly — the exact concern flagged for Phase 4: does the overlay +//! regress tree search / `--ext` under churn? +//! +//! Each subject is benched in two states on the same fixture: +//! * `compacted` — `delta = None` (the post-compaction / cold-load fast path) +//! * `churned` — `delta = Some` populated by a real `apply_usn_patch` batch +//! of ~40k creates (near the 50k compaction ceiling = peak overlay size) +//! +//! plus a `for_each_child` (zero-alloc primitive) vs `children_of` (Cow) +//! comparison in the churned state, which sizes the headroom available if the +//! slice-callers ever need the zero-alloc path. +//! +//! Run with: `cargo bench --bench overlay_read` +//! +//! Reference baseline (Apple M-series, ~500k records, ~40k-change delta): +//! +//! ```text +//! subject delta=None churned +//! children_of (one dir) ~2.1 ns ~631 ns +//! for_each_child (one dir, churn) — ~354 ns +//! records_with_ext (hot ext ~100k) ~1.8 ns ~295 µs +//! tree walk (2000 dirs / ~500k) ~667 µs ~2.09 ms +//! ``` +//! +//! Read: the overlay overhead under churn is real but small in absolute terms +//! — a whole-tree walk stays ~2 ms, and the `records_with_ext` tax on a hot +//! extension is dwarfed by the downstream path-resolution of its ~100k results. +//! The zero-alloc `for_each_child` (~1.8× faster per call than `children_of` in +//! the churned state) is the ready lever if a future workload makes it bite. + +#![expect(clippy::missing_docs_in_private_items, reason = "benchmark code")] +#![expect(clippy::min_ident_chars, reason = "benchmark uses short loop vars")] +#![expect(clippy::cast_possible_truncation, reason = "fixture indices fit u32")] +#![expect( + unused_crate_dependencies, + reason = "bench links uffs-core's full dependency set but uses only a subset" +)] +#![expect( + clippy::indexing_slicing, + reason = "bench fixture indices are modulo a const-length array — always in bounds" +)] + +use core::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; +use uffs_core::compact::{ + ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex, IndexSource, apply_usn_patch, +}; +use uffs_core::compact_storage::ColumnStorage; +use uffs_core::trigram::TrigramIndex; +use uffs_mft::usn::FileChange; +use uffs_text::case_fold::CaseFold; + +/// Fixture shape: 2000 directories under the root, each with 250 files = ~500k +/// records — a realistic multi-hundred-thousand-record drive. +const NUM_DIRS: usize = 2_000; +const FILES_PER_DIR: usize = 250; +/// Churn just under the 50k compaction ceiling, so the delta is at its peak +/// size (worst case for the overlay merge cost) without triggering a refold. +const CHURN_CHANGES: usize = 40_000; +/// Five extensions, interned at ids 1..=5 (id 0 = no extension). +const EXTS: [&str; 5] = ["txt", "rs", "log", "json", "bin"]; + +fn push_file( + names: &mut Vec, + records: &mut Vec, + name: &str, + parent: u32, + dir: bool, + ext_id: u16, +) { + let offset = names.len() as u32; + names.extend_from_slice(name.as_bytes()); + records.push(CompactRecord { + name_offset: offset, + flags: if dir { 0x10 } else { 0 }, + parent_idx: parent, + name_len: name.len() as u16, + extension_id: ext_id, + name_first_byte: name.as_bytes().first().copied().unwrap_or(0), + ..CompactRecord::default() + }); +} + +/// Build the base drive with `delta = None`. The trigram base is left empty +/// (not a subject of this bench) to keep fixture setup fast; children + ext are +/// real CSR builds since they are what the overlay reads. +fn build_drive() -> DriveCompactIndex { + let mut names: Vec = Vec::new(); + let mut records: Vec = Vec::new(); + + push_file(&mut names, &mut records, "C", u32::MAX, true, 0); + for d in 0..NUM_DIRS { + push_file(&mut names, &mut records, &format!("dir{d}"), 0, true, 0); + } + for d in 0..NUM_DIRS { + let dir_idx = (1 + d) as u32; + for f in 0..FILES_PER_DIR { + let ext_id = (f % EXTS.len()) as u16 + 1; + let name = format!("file{d}_{f}.{}", EXTS[f % EXTS.len()]); + push_file(&mut names, &mut records, &name, dir_idx, false, ext_id); + } + } + + let fold = CaseFold::default_table(); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + let frs_to_compact: Vec = (0..records.len() as u32).collect(); + let ext_names: Vec> = core::iter::once(Box::from("")) + .chain(EXTS.iter().map(|e| Box::from(*e))) + .collect(); + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: TrigramIndex::empty().into(), + children: children.into(), + ext_index: ext_index.into(), + fold, + ext_names, + source: IndexSource::MftFile(std::path::PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact, + delta: None, + } +} + +/// Clone the base drive and populate a delta via a real `apply_usn_patch` batch +/// of `CHURN_CHANGES` creates spread across the directories. +fn churned_drive(base: &DriveCompactIndex) -> DriveCompactIndex { + let mut drive = base.clone(); + let base_count = drive.records.len(); + let changes: Vec = (0..CHURN_CHANGES) + .map(|k| { + let parent_dir = (k % NUM_DIRS) as u64 + 1; // frs == idx for base records + FileChange { + frs: ((base_count + k) as u64).into(), + parent_frs: parent_dir.into(), + filename: format!("churn{k}.{}", EXTS[k % EXTS.len()]), + created: true, + ..FileChange::default() + } + }) + .collect(); + apply_usn_patch(&mut drive, &changes); + assert!(drive.delta.is_some(), "churn must leave a populated delta"); + drive +} + +/// Recursively walk every directory from the root via `children_of`, summing +/// child indices — the cumulative overlay cost a tree-scoped search pays. +fn tree_walk(drive: &DriveCompactIndex) -> u64 { + let mut sum: u64 = 0; + let mut stack: Vec = vec![0]; + while let Some(parent) = stack.pop() { + for &child in drive.children_of(parent).iter() { + sum = sum.wrapping_add(u64::from(child)); + // Only directories have children; recurse into them. + if drive + .records + .get(child as usize) + .is_some_and(|rec| rec.is_directory()) + { + stack.push(child); + } + } + } + sum +} + +fn bench_overlay(c: &mut Criterion) { + let base = build_drive(); + let churned = churned_drive(&base); + + // A directory with a full base child list plus churn additions, and the + // "txt" extension (id 1) with a large posting — the realistic hot lookups. + let probe_dir: u32 = 1; + let txt_ext: u16 = 1; + + let mut group = c.benchmark_group("overlay_read"); + + group.bench_function("children_of/compacted", |b| { + b.iter(|| black_box(base.children_of(black_box(probe_dir)).len())); + }); + group.bench_function("children_of/churned", |b| { + b.iter(|| black_box(churned.children_of(black_box(probe_dir)).len())); + }); + // Zero-alloc primitive in the churned state — the headroom if the + // slice-callers ever migrate off children_of. + group.bench_function("for_each_child/churned", |b| { + b.iter(|| { + let mut n = 0_u64; + churned.for_each_child(black_box(probe_dir), |_| n = n.wrapping_add(1)); + black_box(n) + }); + }); + + group.bench_function("records_with_ext/compacted", |b| { + b.iter(|| black_box(base.records_with_ext(black_box(txt_ext)).len())); + }); + group.bench_function("records_with_ext/churned", |b| { + b.iter(|| black_box(churned.records_with_ext(black_box(txt_ext)).len())); + }); + + group.bench_function("tree_walk/compacted", |b| { + b.iter(|| black_box(tree_walk(black_box(&base)))); + }); + group.bench_function("tree_walk/churned", |b| { + b.iter(|| black_box(tree_walk(black_box(&churned)))); + }); + + group.finish(); +} + +criterion_group!(benches, bench_overlay); +criterion_main!(benches); diff --git a/crates/uffs-core/src/aggregate/finalize.rs b/crates/uffs-core/src/aggregate/finalize.rs index 3180af132..a78e89240 100644 --- a/crates/uffs-core/src/aggregate/finalize.rs +++ b/crates/uffs-core/src/aggregate/finalize.rs @@ -253,7 +253,7 @@ impl BucketRow { /// Finalize accumulated results into a response. /// Finalize aggregate results, optionally using a cross-drive -/// [`ExtensionMap`] for correct extension key resolution. +/// [`crate::aggregate::ExtensionMap`] for correct extension key resolution. pub(crate) fn finalize_with_ext_map( accumulators: Vec, _plan: &AggregatePlan, diff --git a/crates/uffs-core/src/aggregate/rollup.rs b/crates/uffs-core/src/aggregate/rollup.rs index a2e749e78..28cf7e094 100644 --- a/crates/uffs-core/src/aggregate/rollup.rs +++ b/crates/uffs-core/src/aggregate/rollup.rs @@ -194,6 +194,8 @@ pub(crate) fn resolve_rollup_key(key: u32, mode: RollupMode, drive: &DriveCompac reason = "tests assert against fixtures with known shape; indexing panic = test failure" )] mod tests { + use alloc::sync::Arc; + use super::*; #[test] @@ -233,7 +235,6 @@ mod tests { use crate::compact::{ChildrenIndex, CompactRecord, ExtensionIndex, IndexSource}; use crate::trigram::TrigramIndex; - // Build names blob: concatenated UTF-8 strings. let name_strs = [ "root", @@ -254,8 +255,6 @@ mod tests { let dir = 0x0010_u32; // FILE_ATTRIBUTE_DIRECTORY let records = vec![ CompactRecord { - size: 0, - allocated: 0, name_offset: offsets[0], flags: dir, parent_idx: 0, @@ -263,8 +262,6 @@ mod tests { ..Default::default() }, CompactRecord { - size: 0, - allocated: 0, name_offset: offsets[1], flags: dir, parent_idx: 0, @@ -272,8 +269,6 @@ mod tests { ..Default::default() }, CompactRecord { - size: 0, - allocated: 0, name_offset: offsets[2], flags: dir, parent_idx: 0, @@ -290,8 +285,6 @@ mod tests { ..Default::default() }, CompactRecord { - size: 0, - allocated: 0, name_offset: offsets[4], flags: dir, parent_idx: 1, @@ -324,9 +317,9 @@ mod tests { letter: uffs_mft::platform::DriveLetter::C, records: crate::compact_storage::ColumnStorage::from_vec(records), names: crate::compact_storage::ColumnStorage::from_vec(names_blob), - trigram: TrigramIndex::empty(), - children, - ext_index: ExtensionIndex::build(&[]), + trigram: Arc::new(TrigramIndex::empty()), + children: Arc::new(children), + ext_index: Arc::new(ExtensionIndex::build(&[])), fold: uffs_text::case_fold::CaseFold::default_table(), ext_names: vec![], source: IndexSource::MftFile(PathBuf::from("C:")), @@ -335,6 +328,7 @@ mod tests { path_trie: None, // unused by aggregation tests — see compact.rs::frs_to_compact docs. frs_to_compact: Vec::new(), + delta: None, } } diff --git a/crates/uffs-core/src/compact.rs b/crates/uffs-core/src/compact.rs index 0a1ddb1a9..00052dd35 100644 --- a/crates/uffs-core/src/compact.rs +++ b/crates/uffs-core/src/compact.rs @@ -9,13 +9,14 @@ //! `.uffs` cache file. //! //! See `docs/architecture/COMPACT_INDEX_DESIGN.md` for the full design. -//! Exception: `file_size_policy` — core data structures + builder, tightly -//! coupled. - -use std::time::Instant; +//! +//! This module owns [`DriveCompactIndex`] (the loaded drive + its search choke +//! points) and re-exports the row type, the CSR indexes, path-length +//! computation, and the MFT→compact builder from focused submodules +//! (`record`, `children`, `extension`, `path_len`, `builder`, `delta`). -use rayon::prelude::*; -use uffs_mft::index::MftIndex; +use alloc::borrow::Cow; +use alloc::sync::Arc; use crate::bloom::Bloom; pub use crate::compact_loader::apply_usn_patch; @@ -28,386 +29,34 @@ use crate::compact_storage::ColumnStorage; use crate::path_trie::PathTrie; use crate::trigram::TrigramIndex; -/// Compact per-record data for in-memory search, filter, and sort. -/// -/// 80 bytes per record (76 data + 4 explicit tail padding). -/// Derives `bytemuck::Pod` + `Zeroable` so the entire record array can be -/// serialized/deserialized as a single bulk `memcpy` — no per-field encoding. -#[derive(Debug, Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] -#[repr(C)] -pub struct CompactRecord { - // ── u64 fields first (8-byte aligned) ───────────────────────── - /// Logical file size in bytes. - pub size: u64, - /// Allocated size on disk in bytes ("Size on Disk" column). - pub allocated: u64, - /// Sum of logical file sizes in entire subtree. - pub treesize: u64, - /// Sum of allocated sizes in entire subtree. - pub tree_allocated: u64, - /// Creation time (Unix microseconds). - pub created: i64, - /// Last write time (Unix microseconds). - pub modified: i64, - /// Last access time (Unix microseconds). - pub accessed: i64, - - // ── u32 fields (4-byte aligned) ─────────────────────────────── - /// Byte offset into the names blob. - pub name_offset: u32, - /// Raw NTFS `FILE_ATTRIBUTE_*` flags. - pub flags: u32, - /// Index into the compact array of the parent directory. - /// `u32::MAX` = root or orphan. - pub parent_idx: u32, - /// Count of all descendants in subtree. 0 for files. - pub descendants: u32, - - // ── u16 fields (2-byte aligned) ─────────────────────────────── - /// UTF-8 byte length of the filename. - pub name_len: u16, - /// Interned extension ID (0 = no extension). - pub extension_id: u16, - /// Full path length in UTF-8 bytes (e.g. `C:\Windows\System32\cmd.exe` = - /// 28). Precomputed at index build time via top-down parent-chain walk. - /// Saturates at `u16::MAX` (65 535) for extremely deep paths. - pub path_len: u16, - - /// First byte of the filename (e.g. `b'$'` for NTFS metafiles). - /// - /// Cached here as a cheap hot-path *gate*: only `$`-prefixed records can be - /// NTFS metafiles, so [`is_system_metafile`](Self::is_system_metafile) can - /// reject virtually every record with one sequential field read instead of - /// a random cache-miss into the names arena. The handful of `$`-prefixed - /// candidates then pay one arena lookup for the authoritative name check. - pub name_first_byte: u8, - - /// Explicit tail padding for 8-byte struct alignment. - /// Required by `bytemuck::Pod` — no implicit padding allowed. - #[expect( - clippy::pub_underscore_fields, - reason = "bytemuck Pod requires all fields same visibility" - )] - pub _pad: [u8; 1], -} - -/// The fixed set of reserved NTFS metafile names: the `$`-prefixed records at -/// reserved FRS 0–15 and under the `$Extend` directory. An NTFS volume can -/// only ever contain *these* specific metafiles. -/// -/// Any *other* `$`-prefixed name — `$Recycle.Bin`, `$PatchCache`, -/// `$WinREAgent`, the `WinSxS` `$$_*.cdf-ms` filemaps, or a user file literally -/// named `$foo` — is an ordinary file that file managers and tools like -/// Everything display. Classifying those as metafiles is exactly the bug -/// `--hide-system` had. -/// -/// Matched case-insensitively: NTFS itself is case-insensitive, and these -/// canonical names are occasionally surfaced with varied casing. -pub(crate) const NTFS_METAFILE_NAMES: &[&str] = &[ - // Reserved FRS 0–11 (volume root metafiles) - "$MFT", - "$MFTMirr", - "$LogFile", - "$Volume", - "$AttrDef", - "$Bitmap", - "$Boot", - "$BadClus", - "$Secure", - "$UpCase", - "$Extend", - // `$Extend` directory children - "$ObjId", - "$Quota", - "$Reparse", - "$UsnJrnl", - "$RmMetadata", - "$Deleted", - // `$Extend\$RmMetadata` children - "$Repair", - "$Tops", - "$TxfLog", - "$Txf", -]; - -/// Returns whether `name` is one of the reserved `NTFS_METAFILE_NAMES` -/// (a crate-private allowlist, so no intra-doc link from this public item). -/// -/// Real metafiles are already excluded from the compact index at build time -/// (`build_compact_index` drops them via `PathResolver` FRS-validity, not by -/// name). This exact-name check is the *authoritative* classifier for the -/// `--hide-system` filter, so it can never misclassify an ordinary -/// `$`-prefixed file as a metafile. -#[must_use] -#[inline] -pub fn is_ntfs_metafile_name(name: &str) -> bool { - NTFS_METAFILE_NAMES - .iter() - .any(|reserved| name.eq_ignore_ascii_case(reserved)) -} - -impl CompactRecord { - /// Directory flag bit in raw NTFS `FILE_ATTRIBUTE_DIRECTORY`. - const DIRECTORY_BIT: u32 = 0x0010; - - /// Returns `true` if this record is a directory. - #[inline] - #[must_use] - pub const fn is_directory(self) -> bool { - self.flags & Self::DIRECTORY_BIT != 0 - } - - /// Returns `true` if this record is one of the reserved NTFS metafiles - /// (`$MFT`, `$LogFile`, `$Bitmap`, `$Secure`, the `$Extend` family, …). - /// - /// The cached [`name_first_byte`](Self::name_first_byte) field is a cheap - /// gate: every metafile name starts with `$`, and `$`-prefixed records are - /// a vanishing fraction of an index, so this rejects virtually every record - /// with a single byte comparison and only touches the names arena for the - /// handful of `$`-prefixed candidates. The arena lookup is *required* for - /// correctness, because an ordinary file may also start with `$` - /// (`$Recycle.Bin`, `$PatchCache`, the `WinSxS` `$$_*.cdf-ms` filemaps) — - /// those are NOT metafiles and must not be hidden by `--hide-system`. - /// See [`is_ntfs_metafile_name`]. - #[inline] - #[must_use] - pub fn is_system_metafile(&self, names: &[u8]) -> bool { - self.name_first_byte == b'$' && is_ntfs_metafile_name(self.name(names)) - } - - /// Get the name from a names blob as a **lossy `&str` view**. - /// - /// Valid-UTF-8 names (the common case) are returned verbatim; an ill-formed - /// (surrogate-bearing) name stored as WTF-8 returns `""` for display. Use - /// [`Self::name_bytes`] for the lossless bytes that exact/substring search - /// matches against, so a file with an ill-formed name stays findable - /// (WI-4.4). - #[inline] - #[must_use] - pub fn name<'a>(&self, names: &'a [u8]) -> &'a str { - core::str::from_utf8(self.name_bytes(names)).unwrap_or("") - } - - /// Get the name's **raw bytes** (WTF-8) from a names blob — the lossless - /// accessor. - /// - /// Returns exactly the stored bytes, including the byte-faithful encoding - /// of an ill-formed NTFS name (unpaired surrogates). This is what makes - /// every file matchable/findable by its true name regardless of UTF-8 - /// well-formedness (WI-4.4). Returns `&[]` for an out-of-range slice. - #[inline] - #[must_use] - pub fn name_bytes<'a>(&self, names: &'a [u8]) -> &'a [u8] { - let start = self.name_offset as usize; - let end = start.saturating_add(self.name_len as usize); - names.get(start..end).unwrap_or(&[]) - } -} - -// Compile-time size assertion. -const _: () = assert!( - size_of::() == 80, - "CompactRecord must be exactly 80 bytes" -); - -/// Children index in CSR (Compressed Sparse Row) layout. -/// -/// `children(i)` returns the compact indices of record i's children as -/// a contiguous `&[u32]` slice. The CSR layout avoids per-record `Vec` -/// allocations and enables bulk serialization/deserialization. -#[derive(Clone)] -pub struct ChildrenIndex { - /// CSR offsets — one per record + sentinel. Length = `record_count` + 1. - /// Children of record `i` are `values[offsets[i]..offsets[i+1]]`. - offsets: Vec, - /// Flat array of all child indices. - values: Vec, -} - -impl ChildrenIndex { - /// Total heap capacity (offsets + values) in bytes. - #[must_use] - pub const fn heap_size_bytes(&self) -> usize { - self.offsets.capacity() * size_of::() + self.values.capacity() * size_of::() - } - - /// Build from `CompactRecord::parent_idx` in two passes (count + scatter). - #[must_use] - pub fn build(records: &[CompactRecord]) -> Self { - // Count children per parent - let mut counts = vec![0_u32; records.len()]; - for rec in records { - let parent = rec.parent_idx; - if parent != u32::MAX - && let Some(cnt) = counts.get_mut(parent as usize) - { - *cnt += 1; - } - } - - // Prefix-sum → offsets - let mut offsets = Vec::with_capacity(records.len() + 1); - let mut running = 0_u32; - for &cnt in &counts { - offsets.push(running); - running = running.saturating_add(cnt); - } - offsets.push(running); - - // Scatter children into values - let mut values = vec![0_u32; running as usize]; - let mut write_pos = offsets.clone(); - for (idx, rec) in records.iter().enumerate() { - let parent = rec.parent_idx; - if parent != u32::MAX - && let Some(pos) = write_pos.get_mut(parent as usize) - && let Some(slot) = values.get_mut(*pos as usize) - { - let child_idx = uffs_mft::len_to_u32(idx); - *slot = child_idx; - *pos += 1; - } - } - - Self { offsets, values } - } - - /// Construct directly from pre-built CSR arrays (cache deserialization). - #[must_use] - pub const fn from_csr(offsets: Vec, values: Vec) -> Self { - Self { offsets, values } - } - - /// Borrow the CSR components for serialization. - #[must_use] - pub(crate) fn as_csr(&self) -> (&[u32], &[u32]) { - (&self.offsets, &self.values) - } - - /// Return the children of record `idx` as a contiguous slice. - #[must_use] - pub fn get(&self, idx: usize) -> &[u32] { - let start = self.offsets.get(idx).copied().unwrap_or(0) as usize; - let end = self.offsets.get(idx + 1).copied().unwrap_or(0) as usize; - self.values.get(start..end).unwrap_or(&[]) - } - - /// Total number of child entries across all records. - #[must_use] - pub const fn total_children(&self) -> usize { - self.values.len() - } - - /// Number of records tracked (one slot per record). - #[must_use] - pub const fn record_count(&self) -> usize { - self.offsets.len().saturating_sub(1) - } - - /// Create an empty children index. - #[must_use] - pub fn empty() -> Self { - Self { - offsets: vec![0], - values: Vec::new(), - } - } -} - -/// Extension inverted index: `extension_id → &[u32]` (record indices). -/// -/// CSR layout identical to `ChildrenIndex`. Built once at load time in a -/// single O(N) pass so `--ext rs` queries can iterate only matching records -/// instead of scanning all 25M entries. -#[derive(Clone)] -pub struct ExtensionIndex { - /// CSR offsets — length = `max_ext_id` + 2 (one per `ext_id` + sentinel). - offsets: Vec, - /// Flat array of record indices, grouped by `extension_id`. - values: Vec, -} - -impl ExtensionIndex { - /// Total heap capacity (offsets + values) in bytes. - #[must_use] - pub const fn heap_size_bytes(&self) -> usize { - self.offsets.capacity() * size_of::() + self.values.capacity() * size_of::() - } - - /// Build from compact records in two passes (count + scatter). - #[must_use] - pub fn build(records: &[CompactRecord]) -> Self { - // Find the maximum extension_id to size the offsets array. - let max_id = records - .iter() - .map(|rec| rec.extension_id) - .max() - .unwrap_or(0) as usize; - - // Pass 1: count records per extension_id. - let mut counts = vec![0_u32; max_id + 1]; - for rec in records { - if rec.name_len == 0 { - continue; - } - if let Some(cnt) = counts.get_mut(rec.extension_id as usize) { - *cnt += 1; - } - } - - // Prefix-sum → offsets. - let mut offsets = Vec::with_capacity(max_id + 2); - let mut running = 0_u32; - for &cnt in &counts { - offsets.push(running); - running = running.saturating_add(cnt); - } - offsets.push(running); - - // Pass 2: scatter record indices into values. - let mut values = vec![0_u32; running as usize]; - let mut write_pos = offsets.clone(); - for (idx, rec) in records.iter().enumerate() { - if rec.name_len == 0 { - continue; - } - let eid = rec.extension_id as usize; - if let Some(pos) = write_pos.get_mut(eid) - && let Some(slot) = values.get_mut(*pos as usize) - { - let idx_u32 = uffs_mft::len_to_u32(idx); - *slot = idx_u32; - *pos += 1; - } - } - - Self { offsets, values } - } - - /// Return record indices for the given `extension_id`. - #[must_use] - pub fn get(&self, ext_id: u16) -> &[u32] { - let eid = ext_id as usize; - let start = self.offsets.get(eid).copied().unwrap_or(0) as usize; - let end = self.offsets.get(eid + 1).copied().unwrap_or(0) as usize; - self.values.get(start..end).unwrap_or(&[]) - } - - /// Create an empty extension index. - #[must_use] - pub fn empty() -> Self { - Self { - offsets: vec![0], - values: Vec::new(), - } - } - - /// Total number of indexed record entries. - #[must_use] - pub const fn total_entries(&self) -> usize { - self.values.len() - } -} +/// Mutable delta overlay over the immutable base CSR indexes (Phase 2+). +pub mod delta; + +// File-size decomposition: the row type, the CSR indexes, path-length +// computation, and the MFT→compact builder live in focused submodules. Every +// public item is re-exported below so the canonical `crate::compact::X` paths +// (used across the workspace) are unchanged. +mod builder; +mod children; +mod extension; +mod path_len; +mod record; + +pub use builder::build_compact_index; +pub(crate) use builder::{INDEX_TTL_SECONDS, resolve_case_fold}; +pub use children::ChildrenIndex; +pub use delta::IndexDelta; +pub use extension::ExtensionIndex; +pub(crate) use path_len::{PathChange, compute_path_lengths, update_path_lengths_incremental}; +pub(crate) use record::NTFS_METAFILE_NAMES; +pub use record::{CompactRecord, is_ntfs_metafile_name}; + +/// Touched-record count (adds + tombstones since the last compaction) above +/// which [`DriveCompactIndex::apply_index_delta`] folds the delta back into +/// fresh bases (design §5.4). Sized to amortize the ~340 ms base rebuild +/// across many small USN applies while bounding delta memory + per-search merge +/// cost; tune from the apply-cost bench + live USN-apply DEBUG summaries. +pub(crate) const TRIGRAM_COMPACT_THRESHOLD: u32 = 50_000; /// A loaded drive with compact index. #[derive(Clone)] @@ -428,12 +77,21 @@ pub struct DriveCompactIndex { /// rationale. pub names: ColumnStorage, /// Trigram inverted index built from folded names (char-level, `$UpCase`). - pub trigram: TrigramIndex, + /// + /// `Arc`-shared (Phase 3): the per-apply whole-body clone the daemon takes + /// before patching pointer-clones this immutable base (a refcount bump) + /// instead of deep-copying its ~hundreds-of-MB CSR arrays. The apply path + /// never mutates it in place — it overlays changes on [`Self::delta`] and + /// only ever *replaces* the whole `Arc` at compaction. + pub trigram: Arc, /// CSR children index: `children.get(i)` → child indices of record i. - pub children: ChildrenIndex, + /// `Arc`-shared (Phase 3) — see [`Self::trigram`]; rebuilt (Arc replaced) + /// each apply until Phase 4 gives it a delta overlay. + pub children: Arc, /// Extension inverted index: `ext_id → record indices`. /// Enables O(K) `--ext` queries where K = matching records, not O(N). - pub ext_index: ExtensionIndex, + /// `Arc`-shared (Phase 3) — see [`Self::trigram`]. + pub ext_index: Arc, /// NTFS `$UpCase` case folding engine for this volume. pub fold: uffs_text::case_fold::CaseFold, /// Extension name table: `ext_names[extension_id]` → lowercase extension @@ -490,6 +148,18 @@ pub struct DriveCompactIndex { /// silently degrades to the full-reload fallback. See the /// v9 → v10 cache format bump in `compact_cache::COMPACT_VERSION`. pub frs_to_compact: Vec, + /// Incremental-index-maintenance overlay (design §5.1). + /// + /// `None` on a freshly built / freshly compacted / cache-loaded index: + /// the base CSR indexes ([`Self::trigram`], [`Self::children`], + /// [`Self::ext_index`]) are authoritative and search reads them with zero + /// overhead. Once [`crate::compact_loader::apply_usn_patch`] starts + /// overlaying USN deltas (Phase 2b) this becomes `Some`, and the search + /// choke points ([`Self::trigram_search`], …) merge base ∪ delta minus + /// tombstones. Compaction folds the delta into a fresh base and resets it + /// to `None`. Never serialized — the on-disk cache is always delta-free + /// (compact before save), so a cache load yields `None`. + pub delta: Option, } /// Per-component heap footprint of a [`DriveCompactIndex`]. @@ -524,6 +194,210 @@ impl AsRef for DriveCompactIndex { } impl DriveCompactIndex { + /// Trigram candidate search through the base ∪ delta overlay (design §5.2). + /// + /// The single choke point every trigram caller goes through. When + /// [`Self::delta`] is `None` (fresh / compacted index) it delegates to the + /// base [`TrigramIndex::search`] with **zero** overhead. When a delta is + /// present it merges, per needle-trigram, the base posting with the delta + /// posting, intersects across the needle's trigrams (the trigram AND), then + /// resolves tombstones on the final candidate set. + /// + /// **Tombstone correctness:** a candidate whose record is tombstoned is + /// kept **iff** it appears in the delta posting of *every* needle + /// trigram — i.e. it was re-added (renamed-in) under a name that still + /// contains the needle. A deleted record (tombstoned, no re-add) and a + /// renamed-away record matched only via its stale base postings are + /// both dropped. Filtering the final set (not per posting list) is what + /// lets a renamed file remain visible under its new name while + /// disappearing from its old one. + /// + /// Returns `None` for needles under 3 codepoints (caller falls back to a + /// linear scan), mirroring [`TrigramIndex::search`]. + #[must_use] + pub fn trigram_search(&self, needle: &str) -> Option> { + let Some(delta) = &self.delta else { + return self.trigram.search(needle, self.fold); + }; + let trigrams = crate::trigram::needle_trigrams(needle, self.fold)?; + if trigrams.is_empty() { + return Some(Vec::new()); + } + + // Per needle-trigram effective posting = base ∪ delta. An absent trigram + // (empty in both) is skipped, never zeroing the result — the trigram + // index is a candidate pre-filter, exactly as the base search treats it. + let mut lists: Vec> = Vec::with_capacity(trigrams.len()); + for &tri in &trigrams { + let base = self.trigram.get_posting(tri).unwrap_or(&[]); + let merged = delta::merge_postings(base, delta.trigram_postings(tri)); + if !merged.is_empty() { + lists.push(merged); + } + } + if lists.is_empty() { + return Some(Vec::new()); + } + + lists.sort_unstable_by_key(Vec::len); + let mut result = lists.first().cloned().unwrap_or_default(); + for list in lists.iter().skip(1) { + crate::trigram::intersect_in_place(&mut result, list); + if result.is_empty() { + break; + } + } + + // Final tombstone resolution: keep a tombstoned candidate only if it was + // re-added under a name covering every needle trigram (see doc above). + if !delta.tombstones.is_empty() { + result.retain(|&idx| { + !delta.is_tombstoned(idx) + || trigrams + .iter() + .all(|&tri| delta.trigram_postings(tri).binary_search(&idx).is_ok()) + }); + } + Some(result) + } + + /// Fold the delta overlay back into fresh bases and clear it (design §5.4 + /// compaction). Rebuilds the trigram (Phase 2b) and extension (Phase 4a) + /// bases from the current records — which already reflect every applied + /// mutation — then resets `delta = None` so subsequent searches take the + /// zero-overhead base fast path. + /// + /// O(total records); the per-apply path drives toward this running only + /// occasionally (every [`TRIGRAM_COMPACT_THRESHOLD`] touched records) or + /// before serialization (the on-disk cache is always delta-free). + pub(crate) fn compact_base(&mut self) { + self.trigram = Arc::new(TrigramIndex::build(&self.records, &self.names, self.fold)); + self.ext_index = Arc::new(ExtensionIndex::build(&self.records)); + self.children = Arc::new(ChildrenIndex::build(&self.records)); + self.delta = None; + } + + /// Invoke `f` for each live child record index of `parent`, through the + /// base ∪ delta overlay (Phase 4b). Zero allocation. + /// + /// When [`Self::delta`] is `None` the base CSR is authoritative (it was + /// built from the current records and nothing has moved since) so it is + /// iterated directly. With a delta present, base ∪ delta children are + /// sorted-merged and each is validated against the live records — kept iff + /// `records[c].parent_idx == parent` and the record is live. That records + /// check is what makes a moved-away or deleted child correct **without** a + /// children tombstone. + pub fn for_each_child(&self, parent: u32, mut visit: F) { + let base = self.children.get(parent as usize); + let Some(delta) = &self.delta else { + for &child in base { + visit(child); + } + return; + }; + let is_valid = |child: u32| { + self.records + .get(child as usize) + .is_some_and(|rec| rec.parent_idx == parent && rec.name_len != 0) + }; + delta::merge_filter(base, delta.child_postings(parent), is_valid, visit); + } + + /// Live child record indices of `parent`, through the base ∪ delta overlay + /// (Phase 4b) — the slice-returning form of [`Self::for_each_child`] for + /// callers that need an owned/borrowed list. Zero-alloc `Cow::Borrowed` + /// when [`Self::delta`] is `None`. + #[must_use] + pub fn children_of(&self, parent: u32) -> Cow<'_, [u32]> { + if self.delta.is_none() { + return Cow::Borrowed(self.children.get(parent as usize)); + } + let mut out = Vec::new(); + self.for_each_child(parent, |child| out.push(child)); + Cow::Owned(out) + } + + /// Record indices whose extension is `ext_id`, through the base ∪ delta + /// overlay (Phase 4a). The choke point every `--ext` query goes through. + /// + /// When [`Self::delta`] is `None` this borrows the base CSR posting slice + /// with **zero** allocation. With a delta present it merges the base and + /// delta postings, then validates each candidate against the live records — + /// keeping `idx` only if `records[idx].extension_id == ext_id` and the + /// record is live (`name_len != 0`). That records check is what makes a + /// renamed extension (`foo.log` → `foo.pdf`) and a delete correct + /// **without** a separate ext tombstone: a stale base posting fails the + /// check. + #[must_use] + pub fn records_with_ext(&self, ext_id: u16) -> Cow<'_, [u32]> { + let base = self.ext_index.get(ext_id); + let Some(delta) = &self.delta else { + return Cow::Borrowed(base); + }; + let merged = delta::merge_postings(base, delta.ext_postings(ext_id)); + let filtered: Vec = merged + .into_iter() + .filter(|&idx| { + self.records + .get(idx as usize) + .is_some_and(|rec| rec.extension_id == ext_id && rec.name_len != 0) + }) + .collect(); + Cow::Owned(filtered) + } + + /// Overlay one USN apply's changes onto the base+delta index instead of + /// rebuilding the trigram (Phase 2b) and extension (Phase 4a) bases. + /// + /// `adds` are the created / renamed / reused records (their post-mutation + /// name trigrams + extension + parent are added to the delta); `tombstones` + /// are the deleted / renamed-away / reused-slot records (their stale base + /// **trigram** postings are masked — the ext/children overlays validate + /// candidates against the live records instead, so they need no tombstone). + /// Returns `true` if the accumulated delta crossed + /// [`TRIGRAM_COMPACT_THRESHOLD`] and triggered a [`Self::compact_base`] + /// fold this call. + pub(crate) fn apply_index_delta(&mut self, adds: &[PathChange], tombstones: &[u32]) -> bool { + // Fast path for a batch that will cross the compaction threshold anyway + // (e.g. a 100k-file burst): populating the delta only to discard it is + // pure waste. Refold the base directly from the records — which already + // reflect every change in this batch — and drop any prior overlay. + let pending = self.delta.as_ref().map_or(0, IndexDelta::len); + let batch = u32::try_from(adds.len().saturating_add(tombstones.len())).unwrap_or(u32::MAX); + if pending.saturating_add(batch) > TRIGRAM_COMPACT_THRESHOLD { + self.compact_base(); + return true; + } + + let mut delta = self.delta.take().unwrap_or_default(); + for &idx in tombstones { + delta.tombstone(idx); + } + let fold = self.fold; + for change in adds { + let Some(rec) = self.records.get(change.idx as usize) else { + continue; + }; + // A record tombstoned this same batch (e.g. created-then-deleted) is + // gone; skip its add entirely. + if rec.name_len == 0 { + continue; + } + // Trigram postings only for names ≥ 3 codepoints (shorter names are + // found via linear scan, not the trigram pre-filter — matching the + // base build); but the extension + children overlays are added for + // EVERY record regardless of name length, so an `--ext` / tree query + // never misses a short-named create/rename. + let trigrams = + crate::trigram::needle_trigrams(rec.name(&self.names), fold).unwrap_or_default(); + delta.add_record(change.idx, &trigrams, rec.extension_id, rec.parent_idx); + } + // The early check above guarantees `pending + batch ≤ threshold`, and + // the populated delta can only be ≤ that, so no compaction is due here. + self.delta = Some(delta); + false + } + /// Compute the total heap footprint of this index (in bytes). /// /// This measures *capacity* (what the allocator reserved), not *len* @@ -654,501 +528,6 @@ impl DriveCompactIndex { } } -/// Expand alternate data streams (ADS) for a single record, producing the -/// name × stream cross product as extra `CompactRecord` entries. -#[expect( - clippy::single_call_fn, - reason = "Extracted to keep expand_links_and_ads under the too_many_lines limit" -)] -fn expand_ads_streams( - index: &MftIndex, - record: &uffs_mft::index::FileRecord, - resolve_parent: &dyn Fn(uffs_mft::ParentFrs, uffs_mft::Frs) -> u32, - names: &mut Vec, - extra: &mut Vec, -) { - // Collect all names for this record (primary + hardlinks). - let mut all_names: Vec<(&str, u32)> = Vec::new(); - let primary_name = index.get_name(record.first_name.name); - if !primary_name.is_empty() { - let pid = resolve_parent(record.first_name.parent_frs, record.frs); - all_names.push((primary_name, pid)); - } - if record.name_count > 1 { - let mut le = record.first_name.next_entry; - while le != uffs_mft::NO_ENTRY { - let Some(lnk) = index.links.get(le as usize) else { - break; - }; - let ln = index.get_name(lnk.name); - if !ln.is_empty() { - let lp = resolve_parent(lnk.parent_frs, record.frs); - all_names.push((ln, lp)); - } - le = lnk.next_entry; - } - } - - // Walk output streams (skip default $DATA at head of chain). - let mut se = record.first_stream.next_entry; - while se != uffs_mft::NO_ENTRY { - let Some(stream) = index.streams.get(se as usize) else { - break; - }; - if stream.is_output_stream() { - let sn = index.stream_name(stream); - if !sn.is_empty() { - for &(base_name, parent_idx) in &all_names { - let combined = format!("{base_name}:{sn}"); - let name_offset = uffs_mft::len_to_u32(names.len()); - let name_len = uffs_mft::len_to_u16(combined.len()); - names.extend_from_slice(combined.as_bytes()); - - extra.push(CompactRecord { - size: stream.size.length, - allocated: stream.size.allocated, - treesize: 0, - tree_allocated: 0, - created: record.stdinfo.created, - modified: record.stdinfo.modified, - accessed: record.stdinfo.accessed, - name_offset, - flags: record.stdinfo.flags, - parent_idx, - descendants: 0, - name_len, - extension_id: 0, - path_len: 0, - name_first_byte: combined.as_bytes().first().copied().unwrap_or(0), - _pad: [0; 1], - }); - } - } - } - se = stream.next_entry; - } -} - -/// Resolve a typed `ParentFrs` (vs an own typed `Frs`) into a compact-record -/// index, returning `u32::MAX` for the "no real parent" cases (self-reference, -/// `NO_ENTRY` sentinel, or root). -/// -/// Extracted as a free helper so the typed `ParentFrs`/`Frs` signature is -/// enforced at every call site AND so `build_compact_index` stays under -/// the clippy `too_many_lines` budget. -#[expect( - clippy::single_call_fn, - reason = "Wrapped by a closure in build_compact_index; kept free-standing \ - for clippy::too_many_lines budget headroom" -)] -fn resolve_parent_compact_idx( - index: &MftIndex, - parent_frs: uffs_mft::ParentFrs, - own_frs: uffs_mft::Frs, -) -> u32 { - let parent = parent_frs.as_frs(); - if parent == own_frs || parent_frs.raw() == u64::from(uffs_mft::NO_ENTRY) || parent.is_root() { - return u32::MAX; - } - let parent_usize = uffs_mft::frs_to_usize(parent.raw()); - index - .frs_to_idx - .get(parent_usize) - .copied() - .filter(|&idx| idx != uffs_mft::NO_ENTRY) - .unwrap_or(u32::MAX) -} - -/// Expand hardlinks and ADS into additional `CompactRecord` entries. -/// -/// Phase 2 (hardlinks): for each valid record with `name_count > 1`, walks the -/// link chain and creates additional records with alternate name/parent. -/// -/// Phase 3 (ADS): delegates to [`expand_ads_streams`] for each valid record -/// with `stream_count > 1`. -#[expect( - clippy::single_call_fn, - reason = "Extracted to keep build_compact_index under the too_many_lines limit" -)] -fn expand_links_and_ads( - index: &MftIndex, - resolver: &uffs_mft::index::PathResolver, - resolve_parent: &dyn Fn(uffs_mft::ParentFrs, uffs_mft::Frs) -> u32, - names: &mut Vec, -) -> Vec { - let mut extra: Vec = Vec::new(); - - for (idx, record) in index.records.iter().enumerate() { - if !resolver.is_valid_idx(idx) { - continue; - } - - // Phase 2: hardlink expansion. - if record.name_count > 1 { - let mut link_entry = record.first_name.next_entry; - while link_entry != uffs_mft::NO_ENTRY { - let Some(link) = index.links.get(link_entry as usize) else { - break; - }; - let link_parent = resolve_parent(link.parent_frs, record.frs); - extra.push(CompactRecord { - size: record.first_stream.size.length, - allocated: record.first_stream.size.allocated, - treesize: record.treesize, - tree_allocated: record.tree_allocated, - created: record.stdinfo.created, - modified: record.stdinfo.modified, - accessed: record.stdinfo.accessed, - name_offset: link.name.offset, - flags: record.stdinfo.flags, - parent_idx: link_parent, - descendants: record.descendants, - name_len: link.name.length(), - extension_id: link.name.extension_id(), - path_len: 0, - name_first_byte: names.get(link.name.offset as usize).copied().unwrap_or(0), - _pad: [0; 1], - }); - link_entry = link.next_entry; - } - } - - // Phase 3: ADS expansion (name × stream cross product). - if record.stream_count > 1 { - expand_ads_streams(index, record, resolve_parent, names, &mut extra); - } - } - extra -} - -/// Compute `path_len` (in **characters**, not bytes) for every record -/// via top-down BFS. -/// -/// Root entries (`parent_idx == u32::MAX`) get -/// `path_len = 2 + 1 + name_chars` (e.g. `"C:\" + name`), and children -/// accumulate `parent.path_len + 1 (separator) + name_chars`. -/// Saturates at `u16::MAX` (65 535) for extremely deep paths. -/// -/// Character counting matches `str::chars().count()` so the precomputed -/// value agrees with the display-row path-length filter. -pub(crate) fn compute_path_lengths( - records: &mut [CompactRecord], - names: &[u8], - drive_letter: uffs_mft::platform::DriveLetter, -) { - // Drive prefix in characters: the letter (1 char) + colon (1 char) = 2. - // `DriveLetter` is ASCII A–Z by construction (validated in - // `DriveLetter::parse`), so the previous runtime `debug_assert!` - // is now a tautology and was removed. The arithmetic only cares - // about "1 letter char + 1 colon". - let _: uffs_mft::platform::DriveLetter = drive_letter; - let drive_prefix_chars: u32 = 1 /* letter */ + 1 /* ':' */; - - // Build forward adjacency list (parent → children) for top-down BFS. - let record_count = records.len(); - let mut children_of: Vec> = vec![Vec::new(); record_count]; - let mut roots: Vec = Vec::new(); - - for (idx, rec) in records.iter().enumerate() { - let pi = rec.parent_idx; - if pi == u32::MAX { - roots.push(uffs_mft::len_to_u32(idx)); - } else if let Some(siblings) = children_of.get_mut(pi as usize) { - siblings.push(uffs_mft::len_to_u32(idx)); - } - } - - // BFS from roots. - let mut queue = alloc::collections::VecDeque::with_capacity(roots.len()); - for &root in &roots { - let Some(rec) = records.get(root as usize) else { - continue; - }; - let name_chars = name_char_count(rec, names); - let pl = if name_chars == 0 { - // Drive root directory: "C:\" - drive_prefix_chars + 1 - } else { - // Top-level file/dir: "C:\" - drive_prefix_chars + 1 + name_chars - }; - if let Some(slot) = records.get_mut(root as usize) { - slot.path_len = uffs_mft::len_to_u16(pl as usize); - } - queue.push_back(root); - } - - while let Some(idx) = queue.pop_front() { - let parent_pl = records - .get(idx as usize) - .map_or(0, |rec| u32::from(rec.path_len)); - let children: Vec = children_of - .get(idx as usize) - .map_or_else(Vec::new, Clone::clone); - for &child in &children { - let child_chars = records - .get(child as usize) - .map_or(0, |rec| name_char_count(rec, names)); - // path = parent_path + "\" + name - let pl = parent_pl.saturating_add(1).saturating_add(child_chars); - if let Some(slot) = records.get_mut(child as usize) { - slot.path_len = uffs_mft::len_to_u16(pl as usize); - } - queue.push_back(child); - } - } -} - -/// Count the number of Unicode characters in a record's filename. -/// -/// Falls back to `name_len` (byte count) if the name slice is not valid -/// UTF-8 — this is correct for ASCII names and a safe upper bound -/// otherwise. -fn name_char_count(rec: &CompactRecord, names: &[u8]) -> u32 { - let start = rec.name_offset as usize; - let end = start + rec.name_len as usize; - names - .get(start..end) - .and_then(|slice| core::str::from_utf8(slice).ok()) - .map_or_else( - || u32::from(rec.name_len), - |name| uffs_mft::len_to_u32(name.chars().count()), - ) -} - -/// Build a `DriveCompactIndex` from a loaded `MftIndex`. -/// -/// Returns `(DriveCompactIndex, compact_build_ms, trigram_build_ms)`. -#[must_use] -pub fn build_compact_index( - drive_letter: uffs_mft::platform::DriveLetter, - index: &MftIndex, -) -> (DriveCompactIndex, u128, u128) { - use uffs_mft::index::PathResolver; - - let compact_start = Instant::now(); - - // Build path resolver to determine which records are valid. - // This filters out system metafiles (FRS 0-15 except root) and - // propagates invalidity to descendants (e.g., $Extend children). - let resolver = PathResolver::build(index, false); - - // Closure wraps the free helper `resolve_parent_compact_idx` so the - // typed `ParentFrs`/`Frs` signature is enforced at every call site - // (own↔parent swap becomes a compile error). Keeping the helper - // free-standing also keeps `build_compact_index` under the - // clippy::too_many_lines budget. - let resolve_parent = |parent_frs: uffs_mft::ParentFrs, own_frs: uffs_mft::Frs| -> u32 { - resolve_parent_compact_idx(index, parent_frs, own_frs) - }; - - // Phase 1: build primary compact records (parallel). - let mut records: Vec = index - .records - .par_iter() - .enumerate() - .map(|(idx, record)| { - // Skip invalid records (system metafiles + descendants). - if !resolver.is_valid_idx(idx) { - return CompactRecord::default(); - } - - let name_ref = &record.first_name.name; - let parent_idx = resolve_parent(record.first_name.parent_frs, record.frs); - - CompactRecord { - size: record.first_stream.size.length, - allocated: record.first_stream.size.allocated, - treesize: record.treesize, - tree_allocated: record.tree_allocated, - created: record.stdinfo.created, - modified: record.stdinfo.modified, - accessed: record.stdinfo.accessed, - name_offset: name_ref.offset, - flags: record.stdinfo.flags, - parent_idx, - descendants: record.descendants, - name_len: name_ref.length(), - extension_id: name_ref.extension_id(), - path_len: 0, - name_first_byte: index - .names - .get(name_ref.offset as usize) - .copied() - .unwrap_or(0), - _pad: [0; 1], - } - }) - .collect(); - - // Phase 2+3: expand hardlinks and ADS (sequential — rare, <1% of records). - let mut names = index.names.clone(); - let expanded = expand_links_and_ads(index, &resolver, &resolve_parent, &mut names); - records.extend(expanded); - - // Phase 4: compute path_len (in characters) for every record via - // top-down BFS. path_len = char count of "C:\dir\name". - compute_path_lengths(&mut records, &names, drive_letter); - - let compact_elapsed = compact_start.elapsed().as_millis(); - - // Try live $UpCase from the NTFS volume; fall back to compiled-in default. - let fold = resolve_case_fold(drive_letter); - - let tri_start = Instant::now(); - let trigram = TrigramIndex::build(&records, &names, fold); - let tri_elapsed = tri_start.elapsed().as_millis(); - - // Build children CSR index from parent_idx (two-pass: count + scatter). - let children = ChildrenIndex::build(&records); - - // Copy extension name table from MftIndex (Arc → Box). - let mut ext_names: Vec> = index - .extensions - .names - .iter() - .map(|arc| Box::from(arc.as_ref())) - .collect(); - - let ext_t0 = Instant::now(); - let ext_index = ExtensionIndex::build(&records); - let ext_build_ms = ext_t0.elapsed().as_millis(); - tracing::info!( - drive = %drive_letter, - entries = ext_index.total_entries(), - build_ms = ext_build_ms, - "ExtensionIndex built" - ); - - shrink_compact_vecs(drive_letter, &mut records, &mut names, &mut ext_names); - - // Phase 8: clone the FRS → mft_idx mapping off the transient - // `MftIndex` before it goes out of scope. In the primary - // `build_compact_index` path compact_idx == mft_idx (records - // are produced 1:1 by `index.records.par_iter().enumerate()`), - // so `frs_to_idx` is exactly the FRS → compact_idx mapping the - // surgical-patch path needs. Hardlink / ADS-expanded records - // append at the END with the same FRS but higher compact_idx; - // those secondary slots are not addressable from journal events - // (USN events reference primary FRS) so the primary mapping is - // sufficient. `uffs_mft::NO_ENTRY == u32::MAX` matches the - // sentinel `frs_to_compact` uses for unmapped slots. - let mut compact_index = DriveCompactIndex { - letter: drive_letter, - records: ColumnStorage::from_vec(records), - names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, - fold, - ext_names, - source: IndexSource::MftFile(std::path::PathBuf::from(format!("{drive_letter}:"))), - source_epoch: index.build_epoch, - bloom: None, - path_trie: None, - frs_to_compact: index.frs_to_idx.clone(), - }; - - // Phase 4: populate bloom + path_trie from the freshly-built - // index. These are needed for the search-skip pre-check - // (Commit F) and serialised into the v9+ cache (Commit D). - let bloom = compact_index.build_bloom(); - let path_trie = compact_index.build_path_trie(); - compact_index.bloom = Some(bloom); - compact_index.path_trie = Some(path_trie); - - (compact_index, compact_elapsed, tri_elapsed) -} - -/// Shrink all growable Vecs to exact fit after compact index build. -/// -/// Reclaims capacity slack from the doubling growth strategy used during -/// construction. Saves ~500 MB across 7 drives. -fn shrink_compact_vecs( - drive_letter: uffs_mft::platform::DriveLetter, - records: &mut Vec, - names: &mut Vec, - ext_names: &mut Vec>, -) { - let pre = records.capacity() * size_of::() + names.capacity(); - records.shrink_to_fit(); - names.shrink_to_fit(); - ext_names.shrink_to_fit(); - let post = records.capacity() * size_of::() + names.capacity(); - let reclaimed_mb = pre.saturating_sub(post) / (1024 * 1024); - if reclaimed_mb > 0 { - tracing::info!( - drive = %drive_letter, - reclaimed_mb, - "shrink_to_fit reclaimed memory" - ); - } -} - -/// Cache TTL in seconds (4 hours — same as Windows CLI). -/// -/// USN Journal handles incremental freshness; this is a safety-net full rescan. -pub(crate) const INDEX_TTL_SECONDS: u64 = 14400; - -// ── Live $UpCase resolution ────────────────────────────────────────── - -/// Try to read the live `$UpCase` table from the NTFS volume for -/// `drive_letter`. On success, log the result at `INFO` and any diffs -/// from the compiled-in default at `WARN`. On failure, log at `WARN` -/// and fall back to [`CaseFold::default_table()`]. -pub(crate) fn resolve_case_fold( - drive_letter: uffs_mft::platform::DriveLetter, -) -> uffs_text::case_fold::CaseFold { - let live_table = match uffs_mft::platform::upcase::read_upcase_table(drive_letter) { - Ok(table) => table, - Err(err) => { - tracing::warn!( - drive = %drive_letter, - error = %err, - "$UpCase live read failed — falling back to compiled-in default table" - ); - return uffs_text::case_fold::CaseFold::default_table(); - } - }; - - // Leak the box to get a `&'static [u16]` for CaseFold::from_ntfs. - let live_fold = uffs_text::case_fold::CaseFold::from_ntfs(Box::leak(live_table)); - log_upcase_comparison(drive_letter, &live_fold); - live_fold -} - -/// Log the comparison between live and compiled-in `$UpCase` tables. -fn log_upcase_comparison( - drive_letter: uffs_mft::platform::DriveLetter, - live_fold: &uffs_text::case_fold::CaseFold, -) { - let default = uffs_text::case_fold::CaseFold::default_table(); - let diffs = default.diff(live_fold); - - if diffs.is_empty() { - tracing::info!( - drive = %drive_letter, - "$UpCase loaded from live volume — identical to compiled-in default" - ); - return; - } - - tracing::info!( - drive = %drive_letter, - diff_count = diffs.len(), - "$UpCase loaded from live volume — differs from compiled-in default" - ); - for diff in &diffs { - tracing::warn!( - drive = %drive_letter, - codepoint = format_args!("U+{:04X}", diff.codepoint), - default = format_args!("U+{:04X}", diff.default_maps_to), - live = format_args!("U+{:04X}", diff.live_maps_to), - "$UpCase diff" - ); - } -} - // ════════════════════════════════════════════════════════════════════════ // REGRESSION TESTS — Search Pipeline Parity Guards // @@ -1159,3 +538,7 @@ fn log_upcase_comparison( #[cfg(test)] #[path = "compact_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "compact_trigram_delta_tests.rs"] +mod trigram_delta_tests; diff --git a/crates/uffs-core/src/compact/builder.rs b/crates/uffs-core/src/compact/builder.rs new file mode 100644 index 000000000..8ce1a8475 --- /dev/null +++ b/crates/uffs-core/src/compact/builder.rs @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Build a [`DriveCompactIndex`] from a loaded `MftIndex`: struct-of-arrays +//! column assembly, hardlink + ADS expansion, `$UpCase` case-fold resolution, +//! and the post-build vec shrink. + +use alloc::sync::Arc; +use std::time::Instant; + +use rayon::prelude::*; +use uffs_mft::index::MftIndex; + +use crate::compact::{ + ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex, IndexSource, + compute_path_lengths, +}; +use crate::compact_storage::ColumnStorage; +use crate::trigram::TrigramIndex; + +/// Expand alternate data streams (ADS) for a single record, producing the +/// name × stream cross product as extra `CompactRecord` entries. +#[expect( + clippy::single_call_fn, + reason = "Extracted to keep expand_links_and_ads under the too_many_lines limit" +)] +fn expand_ads_streams( + index: &MftIndex, + record: &uffs_mft::index::FileRecord, + resolve_parent: &dyn Fn(uffs_mft::ParentFrs, uffs_mft::Frs) -> u32, + names: &mut Vec, + extra: &mut Vec, +) { + // Collect all names for this record (primary + hardlinks). + let mut all_names: Vec<(&str, u32)> = Vec::new(); + let primary_name = index.get_name(record.first_name.name); + if !primary_name.is_empty() { + let pid = resolve_parent(record.first_name.parent_frs, record.frs); + all_names.push((primary_name, pid)); + } + if record.name_count > 1 { + let mut le = record.first_name.next_entry; + while le != uffs_mft::NO_ENTRY { + let Some(lnk) = index.links.get(le as usize) else { + break; + }; + let ln = index.get_name(lnk.name); + if !ln.is_empty() { + let lp = resolve_parent(lnk.parent_frs, record.frs); + all_names.push((ln, lp)); + } + le = lnk.next_entry; + } + } + + // Walk output streams (skip default $DATA at head of chain). + let mut se = record.first_stream.next_entry; + while se != uffs_mft::NO_ENTRY { + let Some(stream) = index.streams.get(se as usize) else { + break; + }; + if stream.is_output_stream() { + let sn = index.stream_name(stream); + if !sn.is_empty() { + for &(base_name, parent_idx) in &all_names { + let combined = format!("{base_name}:{sn}"); + let name_offset = uffs_mft::len_to_u32(names.len()); + let name_len = uffs_mft::len_to_u16(combined.len()); + names.extend_from_slice(combined.as_bytes()); + + extra.push(CompactRecord { + size: stream.size.length, + allocated: stream.size.allocated, + treesize: 0, + tree_allocated: 0, + created: record.stdinfo.created, + modified: record.stdinfo.modified, + accessed: record.stdinfo.accessed, + name_offset, + flags: record.stdinfo.flags, + parent_idx, + descendants: 0, + name_len, + extension_id: 0, + path_len: 0, + name_first_byte: combined.as_bytes().first().copied().unwrap_or(0), + _pad: [0; 1], + }); + } + } + } + se = stream.next_entry; + } +} + +/// Resolve a typed `ParentFrs` (vs an own typed `Frs`) into a compact-record +/// index, returning `u32::MAX` for the "no real parent" cases (self-reference, +/// `NO_ENTRY` sentinel, or root). +/// +/// Extracted as a free helper so the typed `ParentFrs`/`Frs` signature is +/// enforced at every call site AND so `build_compact_index` stays under +/// the clippy `too_many_lines` budget. +#[expect( + clippy::single_call_fn, + reason = "Wrapped by a closure in build_compact_index; kept free-standing \ + for clippy::too_many_lines budget headroom" +)] +fn resolve_parent_compact_idx( + index: &MftIndex, + parent_frs: uffs_mft::ParentFrs, + own_frs: uffs_mft::Frs, +) -> u32 { + let parent = parent_frs.as_frs(); + if parent == own_frs || parent_frs.raw() == u64::from(uffs_mft::NO_ENTRY) || parent.is_root() { + return u32::MAX; + } + let parent_usize = uffs_mft::frs_to_usize(parent.raw()); + index + .frs_to_idx + .get(parent_usize) + .copied() + .filter(|&idx| idx != uffs_mft::NO_ENTRY) + .unwrap_or(u32::MAX) +} + +/// Expand hardlinks and ADS into additional `CompactRecord` entries. +/// +/// Phase 2 (hardlinks): for each valid record with `name_count > 1`, walks the +/// link chain and creates additional records with alternate name/parent. +/// +/// Phase 3 (ADS): delegates to [`expand_ads_streams`] for each valid record +/// with `stream_count > 1`. +#[expect( + clippy::single_call_fn, + reason = "Extracted to keep build_compact_index under the too_many_lines limit" +)] +fn expand_links_and_ads( + index: &MftIndex, + resolver: &uffs_mft::index::PathResolver, + resolve_parent: &dyn Fn(uffs_mft::ParentFrs, uffs_mft::Frs) -> u32, + names: &mut Vec, +) -> Vec { + let mut extra: Vec = Vec::new(); + + for (idx, record) in index.records.iter().enumerate() { + if !resolver.is_valid_idx(idx) { + continue; + } + + // Phase 2: hardlink expansion. + if record.name_count > 1 { + let mut link_entry = record.first_name.next_entry; + while link_entry != uffs_mft::NO_ENTRY { + let Some(link) = index.links.get(link_entry as usize) else { + break; + }; + let link_parent = resolve_parent(link.parent_frs, record.frs); + extra.push(CompactRecord { + size: record.first_stream.size.length, + allocated: record.first_stream.size.allocated, + treesize: record.treesize, + tree_allocated: record.tree_allocated, + created: record.stdinfo.created, + modified: record.stdinfo.modified, + accessed: record.stdinfo.accessed, + name_offset: link.name.offset, + flags: record.stdinfo.flags, + parent_idx: link_parent, + descendants: record.descendants, + name_len: link.name.length(), + extension_id: link.name.extension_id(), + path_len: 0, + name_first_byte: names.get(link.name.offset as usize).copied().unwrap_or(0), + _pad: [0; 1], + }); + link_entry = link.next_entry; + } + } + + // Phase 3: ADS expansion (name × stream cross product). + if record.stream_count > 1 { + expand_ads_streams(index, record, resolve_parent, names, &mut extra); + } + } + extra +} + +/// Build a `DriveCompactIndex` from a loaded `MftIndex`. +/// +/// Returns `(DriveCompactIndex, compact_build_ms, trigram_build_ms)`. +#[must_use] +pub fn build_compact_index( + drive_letter: uffs_mft::platform::DriveLetter, + index: &MftIndex, +) -> (DriveCompactIndex, u128, u128) { + use uffs_mft::index::PathResolver; + + let compact_start = Instant::now(); + + // Build path resolver to determine which records are valid. + // This filters out system metafiles (FRS 0-15 except root) and + // propagates invalidity to descendants (e.g., $Extend children). + let resolver = PathResolver::build(index, false); + + // Closure wraps the free helper `resolve_parent_compact_idx` so the + // typed `ParentFrs`/`Frs` signature is enforced at every call site + // (own↔parent swap becomes a compile error). Keeping the helper + // free-standing also keeps `build_compact_index` under the + // clippy::too_many_lines budget. + let resolve_parent = |parent_frs: uffs_mft::ParentFrs, own_frs: uffs_mft::Frs| -> u32 { + resolve_parent_compact_idx(index, parent_frs, own_frs) + }; + + // Phase 1: build primary compact records (parallel). + let mut records: Vec = index + .records + .par_iter() + .enumerate() + .map(|(idx, record)| { + // Skip invalid records (system metafiles + descendants). + if !resolver.is_valid_idx(idx) { + return CompactRecord::default(); + } + + let name_ref = &record.first_name.name; + let parent_idx = resolve_parent(record.first_name.parent_frs, record.frs); + + CompactRecord { + size: record.first_stream.size.length, + allocated: record.first_stream.size.allocated, + treesize: record.treesize, + tree_allocated: record.tree_allocated, + created: record.stdinfo.created, + modified: record.stdinfo.modified, + accessed: record.stdinfo.accessed, + name_offset: name_ref.offset, + flags: record.stdinfo.flags, + parent_idx, + descendants: record.descendants, + name_len: name_ref.length(), + extension_id: name_ref.extension_id(), + path_len: 0, + name_first_byte: index + .names + .get(name_ref.offset as usize) + .copied() + .unwrap_or(0), + _pad: [0; 1], + } + }) + .collect(); + + // Phase 2+3: expand hardlinks and ADS (sequential — rare, <1% of records). + let mut names = index.names.clone(); + let expanded = expand_links_and_ads(index, &resolver, &resolve_parent, &mut names); + records.extend(expanded); + + // Phase 4: compute path_len (in characters) for every record via + // top-down BFS. path_len = char count of "C:\dir\name". + compute_path_lengths(&mut records, &names, drive_letter); + + let compact_elapsed = compact_start.elapsed().as_millis(); + + // Try live $UpCase from the NTFS volume; fall back to compiled-in default. + let fold = resolve_case_fold(drive_letter); + + let tri_start = Instant::now(); + let trigram = TrigramIndex::build(&records, &names, fold); + let tri_elapsed = tri_start.elapsed().as_millis(); + + // Build children CSR index from parent_idx (two-pass: count + scatter). + let children = ChildrenIndex::build(&records); + + // Copy extension name table from MftIndex (Arc → Box). + let mut ext_names: Vec> = index + .extensions + .names + .iter() + .map(|arc| Box::from(arc.as_ref())) + .collect(); + + let ext_t0 = Instant::now(); + let ext_index = ExtensionIndex::build(&records); + let ext_build_ms = ext_t0.elapsed().as_millis(); + tracing::info!( + drive = %drive_letter, + entries = ext_index.total_entries(), + build_ms = ext_build_ms, + "ExtensionIndex built" + ); + + shrink_compact_vecs(drive_letter, &mut records, &mut names, &mut ext_names); + + // Phase 8: clone the FRS → mft_idx mapping off the transient + // `MftIndex` before it goes out of scope. In the primary + // `build_compact_index` path compact_idx == mft_idx (records + // are produced 1:1 by `index.records.par_iter().enumerate()`), + // so `frs_to_idx` is exactly the FRS → compact_idx mapping the + // surgical-patch path needs. Hardlink / ADS-expanded records + // append at the END with the same FRS but higher compact_idx; + // those secondary slots are not addressable from journal events + // (USN events reference primary FRS) so the primary mapping is + // sufficient. `uffs_mft::NO_ENTRY == u32::MAX` matches the + // sentinel `frs_to_compact` uses for unmapped slots. + let mut compact_index = DriveCompactIndex { + letter: drive_letter, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), + fold, + ext_names, + source: IndexSource::MftFile(std::path::PathBuf::from(format!("{drive_letter}:"))), + source_epoch: index.build_epoch, + bloom: None, + path_trie: None, + frs_to_compact: index.frs_to_idx.clone(), + // Freshly built from the MFT — base CSR indexes are authoritative, + // no overlay yet. apply_usn_patch (Phase 2b) starts the delta. + delta: None, + }; + + // Phase 4: populate bloom + path_trie from the freshly-built + // index. These are needed for the search-skip pre-check + // (Commit F) and serialised into the v9+ cache (Commit D). + let bloom = compact_index.build_bloom(); + let path_trie = compact_index.build_path_trie(); + compact_index.bloom = Some(bloom); + compact_index.path_trie = Some(path_trie); + + (compact_index, compact_elapsed, tri_elapsed) +} + +/// Shrink all growable Vecs to exact fit after compact index build. +/// +/// Reclaims capacity slack from the doubling growth strategy used during +/// construction. Saves ~500 MB across 7 drives. +fn shrink_compact_vecs( + drive_letter: uffs_mft::platform::DriveLetter, + records: &mut Vec, + names: &mut Vec, + ext_names: &mut Vec>, +) { + let pre = records.capacity() * size_of::() + names.capacity(); + records.shrink_to_fit(); + names.shrink_to_fit(); + ext_names.shrink_to_fit(); + let post = records.capacity() * size_of::() + names.capacity(); + let reclaimed_mb = pre.saturating_sub(post) / (1024 * 1024); + if reclaimed_mb > 0 { + tracing::info!( + drive = %drive_letter, + reclaimed_mb, + "shrink_to_fit reclaimed memory" + ); + } +} + +/// Cache TTL in seconds (4 hours — same as Windows CLI). +/// +/// USN Journal handles incremental freshness; this is a safety-net full rescan. +pub(crate) const INDEX_TTL_SECONDS: u64 = 14400; + +// ── Live $UpCase resolution ────────────────────────────────────────── + +/// Try to read the live `$UpCase` table from the NTFS volume for +/// `drive_letter`. On success, log the result at `INFO` and any diffs +/// from the compiled-in default at `WARN`. On failure, log at `WARN` +/// and fall back to [`uffs_text::case_fold::CaseFold::default_table()`]. +pub(crate) fn resolve_case_fold( + drive_letter: uffs_mft::platform::DriveLetter, +) -> uffs_text::case_fold::CaseFold { + let live_table = match uffs_mft::platform::upcase::read_upcase_table(drive_letter) { + Ok(table) => table, + Err(err) => { + tracing::warn!( + drive = %drive_letter, + error = %err, + "$UpCase live read failed — falling back to compiled-in default table" + ); + return uffs_text::case_fold::CaseFold::default_table(); + } + }; + + // Leak the box to get a `&'static [u16]` for CaseFold::from_ntfs. + let live_fold = uffs_text::case_fold::CaseFold::from_ntfs(Box::leak(live_table)); + log_upcase_comparison(drive_letter, &live_fold); + live_fold +} + +/// Log the comparison between live and compiled-in `$UpCase` tables. +fn log_upcase_comparison( + drive_letter: uffs_mft::platform::DriveLetter, + live_fold: &uffs_text::case_fold::CaseFold, +) { + let default = uffs_text::case_fold::CaseFold::default_table(); + let diffs = default.diff(live_fold); + + if diffs.is_empty() { + tracing::info!( + drive = %drive_letter, + "$UpCase loaded from live volume — identical to compiled-in default" + ); + return; + } + + tracing::info!( + drive = %drive_letter, + diff_count = diffs.len(), + "$UpCase loaded from live volume — differs from compiled-in default" + ); + for diff in &diffs { + tracing::warn!( + drive = %drive_letter, + codepoint = format_args!("U+{:04X}", diff.codepoint), + default = format_args!("U+{:04X}", diff.default_maps_to), + live = format_args!("U+{:04X}", diff.live_maps_to), + "$UpCase diff" + ); + } +} diff --git a/crates/uffs-core/src/compact/children.rs b/crates/uffs-core/src/compact/children.rs new file mode 100644 index 000000000..882d4c688 --- /dev/null +++ b/crates/uffs-core/src/compact/children.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! [`ChildrenIndex`] — CSR parent→children adjacency, the read-side of the +//! tree walk + the Phase-1 subtree path-length propagation. + +use crate::compact::CompactRecord; + +/// Children index in CSR (Compressed Sparse Row) layout. +/// +/// `children(i)` returns the compact indices of record i's children as +/// a contiguous `&[u32]` slice. The CSR layout avoids per-record `Vec` +/// allocations and enables bulk serialization/deserialization. +#[derive(Clone)] +pub struct ChildrenIndex { + /// CSR offsets — one per record + sentinel. Length = `record_count` + 1. + /// Children of record `i` are `values[offsets[i]..offsets[i+1]]`. + offsets: Vec, + /// Flat array of all child indices. + values: Vec, +} + +impl ChildrenIndex { + /// Total heap capacity (offsets + values) in bytes. + #[must_use] + pub const fn heap_size_bytes(&self) -> usize { + self.offsets.capacity() * size_of::() + self.values.capacity() * size_of::() + } + + /// Build from `CompactRecord::parent_idx` in two passes (count + scatter). + #[must_use] + pub fn build(records: &[CompactRecord]) -> Self { + // Count children per parent + let mut counts = vec![0_u32; records.len()]; + for rec in records { + let parent = rec.parent_idx; + if parent != u32::MAX + && let Some(cnt) = counts.get_mut(parent as usize) + { + *cnt += 1; + } + } + + // Prefix-sum → offsets + let mut offsets = Vec::with_capacity(records.len() + 1); + let mut running = 0_u32; + for &cnt in &counts { + offsets.push(running); + running = running.saturating_add(cnt); + } + offsets.push(running); + + // Scatter children into values + let mut values = vec![0_u32; running as usize]; + let mut write_pos = offsets.clone(); + for (idx, rec) in records.iter().enumerate() { + let parent = rec.parent_idx; + if parent != u32::MAX + && let Some(pos) = write_pos.get_mut(parent as usize) + && let Some(slot) = values.get_mut(*pos as usize) + { + let child_idx = uffs_mft::len_to_u32(idx); + *slot = child_idx; + *pos += 1; + } + } + + Self { offsets, values } + } + + /// Construct directly from pre-built CSR arrays (cache deserialization). + #[must_use] + pub const fn from_csr(offsets: Vec, values: Vec) -> Self { + Self { offsets, values } + } + + /// Borrow the CSR components for serialization. + #[must_use] + pub(crate) fn as_csr(&self) -> (&[u32], &[u32]) { + (&self.offsets, &self.values) + } + + /// Return the children of record `idx` as a contiguous slice. + #[must_use] + pub fn get(&self, idx: usize) -> &[u32] { + let start = self.offsets.get(idx).copied().unwrap_or(0) as usize; + let end = self.offsets.get(idx + 1).copied().unwrap_or(0) as usize; + self.values.get(start..end).unwrap_or(&[]) + } + + /// Total number of child entries across all records. + #[must_use] + pub const fn total_children(&self) -> usize { + self.values.len() + } + + /// Number of records tracked (one slot per record). + #[must_use] + pub const fn record_count(&self) -> usize { + self.offsets.len().saturating_sub(1) + } + + /// Create an empty children index. + #[must_use] + pub fn empty() -> Self { + Self { + offsets: vec![0], + values: Vec::new(), + } + } +} diff --git a/crates/uffs-core/src/compact/delta.rs b/crates/uffs-core/src/compact/delta.rs new file mode 100644 index 000000000..750bb5706 --- /dev/null +++ b/crates/uffs-core/src/compact/delta.rs @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Mutable overlay over the immutable base CSR indexes +//! (incremental-index-maintenance §5.1). +//! +//! The base [`crate::trigram::TrigramIndex`] / +//! [`crate::compact::ChildrenIndex`] / [`crate::compact::ExtensionIndex`] are +//! compressed-sparse-row structures: fast to +//! query, immutable, and **expensive to rebuild** (the per-apply rebuild is the +//! cost this project removes). [`IndexDelta`] holds the postings *added* since +//! the last compaction plus a tombstone set for records whose base postings are +//! stale (deleted or renamed away). A search reads `base ∪ delta − tombstones`; +//! an occasional compaction folds the delta back into a fresh base and clears +//! it (`delta = None`). +//! +//! **Invariant:** every posting list is kept **sorted ascending and deduped** +//! on insert, so the base∪delta merge at query time is a linear sorted-merge +//! and tombstone filtering is a sorted-set difference. The base CSR posting +//! lists are already sorted, so the shapes compose. +//! +//! This is Phase-2 scaffolding: the type + its merge primitives land here with +//! unit tests; `DriveCompactIndex` gains the `delta` field and the +//! `trigram_search` choke point when trigram delta is wired (design §4 Phase +//! 2), so each of the ~20 `DriveCompactIndex` construction sites is touched +//! exactly once, with the change that gives the field meaning. + +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Mutable overlay over the immutable base CSR indexes. A `None` +/// `delta` on [`crate::compact::DriveCompactIndex`] means "freshly compacted — +/// pure base, zero query overhead". +#[derive(Debug, Default, Clone)] +pub struct IndexDelta { + /// packed-trigram → sorted, deduped record indices added since compaction. + pub trigram: FxHashMap>, + /// `ext_id` → sorted, deduped record indices added since compaction. + pub ext: FxHashMap>, + /// parent record idx → sorted, deduped child record indices added since + /// compaction. + pub children: FxHashMap>, + /// record indices whose BASE postings are stale (deleted / renamed-away). + pub tombstones: FxHashSet, + /// count of distinct records touched since compaction (the compaction + /// trigger input — see [`IndexDelta::len`]). + pub touched_records: u32, +} + +impl IndexDelta { + /// Register a newly created / renamed-in record's postings across every + /// index overlay. `trigrams` is the packed-trigram set of the record's name + /// (deduped by the caller is fine — `sorted_insert` dedups anyway). + /// + /// A renamed record is `tombstone`d at its stale base postings first, then + /// `add_record`ed at its new ones; create is `add_record` only. + pub fn add_record(&mut self, idx: u32, trigrams: &[u64], ext_id: u16, parent_idx: u32) { + for &key in trigrams { + sorted_insert(self.trigram.entry(key).or_default(), idx); + } + sorted_insert(self.ext.entry(ext_id).or_default(), idx); + // u32::MAX parent = root sentinel; root has no parent posting to add to. + if parent_idx != u32::MAX { + sorted_insert(self.children.entry(parent_idx).or_default(), idx); + } + self.touched_records = self.touched_records.saturating_add(1); + } + + /// Mark a record's BASE postings stale. Idempotent. The record may still + /// reappear in `delta` postings via a subsequent [`IndexDelta::add_record`] + /// (rename = tombstone-old + add-new); tombstone filtering is applied to + /// the final merged set, so that is correct. + pub fn tombstone(&mut self, idx: u32) { + if self.tombstones.insert(idx) { + self.touched_records = self.touched_records.saturating_add(1); + } + } + + /// Whether `idx`'s base postings have been tombstoned. + #[must_use] + pub fn is_tombstoned(&self, idx: u32) -> bool { + self.tombstones.contains(&idx) + } + + /// Records touched since compaction — the compaction-trigger input. Counts + /// distinct adds + tombstones (an add and a tombstone of the same idx, as + /// in a rename, count as two touches, which is the intended "work done" + /// signal). + #[must_use] + pub const fn len(&self) -> u32 { + self.touched_records + } + + /// Whether nothing has been overlaid since compaction. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.touched_records == 0 + } + + /// Delta postings for one packed trigram (sorted, deduped), or `&[]`. + #[must_use] + pub fn trigram_postings(&self, key: u64) -> &[u32] { + self.trigram.get(&key).map_or(&[], Vec::as_slice) + } + + /// Delta postings for one extension id (sorted, deduped), or `&[]`. + #[must_use] + pub fn ext_postings(&self, ext_id: u16) -> &[u32] { + self.ext.get(&ext_id).map_or(&[], Vec::as_slice) + } + + /// Delta child postings for one parent idx (sorted, deduped), or `&[]`. + #[must_use] + pub fn child_postings(&self, parent_idx: u32) -> &[u32] { + self.children.get(&parent_idx).map_or(&[], Vec::as_slice) + } +} + +/// Insert `value` into a sorted, deduped `Vec`, keeping it sorted and +/// deduped. No-op if already present. O(log n) search + O(n) shift — postings +/// are small per key (one apply batch's worth) so this is cheap. +fn sorted_insert(list: &mut Vec, value: u32) { + if let Err(pos) = list.binary_search(&value) { + list.insert(pos, value); + } +} + +/// Sorted-union merge of a base posting list with delta additions, calling +/// `emit` for each value that passes `is_valid` — the zero-alloc building block +/// of the children / extension overlays (Phase 4). Both inputs are sorted + +/// deduped; each merged value is emitted at most once. +/// +/// `is_valid` is the per-candidate liveness check against the current records +/// (e.g. `records[c].parent_idx == parent`), which is what lets the children / +/// ext overlays drop a moved-away or deleted record without a tombstone — a +/// stale base posting simply fails the check. +pub(crate) fn merge_filter bool, E: FnMut(u32)>( + base: &[u32], + delta: &[u32], + is_valid: V, + mut emit: E, +) { + let mut base_it = base.iter().copied().peekable(); + let mut delta_it = delta.iter().copied().peekable(); + loop { + let next = match (base_it.peek().copied(), delta_it.peek().copied()) { + (Some(bv), Some(dv)) if bv < dv => { + base_it.next(); + bv + } + (Some(bv), Some(dv)) if bv > dv => { + delta_it.next(); + dv + } + (Some(bv), Some(_)) => { + base_it.next(); + delta_it.next(); + bv + } + (Some(bv), None) => { + base_it.next(); + bv + } + (None, Some(dv)) => { + delta_it.next(); + dv + } + (None, None) => return, + }; + if is_valid(next) { + emit(next); + } + } +} + +/// Sorted-union merge of a base posting list with delta additions — the +/// per-trigram building block of +/// [`crate::compact::DriveCompactIndex::trigram_search`]. Both inputs are +/// sorted and deduped, as is the result. +/// +/// Tombstones are deliberately **not** applied here: a renamed record is +/// tombstoned in base yet legitimately re-added in `delta` under its new name, +/// so tombstone validity can only be decided on the final intersected candidate +/// set (see `trigram_search`), never per posting list. +#[must_use] +pub(crate) fn merge_postings(base: &[u32], delta: &[u32]) -> Vec { + if delta.is_empty() { + return base.to_vec(); + } + if base.is_empty() { + return delta.to_vec(); + } + let mut out = Vec::with_capacity(base.len() + delta.len()); + let mut base_it = base.iter().copied().peekable(); + let mut delta_it = delta.iter().copied().peekable(); + loop { + let next = match (base_it.peek().copied(), delta_it.peek().copied()) { + (Some(bv), Some(dv)) if bv < dv => { + base_it.next(); + bv + } + (Some(bv), Some(dv)) if bv > dv => { + delta_it.next(); + dv + } + (Some(bv), Some(_)) => { + base_it.next(); + delta_it.next(); + bv // equal — emit once + } + (Some(bv), None) => { + base_it.next(); + bv + } + (None, Some(dv)) => { + delta_it.next(); + dv + } + (None, None) => return out, + }; + if out.last() != Some(&next) { + out.push(next); + } + } +} + +#[cfg(test)] +mod tests { + use super::IndexDelta; + + #[test] + fn add_record_keeps_postings_sorted_and_deduped() { + let mut delta = IndexDelta::default(); + // Insert out of order + a duplicate trigram for the same record. + delta.add_record(5, &[300, 100, 200, 100], 2, 4); + delta.add_record(3, &[100], 2, 4); + delta.add_record(9, &[100], 7, 4); + + assert_eq!(delta.trigram_postings(100), &[3, 5, 9], "sorted + deduped"); + assert_eq!(delta.trigram_postings(200), &[5]); + assert_eq!(delta.trigram_postings(300), &[5]); + assert_eq!(delta.ext_postings(2), &[3, 5]); + assert_eq!(delta.ext_postings(7), &[9]); + assert_eq!( + delta.child_postings(4), + &[3, 5, 9], + "all three share parent 4" + ); + assert_eq!(delta.trigram_postings(999), &[] as &[u32], "absent key"); + } + + #[test] + fn root_parent_sentinel_adds_no_child_posting() { + let mut delta = IndexDelta::default(); + delta.add_record(0, &[10], 1, u32::MAX); // root: no parent posting + assert!( + delta.children.is_empty(), + "u32::MAX parent must not create a posting" + ); + assert_eq!(delta.trigram_postings(10), &[0]); + } + + #[test] + fn tombstone_is_idempotent_and_counted_once() { + let mut delta = IndexDelta::default(); + delta.tombstone(7); + delta.tombstone(7); + assert!(delta.is_tombstoned(7)); + assert!(!delta.is_tombstoned(8)); + assert_eq!(delta.len(), 1, "duplicate tombstone is not double-counted"); + } + + #[test] + fn len_counts_distinct_touches_including_rename_as_two() { + let mut delta = IndexDelta::default(); + assert!(delta.is_empty()); + // rename: tombstone old postings, add new — two units of work. + delta.tombstone(4); + delta.add_record(4, &[1, 2], 0, 1); + assert_eq!(delta.len(), 2); + assert!(!delta.is_empty()); + } + + #[test] + fn merge_postings_is_sorted_deduped_union() { + use super::merge_postings; + assert_eq!(merge_postings(&[1, 3, 5, 7], &[2, 5, 6]), vec![ + 1, 2, 3, 5, 6, 7 + ]); + assert_eq!(merge_postings(&[], &[2, 4]), vec![2, 4]); + assert_eq!(merge_postings(&[1, 3], &[]), vec![1, 3]); + assert_eq!(merge_postings(&[], &[]), Vec::::new()); + // full overlap dedups to one copy. + assert_eq!(merge_postings(&[1, 2, 3], &[1, 2, 3]), vec![1, 2, 3]); + } +} diff --git a/crates/uffs-core/src/compact/extension.rs b/crates/uffs-core/src/compact/extension.rs new file mode 100644 index 000000000..c4d0c3e01 --- /dev/null +++ b/crates/uffs-core/src/compact/extension.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! [`ExtensionIndex`] — CSR `extension_id` → records inverted index for O(K) +//! `--ext` queries. + +use crate::compact::CompactRecord; + +/// Extension inverted index: `extension_id → &[u32]` (record indices). +/// +/// CSR layout identical to `ChildrenIndex`. Built once at load time in a +/// single O(N) pass so `--ext rs` queries can iterate only matching records +/// instead of scanning all 25M entries. +#[derive(Clone)] +pub struct ExtensionIndex { + /// CSR offsets — length = `max_ext_id` + 2 (one per `ext_id` + sentinel). + offsets: Vec, + /// Flat array of record indices, grouped by `extension_id`. + values: Vec, +} + +impl ExtensionIndex { + /// Total heap capacity (offsets + values) in bytes. + #[must_use] + pub const fn heap_size_bytes(&self) -> usize { + self.offsets.capacity() * size_of::() + self.values.capacity() * size_of::() + } + + /// Build from compact records in two passes (count + scatter). + #[must_use] + pub fn build(records: &[CompactRecord]) -> Self { + // Find the maximum extension_id to size the offsets array. + let max_id = records + .iter() + .map(|rec| rec.extension_id) + .max() + .unwrap_or(0) as usize; + + // Pass 1: count records per extension_id. + let mut counts = vec![0_u32; max_id + 1]; + for rec in records { + if rec.name_len == 0 { + continue; + } + if let Some(cnt) = counts.get_mut(rec.extension_id as usize) { + *cnt += 1; + } + } + + // Prefix-sum → offsets. + let mut offsets = Vec::with_capacity(max_id + 2); + let mut running = 0_u32; + for &cnt in &counts { + offsets.push(running); + running = running.saturating_add(cnt); + } + offsets.push(running); + + // Pass 2: scatter record indices into values. + let mut values = vec![0_u32; running as usize]; + let mut write_pos = offsets.clone(); + for (idx, rec) in records.iter().enumerate() { + if rec.name_len == 0 { + continue; + } + let eid = rec.extension_id as usize; + if let Some(pos) = write_pos.get_mut(eid) + && let Some(slot) = values.get_mut(*pos as usize) + { + let idx_u32 = uffs_mft::len_to_u32(idx); + *slot = idx_u32; + *pos += 1; + } + } + + Self { offsets, values } + } + + /// Return record indices for the given `extension_id`. + #[must_use] + pub fn get(&self, ext_id: u16) -> &[u32] { + let eid = ext_id as usize; + let start = self.offsets.get(eid).copied().unwrap_or(0) as usize; + let end = self.offsets.get(eid + 1).copied().unwrap_or(0) as usize; + self.values.get(start..end).unwrap_or(&[]) + } + + /// Create an empty extension index. + #[must_use] + pub fn empty() -> Self { + Self { + offsets: vec![0], + values: Vec::new(), + } + } + + /// Total number of indexed record entries. + #[must_use] + pub const fn total_entries(&self) -> usize { + self.values.len() + } +} diff --git a/crates/uffs-core/src/compact/path_len.rs b/crates/uffs-core/src/compact/path_len.rs new file mode 100644 index 000000000..42c85a782 --- /dev/null +++ b/crates/uffs-core/src/compact/path_len.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Per-record `path_len` computation: the cold-load top-down BFS plus the +//! Phase-1 incremental update used by the USN apply path +//! (incremental-index-maintenance §5.5). + +use crate::compact::{ChildrenIndex, CompactRecord}; + +/// Compute `path_len` (in **characters**, not bytes) for every record +/// via top-down BFS. +/// +/// Root entries (`parent_idx == u32::MAX`) get +/// `path_len = 2 + 1 + name_chars` (e.g. `"C:\" + name`), and children +/// accumulate `parent.path_len + 1 (separator) + name_chars`. +/// Saturates at `u16::MAX` (65 535) for extremely deep paths. +/// +/// Character counting matches `str::chars().count()` so the precomputed +/// value agrees with the display-row path-length filter. +pub(crate) fn compute_path_lengths( + records: &mut [CompactRecord], + names: &[u8], + drive_letter: uffs_mft::platform::DriveLetter, +) { + // Drive prefix in characters: the letter (1 char) + colon (1 char) = 2. + // `DriveLetter` is ASCII A–Z by construction (validated in + // `DriveLetter::parse`), so the previous runtime `debug_assert!` + // is now a tautology and was removed. The arithmetic only cares + // about "1 letter char + 1 colon". + let _: uffs_mft::platform::DriveLetter = drive_letter; + let drive_prefix_chars: u32 = 1 /* letter */ + 1 /* ':' */; + + // Build forward adjacency list (parent → children) for top-down BFS. + let record_count = records.len(); + let mut children_of: Vec> = vec![Vec::new(); record_count]; + let mut roots: Vec = Vec::new(); + + for (idx, rec) in records.iter().enumerate() { + let pi = rec.parent_idx; + if pi == u32::MAX { + roots.push(uffs_mft::len_to_u32(idx)); + } else if let Some(siblings) = children_of.get_mut(pi as usize) { + siblings.push(uffs_mft::len_to_u32(idx)); + } + } + + // BFS from roots. + let mut queue = alloc::collections::VecDeque::with_capacity(roots.len()); + for &root in &roots { + let Some(rec) = records.get(root as usize) else { + continue; + }; + let name_chars = name_char_count(rec, names); + let pl = if name_chars == 0 { + // Drive root directory: "C:\" + drive_prefix_chars + 1 + } else { + // Top-level file/dir: "C:\" + drive_prefix_chars + 1 + name_chars + }; + if let Some(slot) = records.get_mut(root as usize) { + slot.path_len = uffs_mft::len_to_u16(pl as usize); + } + queue.push_back(root); + } + + while let Some(idx) = queue.pop_front() { + let parent_pl = records + .get(idx as usize) + .map_or(0, |rec| u32::from(rec.path_len)); + let children: Vec = children_of + .get(idx as usize) + .map_or_else(Vec::new, Clone::clone); + for &child in &children { + let child_chars = records + .get(child as usize) + .map_or(0, |rec| name_char_count(rec, names)); + // path = parent_path + "\" + name + let pl = parent_pl.saturating_add(1).saturating_add(child_chars); + if let Some(slot) = records.get_mut(child as usize) { + slot.path_len = uffs_mft::len_to_u16(pl as usize); + } + queue.push_back(child); + } + } +} + +/// A record whose `path_len` a USN apply must refresh, plus whether the change +/// can shift its whole subtree. +/// +/// Phase 1 of incremental-index-maintenance (design doc §5.5): instead of the +/// O(total) [`compute_path_lengths`] BFS every apply, refresh only the records +/// a batch touched. A **directory rename** moves every descendant's path by a +/// constant Δ, so `subtree` requests the descendant walk; creates and file +/// renames are a single O(1) refresh. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PathChange { + /// Compact index of the created / renamed record. + pub idx: u32, + /// `true` for a directory rename (propagate Δ to descendants); `false` for + /// creates and file renames (refresh this record only). + pub subtree: bool, +} + +/// Refresh `path_len` for only the records a USN batch touched, instead of the +/// O(total-records) [`compute_path_lengths`] BFS — the Phase-1 lever of +/// incremental-index-maintenance (design doc §5.5). +/// +/// `children` is the base CSR; `delta` is the overlay (Phase 4b) — together +/// they give the **current** child adjacency a directory rename walks to shift +/// its subtree, even for children created in the same batch. Caller falls back +/// to the full [`compute_path_lengths`] for cold loads and for batches large +/// enough that incremental loses (see the threshold in +/// `compact_loader/rebuild.rs`). +pub(crate) fn update_path_lengths_incremental( + records: &mut [CompactRecord], + names: &[u8], + drive_letter: uffs_mft::platform::DriveLetter, + children: &ChildrenIndex, + delta: Option<&crate::compact::IndexDelta>, + changed: &[PathChange], +) { + // `DriveLetter` is ASCII A–Z by construction, so the drive prefix is always + // "X:" = 2 chars (matches `compute_path_lengths`). + let _: uffs_mft::platform::DriveLetter = drive_letter; + let drive_prefix_chars: u32 = 1 /* letter */ + 1 /* ':' */; + + for change in changed { + let idx = change.idx as usize; + let Some(rec) = records.get(idx) else { + continue; + }; + // Skip a slot tombstoned within the same batch (create then delete): + // `apply_delete` set name_len=0 + parent=MAX. + if rec.name_len == 0 && rec.parent_idx == u32::MAX { + continue; + } + let old_pl = u32::from(rec.path_len); + let new_pl = path_len_from_parent(records, names, drive_prefix_chars, change.idx); + if let Some(slot) = records.get_mut(idx) { + slot.path_len = uffs_mft::len_to_u16(new_pl as usize); + } + if change.subtree { + let shift = i64::from(new_pl) - i64::from(old_pl); + if shift != 0 { + shift_subtree_path_len(records, children, delta, change.idx, shift); + } + } + } +} + +/// `path_len` for `idx` from its (current) parent's `path_len` + own name — +/// the per-node arithmetic of [`compute_path_lengths`]'s BFS, in isolation. +fn path_len_from_parent( + records: &[CompactRecord], + names: &[u8], + drive_prefix_chars: u32, + idx: u32, +) -> u32 { + let Some(rec) = records.get(idx as usize) else { + return 0; + }; + let name_chars = name_char_count(rec, names); + if rec.parent_idx == u32::MAX { + // Root level: "C:\" (no name) or "C:\". + if name_chars == 0 { + drive_prefix_chars.saturating_add(1) + } else { + drive_prefix_chars + .saturating_add(1) + .saturating_add(name_chars) + } + } else { + let parent_pl = records + .get(rec.parent_idx as usize) + .map_or(0, |parent| u32::from(parent.path_len)); + parent_pl.saturating_add(1).saturating_add(name_chars) + } +} + +/// Add `shift` to every descendant of `root`'s `path_len` (a directory rename +/// moves each descendant's full path by the same amount). +/// +/// Two passes so the read of the child adjacency (which validates against +/// `records`) never overlaps the write of `path_len`: pass 1 collects every +/// descendant over the base ∪ delta children (Phase 4b), pass 2 shifts each. +/// Pure arithmetic, no name/string walk. +fn shift_subtree_path_len( + records: &mut [CompactRecord], + children: &ChildrenIndex, + delta: Option<&crate::compact::IndexDelta>, + root: u32, + shift: i64, +) { + // Pass 1 — collect descendants (read-only over records + children + delta). + let mut descendants: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); + push_children(records, children, delta, root, &mut stack); + while let Some(idx) = stack.pop() { + descendants.push(idx); + push_children(records, children, delta, idx, &mut stack); + } + // Pass 2 — shift each descendant's path_len (mutates records). + for idx in descendants { + if let Some(rec) = records.get_mut(idx as usize) { + let shifted = i64::from(rec.path_len) + .saturating_add(shift) + .clamp(0, i64::from(u16::MAX)); + rec.path_len = u16::try_from(shifted).unwrap_or(u16::MAX); + } + } +} + +/// Push the live children of `parent` (base ∪ delta, validated against +/// `records`) onto `stack`. The read-only adjacency primitive of the subtree +/// walk; mirrors [`crate::compact::DriveCompactIndex::for_each_child`]. +fn push_children( + records: &[CompactRecord], + children: &ChildrenIndex, + delta: Option<&crate::compact::IndexDelta>, + parent: u32, + stack: &mut Vec, +) { + let base = children.get(parent as usize); + let Some(overlay) = delta else { + stack.extend_from_slice(base); + return; + }; + let is_valid = |child: u32| { + records + .get(child as usize) + .is_some_and(|rec| rec.parent_idx == parent && rec.name_len != 0) + }; + crate::compact::delta::merge_filter(base, overlay.child_postings(parent), is_valid, |child| { + stack.push(child); + }); +} + +/// Count the number of Unicode characters in a record's filename. +/// +/// Falls back to `name_len` (byte count) if the name slice is not valid +/// UTF-8 — this is correct for ASCII names and a safe upper bound +/// otherwise. +fn name_char_count(rec: &CompactRecord, names: &[u8]) -> u32 { + let start = rec.name_offset as usize; + let end = start + rec.name_len as usize; + names + .get(start..end) + .and_then(|slice| core::str::from_utf8(slice).ok()) + .map_or_else( + || u32::from(rec.name_len), + |name| uffs_mft::len_to_u32(name.chars().count()), + ) +} diff --git a/crates/uffs-core/src/compact/record.rs b/crates/uffs-core/src/compact/record.rs new file mode 100644 index 000000000..8fcb19638 --- /dev/null +++ b/crates/uffs-core/src/compact/record.rs @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! The 80-byte [`CompactRecord`] row type + the NTFS metafile-name allowlist. +//! +//! Extracted from `compact.rs` (file-size decomposition); the public path +//! `crate::compact::CompactRecord` is preserved via re-export. + +/// Compact per-record data for in-memory search, filter, and sort. +/// +/// 80 bytes per record (76 data + 4 explicit tail padding). +/// Derives `bytemuck::Pod` + `Zeroable` so the entire record array can be +/// serialized/deserialized as a single bulk `memcpy` — no per-field encoding. +#[derive(Debug, Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct CompactRecord { + // ── u64 fields first (8-byte aligned) ───────────────────────── + /// Logical file size in bytes. + pub size: u64, + /// Allocated size on disk in bytes ("Size on Disk" column). + pub allocated: u64, + /// Sum of logical file sizes in entire subtree. + pub treesize: u64, + /// Sum of allocated sizes in entire subtree. + pub tree_allocated: u64, + /// Creation time (Unix microseconds). + pub created: i64, + /// Last write time (Unix microseconds). + pub modified: i64, + /// Last access time (Unix microseconds). + pub accessed: i64, + + // ── u32 fields (4-byte aligned) ─────────────────────────────── + /// Byte offset into the names blob. + pub name_offset: u32, + /// Raw NTFS `FILE_ATTRIBUTE_*` flags. + pub flags: u32, + /// Index into the compact array of the parent directory. + /// `u32::MAX` = root or orphan. + pub parent_idx: u32, + /// Count of all descendants in subtree. 0 for files. + pub descendants: u32, + + // ── u16 fields (2-byte aligned) ─────────────────────────────── + /// UTF-8 byte length of the filename. + pub name_len: u16, + /// Interned extension ID (0 = no extension). + pub extension_id: u16, + /// Full path length in UTF-8 bytes (e.g. `C:\Windows\System32\cmd.exe` = + /// 28). Precomputed at index build time via top-down parent-chain walk. + /// Saturates at `u16::MAX` (65 535) for extremely deep paths. + pub path_len: u16, + + /// First byte of the filename (e.g. `b'$'` for NTFS metafiles). + /// + /// Cached here as a cheap hot-path *gate*: only `$`-prefixed records can be + /// NTFS metafiles, so [`is_system_metafile`](Self::is_system_metafile) can + /// reject virtually every record with one sequential field read instead of + /// a random cache-miss into the names arena. The handful of `$`-prefixed + /// candidates then pay one arena lookup for the authoritative name check. + pub name_first_byte: u8, + + /// Explicit tail padding for 8-byte struct alignment. + /// Required by `bytemuck::Pod` — no implicit padding allowed. + #[expect( + clippy::pub_underscore_fields, + reason = "bytemuck Pod requires all fields same visibility" + )] + pub _pad: [u8; 1], +} + +/// The fixed set of reserved NTFS metafile names: the `$`-prefixed records at +/// reserved FRS 0–15 and under the `$Extend` directory. An NTFS volume can +/// only ever contain *these* specific metafiles. +/// +/// Any *other* `$`-prefixed name — `$Recycle.Bin`, `$PatchCache`, +/// `$WinREAgent`, the `WinSxS` `$$_*.cdf-ms` filemaps, or a user file literally +/// named `$foo` — is an ordinary file that file managers and tools like +/// Everything display. Classifying those as metafiles is exactly the bug +/// `--hide-system` had. +/// +/// Matched case-insensitively: NTFS itself is case-insensitive, and these +/// canonical names are occasionally surfaced with varied casing. +pub(crate) const NTFS_METAFILE_NAMES: &[&str] = &[ + // Reserved FRS 0–11 (volume root metafiles) + "$MFT", + "$MFTMirr", + "$LogFile", + "$Volume", + "$AttrDef", + "$Bitmap", + "$Boot", + "$BadClus", + "$Secure", + "$UpCase", + "$Extend", + // `$Extend` directory children + "$ObjId", + "$Quota", + "$Reparse", + "$UsnJrnl", + "$RmMetadata", + "$Deleted", + // `$Extend\$RmMetadata` children + "$Repair", + "$Tops", + "$TxfLog", + "$Txf", +]; + +/// Returns whether `name` is one of the reserved `NTFS_METAFILE_NAMES` +/// (a crate-private allowlist, so no intra-doc link from this public item). +/// +/// Real metafiles are already excluded from the compact index at build time +/// (`build_compact_index` drops them via `PathResolver` FRS-validity, not by +/// name). This exact-name check is the *authoritative* classifier for the +/// `--hide-system` filter, so it can never misclassify an ordinary +/// `$`-prefixed file as a metafile. +#[must_use] +#[inline] +pub fn is_ntfs_metafile_name(name: &str) -> bool { + NTFS_METAFILE_NAMES + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) +} + +impl CompactRecord { + /// Directory flag bit in raw NTFS `FILE_ATTRIBUTE_DIRECTORY`. + const DIRECTORY_BIT: u32 = 0x0010; + + /// Returns `true` if this record is a directory. + #[inline] + #[must_use] + pub const fn is_directory(self) -> bool { + self.flags & Self::DIRECTORY_BIT != 0 + } + + /// Returns `true` if this record is one of the reserved NTFS metafiles + /// (`$MFT`, `$LogFile`, `$Bitmap`, `$Secure`, the `$Extend` family, …). + /// + /// The cached [`name_first_byte`](Self::name_first_byte) field is a cheap + /// gate: every metafile name starts with `$`, and `$`-prefixed records are + /// a vanishing fraction of an index, so this rejects virtually every record + /// with a single byte comparison and only touches the names arena for the + /// handful of `$`-prefixed candidates. The arena lookup is *required* for + /// correctness, because an ordinary file may also start with `$` + /// (`$Recycle.Bin`, `$PatchCache`, the `WinSxS` `$$_*.cdf-ms` filemaps) — + /// those are NOT metafiles and must not be hidden by `--hide-system`. + /// See [`is_ntfs_metafile_name`]. + #[inline] + #[must_use] + pub fn is_system_metafile(&self, names: &[u8]) -> bool { + self.name_first_byte == b'$' && is_ntfs_metafile_name(self.name(names)) + } + + /// Get the name from a names blob as a **lossy `&str` view**. + /// + /// Valid-UTF-8 names (the common case) are returned verbatim; an ill-formed + /// (surrogate-bearing) name stored as WTF-8 returns `""` for display. Use + /// [`Self::name_bytes`] for the lossless bytes that exact/substring search + /// matches against, so a file with an ill-formed name stays findable + /// (WI-4.4). + #[inline] + #[must_use] + pub fn name<'a>(&self, names: &'a [u8]) -> &'a str { + core::str::from_utf8(self.name_bytes(names)).unwrap_or("") + } + + /// Get the name's **raw bytes** (WTF-8) from a names blob — the lossless + /// accessor. + /// + /// Returns exactly the stored bytes, including the byte-faithful encoding + /// of an ill-formed NTFS name (unpaired surrogates). This is what makes + /// every file matchable/findable by its true name regardless of UTF-8 + /// well-formedness (WI-4.4). Returns `&[]` for an out-of-range slice. + #[inline] + #[must_use] + pub fn name_bytes<'a>(&self, names: &'a [u8]) -> &'a [u8] { + let start = self.name_offset as usize; + let end = start.saturating_add(self.name_len as usize); + names.get(start..end).unwrap_or(&[]) + } +} + +// Compile-time size assertion. +const _: () = assert!( + size_of::() == 80, + "CompactRecord must be exactly 80 bytes" +); diff --git a/crates/uffs-core/src/compact_cache.rs b/crates/uffs-core/src/compact_cache.rs index 63e32bda5..5f510e966 100644 --- a/crates/uffs-core/src/compact_cache.rs +++ b/crates/uffs-core/src/compact_cache.rs @@ -791,9 +791,9 @@ where letter: parsed.drive_letter, records, names, - trigram, - children: parsed.children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(parsed.children), + ext_index: Arc::new(ext_index), fold: parsed.fold, ext_names, source: IndexSource::MftFile(PathBuf::from(format!("{}:", parsed.drive_letter))), @@ -806,6 +806,9 @@ where // covers the future-format edge case where a new cache // version omits the section. frs_to_compact: parsed.frs_to_compact_loaded.unwrap_or_default(), + // Cache load is always delta-free — the on-disk format stores base only + // (compact before save), so a freshly loaded index has no overlay. + delta: None, }; // Phase 4 Commit D — v9+ caches embed the bloom + trie directly, diff --git a/crates/uffs-core/src/compact_cache/parked.rs b/crates/uffs-core/src/compact_cache/parked.rs index 7b45e4a29..6e3de4749 100644 --- a/crates/uffs-core/src/compact_cache/parked.rs +++ b/crates/uffs-core/src/compact_cache/parked.rs @@ -458,6 +458,7 @@ pub fn load_parked_body( #[cfg(test)] mod tests { + use alloc::sync::Arc; use std::path::PathBuf; use super::*; @@ -508,9 +509,9 @@ mod tests { letter: uffs_mft::platform::DriveLetter::C, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from(""), Box::from("toml")], source: IndexSource::MftFile(PathBuf::from("C:")), @@ -518,6 +519,7 @@ mod tests { bloom: None, path_trie: None, frs_to_compact: Vec::new(), + delta: None, }; index.bloom = Some(index.build_bloom()); index.path_trie = Some(index.build_path_trie()); diff --git a/crates/uffs-core/src/compact_cache/tests.rs b/crates/uffs-core/src/compact_cache/tests.rs index f755d1355..8c53b619d 100644 --- a/crates/uffs-core/src/compact_cache/tests.rs +++ b/crates/uffs-core/src/compact_cache/tests.rs @@ -73,9 +73,9 @@ fn make_test_index() -> DriveCompactIndex { letter: uffs_mft::platform::DriveLetter::T, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from("")], source: IndexSource::MftFile(PathBuf::from("T:")), @@ -83,6 +83,7 @@ fn make_test_index() -> DriveCompactIndex { bloom: None, path_trie: None, frs_to_compact, + delta: None, } } diff --git a/crates/uffs-core/src/compact_filters.rs b/crates/uffs-core/src/compact_filters.rs index 4f4df56c0..0bd8c0960 100644 --- a/crates/uffs-core/src/compact_filters.rs +++ b/crates/uffs-core/src/compact_filters.rs @@ -152,6 +152,7 @@ impl DriveCompactIndex { #[cfg(test)] mod tests { + use alloc::sync::Arc; use std::path::PathBuf; use super::*; @@ -203,9 +204,9 @@ mod tests { letter: uffs_mft::platform::DriveLetter::C, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from(""), Box::from("toml")], source: IndexSource::MftFile(PathBuf::from("C:")), @@ -213,6 +214,7 @@ mod tests { bloom: None, path_trie: None, frs_to_compact: Vec::new(), + delta: None, } } @@ -322,9 +324,9 @@ mod tests { letter: uffs_mft::platform::DriveLetter::X, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from("")], source: IndexSource::MftFile(PathBuf::from("X:")), @@ -332,6 +334,7 @@ mod tests { bloom: None, path_trie: None, frs_to_compact: Vec::new(), + delta: None, }; let bloom = drive.build_bloom(); diff --git a/crates/uffs-core/src/compact_loader.rs b/crates/uffs-core/src/compact_loader.rs index 8676a18c8..94aca73c4 100644 --- a/crates/uffs-core/src/compact_loader.rs +++ b/crates/uffs-core/src/compact_loader.rs @@ -11,10 +11,10 @@ use std::time::Instant; use uffs_mft::index::MftIndex; -use crate::compact::{ - ChildrenIndex, CompactRecord, DriveCompactIndex, INDEX_TTL_SECONDS, build_compact_index, -}; -use crate::trigram::TrigramIndex; +use crate::compact::{DriveCompactIndex, INDEX_TTL_SECONDS, build_compact_index}; + +mod apply; +mod rebuild; /// What produced a given `DriveCompactIndex`. #[derive(Clone)] @@ -454,210 +454,6 @@ pub fn load_mft_file( load_drive(&MftSource::File(mft_path.to_path_buf(), drive), no_cache) } -/// A USN-created file's identity, staged into the index's names blob + -/// extension table via a mutable `drive` borrow BEFORE any record borrow. -/// -/// All fields are `Copy`, so the caller can take a `&mut CompactRecord` -/// after this returns without a borrow conflict. -struct StagedCreate { - /// Byte offset of the staged name in `drive.names`. - name_offset: u32, - /// UTF-8 byte length of the staged name. - name_len: u16, - /// Cached first byte of the name (hot-path metafile gate). - name_first_byte: u8, - /// Interned extension id for the new name (`0` = no extension). - extension_id: u16, - /// Compact index of the parent directory (`u32::MAX` if unmapped). - parent_idx: u32, - /// Real size/timestamps/flags from a targeted MFT read, or all-zero when - /// the USN-only change carried no metadata (a later re-warm fills it). - /// Representation matches `CompactRecord`, so it copies straight in. - meta: uffs_mft::usn::RecordMeta, -} - -/// Append `change`'s filename to the names blob and intern its extension, -/// resolving the parent's compact index. Mutably borrows `drive`, so it -/// must run before any `&mut CompactRecord` borrow. -fn stage_create(drive: &mut DriveCompactIndex, change: &uffs_mft::usn::FileChange) -> StagedCreate { - let extension_id = drive.intern_extension(&change.filename); - let name_start = drive.names.len(); - drive - .names - .as_mut_vec() - .extend_from_slice(change.filename.as_bytes()); - let parent_frs_usize = uffs_mft::frs_to_usize(change.parent_frs.raw()); - let parent_idx = drive - .frs_to_compact - .get(parent_frs_usize) - .copied() - .unwrap_or(u32::MAX); - StagedCreate { - name_offset: uffs_mft::len_to_u32(name_start), - name_len: uffs_mft::len_to_u16(change.filename.len()), - name_first_byte: change.filename.as_bytes().first().copied().unwrap_or(0), - extension_id, - parent_idx, - meta: change.meta.unwrap_or_default(), - } -} - -/// Overwrite an existing compact slot with a reused/re-animated file's -/// identity. Per-file metrics come from the staged metadata — real values -/// when a targeted MFT read backfilled them, else zero (a later re-warm -/// fills them; the USN `FileChange` carries only name + parent). -const fn overwrite_slot(rec: &mut CompactRecord, staged: &StagedCreate) { - rec.name_offset = staged.name_offset; - rec.name_len = staged.name_len; - rec.name_first_byte = staged.name_first_byte; - rec.extension_id = staged.extension_id; - rec.parent_idx = staged.parent_idx; - rec.size = staged.meta.size; - rec.allocated = staged.meta.allocated; - rec.created = staged.meta.created; - rec.modified = staged.meta.modified; - rec.accessed = staged.meta.accessed; - rec.flags = staged.meta.flags; - // Tree metrics are recomputed post-loop (CSR rebuild + compute_path_ - // lengths); never carried by a USN change. - rec.treesize = 0; - rec.tree_allocated = 0; - rec.descendants = 0; - rec.path_len = 0; -} - -/// Apply a delete change: tombstone the slot (`name_len = 0`, parent -/// unmapped so the CSR rebuild drops it) and unmap its FRS so a later batch -/// can't re-animate the tombstone. -fn apply_delete( - drive: &mut DriveCompactIndex, - frs_usize: usize, - compact_idx: u32, - stats: &mut PatchStats, -) { - if compact_idx == u32::MAX { - stats.skipped += 1; - return; - } - if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { - rec.name_len = 0; - rec.parent_idx = u32::MAX; - if let Some(slot) = drive.frs_to_compact.get_mut(frs_usize) { - *slot = u32::MAX; - } - stats.deleted += 1; - } -} - -/// Apply a create change: overwrite the mapped slot when the MFT record -/// number was reused (tombstone OR stale live record), or append a fresh -/// record + register its FRS mapping when the slot is new. -fn apply_create( - drive: &mut DriveCompactIndex, - change: &uffs_mft::usn::FileChange, - frs_usize: usize, - compact_idx: u32, - stats: &mut PatchStats, -) { - if change.filename.is_empty() { - stats.skipped += 1; - return; - } - // Stage name + interned extension up front (mutable index borrow) so the - // per-record write can take a `&mut CompactRecord` without conflict. - let staged = stage_create(drive, change); - if compact_idx == u32::MAX { - // Brand-new record: append, then register the FRS mapping. NTFS - // reuses freed record numbers and a long-running daemon can outgrow - // the build-time table, so extend + sentinel-fill any gap. - let new_rec = CompactRecord { - size: staged.meta.size, - allocated: staged.meta.allocated, - treesize: 0, - tree_allocated: 0, - created: staged.meta.created, - modified: staged.meta.modified, - accessed: staged.meta.accessed, - name_offset: staged.name_offset, - flags: staged.meta.flags, - parent_idx: staged.parent_idx, - descendants: 0, - name_len: staged.name_len, - extension_id: staged.extension_id, - // path_len filled by `compute_path_lengths` post-loop. - path_len: 0, - name_first_byte: staged.name_first_byte, - _pad: [0; 1], - }; - let new_compact_idx = uffs_mft::len_to_u32(drive.records.len()); - drive.records.as_mut_vec().push(new_rec); - if frs_usize >= drive.frs_to_compact.len() { - drive - .frs_to_compact - .resize(frs_usize.saturating_add(1), u32::MAX); - } - if let Some(slot) = drive.frs_to_compact.get_mut(frs_usize) { - *slot = new_compact_idx; - } - stats.created += 1; - } else if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { - // The record number is already mapped. A `created` event means NTFS - // reused that slot for a NEW file — the old occupant (a tombstone, OR - // a stale live record whose delete was coalesced/missed) no longer - // exists. Overwrite it wholesale. Skipping a live slot here is what - // dropped FRS-reused recreates (the "delta.pdf vanished" report). - overwrite_slot(rec, &staged); - stats.created += 1; - } -} - -/// Apply a rename change: re-point the name, **re-intern the extension** (a -/// rename can change it: `foo.log` → `foo.pdf`), refresh the first-byte -/// cache, and update `parent_idx`. The FRS keeps its slot, so the mapping is -/// unchanged. -fn apply_rename( - drive: &mut DriveCompactIndex, - change: &uffs_mft::usn::FileChange, - compact_idx: u32, - stats: &mut PatchStats, -) { - if compact_idx == u32::MAX || change.filename.is_empty() { - stats.skipped += 1; - return; - } - let extension_id = drive.intern_extension(&change.filename); - let name_start = drive.names.len(); - drive - .names - .as_mut_vec() - .extend_from_slice(change.filename.as_bytes()); - let new_parent_frs = uffs_mft::frs_to_usize(change.parent_frs.raw()); - let new_parent_compact = drive - .frs_to_compact - .get(new_parent_frs) - .copied() - .unwrap_or(u32::MAX); - if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { - rec.name_offset = uffs_mft::len_to_u32(name_start); - rec.name_len = uffs_mft::len_to_u16(change.filename.len()); - rec.extension_id = extension_id; - rec.name_first_byte = change.filename.as_bytes().first().copied().unwrap_or(0); - rec.parent_idx = new_parent_compact; - // Apply backfilled size/timestamps/flags when a targeted MFT read - // attached them (corrects a record previously created USN-only with - // zeroed metrics); otherwise leave the existing values untouched. - if let Some(meta) = change.meta { - rec.size = meta.size; - rec.allocated = meta.allocated; - rec.created = meta.created; - rec.modified = meta.modified; - rec.accessed = meta.accessed; - rec.flags = meta.flags; - } - stats.renamed += 1; - } -} - /// Apply USN changes in-place to the compact index. /// /// Mutates records (`parent_idx`, names, flags) and the @@ -699,6 +495,19 @@ pub fn apply_usn_patch( ) -> PatchStats { let mut stats = PatchStats::default(); + // Phase 1: collect the records whose path_len must be refreshed so the + // post-loop rebuild can do an O(changed) path update instead of the + // O(total) BFS (incremental-index-maintenance §5.5). `path_changes` + // doubles as the Phase-2b trigram-ADD set (every created / renamed record + // re-adds its new name's trigrams to the delta). + let mut path_changes: Vec = Vec::new(); + // Phase 2b: records whose stale base trigram postings must be masked — + // deletes, renames, and FRS-reuse overwrites. + let mut tombstones: Vec = Vec::new(); + + // Wall-clock the whole apply (O(changed) mutation loop + the post-loop + // overlay/path refresh) for the DEBUG batch summary. + let t_apply = Instant::now(); for change in changes { // Typed `Frs` → raw `u64` lift at the frs_to_compact CSR lookup // boundary. The mapping table is `Vec` indexed by `usize`, @@ -729,51 +538,57 @@ pub fn apply_usn_patch( // The flags are mutually-exclusive net states (resolved in // `aggregate_changes`), so a simple priority dispatch is correct. if change.deleted { - apply_delete(drive, frs_usize, compact_idx, &mut stats); + apply::apply_delete(drive, frs_usize, compact_idx, &mut stats, &mut tombstones); } else if change.created { - apply_create(drive, change, frs_usize, compact_idx, &mut stats); + apply::apply_create( + drive, + change, + frs_usize, + compact_idx, + &mut stats, + &mut path_changes, + &mut tombstones, + ); } else if change.renamed { - apply_rename(drive, change, compact_idx, &mut stats); + apply::apply_rename( + drive, + change, + compact_idx, + &mut stats, + &mut path_changes, + &mut tombstones, + ); } else { stats.skipped += 1; } } - // Rebuild derived structures from updated records + names. - // Children CSR: ~100ms for 7M records. Trigram: ~500ms for 7M records. - // Both are necessary so newly created/renamed files appear in tree - // traversal AND trigram search. - drive.children = ChildrenIndex::build(&drive.records); - // Recompute path_len for all records (picks up creates + renames). - crate::compact::compute_path_lengths(&mut drive.records, &drive.names, drive.letter); - // Rebuild trigram index using CaseFold — no names_lower clone needed. - drive.trigram = TrigramIndex::build(&drive.records, &drive.names, drive.fold); - // Rebuild extension inverted index so --ext queries reflect USN changes. - drive.ext_index = crate::compact::ExtensionIndex::build(&drive.records); + // Overlay the batch onto the base ∪ delta indexes + refresh path lengths + // (incremental-index-maintenance); the occasional compaction folds the + // delta back into fresh bases. Extracted to `rebuild.rs`. + let compacted = rebuild::rebuild_derived(drive, &path_changes, &tombstones); if !changes.is_empty() { - log_batch_summary(drive, changes.len(), &stats); + rebuild::log_batch_summary( + drive, + changes.len(), + &stats, + compacted, + t_apply.elapsed().as_micros(), + ); } stats } -/// Emit the per-batch USN-apply summary (how the poll mutated the index) -/// at DEBUG. -fn log_batch_summary(drive: &DriveCompactIndex, changes: usize, stats: &PatchStats) { - tracing::debug!( - drive = %drive.letter, - changes, - created = stats.created, - deleted = stats.deleted, - renamed = stats.renamed, - skipped = stats.skipped, - records = drive.records.len(), - ext_index_entries = drive.ext_index.total_entries(), - "usn apply: batch applied" - ); -} - #[cfg(test)] #[path = "compact_loader_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "compact_loader_path_oracle_tests.rs"] +mod path_oracle_tests; + +#[cfg(test)] +#[path = "compact_loader_trigram_oracle_tests.rs"] +mod trigram_oracle_tests; diff --git a/crates/uffs-core/src/compact_loader/apply.rs b/crates/uffs-core/src/compact_loader/apply.rs new file mode 100644 index 000000000..ae4d2a567 --- /dev/null +++ b/crates/uffs-core/src/compact_loader/apply.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Per-change record mutation for [`super::apply_usn_patch`]: stage a created +//! file into the names blob + extension table, then apply create / delete / +//! rename to the compact records + `frs_to_compact` mapping, collecting the +//! path-length and trigram-delta change sets for the post-loop rebuild. + +use super::PatchStats; +use crate::compact::{CompactRecord, DriveCompactIndex}; + +/// A USN-created file's identity, staged into the index's names blob + +/// extension table via a mutable `drive` borrow BEFORE any record borrow. +/// +/// All fields are `Copy`, so the caller can take a `&mut CompactRecord` +/// after this returns without a borrow conflict. +struct StagedCreate { + /// Byte offset of the staged name in `drive.names`. + name_offset: u32, + /// UTF-8 byte length of the staged name. + name_len: u16, + /// Cached first byte of the name (hot-path metafile gate). + name_first_byte: u8, + /// Interned extension id for the new name (`0` = no extension). + extension_id: u16, + /// Compact index of the parent directory (`u32::MAX` if unmapped). + parent_idx: u32, + /// Real size/timestamps/flags from a targeted MFT read, or all-zero when + /// the USN-only change carried no metadata (a later re-warm fills it). + /// Representation matches `CompactRecord`, so it copies straight in. + meta: uffs_mft::usn::RecordMeta, +} + +/// Append `change`'s filename to the names blob and intern its extension, +/// resolving the parent's compact index. Mutably borrows `drive`, so it +/// must run before any `&mut CompactRecord` borrow. +fn stage_create(drive: &mut DriveCompactIndex, change: &uffs_mft::usn::FileChange) -> StagedCreate { + let extension_id = drive.intern_extension(&change.filename); + let name_start = drive.names.len(); + drive + .names + .as_mut_vec() + .extend_from_slice(change.filename.as_bytes()); + let parent_frs_usize = uffs_mft::frs_to_usize(change.parent_frs.raw()); + let parent_idx = drive + .frs_to_compact + .get(parent_frs_usize) + .copied() + .unwrap_or(u32::MAX); + StagedCreate { + name_offset: uffs_mft::len_to_u32(name_start), + name_len: uffs_mft::len_to_u16(change.filename.len()), + name_first_byte: change.filename.as_bytes().first().copied().unwrap_or(0), + extension_id, + parent_idx, + meta: change.meta.unwrap_or_default(), + } +} + +/// Overwrite an existing compact slot with a reused/re-animated file's +/// identity. Per-file metrics come from the staged metadata — real values +/// when a targeted MFT read backfilled them, else zero (a later re-warm +/// fills them; the USN `FileChange` carries only name + parent). +const fn overwrite_slot(rec: &mut CompactRecord, staged: &StagedCreate) { + rec.name_offset = staged.name_offset; + rec.name_len = staged.name_len; + rec.name_first_byte = staged.name_first_byte; + rec.extension_id = staged.extension_id; + rec.parent_idx = staged.parent_idx; + rec.size = staged.meta.size; + rec.allocated = staged.meta.allocated; + rec.created = staged.meta.created; + rec.modified = staged.meta.modified; + rec.accessed = staged.meta.accessed; + rec.flags = staged.meta.flags; + // Tree metrics are recomputed post-loop (CSR rebuild + compute_path_ + // lengths); never carried by a USN change. + rec.treesize = 0; + rec.tree_allocated = 0; + rec.descendants = 0; + rec.path_len = 0; +} + +/// Apply a delete change: tombstone the slot (`name_len = 0`, parent +/// unmapped so the CSR rebuild drops it) and unmap its FRS so a later batch +/// can't re-animate the tombstone. +pub(super) fn apply_delete( + drive: &mut DriveCompactIndex, + frs_usize: usize, + compact_idx: u32, + stats: &mut PatchStats, + tombstones: &mut Vec, +) { + if compact_idx == u32::MAX { + stats.skipped += 1; + return; + } + if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { + rec.name_len = 0; + rec.parent_idx = u32::MAX; + if let Some(slot) = drive.frs_to_compact.get_mut(frs_usize) { + *slot = u32::MAX; + } + // Phase 2b: mask the deleted record's stale base trigram postings. + tombstones.push(compact_idx); + stats.deleted += 1; + } +} + +/// Apply a create change: overwrite the mapped slot when the MFT record +/// number was reused (tombstone OR stale live record), or append a fresh +/// record + register its FRS mapping when the slot is new. +pub(super) fn apply_create( + drive: &mut DriveCompactIndex, + change: &uffs_mft::usn::FileChange, + frs_usize: usize, + compact_idx: u32, + stats: &mut PatchStats, + path_changes: &mut Vec, + tombstones: &mut Vec, +) { + if change.filename.is_empty() { + stats.skipped += 1; + return; + } + // Stage name + interned extension up front (mutable index borrow) so the + // per-record write can take a `&mut CompactRecord` without conflict. + let staged = stage_create(drive, change); + if compact_idx == u32::MAX { + // Brand-new record: append, then register the FRS mapping. NTFS + // reuses freed record numbers and a long-running daemon can outgrow + // the build-time table, so extend + sentinel-fill any gap. + let new_rec = CompactRecord { + size: staged.meta.size, + allocated: staged.meta.allocated, + treesize: 0, + tree_allocated: 0, + created: staged.meta.created, + modified: staged.meta.modified, + accessed: staged.meta.accessed, + name_offset: staged.name_offset, + flags: staged.meta.flags, + parent_idx: staged.parent_idx, + descendants: 0, + name_len: staged.name_len, + extension_id: staged.extension_id, + // path_len filled by `compute_path_lengths` post-loop. + path_len: 0, + name_first_byte: staged.name_first_byte, + _pad: [0; 1], + }; + let new_compact_idx = uffs_mft::len_to_u32(drive.records.len()); + drive.records.as_mut_vec().push(new_rec); + if frs_usize >= drive.frs_to_compact.len() { + drive + .frs_to_compact + .resize(frs_usize.saturating_add(1), u32::MAX); + } + if let Some(slot) = drive.frs_to_compact.get_mut(frs_usize) { + *slot = new_compact_idx; + } + // A new record has no descendants yet → O(1) path refresh, no subtree. + path_changes.push(crate::compact::PathChange { + idx: new_compact_idx, + subtree: false, + }); + stats.created += 1; + } else if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { + // The record number is already mapped. A `created` event means NTFS + // reused that slot for a NEW file — the old occupant (a tombstone, OR + // a stale live record whose delete was coalesced/missed) no longer + // exists. Overwrite it wholesale. Skipping a live slot here is what + // dropped FRS-reused recreates (the "delta.pdf vanished" report). + overwrite_slot(rec, &staged); + // FRS-reuse overwrite: treat as a fresh record (its old subtree, if + // any, was deleted/remapped and is handled by its own changes). + path_changes.push(crate::compact::PathChange { + idx: compact_idx, + subtree: false, + }); + // Phase 2b: the reused slot's old occupant's base postings are stale — + // mask them; the new name is re-added via `path_changes`. + tombstones.push(compact_idx); + stats.created += 1; + } +} + +/// Apply a rename change: re-point the name, **re-intern the extension** (a +/// rename can change it: `foo.log` → `foo.pdf`), refresh the first-byte +/// cache, and update `parent_idx`. The FRS keeps its slot, so the mapping is +/// unchanged. +pub(super) fn apply_rename( + drive: &mut DriveCompactIndex, + change: &uffs_mft::usn::FileChange, + compact_idx: u32, + stats: &mut PatchStats, + path_changes: &mut Vec, + tombstones: &mut Vec, +) { + if compact_idx == u32::MAX || change.filename.is_empty() { + stats.skipped += 1; + return; + } + let extension_id = drive.intern_extension(&change.filename); + let name_start = drive.names.len(); + drive + .names + .as_mut_vec() + .extend_from_slice(change.filename.as_bytes()); + let new_parent_frs = uffs_mft::frs_to_usize(change.parent_frs.raw()); + let new_parent_compact = drive + .frs_to_compact + .get(new_parent_frs) + .copied() + .unwrap_or(u32::MAX); + if let Some(rec) = drive.records.as_mut_slice().get_mut(compact_idx as usize) { + rec.name_offset = uffs_mft::len_to_u32(name_start); + rec.name_len = uffs_mft::len_to_u16(change.filename.len()); + rec.extension_id = extension_id; + rec.name_first_byte = change.filename.as_bytes().first().copied().unwrap_or(0); + rec.parent_idx = new_parent_compact; + // Apply backfilled size/timestamps/flags when a targeted MFT read + // attached them (corrects a record previously created USN-only with + // zeroed metrics); otherwise leave the existing values untouched. + if let Some(meta) = change.meta { + rec.size = meta.size; + rec.allocated = meta.allocated; + rec.created = meta.created; + rec.modified = meta.modified; + rec.accessed = meta.accessed; + rec.flags = meta.flags; + } + // A directory rename shifts every descendant's path by a constant Δ; + // a file rename only refreshes this record. + path_changes.push(crate::compact::PathChange { + idx: compact_idx, + subtree: rec.is_directory(), + }); + // Phase 2b: mask the old-name base postings; the new name is re-added + // via `path_changes`. The trigram_search tombstone logic keeps the + // record visible under its new name and gone from its old one. + tombstones.push(compact_idx); + stats.renamed += 1; + } +} diff --git a/crates/uffs-core/src/compact_loader/rebuild.rs b/crates/uffs-core/src/compact_loader/rebuild.rs new file mode 100644 index 000000000..4207a7e53 --- /dev/null +++ b/crates/uffs-core/src/compact_loader/rebuild.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Post-apply derived-index maintenance for [`super::apply_usn_patch`]. +//! +//! After the per-change loop mutates the record columns + `frs_to_compact`, +//! this overlays the batch onto the base ∪ delta indexes +//! (incremental-index-maintenance: trigram + extension + children) and +//! refreshes the touched records' `path_len`, so newly created / renamed / +//! deleted files appear in tree traversal AND trigram / `--ext` search — all in +//! O(changed), with an occasional O(total) compaction folding the delta back +//! into fresh bases. Extracted from `compact_loader.rs` to keep that file under +//! the workspace 800-LOC policy and to house this post-loop concern as one +//! unit. + +use super::PatchStats; +use crate::compact::{DriveCompactIndex, PathChange, update_path_lengths_incremental}; + +/// Above this many touched records, the per-change incremental path update +/// loses to a single O(total) BFS (each create/rename re-walks parents), so we +/// fall back to the full [`crate::compact::compute_path_lengths`]. Sized well +/// above a normal USN poll batch; the 50k disk-save threshold is the practical +/// ceiling on a single apply anyway. +const FULL_PATH_RECOMPUTE_THRESHOLD: usize = 50_000; + +/// Overlay the batch onto the base ∪ delta indexes and refresh the touched +/// records' `path_len`. Returns `true` if the delta crossed the compaction +/// threshold and the bases were refolded this call. +/// +/// The order matters: the trigram / extension / children overlay +/// ([`DriveCompactIndex::apply_index_delta`]) runs FIRST so the +/// directory-rename subtree walk in the path refresh below sees the batch's new +/// children (creates / moves into a renamed directory). +pub(super) fn rebuild_derived( + drive: &mut DriveCompactIndex, + path_changes: &[PathChange], + tombstones: &[u32], +) -> bool { + let compacted = drive.apply_index_delta(path_changes, tombstones); + + // Refresh path_len only for the records this batch touched (O(changed)). An + // empty change set (e.g. a delete-only batch) is a no-op over an empty + // slice; the full O(total) BFS is reserved for the cold-load builder, with + // an apply-time fallback only for a pathologically huge batch where the + // per-record re-walk loses to one BFS. + if path_changes.len() > FULL_PATH_RECOMPUTE_THRESHOLD { + crate::compact::compute_path_lengths(&mut drive.records, &drive.names, drive.letter); + } else { + update_path_lengths_incremental( + drive.records.as_mut_slice(), + &drive.names, + drive.letter, + &drive.children, + drive.delta.as_ref(), + path_changes, + ); + } + + compacted +} + +/// Emit the per-batch USN-apply summary (how the poll mutated the index, the +/// wall-clock cost, and whether it triggered a delta compaction) at DEBUG. +pub(super) fn log_batch_summary( + drive: &DriveCompactIndex, + changes: usize, + stats: &PatchStats, + compacted: bool, + apply_us: u128, +) { + tracing::debug!( + drive = %drive.letter, + changes, + created = stats.created, + deleted = stats.deleted, + renamed = stats.renamed, + skipped = stats.skipped, + records = drive.records.len(), + ext_index_entries = drive.ext_index.total_entries(), + compacted, + apply_us, + "usn apply: batch applied" + ); +} diff --git a/crates/uffs-core/src/compact_loader_path_oracle_tests.rs b/crates/uffs-core/src/compact_loader_path_oracle_tests.rs new file mode 100644 index 000000000..54babcabe --- /dev/null +++ b/crates/uffs-core/src/compact_loader_path_oracle_tests.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Phase-1 path-length oracle for [`super::apply_usn_patch`] +//! (incremental-index-maintenance §7). +//! +//! The per-change incremental `path_len` update done inside `apply_usn_patch` +//! must be **byte-identical** to a full `compute_path_lengths` rebuild — the +//! correctness gate the design requires ("base+delta must be byte-identical to +//! a full rebuild"). The hardest case is a **directory rename**, whose length +//! delta must propagate to every descendant's `path_len` via the children CSR. +//! +//! Kept in a dedicated sibling submodule so neither `compact_loader.rs` nor +//! `compact_loader_tests.rs` crosses the workspace 800-LOC policy ceiling. + +use alloc::sync::Arc; +use std::path::PathBuf; + +use uffs_mft::usn::FileChange; +use uffs_text::case_fold::CaseFold; + +use super::{IndexSource, apply_usn_patch}; +use crate::compact::{ + ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex, compute_path_lengths, +}; +use crate::compact_storage::ColumnStorage; +use crate::trigram::TrigramIndex; + +/// Nested fixture: top dir "C" (frs 5) → dir "sub" (frs 6) → "deep.txt" +/// (frs 7). Names: C[0..1] sub[1..4] deep.txt[4..12]. `path_len`s are +/// initialised via the cold-load BFS so the apply path takes over from a +/// correct baseline, exactly as it does after a real cold load. +fn build_nested_fixture() -> DriveCompactIndex { + let names = b"Csubdeep.txt".to_vec(); + let records = vec![ + CompactRecord { + name_offset: 0, + flags: 0x10, + parent_idx: u32::MAX, + name_len: 1, + name_first_byte: b'C', + ..CompactRecord::default() + }, + CompactRecord { + name_offset: 1, + flags: 0x10, // directory — its rename must shift the subtree + parent_idx: 0, + name_len: 3, + name_first_byte: b's', + ..CompactRecord::default() + }, + CompactRecord { + name_offset: 4, + parent_idx: 1, + name_len: 8, + name_first_byte: b'd', + ..CompactRecord::default() + }, + ]; + let fold = CaseFold::default_table(); + let frs_to_compact: Vec = (0_usize..20) + .map(|frs| match frs { + 5 => 0_u32, + 6 => 1, + 7 => 2, + _ => u32::MAX, + }) + .collect(); + let mut drive = DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records.clone()), + names: ColumnStorage::from_vec(names.clone()), + trigram: Arc::new(TrigramIndex::build(&records, &names, fold)), + children: Arc::new(ChildrenIndex::build(&records)), + ext_index: Arc::new(ExtensionIndex::build(&records)), + fold, + ext_names: vec![Box::from("")], + source: IndexSource::MftFile(PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact, + delta: None, + }; + // Cold-load init of path_lens (the full BFS the apply path replaces). + compute_path_lengths(drive.records.as_mut_slice(), &drive.names, drive.letter); + drive +} + +/// Assert the live (incremental) `path_len`s on `drive` equal a from-scratch +/// `compute_path_lengths` BFS over the same (now-mutated) records. +/// +/// Only **live** records are compared: a tombstoned record +/// (`name_len == 0 && parent_idx == u32::MAX`, set by `apply_delete`) never +/// surfaces in search or path resolution, so its `path_len` is meaningless — +/// the incremental path leaves it stale while a full BFS recomputes it as a +/// root. That divergence is correct, so it is excluded. +fn assert_path_len_matches_full_rebuild(drive: &mut DriveCompactIndex) { + let is_live = |rec: &CompactRecord| !(rec.name_len == 0 && rec.parent_idx == u32::MAX); + let incremental: Vec<(usize, u16)> = drive + .records + .iter() + .enumerate() + .filter(|(_, rec)| is_live(rec)) + .map(|(idx, rec)| (idx, rec.path_len)) + .collect(); + compute_path_lengths(drive.records.as_mut_slice(), &drive.names, drive.letter); + let full_rebuild: Vec<(usize, u16)> = drive + .records + .iter() + .enumerate() + .filter(|(_, rec)| is_live(rec)) + .map(|(idx, rec)| (idx, rec.path_len)) + .collect(); + assert_eq!( + incremental, full_rebuild, + "incremental path_len must equal the full rebuild for live records; \ + incremental={incremental:?} full={full_rebuild:?}", + ); +} + +#[test] +fn incremental_path_len_matches_full_rebuild_oracle() { + let mut drive = build_nested_fixture(); + + // Apply a batch that exercises every path op: directory rename (subtree Δ), + // a fresh create, and a file rename. + apply_usn_patch(&mut drive, &[ + FileChange { + frs: 6_u64.into(), + parent_frs: 5_u64.into(), + filename: "subdirectory".to_owned(), // longer → Δ > 0 + renamed: true, + ..FileChange::default() + }, + FileChange { + frs: 8_u64.into(), + parent_frs: 5_u64.into(), + filename: "new.bin".to_owned(), + created: true, + ..FileChange::default() + }, + FileChange { + frs: 7_u64.into(), + parent_frs: 6_u64.into(), + filename: "deep-renamed.txt".to_owned(), + renamed: true, + ..FileChange::default() + }, + ]); + + // `apply_usn_patch` used the INCREMENTAL path update (batch < threshold). + assert_path_len_matches_full_rebuild(&mut drive); +} + +/// Regression guard for the delete-only batch: a delete pushes **no** +/// `PathChange` (it tombstones its record and shifts no surviving record's +/// `path_len`), so the apply's `path_changes` slice is empty. The path update +/// must then be a *no-op* — NOT a fall-back to the full O(total) BFS, which on +/// a live 3.9 M-record drive was a 0.5 s per-apply regression. Surviving +/// records' `path_len`s must still equal a full rebuild afterwards. +#[test] +fn delete_only_batch_leaves_path_lengths_correct_without_full_recompute() { + let mut drive = build_nested_fixture(); + + // Delete the leaf "deep.txt" (frs 7). No create / rename → empty + // path_changes → must take the no-op incremental branch. + apply_usn_patch(&mut drive, &[FileChange { + frs: 7_u64.into(), + parent_frs: 6_u64.into(), + deleted: true, + ..FileChange::default() + }]); + + // "C" and "sub" are untouched survivors; their path_len must match a full + // rebuild over the post-delete record set. + assert_path_len_matches_full_rebuild(&mut drive); +} + +/// Phase-4b ordering guard: a directory rename (subtree Δ) **and** a child +/// created inside that directory in the **same batch**. The subtree walk reads +/// the base ∪ delta children, and the delta is populated *before* the path +/// walk, so the same-batch create must be found and shifted (or get the right +/// `path_len` directly) regardless of intra-batch order. The created child's +/// `path_len` must equal a full rebuild — which it only can if the walk sees +/// the delta-added child. +#[test] +fn dir_rename_with_same_batch_child_create_matches_full_rebuild() { + let mut drive = build_nested_fixture(); + + apply_usn_patch(&mut drive, &[ + // Rename dir "sub" (frs 6) → "subdirectory" (longer → Δ > 0). + FileChange { + frs: 6_u64.into(), + parent_frs: 5_u64.into(), + filename: "subdirectory".to_owned(), + renamed: true, + ..FileChange::default() + }, + // Create "inside.txt" (frs 8) as a child of that same dir (frs 6). + FileChange { + frs: 8_u64.into(), + parent_frs: 6_u64.into(), + filename: "inside.txt".to_owned(), + created: true, + ..FileChange::default() + }, + ]); + + // Every live record (incl. the same-batch create under the renamed dir) + // must have the byte-identical path_len of a from-scratch BFS. + assert_path_len_matches_full_rebuild(&mut drive); +} diff --git a/crates/uffs-core/src/compact_loader_tests.rs b/crates/uffs-core/src/compact_loader_tests.rs index 9b86f4d5e..b1fe472b7 100644 --- a/crates/uffs-core/src/compact_loader_tests.rs +++ b/crates/uffs-core/src/compact_loader_tests.rs @@ -16,6 +16,7 @@ //! Extracted into a sibling submodule so `compact_loader.rs` stays //! well below the file-size policy ceiling. +use alloc::sync::Arc; use std::path::PathBuf; use uffs_mft::usn::FileChange; @@ -102,9 +103,9 @@ fn make_synthetic_drive() -> DriveCompactIndex { letter: uffs_mft::platform::DriveLetter::T, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from("")], source: IndexSource::MftFile(PathBuf::from("T:")), @@ -112,6 +113,7 @@ fn make_synthetic_drive() -> DriveCompactIndex { bloom: None, path_trie: None, frs_to_compact, + delta: None, } } @@ -267,8 +269,8 @@ fn apply_usn_patch_rename_reinterns_extension() { "first-byte cache must reflect the renamed name" ); assert!( - drive.ext_index.get(pdf_id).contains(&2), - "ExtensionIndex.get(pdf) must include the renamed record" + drive.records_with_ext(pdf_id).contains(&2), + "records_with_ext(pdf) must include the renamed record" ); } @@ -310,7 +312,7 @@ fn apply_usn_patch_create_replaces_live_reused_slot() { let pdf_id = *pdf_ids.first().expect("'pdf' interned"); assert_eq!(record.extension_id, pdf_id, "reused slot tagged 'pdf'"); assert!( - drive.ext_index.get(pdf_id).contains(&2), + drive.records_with_ext(pdf_id).contains(&2), "ExtensionIndex.get(pdf) must include the reused record" ); } @@ -481,12 +483,12 @@ fn apply_usn_patch_created_record_is_findable_by_extension() { "created record must be tagged with the resolved 'pdf' id" ); - // 3. The rebuilt inverted index returns the new record for that id — this is - // exactly what `--ext pdf` walks. - let matches = drive.ext_index.get(pdf_id); + // 3. records_with_ext (base ∪ delta overlay) returns the new record for that id + // — exactly what `--ext pdf` walks. + let matches = drive.records_with_ext(pdf_id); assert!( matches.contains(&u32::try_from(new_idx).expect("idx fits u32")), - "ExtensionIndex.get(pdf) must include the USN-created record" + "records_with_ext(pdf) must include the USN-created record" ); } @@ -557,7 +559,7 @@ fn apply_usn_patch_rebuilds_children_csr_excluding_deletes() { // Pre-state sanity: root (compact_idx 0) starts with three // children — compact_idx 1 ("foo.txt"), 2 ("bar.rs"), 3 ("baz.md"). - let initial_root_children: Vec = drive.children.get(0).to_vec(); + let initial_root_children: Vec = drive.children_of(0).into_owned(); assert_eq!( initial_root_children.len(), 3, @@ -572,7 +574,7 @@ fn apply_usn_patch_rebuilds_children_csr_excluding_deletes() { apply_usn_patch(&mut drive, &changes); - let post_root_children: Vec = drive.children.get(0).to_vec(); + let post_root_children: Vec = drive.children_of(0).into_owned(); assert!( !post_root_children.contains(&1), "deleted compact_idx 1 must not appear in root's children CSR after rebuild" diff --git a/crates/uffs-core/src/compact_loader_trigram_oracle_tests.rs b/crates/uffs-core/src/compact_loader_trigram_oracle_tests.rs new file mode 100644 index 000000000..1d12cda62 --- /dev/null +++ b/crates/uffs-core/src/compact_loader_trigram_oracle_tests.rs @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Phase-2b end-to-end oracle for the trigram base+delta overlay +//! (incremental-index-maintenance §4 Phase 2 / §7). +//! +//! Drives a **real** [`super::apply_usn_patch`] batch (create + rename + +//! delete) so the delta is populated exactly as the live USN path does, then +//! asserts that `trigram_search` through the base ∪ delta overlay returns +//! **identical** candidates to a fully **compacted** index (delta folded into a +//! fresh base). That equivalence is the Phase-2b correctness contract: "base + +//! delta must be byte-identical to a full rebuild" — for search results, across +//! every op. + +use alloc::sync::Arc; +use std::path::PathBuf; + +use uffs_mft::usn::FileChange; +use uffs_text::case_fold::CaseFold; + +use super::{IndexSource, apply_usn_patch}; +use crate::compact::{ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex}; +use crate::compact_storage::ColumnStorage; +use crate::trigram::TrigramIndex; + +/// Push one record (root or file) and register its FRS→compact mapping. +fn push_record( + names: &mut Vec, + records: &mut Vec, + frs_to_compact: &mut Vec, + name: &str, + frs: usize, + parent: u32, + dir: bool, +) { + let idx = u32::try_from(records.len()).expect("fixture fits u32"); + let offset = u32::try_from(names.len()).expect("fixture names fit u32"); + names.extend_from_slice(name.as_bytes()); + records.push(CompactRecord { + name_offset: offset, + flags: if dir { 0x10 } else { 0 }, + parent_idx: parent, + name_len: u16::try_from(name.len()).expect("fixture name fits u16"), + name_first_byte: name.as_bytes().first().copied().unwrap_or(0), + ..CompactRecord::default() + }); + if frs >= frs_to_compact.len() { + frs_to_compact.resize(frs + 1, u32::MAX); + } + if let Some(slot) = frs_to_compact.get_mut(frs) { + *slot = idx; + } +} + +/// Root "C" (frs 5) + four files; FRS mapping populated so `apply_usn_patch` +/// can resolve every change. +fn build_drive() -> DriveCompactIndex { + let mut names = Vec::new(); + let mut records = Vec::new(); + let mut frs_to_compact = Vec::new(); + push_record( + &mut names, + &mut records, + &mut frs_to_compact, + "C", + 5, + u32::MAX, + true, + ); + push_record( + &mut names, + &mut records, + &mut frs_to_compact, + "report.txt", + 10, + 0, + false, + ); + push_record( + &mut names, + &mut records, + &mut frs_to_compact, + "alpha.txt", + 11, + 0, + false, + ); + push_record( + &mut names, + &mut records, + &mut frs_to_compact, + "config.json", + 12, + 0, + false, + ); + push_record( + &mut names, + &mut records, + &mut frs_to_compact, + "datafile.bin", + 13, + 0, + false, + ); + + let fold = CaseFold::default_table(); + let trigram = TrigramIndex::build(&records, &names, fold); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), + fold, + ext_names: vec![Box::from("")], + source: IndexSource::MftFile(PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact, + delta: None, + } +} + +fn sorted_candidates(drive: &DriveCompactIndex, needle: &str) -> Vec { + let mut got = drive.trigram_search(needle).unwrap_or_default(); + got.sort_unstable(); + got +} + +#[test] +fn apply_batch_delta_search_equals_compacted_rebuild_oracle() { + let mut drive = build_drive(); + + // A batch hitting every op: create a file, rename one, delete one. + apply_usn_patch(&mut drive, &[ + FileChange { + frs: 20_u64.into(), + parent_frs: 5_u64.into(), + filename: "newfile.log".to_owned(), + created: true, + ..FileChange::default() + }, + FileChange { + frs: 10_u64.into(), + parent_frs: 5_u64.into(), + filename: "summary.txt".to_owned(), // report.txt -> summary.txt + renamed: true, + ..FileChange::default() + }, + FileChange { + frs: 11_u64.into(), + parent_frs: 5_u64.into(), + deleted: true, // alpha.txt deleted + ..FileChange::default() + }, + ]); + + // The live drive now serves search through the base ∪ delta overlay. + assert!( + drive.delta.is_some(), + "apply must have populated the trigram delta" + ); + + // Oracle reference: the same drive with the delta folded into a fresh base. + let mut compacted = drive.clone(); + compacted.compact_base(); + assert!(compacted.delta.is_none(), "compaction must clear the delta"); + + // Every needle must yield identical candidates from the overlay and the + // compacted rebuild — covering created, renamed (new + old name), deleted, + // and untouched files. + for needle in [ + "summ", "summary", // renamed-in (new name) + "report", "repo", // renamed-away (old name) — gone from both + "newfile", "newf", // created + "alpha", "lpha", // deleted — gone from both + "config", "datafile", "bin", "txt", "log", // untouched / extensions + ] { + let overlay = sorted_candidates(&drive, needle); + let rebuilt = sorted_candidates(&compacted, needle); + assert_eq!( + overlay, rebuilt, + "needle {needle:?}: overlay {overlay:?} != compacted rebuild {rebuilt:?}", + ); + } + + // Spot-check the semantics concretely (compact_idx: report/summary=1, + // config=3, datafile=4, newfile appended at 5). + assert_eq!( + sorted_candidates(&drive, "summary"), + vec![1], + "renamed visible as summary" + ); + assert!( + sorted_candidates(&drive, "report").is_empty(), + "old name gone" + ); + assert_eq!( + sorted_candidates(&drive, "newfile"), + vec![5], + "created visible" + ); + assert!( + sorted_candidates(&drive, "alpha").is_empty(), + "deleted gone" + ); + + // Phase 4a ext oracle: records_with_ext through the overlay must equal the + // compacted rebuild for every extension id (the create interns ".log", the + // rename keeps ".txt", the delete drops ".txt"). Covers id 0 (no extension) + // up past the highest interned id. + let max_ext = drive + .records + .iter() + .map(|rec| rec.extension_id) + .max() + .unwrap_or(0); + for ext_id in 0..=max_ext { + let mut overlay = drive.records_with_ext(ext_id).into_owned(); + overlay.sort_unstable(); + let mut rebuilt = compacted.records_with_ext(ext_id).into_owned(); + rebuilt.sort_unstable(); + assert_eq!( + overlay, rebuilt, + "ext_id {ext_id}: overlay {overlay:?} != compacted rebuild {rebuilt:?}", + ); + } +} + +/// Phase-4b children oracle, exercising the hardest case — a file **moving +/// between directories** (a rename that changes `parent_idx`). The base +/// children CSR is frozen (no per-apply rebuild), so `children_of` must merge +/// base ∪ delta and validate each candidate against the live records: the +/// moved file must leave its old parent's child list and join the new one. +/// `children_of` through the overlay must equal the compacted rebuild for every +/// parent, across move + create + delete. +/// Nested fixture for the children oracle: root C(frs5,idx0) → dirs +/// alpha(6,1) beta(7,2); moved.txt(8,3) under alpha, stay.txt(9,4) under beta. +fn build_nested_dir_fixture() -> DriveCompactIndex { + let mut names = Vec::new(); + let mut records = Vec::new(); + let mut frs = Vec::new(); + push_record(&mut names, &mut records, &mut frs, "C", 5, u32::MAX, true); + push_record(&mut names, &mut records, &mut frs, "alpha", 6, 0, true); + push_record(&mut names, &mut records, &mut frs, "beta", 7, 0, true); + push_record(&mut names, &mut records, &mut frs, "moved.txt", 8, 1, false); + push_record(&mut names, &mut records, &mut frs, "stay.txt", 9, 2, false); + + let fold = CaseFold::default_table(); + let trigram = TrigramIndex::build(&records, &names, fold); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), + fold, + ext_names: vec![Box::from("")], + source: IndexSource::MftFile(PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact: frs, + delta: None, + } +} + +#[test] +fn children_overlay_equals_compacted_rebuild_oracle() { + let mut drive = build_nested_dir_fixture(); + + apply_usn_patch(&mut drive, &[ + // Move moved.txt (frs8) from alpha(6) to beta(7) — same name, new parent. + FileChange { + frs: 8_u64.into(), + parent_frs: 7_u64.into(), + filename: "moved.txt".to_owned(), + renamed: true, + ..FileChange::default() + }, + // Create fresh.txt (frs10) under alpha(6). + FileChange { + frs: 10_u64.into(), + parent_frs: 6_u64.into(), + filename: "fresh.txt".to_owned(), + created: true, + ..FileChange::default() + }, + // Delete stay.txt (frs9). + FileChange { + frs: 9_u64.into(), + parent_frs: 7_u64.into(), + deleted: true, + ..FileChange::default() + }, + ]); + + assert!( + drive.delta.is_some(), + "apply must have populated the children delta" + ); + let mut compacted = drive.clone(); + compacted.compact_base(); + + // children_of must match the compacted rebuild for every record index. + let record_count = u32::try_from(drive.records.len()).expect("fits u32"); + for parent in 0..record_count { + let overlay = drive.children_of(parent).into_owned(); + let rebuilt = compacted.children_of(parent).into_owned(); + assert_eq!( + overlay, rebuilt, + "children_of({parent}): overlay {overlay:?} != compacted {rebuilt:?}", + ); + } + + // Concrete semantics: moved.txt(3) left alpha(1) for beta(2); fresh.txt(5) + // joined alpha; stay.txt(4) deleted from beta. + assert_eq!( + drive.children_of(1).into_owned(), + vec![5], + "alpha = {{fresh.txt}}" + ); + assert_eq!( + drive.children_of(2).into_owned(), + vec![3], + "beta = {{moved.txt}}" + ); +} diff --git a/crates/uffs-core/src/compact_trigram_delta_tests.rs b/crates/uffs-core/src/compact_trigram_delta_tests.rs new file mode 100644 index 000000000..a094f4118 --- /dev/null +++ b/crates/uffs-core/src/compact_trigram_delta_tests.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Phase-2a correctness tests for [`DriveCompactIndex::trigram_search`] — the +//! base ∪ delta overlay choke point (incremental-index-maintenance §5.2). +//! +//! These pin the *semantics* of the overlay by populating an [`IndexDelta`] +//! **manually** (the apply path that fills it for real lands in Phase 2b), so +//! the merge + tombstone resolution is locked down independently of the USN +//! plumbing. The hard case is a rename: the record must become visible under +//! its new name yet vanish from its old one — which is exactly why tombstone +//! filtering is applied to the final candidate set, never per posting list. + +use alloc::sync::Arc; +use std::path::PathBuf; + +use uffs_text::case_fold::CaseFold; + +use crate::compact::{ + ChildrenIndex, CompactRecord, DriveCompactIndex, ExtensionIndex, IndexDelta, IndexSource, +}; +use crate::compact_storage::ColumnStorage; +use crate::trigram::{TrigramIndex, needle_trigrams}; + +/// Append a record (and its name bytes) to the fixture columns. +fn push_record( + names: &mut Vec, + records: &mut Vec, + name: &str, + parent: u32, + dir: bool, +) { + let offset = u32::try_from(names.len()).expect("fixture names blob fits u32"); + names.extend_from_slice(name.as_bytes()); + records.push(CompactRecord { + name_offset: offset, + flags: if dir { 0x10 } else { 0 }, + parent_idx: parent, + name_len: u16::try_from(name.len()).expect("fixture name fits u16"), + name_first_byte: name.as_bytes().first().copied().unwrap_or(0), + ..CompactRecord::default() + }); +} + +/// Build a flat fixture: a root "C" (idx 0) plus one file per name, each a +/// child of the root. Returns the index with `delta = None` (pure base). +fn build_drive(file_names: &[&str]) -> DriveCompactIndex { + let mut names: Vec = Vec::new(); + let mut records: Vec = Vec::new(); + + push_record(&mut names, &mut records, "C", u32::MAX, true); + for name in file_names { + push_record(&mut names, &mut records, name, 0, false); + } + + let fold = CaseFold::default_table(); + // Build the base CSR indexes before moving the columns into storage. + let trigram = TrigramIndex::build(&records, &names, fold); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + let count = u32::try_from(records.len()).expect("fixture record count fits u32"); + let frs_to_compact: Vec = (0..count).collect(); + // Fields in struct-definition order (clippy::inconsistent_struct_constructor). + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::T, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), + fold, + ext_names: vec![Box::from("")], + source: IndexSource::MftFile(PathBuf::from("T:")), + source_epoch: 1, + bloom: None, + path_trie: None, + frs_to_compact, + delta: None, + } +} + +/// Trigram candidates as a sorted Vec for stable assertions. +fn candidates(drive: &DriveCompactIndex, needle: &str) -> Vec { + let mut got = drive.trigram_search(needle).unwrap_or_default(); + got.sort_unstable(); + got +} + +#[test] +fn delta_none_delegates_to_base_search() { + let drive = build_drive(&["report.txt", "alpha.txt"]); + // "repo" matches report.txt (idx 1) only; pure-base fast path. + assert_eq!(candidates(&drive, "repo"), vec![1]); + assert_eq!(candidates(&drive, "alpha"), vec![2]); +} + +#[test] +fn create_via_delta_becomes_searchable() { + let mut drive = build_drive(&["report.txt"]); + let fold = drive.fold; + // Simulate a create of "summary.log" at idx 2 (record need not exist for the + // candidate set; trigram_search is a pre-filter over postings). + let delta = drive.delta.get_or_insert_with(Default::default); + let tris = needle_trigrams("summary.log", fold).unwrap(); + delta.add_record(2, &tris, 0, 0); + + assert_eq!( + candidates(&drive, "summ"), + vec![2], + "new file visible via delta" + ); + assert_eq!( + candidates(&drive, "repo"), + vec![1], + "base file still visible" + ); +} + +#[test] +fn rename_visible_under_new_name_and_gone_from_old() { + let mut drive = build_drive(&["report.txt", "alpha.txt"]); + let fold = drive.fold; + // Rename idx 1 "report.txt" -> "summary.txt": tombstone its stale base + // postings, then re-add under the new name's trigrams. + let delta = drive.delta.get_or_insert_with(Default::default); + delta.tombstone(1); + let tris = needle_trigrams("summary.txt", fold).unwrap(); + delta.add_record(1, &tris, 0, 0); + + assert_eq!( + candidates(&drive, "summ"), + vec![1], + "visible under NEW name" + ); + assert_eq!( + candidates(&drive, "repo"), + Vec::::new(), + "INVISIBLE under OLD name despite stale base postings" + ); + assert_eq!( + candidates(&drive, "alpha"), + vec![2], + "unrelated file untouched" + ); +} + +#[test] +fn delete_via_tombstone_disappears() { + let mut drive = build_drive(&["report.txt", "alpha.txt"]); + // Delete idx 2 "alpha.txt": tombstone, no re-add. + let delta = drive.delta.get_or_insert_with(Default::default); + delta.tombstone(2); + + assert_eq!( + candidates(&drive, "alpha"), + Vec::::new(), + "deleted file no longer a candidate" + ); + assert_eq!(candidates(&drive, "repo"), vec![1], "sibling unaffected"); +} + +#[test] +fn short_needle_returns_none_like_base() { + let mut drive = build_drive(&["report.txt"]); + drive.delta = Some(IndexDelta::default()); + // < 3 codepoints -> None (caller falls back to linear scan), even with a + // delta present. + assert!(drive.trigram_search("re").is_none()); +} diff --git a/crates/uffs-core/src/path_resolver/fast.rs b/crates/uffs-core/src/path_resolver/fast.rs index f145ed9f6..77ff2216d 100644 --- a/crates/uffs-core/src/path_resolver/fast.rs +++ b/crates/uffs-core/src/path_resolver/fast.rs @@ -13,7 +13,7 @@ //! value in the workspace ultimately demotes to a raw `u64` at the //! polars / CSV / JSON edge. //! -//! As a result the internal [`FastEntry.parent_frs`], the +//! As a result the internal [`FastEntry::parent_frs`], the //! [`FastPathResolver::path_cache`] key, and the //! [`FastPathResolver::max_frs`] field stay raw `u64`: they live one //! `for row_idx in 0..df.height()` loop downstream of the polars diff --git a/crates/uffs-core/src/search/filters/ext_match.rs b/crates/uffs-core/src/search/filters/ext_match.rs index 958f051c2..ff040d846 100644 --- a/crates/uffs-core/src/search/filters/ext_match.rs +++ b/crates/uffs-core/src/search/filters/ext_match.rs @@ -44,7 +44,7 @@ pub(in crate::search) fn extension_matches_filter( } /// Extract the filename's extension using the same rules as -/// [`uffs_mft::index::base::MftIndex::intern_extension`]: +/// [`uffs_mft::index::MftIndex::intern_extension`]: /// /// - Dotless names (e.g. `dbt`, `README`) have no extension. /// - Hidden files (e.g. `.gitignore`) have no extension. diff --git a/crates/uffs-core/src/search/filters/mod.rs b/crates/uffs-core/src/search/filters/mod.rs index 6a5b2f713..5a93ec87f 100644 --- a/crates/uffs-core/src/search/filters/mod.rs +++ b/crates/uffs-core/src/search/filters/mod.rs @@ -661,7 +661,7 @@ impl SearchFilters { /// Check derived/computed filters: name length, allocated, tree metrics, /// month. /// - /// Split from [`matches_record`] to keep each function under the + /// Split from [`Self::matches_record`] to keep each function under the /// `too_many_lines` lint threshold. fn matches_derived(&self, rec: &CompactRecord, names: &[u8]) -> bool { // ── Name-length filters (chars, not bytes) ───────────────── diff --git a/crates/uffs-core/src/search/query/mod.rs b/crates/uffs-core/src/search/query/mod.rs index 9d748e36a..b5a9d6dc6 100644 --- a/crates/uffs-core/src/search/query/mod.rs +++ b/crates/uffs-core/src/search/query/mod.rs @@ -424,7 +424,7 @@ pub(crate) fn search_compact_drive( let t_tri = std::time::Instant::now(); let candidates = if !case_sensitive && trigram_needle.len() >= 3 { - drive.trigram.search(&trigram_needle, fold) + drive.trigram_search(&trigram_needle) } else { None }; @@ -484,7 +484,7 @@ fn expand_directory_descendants(drive: &DriveCompactIndex, indices: &mut Vec= limit { return; } - let child_slice = drive.children.get(dir_idx as usize); + let child_slice = drive.children_of(dir_idx); if child_slice.is_empty() { continue; } @@ -382,7 +382,7 @@ fn walk_drive_desc( ); } DescTask::Recurse(dir_idx) => { - let child_slice = drive.children.get(dir_idx as usize); + let child_slice = drive.children_of(dir_idx); if child_slice.is_empty() { continue; } @@ -535,7 +535,7 @@ fn collect_path_only_via_ext_index + Sync>( // `.clone()` (Phase 6c category-δ) that was anticipating a // future filter push that never landed. for &ext_id in &search_filters.resolved_ext_ids { - for &rec_idx_u32 in drive.ext_index.get(ext_id) { + for &rec_idx_u32 in drive.records_with_ext(ext_id).iter() { let rec_idx = rec_idx_u32 as usize; let Some(rec) = drive.records.get(rec_idx) else { continue; diff --git a/crates/uffs-core/src/search/query/path_sorted_top_n.rs b/crates/uffs-core/src/search/query/path_sorted_top_n.rs index edfcafa20..454d21585 100644 --- a/crates/uffs-core/src/search/query/path_sorted_top_n.rs +++ b/crates/uffs-core/src/search/query/path_sorted_top_n.rs @@ -165,7 +165,7 @@ fn walk_tree_path_sorted>( // Enqueue children BEFORE the filter check — a directory // that fails the filter (e.g. `FilesOnly` drops dirs) may // still contain matching descendants that must be visited. - let child_slice = drive.children.get(idx as usize); + let child_slice = drive.children_of(idx); if !child_slice.is_empty() { let mut sorted_children = child_slice.to_vec(); sort_indices_by_name(&mut sorted_children, drive, sort_desc); @@ -264,7 +264,7 @@ fn collect_path_via_ext_index + Sync>( // `.clone()` (Phase 6c category-δ) that was anticipating a // re-aliasing scenario that the current code doesn't hit. for &ext_id in &search_filters.resolved_ext_ids { - for &rec_idx_u32 in drive.ext_index.get(ext_id) { + for &rec_idx_u32 in drive.records_with_ext(ext_id).iter() { let rec_idx = rec_idx_u32 as usize; let Some(rec) = drive.records.get(rec_idx) else { continue; diff --git a/crates/uffs-core/src/search/query/prefix_search.rs b/crates/uffs-core/src/search/query/prefix_search.rs index bc2b892bc..fca5cb543 100644 --- a/crates/uffs-core/src/search/query/prefix_search.rs +++ b/crates/uffs-core/src/search/query/prefix_search.rs @@ -44,7 +44,7 @@ pub(crate) fn search_compact_drive_prefix( // Get trigram candidates using first 3 chars of prefix. // get() safely handles any byte boundaries; prefix is ASCII from pattern. let trigram_needle = prefix.get(..prefix.len().min(3)).unwrap_or(prefix); - let candidates = drive.trigram.search(trigram_needle, drive.fold); + let candidates = drive.trigram_search(trigram_needle); let tri_ms = t_tri.elapsed().as_millis(); let tri_count = candidates.as_ref().map_or(0, Vec::len); diff --git a/crates/uffs-core/src/search/tree.rs b/crates/uffs-core/src/search/tree.rs index a4489c5f6..33ea9ddcc 100644 --- a/crates/uffs-core/src/search/tree.rs +++ b/crates/uffs-core/src/search/tree.rs @@ -419,7 +419,7 @@ pub(crate) fn tree_search( } else { let mut next_dirs = Vec::new(); for &dir_idx in &candidate_dirs { - for &child_idx in drive.children.get(dir_idx as usize) { + for &child_idx in drive.children_of(dir_idx).iter() { if let Some(child_rec) = drive.records.get(child_idx as usize) && child_rec.is_directory() { @@ -449,7 +449,7 @@ pub(crate) fn tree_search( } } else { for &dir_idx in &candidate_dirs { - for &child_idx in drive.children.get(dir_idx as usize) { + for &child_idx in drive.children_of(dir_idx).iter() { if let Some(child_rec) = drive.records.get(child_idx as usize) { let child_name = fold.fold_into(child_rec.name(&drive.names), &mut fold_buf); if name_matches(child_name, leaf_pattern) { @@ -476,7 +476,7 @@ fn collect_descendant_dirs( if out.len() >= max { return; } - for &child_idx in drive.children.get(dir_idx as usize) { + for &child_idx in drive.children_of(dir_idx).iter() { if let Some(child_rec) = drive.records.get(child_idx as usize) && child_rec.is_directory() && child_rec.name_len > 0 @@ -500,7 +500,7 @@ fn collect_all_descendants( if out.len() >= max { return; } - for &child_idx in drive.children.get(dir_idx as usize) { + for &child_idx in drive.children_of(dir_idx).iter() { if let Some(child_rec) = drive.records.get(child_idx as usize) && child_rec.name_len > 0 { @@ -528,7 +528,7 @@ fn trigram_filtered_records( limit: usize, mut predicate: impl FnMut(&crate::compact::CompactRecord) -> bool, ) -> Vec { - let candidates = drive.trigram.search(needle, drive.fold); + let candidates = drive.trigram_search(needle); match candidates { None => drive .records diff --git a/crates/uffs-core/src/trigram.rs b/crates/uffs-core/src/trigram.rs index 613b107b4..aba61eb34 100644 --- a/crates/uffs-core/src/trigram.rs +++ b/crates/uffs-core/src/trigram.rs @@ -236,9 +236,13 @@ impl TrigramIndex { self.keys.len() } - /// Look up the posting list for a single packed char-trigram key. + /// Look up the base posting list for a single packed char-trigram key. + /// + /// `pub(crate)` so [`crate::compact::DriveCompactIndex::trigram_search`] + /// can merge a base posting with its delta overlay (incremental-index + /// §5.2) without re-deriving the CSR lookup. #[must_use] - fn get_posting(&self, packed: u64) -> Option<&[u32]> { + pub(crate) fn get_posting(&self, packed: u64) -> Option<&[u32]> { let idx = self.keys.binary_search(&packed).ok()?; let start = *self.offsets.get(idx)? as usize; let end = *self.offsets.get(idx + 1)? as usize; @@ -252,23 +256,7 @@ impl TrigramIndex { /// linear scan). #[must_use] pub fn search(&self, needle: &str, fold: CaseFold) -> Option> { - let folded: Vec = needle.chars().map(|ch| fold.fold_char(ch)).collect(); - if folded.len() < 3 { - return None; - } - - let mut seen = rustc_hash::FxHashSet::default(); - let mut trigrams: Vec = Vec::new(); - for window in folded.windows(3) { - let Some(&[cp0, cp1, cp2]) = window.first_chunk::<3>() else { - continue; - }; - let packed = pack_char_trigram(cp0, cp1, cp2); - if seen.insert(packed) { - trigrams.push(packed); - } - } - + let trigrams = needle_trigrams(needle, fold)?; if trigrams.is_empty() { return Some(Vec::new()); } @@ -299,15 +287,37 @@ impl TrigramIndex { } } +/// The deduped packed char-trigrams of a search needle, or `None` if the needle +/// folds to fewer than 3 codepoints (caller falls back to a linear scan). +/// +/// Shared by [`TrigramIndex::search`] and the base+delta +/// [`crate::compact::DriveCompactIndex::trigram_search`] so the needle→trigram +/// packing has exactly one definition. +#[must_use] +pub(crate) fn needle_trigrams(needle: &str, fold: CaseFold) -> Option> { + let folded: Vec = needle.chars().map(|ch| fold.fold_char(ch)).collect(); + if folded.len() < 3 { + return None; + } + let mut seen = rustc_hash::FxHashSet::default(); + let mut trigrams: Vec = Vec::new(); + for window in folded.windows(3) { + let Some(&[cp0, cp1, cp2]) = window.first_chunk::<3>() else { + continue; + }; + let packed = pack_char_trigram(cp0, cp1, cp2); + if seen.insert(packed) { + trigrams.push(packed); + } + } + Some(trigrams) +} + /// Intersect a sorted `Vec` with a sorted slice **in place**. /// /// Retains only elements present in both, preserving sorted order. /// Shrinks `result` via `truncate` — no allocation, no new `Vec`. -#[expect( - clippy::single_call_fn, - reason = "separated for clarity — hot-path intersection logic" -)] -fn intersect_in_place(result: &mut Vec, other: &[u32]) { +pub(crate) fn intersect_in_place(result: &mut Vec, other: &[u32]) { let mut write = 0_usize; let mut j = 0_usize; for i in 0..result.len() { diff --git a/crates/uffs-daemon/build.rs b/crates/uffs-daemon/build.rs new file mode 100644 index 000000000..e21c8d5c5 --- /dev/null +++ b/crates/uffs-daemon/build.rs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so +// the workspace `deny(unwrap_used)` / `deny(expect_used)` runtime lints do not +// apply here; best-effort error handling with sensible fallbacks is the +// idiomatic shape for a build script whose only "failure" is "git not present". +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-daemon`. +//! +//! Emits `UFFS_GIT_SHA` — the short commit the daemon was built from, with a +//! `-dirty` suffix when the working tree had uncommitted changes — so the +//! startup log can stamp **which build** is running. A definitive build stamp +//! in the daemon log is how a field log (or a WIN test-script) is tied back to +//! the exact binary that produced it, closing the "ran the wrong/stale binary" +//! trap. Read back via `option_env!("UFFS_GIT_SHA")` in `startup.rs`. + +use std::process::Command; + +fn main() { + let sha = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|raw| raw.trim().to_owned()) + .filter(|trimmed| !trimmed.is_empty()) + .unwrap_or_else(|| "unknown".to_owned()); + + // Append `-dirty` when the working tree has uncommitted changes, so a + // hand-tweaked local build is never mistaken for the clean commit. + let dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok() + .filter(|out| out.status.success()) + .is_some_and(|out| !out.stdout.is_empty()); + + let stamp = if dirty { format!("{sha}-dirty") } else { sha }; + println!("cargo:rustc-env=UFFS_GIT_SHA={stamp}"); + + // Re-run when HEAD moves so the stamp tracks the checked-out commit. + // Best-effort relative path from the crate dir to the repo `.git`; a wrong + // path just means the stamp can lag one commit on an exotic layout, which + // is acceptable for a dev-only marker. + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/uffs-daemon/src/broker_client.rs b/crates/uffs-daemon/src/broker_client.rs index bcb13d890..4c47d9b6c 100644 --- a/crates/uffs-daemon/src/broker_client.rs +++ b/crates/uffs-daemon/src/broker_client.rs @@ -7,18 +7,17 @@ //! to obtain elevated volume handles instead of requiring its own elevation. //! //! Flow: -//! 1. Check if the broker pipe exists ([`uffs_broker_protocol::PIPE_NAME`]) +//! 1. Check if the broker pipe exists (`uffs_broker_protocol::PIPE_NAME`) //! 2. Connect to it -//! 3. Encode the drive letter via -//! [`uffs_broker_protocol::HandleRequest::encode`] -//! 4. Decode the response via [`uffs_broker_protocol::HandleResponse::parse`] +//! 3. Encode the drive letter via `uffs_broker_protocol::HandleRequest::encode` +//! 4. Decode the response via `uffs_broker_protocol::HandleResponse::parse` //! 5. Use the handle for MFT reading //! //! The wire format used to be duplicated here as a `const BROKER_PIPE_NAME` //! plus hand-rolled byte-slicing with a `// must match //! uffs-broker/src/broker.rs` reviewer-comment as the only protection //! against drift. F5 (issue #205) promoted those shared symbols to -//! the dedicated [`uffs_broker_protocol`] crate, eliminating the +//! the dedicated `uffs_broker_protocol` crate, eliminating the //! textual coupling. //! //! `uffs-broker-protocol` is scoped to diff --git a/crates/uffs-daemon/src/cache/background_io.rs b/crates/uffs-daemon/src/cache/background_io.rs index 3b4d9876c..bec625fb2 100644 --- a/crates/uffs-daemon/src/cache/background_io.rs +++ b/crates/uffs-daemon/src/cache/background_io.rs @@ -45,14 +45,14 @@ //! [`crate::index::IndexManager`] holds the trait as //! `Arc`. Production wires //! [`PlatformBackgroundIoPriority`]; the Phase 5 unit tests inject -//! [`tests::CountingBackgroundIoPriority`] so the test can assert +//! `tests::CountingBackgroundIoPriority` so the test can assert //! `begin()` + `end()` pair exactly once per //! `tokio::task::spawn_blocking` closure that runs the periodic //! USN refresh tick. //! //! Production hooks the guard at the top of every per-letter //! closure spawned by -//! [`crate::index::IndexManager::refresh_usn_for_warm_shards`] — +//! [`crate::spawn_journal_loops_for_warm_shards`] — //! the periodic 5-min housekeeping tick that: //! //! * reads each Warm/Hot drive's USN journal (background read), @@ -76,7 +76,7 @@ use std::io; /// Implementations are held as `Arc` on /// [`crate::index::IndexManager`]. Called from inside the /// `tokio::task::spawn_blocking` closures of -/// [`crate::index::IndexManager::refresh_usn_for_warm_shards`] +/// [`crate::spawn_journal_loops_for_warm_shards`] /// (Phase 5 task 5.7). Both methods operate on the **calling /// thread**, not the process, so concurrent USN refreshes for /// multiple drives each enter / leave background mode independently diff --git a/crates/uffs-daemon/src/cache/cache_cleaner.rs b/crates/uffs-daemon/src/cache/cache_cleaner.rs index 9053d70d2..f14ba6739 100644 --- a/crates/uffs-daemon/src/cache/cache_cleaner.rs +++ b/crates/uffs-daemon/src/cache/cache_cleaner.rs @@ -11,9 +11,9 @@ //! * Production gets [`PlatformCacheCleaner`] which calls //! [`uffs_core::compact_cache::compact_cache_path`] and friends to resolve //! the real platform paths and unlinks them via [`std::fs::remove_file`]. -//! * Tests inject [`CountingCacheCleaner`] (and the temp-dir-backed helper +//! * Tests inject `CountingCacheCleaner` (and the temp-dir-backed helper //! [`delete_drive_cache_files`] is unit-tested directly with a -//! [`tempfile::TempDir`]) so the registry-eviction behaviour can be verified +//! `tempfile::TempDir`) so the registry-eviction behaviour can be verified //! without ever touching the host's real cache directory — which would //! otherwise be a destructive operation when a test asks the daemon to //! "forget drive C". @@ -21,7 +21,7 @@ //! Mirrors the [`super::body_loader::BodyLoader`] / //! [`super::working_set::WorkingSetTrim`] hook pattern (Phase 5): //! one `Arc` field on -//! [`crate::index::constructors::LifecycleHooks`], the production +//! `LifecycleHooks`, the production //! impl is wired in [`super::body_loader::DiskBodyLoader`]-style at //! daemon bootstrap, and the test escape hatches stay narrow. @@ -30,7 +30,7 @@ use std::path::{Path, PathBuf}; /// Per-drive cache cleanup operation. /// -/// Used by [`crate::index::IndexManager::forget_drive`] to delete +/// Used by [`crate::index::IndexManager::forget_drives`] to delete /// every on-disk artefact tied to a specific drive letter once the /// shard has been evicted from the in-memory registry. /// @@ -79,7 +79,7 @@ impl CacheCleaner for PlatformCacheCleaner { /// /// Exposed at module scope so the unit test in this file's `tests` /// submodule can drive [`delete_drive_cache_files`] against a -/// [`tempfile::TempDir`] without dragging the platform paths into +/// `tempfile::TempDir` without dragging the platform paths into /// the test fixture. fn drive_cache_paths(letter: uffs_mft::platform::DriveLetter) -> [PathBuf; 4] { [ diff --git a/crates/uffs-daemon/src/cache/cursor_store.rs b/crates/uffs-daemon/src/cache/cursor_store.rs index edebea822..0e122ff8c 100644 --- a/crates/uffs-daemon/src/cache/cursor_store.rs +++ b/crates/uffs-daemon/src/cache/cursor_store.rs @@ -19,11 +19,11 @@ //! //! ## Mac / Linux fallback //! -//! Production on macOS / Linux uses [`super::journal_loop::NullCursorStore`] -//! instead — there is no NTFS USN journal, so there is no cursor -//! to persist. `DiskCursorStore` is built and wired only on -//! Windows but its implementation is platform-agnostic so Mac -//! tests can drive every code path against a `tempdir` root. +//! Production on macOS / Linux uses +//! [`crate::cache::journal_loop::sources::NullCursorStore`] instead — there is +//! no NTFS USN journal, so there is no cursor to persist. `DiskCursorStore` is +//! built and wired only on Windows but its implementation is platform-agnostic +//! so Mac tests can drive every code path against a `tempdir` root. //! //! [`CursorStore`]: super::journal_loop::CursorStore @@ -46,10 +46,10 @@ pub(crate) struct DiskCursorStore { impl DiskCursorStore { /// Construct a store rooted at `cache_root`. /// - /// `cache_root` is created lazily by [`Self::store`] on the - /// first save (matching the existing compact-cache writer's - /// `create_secure_dir` pattern), so passing a not-yet-existing - /// path is fine. + /// `cache_root` is created lazily by + /// [`crate::cache::journal_loop::CursorStore::store`] on the first save + /// (matching the existing compact-cache writer's `create_secure_dir` + /// pattern), so passing a not-yet-existing path is fine. #[must_use] pub(crate) const fn new(cache_root: PathBuf) -> Self { Self { cache_root } diff --git a/crates/uffs-daemon/src/cache/guarded_load.rs b/crates/uffs-daemon/src/cache/guarded_load.rs index ffdd466c7..aae2efbd8 100644 --- a/crates/uffs-daemon/src/cache/guarded_load.rs +++ b/crates/uffs-daemon/src/cache/guarded_load.rs @@ -27,11 +27,11 @@ //! //! ## The guard //! -//! [`decide_strategy`] inspects the cheap signals only — the persisted +//! `decide_strategy` inspects the cheap signals only — the persisted //! cursor (an 8-byte file read) and `FSCTL_QUERY_USN_JOURNAL` (a single //! ioctl) — and never touches the multi-hundred-MB `MftIndex`: //! -//! * [`WarmLoadStrategy::FastFromCompactCache`] — the persisted cursor lies +//! * `WarmLoadStrategy::FastFromCompactCache` — the persisted cursor lies //! inside the live journal's valid window (`first_usn <= cursor <= //! next_usn`). The compact cache is at least as fresh as that cursor (the //! journal loop writes body + cursor in lockstep, and full rebuilds write a @@ -39,8 +39,8 @@ //! the background loop re-applies `[cursor, live)` — idempotent on any //! overlap — and converges within ~one poll interval. //! -//! * [`WarmLoadStrategy::FullRebuild`] — the cursor is absent (`0` sentinel: -//! cold boot / never persisted), predates the journal (`cursor < first_usn`: +//! * `WarmLoadStrategy::FullRebuild` — the cursor is absent (`0` sentinel: cold +//! boot / never persisted), predates the journal (`cursor < first_usn`: //! wrapped or long-downtime), or postdates it (`cursor > next_usn`: the //! journal was deleted + recreated and is younger than the cursor). In each //! case the background loop cannot converge the existing cache from the @@ -61,7 +61,7 @@ #[cfg(windows)] use uffs_core::compact::DriveCompactIndex; -/// Outcome of [`decide_strategy`]: how to materialise a drive's body on +/// Outcome of `decide_strategy`: how to materialise a drive's body on /// a warm load. /// /// Compiled on Windows (the only platform with a USN journal) and under diff --git a/crates/uffs-daemon/src/cache/journal_loop.rs b/crates/uffs-daemon/src/cache/journal_loop.rs index f8ee86541..295e50cc3 100644 --- a/crates/uffs-daemon/src/cache/journal_loop.rs +++ b/crates/uffs-daemon/src/cache/journal_loop.rs @@ -7,7 +7,7 @@ //! //! Each loaded shard owns one `tokio::task` polling its drive's USN //! journal at [`JournalLoopConfig::poll_interval`] cadence (default -//! 500 ms, overridable via [`UFFS_USN_POLL_INTERVAL_MS`] for tests +//! 500 ms, overridable via `UFFS_USN_POLL_INTERVAL_MS` for tests //! and benchmarks). Per tick: //! //! 1. **Poll the journal** via the trait-object [`JournalSource`]. Returns @@ -17,7 +17,7 @@ //! data). //! 2. **Apply to the shard** via the caller-supplied [`PatchSink`]. Production //! wires this to a closure that calls -//! [`crate::cache::ShardEntry::apply_usn_patch_to_body`] + +//! [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] + //! [`crate::cache::ShardRegistry::replace_warm_body`]; tests wire it to a //! recording fake. //! 3. **Update cursor** so the next poll picks up only what's new. @@ -29,10 +29,10 @@ //! The trait is platform-agnostic; the **journal-source impl** is //! Windows-only (`WindowsJournalSource` wraps `read_usn_journal`). //! On macOS / Linux the production wire-up uses -//! [`MacStubJournalSource`] which always returns empty changes — -//! the loop ticks at the configured cadence but produces no patches. -//! State-machine semantics (cancellation, cursor advance, no-op -//! ticks) are exercised end-to-end on Mac via [`tests::FakeJournalSource`]. +//! [`crate::cache::journal_loop::sources::MacStubJournalSource`] which always +//! returns empty changes — the loop ticks at the configured cadence but +//! produces no patches. State-machine semantics (cancellation, cursor advance, +//! no-op ticks) are exercised end-to-end on Mac via `tests::FakeJournalSource`. //! //! ## Phase 7 commit boundary //! @@ -47,10 +47,12 @@ use core::time::Duration; use tokio::sync::watch; use uffs_mft::usn::FileChange; +mod poll; mod triggers; +pub(crate) use poll::{MAX_POLL_BACKOFF, PollBackoff}; pub(crate) use triggers::{ - ApplyTrigger, DEFAULT_APPLY_INTERVAL_MS, DEFAULT_SAVE_THRESHOLD_AGE, + ApplyTrigger, DEFAULT_APPLY_DEBOUNCE_MS, DEFAULT_APPLY_INTERVAL_MS, DEFAULT_SAVE_THRESHOLD_AGE, DEFAULT_SAVE_THRESHOLD_EVENTS, SaveReason, SaveTrigger, }; @@ -87,10 +89,11 @@ pub(crate) struct JournalPollResult { /// Pluggable USN-journal data source. /// -/// Production wires [`MacStubJournalSource`] (always-empty) on -/// macOS / Linux and `WindowsJournalSource` (cfg(windows), reads via +/// Production wires +/// [`crate::cache::journal_loop::sources::MacStubJournalSource`] (always-empty) +/// on macOS / Linux and `WindowsJournalSource` (cfg(windows), reads via /// `FSCTL_READ_USN_JOURNAL`) on Windows. Tests wire -/// [`tests::FakeJournalSource`] (programmable event queue) to drive +/// `tests::FakeJournalSource` (programmable event queue) to drive /// the [`JournalLoop`] state machine deterministically without a /// live MFT. /// @@ -123,7 +126,7 @@ pub(crate) trait JournalSource: Send + Sync + 'static { /// Production wires this to a closure that: /// /// 1. Looks up the shard for `letter` in the registry. -/// 2. Calls [`crate::cache::ShardEntry::apply_usn_patch_to_body`] on the +/// 2. Calls [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] on the /// current body. /// 3. Swaps the new body via /// [`crate::cache::ShardRegistry::replace_warm_body`]. @@ -218,10 +221,10 @@ pub(crate) trait PatchSink: Send + Sync + 'static { /// Pluggable cursor-persistence surface (Phase 7 task 7.6). /// -/// Production wires [`NullCursorStore`] (always-empty) as a -/// fallback on platforms without a real persisted cursor and the -/// disk-backed implementation lands in the activation commit. -/// Tests wire [`tests::FakeCursorStore`] (in-memory `HashMap`) +/// Production wires [`crate::cache::journal_loop::sources::NullCursorStore`] +/// (always-empty) as a fallback on platforms without a real persisted cursor +/// and the disk-backed implementation lands in the activation commit. +/// Tests wire `tests::FakeCursorStore` (in-memory `HashMap`) /// to drive the load / store path deterministically. /// /// Both methods are **infallible** at the trait level: any @@ -268,12 +271,16 @@ pub(crate) struct JournalLoopConfig { /// when at least one event is pending. Default /// [`DEFAULT_SAVE_THRESHOLD_AGE`] (5 min). pub(crate) save_threshold_age: Duration, - /// Search-freshness apply cadence — decoupled from the disk-save - /// thresholds above. When buffered changes exist and this long - /// has elapsed since the last apply / save, the loop patches the - /// in-memory body via [`PatchSink::trigger_apply`] so the change - /// becomes searchable. Default [`DEFAULT_APPLY_INTERVAL_MS`] (30 s). + /// Search-freshness apply **max-wait** cap (Phase 5) — the ceiling of the + /// debounce model. A burst that never settles is force-applied at least + /// this often, bounding both freshness lag and apply CPU under sustained + /// churn. Default [`DEFAULT_APPLY_INTERVAL_MS`] (2 s). pub(crate) apply_interval: Duration, + /// Search-freshness apply **debounce / settle** window (Phase 5) — once + /// buffered changes have been quiet this long, coalesce them into one + /// apply, so an idle→active transition goes searchable promptly. Default + /// [`DEFAULT_APPLY_DEBOUNCE_MS`] (250 ms). + pub(crate) apply_debounce: Duration, } impl Default for JournalLoopConfig { @@ -284,6 +291,7 @@ impl Default for JournalLoopConfig { save_threshold_events: DEFAULT_SAVE_THRESHOLD_EVENTS, save_threshold_age: DEFAULT_SAVE_THRESHOLD_AGE, apply_interval: Duration::from_millis(DEFAULT_APPLY_INTERVAL_MS), + apply_debounce: Duration::from_millis(DEFAULT_APPLY_DEBOUNCE_MS), } } } @@ -291,9 +299,13 @@ impl Default for JournalLoopConfig { /// Env var overriding [`JournalLoopConfig::poll_interval`] (milliseconds). pub(crate) const POLL_INTERVAL_ENV: &str = "UFFS_USN_POLL_INTERVAL_MS"; -/// Env var overriding [`JournalLoopConfig::apply_interval`] (milliseconds). +/// Env var overriding [`JournalLoopConfig::apply_interval`] (the max-wait cap, +/// milliseconds). pub(crate) const APPLY_INTERVAL_ENV: &str = "UFFS_USN_APPLY_INTERVAL_MS"; +/// Env var overriding [`JournalLoopConfig::apply_debounce`] (milliseconds). +pub(crate) const APPLY_DEBOUNCE_ENV: &str = "UFFS_USN_APPLY_DEBOUNCE_MS"; + impl JournalLoopConfig { /// Build the production config: [`Self::default`] with the two /// millisecond-valued env overrides applied when present + parseable. @@ -314,6 +326,9 @@ impl JournalLoopConfig { if let Some(ms) = env_millis(APPLY_INTERVAL_ENV) { config.apply_interval = Duration::from_millis(ms); } + if let Some(ms) = env_millis(APPLY_DEBOUNCE_ENV) { + config.apply_debounce = Duration::from_millis(ms); + } config } } @@ -437,11 +452,11 @@ impl JournalLoop { }; let mut backoff = PollBackoff::new(self.config.poll_interval, MAX_POLL_BACKOFF); loop { - if !wait_for_next_tick(&mut self.cancel_rx, backoff.current(), letter).await { + if !poll::wait_for_next_tick(&mut self.cancel_rx, backoff.current(), letter).await { return; } - let result = match poll_blocking(Arc::clone(&self.source), cursor).await { + let result = match poll::poll_blocking(Arc::clone(&self.source), cursor).await { Ok(result) => { if backoff.on_success() { tracing::info!( @@ -453,7 +468,7 @@ impl JournalLoop { } Err(failure) => { let streak = backoff.on_failure(); - log_poll_failure(letter, &failure, streak, backoff.current()); + poll::log_poll_failure(letter, &failure, streak, backoff.current()); continue; } }; @@ -501,166 +516,6 @@ impl JournalLoop { } } -/// Wait for the next poll deadline, racing the cancellation watch. -/// -/// **Returns** `true` when the loop should proceed with a poll, -/// `false` when cancellation has been observed and the loop -/// should exit. -async fn wait_for_next_tick( - cancel_rx: &mut watch::Receiver, - poll_interval: Duration, - letter: uffs_mft::platform::DriveLetter, -) -> bool { - if *cancel_rx.borrow() { - tracing::debug!(drive = %letter, "Journal loop cancellation requested before tick"); - return false; - } - tokio::select! { - () = tokio::time::sleep(poll_interval) => true, - changed = cancel_rx.changed() => { - if changed.is_ok() && *cancel_rx.borrow() { - tracing::debug!( - drive = %letter, - "Journal loop cancellation observed during sleep" - ); - false - } else { - true - } - } - } -} - -/// Upper bound on the journal-poll backoff cadence. -/// -/// When the journal is unavailable the loop backs its cadence off geometrically -/// (see [`PollBackoff`]) up to this ceiling, so a persistently unavailable -/// journal — e.g. a non-elevated daemon whose USN handle isn't brokered yet -/// (FU-2b) — polls at most this often instead of every `poll_interval`. Small -/// enough that a recovered journal is picked up promptly; large enough that an -/// unavailable one stops flooding the log and the blocking pool. -const MAX_POLL_BACKOFF: Duration = Duration::from_secs(30); - -/// Why a journal poll tick produced no result. -struct PollFailure { - /// Human-readable cause for the log line. - cause: String, - /// `true` when the `spawn_blocking` task itself failed (panicked / - /// cancelled) rather than the source returning an I/O error. - aborted: bool, -} - -/// Geometric backoff for the journal poll cadence. -/// -/// The journal can be transiently unavailable (volume revocation, broker -/// reconnect) or — for a non-elevated daemon without a brokered USN handle — -/// persistently access-denied. Polling every `base` interval in that state -/// floods the log with one WARN per tick (~2/s) and burns a `spawn_blocking` -/// plus an FSCTL per tick for nothing. This doubles the cadence from `base` -/// toward `cap` on each consecutive failure and snaps back to `base` on the -/// first success, so a healthy journal keeps its tight cadence while an -/// unavailable one goes quiet. -struct PollBackoff { - /// Healthy cadence (the configured `poll_interval`). - base: Duration, - /// Maximum backed-off cadence. - cap: Duration, - /// Cadence the next tick will wait. - current: Duration, - /// Consecutive failures since the last success. - consecutive_failures: u32, -} - -impl PollBackoff { - /// Start at the healthy `base` cadence, backing off no slower than `cap`. - const fn new(base: Duration, cap: Duration) -> Self { - Self { - base, - cap, - current: base, - consecutive_failures: 0, - } - } - - /// Cadence the next tick should wait. - const fn current(&self) -> Duration { - self.current - } - - /// Record a successful poll: reset to `base`. Returns `true` when the loop - /// was previously backed off, so the caller can log a one-shot recovery. - const fn on_success(&mut self) -> bool { - let was_backed_off = self.consecutive_failures > 0; - self.consecutive_failures = 0; - self.current = self.base; - was_backed_off - } - - /// Record a failed poll: double the cadence (saturating at `cap`). Returns - /// the 1-based failure count in the current streak so the caller can log - /// the first failure loudly and demote the rest. - fn on_failure(&mut self) -> u32 { - self.consecutive_failures = self.consecutive_failures.saturating_add(1); - self.current = self.current.saturating_mul(2).min(self.cap); - self.consecutive_failures - } -} - -/// Run one journal poll on the blocking pool. -/// -/// **Returns** `Ok(result)` on success, or `Err(PollFailure)` describing the -/// cause — the caller logs it (with backoff-aware severity) and `continue`s. -async fn poll_blocking( - source: Arc, - cursor: u64, -) -> Result { - match tokio::task::spawn_blocking(move || source.poll(cursor)).await { - Ok(Ok(res)) => Ok(res), - Ok(Err(io_err)) => Err(PollFailure { - cause: io_err.to_string(), - aborted: false, - }), - Err(join_err) => Err(PollFailure { - cause: join_err.to_string(), - aborted: true, - }), - } -} - -/// Log a journal poll failure with backoff-aware severity: the **first** -/// failure of a streak is a WARN (the operator should see the journal went -/// away), every subsequent tick is DEBUG so an unavailable journal doesn't -/// storm the log. -fn log_poll_failure( - letter: uffs_mft::platform::DriveLetter, - failure: &PollFailure, - streak: u32, - next_interval: Duration, -) { - let next_ms = u64::try_from(next_interval.as_millis()).unwrap_or(u64::MAX); - let what = if failure.aborted { - "Journal poll task aborted" - } else { - "Journal poll failed" - }; - if streak <= 1 { - tracing::warn!( - drive = %letter, - error = %failure.cause, - next_poll_ms = next_ms, - "{what}; backing off until the journal recovers" - ); - } else { - tracing::debug!( - drive = %letter, - error = %failure.cause, - streak, - next_poll_ms = next_ms, - "{what}; still backed off" - ); - } -} - /// Buffer the post-poll change batch into `sink`, record it into both /// cadence triggers, and fire whichever is due via [`fire_due_cadence`] /// — or trace-log the no-op tick when `changes` is empty. @@ -678,22 +533,26 @@ fn process_tick( ) { if changes.is_empty() { tracing::trace!(drive = %letter, "Journal poll: no changes"); - return; + } else { + let accepted = sink.accept(letter, changes); + save_trigger.record(changes.len() as u64); + apply_trigger.record(); + tracing::debug!( + drive = %letter, + accepted, + change_count = changes.len(), + cursor, + "Journal poll: buffered tick" + ); } - let accepted = sink.accept(letter, changes); - let change_count = changes.len() as u64; - save_trigger.record(change_count); - apply_trigger.record(change_count); + // Phase 5: evaluate the cadences EVERY tick, not only on change-ticks. The + // apply debounce/settle and the age-based save must be able to fire on a + // QUIET tick (the first poll after a burst ends, or once a low-churn run + // has aged out) — not wait for the next new change. Both evaluations are + // cheap no-ops when nothing is pending, so an idle drive costs nothing + // beyond the poll itself. fire_due_cadence(sink, letter, cursor, save_trigger, apply_trigger, config); - - tracing::debug!( - drive = %letter, - accepted, - change_count = changes.len(), - cursor, - "Journal poll: applied tick" - ); } /// Fire whichever cadence is due this tick — **at most one**, since a @@ -731,10 +590,10 @@ fn fire_due_cadence( cursor, "Journal poll: triggered background compact-cache save" ); - } else if apply_trigger.evaluate(config.apply_interval) { - // No save this tick: patch the in-memory body so search sees - // the change within the apply interval, leaving disk - // persistence to a later save tick. + } else if apply_trigger.evaluate(config.apply_debounce, config.apply_interval) { + // No save this tick: patch the in-memory body so search goes near-live. + // Fires once a burst settles (debounce) or at the max-wait cap under + // sustained churn — disk persistence stays a later save tick's job. sink.trigger_apply(letter); tracing::debug!( drive = %letter, diff --git a/crates/uffs-daemon/src/cache/journal_loop/poll.rs b/crates/uffs-daemon/src/cache/journal_loop/poll.rs new file mode 100644 index 000000000..32815acbf --- /dev/null +++ b/crates/uffs-daemon/src/cache/journal_loop/poll.rs @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Poll scheduling + failure backoff for the per-shard [`super::JournalLoop`]. +//! +//! Houses the cancellation-aware inter-poll wait, the one-shot blocking poll +//! of the [`JournalSource`], and the exponential backoff that keeps a failing +//! drive (an `os 995` re-warm abort, a parked volume) from storming the log +//! and the blocking pool. Extracted from `journal_loop.rs` to keep that file +//! under the workspace 800-LOC policy while keeping the poll-timing concern as +//! one auditable unit. + +use alloc::sync::Arc; +use core::time::Duration; + +use tokio::sync::watch; + +use super::{JournalPollResult, JournalSource}; + +/// Wait for the next poll deadline, racing the cancellation watch. +/// +/// **Returns** `true` when the loop should proceed with a poll, +/// `false` when cancellation has been observed and the loop +/// should exit. +pub(super) async fn wait_for_next_tick( + cancel_rx: &mut watch::Receiver, + poll_interval: Duration, + letter: uffs_mft::platform::DriveLetter, +) -> bool { + if *cancel_rx.borrow() { + tracing::debug!(drive = %letter, "Journal loop cancellation requested before tick"); + return false; + } + tokio::select! { + () = tokio::time::sleep(poll_interval) => true, + changed = cancel_rx.changed() => { + if changed.is_ok() && *cancel_rx.borrow() { + tracing::debug!( + drive = %letter, + "Journal loop cancellation observed during sleep" + ); + false + } else { + true + } + } + } +} + +/// Upper bound on the journal-poll backoff cadence. +/// +/// When the journal is unavailable the loop backs its cadence off geometrically +/// (see [`PollBackoff`]) up to this ceiling, so a persistently unavailable +/// journal — e.g. a non-elevated daemon whose USN handle isn't brokered yet +/// (FU-2b) — polls at most this often instead of every `poll_interval`. Small +/// enough that a recovered journal is picked up promptly; large enough that an +/// unavailable one stops flooding the log and the blocking pool. +pub(crate) const MAX_POLL_BACKOFF: Duration = Duration::from_secs(30); + +/// Why a journal poll tick produced no result. +pub(super) struct PollFailure { + /// Human-readable cause for the log line. + cause: String, + /// `true` when the `spawn_blocking` task itself failed (panicked / + /// cancelled) rather than the source returning an I/O error. + aborted: bool, +} + +/// Geometric backoff for the journal poll cadence. +/// +/// The journal can be transiently unavailable (volume revocation, broker +/// reconnect) or — for a non-elevated daemon without a brokered USN handle — +/// persistently access-denied. Polling every `base` interval in that state +/// floods the log with one WARN per tick (~2/s) and burns a `spawn_blocking` +/// plus an FSCTL per tick for nothing. This doubles the cadence from `base` +/// toward `cap` on each consecutive failure and snaps back to `base` on the +/// first success, so a healthy journal keeps its tight cadence while an +/// unavailable one goes quiet. +pub(crate) struct PollBackoff { + /// Healthy cadence (the configured `poll_interval`). + base: Duration, + /// Maximum backed-off cadence. + cap: Duration, + /// Cadence the next tick will wait. + current: Duration, + /// Consecutive failures since the last success. + consecutive_failures: u32, +} + +impl PollBackoff { + /// Start at the healthy `base` cadence, backing off no slower than `cap`. + pub(crate) const fn new(base: Duration, cap: Duration) -> Self { + Self { + base, + cap, + current: base, + consecutive_failures: 0, + } + } + + /// Cadence the next tick should wait. + pub(crate) const fn current(&self) -> Duration { + self.current + } + + /// Record a successful poll: reset to `base`. Returns `true` when the loop + /// was previously backed off, so the caller can log a one-shot recovery. + pub(crate) const fn on_success(&mut self) -> bool { + let was_backed_off = self.consecutive_failures > 0; + self.consecutive_failures = 0; + self.current = self.base; + was_backed_off + } + + /// Record a failed poll: double the cadence (saturating at `cap`). Returns + /// the 1-based failure count in the current streak so the caller can log + /// the first failure loudly and demote the rest. + pub(crate) fn on_failure(&mut self) -> u32 { + self.consecutive_failures = self.consecutive_failures.saturating_add(1); + self.current = self.current.saturating_mul(2).min(self.cap); + self.consecutive_failures + } +} + +/// Run one journal poll on the blocking pool. +/// +/// **Returns** `Ok(result)` on success, or `Err(PollFailure)` describing the +/// cause — the caller logs it (with backoff-aware severity) and `continue`s. +pub(super) async fn poll_blocking( + source: Arc, + cursor: u64, +) -> Result { + match tokio::task::spawn_blocking(move || source.poll(cursor)).await { + Ok(Ok(res)) => Ok(res), + Ok(Err(io_err)) => Err(PollFailure { + cause: io_err.to_string(), + aborted: false, + }), + Err(join_err) => Err(PollFailure { + cause: join_err.to_string(), + aborted: true, + }), + } +} + +/// Log a journal poll failure with backoff-aware severity: the **first** +/// failure of a streak is a WARN (the operator should see the journal went +/// away), every subsequent tick is DEBUG so an unavailable journal doesn't +/// storm the log. +pub(super) fn log_poll_failure( + letter: uffs_mft::platform::DriveLetter, + failure: &PollFailure, + streak: u32, + next_interval: Duration, +) { + let next_ms = u64::try_from(next_interval.as_millis()).unwrap_or(u64::MAX); + let what = if failure.aborted { + "Journal poll task aborted" + } else { + "Journal poll failed" + }; + if streak <= 1 { + tracing::warn!( + drive = %letter, + error = %failure.cause, + next_poll_ms = next_ms, + "{what}; backing off until the journal recovers" + ); + } else { + tracing::debug!( + drive = %letter, + error = %failure.cause, + streak, + next_poll_ms = next_ms, + "{what}; still backed off" + ); + } +} diff --git a/crates/uffs-daemon/src/cache/journal_loop/sources.rs b/crates/uffs-daemon/src/cache/journal_loop/sources.rs index c420c27a6..e52d518a7 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/sources.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/sources.rs @@ -14,7 +14,7 @@ //! cross-platform tests can exercise the full loop flow without driving real //! journal data. //! -//! * [`WindowsJournalSource`] — production source on Windows, wrapping +//! * `WindowsJournalSource` — production source on Windows, wrapping //! `FSCTL_QUERY_USN_JOURNAL` + `FSCTL_READ_USN_JOURNAL` via //! [`uffs_mft::usn`]. Compile-gated to `cfg(windows)` so a misconfigured Mac //! wiring is rejected at compile time rather than silently degrading to an diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests.rs b/crates/uffs-daemon/src/cache/journal_loop/tests.rs index 02b1f38cc..f45bd9d11 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests.rs @@ -332,6 +332,7 @@ fn fast_config() -> JournalLoopConfig { // tests don't accidentally fire an apply; the apply-cadence // tests override this with a short interval. apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), } } diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests/apply_cadence.rs b/crates/uffs-daemon/src/cache/journal_loop/tests/apply_cadence.rs index 1e8b49581..381999c64 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests/apply_cadence.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests/apply_cadence.rs @@ -46,6 +46,7 @@ fn apply_only_config() -> JournalLoopConfig { save_threshold_events: u64::MAX, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::ZERO, + apply_debounce: Duration::ZERO, ..JournalLoopConfig::default() } } @@ -94,6 +95,7 @@ fn save_tick_suppresses_redundant_apply() { save_threshold_events: 1, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::ZERO, + apply_debounce: Duration::ZERO, ..JournalLoopConfig::default() }; @@ -157,6 +159,7 @@ async fn loop_applies_body_near_live_without_saving() { save_threshold_events: u64::MAX, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::ZERO, + apply_debounce: Duration::ZERO, ..JournalLoopConfig::default() }; let handle = spawn_journal_loop( @@ -186,56 +189,73 @@ async fn loop_applies_body_near_live_without_saving() { #[test] fn apply_trigger_requires_churn() { let mut trigger = ApplyTrigger::new(); - // No events recorded — even a zero interval must not fire. + // Nothing pending — neither the settle nor the cap path may fire. assert!( - !trigger.evaluate(Duration::ZERO), + !trigger.evaluate(Duration::ZERO, Duration::ZERO), "an apply must not fire without buffered churn", ); } #[test] -fn apply_trigger_fires_once_per_interval_then_resets() { +fn apply_trigger_fires_on_settle_then_resets() { let mut trigger = ApplyTrigger::new(); - trigger.record(3); + trigger.record(); + // debounce = 0 → the burst counts as settled immediately, so the apply + // fires; max-wait far out so the cap is not what fires it. assert!( - trigger.evaluate(Duration::ZERO), - "churn present + interval elapsed must fire", + trigger.evaluate(Duration::ZERO, Duration::from_hours(1)), + "a settled burst must fire the apply", ); - // The fire reset the churn counter, so a second evaluate with no - // new events must not fire again. + // The fire cleared the pending run, so a second evaluate without a new + // change must not fire again. assert!( - !trigger.evaluate(Duration::ZERO), - "evaluate must reset the churn counter after firing", + !trigger.evaluate(Duration::ZERO, Duration::from_hours(1)), + "evaluate must clear the pending run after firing", ); } #[test] -fn apply_trigger_respects_interval() { +fn apply_trigger_holds_until_settle() { let mut trigger = ApplyTrigger::new(); - trigger.record(3); - // Interval far in the future — churn exists but the rate limit - // holds the apply back. + trigger.record(); + // debounce far out (burst not settled) AND max-wait far out (cap not hit): + // the apply is held back, and the pending run is retained. assert!( - !trigger.evaluate(Duration::from_hours(1)), - "an apply must wait for the interval even with churn pending", + !trigger.evaluate(Duration::from_hours(1), Duration::from_hours(1)), + "an unsettled, not-yet-capped run must hold the apply back", ); - // The churn is retained (no reset on a held-back tick), so once the - // interval is satisfied the apply fires. + // Once the debounce is satisfied (0), the retained run fires. assert!( - trigger.evaluate(Duration::ZERO), - "retained churn must fire once the interval is satisfied", + trigger.evaluate(Duration::ZERO, Duration::from_hours(1)), + "the retained run must fire once it settles", + ); +} + +#[test] +fn apply_trigger_max_wait_cap_fires_under_sustained_churn() { + let mut trigger = ApplyTrigger::new(); + trigger.record(); + // The burst never settles (debounce far out), but the max-wait cap (0) + // forces the apply so sustained churn can't starve search freshness. + assert!( + trigger.evaluate(Duration::from_hours(1), Duration::ZERO), + "the max-wait cap must fire even when the burst never settles", + ); + assert!( + !trigger.evaluate(Duration::from_hours(1), Duration::ZERO), + "the cap fire must clear the pending run", ); } #[test] fn reset_after_save_clears_pending_churn() { let mut trigger = ApplyTrigger::new(); - trigger.record(5); - // A save tick drained + applied the buffer; the apply trigger must - // forget the churn so it doesn't redundantly re-apply. + trigger.record(); + // A save tick drained + applied the buffer; the apply trigger must forget + // the run so it doesn't redundantly re-apply. trigger.reset_after_save(); assert!( - !trigger.evaluate(Duration::ZERO), - "reset_after_save must clear the churn guard", + !trigger.evaluate(Duration::ZERO, Duration::ZERO), + "reset_after_save must clear the pending run", ); } diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests/integration.rs b/crates/uffs-daemon/src/cache/journal_loop/tests/integration.rs index 6ef794ef1..919ce5dca 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests/integration.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests/integration.rs @@ -97,6 +97,7 @@ async fn ten_thousand_events_end_to_end() { save_threshold_events: SAVE_THRESHOLD_EVENTS, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests/save_log_message.rs b/crates/uffs-daemon/src/cache/journal_loop/tests/save_log_message.rs index b168d2896..5821d0672 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests/save_log_message.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests/save_log_message.rs @@ -86,6 +86,7 @@ fn compact_cache_save_log_message_pins_string_target_and_level() { save_threshold_events: 1, // tight — crosses on the first evaluate save_threshold_age: Duration::from_hours(1), // generous apply_interval: Duration::from_hours(1), // disabled for this test + apply_debounce: Duration::from_hours(1), ..JournalLoopConfig::default() }; let changes = [one_change(10), one_change(11), one_change(12)]; diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests/thresholds.rs b/crates/uffs-daemon/src/cache/journal_loop/tests/thresholds.rs index bc501e340..5333d77b8 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests/thresholds.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests/thresholds.rs @@ -43,6 +43,7 @@ async fn events_threshold_triggers_save() { save_threshold_events: 5, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, @@ -93,6 +94,7 @@ async fn age_threshold_triggers_save_with_pending_events() { save_threshold_events: u64::MAX, save_threshold_age: Duration::from_millis(30), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, @@ -153,6 +155,7 @@ async fn zero_events_drive_does_not_trigger_save() { save_threshold_events: 1, save_threshold_age: Duration::from_millis(20), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, @@ -206,6 +209,7 @@ async fn counter_resets_after_save() { save_threshold_events: 5, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, diff --git a/crates/uffs-daemon/src/cache/journal_loop/tests/wrap_and_persistence.rs b/crates/uffs-daemon/src/cache/journal_loop/tests/wrap_and_persistence.rs index b610b4c6b..eeb45b03b 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/tests/wrap_and_persistence.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/tests/wrap_and_persistence.rs @@ -91,6 +91,7 @@ async fn cursor_handed_to_sink_on_save_trigger() { save_threshold_events: 5, save_threshold_age: Duration::from_hours(24), apply_interval: Duration::from_hours(24), + apply_debounce: Duration::from_hours(24), }; let handle = spawn_journal_loop( uffs_mft::platform::DriveLetter::C, diff --git a/crates/uffs-daemon/src/cache/journal_loop/triggers.rs b/crates/uffs-daemon/src/cache/journal_loop/triggers.rs index 0328d5ac4..70a845484 100644 --- a/crates/uffs-daemon/src/cache/journal_loop/triggers.rs +++ b/crates/uffs-daemon/src/cache/journal_loop/triggers.rs @@ -10,10 +10,10 @@ //! * [`SaveTrigger`] — the rare, expensive **disk save** (default 50k events / //! 5 min). Crossing either threshold patches the body AND persists the //! compact cache + cursor. -//! * [`ApplyTrigger`] — the more frequent, disk-free **in-memory apply** -//! (default 30 s). Buffered churn plus an elapsed interval patches the body -//! so a freshly created / renamed / deleted file becomes searchable, without -//! touching disk. +//! * [`ApplyTrigger`] — the frequent, disk-free **in-memory apply** (Phase 5 +//! debounce 250 ms / max-wait 2 s). A settled burst — or the max-wait cap +//! under sustained churn — patches the body so a freshly created / renamed / +//! deleted file becomes searchable, without touching disk. //! //! A save subsumes an apply (it drains + applies the same buffer), so //! the loop fires at most one of the two per tick; see @@ -47,33 +47,36 @@ pub(crate) const DEFAULT_SAVE_THRESHOLD_EVENTS: u64 = 50_000; /// path without changing the operator-visible recovery window. pub(crate) const DEFAULT_SAVE_THRESHOLD_AGE: Duration = Duration::from_mins(5); -/// Default apply interval for the per-shard journal loop — 30 seconds. +/// Default apply **max-wait** cap for the per-shard journal loop — 2 seconds +/// (Phase 5). /// -/// This is the **search-freshness** knob, decoupled from the much -/// rarer disk-save cadence above. When buffered changes exist and at -/// least this long has elapsed since the last apply / save, the loop -/// patches the in-memory body (via [`super::PatchSink::trigger_apply`]) -/// so a freshly created / renamed / deleted file becomes searchable — -/// instead of waiting up to [`DEFAULT_SAVE_THRESHOLD_AGE`] (5 min) for a -/// disk-save tick to also apply it. +/// This is the ceiling of the debounce model in [`ApplyTrigger`]: under +/// *sustained* back-to-back churn (a burst that never settles), the loop still +/// applies at least every this long so search freshness never lags more than +/// the cap. It is the CPU governor — each apply now costs ~200 ms on a multi- +/// million-record drive (Phases 1-4 made paths/trigram/ext/children +/// incremental), so a 2 s cap throttles a constantly-churning volume to +/// ~200 ms / 2 s ≈ 10 % of one core instead of a continuous drag. /// -/// Thirty seconds is tuned for the per-apply rebuild cost: each apply -/// clones the body and rebuilds the children / trigram / extension -/// indexes (~600 ms on a 7M-record drive, **independent of batch -/// size**). On a filesystem with constant churn that throttles the -/// rebuild to background noise (~600 ms / 30 s ≈ 2 % of one core per -/// active drive) instead of a continuous drag. Crucially it does *not* -/// blunt the common case: because the trigger fires as soon as the -/// interval has elapsed *since the last apply*, the first change after -/// any quiet period is applied within a poll or two — only sustained, -/// back-to-back churn is batched onto the 30 s cadence. On an idle -/// drive no apply fires at all (the event counter stays at zero). +/// The much shorter [`DEFAULT_APPLY_DEBOUNCE_MS`] is what makes the common +/// case feel snappy; this cap only bites when changes never stop. /// -/// Overridable at runtime via the `UFFS_USN_APPLY_INTERVAL_MS` -/// environment variable, mirroring `UFFS_USN_POLL_INTERVAL_MS`, so soak -/// tests and latency-sensitive setups can dial freshness up or down -/// without recompiling. -pub(crate) const DEFAULT_APPLY_INTERVAL_MS: u64 = 30_000; +/// Overridable at runtime via `UFFS_USN_APPLY_INTERVAL_MS`, so soak tests and +/// latency-sensitive setups can dial the cap up or down without recompiling. +pub(crate) const DEFAULT_APPLY_INTERVAL_MS: u64 = 2_000; + +/// Default apply **debounce / settle** window — 250 ms (Phase 5). +/// +/// The snappy half of the apply model: once a run of changes has been quiet for +/// this long (the burst settled), the loop coalesces it into one apply and the +/// new file is searchable. So an idle→active transition (saving one file, +/// finishing an unzip) becomes visible in well under a second, while a +/// continuous burst keeps re-arming the window and falls back to the +/// [`DEFAULT_APPLY_INTERVAL_MS`] cap. Sized below the 500 ms poll cadence so +/// the first quiet poll after a burst always satisfies it. +/// +/// Overridable via `UFFS_USN_APPLY_DEBOUNCE_MS`. +pub(crate) const DEFAULT_APPLY_DEBOUNCE_MS: u64 = 250; /// Why a [`super::PatchSink::trigger_save`] call fired. /// @@ -168,76 +171,84 @@ impl SaveTrigger { /// Per-shard apply-cadence state machine — the search-freshness /// counterpart to [`SaveTrigger`]. /// -/// Where `SaveTrigger` governs the rare, expensive disk save (50k -/// events / 5 min), this governs the more frequent, in-memory body -/// patch (default 30 s, [`DEFAULT_APPLY_INTERVAL_MS`]). Decoupling the -/// two is the whole point: a created / renamed / deleted file must -/// become searchable quickly, but the compact-cache disk write should -/// stay rare. +/// Where `SaveTrigger` governs the rare, expensive disk save (50k events / +/// 5 min), this governs the frequent, in-memory body patch that makes a +/// created / renamed / deleted file searchable. Decoupling the two is the +/// whole point: search must go near-live promptly, but the compact-cache disk +/// write should stay rare. /// -/// The trigger is purely time-gated with a "has churn" guard — it -/// fires when at least one event has been recorded since the last -/// apply / save **and** [`Self::evaluate`]'s `apply_interval` has -/// elapsed. There is intentionally no event-count fast-path: a huge -/// burst is already caught by `SaveTrigger`'s 50k threshold (a save -/// applies too), so the apply tick only needs to bound *latency*, not -/// volume. +/// Phase 5 makes this a **debounce + max-wait** gate rather than a fixed +/// rate-limit, so it is both snappy and CPU-bounded: +/// +/// * **debounce / settle** ([`DEFAULT_APPLY_DEBOUNCE_MS`], 250 ms) — once a run +/// of changes has been quiet for the debounce window, apply. An idle→active +/// transition (one saved file, a finished unzip) becomes searchable in well +/// under a second. +/// * **max-wait** ([`DEFAULT_APPLY_INTERVAL_MS`], 2 s) — a burst that never +/// settles is force-applied at the cap, so sustained churn collapses to one +/// ~200 ms apply per cap (~10 % of a core) instead of thrashing. +/// +/// On an idle drive nothing is ever pending, so [`Self::evaluate`] is a cheap +/// no-op every poll. #[derive(Debug)] pub(crate) struct ApplyTrigger { - /// Wall-clock time of the last apply (or save, which also applies) - /// — or the loop spawn time before any fire. Compared against - /// `Instant::now()` to rate-limit applies to one per - /// `apply_interval`. - last_apply_at: Instant, - /// Events accumulated since the last apply / save. Non-zero is - /// the "there is something to apply" guard; the exact count does - /// not matter (no volume threshold here). - events_since_apply: u64, + /// When the first not-yet-applied change of the current run arrived, or + /// `None` when nothing is pending. The **max-wait** cap is measured from + /// here; `Some` is also the "there is something to apply" guard. + first_change_at: Option, + /// When the most recent change arrived. The **debounce / settle** window is + /// measured from here; only meaningful while `first_change_at` is `Some`. + last_change_at: Instant, } impl ApplyTrigger { - /// Construct a fresh trigger with `last_apply_at` set to - /// `Instant::now()` so the first apply can't fire until - /// `apply_interval` has elapsed since loop spawn. + /// Construct a fresh trigger with nothing pending. pub(super) fn new() -> Self { Self { - last_apply_at: Instant::now(), - events_since_apply: 0, + first_change_at: None, + last_change_at: Instant::now(), } } - /// Record `change_count` events toward the "has churn" guard. - /// Saturating so a runaway drive can't wrap the counter back to - /// zero and suppress an apply. - pub(super) const fn record(&mut self, change_count: u64) { - self.events_since_apply = self.events_since_apply.saturating_add(change_count); + /// Record that the latest poll observed at least one change: start the + /// max-wait clock on the first change of a pending run, and (re)arm the + /// debounce window. The exact count does not matter here — `SaveTrigger` + /// owns the volume threshold; this gate only bounds latency. + pub(super) fn record(&mut self) { + let now = Instant::now(); + if self.first_change_at.is_none() { + self.first_change_at = Some(now); + } + self.last_change_at = now; } - /// Evaluate the apply cadence. + /// Evaluate the debounce + max-wait gate. /// - /// **Returns** `true` (and resets the counters) when there is - /// buffered churn and at least `apply_interval` has elapsed since - /// the last apply / save. Returns `false` — without resetting — - /// otherwise, so a not-yet-due tick keeps accumulating. - pub(super) fn evaluate(&mut self, apply_interval: Duration) -> bool { - if self.events_since_apply == 0 { + /// **Returns** `true` (and clears the pending run) when changes are pending + /// AND either the burst has **settled** (no change for `debounce`) or the + /// run has been pending past `max_wait`. Returns `false` — without + /// clearing — otherwise, so an unsettled, not-yet-capped run keeps + /// accumulating. Must be evaluated every poll (including quiet ones) so the + /// settle can fire on the first quiet tick after a burst ends. + pub(super) fn evaluate(&mut self, debounce: Duration, max_wait: Duration) -> bool { + let Some(first) = self.first_change_at else { return false; - } + }; let now = Instant::now(); - if now.saturating_duration_since(self.last_apply_at) < apply_interval { - return false; + let settled = now.saturating_duration_since(self.last_change_at) >= debounce; + let capped = now.saturating_duration_since(first) >= max_wait; + if settled || capped { + self.first_change_at = None; + true + } else { + false } - self.last_apply_at = now; - self.events_since_apply = 0; - true } - /// Reset the trigger because a **save** tick just drained + applied - /// the buffer (a save subsumes an apply). Clears the churn guard - /// and restarts the interval clock so the loop doesn't fire a - /// redundant apply on the same buffer right after a save. - pub(super) fn reset_after_save(&mut self) { - self.last_apply_at = Instant::now(); - self.events_since_apply = 0; + /// Reset the trigger because a **save** tick just drained + applied the + /// buffer (a save subsumes an apply), so the loop doesn't redundantly + /// re-apply the just-drained run. + pub(super) const fn reset_after_save(&mut self) { + self.first_change_at = None; } } diff --git a/crates/uffs-daemon/src/cache/journal_sink.rs b/crates/uffs-daemon/src/cache/journal_sink.rs index c3163399d..7529f34ce 100644 --- a/crates/uffs-daemon/src/cache/journal_sink.rs +++ b/crates/uffs-daemon/src/cache/journal_sink.rs @@ -26,7 +26,7 @@ //! to the buffer synchronously (no mpsc traffic). `trigger_save` drains the //! buffer for that letter and ships the drained `Vec` into //! [`ApplyMsg::Save`] so the applier can run a *surgical* -//! [`crate::cache::ShardEntry::apply_usn_patch_to_body`] instead of a +//! [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] instead of a //! full [`uffs_core::compact_loader::load_drive_with_usn_refresh`]. //! `journal_wrapped` discards the buffer (a wrap means the journal //! head reset, so any pending events are stale relative to the new @@ -90,7 +90,7 @@ use crate::index::IndexManager; #[derive(Debug)] enum ApplyMsg { /// `trigger_save` callback — the applier runs a surgical - /// [`crate::cache::ShardEntry::apply_usn_patch_to_body`] over + /// [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] over /// the drained per-letter buffer, then `replace_warm_body` + /// `save_compact_cache_background`. The applier converts /// [`SaveReason`] to a stable diagnostic string @@ -114,7 +114,7 @@ enum ApplyMsg { }, /// `trigger_apply` callback — the short apply-cadence sibling of /// `Save`. The applier runs the same surgical - /// [`crate::cache::ShardEntry::apply_usn_patch_to_body`] + + /// [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] + /// `replace_warm_body` over the drained per-letter buffer so the /// in-memory body (and therefore search) goes near-live, but /// **skips** the compact-cache disk write and the cursor persist. @@ -202,12 +202,11 @@ impl RegistryPatchSink { /// /// 1. **Producer is sync-non-blocking by contract.** `accept` / /// `trigger_save` / `journal_wrapped` are `fn`, not `async fn` — invoked - /// synchronously from - /// [`crate::cache::journal_loop::JournalLoop::process_tick`]. They - /// cannot `.await` on a bounded `send`, so a bounded variant would have - /// to use `try_send` + drop-on-full, which is operationally identical to - /// the existing "dead applier silently absorbed" degraded path - /// (documented on `apply_tx`). + /// synchronously from the journal-loop `process_tick`. They cannot + /// `.await` on a bounded `send`, so a bounded variant would have to use + /// `try_send` + drop-on-full, which is operationally identical to the + /// existing "dead applier silently absorbed" degraded path (documented + /// on `apply_tx`). /// /// 2. **Producer cadence is throttled upstream by /// [`crate::cache::journal_loop::SaveTrigger`].** Save messages fire on diff --git a/crates/uffs-daemon/src/cache/mod.rs b/crates/uffs-daemon/src/cache/mod.rs index f42bab93e..64939e3d8 100644 --- a/crates/uffs-daemon/src/cache/mod.rs +++ b/crates/uffs-daemon/src/cache/mod.rs @@ -7,26 +7,27 @@ //! (`docs/refactor/memory-tiering-implementation-plan.md`). //! //! The cache layer wraps each loaded `DriveCompactIndex` in a -//! [`ShardEntry`] that carries: +//! [`crate::cache::shard::ShardEntry`] that carries: //! -//! * a tier state ([`ShardState`]) — Phase 1 pins everything to -//! [`ShardState::Warm`]; Phase 3 wires real transitions. -//! * per-drive query stats ([`DriveStats`]) — atomic counters plus an -//! exponentially-weighted moving average rate; consumed by the adaptive-TTL -//! formulas in Phase 6. +//! * a tier state ([`crate::cache::shard::ShardState`]) — Phase 1 pins +//! everything to [`crate::cache::shard::ShardState::Warm`]; Phase 3 wires +//! real transitions. +//! * per-drive query stats ([`crate::cache::shard::DriveStats`]) — atomic +//! counters plus an exponentially-weighted moving average rate; consumed by +//! the adaptive-TTL formulas in Phase 6. //! * the in-memory body, an `Arc` cloned cheaply into the //! per-search snapshot. //! -//! [`ShardRegistry`] is the top-level container that the daemon swaps -//! under `RwLock>` in place of the old +//! [`crate::cache::registry::ShardRegistry`] is the top-level container that +//! the daemon swaps under `RwLock>` in place of the old //! `Arc`. It maintains a //! cached `Arc` over the active (Warm/Hot) subset so the //! search hot path stays an `Arc::clone` away from a usable backend. //! //! Phase 1 keeps every shard in `Warm` so the active subset always //! matches the full registry; Phase 3 starts demoting and -//! [`ShardRegistry::active_index`] begins to diverge from the full -//! shard list. +//! [`crate::cache::registry::ShardRegistry::active_index`] begins to diverge +//! from the full shard list. pub(crate) mod background_io; pub(crate) mod body_loader; diff --git a/crates/uffs-daemon/src/cache/policy.rs b/crates/uffs-daemon/src/cache/policy.rs index d4a6b1cca..c48556ebb 100644 --- a/crates/uffs-daemon/src/cache/policy.rs +++ b/crates/uffs-daemon/src/cache/policy.rs @@ -217,7 +217,7 @@ pub(crate) fn usn_refresh_interval_secs() -> u64 { /// All three fields are seconds (`u64`) for direct comparison /// against `idle_secs = (now_ms - last_query_at_ms) / 1000`; the /// shared `_secs` postfix is the unit, not name redundancy, hence -/// the [`clippy::struct_field_names`] expectation. +/// the `clippy::struct_field_names` expectation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[expect( clippy::struct_field_names, diff --git a/crates/uffs-daemon/src/cache/prefetch.rs b/crates/uffs-daemon/src/cache/prefetch.rs index ddaef4f78..251483027 100644 --- a/crates/uffs-daemon/src/cache/prefetch.rs +++ b/crates/uffs-daemon/src/cache/prefetch.rs @@ -19,7 +19,7 @@ //! The trait is held by [`crate::index::IndexManager`] as //! `Arc` so production wires the platform impl and //! the Phase 5 unit tests inject a recording fake (see -//! [`tests::RecordingPrefetch`]) to assert the hook fires with the +//! `tests::RecordingPrefetch`) to assert the hook fires with the //! right (records, names) regions on every promote. use std::io; diff --git a/crates/uffs-daemon/src/cache/pressure.rs b/crates/uffs-daemon/src/cache/pressure.rs index 35ce559e9..aab8ff4b4 100644 --- a/crates/uffs-daemon/src/cache/pressure.rs +++ b/crates/uffs-daemon/src/cache/pressure.rs @@ -34,7 +34,7 @@ //! [`IndexManager`][im] holds the trait as //! `Arc`. Production wires //! [`PlatformPressureSignal`]; the Phase 5 unit tests inject -//! [`tests::ControllablePressureSignal`] so the test can `set(Low)` / +//! `tests::ControllablePressureSignal` so the test can `set(Low)` / //! `set(High)` and assert the cascade behaviour deterministically //! without any real OS pressure. //! @@ -58,8 +58,8 @@ use tokio::sync::watch; /// `Low` and `High` are **platform-conditional** — they only exist /// on Windows (where the kernel's /// `LowMemoryResourceNotification` / `HighMemoryResourceNotification` -/// surface them via [`windows_handles::watcher_loop`]) and under -/// `cfg(test)` (so [`tests::ControllablePressureSignal`] can drive +/// surface them via `windows_handles::watcher_loop`) and under +/// `cfg(test)` (so `tests::ControllablePressureSignal` can drive /// deterministic transitions on every host). Mac/Linux production /// builds expose only `Normal`: there is no portable process-wide /// memory-resource-notification API on those targets, and the @@ -81,16 +81,16 @@ pub(crate) enum PressureLevel { /// Subscriber cascade-demotes LRU Warm shards. /// /// Only present on Windows production builds (constructed by - /// [`windows_handles::watcher_loop`]) and under `cfg(test)` - /// (constructed by [`tests::ControllablePressureSignal`]). + /// `windows_handles::watcher_loop`) and under `cfg(test)` + /// (constructed by `tests::ControllablePressureSignal`). #[cfg(any(target_os = "windows", test))] Low, /// Free RAM has risen back above the kernel's high-memory /// threshold; pressure cleared. Subscriber stops the cascade. /// /// Only present on Windows production builds (constructed by - /// [`windows_handles::watcher_loop`]) and under `cfg(test)` - /// (constructed by [`tests::ControllablePressureSignal`]). + /// `windows_handles::watcher_loop`) and under `cfg(test)` + /// (constructed by `tests::ControllablePressureSignal`). #[cfg(any(target_os = "windows", test))] High, } diff --git a/crates/uffs-daemon/src/cache/registry.rs b/crates/uffs-daemon/src/cache/registry.rs index 4394c3b90..d2fd1ff87 100644 --- a/crates/uffs-daemon/src/cache/registry.rs +++ b/crates/uffs-daemon/src/cache/registry.rs @@ -579,7 +579,7 @@ impl ShardRegistry { /// the next dispatch). /// /// Wired into the production refresh path by - /// [`crate::index::IndexManager::refresh_usn_for_warm_shards`]. + /// [`crate::spawn_journal_loops_for_warm_shards`]. #[must_use] pub(crate) fn replace_warm_body( &self, diff --git a/crates/uffs-daemon/src/cache/shard.rs b/crates/uffs-daemon/src/cache/shard.rs index 3789ee112..d6fda3b25 100644 --- a/crates/uffs-daemon/src/cache/shard.rs +++ b/crates/uffs-daemon/src/cache/shard.rs @@ -534,7 +534,9 @@ impl ShardEntry { // body is fully mutable without remap ceremony. The // `frs_to_compact` mapping rides along on the clone so // `apply_usn_patch` can patch it in lock-step with the - // records. + // records. Phase 3 (incremental-index-maintenance) Arc-shares the + // immutable base CSR indexes, so this clone copies records + names + + // the small delta, not the hundreds-of-MB inverted indexes. let mut owned: DriveCompactIndex = (**body_arc).clone(); let stats = uffs_core::compact_loader::apply_usn_patch(&mut owned, changes); Some((Arc::new(owned), stats)) diff --git a/crates/uffs-daemon/src/cache/shard/drive_stats.rs b/crates/uffs-daemon/src/cache/shard/drive_stats.rs index 2ff8ec633..245ce4400 100644 --- a/crates/uffs-daemon/src/cache/shard/drive_stats.rs +++ b/crates/uffs-daemon/src/cache/shard/drive_stats.rs @@ -16,7 +16,7 @@ //! persistence (`AtomicU64` does not derive `Serialize`/`Deserialize`). //! * The two `From` impls that move data between the live atomics and the //! snapshot. -//! * The test-only [`drive_stats_ema_value`] reader, which exists so the +//! * The test-only `drive_stats_ema_value` reader, which exists so the //! production `impl DriveStats` block carries no `#[cfg(test)]`-gated //! methods. //! @@ -209,7 +209,7 @@ impl DriveStats { /// Surfaced via the /// [`uffs_client::protocol::response::StatusDrivesResponse`] wire /// format's `promotions_total` field by - /// [`crate::index::status_drives::IndexManager::status_drives`]. + /// [`crate::index::IndexManager::status_drives`]. #[must_use] pub(crate) fn promotions_total(&self) -> u64 { self.promotions_total.load(Ordering::Relaxed) diff --git a/crates/uffs-daemon/src/cache/shard/tests.rs b/crates/uffs-daemon/src/cache/shard/tests.rs index 93296d275..17f992d7d 100644 --- a/crates/uffs-daemon/src/cache/shard/tests.rs +++ b/crates/uffs-daemon/src/cache/shard/tests.rs @@ -89,9 +89,9 @@ fn make_test_body(letter: uffs_mft::platform::DriveLetter) -> DriveCompactIndex letter, records: ColumnStorage::from_vec(records), names: ColumnStorage::from_vec(names), - trigram, - children, - ext_index, + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), fold, ext_names: vec![Box::from("")], source: IndexSource::MftFile(PathBuf::from(format!("{letter}:"))), @@ -99,6 +99,7 @@ fn make_test_body(letter: uffs_mft::platform::DriveLetter) -> DriveCompactIndex bloom: None, path_trie: None, frs_to_compact, + delta: None, } } diff --git a/crates/uffs-daemon/src/cache/working_set.rs b/crates/uffs-daemon/src/cache/working_set.rs index 559346da9..2a15d6c0a 100644 --- a/crates/uffs-daemon/src/cache/working_set.rs +++ b/crates/uffs-daemon/src/cache/working_set.rs @@ -16,7 +16,7 @@ //! The trait is held by [`crate::index::IndexManager`] as //! `Arc` so production wires the platform impl //! and the Phase 5 unit tests inject a counting fake (see -//! [`tests::CountingWorkingSetTrim`]) to assert the hook fires +//! `tests::CountingWorkingSetTrim`) to assert the hook fires //! exactly once per demote batch. use std::io; diff --git a/crates/uffs-daemon/src/config.rs b/crates/uffs-daemon/src/config.rs index 101022096..f4bfb67b6 100644 --- a/crates/uffs-daemon/src/config.rs +++ b/crates/uffs-daemon/src/config.rs @@ -40,7 +40,7 @@ //! //! * **Commit B (this file):** types + serde + parser + tests. No callers //! wired yet. -//! * **Commit C (next):** wire [`Config::load_from_path`] into +//! * **Commit C (next):** wire [`crate::config::Config::load_from_path`] into //! `crate::run_daemon` startup; replace `cache::policy`'s static getters with //! config-driven readers; pass `TierThresholds` into //! [`crate::cache::policy::next_state_for_idle_with_thresholds`] from @@ -48,7 +48,7 @@ //! //! ## Defaults //! -//! [`Config::default()`] matches the Phase-3 static behavior +//! [`crate::config::Config::default()`] matches the Phase-3 static behavior //! (plan task 6.8): missing `daemon.toml` ⇒ same idle thresholds as //! the bare [`crate::cache::policy`] module. Production users opt //! into longer retention or per-drive constraints by writing an @@ -427,7 +427,7 @@ impl Config { /// practice on every supported platform the answer is `Some`. /// /// Mirrors the conventions used by [`crate::ipc::IpcServer::socket_path`] - /// and [`crate::default_log_file`] so the `daemon.toml` lives + /// and [`crate::log_init::default_log_file`] so the `daemon.toml` lives /// alongside the lifecycle / log artifacts the daemon already /// writes there. #[must_use] @@ -456,7 +456,7 @@ impl Config { } } -/// Errors surfaced by [`Config::load_from_path`] / +/// Errors surfaced by [`crate::config::Config::load_from_path`] / /// [`Config::from_toml`] / [`Config::to_toml`]. #[derive(Debug, thiserror::Error)] pub(crate) enum ConfigError { diff --git a/crates/uffs-daemon/src/handler.rs b/crates/uffs-daemon/src/handler.rs index 585af6f1a..9a8378dcf 100644 --- a/crates/uffs-daemon/src/handler.rs +++ b/crates/uffs-daemon/src/handler.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! JSON-RPC request handler: dispatches methods to [`IndexManager`]. +//! JSON-RPC request handler: dispatches methods to +//! [`crate::index::IndexManager`]. use uffs_client::protocol::response::{ DEFAULT_PRELOAD_PIN_MINUTES, FacetValuesParams, FacetValuesResponse, ForgetParams, diff --git a/crates/uffs-daemon/src/handler_blob.rs b/crates/uffs-daemon/src/handler_blob.rs index c2d77e369..a73452649 100644 --- a/crates/uffs-daemon/src/handler_blob.rs +++ b/crates/uffs-daemon/src/handler_blob.rs @@ -168,7 +168,8 @@ impl RequestHandler { /// case-insensitively. Multi-column projections, aggregation /// requests, projected-JSON mode, and custom sort clauses all /// disqualify the fast path — the response must still carry the - /// full [`SearchRow`] data for the CLI's row-based formatters. + /// full [`uffs_client::protocol::response::SearchRow`] data for the CLI's + /// row-based formatters. /// /// Also requires the caller to have explicitly opted into a /// text-shaped payload via [`Self::caller_opted_into_blob_payload`] diff --git a/crates/uffs-daemon/src/index/aggregation.rs b/crates/uffs-daemon/src/index/aggregation.rs index 2522f9a57..a0435495c 100644 --- a/crates/uffs-daemon/src/index/aggregation.rs +++ b/crates/uffs-daemon/src/index/aggregation.rs @@ -233,7 +233,7 @@ pub(crate) struct AggregationRequest<'a> { pub drives_filter: &'a [uffs_mft::platform::DriveLetter], /// O(1)-per-record predicates: extension IDs, directory flag, /// size bounds. Defaults to "no filter" via - /// [`AggregateFilter::default`]. + /// [`uffs_core::aggregate::AggregateFilter::default`]. pub record_filter: uffs_core::aggregate::AggregateFilter, } @@ -415,8 +415,9 @@ impl IndexManager { /// /// The key mixes every input that can change the computed /// [`uffs_core::aggregate::AggregateOutput`]: - /// - `specs` — the compiled list of [`AggregateSpec`]s, including every - /// `kind`, `label`, `top`, sample spec, and rollup field. + /// - `specs` — the compiled list of + /// [`uffs_core::aggregate::AggregateSpec`]s, including every `kind`, + /// `label`, `top`, sample spec, and rollup field. /// - `pattern` — glob/regex name matcher (`None` vs. `Some("")` are /// distinguished by `Option::hash`). /// - `drives_filter` — the set of drive letters to scope the scan. @@ -549,7 +550,7 @@ impl IndexManager { // Each `AggregateResultData` arm gets its own `wire_*` helper that // returns the 9-tuple consumed by `apply_pagination_and_finalize`. -/// Convert a fully-resolved [`AggregateOutput::response::results`] +/// Convert a fully-resolved [`uffs_core::aggregate::AggregateOutput`]'s results /// into the wire format expected by clients, applying cursor-based /// pagination per result-index. fn convert_aggregate_results_to_wire( @@ -583,8 +584,9 @@ fn convert_aggregate_results_to_wire( .collect() } -/// Build the [`AggregateResultWire`] for a single result, dispatching -/// to one of the per-kind `wire_*` helpers and applying pagination. +/// Build the [`uffs_client::protocol::aggregate_wire::AggregateResultWire`] for +/// a single result, dispatching to one of the per-kind `wire_*` helpers and +/// applying pagination. fn build_aggregate_result_wire( result: uffs_core::aggregate::finalize::AggregateResult, pagination: Option<&uffs_core::aggregate::pagination::PaginatedBuckets>, @@ -655,7 +657,8 @@ type AggregateWireTuple = ( Option, ); -/// Wire builder for [`AggregateResultData::Count`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Count`]. fn wire_count(value: u64) -> AggregateWireTuple { ( "count".to_owned(), @@ -670,7 +673,8 @@ fn wire_count(value: u64) -> AggregateWireTuple { ) } -/// Wire builder for [`AggregateResultData::Stats`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Stats`]. fn wire_stats( field: String, stats: &uffs_core::aggregate::finalize::StatsResult, @@ -697,7 +701,8 @@ fn wire_stats( ) } -/// Wire builder for [`AggregateResultData::Buckets`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Buckets`]. fn wire_buckets( field: String, rows: Vec, @@ -740,7 +745,8 @@ fn wire_buckets( ) } -/// Wire builder for [`AggregateResultData::Missing`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Missing`]. fn wire_missing(field: String, count: u64) -> AggregateWireTuple { ( "missing".to_owned(), @@ -755,7 +761,8 @@ fn wire_missing(field: String, count: u64) -> AggregateWireTuple { ) } -/// Wire builder for [`AggregateResultData::Distinct`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Distinct`]. fn wire_distinct(field: String, count: u64) -> AggregateWireTuple { ( "distinct".to_owned(), @@ -770,7 +777,8 @@ fn wire_distinct(field: String, count: u64) -> AggregateWireTuple { ) } -/// Wire builder for [`AggregateResultData::Rollup`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Rollup`]. fn wire_rollup( mode: String, rows: Vec, @@ -827,11 +835,12 @@ fn wire_rollup( ) } -/// Wire builder for [`AggregateResultData::Duplicates`]. +/// Wire builder for +/// [`uffs_core::aggregate::finalize::AggregateResultData::Duplicates`]. /// /// Materialises sample rows from the daemon-side compact index, then -/// builds a summary [`StatsWire`] mirroring the verifier's view of the -/// duplicate set. +/// builds a summary [`uffs_client::protocol::aggregate_wire::StatsWire`] +/// mirroring the verifier's view of the duplicate set. #[expect( clippy::float_arithmetic, reason = "percentage calculation for waste_pct" diff --git a/crates/uffs-daemon/src/index/constructors.rs b/crates/uffs-daemon/src/index/constructors.rs index 1b337bf91..9ed07abdf 100644 --- a/crates/uffs-daemon/src/index/constructors.rs +++ b/crates/uffs-daemon/src/index/constructors.rs @@ -3,24 +3,24 @@ //! [`IndexManager`] constructors. //! -//! Four entry points share the inner [`Self::new_with_lifecycle_hooks`] +//! Four entry points share the inner [`IndexManager::new_with_lifecycle_hooks`] //! builder so the field-initialization list lives in one place: //! -//! 1. [`Self::new`] — production constructor that takes an explicit +//! 1. [`IndexManager::new`] — production constructor that takes an explicit //! [`Arc`](crate::config::Config). Used by [`crate::run_daemon`] //! after [`crate::config::Config::load_default`] resolves the config (Phase //! 6 Commit C). Tests that don't care about adaptive-TTL behaviour pass //! `Arc::new(crate::config::Config::default())` and get the //! Phase-3-equivalent ladder. -//! 2. [`Self::new_with_lifecycle_hooks`] — module-private builder that takes -//! every hook bundled into a [`LifecycleHooks`] struct plus the config. -//! Production paths reach this via [`Self::new`]; tests reach it via the -//! `_for_test` variants below. -//! 3. [`Self::with_body_loader_for_test`] — test-only entry point that swaps in -//! a custom body loader and keeps the platform defaults for the other hooks -//! (Phase 4 Commit E + earlier). -//! 4. [`Self::with_lifecycle_hooks_for_test`] — test-only entry point that -//! swaps every lifecycle hook (Phase 5) and accepts an explicit +//! 2. [`IndexManager::new_with_lifecycle_hooks`] — module-private builder that +//! takes every hook bundled into a [`LifecycleHooks`] struct plus the +//! config. Production paths reach this via [`IndexManager::new`]; tests +//! reach it via the `_for_test` variants below. +//! 3. `IndexManager::with_body_loader_for_test` — test-only entry point that +//! swaps in a custom body loader and keeps the platform defaults for the +//! other hooks (Phase 4 Commit E + earlier). +//! 4. `IndexManager::with_lifecycle_hooks_for_test` — test-only entry point +//! that swaps every lifecycle hook (Phase 5) and accepts an explicit //! `Arc` (Phase 6). //! //! The hooks themselves live in a [`LifecycleHooks`] struct so the @@ -49,7 +49,7 @@ use crate::events::EventSender; /// Reduces the constructor surface from five trait-object `Arc` /// parameters to a single struct. This (a) keeps the constructor /// signatures under clippy's 7-argument ceiling and (b) lets test -/// code build [`Self::production`] then override only the +/// code build [`LifecycleHooks::production`] then override only the /// hook(s) the test cares about, e.g. /// /// ```ignore @@ -76,7 +76,7 @@ pub(crate) struct LifecycleHooks { pub(crate) background_io: Arc, /// Per-drive cache-file cleanup hook (Phase 8-D `forget` RPC). /// Production uses [`crate::cache::cache_cleaner::PlatformCacheCleaner`]; - /// tests inject [`crate::cache::cache_cleaner::CountingCacheCleaner`] + /// tests inject a test-only `CountingCacheCleaner` /// so registry-eviction behaviour can be verified without /// touching the host's real cache directory. pub(crate) cache_cleaner: Arc, @@ -85,8 +85,8 @@ pub(crate) struct LifecycleHooks { impl LifecycleHooks { /// Production hook bundle — every hook wired to its /// `crate::cache::*::Platform*` impl. Used by - /// [`IndexManager::new`] / [`IndexManager::new_with_config`] and - /// as the spread base for the `_for_test` constructors when a + /// [`IndexManager::new`] (via [`IndexManager::new_with_lifecycle_hooks`]) + /// and as the spread base for the `_for_test` constructors when a /// test only needs to override one hook. #[must_use] pub(crate) fn production() -> Self { @@ -110,7 +110,7 @@ impl IndexManager { /// `Arc::new(crate::config::Config::default())` and get the /// Phase-3-equivalent ladder; tests that exercise per-drive /// `min_tier` overrides or non-default `TiersConfig` values - /// reach for [`Self::with_lifecycle_hooks_for_test`] so they + /// reach for the test-only `with_lifecycle_hooks_for_test` so they /// can also inject counting / recording fakes. #[must_use] pub(crate) fn new( @@ -126,9 +126,9 @@ impl IndexManager { /// background-I/O priority) plus the parsed /// [`Config`](crate::config::Config). /// - /// Production code calls [`Self::new`] / [`Self::new_with_config`] - /// which wire the platform impls; the Phase 5 / Phase 6 unit tests - /// use this path through [`Self::with_lifecycle_hooks_for_test`] + /// Production code calls [`IndexManager::new`] + /// which wires the platform impls; the Phase 5 / Phase 6 unit tests + /// use this path through the test-only `with_lifecycle_hooks_for_test` /// to inject counting / recording / controllable fakes without /// touching the platform cache directory, the process working set, /// or the OS pressure-notification API, and to exercise per-drive diff --git a/crates/uffs-daemon/src/index/dispatch.rs b/crates/uffs-daemon/src/index/dispatch.rs index 56c9b24f0..0bcda38b8 100644 --- a/crates/uffs-daemon/src/index/dispatch.rs +++ b/crates/uffs-daemon/src/index/dispatch.rs @@ -6,19 +6,20 @@ //! Two pre-search hooks plus the single-flight promote machinery //! they share live here: //! -//! * [`Self::record_search_dispatch`] — stamp `last_query_at_ms` on every -//! Warm/Hot shard so the demote controller sees a fresh idle clock. Phase 3 -//! Commit C wired this; Phase 6 reads the same timestamp via +//! * [`IndexManager::record_search_dispatch`] — stamp `last_query_at_ms` on +//! every Warm/Hot shard so the demote controller sees a fresh idle clock. +//! Phase 3 Commit C wired this; Phase 6 reads the same timestamp via //! `DriveStats::decay_ema_qpm` for the adaptive-TTL formulas. -//! * [`Self::ensure_warm_for_dispatch`] — promote any Parked/Cold shards the -//! search will touch, before [`Self::snapshot`] reads the active subset. -//! Three-phase detect → load → swap orchestration with the bloom pre-check -//! (Phase 4 Commit F) and the per-letter single-flight dedup (PR-e). +//! * [`IndexManager::ensure_warm_for_dispatch`] — promote any Parked/Cold +//! shards the search will touch, before [`IndexManager::snapshot`] reads the +//! active subset. Three-phase detect → load → swap orchestration with the +//! bloom pre-check (Phase 4 Commit F) and the per-letter single-flight dedup +//! (PR-e). //! -//! The single-flight machinery — [`Self::load_or_join_in_flight`], -//! [`Self::install_or_join_in_flight_slot`], -//! [`Self::build_load_future`], and the -//! [`Self::bloom_pre_check_should_promote`] decision helper — +//! The single-flight machinery — [`IndexManager::load_or_join_in_flight`], +//! [`IndexManager::install_or_join_in_flight_slot`], +//! [`IndexManager::build_load_future`], and the +//! [`IndexManager::bloom_pre_check_should_promote`] decision helper — //! lives in this module too because callers and helpers form a //! single cohesive cluster: the dedup-map slot manager spawns //! the body-load future, the body-load future drives the @@ -53,7 +54,7 @@ impl IndexManager { /// [`crate::cache::shard::DriveStats::mark_query_at`] so the /// same hot-path write also stores the dispatch timestamp in /// `last_query_at_ms`; the demote controller in - /// [`Self::demote_idle_shards`] reads that timestamp to + /// [`IndexManager::demote_idle_shards`] reads that timestamp to /// compute `idle_secs`. Phase 6 additionally feeds the EMA /// the adaptive-TTL formulas use — see /// [`crate::cache::shard::DriveStats::decay_ema_qpm`]. @@ -72,7 +73,7 @@ impl IndexManager { /// Phase 3 Commit C — promote any Parked/Cold shards that this /// search will dispatch to, before - /// [`Self::snapshot`] reads the active subset. + /// [`IndexManager::snapshot`] reads the active subset. /// /// Three-phase orchestrator (read-detect → spawn-blocking /// load → write-swap) — see implementation comments below. @@ -131,7 +132,7 @@ impl IndexManager { // ── Phase 2: per-letter parallel body load with single-flight dedup ─ // For each Parked/Cold letter, drive one - // [`Self::load_or_join_in_flight`] call in parallel via a + // [`IndexManager::load_or_join_in_flight`] call in parallel via a // [`futures::stream::FuturesUnordered`]. The helper // performs the I/O + decrypt + decompress + runtime-mmap // materialisation inside its inner `tokio::task::spawn_blocking` @@ -181,7 +182,7 @@ impl IndexManager { // body Arc and leaves the canonical registry alone. // // The `JoinError` arm of the pre-PR-e implementation moved - // inside [`Self::load_or_join_in_flight`]'s inner + // inside [`IndexManager::load_or_join_in_flight`]'s inner // spawn-blocking handling: aborts surface as `None` here // with the same `shard.transition` warning. No outcome is // observably different. @@ -265,7 +266,7 @@ impl IndexManager { /// /// Takes Arc fields explicitly (rather than `&self`) so the /// helper can be called from a `FuturesUnordered`-driven - /// `async move` block in [`Self::ensure_warm_for_dispatch`] + /// `async move` block in [`IndexManager::ensure_warm_for_dispatch`] /// without forcing the surrounding loop to hold a borrow of /// `self` across the await. pub(super) async fn load_or_join_in_flight( @@ -282,7 +283,7 @@ impl IndexManager { fut.await } - /// Synchronous slot manager for [`Self::load_or_join_in_flight`]. + /// Synchronous slot manager for [`IndexManager::load_or_join_in_flight`]. /// /// Either returns a clone of the existing per-letter /// [`InFlightLoad`] (the second-and-onwards concurrent caller @@ -359,7 +360,7 @@ impl IndexManager { /// /// This is the future that gets wrapped in /// [`futures::future::Shared`] and stored in the in-flight - /// slot map by [`Self::load_or_join_in_flight`]. Factored out + /// slot map by [`IndexManager::load_or_join_in_flight`]. Factored out /// for readability — the inner async block carries non-trivial /// panic-recovery + prefetch-hint logic that would otherwise /// drown the dedup machinery in noise. diff --git a/crates/uffs-daemon/src/index/forget_drive.rs b/crates/uffs-daemon/src/index/forget_drive.rs index d9259b42d..8cbdf8241 100644 --- a/crates/uffs-daemon/src/index/forget_drive.rs +++ b/crates/uffs-daemon/src/index/forget_drive.rs @@ -7,8 +7,8 @@ //! cleanup half ([`crate::cache::cache_cleaner::CacheCleaner`]). //! //! Three-phase orchestration mirrors the -//! [`super::tiering_ops::IndexManager::hibernate_shards`] / -//! [`super::tiering_ops::IndexManager::preload_drive`] pattern: +//! [`crate::index::IndexManager::hibernate_shards`] / +//! [`crate::index::IndexManager::preload_drive`] pattern: //! //! 1. **Read-lock detect.** A single `self.index.read()` enumerates the //! `(letter, current_tier)` tuples for every drive in the request. If @@ -17,7 +17,8 @@ //! [`uffs_client::protocol::ERR_DRIVE_BUSY`] so a typo on one of five drives //! doesn't accidentally forget the other four. //! 2. **Optional auto-hibernate (force only).** Each non-`Cold` drive is -//! demoted to `Cold` via [`super::IndexManager::demote_letter_with_reason`] +//! demoted to `Cold` via +//! [`crate::cache::registry::ShardRegistry::demote_letter_with_reason`] //! tagged with [`crate::cache::registry::DemoteReason::OperatorHibernate`]. //! The pin is cleared as a side effect (the rebuilt `ShardEntry` starts with //! `pin_until_ms = 0`). @@ -42,9 +43,9 @@ use crate::cache::{ShardState, unix_now_ms}; /// Outcome of a successful [`IndexManager::forget_drives`] call. /// -/// Each drive in the input request lands in exactly one of -/// [`Self::forgotten`] or [`Self::already_absent`] (unless an I/O -/// error pushed it into [`Self::errors`] only). Mirrors the +/// Each drive in the input request lands in exactly one of the response's +/// `forgotten` or `already_absent` lists (unless an I/O error pushed it into +/// `errors` only). Mirrors the /// [`uffs_client::protocol::response::ForgetResponse`] wire shape so /// the handler can build the wire response with one /// `serde::Serialize` call. diff --git a/crates/uffs-daemon/src/index/hotload.rs b/crates/uffs-daemon/src/index/hotload.rs index f31e8332f..858ddaef5 100644 --- a/crates/uffs-daemon/src/index/hotload.rs +++ b/crates/uffs-daemon/src/index/hotload.rs @@ -6,22 +6,22 @@ //! Two distinct entry points cover the runtime "load this drive //! now" surface: //! -//! 1. [`Self::load_single_mft_file`] — a path-based hot-load. Used by the `add` -//! RPC and the file-watcher integration in `crate::lifecycle` when an -//! operator drops a new `*.mft` snapshot into `data_dir`. Skips if the +//! 1. [`IndexManager::load_single_mft_file`] — a path-based hot-load. Used by +//! the `add` RPC and the file-watcher integration in `crate::lifecycle` when +//! an operator drops a new `*.mft` snapshot into `data_dir`. Skips if the //! drive is already loaded (no replace). -//! 2. [`Self::hot_load_drive`] — a letter-based hot-load. Used by the `load` -//! RPC. On Windows reads the live MFT directly; on Mac/Linux looks for a -//! snapshot under `data_dir/drive_X/`. Replaces an already-loaded drive -//! (the operator wants a re-read). +//! 2. [`IndexManager::hot_load_drive`] — a letter-based hot-load. Used by the +//! `load` RPC. On Windows reads the live MFT directly; on Mac/Linux looks +//! for a snapshot under `data_dir/drive_X/`. Replaces an already-loaded +//! drive (the operator wants a re-read). //! //! Both paths share the per-drive blocking-load helper -//! [`Self::blocking_load_drive`] which wraps +//! [`IndexManager::blocking_load_drive`] which wraps //! [`uffs_core::compact::load_drive`] in `spawn_blocking` and //! reclaims allocator pages on completion. Auto-discovery from //! the data directory is provided by -//! [`Self::discover_and_load_drive`] / -//! [`Self::ensure_drives_loaded`] so the search RPC can +//! [`IndexManager::discover_and_load_drive`] / +//! [`IndexManager::ensure_drives_loaded`] so the search RPC can //! transparently load drives the user named but didn't //! pre-mount. diff --git a/crates/uffs-daemon/src/index/info.rs b/crates/uffs-daemon/src/index/info.rs index 8de319f01..861e8433d 100644 --- a/crates/uffs-daemon/src/index/info.rs +++ b/crates/uffs-daemon/src/index/info.rs @@ -20,14 +20,14 @@ //! The four functions in this module form one cohesive //! pipeline: //! -//! 1. [`Self::info`] — the async public entry point. Snapshots the registry, -//! hands the snapshot to the synchronous tree-walk, and wraps the resulting -//! `Option` in [`InfoResponse`]. -//! 2. [`Self::info_tree_lookup`] — the synchronous walker. Drives the parse + -//! segment-by-segment match. -//! 3. [`Self::parse_drive_prefix`] — helper that splits `"C:\\foo"` into -//! `(uffs_mft::platform::DriveLetter::C, "foo")`. -//! 4. [`Self::build_info_json`] — turns a matching +//! 1. [`IndexManager::info`] — the async public entry point. Snapshots the +//! registry, hands the snapshot to the synchronous tree-walk, and wraps the +//! resulting `Option` in [`InfoResponse`]. +//! 2. [`IndexManager::info_tree_lookup`] — the synchronous walker. Drives the +//! parse + segment-by-segment match. +//! 3. [`IndexManager::parse_drive_prefix`] — helper that splits `"C:\\foo"` +//! into `(uffs_mft::platform::DriveLetter::C, "foo")`. +//! 4. [`IndexManager::build_info_json`] — turns a matching //! [`uffs_core::compact::CompactRecord`] into the JSON payload the response //! carries. //! @@ -109,9 +109,7 @@ impl IndexManager { return Some(Self::build_info_json(drive, rec, &resolved)); } // Collect children for next segment. - next_candidates.extend_from_slice( - drive.children.get(uffs_mft::u32_as_usize(root_idx)), - ); + next_candidates.extend_from_slice(&drive.children_of(root_idx)); } } } @@ -130,9 +128,7 @@ impl IndexManager { ); return Some(Self::build_info_json(drive, rec, &resolved)); } - next_candidates.extend_from_slice( - drive.children.get(uffs_mft::u32_as_usize(child_idx)), - ); + next_candidates.extend_from_slice(&drive.children_of(child_idx)); } } } diff --git a/crates/uffs-daemon/src/index/journal.rs b/crates/uffs-daemon/src/index/journal.rs index d536b0ca9..5bcc70663 100644 --- a/crates/uffs-daemon/src/index/journal.rs +++ b/crates/uffs-daemon/src/index/journal.rs @@ -10,8 +10,8 @@ //! * [`IndexManager::handle_journal_save`] (Phase 8) — clones the warm //! `DriveCompactIndex` body, applies the buffered //! [`uffs_mft::usn::FileChange`] batch via -//! [`crate::cache::ShardEntry::apply_usn_patch_to_body`], swaps the new Arc -//! into the registry, and persists via +//! [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`], swaps the +//! new Arc into the registry, and persists via //! [`uffs_core::compact_cache::save_compact_cache_background`]. Fast path: //! ~600 ms patch + ~5 s background save on a 7M-record drive, vs ~7 s for the //! full reload. @@ -129,14 +129,14 @@ impl IndexManager { /// Phase 8 surgical-patch path: clone the Warm body, apply the /// drained per-letter [`FileChange`] batch via - /// [`crate::cache::ShardEntry::apply_usn_patch_to_body`], swap + /// [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`], swap /// the new Arc into the registry, and persist the patched body /// via [`uffs_core::compact_cache::save_compact_cache_background`]. /// /// Called by the [`crate::cache::journal_sink::RegistryPatchSink`] /// applier task on every `Save` message (events-exceeded / /// age-elapsed) — the *fast* counterpart to - /// [`Self::handle_journal_refresh`] (which the applier still + /// [`IndexManager::handle_journal_refresh`] (which the applier still /// uses for `Wrap` messages where the cursor reset invalidates /// the buffered batch). /// @@ -324,7 +324,7 @@ impl IndexManager { /// Per-letter write-lock swap of a freshly-loaded body Arc. /// - /// Extracted from [`Self::handle_journal_refresh`] so the parent + /// Extracted from [`IndexManager::handle_journal_refresh`] so the parent /// stays under clippy's strict-gate cognitive-complexity ceiling. /// `replace_warm_body` returns `None` when the shard demoted to /// `Parked` / `Cold` between the threshold trigger and this swap @@ -493,7 +493,7 @@ enum BodyApplyOutcome { /// (rightly) flags as a smell. enum PatchTaskOutcome { /// The task ran to completion and the shard's - /// [`crate::cache::ShardEntry::apply_usn_patch_to_body`] returned + /// [`crate::cache::shard::ShardEntry::apply_usn_patch_to_body`] returned /// a fresh body Arc + per-batch stats. Caller swaps the body /// into the registry + spawns a background cache save. Applied( diff --git a/crates/uffs-daemon/src/index/loading.rs b/crates/uffs-daemon/src/index/loading.rs index 9eda05512..27063da48 100644 --- a/crates/uffs-daemon/src/index/loading.rs +++ b/crates/uffs-daemon/src/index/loading.rs @@ -10,13 +10,12 @@ //! file the operator passed via `--data-dir` on its own blocking thread; //! when every file has been processed the daemon emits `DaemonReady`. //! 2. **`load_live_drives`** (Windows-only) — online mode. Loads each NTFS -//! volume's live MFT in parallel, capped per-drive by -//! [`Self::DRIVE_LOAD_TIMEOUT`] so a single stuck drive can't hang the -//! daemon indefinitely. +//! volume's live MFT in parallel, capped per-drive by `DRIVE_LOAD_TIMEOUT` +//! so a single stuck drive can't hang the daemon indefinitely. //! -//! Both paths funnel successful loads through [`Self::add_drive`] +//! Both paths funnel successful loads through [`IndexManager::add_drive`] //! which performs the atomic registry pointer-swap and bumps the -//! aggregate cache's `index_version`. [`Self::replace_drive`] +//! aggregate cache's `index_version`. [`IndexManager::replace_drive`] //! lives in this module too because it mirrors `add_drive`'s swap //! semantics — used by the refresh path to update an //! already-loaded drive in place. @@ -211,9 +210,9 @@ impl IndexManager { /// accurate incremental progress and cutting total wall time from /// `sum(per-drive)` to `max(per-drive)`. /// - /// Each drive has a [`Self::DRIVE_LOAD_TIMEOUT`] — if exceeded the drive - /// is skipped and an error is logged. This prevents a single stuck - /// volume from making the daemon unkillable. + /// Each drive has a [`crate::index::IndexManager::DRIVE_LOAD_TIMEOUT`] — if + /// exceeded the drive is skipped and an error is logged. This prevents + /// a single stuck volume from making the daemon unkillable. #[cfg(windows)] pub(crate) async fn load_live_drives( &self, @@ -313,8 +312,9 @@ impl IndexManager { } /// Drain `join_set` until every drive task finishes or any single - /// task overruns [`Self::DRIVE_LOAD_TIMEOUT`]. Each completion - /// updates the daemon status so clients see incremental progress. + /// task overruns [`crate::index::IndexManager::DRIVE_LOAD_TIMEOUT`]. Each + /// completion updates the daemon status so clients see incremental + /// progress. #[cfg(windows)] async fn collect_drive_load_results( &self, @@ -457,7 +457,7 @@ impl IndexManager { } /// Emit the post-load `DaemonReady` event and the cumulative heap - /// summary. Extracted to keep [`Self::load_live_drives`] flat. + /// summary. Extracted to keep [`IndexManager::load_live_drives`] flat. #[cfg(windows)] async fn emit_daemon_ready_summary(&self) { let snap = self.snapshot().await; diff --git a/crates/uffs-daemon/src/index/mod.rs b/crates/uffs-daemon/src/index/mod.rs index 9d76ab62f..7e13bc875 100644 --- a/crates/uffs-daemon/src/index/mod.rs +++ b/crates/uffs-daemon/src/index/mod.rs @@ -3,8 +3,8 @@ //! Index management: load drives, hold compact indices, refresh. //! -//! The [`IndexManager`] is the daemon's core data structure. It holds -//! the compact search indices for all loaded drives and delegates to +//! The [`crate::index::IndexManager`] is the daemon's core data structure. It +//! holds the compact search indices for all loaded drives and delegates to //! `uffs_core::search` for query execution. //! //! Each cluster of methods lives in its own sibling module — see the @@ -73,7 +73,7 @@ type InFlightLoad = Shared` over its /// active (Warm/Hot) subset so the search hot path stays one /// `Arc::clone` away from a usable backend — see - /// [`Self::snapshot`]. + /// [`IndexManager::snapshot`]. /// /// Phase 1 of the memory-tiering work replaced the previous /// `Arc` field with `Arc`; every shard @@ -142,12 +142,12 @@ pub(crate) struct IndexManager { /// Sizing: we target `max(2, (cpus × 26) / (drives × 10))` permits /// by default so the product `permits × drives ≈ 2.6 × cpus`, the /// empirically-best oversubscription on multi-drive boxes (see - /// [`Self::auto_concurrency_target`] for the measurement that + /// [`IndexManager::auto_concurrency_target`] for the measurement that /// landed on the 2.6× factor). The `UFFS_SEARCH_MAX_CONCURRENCY` /// env var overrides the formula for benchmark sweeps or for /// operators who want to clamp down on oversubscription. The /// semaphore is *replaced* (not mutated) when drive count changes - /// via [`Self::tune_concurrency`]; in-flight queries hold owned + /// via [`IndexManager::tune_concurrency`]; in-flight queries hold owned /// permits on the pre-swap instance and finish naturally. search_semaphore: RwLock>, /// Cached CPU count for the concurrency formula. Captured once at @@ -183,44 +183,44 @@ pub(crate) struct IndexManager { /// promote-on-search. Production paths use /// [`crate::cache::body_loader::DiskBodyLoader`]; the /// Commit-E integration tests inject fakes via - /// [`Self::with_body_loader_for_test`]. + /// `IndexManager::with_body_loader_for_test`. body_loader: Arc, /// Process-level working-set trim hook (Phase 5 task 5.1). /// Called once at the end of every demote batch in - /// [`Self::demote_idle_shards`] (task 5.4). Production wires + /// [`IndexManager::demote_idle_shards`] (task 5.4). Production wires /// [`crate::cache::working_set::PlatformWorkingSetTrim`] /// (Mac/Linux no-op, Windows `EmptyWorkingSet`); the Phase 5 /// tests inject - /// [`crate::cache::working_set::tests::CountingWorkingSetTrim`] + /// `crate::cache::working_set::tests::CountingWorkingSetTrim` /// to assert exactly-once invocation per batch. working_set_trim: Arc, /// Region kernel-prefetch hook (Phase 5 task 5.2). Called /// inside the per-letter `spawn_blocking` task in - /// [`Self::ensure_warm_for_dispatch`] right after the body + /// [`IndexManager::ensure_warm_for_dispatch`] right after the body /// loader returns the freshly-loaded body (task 5.5), so the /// kernel can start paging in records + names while the /// orchestrator acquires the registry write-lock. Production /// wires [`crate::cache::prefetch::PlatformPrefetch`] (Windows /// `PrefetchVirtualMemory`, Mac/Linux `posix_madvise`); the /// Phase 5 tests inject - /// [`crate::cache::prefetch::tests::RecordingPrefetch`] to assert the + /// `crate::cache::prefetch::tests::RecordingPrefetch` to assert the /// records + names regions reach the kernel. prefetch: Arc, /// Memory-pressure signal source (Phase 5 task 5.3). Held so /// the daemon's `spawn_pressure_subscriber` (in `lib.rs`) can - /// call [`Self::subscribe_pressure`] to obtain a + /// call [`IndexManager::subscribe_pressure`] to obtain a /// [`tokio::sync::watch::Receiver`] and react to `Low` events /// by cascade-demoting LRU Warm shards via - /// [`Self::cascade_demote_one_step`] (task 5.6). Production + /// [`IndexManager::cascade_demote_one_step`] (task 5.6). Production /// wires [`crate::cache::pressure::PlatformPressureSignal`] /// (Mac/Linux never-fires, Windows future watcher thread); the /// Phase 5 task 5.10 tests inject - /// [`crate::cache::pressure::tests::ControllablePressureSignal`] + /// `crate::cache::pressure::tests::ControllablePressureSignal` /// to broadcast deterministic transitions and assert the LRU /// cascade order. pressure: Arc, /// Thread-level background-I/O priority hook (Phase 5 task 5.7). - /// Held so [`Self::handle_journal_refresh`] can wrap the + /// Held so [`IndexManager::handle_journal_refresh`] can wrap the /// per-letter `tokio::task::spawn_blocking` closure in a /// [`crate::cache::background_io::BackgroundIoScope`] so the USN /// catch-up + encrypted-cache write happen at Windows @@ -229,25 +229,25 @@ pub(crate) struct IndexManager { /// wires [`crate::cache::background_io::PlatformBackgroundIoPriority`] /// (no-op on Mac/Linux, `SetThreadPriority` on Windows); the /// Phase 5 unit tests inject - /// [`crate::cache::background_io::tests::CountingBackgroundIoPriority`] + /// `crate::cache::background_io::tests::CountingBackgroundIoPriority` /// to assert the begin/end pair fires exactly once per refresh /// closure. /// /// Phase 7 activation moved the call site from the deleted /// `refresh_usn_for_warm_shards` global tick to - /// [`Self::handle_journal_refresh`] (per-shard, threshold-driven). + /// [`IndexManager::handle_journal_refresh`] (per-shard, threshold-driven). background_io: Arc, /// Per-drive cache-file cleanup hook (Phase 8-D `forget` RPC). /// - /// Called from [`Self::forget_drive`] after the in-memory - /// shard has been evicted from the registry, to delete every + /// Called from [`crate::index::IndexManager::forget_drives`] after the + /// in-memory shard has been evicted from the registry, to delete every /// per-drive on-disk artefact (encrypted compact body, USN /// cursor, MFT index, lock file). Production wires /// [`crate::cache::cache_cleaner::PlatformCacheCleaner`] which /// resolves the canonical cache paths via /// [`uffs_core::compact_cache`] / [`uffs_mft::cache`] and /// unlinks each via [`std::fs::remove_file`]; tests inject - /// [`crate::cache::cache_cleaner::CountingCacheCleaner`] so + /// `crate::cache::cache_cleaner::CountingCacheCleaner` so /// registry-eviction behaviour can be verified deterministically /// without ever touching the host's cache directory. cache_cleaner: Arc, @@ -269,11 +269,11 @@ pub(crate) struct IndexManager { /// Pre-fix RAM math (Windows v0.5.83 storm window): 8 × 1.3 GB /// transient × 4 drives ≈ 32 GB peak. Post-fix: 1 × 1.3 GB /// per Parked drive in flight at any moment, ≈ 2 GB peak for - /// the same workload. See [`Self::load_or_join_in_flight`]. + /// the same workload. See [`IndexManager::load_or_join_in_flight`]. in_flight_promotes: InFlightPromotes, /// Phase 7 activation: per-letter [`JournalLoopHandle`] map. /// - /// Populated by [`Self::attach_journal_handle`] from the + /// Populated by [`IndexManager::attach_journal_handle`] from the /// per-shard journal-loop spawn site /// (`lib.rs::spawn_journal_loops_for_warm_shards`) after each /// [`crate::cache::journal_loop::spawn_journal_loop`] call. @@ -294,10 +294,10 @@ pub(crate) struct IndexManager { /// because the critical section is microscopic — a /// [`std::collections::HashMap::insert`] on a map bounded by /// the loaded-drive count (≤ 26 entries). Mirrors the - /// [`Self::in_flight_promotes`] field's lock-choice rationale + /// [`IndexManager::in_flight_promotes`] field's lock-choice rationale /// immediately above. /// - /// Poison handling matches [`Self::in_flight_promotes`] (the + /// Poison handling matches [`IndexManager::in_flight_promotes`] (the /// `HashMap` stores no invariants that need recovery, so we /// recover the inner state via [`std::sync::PoisonError::into_inner`] /// rather than panicking on poisoning). @@ -315,7 +315,7 @@ pub(crate) struct IndexManager { /// Parsed `daemon.toml` (Phase 6). Loaded once at /// [`crate::run_daemon`] startup via /// [`crate::config::Config::load_default`] and shared across - /// every controller — read by [`Self::demote_idle_shards`] for + /// every controller — read by [`IndexManager::demote_idle_shards`] for /// the per-drive [`TierThresholds`] sizing + per-drive /// `min_tier` clamp (plan tasks 6.1, 6.3, 6.6, 6.7). /// @@ -341,7 +341,7 @@ impl IndexManager { /// /// Returns `None` if the semaphore was closed (daemon shutting /// down). The permit is tied to the semaphore instance that was - /// current at acquisition time — if [`Self::tune_concurrency`] + /// current at acquisition time — if [`IndexManager::tune_concurrency`] /// swaps the semaphore while this permit is outstanding, the old /// instance stays alive until the permit is dropped, so in-flight /// queries always see a consistent admission slot. @@ -355,7 +355,7 @@ impl IndexManager { /// /// Accepts any positive `usize`. Invalid or empty values are /// ignored and the auto-tuned default is used instead. Applied - /// every time [`Self::tune_concurrency`] runs, so the daemon can + /// every time [`IndexManager::tune_concurrency`] runs, so the daemon can /// be re-tuned at runtime by setting the env var and invoking an /// operation that re-tunes (e.g. a refresh). Typical use is to /// set it before `uffs --daemon start` for benchmark sweeps: @@ -400,7 +400,7 @@ impl IndexManager { /// the raw ratio can round down to 1 or 0). /// /// The `UFFS_SEARCH_MAX_CONCURRENCY` env var still overrides this - /// computation directly — see [`Self::tune_concurrency`]. + /// computation directly — see [`IndexManager::tune_concurrency`]. #[must_use] pub(crate) const fn auto_concurrency_target(cpus: usize, drives: usize) -> usize { // Clamp drives=0 → 1 so the pre-load admission window (before any @@ -419,7 +419,7 @@ impl IndexManager { /// Re-size the search semaphore to match the currently loaded /// drive count. /// - /// **Default formula**: see [`Self::auto_concurrency_target`] — + /// **Default formula**: see [`IndexManager::auto_concurrency_target`] — /// roughly `max(2, 2.6 × cpus / drives)`. The 30 % oversubscription /// vs. the simpler `2 × cpus / drives` lets the work-stealing /// scheduler chew through concurrent queries without serialising on @@ -483,8 +483,8 @@ impl IndexManager { /// Increment `index_version` and notify the aggregate cache so it /// drops entries computed against the previous generation. /// - /// Called from every drive-mutating path ([`Self::add_drive`] and - /// [`Self::replace_drive`]). Cheap: one atomic fetch-add plus a + /// Called from every drive-mutating path ([`IndexManager::add_drive`] and + /// [`IndexManager::replace_drive`]). Cheap: one atomic fetch-add plus a /// single `Mutex::lock` inside the cache. fn bump_index_version(&self) { let new_version = self.index_version.fetch_add(1, Ordering::Relaxed) + 1; diff --git a/crates/uffs-daemon/src/index/refresh.rs b/crates/uffs-daemon/src/index/refresh.rs index 2f7370a12..e27637407 100644 --- a/crates/uffs-daemon/src/index/refresh.rs +++ b/crates/uffs-daemon/src/index/refresh.rs @@ -7,7 +7,7 @@ //! sequentially, reloads each drive's MFT (live on Windows or the //! original `.mft` snapshot on Mac/Linux) on a blocking thread, //! and atomically swaps the new compact index into the registry -//! via [`Self::replace_drive`]. +//! via [`IndexManager::replace_drive`]. //! //! Sequential — not parallel — because the typical refresh tick //! is operator-driven (`uffs refresh`) and the per-drive cost is @@ -15,14 +15,14 @@ //! drive). A single-flight serial loop keeps the //! `RefreshStarted`/`RefreshComplete` event pair semantically //! tight (one tick = one operator action) and avoids the -//! background-IO cascade the [`Self::refresh_usn_for_warm_shards`] +//! background-IO cascade the [`crate::spawn_journal_loops_for_warm_shards`] //! controller already covers for incremental updates. //! //! The `live_refresh_supported` + `is_live_drive_marker` pair //! gates the platform-specific branch in -//! [`Self::resolve_refresh_mft_source`]: a 2-char path like +//! [`IndexManager::resolve_refresh_mft_source`]: a 2-char path like //! `"C:"` is the opaque marker for a live MFT volume that -//! [`crate::index::loading::IndexManager::load_live_drives`] +//! `load_live_drives` //! installs on Windows, while every other path length is an //! on-disk `.mft` snapshot reloadable from disk on any platform. @@ -96,9 +96,10 @@ impl IndexManager { } /// Trace + dispatch the `Result, JoinError>` returned - /// by [`refresh_one_drive`]'s `spawn_blocking`. On success defers - /// to [`apply_refresh_success`]; on either error arm emits the - /// matching error trace. + /// by [`crate::index::IndexManager::refresh_one_drive`]'s `spawn_blocking`. + /// On success defers + /// to [`crate::index::IndexManager::apply_refresh_success`]; on either + /// error arm emits the matching error trace. async fn apply_refresh_result( &self, letter: uffs_mft::platform::DriveLetter, @@ -173,9 +174,9 @@ impl IndexManager { /// /// A path like `"C:"` (length ≤ 2) is an opaque marker for a /// live MFT scan — valid on Windows, rejected at the - /// [`Self::refresh_one_drive`] call site on every other platform via - /// [`Self::live_refresh_supported`]. Anything longer is an on-disk - /// `.mft` snapshot reloadable from disk on any platform. + /// [`IndexManager::refresh_one_drive`] call site on every other platform + /// via [`IndexManager::live_refresh_supported`]. Anything longer is an + /// on-disk `.mft` snapshot reloadable from disk on any platform. /// /// [`MftSource`]: uffs_core::compact::MftSource fn resolve_refresh_mft_source( diff --git a/crates/uffs-daemon/src/index/stats.rs b/crates/uffs-daemon/src/index/stats.rs index c5d204542..b274d479e 100644 --- a/crates/uffs-daemon/src/index/stats.rs +++ b/crates/uffs-daemon/src/index/stats.rs @@ -5,17 +5,17 @@ //! //! Three closely-related accessors: //! -//! * [`Self::stats`] — query-rate / latency aggregates plus +//! * [`IndexManager::stats`] — query-rate / latency aggregates plus //! [`uffs_core::aggregate::AggregateCache::stats`] hit-rate counters. //! Returned by the `stats` RPC. -//! * [`Self::status`] — broad daemon health snapshot (uptime, connections, PID, -//! version) plus per-drive heap breakdown and the OS-reported RSS / mimalloc -//! committed bytes from [`crate::telemetry::mem_snapshot`]. Returned by the -//! `status` RPC. -//! * [`Self::total_index_heap_bytes`] — the per-drive heap sum on its own, -//! called by [`crate::telemetry::spawn_mem_snapshot_task`] for the +//! * [`IndexManager::status`] — broad daemon health snapshot (uptime, +//! connections, PID, version) plus per-drive heap breakdown and the +//! OS-reported RSS / mimalloc committed bytes from +//! [`crate::telemetry::mem_snapshot`]. Returned by the `status` RPC. +//! * [`IndexManager::total_index_heap_bytes`] — the per-drive heap sum on its +//! own, called by [`crate::telemetry::spawn_mem_snapshot_task`] for the //! `mem.snapshot` heartbeat trace. Avoids the per-drive `Vec` allocation -//! that [`Self::status`] does. +//! that [`IndexManager::status`] does. //! //! All three are read-only: they snapshot the registry once //! and walk every loaded drive (Hot / Warm / Parked / Cold) @@ -80,11 +80,11 @@ impl IndexManager { /// Snapshots the `DaemonStatus` upfront via `.read().await.clone()` /// rather than holding the read guard across the inner awaits /// below. Without the snapshot, the guard would be held across - /// [`Self::has_drives`], [`Self::total_records`], and - /// [`Self::snapshot`] — three independent `self.index.read().await` + /// [`IndexManager::has_drives`], [`IndexManager::total_records`], and + /// [`IndexManager::snapshot`] — three independent `self.index.read().await` /// acquisitions — blocking any concurrent - /// [`Self::set_ready`] / [`Self::set_loading_progress`] / - /// [`crate::index::refresh::IndexManager::refresh`] writer on + /// [`IndexManager::set_ready`] / `set_loading_progress` / + /// [`crate::index::IndexManager::refresh`] writer on /// `self.status` for the duration of the status RPC (which on a /// many-drive box with a slow snapshot path can be tens of /// milliseconds). `DaemonStatus` is a small `Clone` enum @@ -149,7 +149,7 @@ impl IndexManager { /// /// Used by [`crate::telemetry::spawn_mem_snapshot_task`] to emit /// the `mem.snapshot` tracing event without going through the full - /// [`Self::status`] path (which builds a per-drive `Vec` we don't + /// [`IndexManager::status`] path (which builds a per-drive `Vec` we don't /// need for the heartbeat). pub(crate) async fn total_index_heap_bytes(&self) -> u64 { let snap = self.snapshot().await; diff --git a/crates/uffs-daemon/src/index/tests/forget_status.rs b/crates/uffs-daemon/src/index/tests/forget_status.rs index 48372816b..dec950f9f 100644 --- a/crates/uffs-daemon/src/index/tests/forget_status.rs +++ b/crates/uffs-daemon/src/index/tests/forget_status.rs @@ -7,7 +7,7 @@ //! `forget` tests pin the eviction guard (busy-without-force vs. //! force-auto-hibernate), the registry-eviction step, the //! cache-cleaner side effect (verified via the -//! [`crate::cache::cache_cleaner::CountingCacheCleaner`] fake), and +//! `crate::cache::cache_cleaner::CountingCacheCleaner` fake), and //! the per-drive classification (`forgotten` vs. `already_absent`). //! //! `status_drives` tests pin the per-drive row builder: tier @@ -16,7 +16,7 @@ //! deterministic ascending sort. //! //! Every test uses [`super::IndexManager::with_lifecycle_hooks_for_test`] -//! to inject a [`CountingCacheCleaner`] (and a [`FixedBodyLoader`] +//! to inject a `CountingCacheCleaner` (and a [`FixedBodyLoader`] //! for the preload-then-forget sequences) so the host's real cache //! directory is **never** touched — a "forget drive C" call against //! the platform paths would be catastrophic in CI. diff --git a/crates/uffs-daemon/src/index/tiering_ops.rs b/crates/uffs-daemon/src/index/tiering_ops.rs index 5ab7a4c06..31f592298 100644 --- a/crates/uffs-daemon/src/index/tiering_ops.rs +++ b/crates/uffs-daemon/src/index/tiering_ops.rs @@ -5,21 +5,22 @@ //! //! Phase 8 commit-level decomposition (sub-phases 8-B / 8-C): //! -//! * [`Self::hibernate_shards`] — sub-phase 8-B. Walks every shard in the -//! registry (or a caller-supplied subset) and demotes each non-`Cold` shard -//! to `Cold` in a single write-lock batch. Mirrors the orchestration shape of -//! [`Self::demote_idle_shards`] (Phase 3 Commit D) — read-lock detect → -//! write-lock atomic batch → single `bump_index_version`. Hibernate -//! explicitly clears tier pins by virtue of rebuilding the shard as `Cold` -//! (the new `ShardEntry` starts with `pin_until_ms = 0`). +//! * [`IndexManager::hibernate_shards`] — sub-phase 8-B. Walks every shard in +//! the registry (or a caller-supplied subset) and demotes each non-`Cold` +//! shard to `Cold` in a single write-lock batch. Mirrors the orchestration +//! shape of [`IndexManager::demote_idle_shards`] (Phase 3 Commit D) — +//! read-lock detect → write-lock atomic batch → single `bump_index_version`. +//! Hibernate explicitly clears tier pins by virtue of rebuilding the shard as +//! `Cold` (the new `ShardEntry` starts with `pin_until_ms = 0`). //! -//! * [`Self::preload_drive`] — sub-phase 8-C. Promotes a single drive to `Hot` -//! via the existing per-letter single-flight body-load + `Prefetch::hint` -//! machinery ([`Self::load_or_join_in_flight`] from [`super::dispatch`]) and -//! arms the tier pin for `pin_minutes` minutes. Source state can be `Cold` -//! (loads body from encrypted compact cache), `Parked` (loads body, dropping -//! the parked bloom + trie), `Warm` (clones the existing body), or `Hot` -//! (skips the rebuild and atomically extends the pin). +//! * [`IndexManager::preload_drive`] — sub-phase 8-C. Promotes a single drive +//! to `Hot` via the existing per-letter single-flight body-load + +//! `Prefetch::hint` machinery ([`IndexManager::load_or_join_in_flight`] from +//! [`super::dispatch`]) and arms the tier pin for `pin_minutes` minutes. +//! Source state can be `Cold` (loads body from encrypted compact cache), +//! `Parked` (loads body, dropping the parked bloom + trie), `Warm` (clones +//! the existing body), or `Hot` (skips the rebuild and atomically extends the +//! pin). //! //! Why a sibling file (instead of folding into //! [`super::transitions`]): the two background controllers in @@ -108,7 +109,7 @@ impl IndexManager { /// daemon actually knew about). /// /// Three-phase orchestration mirroring - /// [`Self::demote_idle_shards`]: + /// [`IndexManager::demote_idle_shards`]: /// /// 1. **Read-lock detect.** Single `self.index.read()` to enumerate the /// (letter, from-state) tuples for every shard the call will touch. Cold @@ -223,8 +224,8 @@ impl IndexManager { /// /// * `Cold` / `Parked`: drives the existing per-letter single-flight /// body-load + `Prefetch::hint` machinery via - /// [`Self::load_or_join_in_flight_for_preload`]; rebuilds the registry - /// with a `Hot` `ShardEntry` via + /// [`crate::index::IndexManager::load_or_join_in_flight`]; rebuilds the + /// registry with a `Hot` `ShardEntry` via /// [`crate::cache::ShardRegistry::promote_letter_to_hot`]; atomically /// arms the pin on the new shard. /// * `Warm`: clones the live body, calls `Prefetch::hint` to pre-fault its @@ -322,7 +323,7 @@ impl IndexManager { /// Atomic write-lock swap: rebuild the registry with `letter` /// in `Hot` carrying `body`, then arm the pin on the new shard. /// - /// Factored out of [`Self::preload_drive`] so the Warm-source + /// Factored out of [`IndexManager::preload_drive`] so the Warm-source /// and Cold/Parked-source code paths converge on a single /// rebuild + pin sequence — keeps the per-source-state logic /// readable above and the swap mechanics auditable here. @@ -380,7 +381,7 @@ impl IndexManager { /// in memory. /// /// Mirrors the prefault block inside - /// [`Self::build_load_future`] but for the Warm-source preload + /// [`IndexManager::build_load_future`] but for the Warm-source preload /// path where the body is reused rather than freshly loaded. /// Fire-and-forget: any I/O error is logged at /// `target: "shard.transition"` and the preload continues. diff --git a/crates/uffs-daemon/src/index/transitions.rs b/crates/uffs-daemon/src/index/transitions.rs index f565dd3cc..e552c6d7f 100644 --- a/crates/uffs-daemon/src/index/transitions.rs +++ b/crates/uffs-daemon/src/index/transitions.rs @@ -6,9 +6,9 @@ //! Three independent controllers, each spawned from //! [`crate::run_daemon`], share this module: //! -//! 1. [`Self::demote_idle_shards`] — Phase 3 Commit D idle-tick demote. Walks -//! the registry once per 30 s tick, demotes any shard whose `idle_secs` -//! exceeds its tier's TTL, and calls +//! 1. [`IndexManager::demote_idle_shards`] — Phase 3 Commit D idle-tick demote. +//! Walks the registry once per 30 s tick, demotes any shard whose +//! `idle_secs` exceeds its tier's TTL, and calls //! [`crate::cache::working_set::WorkingSetTrim::trim`] once per batch. Phase //! 6 Commit C wired the static-TTL lookup to the adaptive //! [`crate::cache::policy::next_state_for_idle_with_thresholds`] helper: @@ -18,12 +18,13 @@ //! `[shards.per_drive."X:"].min_tier` floor (plan tasks 6.4, 6.6). Every //! demote evaluation emits a `shard.ttl` tracing event with the chosen TTL, //! the live rate, and a structured reason (plan task 6.7). -//! 2. [`Self::cascade_demote_one_step`] (+ [`Self::subscribe_pressure`]) — -//! Phase 5 task 5.6 pressure-cascade. Picks the LRU Warm shard, demotes it -//! Warm → Parked, and trims the working set; the subscriber loop in `lib.rs` -//! calls this in a tight loop while -//! [`crate::cache::pressure::PressureLevel`] reports `Critical`, yielding -//! between calls so the cascade stops as soon as the pressure clears. +//! 2. [`IndexManager::cascade_demote_one_step`] (+ +//! [`IndexManager::subscribe_pressure`]) — Phase 5 task 5.6 +//! pressure-cascade. Picks the LRU Warm shard, demotes it Warm → Parked, +//! and trims the working set; the subscriber loop in `lib.rs` calls this in +//! a tight loop while [`crate::cache::pressure::PressureLevel`] reports +//! `Critical`, yielding between calls so the cascade stops as soon as the +//! pressure clears. //! //! Phase 7 activation moved the third (USN-refresh) controller out //! of this module: the deleted `refresh_usn_for_warm_shards` global @@ -37,7 +38,7 @@ //! The two remaining controllers consume the same Arc-swap registry //! mutation primitives (`demote_letter`, `replace_warm_body`) //! exposed by [`crate::cache::registry::ShardRegistry`], and both -//! call [`Self::bump_index_version`] after a successful mutation so +//! call [`IndexManager::bump_index_version`] after a successful mutation so //! the aggregate cache drops stale entries. Keeping them in one //! module keeps that contract visible. @@ -157,7 +158,7 @@ impl IndexManager { /// current [`PressureLevel`] and waking on every transition. /// The daemon's `spawn_pressure_subscriber` (in `lib.rs`) is /// the sole production consumer; the Phase 5 task 5.10 test - /// uses [`Self::cascade_demote_one_step`] directly without + /// uses [`IndexManager::cascade_demote_one_step`] directly without /// going through the watch channel. /// /// [`PressureLevel`]: crate::cache::pressure::PressureLevel diff --git a/crates/uffs-daemon/src/index/wire_spec.rs b/crates/uffs-daemon/src/index/wire_spec.rs index c3bc141ec..4de8a75d1 100644 --- a/crates/uffs-daemon/src/index/wire_spec.rs +++ b/crates/uffs-daemon/src/index/wire_spec.rs @@ -7,9 +7,9 @@ //! (`run_aggregations` and the duplicate-verifier glue) and the wire //! decoder can be read independently. The decoder has no dependency //! on [`IndexManager`] state — every match arm is a pure -//! [`AggregateSpecWire`] → [`AggregateSpec`] mapping — so isolating -//! it makes the transport contract obvious without changing any -//! call site. +//! [`uffs_client::protocol::aggregate_wire::AggregateSpecWire`] → +//! [`uffs_core::aggregate::AggregateSpec`] mapping — so isolating it makes the +//! transport contract obvious without changing any call site. //! //! Public surface is unchanged: callers still write //! `IndexManager::convert_wire_spec(ws)`. @@ -97,8 +97,9 @@ pub(crate) enum WireSpecError { } impl IndexManager { - /// Convert a wire-protocol [`AggregateSpecWire`] into one or more - /// core [`AggregateSpec`]s. + /// Convert a wire-protocol + /// [`uffs_client::protocol::aggregate_wire::AggregateSpecWire`] into one or + /// more core [`uffs_core::aggregate::AggregateSpec`]s. /// /// Presets expand to multiple specs; all other kinds produce /// exactly one. @@ -112,8 +113,8 @@ impl IndexManager { /// pre-Phase-5d `String` payload so operator-facing log lines are /// unchanged. /// - /// [`AggregateSpec`]: uffs_core::aggregate::spec::AggregateSpec - /// [`AggregateSpecWire`]: uffs_client::protocol::AggregateSpecWire + /// [`uffs_core::aggregate::AggregateSpec`]: uffs_core::aggregate::spec::AggregateSpec + /// [`uffs_client::protocol::aggregate_wire::AggregateSpecWire`]: uffs_client::protocol::AggregateSpecWire #[expect( clippy::too_many_lines, reason = "straightforward match arms — one per wire kind" @@ -282,7 +283,7 @@ fn require_field( }) } -/// Parse wire metric strings to [`BucketMetric`]s. +/// Parse wire metric strings to [`uffs_core::aggregate::BucketMetric`]s. /// /// Empty input falls back to the default `[Count, TotalBytes]` pair so /// `terms` / `histogram` / `range` / `rollup` / `date_histogram` @@ -309,7 +310,7 @@ fn parse_bucket_metrics(wire: &[String]) -> Vec) -> tokio::task::J /// docs for the full rationale. /// /// **Platform split**: -/// * **Windows**: each letter gets a -/// [`crate::cache::journal_loop::sources::WindowsJournalSource`] (real +/// * **Windows**: each letter gets a `WindowsJournalSource` (real /// `FSCTL_QUERY_USN_JOURNAL` + `FSCTL_READ_USN_JOURNAL`) and a /// [`crate::cache::cursor_store::DiskCursorStore`] rooted at /// `uffs_mft::cache::cache_dir()`. diff --git a/crates/uffs-daemon/src/lifecycle.rs b/crates/uffs-daemon/src/lifecycle.rs index 860c71176..21d531939 100644 --- a/crates/uffs-daemon/src/lifecycle.rs +++ b/crates/uffs-daemon/src/lifecycle.rs @@ -336,8 +336,9 @@ impl LifecycleManager { /// Check for stale PID file on startup. Returns `true` if safe to proceed. /// - /// Uses [`parse_pid_file`] for structured parsing and validates the exe - /// hash via [`expected_daemon_exe_hash`] to detect stale files from + /// Uses [`crate::lifecycle::LifecycleManager::parse_pid_file`] for + /// structured parsing and validates the exe + /// hash via [`crate::lifecycle::LifecycleManager::expected_daemon_exe_hash`] to detect stale files from /// different binaries. pub(crate) fn check_stale_pid(&self) -> bool { if !self.pid_path.exists() { diff --git a/crates/uffs-daemon/src/runtime_orphans.rs b/crates/uffs-daemon/src/runtime_orphans.rs index 1e0052c0a..b07e30b86 100644 --- a/crates/uffs-daemon/src/runtime_orphans.rs +++ b/crates/uffs-daemon/src/runtime_orphans.rs @@ -13,9 +13,9 @@ //! `FILE_FLAG_DELETE_ON_CLOSE` self-cleans, but the empty `/` //! directory wrapper still needs sweeping. //! -//! [`sweep_runtime_tempfile_orphans`] runs once at daemon startup, -//! after [`crate::bootstrap_lifecycle_manager`] (so the PID file -//! proves we're the live daemon) and before any drive load (so the +//! [`crate::runtime_orphans::sweep_runtime_tempfile_orphans`] runs once at +//! daemon startup, after [`crate::startup::bootstrap_lifecycle_manager`] (so +//! the PID file proves we're the live daemon) and before any drive load (so the //! sweep can't accidentally remove our own future runtime tempfile //! subdir). //! diff --git a/crates/uffs-daemon/src/startup.rs b/crates/uffs-daemon/src/startup.rs index 47e5a7c4c..a65a94fbc 100644 --- a/crates/uffs-daemon/src/startup.rs +++ b/crates/uffs-daemon/src/startup.rs @@ -60,9 +60,13 @@ pub(crate) fn log_daemon_starting(config: &DaemonConfig) { // `warm_up_broker_handles` request milliseconds later failed with // ERROR_PIPE_BUSY (2026-06-13 VM finding). Broker presence is now // established only by attempting the handle request itself. + // `git` is the short commit the binary was built from (emitted by this + // crate's build.rs; "unknown" when git was unavailable at build time) — it + // pins exactly which build a field log came from. tracing::info!( pid = std::process::id(), version = env!("CARGO_PKG_VERSION"), + git = option_env!("UFFS_GIT_SHA").unwrap_or("unknown"), mft_files = ?config.mft_files, drives = ?config.drives, data_dir = ?config.data_dir, @@ -194,7 +198,7 @@ pub(crate) fn gather_mft_files(config: &DaemonConfig) -> Vec { /// insensitive — `DriveLetter::parse` canonicalises to uppercase). /// /// `pub(crate)` so the regression-pin test in -/// [`crate::tests`] can exercise the contract directly without +/// `crate::tests` can exercise the contract directly without /// going through [`gather_mft_files`]. pub(crate) fn drive_letter_matches( path: &std::path::Path, diff --git a/crates/uffs-daemon/src/telemetry.rs b/crates/uffs-daemon/src/telemetry.rs index 8c7a7989e..a807cabf8 100644 --- a/crates/uffs-daemon/src/telemetry.rs +++ b/crates/uffs-daemon/src/telemetry.rs @@ -8,12 +8,12 @@ //! //! Provides: //! -//! * [`mem_snapshot`] — a cross-platform helper that returns the daemon's -//! current resident-set size and mimalloc-committed bytes. -//! * [`spawn_mem_snapshot_task`] — spawns a background tokio task that logs -//! that snapshot at a configurable interval as a `mem.snapshot` tracing event -//! so a long-running daemon produces the time-series the tiering work needs -//! to measure its impact. +//! * [`crate::telemetry::mem_snapshot`] — a cross-platform helper that returns +//! the daemon's current resident-set size and mimalloc-committed bytes. +//! * [`crate::telemetry::spawn_mem_snapshot_task`] — spawns a background tokio +//! task that logs that snapshot at a configurable interval as a +//! `mem.snapshot` tracing event so a long-running daemon produces the +//! time-series the tiering work needs to measure its impact. //! //! The numbers come from mimalloc's `mi_process_info`, which is //! implemented uniformly on Mac, Linux and Windows; this lets the diff --git a/crates/uffs-format/src/derived.rs b/crates/uffs-format/src/derived.rs index 56ddb40b1..e10b8de77 100644 --- a/crates/uffs-format/src/derived.rs +++ b/crates/uffs-format/src/derived.rs @@ -113,7 +113,7 @@ const CODE: &[&str] = &[ /// /// Dot-gated: dotfiles (`.bash_history`), dotless names (`README`), and /// trailing-dot names (`foo.`) all return `None`. Visible to the rest -/// of the crate so [`writer::write_display_row_columns`] can format the +/// of the crate so [`crate::writer::write_row`] can format the /// `Extension` column with the same rule as the sort key (regression: /// T62 `--sort extension` MCP failure where `.bash_history`'s displayed /// `ext` disagreed with its sort position). diff --git a/crates/uffs-mft/src/io/parser/unified.rs b/crates/uffs-mft/src/io/parser/unified.rs index 65f53ec60..08e893e60 100644 --- a/crates/uffs-mft/src/io/parser/unified.rs +++ b/crates/uffs-mft/src/io/parser/unified.rs @@ -274,8 +274,9 @@ fn store_name_lossless( /// The parser call sites are spread across nine modules and do not thread a /// stats accumulator through their (hot-path) signatures, so the count is /// gathered here with a single relaxed atomic — cheap, lock-free, and read -/// at index-build time into [`crate::index::stats::MftStats::lossy_name_count`] -/// for the "N filenames were stored with U+FFFD" warning. `Relaxed` is +/// at index-build time into the `lossy_name_count` field of +/// [`crate::index::MftStats`] for the "N filenames were stored with +/// U+FFFD" warning. `Relaxed` is /// sufficient: it is a monotonic diagnostic counter, not a synchronisation /// point. pub(crate) static LOSSY_NAME_COUNT: core::sync::atomic::AtomicU64 = diff --git a/crates/uffs-mft/src/parse/columns.rs b/crates/uffs-mft/src/parse/columns.rs index 92f5c1da5..5b567899a 100644 --- a/crates/uffs-mft/src/parse/columns.rs +++ b/crates/uffs-mft/src/parse/columns.rs @@ -6,8 +6,8 @@ //! # FRS wire-boundary policy (Phase 4 sub-phase 5d.4) //! //! The `frs: Vec` / `parent_frs: Vec` fields are the -//! columnar staging buffers that feed -//! [`crate::reader::dataframe_build`] and ultimately become +//! columnar staging buffers that feed the reader's +//! `dataframe_build` stage and ultimately become //! `polars::Series::new("frs", _)` columns. They are deliberately //! raw `u64` because the polars column type is the FRS wire boundary //! by Phase-4 doctrine — every typed [`crate::Frs`] / [`crate::ParentFrs`] diff --git a/crates/uffs-mft/src/reader.rs b/crates/uffs-mft/src/reader.rs index a082bed96..dd52955f5 100644 --- a/crates/uffs-mft/src/reader.rs +++ b/crates/uffs-mft/src/reader.rs @@ -40,8 +40,8 @@ pub use self::stats::{MftProgress, MftStats}; /// `MftReader` dispatch to the correct pipeline without `#[cfg]` gates on /// every public method. /// -/// The `LiveVolume` variant boxes its [`VolumeHandle`] so the enum stays -/// compact (one pointer per variant) instead of allocating the +/// The `LiveVolume` variant boxes its `VolumeHandle` (Windows-only) so the enum +/// stays compact (one pointer per variant) instead of allocating the /// `VolumeHandle`-sized inline payload (~120 bytes including the NTFS /// volume-data block) on every `MftReader` instance — also silences the /// rustc `variant_size_differences` lint comparing against the diff --git a/crates/uffs-mft/src/tree_metrics.rs b/crates/uffs-mft/src/tree_metrics.rs index 45a5a7c8f..e5440b2d3 100644 --- a/crates/uffs-mft/src/tree_metrics.rs +++ b/crates/uffs-mft/src/tree_metrics.rs @@ -76,7 +76,7 @@ struct RecordSnapshot { /// exactly. /// /// **Important**: `name_info` must be the transformed index, NOT the raw -/// `name_index`. Use [`compute_name_info`] to convert `name_index` to +/// `name_index`. Use [`compute_name_info_checked`] to convert `name_index` to /// `name_info`. #[inline] const fn delta(value: u64, name_info: u32, total_names: u32) -> u64 { @@ -120,9 +120,9 @@ const fn compute_name_info(name_index: u32, total_names: u32) -> u32 { /// Computes `name_info` with optional debug logging when clamping occurs. /// -/// This is the debug-aware version of [`compute_name_info`] that logs when -/// `name_index >= total_names`, which indicates a potential parity issue -/// (two hardlinks mapping to the same `i` can skew totals). +/// This is the debug-aware version of the test-only `compute_name_info` that +/// logs when `name_index >= total_names`, which indicates a potential parity +/// issue (two hardlinks mapping to the same `i` can skew totals). #[inline] #[expect( clippy::single_call_fn, diff --git a/docs/architecture/baselines/incremental-index-2026-06-26.json b/docs/architecture/baselines/incremental-index-2026-06-26.json new file mode 100644 index 000000000..9a77e67bf --- /dev/null +++ b/docs/architecture/baselines/incremental-index-2026-06-26.json @@ -0,0 +1,26 @@ +{ + "_comment": "Incremental-index-maintenance per-apply timing baseline (design §8). Captured by scripts/windows/idx-delta-verify.rs on a live Windows MFT. Later phases diff against this to detect a timing regression. Means over the captured applies; values in milliseconds.", + "build_git": "629966bc2", + "version": "0.6.14", + "captured": "2026-06-26", + "drive": "C", + "drive_records": 3889117, + "apply_samples": 12, + "per_apply_ms": { + "clone": 165.576, + "loop": 61.797, + "children": 54.058, + "paths": 623.464, + "trigram": 377.988, + "ext": 84.426, + "rebuild_subtotal": 1139.936, + "full_apply": 1367.309 + }, + "targets_by_phase": { + "1_paths_incremental": "623 -> ~0 for small batches", + "2_trigram_delta": "378 -> base+delta merge cost", + "3_clone_arc_share": "166 -> records+names+delta only", + "4_ext_children_delta": "84 + 54 -> overlay", + "regression_tolerance_pct": 15 + } +} diff --git a/docs/architecture/incremental-index-maintenance.md b/docs/architecture/incremental-index-maintenance.md new file mode 100644 index 000000000..2f4cca925 --- /dev/null +++ b/docs/architecture/incremental-index-maintenance.md @@ -0,0 +1,481 @@ + + +# Incremental Index Maintenance — Two-Tier Base + Delta (LSM-style) + +**Status:** Phases 1–6 complete + WIN-validated — per-apply 1367 ms → ~200 ms (−85%): paths/trigram/ext/children all incremental/overlay-served, clone Arc-shared, apply cadence now debounce+max-wait (snappy + CPU-bounded). Phase 6 stripped the `IDXDELTA` dev instrumentation: the per-batch cost now lands as the `usn apply: batch applied` DEBUG summary, the git stamp graduated onto the `uffsd starting` banner, and the timing baseline became the cross-platform `apply_cost` Criterion bench (perf guard). +**Owner:** _(assign)_ +**Branch:** `feat/incremental-index-maintenance` + +--- + +## 1. Problem + +Every live USN apply (`uffs_core::compact_loader::apply_usn_patch`) mutates the +record columns in place (O(changed)), then **rebuilds the derived structures +from scratch (O(total records))**. + +### Measured baseline (Phase 0, build `629966bc2`, live C: = 3,889,117 records) + +Captured by `scripts/windows/idx-delta-verify.rs` — mean over 12 applies +(`docs/architecture/baselines/` once committed): + +| Step | Mean | Kind | Incremental target | +|------|-----:|------|--------------------| +| **`compute_path_lengths`** | **623 ms** | per-record path-len recompute | **#1 — only changed records + renamed subtree** | +| `TrigramIndex::build` | 378 ms | CSR inverted index | base + delta overlay | +| whole-body **clone** (Arc-swap) | 166 ms | deep copy in `shard.rs` | Arc-share the immutable base CSR | +| `ExtensionIndex::build` | 84 ms | CSR | base + delta overlay | +| per-change **loop** | 62 ms | O(changed) | already incremental | +| `ChildrenIndex::build` | 54 ms | CSR | base + delta overlay | +| **rebuild subtotal** | **1140 ms** | | | +| **full apply (clone+loop+rebuild)** | **≈ 1367 ms** | | **the number to beat** | + +> **Baseline overturned the original assumption.** This doc first guessed +> *trigram* was the ~80 % win (~500 ms of ~600 ms). The measurement says the +> full apply is **~1.37 s** (not ~600 ms), and **`compute_path_lengths` (623 ms) +> is the single biggest cost — larger than trigram (378 ms)**. Instrumenting the +> *clone* separately (166 ms) was also load-bearing: the rebuild timing alone +> hid it. The phase order in §4 is sequenced from this data, not the guess. + +So a single-file change pays a **~1.37 s** full apply. Consequences already +observed in production / the verify harness: + +- **Apply backlog** when the apply interval drops below the rebuild cost + (mitigated, not removed, by the apply-coalescing guard in `fix/usn-apply-coalesce`). +- **Churn CPU**: a continuously-active drive burns a bounded fraction of a core + on rebuilds. +- **Freshness/CPU tradeoff**: the production apply interval is pinned at **30 s** + precisely to keep rebuild churn down — i.e. we trade search freshness for CPU + *because* each apply is O(n). + +These CSR structures are **immutable / read-optimized**: inserting one record's +postings means shifting the flat `values`/`offsets` arrays — the same cost as a +rebuild. **You cannot cheaply mutate them in place.** This is fundamental, not a +missing optimization. + +## 2. Goal + +Turn apply from **O(total records)** into **O(changed records)** without +regressing search correctness or latency: + +- Sub-second search freshness becomes cheap (apply interval can drop to ~1 s or + event-driven). +- Churn CPU drops to ~proportional-to-changes. +- The existing full rebuild survives, but only as an **occasional compaction + step**, not a per-apply tax. (This also speeds the save-tick path.) + +**Non-goals:** changing the on-disk compact-cache format (the base CSR is still +what we serialize); changing search semantics/results; touching the +Windows-only I/O path. + +## 3. Architecture — two-tier (base + delta + tombstones) + +The Lucene-segment / LSM pattern: + +``` +DriveCompactIndex +├── records / names (mutated in place — already O(changed)) +├── frs_to_compact (mutated in place — already O(changed)) +├── trigram: TrigramIndex (BASE) ─┐ +├── children: ChildrenIndex (BASE) │ immutable CSR, rebuilt only at compaction +├── ext_index:ExtensionIndex (BASE) ─┘ +└── delta: Option (NEW — small mutable overlay) + ├── trigram: HashMap> + ├── ext: HashMap> + ├── children: HashMap> + └── tombstones: FxHashSet (records whose BASE postings are stale) +``` + +- **Base layer** — the current immutable CSR indexes. Built at cold-load and at + compaction; never mutated between. +- **Delta layer** — per-index mutable overlays holding postings for records + created/renamed *since the last compaction*. +- **Tombstones** — record indices whose **base** postings are stale (deleted, or + renamed and re-added to the delta with a new name). Search subtracts them. + +### 3.1 Semantics by operation + +| USN op | records/names | tombstone (base idx) | delta postings | +|--------|---------------|----------------------|----------------| +| **create** | append new record (new idx) | — (idx not in base) | add new idx → trigram/ext/children | +| **delete** | mark record removed | tombstone the mapped base idx; if idx was a recent create, drop it from delta instead | remove from delta if present | +| **rename** | update name/ext/parent in place | tombstone the base idx (old-name base postings now stale) | add the same idx → trigram/ext/children **with the new name** | + +Key invariant: **a record index appears in search results iff** it is +`(in base AND not tombstoned) OR (in delta)`. A renamed record is *both* +tombstoned-in-base (old name suppressed) *and* present-in-delta (new name found) +— same idx, no data duplication. + +### 3.2 Search integration (the hot path — highest risk) + +Every read that consults a base index must consult `base ∪ delta` and subtract +tombstones. Wrap each at a single choke point on `DriveCompactIndex`: + +| Base call (today) | New delta-aware accessor | Callers to migrate | +|-------------------|--------------------------|--------------------| +| `self.trigram.search(needle, fold) -> Option>` | `self.trigram_search(needle) -> Option>` | `search/tree.rs`, `search/query/mod.rs`, `search/query/prefix_search.rs` | +| `self.children.get(idx) -> &[u32]` | `self.children_of(idx) -> SmallVec/Cow<[u32]>` | `FastPathResolver`, directory listing, tree search | +| `self.ext_index.get(ext_id) -> &[u32]` | `self.records_with_ext(ext_id) -> Cow<[u32]>` | `--ext` filter dispatch | + +- **Trigram** intersects posting lists across the needle's trigrams. For each + trigram `t`, the effective posting list is `base.get_posting(t) ∪ delta.trigram[t]` + (sorted-merge, dedup). Intersect across trigrams as today; **filter tombstones + on the final result** (cheap — one `FxHashSet` lookup per surviving idx). +- **Ext / children** return `base.get(k)` filtered through tombstones, with + `delta[k]` appended. When the delta is empty (`delta == None`), every accessor + is a zero-overhead passthrough to the base — *no regression for the common, + freshly-compacted case.* + +### 3.3 Compaction + +Fold the delta back into a fresh base CSR (this **is** today's +`apply_usn_patch` rebuild path, reused verbatim) when any trigger fires: + +- delta record count `> COMPACT_THRESHOLD_RECORDS` (start at 50 000), **or** +- delta record count `> COMPACT_THRESHOLD_FRACTION` of base (start at 5 %), **or** +- the save tick fires (we already pay a rebuild there — fold the delta in then). + +After compaction: new base, `delta = None`, tombstones cleared. Compaction runs +on the existing background `spawn_blocking` applier path, never on a query. + +## 4. Phases (each is independently shippable + reversible) + +> Each phase keeps the **full-rebuild path as the oracle** (see §7). A phase is +> "done" only when the oracle test passes and the baseline (§8) shows no search +> regression. + +Order is by **measured cost** (§1), biggest lever first, cheapest/riskiest last. +Cumulative "apply after this phase" assumes a small change batch on the 3.89M +baseline (clone+loop are constant-ish; each phase removes one rebuild term). + +- **Phase 0 — scaffolding (✅ done on this branch):** + - Design doc; build-id stamp + per-step `IDXDELTA-TIMING` (§9); WIN rig + + baseline (§8, §10). **Done:** baseline captured (≈1367 ms). + - **Still in Phase 0 (next):** `IndexDelta` struct + `delta: Option` + field on `DriveCompactIndex` (unused, `None` everywhere → zero behavior + change) + the oracle harness (§7). Gate for every phase below. + +- **Phase 1 — incremental `compute_path_lengths` (623 ms → ~O(changed); the #1 win):** + This is *not* a base+delta overlay — `path_len` is a per-`CompactRecord` + field (`= parent.path_len + 1 separator + name_len`), so it is updated + surgically. Approach (§5.5): + - **create / file-rename:** recompute just that record's `path_len` from its + (unchanged) parent's `path_len` + new `name_len` — O(1). + - **directory rename:** `Δ = new_dir_path_len − old_dir_path_len`; walk the + renamed dir's subtree via the (still-fresh) children CSR and add `Δ` to each + descendant's `path_len` — O(subtree), cheap arithmetic, no string walk. + - **delete:** record is tombstoned; `path_len` irrelevant. + - Children + trigram + ext **still full-rebuild** this phase (keeps the diff + small and gives a valid children CSR for the subtree walk). + - **Acceptance:** oracle passes (path resolution identical to a full rebuild); + `paths_us` drops from ~623 ms to sub-ms for small batches; apply ≈ 744 ms. + +- **Phase 2 — trigram delta (378 ms; base + delta overlay):** + `IndexDelta.trigram` + tombstones + `DriveCompactIndex::trigram_search` (§3.2, + §5.1–5.3); apply stops rebuilding trigram; migrate the 3 trigram callers; + compaction folds the delta. **Acceptance:** oracle passes; trigram search + within baseline + ε; apply ≈ 366 ms. + +- **Phase 3 — shrink the clone (166 ms; Arc-share the base CSR):** + Hold the immutable base indexes as `Arc` / `Arc` / + `Arc` on `DriveCompactIndex` so the per-apply whole-body clone + copies records + names + the small delta, **not** the large inverted indexes + (pointer-clone the Arcs). **Acceptance:** `clone_us` drops materially; oracle + unaffected (pure representation change). Best done after Phase 2 makes trigram + a shareable base. + +- **Phase 4 — extension + children delta (84 + 54 ms):** same overlay shape for + `ext_index` → `records_with_ext` and `children` → `children_of`. **Children is + the highest-care** index — it feeds `FastPathResolver` *and* the Phase-1 subtree + walk; exercise the path-resolver oracle heavily and keep the children full + rebuild until its delta + the Phase-1 walk are reconciled. + +- **Phase 5 — unify + retire per-apply rebuild + re-tune:** apply is now O(changed) + end-to-end; the full rebuild runs only at compaction. Re-evaluate the production + apply-interval default (candidate: 30 s → ~2 s or event-driven). Remove the dead + per-apply rebuild branch. + +- **Phase 6 — cleanup (done):** grep-removed every `IDXDELTA` dev marker; kept + the build.rs git stamp (folded into the `uffsd starting` banner) and the + per-apply timing (now the `usn apply: batch applied` DEBUG summary); folded the + baseline into the committed `apply_cost` perf bench; retargeted + `idx-delta-verify.rs` onto the graduated logs (§9, §10). + +## 5. Detailed implementation guidelines (junior-dev executable) + +### 5.1 New types (`crates/uffs-core/src/compact/delta.rs`, new file) + +```rust +/// Mutable overlay over the immutable base CSR indexes. `None` on +/// DriveCompactIndex means "freshly compacted — pure base, zero overhead". +#[derive(Debug, Default, Clone)] +pub struct IndexDelta { + /// packed-trigram -> sorted, deduped record indices added since compaction. + pub trigram: rustc_hash::FxHashMap>, + /// ext_id -> record indices added since compaction. + pub ext: rustc_hash::FxHashMap>, + /// parent record idx -> child record indices added since compaction. + pub children: rustc_hash::FxHashMap>, + /// record indices whose BASE postings are stale (deleted / renamed-away). + pub tombstones: rustc_hash::FxHashSet, + /// running count of distinct records touched (compaction trigger input). + pub touched_records: u32, +} +``` + +- All postings kept **sorted + deduped** on insert (binary-search insert) so the + base∪delta merge is a linear sorted-merge. +- Provide: `add_record(idx, trigrams: &[u64], ext_id, parent_idx)`, + `tombstone(idx)`, `is_tombstoned(idx)`, `len()` (for compaction trigger). + +### 5.2 `DriveCompactIndex` accessors (single choke point) + +Implement on `DriveCompactIndex` (in `compact.rs`), each a passthrough when +`self.delta.is_none()`: + +```rust +pub fn trigram_search(&self, needle: &str) -> Option> { + let base = self.trigram.search(needle, self.fold)?; // existing logic + let Some(delta) = &self.delta else { return Some(base); }; // fast path + // merge per-trigram postings from delta, re-intersect, filter tombstones + // (helper: merge_and_filter — see delta.rs) + Some(self.merge_trigram(needle, base, delta)) +} +``` + +> **Correctness note for trigram:** because trigram search is an **AND +> intersection** across the needle's trigrams, a delta record only survives if it +> is in the delta posting for *every* trigram of the needle. Since `add_record` +> inserts the idx into all of the record's name-trigrams, this holds. Tombstone +> filtering is applied to the final intersected set, never per-list (a base idx +> may legitimately appear in some lists; only the final membership matters). + +### 5.3 `apply_usn_patch` changes (`compact_loader.rs`) + +Today (per phase, replace the rebuild for the migrated index): + +```rust +// BEFORE (per apply): +drive.trigram = TrigramIndex::build(&drive.records, &drive.names, drive.fold); // ~500ms + +// AFTER (per apply): +let delta = drive.delta.get_or_insert_with(IndexDelta::default); +for &idx in &created_or_renamed_idxs { + delta.add_record(idx, &trigrams_for(idx), ext_of(idx), parent_of(idx)); +} +for &idx in &deleted_or_renamed_old { + delta.tombstone(idx); +} +if delta.len() > COMPACT_THRESHOLD { compact(drive); } // occasional full rebuild +``` + +Keep `compact(drive)` = the *current* full rebuild (children+trigram+ext+ +path-lengths), then `drive.delta = None`. + +### 5.4 Serialization + +The compact-cache (`compact_cache.rs`) serializes **base only**. Before a disk +save, **compact first** (fold delta → base), then serialize. So the on-disk +format is unchanged and always delta-free. (Cold load → `delta = None`.) + +### 5.5 Phase 1 — incremental `compute_path_lengths` (the #1 lever) + +`compute_path_lengths` today (`compact.rs`) builds a parent→children adjacency +and BFS-recomputes **every** record's `path_len` where +`path_len = parent.path_len + 1 (separator) + name_char_count`. That O(n) BFS is +the 623 ms. The incremental version only touches what changed. + +**Inputs.** `apply_usn_patch`'s per-change loop already knows each touched +record's compact idx and disposition. Collect them into a small list as the loop +runs (no extra pass): `Vec<(u32 idx, PathOp)>` where +`PathOp = { Created, FileRenamed, DirRenamed, Deleted }`. The directory bit comes +from `CompactRecord::flags` (`FILE_ATTRIBUTE_DIRECTORY`). + +**New fn** (e.g. `compact.rs::update_path_lengths_incremental`): + +```rust +pub(crate) fn update_path_lengths_incremental( + records: &mut [CompactRecord], + names: &[u8], + drive_letter: DriveLetter, + children: &ChildrenIndex, // the freshly-rebuilt CSR (Phase 1 keeps it) + changed: &[(u32, PathOp)], +) { + for &(idx, op) in changed { + match op { + PathOp::Deleted => {} // tombstoned; path_len irrelevant + PathOp::Created | PathOp::FileRenamed => { + // parent is unchanged → its path_len is valid. O(1). + set_path_len_from_parent(records, names, drive_letter, idx); + } + PathOp::DirRenamed => { + let old = records[idx as usize].path_len; + set_path_len_from_parent(records, names, drive_letter, idx); + let delta = i32::from(records[idx as usize].path_len) - i32::from(old); + if delta != 0 { + // every descendant's path runs *through* this dir, so its + // path_len shifts by exactly `delta`. DFS/BFS the subtree + // via the children CSR; pure arithmetic, no name walk. + shift_subtree_path_len(records, children, idx, delta); + } + } + } + } +} +``` + +- `set_path_len_from_parent`: `path_len = parent.path_len + 1 + name_char_count` + (root/drive cases identical to the BFS seed in `compute_path_lengths`). +- `shift_subtree_path_len`: stack/queue over `children.get(idx)` recursively, + `rec.path_len = (rec.path_len as i32 + delta) as u16` (saturating). + +**Wiring** (`compact_loader/rebuild.rs`): in Phase 1 keep the children/trigram/ext +full rebuilds, but **replace the `compute_path_lengths(...)` call with +`update_path_lengths_incremental(..., changed)`**. Children must be rebuilt +*before* the path update so the subtree walk sees current adjacency. Gate behind +a `changed.len() < FULL_RECOMPUTE_THRESHOLD` fallback to the full BFS for +pathological huge batches (and for the cold-load path, which still calls the full +`compute_path_lengths`). + +**Edge cases the oracle (§7) must cover:** rename a directory with a deep subtree +(Δ propagation); FRS-reuse (create into a just-deleted slot); a file whose parent +was itself renamed in the same batch (process parents before children — sort +`changed` by depth, or rely on the BFS order the children CSR already gives); +case-only rename (`name_char_count` unchanged → Δ = 0, no subtree walk). + +## 6. Risk register + +| Risk | Mitigation | +|------|------------| +| Search correctness drift (base∪delta ≠ truth) | Oracle test (§7) is mandatory per phase; property-based over random op sequences. | +| Hot-path latency regression (delta merge cost) | Passthrough when `delta == None`; baseline timing gate (§8); keep delta small via compaction threshold. | +| Tombstone leak (memory grows on churny drive) | Compaction threshold bounds delta+tombstone size; `touched_records` trigger. | +| Rename edge cases (FRS reuse, case-only rename) | Dedicated oracle scenarios; reuse the USN net-state resolution already in `uffs-mft::usn`. | +| Path resolver fed stale children (Phase 3) | Path-resolver-specific oracle; Phase 3 isolated + last. | + +## 7. Oracle test harness (the core correctness guarantee) + +**Invariant:** for any sequence of USN ops, the two-tier index must be +**observationally identical** to a freshly-rebuilt full index. + +Location: `crates/uffs-core/src/compact/delta_oracle_tests.rs`. + +``` +fn oracle(ops: &[Op]) { + let mut incremental = base_index(); // two-tier (delta path) + let mut rebuilt = base_index(); // control (full rebuild every apply) + for op in ops { + apply_incremental(&mut incremental, op); // delta path + apply_full_rebuild(&mut rebuilt, op); // O(n) control + for q in QUERY_BATTERY { // name / --ext / prefix / tree / path-resolve + assert_eq!(sorted(incremental.query(q)), sorted(rebuilt.query(q)), + "divergence after {op:?} on query {q:?}"); + } + } + // After a forced compaction, the base CSR must be byte-identical to a + // from-scratch rebuild of the same record set. + incremental.compact(); + assert_eq!(incremental.trigram, rebuilt.trigram); // byte-identical + assert_eq!(incremental.children, rebuilt.children); + assert_eq!(incremental.ext_index, rebuilt.ext_index); +} +``` + +- **Query battery:** exact-name, substring (trigram), `--ext`, prefix, tree/glob, + and **path resolution** (FastPathResolver) — one assertion per query type. +- **Op generation:** both hand-written regression scenarios (create→rename→delete, + FRS reuse, case-only rename, delete-then-recreate-into-same-dir) **and** a + `proptest`/seeded-random generator over `{create, delete, rename}` with a small + name alphabet (so trigrams collide and intersections are exercised). +- Runs cross-platform (no live MFT — synthetic records), so it gates every PR. + +## 8. Baseline + timing-regression detection + +- Add an env-gated micro-benchmark (`cargo bench` or a `#[ignore]` timing test) + that, on a synthetic N-record drive, measures: **apply latency**, **trigram / + ext / children search latency** at delta sizes `{0, 1k, 10k, 50k}`, and + **compaction latency**. +- Capture a **baseline JSON** (`docs/architecture/baselines/incremental-index-.json`) + committed at the end of Phase 0 (pure-base numbers) and refreshed per phase. +- **Committed perf guard (landed):** the cross-platform `apply_cost` Criterion + bench (`crates/uffs-core/benches/apply_cost.rs`) applies representative batches + (`creates/256`, `creates/4000`, `mixed/4000`, `deletes/4000`) to a ~500k-record + fixture and times the apply alone (clone excluded via `iter_batched`). Paired + with `overlay_read.rs` (search-under-churn), this is the regression guard the + `IDXDELTA-TIMING` baseline graduated into. The §10 WIN rig is the live + confirmation under real USN churn. + +## 9. Dev instrumentation — `IDXDELTA` marker (removed in Phase 6) + +During the build-out, all temporary logging/timing carried the literal token +`IDXDELTA` so it could be grep-and-removed in one pass. Phase 6 did exactly that +(`grep -rn IDXDELTA crates/ scripts/` → zero hits). Two pieces graduated into +permanent facilities instead of being deleted: + +- **Build identifier** — the `git=` build stamp (emitted by + `uffs-daemon/build.rs` as `UFFS_GIT_SHA`) folded into the existing + `uffsd starting` INFO banner, so every field log still pins which binary + produced it (the wrong-build trap is closed permanently, not just for the dev + flow). +- **Per-apply timing** — the per-step `IDXDELTA-TIMING apply` lines became the + single `usn apply: batch applied` DEBUG summary in `compact_loader/rebuild.rs` + (`changes / created / deleted / renamed / skipped / records / ext_index_entries + / compacted / apply_us`). The whole-body clone timing was dropped (the clone is + now Arc-shared and cheap — Phase 3). + +The per-search timing + compaction-event lines were never needed beyond the +overlay bring-up and were removed outright. + +## 10. Dev test-script — `scripts/windows/idx-delta-verify.rs` + +Modeled on `scripts/windows/usn-verify.rs` (same `~/bin/uffs.exe` resolution, +`~/idxtest` scratch, `_run/` artifact dir, daemon-restart-with-logging pattern). +What it adds beyond usn-verify: + +1. **Build confirmation** — read the `git=` stamp off the `uffsd starting` banner + and assert it equals repo HEAD (fail fast on a stale binary). +2. **Churn generator** — create / rename / delete in escalating bursts (1 000, + 10 000, 100 000 files) so the delta grows and the 100k burst crosses the 50k + compaction threshold, capturing each `usn apply: batch applied` DEBUG line to + `_run/idx-timing.log` (perf) and a per-burst freshness probe (correctness). +3. **Freshness probe** — after a burst, measure wall-clock from file-op to + search-visible (should be ≈ apply cadence, no backlog). +4. **Per-apply summary** — parse the captured apply lines into `_run/baseline.txt`: + applies fired, changes coalesced, mean/max `apply_us`, and compaction count. + The cross-platform `apply_cost` Criterion bench is the committed perf guard; + this on-box summary is the live confirmation under real USN churn. +5. **Mutate smoke** — rename + delete on unique sentinel names, asserting the new + name appears, the deleted name leaves, and the old name is gone (the live + analogue of the §7 oracle). + +Output: one shareable `~/idxtest/_run/` dir, exactly like the USN flow — so we can +"push → pull on WIN → run → share `_run/`" each iteration. + +## 11. Tracking + +| Phase | Item | Status | PR | Notes | +|-------|------|--------|----|----| +| 0 | Design doc (+ measured baseline + data-driven re-order) | ✅ done | `2e57d6013`, this | | +| 0 | Dev markers + build-id stamp (§9) | ✅ done | `629966bc2` | `IDXDELTA` | +| 0 | Per-step apply timing (clone/loop/rebuild) | ✅ done | `629966bc2` | µs integers | +| 0 | `idx-delta-verify.rs` WIN rig + baseline (§8, §10) | ✅ done | `629966bc2` | ≈1367 ms | +| 0 | `IndexDelta` type | ✅ done | `61dfde09d` | `compact/delta.rs`, unit-tested; posting/tombstone overlay | +| 0 | `delta: Option` field on `DriveCompactIndex` | ✅ done | `1cf72d589` | wired with `trigram_search` (Phase 2a) so each of ~20 ctor sites was touched once | +| 0 | Oracle harness (§7) | ✅ done | `9806bc339`, `b7c688e09` | path-len oracle + trigram base+delta oracle (overlay ≡ compacted rebuild) | +| **1** | **Incremental `compute_path_lengths` (§5.5)** | ✅ done | `9806bc339` | 623 ms → ~O(changed); WIN-validated 0.005 ms; oracle byte-identical incl. dir-rename subtree Δ | +| **2a** | **`trigram_search` base+delta choke point (plumbing)** | ✅ done | `1cf72d589` | zero-behavior-change; field + 3 caller migration; rename-visibility unit-tested | +| **2b** | **Apply populates trigram delta; no per-tick rebuild** | ✅ done | `b7c688e09` | 338 ms → ~0 (compaction at 50k touched); end-to-end oracle; awaiting WIN timing | +| — | *Decompose `compact.rs` 1363 → 385* (refactor) | ✅ done | `c3728b0c1` | 5 submodules; off file-size exception list | +| **3** | **Shrink clone — Arc-share base CSR indexes** | ✅ done | `33e754b04` | 166 → 78 ms (WIN); records/names/delta still copied | +| **4a** | **Extension delta (`records_with_ext`)** | ✅ done | `42ff96b94` | 58 → ~0 ms (WIN); Cow overlay, records-validated (no ext tombstone) | +| **4b** | **Children delta (`for_each_child` / `children_of`)** | ✅ done | `abe9ff115` | 60 → ~0 ms (WIN); apply reordered (delta before paths); move/create/delete + same-batch-create oracles | +| — | *Overlay read-cost microbench* | ✅ done | `1a7eff444` | churn overhead measured: small (tree walk 0.7→2.1 ms); `for_each_child` lever ready | +| **5** | **Apply cadence: debounce + max-wait (snappy + CPU-bounded)** | ✅ done | this | `ApplyTrigger` 30 s rate-limit → 250 ms debounce / 2 s max-wait; cadences evaluated every poll; full apply now ~200 ms (WIN, −85% from 1367) | +| **6** | **Remove `IDXDELTA` dev helpers; graduate baseline → perf test** | ✅ done | this | `grep -rn IDXDELTA crates/ scripts/` → 0; git stamp folded into `uffsd starting`; per-apply → `usn apply: batch applied` DEBUG; `apply_cost` bench is the committed perf guard; WIN rig retargeted | + +**Done-definition (whole project):** apply is O(changes); oracle green; no search +latency regression vs baseline; production apply interval reduced; all `IDXDELTA` +dev scaffolding removed. **All met.** diff --git a/just/test.just b/just/test.just index f13c38076..f89e52e6f 100644 --- a/just/test.just +++ b/just/test.just @@ -505,9 +505,15 @@ uninstall-hooks: @printf "\033[0;32m✅ Git hooks uninstalled — commits and pushes are no longer gated.\033[0m\n" # Validate rustdoc links and warnings (catches private-intra-doc-links etc.). +# +# `--document-private-items` is REQUIRED for the "catches private-intra-doc-links" +# promise: without it rustdoc only validates links reachable from the public API +# surface, so a broken `[`crate::path::pub_crate_item`]` (or a `//!` shortcut to a +# private sibling) silently renders as dead text. Cross-platform: `#[cfg(windows)]` +# items absent on the macOS/Linux host are referenced as code spans, not links. rustdoc: @printf "\033[0;34m📚 Rustdoc link validation...\033[0m\n" - RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps + RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps --document-private-items # Enforce oversized Rust file policy with explicit exceptions. file-size-policy: diff --git a/just/workflow.just b/just/workflow.just index 527cbd2dd..84f7a0df7 100644 --- a/just/workflow.just +++ b/just/workflow.just @@ -21,8 +21,8 @@ phase1-test: @printf "\033[0;36m → just test-doc\033[0m\n" just test-doc @printf "\033[0;34mStep 3b: Rustdoc link validation (FAST-FAIL)...\033[0m\n" - @printf "\033[0;36m → RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps\033[0m\n" - RUSTDOCFLAGS='-Dwarnings' cargo doc --workspace --all-features --no-deps + @printf "\033[0;36m → just rustdoc\033[0m\n" + just rustdoc @printf "\033[0;34mStep 4: Ultra-strict production code linting (FAST-FAIL)...\033[0m\n" @printf "\033[0;36m → just lint-prod\033[0m\n" just lint-prod diff --git a/scripts/ci-pipeline/src/phases.rs b/scripts/ci-pipeline/src/phases.rs index a6c210c05..dc0643df2 100644 --- a/scripts/ci-pipeline/src/phases.rs +++ b/scripts/ci-pipeline/src/phases.rs @@ -215,11 +215,16 @@ async fn phase1_fanout_validation(ctx: &PipelineContext) -> Result<()> { ("Anti-pattern gate", "bash", vec![ "scripts/ci/anti_pattern_gate.sh", ]), + // `--document-private-items` is REQUIRED to validate links across the + // private surface (`pub(crate)` items, `//!` shortcuts to private + // siblings); without it rustdoc only checks the public API and a broken + // link silently renders as dead text. Mirrors `just rustdoc`. ("Rustdoc link validation", "cargo", vec![ "doc", "--workspace", "--all-features", "--no-deps", + "--document-private-items", ]), ]; execute_parallel_with_env(parallel_commands, &[("RUSTDOCFLAGS", "-Dwarnings")], ctx).await diff --git a/scripts/ci-pipeline/src/ship.rs b/scripts/ci-pipeline/src/ship.rs index 9aef782b5..457fb4ec3 100644 --- a/scripts/ci-pipeline/src/ship.rs +++ b/scripts/ci-pipeline/src/ship.rs @@ -345,11 +345,14 @@ async fn tracked_parallel_validation_step( "unused-crate-dependencies", ]), ("Dependency security", "cargo", vec!["deny", "check"]), + // `--document-private-items` validates links across the private + // surface too — see the matching note in `phases.rs`. ("Rustdoc link validation", "cargo", vec![ "doc", "--workspace", "--all-features", "--no-deps", + "--document-private-items", ]), ]; execute_parallel_with_env(parallel_commands, &[("RUSTDOCFLAGS", "-Dwarnings")], ctx).await diff --git a/scripts/ci/file_size_exceptions.txt b/scripts/ci/file_size_exceptions.txt index b6d8908d4..91304a425 100644 --- a/scripts/ci/file_size_exceptions.txt +++ b/scripts/ci/file_size_exceptions.txt @@ -14,7 +14,6 @@ crates/uffs-mft/src/reader/index_read.rs|PERMANENT: Single impl MftReader block crates/uffs-diag/src/bin/compare_scan_parity.rs|PERMANENT: Standalone diagnostic binary; single-file readability outweighs LOC policy for tooling crates/uffs-mcp/src/cookbook.rs|PERMANENT: Declarative JSON data (curated agent cookbook examples); splitting by line count would fragment the cohesive narrative crates/uffs-core/src/compact_cache.rs|PERMANENT: Serialize/deserialize pipeline for compact index cache (heap path + Phase 2b runtime mmap path), tightly coupled with shared parse_compact_body + assemble_compact_index helpers; tests already extracted to compact_cache/tests.rs sibling module -crates/uffs-core/src/compact.rs|PERMANENT: Core compact index data structures + builder; only 13 over limit crates/uffs-daemon/src/index/aggregation.rs|PERMANENT: Daemon-side aggregation dispatch; tightly coupled helpers, only 54 over limit crates/uffs-client/src/protocol/tests.rs|PERMANENT: Wire format round-trip test suite; splitting fragments test cohesion crates/uffs-core/src/search/backend_tests.rs|PERMANENT: Backend sort/filter integration test suite; shared fixtures require cohesion diff --git a/scripts/ci/gates.toml b/scripts/ci/gates.toml index 634af829a..5b8a50725 100644 --- a/scripts/ci/gates.toml +++ b/scripts/ci/gates.toml @@ -392,6 +392,7 @@ command = [ "--all-features", "--no-deps", "--locked", + "--document-private-items", ] tiers = ["pre-push", "pr-fast"] gate_when = "rust_changed" @@ -405,6 +406,13 @@ notes = """ Catches broken doc-links, unresolved `[symbol]` references, and any intra-doc cross-reference drift. pr-fast's `docs` job runs both this and `doc-tests` in one step. + +`--document-private-items` is REQUIRED: without it rustdoc validates +only links reachable from the public API surface, so a broken +`[`crate::path::pub_crate_item`]` (or a `//!` shortcut to a private +sibling) silently renders as dead text instead of failing. Cross- +platform — `#[cfg(windows)]` items absent on the macOS/Linux runner +are written as code spans, not links. """ [[gate]] diff --git a/scripts/hooks/_lint_pre_push.sh b/scripts/hooks/_lint_pre_push.sh index acb49450d..0b98bf457 100755 --- a/scripts/hooks/_lint_pre_push.sh +++ b/scripts/hooks/_lint_pre_push.sh @@ -238,7 +238,7 @@ if (( CODE_CHANGED )); then run_seq "lint-ci-no-default" just lint-ci-no-default run_seq "lint-prod" just lint-prod run_seq "lint-tests" just lint-tests - run_seq "rustdoc" env RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --all-features --no-deps --locked + run_seq "rustdoc" env RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --all-features --no-deps --locked --document-private-items run_seq "doc-tests" env RUSTDOCFLAGS=-Dwarnings cargo test --doc --workspace --all-features --locked run_seq "tests" cargo nextest run --workspace --all-targets --all-features --no-run --locked --hide-progress-bar run_seq "smoke" cargo nextest run --workspace --profile pre-push-smoke --locked diff --git a/scripts/windows/idx-delta-verify.rs b/scripts/windows/idx-delta-verify.rs new file mode 100644 index 000000000..12e99c516 --- /dev/null +++ b/scripts/windows/idx-delta-verify.rs @@ -0,0 +1,553 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1" +//! ``` +//! +//! idx-delta-verify.rs — live WIN verification + perf guard for the incremental- +//! index-maintenance work (design: `docs/architecture/incremental-index-maintenance.md`). +//! +//! Goal: on the WIN box, prove the delta-overlay apply stays correct (creates, +//! renames, deletes become search-visible promptly) AND fast (per-apply cost +//! tracks the batch, not the drive size). It deliberately mirrors +//! `scripts/windows/usn-verify.rs` (same `~/bin/uffs.exe` resolution, `~/idxtest` +//! scratch, `_run/` artifacts, daemon-restart-with-logging) so the dev loop is +//! identical: push -> pull on WIN -> run -> share `_run/`. +//! +//! What it does: +//! 0. BIN SYNC — copies the freshly built `uffs`/`uffsd` (+ broker/mcp if +//! present) from **the build dir cargo actually uses** (`cargo metadata`'s +//! `target_directory`, honouring `CARGO_TARGET_DIR` / `.cargo/*.toml`; +//! override with `UFFS_RELEASE_DIR`) into `~/bin`, so the rig can never run +//! a stale daemon. Build, then run — no manual copy step. +//! 1. BUILD CONFIRMATION — restarts the daemon with logging, then reads the +//! `git=` stamp off the `uffsd starting` line and asserts it equals repo +//! HEAD (hard stale-daemon guard). +//! 2. CHURN + TIMING — creates files in escalating bursts so each apply fires, +//! captures every `usn apply: batch applied` DEBUG line, and summarises the +//! per-apply wall-clock + compaction count at the drive's live record count. +//! 3. FRESHNESS — measures wall-clock from a create to the file being +//! search-visible (sanity: no backlog at the pinned apply cadence). +//! 4. BASELINE — writes `_run/baseline.txt` (the per-apply numbers) + +//! `_run/idx-timing.log` (the raw `usn apply` lines). +//! +//! Usage: rust-script scripts\windows\idx-delta-verify.rs + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; + +/// Phase-5 apply **max-wait** cap (ms) for the test daemon — the ceiling under +/// sustained churn. With apply now ~200 ms (Phases 1-4), the production default +/// is 2 s; the rig pins it explicitly so the apply cadence is deterministic +/// across runs. +const APPLY_INTERVAL_MS: &str = "2000"; +/// Phase-5 apply **debounce / settle** window (ms) — the snappy half: a burst +/// that goes quiet for this long is applied at once, so an idle→active change +/// is searchable well under a second. +const APPLY_DEBOUNCE_MS: &str = "250"; +/// Settle after `--daemon stop` so the socket / PID file clear. +const KILL_SETTLE: Duration = Duration::from_secs(2); +/// Poll cadence while waiting for a burst's files to become search-visible. +const POLL_INTERVAL: Duration = Duration::from_millis(500); +/// `uffs_core=debug` surfaces the per-batch `usn apply: batch applied` summary +/// (logged at DEBUG); `info` everywhere else keeps the daemon `uffsd starting` +/// build stamp and the rest of the log readable. +const LOG_SPEC: &str = "info,uffs_core=debug,uffs_daemon=info"; +/// Escalating create-burst sizes — bigger bursts exercise bigger apply batches. +/// The 100k burst crosses `TRIGRAM_COMPACT_THRESHOLD` (50k) so it also forces a +/// delta compaction (full base refold, `compacted=true`) under load, while the +/// smaller bursts stay on the steady-state delta-overlay apply. +const BURSTS: &[usize] = &[1_000, 10_000, 100_000]; + +/// `~/bin/uffs.exe` — the canonical user-installed **Rust** binary. Pinned to +/// the explicit `.exe` so a bare `uffs` can't resolve the C++ `uffs.com` via +/// PATHEXT (see usn-verify.rs). Copy your freshly built binaries into `~/bin` +/// first — the spawned `uffsd.exe` is the one next to this `uffs.exe`. +fn uffs_bin() -> PathBuf { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .expect("USERPROFILE or HOME must be set"); + let name = if cfg!(windows) { "uffs.exe" } else { "uffs" }; + home.join("bin").join(name) +} + +/// Display name for the cosmetic `$ ...` echoes — `uffs.exe`, never bare `uffs`. +fn uffs_display() -> &'static str { + if cfg!(windows) { "uffs.exe" } else { "uffs" } +} + +fn home_dir() -> PathBuf { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .expect("USERPROFILE or HOME must be set") +} + +/// Binaries the rig depends on, copied fresh from the build dir into `~/bin`. +/// `uffs` + `uffsd` are required (the daemon under test); the broker is +/// optional (only present once `uffs-broker` has been built) and copied +/// best-effort so a non-elevated box still re-syncs the two it needs. +const REQUIRED_BINS: &[&str] = &["uffs", "uffsd"]; +const OPTIONAL_BINS: &[&str] = &["uffs-broker", "uffsmcp"]; + +/// Add the platform executable suffix (`.exe` on Windows). +fn exe(name: &str) -> String { + if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() } +} + +/// Resolve the `release/` dir of **the build cargo actually uses** — honouring +/// `CARGO_TARGET_DIR`, `.cargo/*.toml` `build.target-dir`, etc. — so the rig +/// copies the binary that was just built, not a stale `~/bin` copy (the +/// stale-binary trap that has bitten this dev loop repeatedly). +/// +/// Order: explicit `UFFS_RELEASE_DIR` override → `cargo metadata`'s +/// `target_directory` + `release`. +fn release_dir() -> Result { + if let Some(dir) = std::env::var_os("UFFS_RELEASE_DIR") { + return Ok(PathBuf::from(dir)); + } + let out = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps"]) + .output() + .context("failed to run `cargo metadata` to locate the build dir")?; + if !out.status.success() { + bail!( + "`cargo metadata` failed ({}). Run the rig from inside the repo, or set \ + UFFS_RELEASE_DIR to your build's release dir.", + out.status + ); + } + let json = String::from_utf8_lossy(&out.stdout); + let target = parse_target_directory(&json).context( + "could not find target_directory in `cargo metadata` output; \ + set UFFS_RELEASE_DIR explicitly", + )?; + Ok(PathBuf::from(target).join("release")) +} + +/// Extract the JSON string value of `"target_directory"` from one-line +/// `cargo metadata` output, unescaping `\\`/`\"`/`\/` (Windows paths arrive as +/// `C:\\rust-target\\ttapi`). No serde dependency — a focused hand-scan. +fn parse_target_directory(json: &str) -> Option { + let key = "\"target_directory\":\""; + let start = json.find(key)? + key.len(); + let mut out = String::new(); + let mut chars = json[start..].chars(); + while let Some(ch) = chars.next() { + match ch { + '"' => return Some(out), + '\\' => match chars.next()? { + 'n' => out.push('\n'), + other => out.push(other), // \\ -> \, \" -> ", \/ -> / + }, + other => out.push(other), + } + } + None +} + +/// Short HEAD SHA of the repo (`git rev-parse --short HEAD`), for the +/// build-id match guard. `None` if git is unavailable. +fn git_head_short() -> Option { + let out = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) + .filter(|sha| !sha.is_empty()) +} + +/// Whether the diff between the running daemon's build SHA and HEAD touches any +/// **build-affecting** path (crate source or a Cargo manifest), i.e. the binary +/// is genuinely stale. A HEAD that advanced only through `scripts/` or `docs/` +/// (e.g. a verify-rig tweak) leaves the daemon binary current, so it is NOT +/// stale. Defaults to `true` (assume stale) if git can't answer — fail safe. +fn build_is_stale(daemon_sha: &str, head_sha: &str) -> bool { + let Ok(out) = Command::new("git") + .args(["diff", "--name-only", daemon_sha, head_sha]) + .output() + else { + return true; + }; + if !out.status.success() { + return true; + } + String::from_utf8_lossy(&out.stdout).lines().any(|path| { + path.starts_with("crates/") + || path == "Cargo.toml" + || path == "Cargo.lock" + || path.starts_with("rust-toolchain") + }) +} + +/// Copy freshly built binaries from the cargo build dir into `~/bin` so the rig +/// always exercises the just-built daemon. Required bins missing → bail with a +/// "build first" hint; optional bins are copied only if present. +fn sync_bins(bin_dir: &Path) -> Result<()> { + let src_dir = release_dir()?; + println!("\n== Bin sync =="); + println!(" build dir: {}", src_dir.display()); + println!(" dest: {}", bin_dir.display()); + fs::create_dir_all(bin_dir).with_context(|| format!("create {}", bin_dir.display()))?; + + for name in REQUIRED_BINS { + let src = src_dir.join(exe(name)); + if !src.exists() { + bail!( + "required binary {} not found — build first \ + (e.g. `cargo build --release -p uffs-cli -p uffs-daemon`).", + src.display() + ); + } + copy_bin(&src, &bin_dir.join(exe(name)))?; + } + for name in OPTIONAL_BINS { + let src = src_dir.join(exe(name)); + if src.exists() { + // Best-effort: the broker is a running LocalSystem service, so its + // exe is legitimately locked (os error 32). The rig only needs a + // fresh uffs + uffsd, so a locked/failed optional copy just warns. + if let Err(err) = copy_bin(&src, &bin_dir.join(exe(name))) { + println!(" skip {} ({err})", exe(name)); + } + } + } + Ok(()) +} + +/// Copy one binary, reporting its source build mtime so a stale build is +/// visible at a glance. +fn copy_bin(src: &Path, dest: &Path) -> Result<()> { + let built = src + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.elapsed().ok()) + .map_or_else(|| "?".to_owned(), |age| format!("{}s ago", age.as_secs())); + fs::copy(src, dest) + .with_context(|| format!("copy {} -> {}", src.display(), dest.display()))?; + println!(" copied {} (built {built})", dest.display()); + Ok(()) +} + +/// Run a `uffs.exe` subcommand inheriting stdout/stderr. +fn run(uffs: &Path, args: &[&str]) -> Result<()> { + println!("\n$ {} {}", uffs_display(), args.join(" ")); + Command::new(uffs) + .args(args) + .status() + .with_context(|| format!("failed to spawn uffs {}", args.join(" ")))?; + Ok(()) +} + +/// Run a search, return (row_count, captured_stdout). A row is a quoted CSV +/// data line (minus the header). +fn search(uffs: &Path, term: &str) -> Result<(usize, String)> { + let output = Command::new(uffs) + .args([term, "--format", "csv"]) + .output() + .with_context(|| format!("failed to spawn uffs {term}"))?; + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + let rows = text + .lines() + .filter(|line| line.starts_with('"')) + .count() + .saturating_sub(1); + Ok((rows, text)) +} + +/// Poll `search(term)` until at least `expected` rows are visible or `max_wait` +/// elapses. Returns `(rows_seen, latency, timed_out)` — the wall-clock from the +/// first poll to visibility is the true apply-to-searchable latency (vs. the old +/// fixed-sleep probe which only measured the settle constant). +fn poll_until_visible( + uffs: &Path, + term: &str, + expected: usize, + max_wait: Duration, +) -> Result<(usize, Duration, bool)> { + let start = Instant::now(); + loop { + let (rows, _) = search(uffs, term)?; + if rows >= expected { + return Ok((rows, start.elapsed(), false)); + } + if start.elapsed() >= max_wait { + return Ok((rows, start.elapsed(), true)); + } + sleep(POLL_INTERVAL); + } +} + +/// Poll `search(term)` until **zero** rows match (the deleted / renamed-away +/// file has left the index) or `max_wait` elapses. Returns +/// `(rows_remaining, latency, timed_out)`. +fn poll_until_absent(uffs: &Path, term: &str, max_wait: Duration) -> Result<(usize, Duration, bool)> { + let start = Instant::now(); + loop { + let (rows, _) = search(uffs, term)?; + if rows == 0 { + return Ok((0, start.elapsed(), false)); + } + if start.elapsed() >= max_wait { + return Ok((rows, start.elapsed(), true)); + } + sleep(POLL_INTERVAL); + } +} + +fn main() -> Result<()> { + let uffs = uffs_bin(); + + // Sync freshly built bins from the actual cargo build dir into ~/bin so the + // rig never runs a stale daemon. Capture HEAD so the build-confirmation + // step can assert the running uffsd is THIS commit. + let bin_dir = home_dir().join("bin"); + sync_bins(&bin_dir)?; + let head_sha = git_head_short(); + + if !uffs.exists() { + bail!( + "uffs binary not found at {} even after bin sync — check the build dir.", + uffs.display() + ); + } + + let base = home_dir().join("idxtest"); + let run_dir = base.join("_run"); + println!("== UFFS incremental-index baseline rig =="); + println!("binary: {}", uffs.display()); + println!("scratch: {}", base.display()); + println!("artifacts: {}", run_dir.display()); + + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(&run_dir).with_context(|| format!("create {}", run_dir.display()))?; + + run(&uffs, &["--version"])?; + + // ── Restart the daemon with logging into the artifacts dir ────────────── + let _ = Command::new(&uffs).args(["--daemon", "stop"]).status(); + sleep(KILL_SETTLE); + println!( + "\n$ {} --daemon start (UFFS_LOG={LOG_SPEC}, UFFS_USN_APPLY_INTERVAL_MS={APPLY_INTERVAL_MS}, UFFS_USN_APPLY_DEBOUNCE_MS={APPLY_DEBOUNCE_MS})", + uffs_display() + ); + let status = Command::new(&uffs) + .args(["--daemon", "start"]) + .env("UFFS_LOG", LOG_SPEC) + .env("UFFS_LOG_DIR", &run_dir) + .env("UFFS_USN_APPLY_INTERVAL_MS", APPLY_INTERVAL_MS) + .env("UFFS_USN_APPLY_DEBOUNCE_MS", APPLY_DEBOUNCE_MS) + .status() + .context("failed to spawn `uffs --daemon start`")?; + if !status.success() { + bail!("`uffs --daemon start` exited with {status}"); + } + run(&uffs, &["--status"])?; + + let log_path = run_dir.join("uffsd.log"); + + // ── 1. BUILD CONFIRMATION — fail fast on a stale binary ───────────────── + println!("\n== Build confirmation =="); + let build_line = read_log(&log_path) + .lines() + .find(|line| line.contains("uffsd starting")) + .map(str::to_owned); + let build_line = match build_line { + Some(line) => { + println!(" OK — {}", line.trim()); + line + } + None => bail!( + "no `uffsd starting` line in {} — the daemon did not log a startup banner. \ + Rebuild then re-run (the rig re-syncs ~/bin for you).", + log_path.display() + ), + }; + + // Build-id match guard: the running daemon's git SHA must equal repo HEAD, + // else a stale uffsd is being exercised (the trap that has cost several + // 30-min WIN cycles). `git=""` is stamped on the `uffsd starting` line. + if let Some(head) = &head_sha { + let logged = build_line + .split("git=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .unwrap_or(""); + if logged == head { + println!(" build-id match: uffsd git={logged} == HEAD {head}"); + } else if build_is_stale(logged, head) { + bail!( + "STALE DAEMON: running uffsd is git={logged:?} but HEAD is {head:?} and \ + crate source / Cargo manifests differ between them — rebuild + re-run \ + (the rig re-syncs ~/bin, but you must `cargo build --release` first).", + ); + } else { + // HEAD advanced only through scripts/docs (e.g. this rig itself); + // the daemon binary is still current with the crate source. + println!( + " build-id OK: uffsd git={logged}, HEAD={head} differ only in \ + non-source files — binary is current." + ); + } + } + + // ── 2 + 3. CHURN, TIMING, FRESHNESS ───────────────────────────────────── + // Each burst is measured independently via a per-round filename prefix so + // the poll target is exactly that burst's `count` (not the running total), + // and creation throughput is reported apart from apply-to-visible latency. + for (round, &count) in BURSTS.iter().enumerate() { + println!("\n== Burst {}: create {count} files ==", round + 1); + let create_start = Instant::now(); + for i in 0..count { + fs::write(base.join(format!("idx_{round}_{i}.tmp")), b"x") + .with_context(|| format!("write idx_{round}_{i}.tmp"))?; + } + let create_elapsed = create_start.elapsed(); + + // Visibility budget scales with batch size: file-creation IO + USN poll + // + apply + (for the 100k burst) a delta compaction. ~20 s floor plus + // ~1 s per 5k files → 100k allows ~40 s before flagging a backlog. + let max_wait = Duration::from_secs(20 + (count as u64) / 5_000); + let term = format!("idx_{round}_"); + let (rows, latency, timed_out) = poll_until_visible(&uffs, &term, count, max_wait)?; + let rate = (count as f64) / create_elapsed.as_secs_f64().max(0.001); + println!( + " created {count} in {:.1}s ({:.0} files/s); '{term}' -> {rows}/{count} \ + visible after {:.1}s{}", + create_elapsed.as_secs_f64(), + rate, + latency.as_secs_f64(), + if timed_out { " <<< TIMED OUT (apply backlog)" } else { "" }, + ); + } + + // ── Rename + delete correctness smoke, on UNIQUE sentinel names ───────── + // `idxmutate*` shares no trigram with the bulk `idx__` files, so + // each search is unambiguous (the old `idx_0_1` probe matched 111 bulk + // files by substring — a false signal). Poll-until-applied, not a sleep. + println!("\n== Mutate smoke (unique sentinels) =="); + let src = base.join("idxmutate_src.tmp"); + let del = base.join("idxmutate_del.tmp"); + fs::write(&src, b"x").context("write idxmutate_src.tmp")?; + fs::write(&del, b"x").context("write idxmutate_del.tmp")?; + let (staged, _, stage_to) = + poll_until_visible(&uffs, "idxmutate", 2, Duration::from_secs(20))?; + println!( + " staged 2 sentinels; 'idxmutate' -> {staged}/2 visible{}", + if stage_to { " <<< TIMED OUT" } else { "" } + ); + + fs::rename(&src, base.join("idxmutate_renamed.tmp")).context("rename sentinel")?; + fs::remove_file(&del).context("delete sentinel")?; + + let mutate_wait = Duration::from_secs(20); + let (ren_rows, ren_lat, ren_to) = + poll_until_visible(&uffs, "idxmutate_renamed", 1, mutate_wait)?; + let (del_rows, del_lat, del_to) = poll_until_absent(&uffs, "idxmutate_del", mutate_wait)?; + let (old_rows, _, _) = poll_until_absent(&uffs, "idxmutate_src", Duration::from_secs(6))?; + println!( + " rename : 'idxmutate_renamed' -> {ren_rows} after {:.1}s (expect >=1){}", + ren_lat.as_secs_f64(), + if ren_to { " <<< FAIL/TIMED OUT" } else { "" } + ); + println!( + " delete : 'idxmutate_del' -> {del_rows} after {:.1}s (expect 0){}", + del_lat.as_secs_f64(), + if del_to { " <<< FAIL/TIMED OUT" } else { "" } + ); + println!(" oldname: 'idxmutate_src' -> {old_rows} (expect 0, renamed away)"); + + // ── Stop the daemon to flush, then extract + summarise the timing ─────── + println!("\n== Stopping daemon to flush the log =="); + let _ = Command::new(&uffs).args(["--daemon", "stop"]).status(); + sleep(KILL_SETTLE); + + let log = read_log(&log_path); + let timing_lines: Vec<&str> = log + .lines() + .filter(|line| line.contains("usn apply: batch applied")) + .collect(); + fs::write(run_dir.join("idx-timing.log"), timing_lines.join("\n"))?; + + let baseline = summarise(&timing_lines); + println!("\n== BASELINE (per-apply cost) =="); + println!("{baseline}"); + fs::write(run_dir.join("baseline.txt"), &baseline)?; + + println!("\n== Done =="); + println!("Share: {}", run_dir.display()); + println!("Key: baseline.txt (per-apply numbers), idx-timing.log (raw apply lines), uffsd.log."); + Ok(()) +} + +/// All numeric values of a `key=value` tracing field across the lines that +/// carry it. Field-generic so a new apply-summary field needs no parser change. +fn field_values(lines: &[&str], key: &str) -> Vec { + let prefix = format!("{key}="); + lines + .iter() + .filter_map(|line| { + line.split_whitespace() + .find_map(|tok| tok.strip_prefix(&prefix)) + .and_then(|raw| raw.parse::().ok()) + }) + .collect() +} + +/// Build the human-readable baseline from the `usn apply: batch applied` lines: +/// how many applies fired, the changes they coalesced, the per-apply wall-clock +/// (mean + worst case), and how many crossed the compaction threshold. The +/// per-apply `apply_us` is the number the perf guard watches — it must track the +/// batch size, not the drive's record count. +fn summarise(lines: &[&str]) -> String { + if lines.is_empty() { + return " (no `usn apply: batch applied` lines captured — did any apply fire? \ + check uffsd.log, the apply cadence, and that UFFS_LOG enables uffs_core=debug)" + .to_owned(); + } + let records = field_values(lines, "records") + .into_iter() + .fold(0_f64, f64::max); + let changes = field_values(lines, "changes"); + let total_changes: f64 = changes.iter().sum(); + let mean_changes = total_changes / changes.len().max(1) as f64; + + // `apply_us` is whole-microsecond (integer, per uffs-core's no-float policy); + // render as ms here (1 us = 0.001 ms). + let apply = field_values(lines, "apply_us"); + let mean_apply_ms = apply.iter().sum::() / apply.len().max(1) as f64 / 1000.0; + let max_apply_ms = apply.iter().copied().fold(0_f64, f64::max) / 1000.0; + + // `compacted=true` counts the applies that crossed TRIGRAM_COMPACT_THRESHOLD + // and refolded the bases (the O(total) path); the rest stayed O(changed). + let compactions = lines + .iter() + .filter(|line| line.split_whitespace().any(|tok| tok == "compacted=true")) + .count(); + + format!( + " apply lines: {}\n \ + drive records: {records:.0}\n \ + total changes: {total_changes:.0}\n \ + mean changes/apply {mean_changes:>10.0}\n \ + compactions: {compactions} (compacted=true, full base refold)\n \ + ─────────────────────────────────\n \ + mean apply {mean_apply_ms:>10.3} ms\n \ + max apply {max_apply_ms:>10.3} ms <- worst per-apply cost\n", + lines.len() + ) +} + +/// Read the daemon log, tolerating a missing file (returns empty). +fn read_log(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_default() +}