Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
f71fe3c
fix(cypher): preserve integer list precision
stugorf Oct 25, 2026
0d3681b
feat(cypher): support list and map parameters
stugorf Oct 25, 2026
a61cea3
fix(graph): prevent stale concurrent refresh publication
stugorf Oct 25, 2026
181875f
refactor(graph): add read-only config mapping iterators
stugorf Oct 25, 2026
356f204
feat(cypher): validate known comparison and arithmetic types
stugorf Oct 25, 2026
5bc730e
perf(cypher): cache snapshot-local scalar query templates
stugorf Oct 25, 2026
7adc028
feat(cypher): bind parameters in skip and limit
stugorf Oct 25, 2026
3c3e2af
fix(cypher): bypass templates for parameterized row counts
stugorf Oct 25, 2026
dd15d82
test(cypher): document and exercise template reuse boundaries
stugorf Oct 25, 2026
9f82994
test(graph): restore refresh assertion array import
stugorf Oct 25, 2026
aa368eb
fix(query): resolve shadowing output aliases in sort
stugorf Oct 25, 2026
2c5e1ac
fix(cypher): lower empty maps as empty struct values
stugorf Oct 25, 2026
0dbe2e1
perf(cypher): count unmapped comprehensions without list aggregation
stugorf Oct 25, 2026
1ac21f9
feat(cypher): add math, string, and list scalar functions
stugorf Oct 25, 2026
fbff970
refactor(cypher): reuse templates for stable unaliased columns
stugorf Oct 25, 2026
b118562
test(cypher): keep unsupported-function coverage after replace support
stugorf Oct 25, 2026
dbef77d
feat(query): execute read-only correlated CALL subqueries
stugorf Oct 25, 2026
c16fd54
feat(cypher): execute union queries with isolated scopes
stugorf Oct 25, 2026
123c8d3
fix(query): preserve CALL scopes across UNION branches
stugorf Oct 25, 2026
67912be
fix(csr): stream expansion in bounded chunks
stugorf Oct 25, 2026
131741a
perf(csr): traverse variable-length paths iteratively
stugorf Oct 25, 2026
e307822
perf(csr): reuse filtered target columns within each batch
stugorf Oct 25, 2026
0870350
perf(graph): search shortest paths from both ends
stugorf Oct 25, 2026
673d106
feat(cypher): add map and entity keys with node labels
stugorf Oct 25, 2026
6e535f6
feat(cypher): preserve path and relationship values
stugorf Oct 25, 2026
8e1563f
feat(cypher): add ordered reduce and list predicates
stugorf Oct 25, 2026
ae1218f
feat(python): ship public API type stubs
stugorf Oct 25, 2026
647018a
feat(graph): enforce cooperative query deadlines
stugorf Oct 25, 2026
b05ed30
fix(query): retain scope and snapshot boundaries across features
stugorf Oct 25, 2026
97ce7d6
fix(cypher): read every rich path source partition
stugorf Oct 25, 2026
33da59d
fix(query): yield while CALL consumes empty batches
stugorf Oct 25, 2026
65ae2a7
perf(cypher): keep compact paths through WITH aliases
stugorf Oct 25, 2026
6f1787f
docs(python): show standalone bridge tests on macOS
stugorf Oct 25, 2026
32edb0c
test(cypher): cover path aliases through CALL and UNION
stugorf Oct 25, 2026
68fdcb4
fix(cypher): reject ambiguous rich path endpoint IDs
stugorf Oct 25, 2026
f4f5bfa
fix(python): release timed streams after execution errors
stugorf Oct 25, 2026
5f4d139
test(query): isolate CALL cooperative scheduling bound
stugorf Oct 25, 2026
c3c799c
refactor(cypher): reuse strict DataFusion math kernels
stugorf Oct 25, 2026
99655be
fix(cypher): check scalar result types across query scopes
stugorf Oct 25, 2026
3465806
fix(cypher): preserve cross-label shortest path identity
stugorf Oct 25, 2026
ff9108f
fix(cypher): reject unsigned range argument overflow
stugorf Oct 25, 2026
c9b3c97
fix(cypher): guard SQL export of runtime-only functions
stugorf Oct 25, 2026
91d6b48
test(query): configure graph for path cache eligibility
stugorf Oct 25, 2026
2dac56b
feat(python): allow query partition target overrides
stugorf Oct 25, 2026
7461a77
docs(python): locate stdlib for embedded bridge tests
stugorf Oct 25, 2026
28566db
docs(cypher): clarify unsigned math conversion
stugorf Oct 25, 2026
063e156
fix(cypher): preserve typed nulls in integrated SQL export
stugorf Oct 25, 2026
bfbbb24
docs(cypher): explain path and CALL AST migrations
stugorf Oct 25, 2026
3a6039b
perf(csr): inline neighbor cursor lookups
stugorf Oct 26, 2026
ad3b35a
fix(query): stop Cartesian work at deadline checkpoints
stugorf Oct 26, 2026
2a9357a
fix(query): wire existing runtime and physical plan APIs
stugorf Oct 26, 2026
1d5d283
fix(query): share one monotonic clock through deadline planning
stugorf Oct 26, 2026
8ab8bf2
perf(paths): keep null checks on compact shortest plans
stugorf Oct 26, 2026
6745cbe
fix(paths): borrow null-check inputs during demand analysis
stugorf Oct 26, 2026
c89fe9e
fix(paths): bind compact null tests to the length column
stugorf Oct 26, 2026
1398769
style(tests): wrap the compact-path null regression query
stugorf Oct 26, 2026
6f86a1a
perf(csr): inline the measured cursor construction hot path
stugorf Oct 26, 2026
23bc699
test(query): cover deadlines in correlated CALL subqueries
stugorf Oct 26, 2026
7d2ef3e
fix(csr): integrate bounded raw work across traversal operators
stugorf Oct 26, 2026
5fe24cd
docs: verify issue resolutions against current contribution rules
stugorf Oct 26, 2026
c47bf5c
Merge integrated Cypher, CSR and engine work
prrao87 Oct 26, 2026
bd11b76
fix(cypher): keep string-number comparisons coercible
prrao87 Oct 26, 2026
dd19097
fix(csr): give unlabeled variable-length targets the source label
prrao87 Oct 26, 2026
3c1809e
fix(cypher): export typed nulls as NULL outside the Default dialect
prrao87 Oct 26, 2026
ff2123f
fix(cypher): reject queries that mix UNION and UNION ALL
prrao87 Oct 26, 2026
e895aaa
fix(paths): keep start-node filters above the path operator
prrao87 Oct 26, 2026
4d0ed86
docs: document the target_partitions range and Rust constructor
prrao87 Oct 26, 2026
bec6758
feat(rust): document cooperative query deadlines for Rust callers
prrao87 Oct 26, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/lance-csr-benches/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ lance-csr = { path = "../lance-csr", version = "0.3.0" }
lance = "13.0.0"
arrow-array = "58"
arrow-schema = "58"
datafusion = { version = "54", default-features = false }
criterion = { version = "0.5", features = ["async", "async_tokio", "html_reports"] }
futures = "0.3"
tempfile = "3"
Expand Down
38 changes: 38 additions & 0 deletions crates/lance-csr-benches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,41 @@ cargo bench -p lance-csr-benches --bench graph_execution

