2026-08-23 00:33:53 -04:00
|
|
|
//! Parallel filesystem walk: one shared queue of directories, N workers. A
|
|
|
|
|
//! worker reads a directory **and** does its per-file work before moving on:
|
|
|
|
|
//! on SMB the directory read primes the client's attribute cache for only
|
|
|
|
|
//! about a second (`actimeo`), so an immediate `stat` is free and a late one
|
|
|
|
|
//! is a full network round trip. Every path below a root is canonical by
|
|
|
|
|
//! construction: roots are canonicalized once at seed time and directories
|
|
|
|
|
//! only ever reached by joining names onto them.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
use std::collections::HashSet;
|
2026-08-02 19:04:30 -04:00
|
|
|
use std::fs;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
2026-08-05 18:05:04 -04:00
|
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
2026-08-02 19:04:30 -04:00
|
|
|
use std::sync::{mpsc, Arc, Condvar, Mutex};
|
2026-08-05 19:17:11 -04:00
|
|
|
use std::thread::JoinHandle;
|
2026-08-02 19:04:30 -04:00
|
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
use crate::config::{Config, IgnoreSet};
|
|
|
|
|
use crate::extract::Registry;
|
|
|
|
|
use crate::file_handling::{
|
2026-08-20 02:34:08 -04:00
|
|
|
classify_by_mtime, classify_for_indexing, dir_to_db_parent, path_to_db_string,
|
|
|
|
|
prepare_file_record, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
|
2026-08-02 19:04:30 -04:00
|
|
|
};
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
mod pool;
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests;
|
|
|
|
|
|
|
|
|
|
pub(crate) use pool::WorkerStats;
|
|
|
|
|
use pool::{Found, PrefetchWork, Queue, Shared};
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// Files one worker takes for itself before handing the rest to the pool.
|
|
|
|
|
const FILES_PER_JOB: usize = 128;
|
|
|
|
|
|
|
|
|
|
const CHANNEL_CAP: usize = 4096;
|
|
|
|
|
|
|
|
|
|
const LOCAL_THREADS: usize = 4;
|
|
|
|
|
|
|
|
|
|
const NETWORK_THREADS: usize = 16;
|
|
|
|
|
|
|
|
|
|
/// One file the walk found, with everything the DB writer needs.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct WalkedFile {
|
2026-08-20 02:34:08 -04:00
|
|
|
/// Canonical path. The row it keys is `(parent, name)`; see
|
|
|
|
|
/// [`crate::file_handling::split_db_path`].
|
2026-08-02 19:04:30 -04:00
|
|
|
pub path: String,
|
|
|
|
|
pub action: FileIndexAction,
|
|
|
|
|
pub record: Option<OwnedNewFile>,
|
2026-08-23 00:33:53 -04:00
|
|
|
/// 128-bit truncated SHA-256 of the path, for the duplicate-visit set.
|
2026-08-02 22:21:39 -04:00
|
|
|
pub digest: u128,
|
2026-08-09 16:25:43 -04:00
|
|
|
/// True when this file was reached by resolving a symlink. Its row is
|
|
|
|
|
/// invisible to its real parent's reconciliation, so the caller must
|
2026-08-02 22:21:39 -04:00
|
|
|
/// exempt it from the vanished-directory sweep.
|
|
|
|
|
pub aliased: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WalkedFile {
|
2026-08-09 16:25:43 -04:00
|
|
|
/// Seen, but with nothing to write: the row stays.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn skipped(path: String, digest: u128, aliased: bool) -> Self {
|
2026-08-04 03:27:05 -04:00
|
|
|
WalkedFile {
|
|
|
|
|
path,
|
|
|
|
|
action: FileIndexAction::Skip,
|
|
|
|
|
record: None,
|
|
|
|
|
digest,
|
|
|
|
|
aliased,
|
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
#[allow(clippy::large_enum_variant)]
|
2026-08-02 22:21:39 -04:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum WalkEvent {
|
|
|
|
|
File(WalkedFile),
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Paths whose row should be deleted: in one directory's index rows,
|
|
|
|
|
/// absent from its listing. Emitted only for successfully read dirs.
|
2026-08-02 22:21:39 -04:00
|
|
|
Stale(Vec<String>),
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// One file a directory read produced. `cached` is `Some` only on Windows,
|
|
|
|
|
/// where `FindNextFileW` returns size, mtime and attributes alongside the
|
|
|
|
|
/// name; Unix `getdents64` returns only `d_type`.
|
2026-08-05 18:05:04 -04:00
|
|
|
struct PendingFile {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
cached: Option<crate::platform::CachedMetadata>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PendingFile {
|
|
|
|
|
fn uncached(path: PathBuf) -> Self {
|
|
|
|
|
PendingFile { path, cached: None }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
enum Job {
|
2026-08-02 22:21:39 -04:00
|
|
|
Dir(PathBuf, Arc<DirRows>),
|
2026-08-05 18:05:04 -04:00
|
|
|
Files(Vec<PendingFile>, Arc<DirRows>),
|
2026-08-02 22:21:39 -04:00
|
|
|
/// A resolved symlink target, with the stored mtime for its own path.
|
|
|
|
|
Alias(PathBuf, Option<u64>),
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// How many entries each filter rejected. A pruned *directory* is one
|
|
|
|
|
/// increment, not one per file beneath it: the subtree is never enumerated.
|
2026-08-05 18:05:04 -04:00
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct PruneCounts {
|
|
|
|
|
pub dot_named: AtomicU64,
|
|
|
|
|
/// Windows entries carrying `FILE_ATTRIBUTE_HIDDEN`.
|
|
|
|
|
pub attribute: AtomicU64,
|
|
|
|
|
pub ignored: AtomicU64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PruneCounts {
|
|
|
|
|
pub fn total(&self) -> u64 {
|
|
|
|
|
self.dot_named.load(Ordering::Relaxed)
|
|
|
|
|
+ self.attribute.load(Ordering::Relaxed)
|
|
|
|
|
+ self.ignored.load(Ordering::Relaxed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn summary(&self) -> Option<String> {
|
|
|
|
|
if self.total() == 0 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(format!(
|
|
|
|
|
"pruned {} entries: {} hidden by attribute, {} dot-named, {} by ignore pattern",
|
|
|
|
|
self.total(),
|
|
|
|
|
self.attribute.load(Ordering::Relaxed),
|
|
|
|
|
self.dot_named.load(Ordering::Relaxed),
|
|
|
|
|
self.ignored.load(Ordering::Relaxed),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
struct Ctx {
|
|
|
|
|
follow_symlinks: bool,
|
|
|
|
|
include_hidden: bool,
|
|
|
|
|
ignore: IgnoreSet,
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The index's own files, which this walk must never so much as open —
|
|
|
|
|
/// see [`crate::file_handling::index_file_set`] for why opening one is fatal.
|
2026-08-20 02:34:08 -04:00
|
|
|
index_files: HashSet<PathBuf>,
|
2026-08-05 18:05:04 -04:00
|
|
|
pruned: PruneCounts,
|
2026-08-02 19:04:30 -04:00
|
|
|
config: Config,
|
|
|
|
|
registry: Arc<Registry>,
|
|
|
|
|
unreadable: UnreadableDirs,
|
2026-08-03 03:06:19 -04:00
|
|
|
stop_flag: Arc<AtomicBool>,
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
static UNREADABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// The same, for unrepresentable names — a share can hold thousands.
|
2026-08-20 02:34:08 -04:00
|
|
|
static UNREPRESENTABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
pub fn reset_run_warnings() {
|
|
|
|
|
UNREADABLE_WARNINGS.reset();
|
2026-08-20 02:34:08 -04:00
|
|
|
UNREPRESENTABLE_WARNINGS.reset();
|
2026-08-05 18:05:04 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Read one directory: subdirectories and overflow file chunks go to `found`
|
|
|
|
|
/// for the pool, the remaining files come back for this worker, `stale` gets
|
|
|
|
|
/// the paths whose row has no file behind it. A directory that cannot be read
|
|
|
|
|
/// returns before reconciling — it must not read as an empty one.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn read_directory(
|
|
|
|
|
dir: &Path,
|
|
|
|
|
rows: &Arc<DirRows>,
|
|
|
|
|
ctx: &Ctx,
|
|
|
|
|
found: &mut Vec<Found>,
|
|
|
|
|
stale: &mut Vec<String>,
|
2026-08-05 18:05:04 -04:00
|
|
|
) -> Vec<PendingFile> {
|
2026-08-02 19:04:30 -04:00
|
|
|
let entries = match fs::read_dir(dir) {
|
|
|
|
|
Ok(entries) => entries,
|
|
|
|
|
Err(e) => {
|
2026-08-05 18:05:04 -04:00
|
|
|
if UNREADABLE_WARNINGS.allow() {
|
|
|
|
|
crate::log_warn!("cannot read {}: {}", dir.display(), e);
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// "Gone" is not "could not look": a directory deleted mid-walk
|
|
|
|
|
// *should* fall to the stale sweep. Only `NotFound` is unambiguous
|
|
|
|
|
// — the same distinction `verb_for` draws.
|
2026-08-20 02:34:08 -04:00
|
|
|
if e.kind() != std::io::ErrorKind::NotFound {
|
|
|
|
|
ctx.unreadable.record(dir.to_path_buf());
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
return Vec::new();
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-08-23 00:33:53 -04:00
|
|
|
// Every `continue` in the loop must be a genuine "not indexable", or the
|
|
|
|
|
// stale diff below deletes live rows.
|
2026-08-02 22:21:39 -04:00
|
|
|
let mut present: HashSet<String> = HashSet::new();
|
|
|
|
|
let mut unreadable_entry = false;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
let mut files = Vec::new();
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let entry = match entry {
|
|
|
|
|
Ok(entry) => entry,
|
|
|
|
|
Err(e) => {
|
2026-08-05 18:05:04 -04:00
|
|
|
if UNREADABLE_WARNINGS.allow() {
|
|
|
|
|
crate::log_warn!("cannot read an entry of {}: {}", dir.display(), e);
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
ctx.unreadable.record(dir.to_path_buf());
|
2026-08-09 16:25:43 -04:00
|
|
|
// An incomplete listing cannot decide what is missing: an
|
|
|
|
|
// entry we failed to read looks identical to a deleted one.
|
2026-08-02 22:21:39 -04:00
|
|
|
unreadable_entry = true;
|
2026-08-02 19:04:30 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let name = entry.file_name();
|
2026-08-23 00:33:53 -04:00
|
|
|
// **The screen for names the index cannot spell, and the only one**
|
|
|
|
|
// (invalid UTF-8 on Unix, unpaired UTF-16 surrogates on Windows).
|
|
|
|
|
// Skipped by choice: every path in this walk is a database *key*, and
|
|
|
|
|
// a lossy path must never become a DB key — the lossy spelling names a
|
|
|
|
|
// different file. Leaving the entry out of `present` is safe: no
|
|
|
|
|
// stored row can carry such a name, so there is no row to protect.
|
2026-08-20 02:34:08 -04:00
|
|
|
let Some(name) = name.to_str() else {
|
|
|
|
|
if UNREPRESENTABLE_WARNINGS.allow() {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"Skipping {:?} (name is not valid UTF-8, so it cannot be stored, hashed \
|
|
|
|
|
or text-indexed)",
|
|
|
|
|
entry.path()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-08-23 00:33:53 -04:00
|
|
|
// The closure runs only on Windows, where `entry.metadata()` is free
|
|
|
|
|
// and reports the entry itself, not a link target.
|
2026-08-05 18:05:04 -04:00
|
|
|
if !ctx.include_hidden {
|
|
|
|
|
if let Some(reason) =
|
2026-08-20 02:34:08 -04:00
|
|
|
crate::platform::entry_hidden_reason(name, || entry.metadata().ok())
|
2026-08-05 18:05:04 -04:00
|
|
|
{
|
|
|
|
|
match reason {
|
|
|
|
|
crate::platform::HiddenReason::DotPrefix => {
|
|
|
|
|
ctx.pruned.dot_named.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
}
|
|
|
|
|
crate::platform::HiddenReason::Attribute => {
|
|
|
|
|
ctx.pruned.attribute.fetch_add(1, Ordering::Relaxed);
|
2026-08-23 00:33:53 -04:00
|
|
|
// A folder skipped over an attribute Explorer does
|
|
|
|
|
// not show has no other way of being discovered.
|
2026-08-05 18:05:04 -04:00
|
|
|
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
|
|
|
|
|
crate::log_info!(
|
|
|
|
|
"skipping {}: hidden attribute set (enable \"include hidden \
|
|
|
|
|
files\" to index it)",
|
|
|
|
|
entry.path().display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-20 02:34:08 -04:00
|
|
|
if ctx.ignore.matches_component(name) {
|
2026-08-05 18:05:04 -04:00
|
|
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
2026-08-02 19:04:30 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if ctx.ignore.matches_path_pattern(&path) {
|
2026-08-05 18:05:04 -04:00
|
|
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
2026-08-02 19:04:30 -04:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// The index's own database and sidecars — hashing one cancels
|
|
|
|
|
// SQLite's locks process-wide (`file_handling::index_file_set`).
|
|
|
|
|
// `continue`, not `skipped`: the name stays out of `present`, so old
|
|
|
|
|
// rows for the index fall to the stale sweep; `skipped` keeps them.
|
2026-08-20 02:34:08 -04:00
|
|
|
if ctx.index_files.contains(&path) {
|
|
|
|
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
// `file_type` is the cached `d_type` from the directory read.
|
2026-08-02 19:04:30 -04:00
|
|
|
match entry.file_type() {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Not marked present: a name that was a file last run and is a
|
|
|
|
|
// directory now *should* lose its row.
|
2026-08-02 22:21:39 -04:00
|
|
|
Ok(ft) if ft.is_dir() => found.push(Found::Dir(path)),
|
2026-08-02 19:04:30 -04:00
|
|
|
Ok(ft) if ft.is_symlink() => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Directory and file targets gate together, or the walkers
|
|
|
|
|
// disagree: `filtered_walk` follows neither kind, so a file
|
|
|
|
|
// target followed only here would never update between runs.
|
2026-08-03 03:06:19 -04:00
|
|
|
if !ctx.follow_symlinks {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-23 00:33:53 -04:00
|
|
|
// Normalized like the roots, or on Windows the target keeps
|
|
|
|
|
// `canonicalize`'s `\\?\` prefix, under which ignore patterns
|
|
|
|
|
// never match and `seen_dirs` cannot dedup.
|
2026-08-02 19:04:30 -04:00
|
|
|
if let Ok(target) = path.canonicalize() {
|
2026-08-23 00:33:53 -04:00
|
|
|
// The target is a different path than the screened link;
|
|
|
|
|
// its lossy spelling would name some other file entirely.
|
2026-08-20 02:34:08 -04:00
|
|
|
if target.to_str().is_none() {
|
|
|
|
|
if UNREPRESENTABLE_WARNINGS.allow() {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"Skipping {} (its target {:?} is not valid UTF-8, so it cannot \
|
|
|
|
|
be stored, hashed or text-indexed)",
|
|
|
|
|
path.display(),
|
|
|
|
|
target
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-04 03:27:05 -04:00
|
|
|
let target = PathBuf::from(path_to_db_string(&target));
|
2026-08-23 00:33:53 -04:00
|
|
|
// A symlink pointing at the index would otherwise walk
|
|
|
|
|
// straight into an `open`.
|
2026-08-20 02:34:08 -04:00
|
|
|
if ctx.index_files.contains(&target) {
|
|
|
|
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
match fs::metadata(&target) {
|
2026-08-03 03:06:19 -04:00
|
|
|
Ok(m) if m.is_dir() => found.push(Found::Dir(target)),
|
2026-08-09 16:25:43 -04:00
|
|
|
// The target's row belongs to its own directory, so
|
|
|
|
|
// it is not marked present here.
|
2026-08-02 22:21:39 -04:00
|
|
|
Ok(_) => found.push(Found::Alias(target)),
|
2026-08-02 19:04:30 -04:00
|
|
|
Err(_) => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
Ok(_) => {
|
2026-08-20 02:34:08 -04:00
|
|
|
present.insert(name.to_string());
|
2026-08-23 00:33:53 -04:00
|
|
|
// `None` on Unix and on any reparse point.
|
2026-08-05 18:05:04 -04:00
|
|
|
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
|
|
|
|
|
files.push(PendingFile { path, cached });
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
// Type unknown: mark it present so an existing row survives.
|
2026-08-02 22:21:39 -04:00
|
|
|
Err(_) => {
|
2026-08-20 02:34:08 -04:00
|
|
|
present.insert(name.to_string());
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !unreadable_entry {
|
2026-08-09 16:25:43 -04:00
|
|
|
stale.extend(
|
|
|
|
|
rows.keys()
|
|
|
|
|
.filter(|name| !present.contains(name.as_str()))
|
|
|
|
|
.map(|name| path_to_db_string(&dir.join(name))),
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:25:43 -04:00
|
|
|
// Spread a wide directory across the pool, keeping the tail for ourselves
|
|
|
|
|
// so the entries the read just warmed are handled now.
|
2026-08-02 19:04:30 -04:00
|
|
|
while files.len() > FILES_PER_JOB {
|
|
|
|
|
let chunk = files.split_off(files.len() - FILES_PER_JOB);
|
2026-08-02 22:21:39 -04:00
|
|
|
found.push(Found::Files(chunk, rows.clone()));
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
files
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
/// How a file's stored mtime is to be found.
|
|
|
|
|
enum Known<'a> {
|
|
|
|
|
InDir(&'a DirRows),
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Resolved by exact path: a symlink target's row lives under a
|
|
|
|
|
/// different parent.
|
2026-08-02 22:21:39 -04:00
|
|
|
Exact(Option<u64>),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 128-bit truncated SHA-256 of a path, for the writer's duplicate-visit set.
|
2026-08-23 00:33:53 -04:00
|
|
|
/// 16 bytes is ~4e-26 collision probability at 7M paths (8 would be ~1e-6),
|
|
|
|
|
/// and a collision silently drops a real file. Cryptographic because shared
|
|
|
|
|
/// filenames are attacker-supplied: a chosen pair could hide one file.
|
2026-08-02 22:21:39 -04:00
|
|
|
pub fn path_digest(path: &str) -> u128 {
|
|
|
|
|
let digest = Sha256::digest(path.as_bytes());
|
|
|
|
|
let mut bytes = [0u8; 16];
|
|
|
|
|
bytes.copy_from_slice(&digest[..16]);
|
|
|
|
|
u128::from_be_bytes(bytes)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// At most one `stat`, then classify; only files that will be written get
|
|
|
|
|
/// opened, and small text files are finished outright. "At most": on Windows
|
|
|
|
|
/// [`PendingFile::cached`] may already hold the answer.
|
2026-08-05 18:05:04 -04:00
|
|
|
fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
|
|
|
|
|
let PendingFile { path, cached } = file;
|
2026-08-23 00:33:53 -04:00
|
|
|
// Every route here has already screened the path for UTF-8:
|
|
|
|
|
// `path_to_db_string` is lossy, and a lossy string would key another
|
|
|
|
|
// file's row and could consume its digest.
|
2026-08-20 02:34:08 -04:00
|
|
|
debug_assert!(
|
|
|
|
|
path.to_str().is_some(),
|
|
|
|
|
"an unrepresentable path reached prepare(): {:?}",
|
|
|
|
|
path
|
|
|
|
|
);
|
2026-08-02 19:04:30 -04:00
|
|
|
let db_path = path_to_db_string(&path);
|
2026-08-02 22:21:39 -04:00
|
|
|
let digest = path_digest(&db_path);
|
|
|
|
|
let aliased = matches!(known, Known::Exact(_));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
let Ok(meta) = crate::platform::metadata_or_stat(&path, cached) else {
|
2026-08-09 16:25:43 -04:00
|
|
|
// Seen but unreadable: a transient stat failure must not read as
|
|
|
|
|
// "deleted".
|
2026-08-02 22:21:39 -04:00
|
|
|
return WalkedFile::skipped(db_path, digest, aliased);
|
2026-08-02 19:04:30 -04:00
|
|
|
};
|
|
|
|
|
let Some(mtime) = meta
|
|
|
|
|
.modified()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
|
|
|
|
.map(|d| d.as_secs())
|
|
|
|
|
else {
|
2026-08-02 22:21:39 -04:00
|
|
|
return WalkedFile::skipped(db_path, digest, aliased);
|
2026-08-02 19:04:30 -04:00
|
|
|
};
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
let action = match known {
|
|
|
|
|
Known::InDir(rows) => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// `to_str`, not lossy: the lossy spelling of one file is a valid
|
|
|
|
|
// name for another. The screen makes it always `Some`.
|
2026-08-02 22:21:39 -04:00
|
|
|
let name = path
|
|
|
|
|
.file_name()
|
2026-08-20 02:34:08 -04:00
|
|
|
.and_then(|n| n.to_str())
|
2026-08-02 22:21:39 -04:00
|
|
|
.unwrap_or_default();
|
2026-08-20 02:34:08 -04:00
|
|
|
classify_for_indexing(name, mtime, rows)
|
2026-08-02 22:21:39 -04:00
|
|
|
}
|
|
|
|
|
Known::Exact(stored) => classify_by_mtime(stored, mtime),
|
|
|
|
|
};
|
2026-08-02 19:04:30 -04:00
|
|
|
let record = match action {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Unchanged: never opened, never hashed — must stay at one syscall.
|
2026-08-02 19:04:30 -04:00
|
|
|
FileIndexAction::Skip => None,
|
2026-08-09 16:25:43 -04:00
|
|
|
// `prepare_file_record` gates on `is_file()`, which keeps us from
|
2026-08-23 00:33:53 -04:00
|
|
|
// opening a FIFO — an uninterruptible forever-block.
|
2026-08-02 19:04:30 -04:00
|
|
|
_ => prepare_file_record(&db_path, &meta, &ctx.config, &ctx.registry),
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-04 03:27:05 -04:00
|
|
|
WalkedFile {
|
|
|
|
|
path: db_path,
|
|
|
|
|
action,
|
|
|
|
|
record,
|
|
|
|
|
digest,
|
|
|
|
|
aliased,
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender<WalkEvent>) {
|
2026-08-02 19:04:30 -04:00
|
|
|
while let Some((job, slot)) = shared.take() {
|
2026-08-03 03:06:19 -04:00
|
|
|
let _busy = shared.stats.enter();
|
2026-08-17 19:26:18 -04:00
|
|
|
if ctx.stop_flag.load(Ordering::Relaxed) {
|
2026-08-02 19:04:30 -04:00
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut found = Vec::new();
|
2026-08-02 22:21:39 -04:00
|
|
|
let mut stale = Vec::new();
|
|
|
|
|
let (files, rows) = match job {
|
|
|
|
|
Job::Dir(dir, rows) => {
|
|
|
|
|
let files = read_directory(&dir, &rows, ctx, &mut found, &mut stale);
|
|
|
|
|
(files, rows)
|
|
|
|
|
}
|
|
|
|
|
Job::Files(files, rows) => (files, rows),
|
|
|
|
|
Job::Alias(path, stored) => {
|
|
|
|
|
slot.finish(found);
|
2026-08-05 18:05:04 -04:00
|
|
|
let file = PendingFile::uncached(path);
|
2026-08-04 03:27:05 -04:00
|
|
|
if tx
|
2026-08-05 18:05:04 -04:00
|
|
|
.send(WalkEvent::File(prepare(file, Known::Exact(stored), ctx)))
|
2026-08-04 03:27:05 -04:00
|
|
|
.is_err()
|
|
|
|
|
{
|
2026-08-02 22:21:39 -04:00
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Hand the subdirectories over before doing our own per-file work, so
|
2026-08-09 16:25:43 -04:00
|
|
|
// the rest of the pool never idles waiting behind one worker.
|
2026-08-02 19:04:30 -04:00
|
|
|
slot.finish(found);
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
if !stale.is_empty() && tx.send(WalkEvent::Stale(stale)).is_err() {
|
|
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
for file in files {
|
2026-08-17 19:26:18 -04:00
|
|
|
if ctx.stop_flag.load(Ordering::Relaxed) {
|
2026-08-02 19:04:30 -04:00
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-04 03:27:05 -04:00
|
|
|
if tx
|
2026-08-05 18:05:04 -04:00
|
|
|
.send(WalkEvent::File(prepare(file, Known::InDir(&rows), ctx)))
|
2026-08-04 03:27:05 -04:00
|
|
|
.is_err()
|
|
|
|
|
{
|
2026-08-02 19:04:30 -04:00
|
|
|
// Receiver gone: the run was stopped or failed. Not an error.
|
|
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
/// Serves the pool's directory-row and symlink-mtime lookups from one
|
2026-08-23 00:33:53 -04:00
|
|
|
/// read-only connection. A failed query abandons the job: the directory goes
|
|
|
|
|
/// unwalked, which reconciliation reads as "not seen" and deletes nothing.
|
2026-08-02 22:21:39 -04:00
|
|
|
fn prefetcher(shared: &Shared, db_path: &str) {
|
|
|
|
|
let conn = match crate::db::open::open_walk_reader(db_path) {
|
|
|
|
|
Ok(conn) => conn,
|
|
|
|
|
Err(e) => {
|
2026-08-23 00:33:53 -04:00
|
|
|
// Without rows, every file looks new and every row stale.
|
2026-08-02 22:21:39 -04:00
|
|
|
crate::log_warn!("walk reader: {}", e);
|
|
|
|
|
shared.shutdown();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while let Some(work) = shared.take_prefetch() {
|
|
|
|
|
match work {
|
|
|
|
|
PrefetchWork::Dir(dir) => {
|
2026-08-20 02:34:08 -04:00
|
|
|
match crate::db::repo::dir_rows(&conn, &dir_to_db_parent(&dir)) {
|
2026-08-02 22:21:39 -04:00
|
|
|
Ok(rows) => shared.finish_prefetch(Job::Dir(dir, Arc::new(rows))),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::log_warn!("{}", e);
|
|
|
|
|
shared.abandon_prefetch();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PrefetchWork::Alias(path) => {
|
|
|
|
|
match crate::db::repo::mtime_for_path(&conn, &path_to_db_string(&path)) {
|
|
|
|
|
Ok(stored) => shared.finish_prefetch(Job::Alias(path, stored)),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::log_warn!("{}", e);
|
|
|
|
|
shared.abandon_prefetch();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
/// A running parallel walk. Iterating it drains finished files; dropping it
|
|
|
|
|
/// stops the workers and joins them.
|
|
|
|
|
pub struct ParallelWalk {
|
2026-08-02 22:21:39 -04:00
|
|
|
rx: Option<mpsc::Receiver<WalkEvent>>,
|
2026-08-05 18:05:04 -04:00
|
|
|
pending: Option<WalkEvent>,
|
2026-08-02 19:04:30 -04:00
|
|
|
handles: Vec<JoinHandle<()>>,
|
2026-08-02 22:21:39 -04:00
|
|
|
prefetch: Option<JoinHandle<()>>,
|
2026-08-02 19:04:30 -04:00
|
|
|
shared: Arc<Shared>,
|
|
|
|
|
ctx: Arc<Ctx>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ParallelWalk {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Directories that could not be read. Final once the iterator has ended.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn unreadable(&self) -> &UnreadableDirs {
|
|
|
|
|
&self.ctx.unreadable
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 18:05:04 -04:00
|
|
|
pub fn pruned(&self) -> &PruneCounts {
|
|
|
|
|
&self.ctx.pruned
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
/// Every canonical directory the walk queued, in `files.parent` spelling.
|
2026-08-09 16:25:43 -04:00
|
|
|
/// The vanished-directory sweep needs this: a directory deleted wholesale
|
|
|
|
|
/// is never read, so nothing reconciles the rows beneath it.
|
2026-08-02 22:21:39 -04:00
|
|
|
pub fn seen_dirs(&self) -> HashSet<String> {
|
2026-08-09 16:25:43 -04:00
|
|
|
crate::lock_ok(&self.shared.queue)
|
2026-08-02 22:21:39 -04:00
|
|
|
.seen_dirs
|
|
|
|
|
.iter()
|
2026-08-20 02:34:08 -04:00
|
|
|
.map(|d| dir_to_db_parent(d))
|
2026-08-02 22:21:39 -04:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Cloneable worker-activity handle; permanently zero once workers exit.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn worker_stats(&self) -> WorkerStats {
|
2026-08-03 03:06:19 -04:00
|
|
|
self.shared.stats.clone()
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Join the workers and report whether every one finished cleanly. A dead
|
|
|
|
|
/// worker and a finished one look identical from the receiving end, and
|
|
|
|
|
/// treating a panicked walk as complete would hand stale cleanup a
|
|
|
|
|
/// partial file set.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn finish(&mut self) -> bool {
|
|
|
|
|
// Dropping the receiver first releases any worker parked in `send`.
|
|
|
|
|
self.rx = None;
|
|
|
|
|
let mut clean = true;
|
|
|
|
|
for handle in self.handles.drain(..) {
|
|
|
|
|
if handle.join().is_err() {
|
|
|
|
|
clean = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
if let Some(handle) = self.prefetch.take() {
|
2026-08-09 16:25:43 -04:00
|
|
|
// `shutdown` releases a prefetcher parked behind PREFETCH_AHEAD;
|
|
|
|
|
// without it this join would block until the queue emptied.
|
2026-08-02 22:21:39 -04:00
|
|
|
self.shared.shutdown();
|
|
|
|
|
if handle.join().is_err() {
|
|
|
|
|
clean = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
clean
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 03:06:19 -04:00
|
|
|
/// Result of a non-blocking pull from a producer pool.
|
|
|
|
|
pub enum TryNext<T> {
|
|
|
|
|
Item(T),
|
2026-08-02 19:04:30 -04:00
|
|
|
Empty,
|
2026-08-23 00:33:53 -04:00
|
|
|
/// All workers exited, for any reason.
|
2026-08-02 19:04:30 -04:00
|
|
|
Finished,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// `None` is a receiver the owner already dropped: reads as finished.
|
2026-08-03 03:06:19 -04:00
|
|
|
pub(crate) fn try_recv_next<T>(rx: Option<&mpsc::Receiver<T>>) -> TryNext<T> {
|
|
|
|
|
match rx {
|
|
|
|
|
None => TryNext::Finished,
|
|
|
|
|
Some(rx) => match rx.try_recv() {
|
|
|
|
|
Ok(item) => TryNext::Item(item),
|
|
|
|
|
Err(mpsc::TryRecvError::Empty) => TryNext::Empty,
|
|
|
|
|
Err(mpsc::TryRecvError::Disconnected) => TryNext::Finished,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// [`try_recv_next`] with a wait. Not a sleep backoff: on Windows the default
|
|
|
|
|
/// timer resolution is 15.6 ms, so a 2 ms sleep stalls for 15.6;
|
|
|
|
|
/// `recv_timeout` parks on the channel's condvar and wakes immediately.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub(crate) fn recv_next_timeout<T>(
|
|
|
|
|
rx: Option<&mpsc::Receiver<T>>,
|
|
|
|
|
timeout: std::time::Duration,
|
|
|
|
|
) -> TryNext<T> {
|
|
|
|
|
match rx {
|
|
|
|
|
None => TryNext::Finished,
|
|
|
|
|
Some(rx) => match rx.recv_timeout(timeout) {
|
|
|
|
|
Ok(item) => TryNext::Item(item),
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Timeout) => TryNext::Empty,
|
|
|
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => TryNext::Finished,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
impl ParallelWalk {
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Non-blocking variant of `next`, for callers multiplexing several walks.
|
2026-08-03 03:06:19 -04:00
|
|
|
pub fn try_next(&mut self) -> TryNext<WalkEvent> {
|
2026-08-05 18:05:04 -04:00
|
|
|
if let Some(event) = self.pending.take() {
|
|
|
|
|
return TryNext::Item(event);
|
|
|
|
|
}
|
2026-08-03 03:06:19 -04:00
|
|
|
try_recv_next(self.rx.as_ref())
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
2026-08-05 18:05:04 -04:00
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// Wait up to `timeout` for output, holding it for the next `try_next`.
|
2026-08-05 18:05:04 -04:00
|
|
|
pub fn wait_ready(&mut self, timeout: std::time::Duration) -> bool {
|
|
|
|
|
if self.pending.is_some() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
match recv_next_timeout(self.rx.as_ref(), timeout) {
|
|
|
|
|
TryNext::Item(event) => {
|
|
|
|
|
self.pending = Some(event);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
TryNext::Finished => true,
|
|
|
|
|
TryNext::Empty => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Iterator for ParallelWalk {
|
2026-08-02 22:21:39 -04:00
|
|
|
type Item = WalkEvent;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
fn next(&mut self) -> Option<WalkEvent> {
|
2026-08-05 18:05:04 -04:00
|
|
|
if let Some(event) = self.pending.take() {
|
|
|
|
|
return Some(event);
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
self.rx.as_ref()?.recv().ok()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for ParallelWalk {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
self.shared.shutdown();
|
|
|
|
|
self.finish();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Walk `roots` in parallel, yielding every indexable file exactly once per
|
2026-08-23 00:33:53 -04:00
|
|
|
/// canonical path. `workers` (clamped to 1..=64) is explicit for per-root
|
|
|
|
|
/// overrides; `db_path` is opened read-only by the row prefetcher.
|
2026-08-02 19:04:30 -04:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
pub fn walk_indexable_files(
|
|
|
|
|
roots: &[String],
|
|
|
|
|
follow_symlinks: bool,
|
|
|
|
|
include_hidden: bool,
|
|
|
|
|
ignore: IgnoreSet,
|
2026-08-02 22:21:39 -04:00
|
|
|
db_path: &str,
|
2026-08-02 19:04:30 -04:00
|
|
|
config: Config,
|
|
|
|
|
registry: Arc<Registry>,
|
2026-08-03 03:06:19 -04:00
|
|
|
stop_flag: Arc<AtomicBool>,
|
2026-08-02 19:04:30 -04:00
|
|
|
workers: usize,
|
|
|
|
|
) -> ParallelWalk {
|
|
|
|
|
let mut queue = Queue::default();
|
|
|
|
|
let mut unresolvable: Vec<PathBuf> = Vec::new();
|
|
|
|
|
for root in roots {
|
2026-08-23 00:33:53 -04:00
|
|
|
// A non-canonical root makes every file look new and every stored
|
|
|
|
|
// row look stale. Roots themselves are never filtered — the user
|
|
|
|
|
// chose them.
|
2026-08-02 19:04:30 -04:00
|
|
|
match fs::canonicalize(root) {
|
2026-08-23 00:33:53 -04:00
|
|
|
// A root string is UTF-8 (from the config), but what it resolves
|
|
|
|
|
// to need not be; stored lossily it would be walked under a
|
|
|
|
|
// parent naming some other directory. Treated as unresolvable.
|
2026-08-20 02:34:08 -04:00
|
|
|
Ok(dir) if dir.to_str().is_none() => {
|
|
|
|
|
crate::log_warn!(
|
|
|
|
|
"cannot index root {}: it resolves to {:?}, whose name is not valid UTF-8",
|
|
|
|
|
root,
|
|
|
|
|
dir
|
|
|
|
|
);
|
|
|
|
|
unresolvable.push(PathBuf::from(root));
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
Ok(dir) => {
|
|
|
|
|
let dir = PathBuf::from(path_to_db_string(&dir));
|
|
|
|
|
if queue.seen_dirs.insert(dir.clone()) {
|
2026-08-02 22:21:39 -04:00
|
|
|
queue.needs_rows.push(dir);
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
crate::log_warn!("cannot resolve indexing root {}: {}", root, e);
|
2026-08-23 00:33:53 -04:00
|
|
|
// An unmounted root is indistinguishable from "everything
|
|
|
|
|
// was deleted"; recorded so stale cleanup leaves it alone.
|
2026-08-02 19:04:30 -04:00
|
|
|
unresolvable.push(PathBuf::from(root));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let threads = workers.clamp(1, 64);
|
|
|
|
|
let shared = Arc::new(Shared {
|
|
|
|
|
queue: Mutex::new(queue),
|
|
|
|
|
idle: Condvar::new(),
|
2026-08-03 03:06:19 -04:00
|
|
|
stats: WorkerStats::new(threads),
|
2026-08-02 19:04:30 -04:00
|
|
|
});
|
|
|
|
|
let ctx = Arc::new(Ctx {
|
|
|
|
|
follow_symlinks,
|
|
|
|
|
include_hidden,
|
|
|
|
|
ignore,
|
2026-08-20 02:34:08 -04:00
|
|
|
index_files: crate::file_handling::index_file_set(Path::new(db_path)),
|
2026-08-05 18:05:04 -04:00
|
|
|
pruned: PruneCounts::default(),
|
2026-08-02 19:04:30 -04:00
|
|
|
config,
|
|
|
|
|
registry,
|
|
|
|
|
unreadable: UnreadableDirs::default(),
|
|
|
|
|
stop_flag,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for root in unresolvable {
|
|
|
|
|
ctx.unreadable.record(root);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (tx, rx) = mpsc::sync_channel(CHANNEL_CAP);
|
|
|
|
|
let handles = (0..threads)
|
|
|
|
|
.map(|_| {
|
|
|
|
|
let (shared, ctx, tx) = (shared.clone(), ctx.clone(), tx.clone());
|
2026-08-05 19:17:11 -04:00
|
|
|
crate::platform::spawn_worker("qs-walk", move || {
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::platform::set_background_priority();
|
|
|
|
|
worker(&shared, &ctx, &tx)
|
|
|
|
|
})
|
2026-08-02 19:04:30 -04:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
// The workers must hold the only senders, or `recv` never reports the end
|
2026-08-09 16:25:43 -04:00
|
|
|
// of the walk.
|
2026-08-02 19:04:30 -04:00
|
|
|
drop(tx);
|
|
|
|
|
|
2026-08-02 22:21:39 -04:00
|
|
|
let prefetch = {
|
|
|
|
|
let (shared, db_path) = (shared.clone(), db_path.to_string());
|
2026-08-05 19:17:11 -04:00
|
|
|
crate::platform::spawn_worker("qs-prefetch", move || {
|
2026-08-03 03:06:19 -04:00
|
|
|
crate::platform::set_background_priority();
|
|
|
|
|
prefetcher(&shared, &db_path)
|
|
|
|
|
})
|
2026-08-02 22:21:39 -04:00
|
|
|
};
|
|
|
|
|
|
2026-08-04 03:27:05 -04:00
|
|
|
ParallelWalk {
|
|
|
|
|
rx: Some(rx),
|
2026-08-05 18:05:04 -04:00
|
|
|
pending: None,
|
2026-08-04 03:27:05 -04:00
|
|
|
handles,
|
|
|
|
|
prefetch: Some(prefetch),
|
|
|
|
|
shared,
|
|
|
|
|
ctx,
|
|
|
|
|
}
|
2026-08-02 19:04:30 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 00:33:53 -04:00
|
|
|
/// A network share wants far more threads than cores — each worker is mostly
|
|
|
|
|
/// blocked on a round trip — and with mixed roots the higher count wins.
|
2026-08-02 19:04:30 -04:00
|
|
|
pub fn thread_count_for(roots: &[String]) -> usize {
|
|
|
|
|
let network = roots
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|r| crate::platform::is_network_path(Path::new(r)));
|
|
|
|
|
if network {
|
|
|
|
|
NETWORK_THREADS
|
|
|
|
|
} else {
|
|
|
|
|
LOCAL_THREADS
|
|
|
|
|
}
|
|
|
|
|
}
|