Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/glob_speed_depth_bound_test.py tests/e2e/glob_speed_one_level_test.py tests/e2e/glob_speed_recursive_test.py tests/e2e/glob_speed_symlink_test.py tests/e2e/glob_speed_dotfiles_test.py tests/e2e/glob_speed_node_modules_test.py tests/e2e/glob_speed_matchers_test.py tests/e2e/glob_speed_gitignore_test.py tests/e2e/stat_speed_test.py tests/e2e/query_contract_test.py -x -q",
"ran_at": 1791768231,
"exit_code": 0,
"commit": "d6653e1f4d8a1d8b4b723b6f24c0bc805e4aa7e1",
"branch": "bot/speed-walk-rowpath-1011"
}
1 change: 1 addition & 0 deletions packages/rust/changelog.d/2026-10-11-faster-path-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Changed** Path queries over large trees return sooner: a matched file's path is no longer copied into its row, query cells reuse their buffers, and a large result's rows are freed in the background.
21 changes: 21 additions & 0 deletions packages/rust/migrations.d/2026-10-11-faster-path-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Faster path rows

#### Summary

Path queries over large trees build, read and free their rows with fewer allocations. Results and ordering are unchanged; nothing breaks.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

_None._

#### Verification

Run `dirsql --help`; the CLI starts as before.
51 changes: 47 additions & 4 deletions packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,10 +1151,9 @@ impl Db {
let mut rows = stmt.query([])?;
let mut cells = Vec::with_capacity(column_names.len());
while let Some(row) = rows.next()? {
cells.clear();
for i in 0..column_names.len() {
let val: rusqlite::types::Value = row.get(i)?;
cells.push(Value::from(val));
cells.resize(column_names.len(), Value::Null);
for (i, cell) in cells.iter_mut().enumerate() {
fill_cell(cell, row.get_ref(i)?);
}
on_row(&mut state, &cells);
}
Expand Down Expand Up @@ -1428,6 +1427,17 @@ pub(crate) fn table_catalog_entry(
.optional()
}

/// Set `cell` to what SQLite holds in `source`.
fn fill_cell(cell: &mut Value, source: ValueRef<'_>) {
match (&mut *cell, source) {
(Value::Text(held), ValueRef::Text(bytes)) => {
held.clear();
held.push_str(&String::from_utf8_lossy(bytes));
}
_ => *cell = Value::from(rusqlite::types::Value::from(source)),
}
}

impl From<rusqlite::types::Value> for Value {
fn from(v: rusqlite::types::Value) -> Self {
match v {
Expand Down Expand Up @@ -1573,6 +1583,39 @@ mod tests {
.unwrap()
}

#[test]
fn filling_a_text_cell_with_text_reuses_its_buffer() {
let mut cell = Value::Text(String::with_capacity(64));
let Value::Text(held) = &cell else {
unreachable!()
};
let buffer = held.as_ptr();

fill_cell(&mut cell, ValueRef::Text(b"a.md"));

let Value::Text(filled) = &cell else {
panic!("a text source fills a text cell, got {cell:?}")
};
assert_eq!(filled, "a.md");
assert_eq!(filled.as_ptr(), buffer);
}

#[test]
fn filling_a_cell_takes_each_kind_of_source() {
let mut cell = Value::Text("old".into());

fill_cell(&mut cell, ValueRef::Integer(7));
assert_eq!(cell, Value::Integer(7));
fill_cell(&mut cell, ValueRef::Real(0.5));
assert_eq!(cell, Value::Real(0.5));
fill_cell(&mut cell, ValueRef::Blob(&[0, 255]));
assert_eq!(cell, Value::Blob(vec![0, 255]));
fill_cell(&mut cell, ValueRef::Text(b"t"));
assert_eq!(cell, Value::Text("t".into()));
fill_cell(&mut cell, ValueRef::Null);
assert_eq!(cell, Value::Null);
}

#[test]
fn query_each_folds_each_row_in_projection_order() {
let db = titled_docs(&["a", "b"]);
Expand Down
47 changes: 39 additions & 8 deletions packages/rust/src/vtab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ struct ScanSpec {
/// path-derived text columns are slices of `path` located by [`PathSpans`],
/// so a row costs two allocations however many columns a query reads.
struct FileRow {
/// The path as scanned, root-relative; where the lazy content read goes.
rel: PathBuf,
/// The path as scanned, root-relative, when `path` is not already it.
rel: Option<PathBuf>,
/// The path as reported, under the table's prefix when it has one.
path: String,
spans: PathSpans,
Expand All @@ -205,7 +205,13 @@ struct FileRow {

impl FileRow {
fn new(path_prefix: &Path, rel: PathBuf) -> Self {
let path = reported_path(path_prefix, &rel);
let (rel, path) = match reported_in_place(path_prefix, rel) {
Ok(path) => (None, path),
Err(rel) => {
let path = reported_path(path_prefix, &rel);
(Some(rel), path)
}
};
let spans = PathSpans::of(&path);
Self {
rel,
Expand All @@ -216,6 +222,11 @@ impl FileRow {
}
}

/// The path as scanned, root-relative; where the lazy content read goes.
fn rel(&self) -> &Path {
self.rel.as_deref().unwrap_or_else(|| Path::new(&self.path))
}

fn basename(&self) -> Option<&str> {
self.spans.name_at.map(|at| &self.path[at as usize..])
}
Expand Down Expand Up @@ -367,7 +378,7 @@ fn parse_module_args(args: &[&[u8]]) -> Result<ScanSpec> {

impl ScanSpec {
fn stat(&self, row: &FileRow) -> StatFacts {
fs::metadata(self.root.join(&row.rel)).map_or_else(
fs::metadata(self.root.join(row.rel())).map_or_else(
|_| StatFacts::default(),
|m| StatFacts::from_parts(m.len(), m.modified().ok(), m.created().ok()),
)
Expand All @@ -388,7 +399,7 @@ impl ScanSpec {
}

fn read(&self, row: &FileRow) -> Option<String> {
(self.reader)(&self.root.join(&row.rel), self.facts(row).size)
(self.reader)(&self.root.join(row.rel()), self.facts(row).size)
}
}

Expand All @@ -406,6 +417,15 @@ fn reported_path(path_prefix: &Path, rel_path: &Path) -> String {
format!("{prefix}/{rel_path}")
}

/// The reported path taken from `rel` itself, when it is already what a
/// table with no prefix reports: handing `rel` back otherwise.
fn reported_in_place(path_prefix: &Path, rel: PathBuf) -> Result<String, PathBuf> {
if !path_prefix.as_os_str().is_empty() || std::path::MAIN_SEPARATOR != '/' {
return Err(rel);
}
rel.into_os_string().into_string().map_err(PathBuf::from)
}

impl TableSource for ScanSpec {
type Row = FileRow;

Expand Down Expand Up @@ -665,6 +685,17 @@ mod tests {
FileRow::new(Path::new(prefix), PathBuf::from(rel))
}

#[cfg(unix)]
#[test]
fn a_row_without_a_prefix_keeps_the_scanned_path_allocation() {
let rel = PathBuf::from("docs/a.md");
let scanned = rel.as_os_str().as_encoded_bytes().as_ptr();

let row = FileRow::new(Path::new(""), rel);

assert_eq!(row.path.as_ptr(), scanned);
}

fn no_facts(_: &FileRow) -> StatFacts {
StatFacts::default()
}
Expand Down Expand Up @@ -839,7 +870,7 @@ mod tests {
assert_eq!(rows.len(), 100);
for (n, row) in rows.iter().enumerate() {
assert_eq!(row.path, format!("/root/f{n:03}.md"));
assert_eq!(row.rel, PathBuf::from(format!("f{n:03}.md")));
assert_eq!(row.rel(), Path::new(&format!("f{n:03}.md")));
}
}

Expand Down Expand Up @@ -1076,8 +1107,8 @@ mod tests {
assert_eq!(row.dir(), Some("/var/log"));
assert_eq!(row.basename(), Some("a.log"));
assert_eq!(
row.rel,
PathBuf::from("a.log"),
row.rel(),
Path::new("a.log"),
"content is read from the scanned path"
);
}
Expand Down
60 changes: 59 additions & 1 deletion packages/rust/src/vtab_scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const PREFETCH_LOOKUP_COST: f64 = 100.;
/// `idxNum` for a full scan; a lookup carries its column as `column + 1`.
const SCAN_IDX: c_int = 0;

/// Rows a statement's row set holds before its eviction frees them on a
/// thread of their own: below it, spawning costs more than the freeing, and
/// above it the statement need not wait on the freeing.
const FREE_ELSEWHERE_ROWS: usize = 4096;

/// Set in `idxNum` when the statement names the prefetch column; the low bits
/// still name the lookup.
const PREFETCH_IDX: c_int = 1 << 30;
Expand Down Expand Up @@ -217,7 +222,12 @@ impl<S: TableSource> RowCache<S> {

impl<S: TableSource> Held for RowCache<S> {
fn evict(&self) {
*self.slot.lock().unwrap() = Slot::Empty;
let evicted = std::mem::replace(&mut *self.slot.lock().unwrap(), Slot::Empty);
if let Slot::Ready(rows) = evicted
&& rows.len() >= FREE_ELSEWHERE_ROWS
{
let _ = thread::Builder::new().spawn(move || drop(rows));
}
}

fn warm(&self) {
Expand Down Expand Up @@ -996,6 +1006,54 @@ mod tests {
}
}

/// A row that reports the thread it is freed on.
struct Noted(std::sync::mpsc::Sender<thread::ThreadId>);

impl Drop for Noted {
fn drop(&mut self) {
let _ = self.0.send(thread::current().id());
}
}

struct Many {
freed_on: Mutex<std::sync::mpsc::Sender<thread::ThreadId>>,
}

impl TableSource for Many {
type Row = Noted;
const NAME: &'static str = "many";

fn connect(_args: &[&[u8]]) -> Result<(String, Self)> {
unreachable!()
}

fn rows(&self) -> Arc<Vec<Noted>> {
let freed_on = self.freed_on.lock().unwrap();
Arc::new((0..10_000).map(|_| Noted(freed_on.clone())).collect())
}

fn column(&self, _row: &Noted, _ctx: &mut Context, _i: c_int) -> Result<()> {
unreachable!()
}
}

#[test]
fn a_large_evicted_row_set_is_freed_off_the_evicting_thread() {
let (freed_on, freed) = std::sync::mpsc::channel();
let cache = RowCache::new(Arc::new(Many {
freed_on: Mutex::new(freed_on),
}));
drop(cache.rows());

cache.evict();

let here = thread::current().id();
let timeout = std::time::Duration::from_secs(10);
for _ in 0..10_000 {
assert_ne!(freed.recv_timeout(timeout).unwrap(), here);
}
}

fn counted() -> RowCache<Counted> {
RowCache::new(Arc::new(Counted {
scans: std::sync::atomic::AtomicUsize::new(0),
Expand Down
7 changes: 7 additions & 0 deletions packages/ts/e2e-attestations/bot-speed-walk-rowpath-1011.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791768301,
"exit_code": 0,
"commit": "71d115d6a0a35181bff17f3c42b7b96fe5139371",
"branch": "bot/speed-walk-rowpath-1011"
}
Loading