This crate is not published to crates.io and is excluded from releases.
It exists solely for performance testing during development.


## Query template reuse

Measure fresh parsing/planning/execution against reuse of an unoptimized scalar-parameter template on the
same 10,000-row Arrow snapshot. Both paths optimize and build a physical plan for each execution. The harness
checks identical Arrow results, row count and ID checksum before timing; catalog construction is excluded.

```bash
cargo bench -p lance-csr-benches --bench graph_execution -- query_templates --warm-up-time 1
```

For a stage breakdown on a separate fixed 1,000-row scan, run the optional core diagnostic:

```bash
cargo test --release -p lance-csr profile_query_planning_stages -- --ignored --nocapture
```

It reports elapsed means for 100 iterations of parsing, semantic analysis, graph logical planning,
DataFusion logical planning, optimization, physical planning, execution and cached binding. Physical timing
calls DataFusion's query planner directly so optimization is counted only once. It verifies 990 result rows
and their ID checksum. These local measurements do not replace the two SF1 suites required for performance
submissions; record runtime, hardware, revision and build profile with any reported numbers. The engine's
Python cache also includes dictionary conversion and lookup, which this Rust microbenchmark does not time.


## Comprehension counts

Compare `size([x IN list WHERE predicate])` with the equivalent identity-mapped comprehension, which keeps the
ordinary list-building path. Both queries run against 1,000 input rows with list lengths 16 and 256; results,
row count and summed counts must match before timing. This measures planning plus execution after parsing.

