mirror of
https://github.com/immich-app/walkrs.git
synced 2026-09-30 13:32:57 +08:00
The async iterator API is quite ergonomic, and allows us to send paths in batches. Sending paths in batches means that we can start processing the paths before we've finished searching, and also makes passing messages between rust and nodejs much faster. I tried quite a few variants, like using a Buffer and manually reconstructing strings, callbacks, etc. The fastest way to pass data between rust and nodejs is by encoding / decoding JSON. It's pretty unintuitive, but this is mainly because rust and C++ (native JSON.parse) are way faster than constructing strings manually with the v8 engine. Both callbacks and iterators are quite fast, and have their own trade-offs. The API for iterators is much more ergonomic however, so that's what I pursued. The performance improvement compared to the current implementation is huge. Up to 2-3x in some cases. ❯ hyperfine 'node example/old.ts' 'node example/stream.ts' 'node example/main.ts' Benchmark 1: node example/old.ts Time (mean ± σ): 2.007 s ± 0.478 s [User: 2.572 s, System: 2.357 s] Range (min … max): 1.619 s … 3.326 s 10 runs Benchmark 2: node example/stream.ts Time (mean ± σ): 1.234 s ± 0.258 s [User: 2.976 s, System: 2.811 s] Range (min … max): 0.913 s … 1.735 s 10 runs Benchmark 3: node example/main.ts Time (mean ± σ): 1.117 s ± 0.267 s [User: 2.425 s, System: 2.484 s] Range (min … max): 0.809 s … 1.680 s 10 runs Summary node example/main.ts ran 1.10 ± 0.35 times faster than node example/stream.ts 1.80 ± 0.61 times faster than node example/old.ts
150 lines
3.6 KiB
Rust
150 lines
3.6 KiB
Rust
mod batch_sender;
|
|
mod extension_filter;
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use globset::{GlobSet, GlobSetBuilder};
|
|
use ignore::{DirEntry, WalkBuilder, WalkState};
|
|
use napi::bindgen_prelude::*;
|
|
use napi_derive::napi;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::mpsc::{self, UnboundedSender};
|
|
|
|
use batch_sender::BatchSender;
|
|
use extension_filter::ExtensionFilter;
|
|
|
|
#[napi(object)]
|
|
pub struct WalkOptions {
|
|
#[napi(ts_type = "string[]")]
|
|
pub paths: Vec<String>,
|
|
|
|
#[napi(ts_type = "boolean | undefined")]
|
|
pub include_hidden: Option<bool>,
|
|
|
|
#[napi(ts_type = "string[] | undefined")]
|
|
pub exclusion_patterns: Option<Vec<String>>,
|
|
|
|
#[napi(ts_type = "string[] | undefined")]
|
|
pub extensions: Option<Vec<String>>,
|
|
}
|
|
|
|
#[napi(async_iterator)]
|
|
pub struct Walk {
|
|
rx: Arc<Mutex<mpsc::UnboundedReceiver<Vec<u8>>>>,
|
|
}
|
|
|
|
impl napi::bindgen_prelude::AsyncGenerator for Walk {
|
|
type Yield = Buffer;
|
|
type Next = ();
|
|
type Return = ();
|
|
|
|
fn next(
|
|
&mut self,
|
|
_value: Option<Self::Next>,
|
|
) -> impl std::future::Future<Output = Result<Option<Self::Yield>>> + Send + 'static {
|
|
let rx = Arc::clone(&self.rx);
|
|
async move { Ok(rx.lock().await.recv().await.map(Into::into)) }
|
|
}
|
|
}
|
|
|
|
#[napi]
|
|
pub fn walk(options: WalkOptions) -> Result<Walk> {
|
|
let (tx, rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
|
|
|
if options.paths.is_empty() {
|
|
return Ok(Walk {
|
|
rx: Arc::new(Mutex::new(rx)),
|
|
});
|
|
}
|
|
|
|
let exclusion_set = Arc::new(build_exclusion_set(&options.exclusion_patterns.unwrap_or_default())?);
|
|
let extension_set = Arc::new(ExtensionFilter::new(&options.extensions.unwrap_or_default()));
|
|
|
|
let mut walk_builder = WalkBuilder::new(&options.paths[0]);
|
|
for path in &options.paths[1..] {
|
|
walk_builder.add(path);
|
|
}
|
|
|
|
let walker = walk_builder
|
|
.git_ignore(false)
|
|
.hidden(!options.include_hidden.unwrap_or(false))
|
|
.parents(false)
|
|
.ignore(false)
|
|
.git_global(false)
|
|
.git_exclude(false)
|
|
.build_parallel();
|
|
|
|
std::thread::spawn(move || walker.run(|| visit(tx.clone(), Arc::clone(&exclusion_set), Arc::clone(&extension_set))));
|
|
|
|
Ok(Walk {
|
|
rx: Arc::new(Mutex::new(rx)),
|
|
})
|
|
}
|
|
|
|
fn build_exclusion_set(exclusion_patterns: &[String]) -> Result<GlobSet> {
|
|
let mut builder = GlobSetBuilder::new();
|
|
for pattern in exclusion_patterns {
|
|
builder.add(
|
|
globset::GlobBuilder::new(pattern)
|
|
.case_insensitive(true)
|
|
.build()
|
|
.map_err(|e| {
|
|
Error::new(
|
|
Status::InvalidArg,
|
|
format!("Invalid exclusion pattern '{pattern}': {e}"),
|
|
)
|
|
})?,
|
|
);
|
|
}
|
|
builder
|
|
.build()
|
|
.map_err(|e| Error::new(Status::InvalidArg, format!("Failed to build exclusion patterns: {e}")))
|
|
}
|
|
|
|
fn visit(
|
|
tx: UnboundedSender<Vec<u8>>,
|
|
exclusion_set: Arc<GlobSet>,
|
|
extension_filter: Arc<ExtensionFilter>,
|
|
) -> Box<dyn FnMut(std::result::Result<DirEntry, ignore::Error>) -> WalkState + Send> {
|
|
let mut batch_sender = BatchSender::new(tx);
|
|
|
|
Box::new(move |entry_result| {
|
|
let Ok(entry) = entry_result else {
|
|
return WalkState::Continue;
|
|
};
|
|
|
|
let Some(ft) = entry.file_type() else {
|
|
return WalkState::Continue;
|
|
};
|
|
|
|
let path: &Path = entry.path();
|
|
|
|
if exclusion_set.is_match(path) {
|
|
return if ft.is_dir() {
|
|
WalkState::Skip
|
|
} else {
|
|
WalkState::Continue
|
|
};
|
|
}
|
|
|
|
if !ft.is_file() {
|
|
return WalkState::Continue;
|
|
}
|
|
|
|
if !extension_filter.is_match(path) {
|
|
return WalkState::Continue;
|
|
}
|
|
|
|
let Ok(path) = entry.into_path().into_os_string().into_string() else {
|
|
return WalkState::Continue;
|
|
};
|
|
|
|
if batch_sender.send(path).is_err() {
|
|
return WalkState::Quit;
|
|
}
|
|
|
|
WalkState::Continue
|
|
})
|
|
}
|