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
8 changes: 8 additions & 0 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ per-binding surface. The one signature change is Rust-only: `vtab::load_module`
and `parsed_vtab::load_module` take the `Arc<StatementScope>` their tables
cache under, a surface neither binding exposes.

**Path tables of one statement walked at the same time — Rust-crate-only
surface, no SDK drift.** A statement that names several path tables starts
every walk before SQLite opens the first cursor, each on a thread of its own,
so a join over two globs costs about one walk instead of one per table. The
names come from the SQLite authorizer at the final prepare, so the fallback
loop above stays the only thing that registers a table. `StatementScope::warm`
lives in the shared core; no SDK signature changes.

## AsyncDirSQL

| API | Python | Rust |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1790970269,
"exit_code": 0,
"commit": "e7987c66259acd23c0c1e23b0aba965b97201992",
"branch": "claude/1238-parallel-walk"
}
4 changes: 0 additions & 4 deletions packages/python/tests/e2e/use_case_join_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,6 @@ def root(tmp_path):
finally:
shutil.rmtree(tree, ignore_errors=True)

@pytest.mark.xfail(
strict=True,
reason="two serial tree walks run over the speed-of-light bar",
)
def it_matches_native_rows_within_the_bar(root):
startup = startup_seconds()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Changed** A statement that names several path-tables walks their trees at the same time, each on a thread of its own, instead of one after the other as SQLite opens their cursors. A join between two globs over one corpus now costs about one walk.
41 changes: 41 additions & 0 deletions packages/rust/migrations.d/2026-10-02-path-table-parallel-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
### Core: the path tables of one statement are walked at the same time

#### Summary

A statement that names several path tables used to walk their trees one
after the other, each walk running inside SQLite's first `xFilter` on that
table's cursor. The walks now start together when the statement is prepared,
each on a thread of its own, and the first cursor over a table joins its
walk. Nothing in the API changed: no signature, flag, config key, exit code
or row shape differs. Rust callers, the CLI and the Python and TypeScript
SDKs all pick this up without edits.

#### Required changes

_None._

#### Deprecations removed

_None._

#### Behavior changes without code changes

- Every path table a statement reads is walked as soon as the statement is
prepared, even one whose cursor SQLite ends up never opening (the inner
side of a join whose outer side turns out empty). Before, such a table cost
nothing; now it costs a walk on a background thread. The rows are the same
either way.
- A statement over several path tables uses as many threads as it names
tables while they walk, so it runs on more than one core at once.

#### Verification

```bash
cd "$(mktemp -d)"
mkdir -p papers/one papers/two
printf 'T1' > papers/one/title.md
printf 'A1' > papers/one/abstract.md
printf 'T2' > papers/two/title.md
dirsql query "SELECT a.dir, t.content AS title FROM './papers/*/abstract.md' a JOIN './papers/*/title.md' t ON t.dir = a.dir ORDER BY a.dir"
# expected: [{"dir":"papers/one","title":"T1"}] -- the same rows as before, one walk's worth of wall time
```
45 changes: 38 additions & 7 deletions packages/rust/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -803,9 +803,9 @@ impl Db {
// error stands. That is what bounds the loop.
let mut attempted: HashSet<String> = HashSet::new();

let mut stmt = loop {
let (mut stmt, reads) = loop {
let error = match self.prepare_guarded(sql) {
Ok(stmt) => break stmt,
Ok(prepared) => break prepared,
Err(e) => e,
};
let Some((name, table)) = self.path_table_for(&error)? else {
Expand All @@ -820,6 +820,7 @@ impl Db {
if !stmt.readonly() {
return Err(DbError::WriteForbidden);
}
self.scope.warm(&reads);
let column_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();

let rows = stmt.query_map([], |row| {
Expand All @@ -842,10 +843,10 @@ impl Db {
}

/// Prepare `sql` with the internal-table / ATTACH authorizer installed for
/// exactly that one call. The authorizer is installed and cleared around
/// every attempt, so a re-prepare after registering a path-table is gated
/// identically to the first.
fn prepare_guarded(&self, sql: &str) -> Result<rusqlite::Statement<'_>> {
/// exactly that one call, and name the tables it reads. The authorizer is
/// installed and cleared around every attempt, so a re-prepare after
/// registering a path-table is gated identically to the first.
fn prepare_guarded(&self, sql: &str) -> Result<(rusqlite::Statement<'_>, HashSet<String>)> {
use rusqlite::hooks::{AuthAction, AuthContext, Authorization};
use std::sync::{Arc, Mutex};

Expand All @@ -854,12 +855,18 @@ impl Db {
// matching message (the closure must be `'static`, so it owns a clone).
let denial: Arc<Mutex<Option<&'static str>>> = Arc::new(Mutex::new(None));
let denial_cb = Arc::clone(&denial);
let reads: Arc<Mutex<HashSet<String>>> = Arc::default();
let reads_cb = Arc::clone(&reads);
self.conn
.authorizer(Some(move |ctx: AuthContext<'_>| match ctx.action {
AuthAction::Read { table_name, .. } if is_internal_table(table_name) => {
*denial_cb.lock().unwrap() = Some(INTERNAL_TABLE_DENIED_MSG);
Authorization::Deny
}
AuthAction::Read { table_name, .. } => {
reads_cb.lock().unwrap().insert(table_name.to_owned());
Authorization::Allow
}
AuthAction::Pragma {
pragma_value: Some(value),
..
Expand All @@ -883,7 +890,7 @@ impl Db {
.authorizer(None::<fn(AuthContext<'_>) -> Authorization>);

match prepared {
Ok(stmt) => Ok(stmt),
Ok(stmt) => Ok((stmt, std::mem::take(&mut *reads.lock().unwrap()))),
Err(e)
if e.sqlite_error_code()
== Some(rusqlite::ErrorCode::AuthorizationForStatementDenied) =>
Expand Down Expand Up @@ -1609,6 +1616,30 @@ mod tests {
);
}

#[test]
fn prepare_guarded_names_the_tables_a_statement_reads() {
let db = Db::new().unwrap();
db.conn
.execute_batch("CREATE TABLE a(x); CREATE TABLE b(y)")
.unwrap();

let (_, reads) = db
.prepare_guarded("SELECT count(*) FROM a JOIN b ON b.y = a.x")
.unwrap();

assert_eq!(reads, HashSet::from(["a".to_string(), "b".to_string()]));
}

#[test]
fn prepare_guarded_names_a_table_the_statement_takes_no_column_from() {
let db = Db::new().unwrap();
db.conn.execute_batch("CREATE TABLE a(x)").unwrap();

let (_, reads) = db.prepare_guarded("SELECT count(*) FROM a").unwrap();

assert_eq!(reads, HashSet::from(["a".to_string()]));
}

#[test]
fn internal_table_denied_message_mentions_the_namespace() {
assert!(INTERNAL_TABLE_DENIED_MSG.contains("not authorized"));
Expand Down
Loading
Loading