```bash
cargo bench -p lance-csr-benches --bench graph_execution -- comprehension_count --warm-up-time 1
```

The count path removes ordered list aggregation. It still numbers and unnests rows, evaluates predicates and
groups by input row, so these measurements do not establish a general bound on planning or memory use.
116 changes: 115 additions & 1 deletion crates/lance-csr-benches/benches/graph_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,5 +336,119 @@ fn bench_cypher_execution(c: &mut Criterion) {
group.finish();
}

criterion_group!(benches, bench_cypher_execution);
// A fixed snapshot isolates template reuse from dataset/catalog construction.
// The result count and checksum are checked outside the timed loops.
fn bench_query_templates(c: &mut Criterion) {
use datafusion::datasource::{DefaultTableSource, MemTable};
use datafusion::execution::context::SessionContext;
use lance_csr::{GraphSourceCatalog, InMemoryCatalog};
let rt = tokio::runtime::Runtime::new().unwrap();
let context = SessionContext::new();
let batch = make_people_batch(10_000);
let provider = Arc::new(MemTable::try_new(batch.schema(), vec![vec![batch]]).unwrap());
let catalog: Arc<dyn GraphSourceCatalog> = Arc::new(
InMemoryCatalog::new()
.with_node_source("Person", Arc::new(DefaultTableSource::new(provider))),
);
let config = GraphConfig::builder()
.node_table("Person", "person_id")
.build()
.unwrap();
let text = "MATCH (p:Person) WHERE p.age >= $minimum RETURN p.person_id AS id ORDER BY id";
let fresh = || {
CypherQuery::new(text)
.unwrap()
.with_config(config.clone())
.with_parameter("minimum", 50)
};
let mut template = fresh();
assert!(template.prepare_cached_plan(catalog.clone()).unwrap());
let expected = rt
.block_on(fresh().execute_with_catalog_and_context(catalog.clone(), context.clone()))
.unwrap();
let cached = rt
.block_on(template.execute_with_catalog_and_context(catalog.clone(), context.clone()))
.unwrap();
assert_eq!(cached, expected);
let expected_ids: Vec<i32> = (0..10_000).filter(|id| 20 + id % 60 >= 50).collect();
assert_eq!(cached.num_rows(), expected_ids.len());
let ids = cached
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
assert_eq!(
ids.values().iter().map(|id| i64::from(*id)).sum::<i64>(),
expected_ids.iter().map(|id| i64::from(*id)).sum::<i64>()
);
let mut group = c.benchmark_group("query_templates");
group.bench_function("cold_parse_plan_execute", |b| {
b.iter(|| {
black_box(
rt.block_on(
fresh().execute_with_catalog_and_context(catalog.clone(), context.clone()),
)
.unwrap(),
);
})
});
group.bench_function("cached_bind_plan_execute", |b| {
b.iter(|| {
let query = template.clone().with_parameter("minimum", black_box(50));
black_box(
rt.block_on(
query.execute_with_catalog_and_context(catalog.clone(), context.clone()),
)
.unwrap(),
);
})
});
group.finish();
}

fn bench_comprehension_counts(c: &mut Criterion) {
use arrow_array::Int64Array;
let rt = tokio::runtime::Runtime::new().unwrap();
let config = GraphConfig::builder()
.node_table("Person", "person_id")
.build()
.unwrap();
let data = HashMap::from([("Person".to_string(), make_people_batch(1000))]);
let mut group = c.benchmark_group("comprehension_count");
for length in [16, 256] {
let query = |map| {
CypherQuery::new(&format!(
"MATCH (p:Person) RETURN p.person_id AS id, size([x IN range(0, {}) WHERE x % 2 = 0 {map}]) AS n ORDER BY id", length - 1
)).unwrap().with_config(config.clone())
};
let count = query("");
let reference = query("| x");
let actual = execute_cypher_query(&rt, &count, data.clone());
let expected = execute_cypher_query(&rt, &reference, data.clone());
assert_eq!(actual.columns(), expected.columns());
assert_eq!(actual.num_rows(), 1000);
let counts = actual
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
assert_eq!(counts.values().iter().sum::<i64>(), 1000 * length / 2);
for (name, query) in [
("filtered_count", &count),
("materialized_reference", &reference),
] {
group.bench_with_input(BenchmarkId::new(name, length), &length, |b, _| {
b.iter(|| black_box(execute_cypher_query(&rt, query, data.clone())));
});
}
}
group.finish();
}

criterion_group!(
benches,
bench_cypher_execution,
bench_query_templates,
bench_comprehension_counts
);
criterion_main!(benches);
1 change: 1 addition & 0 deletions crates/lance-csr-python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/lance-csr-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ lance-csr = { path = "../lance-csr" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py312", "py-clone"] }
tokio = { version = "1.37", features = ["rt-multi-thread", "macros"] }
tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "sync", "time"] }
72 changes: 68 additions & 4 deletions crates/lance-csr-python/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,54 @@
// limitations under the License.

use futures::Future;
use pyo3::{PyResult, Python};
use pyo3::{
exceptions::{PyTimeoutError, PyValueError},
PyResult, Python,
};
use std::time::Duration;

pub(crate) const QUERY_TIMEOUT_MESSAGE: &str = "Query timeout exceeded";

/// One absolute deadline shared by planning, execution and all streamed reads.
#[derive(Clone, Copy)]
pub(crate) struct QueryDeadline(Option<tokio::time::Instant>);

impl QueryDeadline {
pub(crate) fn new(timeout: Option<f64>) -> PyResult<Self> {
let Some(seconds) = timeout else {
return Ok(Self(None));
};
let duration = Duration::try_from_secs_f64(seconds).map_err(|_| {
PyValueError::new_err("timeout must be a finite nonnegative number of seconds")
})?;
let instant = tokio::time::Instant::now()
.checked_add(duration)
.ok_or_else(|| PyValueError::new_err("timeout exceeds the supported deadline range"))?;
Ok(Self(Some(instant)))
}

pub(crate) fn instant(self) -> Option<tokio::time::Instant> {
self.0
}

pub(crate) fn check(self) -> PyResult<()> {
if self
.0
.is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
{
Err(PyTimeoutError::new_err(QUERY_TIMEOUT_MESSAGE))
} else {
Ok(())
}
}

async fn expired(self) {
match self.0 {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending::<()>().await,
}
}
}

pub const SIGNAL_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);

Expand Down Expand Up @@ -56,7 +103,22 @@ impl BackgroundExecutor {
where
F::Output: Send,
{
let future = Self::result_or_interrupt(future);
self.block_on_until(py, QueryDeadline(None), future)
}

/// Apply a query deadline without changing unrelated executor callers.
pub(crate) fn block_on_until<F: Future + Send>(
&self,
py: Option<Python<'_>>,
deadline: QueryDeadline,
future: F,
) -> PyResult<F::Output>
where
F::Output: Send,
{
deadline.check()?;
let future = lance_csr::query::with_execution_deadline(deadline.instant(), future);
let future = Self::result_or_interrupt(future, deadline);
if let Some(py) = py {
py.detach(move || self.runtime.block_on(future))
} else {
Expand All @@ -65,7 +127,7 @@ impl BackgroundExecutor {
}
}

async fn result_or_interrupt<F>(future: F) -> PyResult<F::Output>
async fn result_or_interrupt<F>(future: F, deadline: QueryDeadline) -> PyResult<F::Output>
where
F: Future + Send,
F::Output: Send,
Expand All @@ -84,7 +146,9 @@ impl BackgroundExecutor {
};

tokio::select! {
result = future => Ok(result),
biased;
_ = deadline.expired() => Err(PyTimeoutError::new_err(QUERY_TIMEOUT_MESSAGE)),
result = future => { deadline.check()?; Ok(result) },
err = interrupt_future => err,
}
}
Expand Down
Loading
Loading