Skip to content

Commit b0dbc30

Browse files
authored
feat: add spill-backed sorting and aggregation (#377)
* feat(execution): add spillable vector * feat(execution): add spill-backed external sort * feat(execution): add stream aggregation * refactor(execution): simplify values and stream aggregation * feat(optimizer): support forced spill aggregation * chore: clean up spill docs and clippy warnings * feat(optimizer): support forced nested-loop joins * test: cover spill error paths * refactor: keep external sort heads in cursors * refactor: simplify spill state handling
1 parent 3f0df6f commit b0dbc30

49 files changed

Lines changed: 2507 additions & 268 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ lmdb = ["dep:lmdb", "dep:lmdb-sys"]
3737
pprof = ["pprof/criterion", "pprof/flamegraph"]
3838
python = ["parser", "dep:pyo3"]
3939
shell = ["parser", "dep:comfy-table", "dep:rustyline"]
40+
spill = []
4041
wasm = [
4142
"dep:js-sys",
4243
"dep:wasm-bindgen",
44+
"time",
45+
"macros",
4346
"parser",
4447
]
4548

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ COVERAGE_REPORT_ARGS ?= --llvm --ignore-not-existing --keep-only 'src/**' --igno
2121

2222
## Run default Rust tests in the current environment (non-WASM).
2323
test:
24-
$(CARGO) test --all
24+
$(CARGO) test --all --features spill
2525

2626
## Run Python binding API tests implemented with pyo3.
2727
test-python:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ fn main() -> Result<(), DatabaseError> {
143143
- Cargo features:
144144
- `rocksdb` is enabled by default
145145
- `parser` is enabled by default and provides the SQL parser frontend
146+
- `spill` optionally enables spill-backed external sorting and aggregation
147+
- `spill` and `wasm` are mutually exclusive
146148
- `lmdb` is optional
147149
- `unsafe_txdb_checkpoint` enables experimental checkpoint support for RocksDB `TransactionDB`
148150
- `cargo check --no-default-features --features lmdb` builds an LMDB-only native configuration

benchmarks/query_benchmark.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,15 @@ fn query_on_execute(c: &mut Criterion) {
112112
let kite_label = format!("KiteSQL: {name} by '{case}'");
113113
c.bench_function(&kite_label, |b| {
114114
b.iter(|| {
115-
for tuple in database.run(case).unwrap() {
116-
let _ = tuple.unwrap();
117-
}
115+
let mut tuples = database.run(case).unwrap();
116+
while tuples
117+
.next_tuple(|_, tuple| {
118+
std::hint::black_box(tuple);
119+
})
120+
.unwrap()
121+
.is_some()
122+
{}
123+
tuples.done().unwrap();
118124
})
119125
});
120126

docs/features.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,68 @@ See [the ORM guide](../src/orm/README.md) for the full ORM guide, including:
4242
- public ORM structs, enums, and traits
4343
- related `ResultIter::orm::<M>()` integration
4444

45+
### Spill-backed Aggregation: `features = ["spill"]`
46+
47+
The native-only `spill` feature bounds memory usage for large grouped
48+
aggregations. It is not enabled by default and cannot be combined with `wasm`.
49+
50+
For SQL, place the optimizer hint immediately after `SELECT`:
51+
52+
```sql
53+
SELECT /*+ FORCE_AGG_SPILL */ user_id, COUNT(*)
54+
FROM orders
55+
GROUP BY user_id
56+
ORDER BY user_id;
57+
```
58+
59+
For ORM queries, enable both `orm` and `spill`, then call `force_spill()` before
60+
building the projection and aggregate plan:
61+
62+
```rust,ignore
63+
let grouped = database.bind(|ctx| {
64+
ctx.from::<Order>()?
65+
.force_spill()?
66+
.project_tuple(|e| {
67+
Ok(vec![e.column(Order::user_id())?, e.count_all()?])
68+
})?
69+
.group_by(|e| e.column(Order::user_id()))?
70+
.order_by(Order::user_id())?
71+
.finish()
72+
})?;
73+
```
74+
75+
KiteSQL still reuses an already ordered input when possible. Otherwise it adds
76+
an external sort on the group keys and executes a streaming aggregate. The
77+
hint is intended for grouped aggregates or `DISTINCT`; an aggregate without
78+
group keys already uses constant-size accumulator state.
79+
80+
### Nested-loop Join Hint
81+
82+
Use `FORCE_NEST_LOOP_JOIN` to select the nested-loop implementation for joins
83+
in the current `SELECT` query block:
84+
85+
```sql
86+
SELECT /*+ FORCE_NEST_LOOP_JOIN */ orders.id, users.name
87+
FROM orders
88+
JOIN users ON orders.user_id = users.id;
89+
```
90+
91+
ORM queries can select the same implementation before adding joins:
92+
93+
```rust,ignore
94+
let joined = database.bind(|ctx| {
95+
ctx.from::<Order>()?
96+
.force_nested_loop()
97+
.inner_join::<User, _>(|e| {
98+
e.column(Order::user_id())?.eq(e.column(User::id())?)
99+
})?
100+
.finish()
101+
})?;
102+
```
103+
104+
It avoids Hash Join's build-side tuple materialization, but may perform
105+
substantially more work on large inputs.
106+
45107
### User-Defined Function: `features = ["macros"]`
46108
```rust
47109
scala_function!(TestFunction::test(LogicalType::Integer, LogicalType::Integer) -> LogicalType::Integer => |v1: DataValue, v2: DataValue| {

src/binder/aggregate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ impl<T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'_, '_, T, A>
5555
agg_calls,
5656
groupby_exprs,
5757
false,
58+
self.force_spill,
5859
))
5960
}
6061

src/binder/distinct.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ impl<T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'_, '_, T, A>
3535
vec![],
3636
select_list,
3737
true,
38+
self.force_spill,
3839
))
3940
}
4041

src/binder/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,8 @@ impl<'a, T: Transaction> BinderContext<'a, T> {
561561
pub struct Binder<'a, 'parent, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> {
562562
pub(crate) context: BinderContext<'a, T>,
563563
pub(crate) args: &'a A,
564+
pub(crate) force_spill: bool,
565+
pub(crate) force_nested_loop: bool,
564566
with_pk: Option<TableName>,
565567
pub(crate) parent: Option<&'parent BinderContext<'a, T>>,
566568
}
@@ -574,6 +576,8 @@ impl<'a, 'parent, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<
574576
Binder {
575577
context,
576578
args,
579+
force_spill: false,
580+
force_nested_loop: false,
577581
with_pk: None,
578582
parent,
579583
}

src/binder/parser.rs

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,7 @@ where
13641364
self.binder.bind_table_ref_sql(from, self.arena)?,
13651365
JoinCondition::None,
13661366
JoinType::Cross,
1367+
self.binder.force_nested_loop,
13671368
)
13681369
}
13691370
plan
@@ -2594,6 +2595,7 @@ impl<'a, 'parent, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<
25942595
arena: &mut PlanArena,
25952596
) -> Result<LogicalPlan, DatabaseError> {
25962597
let Select {
2598+
optimizer_hint,
25972599
projection,
25982600
from,
25992601
selection,
@@ -2615,19 +2617,40 @@ impl<'a, 'parent, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<
26152617
"QUALIFY is not supported".to_string(),
26162618
));
26172619
}
2618-
Ok(self
2619-
.build_plan(arena)
2620-
.from_sql(from)?
2621-
.select_list_from_sql(projection)?
2622-
.where_sql(selection.as_ref())?
2623-
.aggregate_sql(group_by, having.as_ref(), orderby)?
2624-
.having()?
2625-
.window()?
2626-
.distinct_sql(distinct.as_ref())?
2627-
.order_by()?
2628-
.project()?
2629-
.select_into_sql(into.as_ref())?
2630-
.finish())
2620+
let has_hint = |expected: &str| {
2621+
optimizer_hint.as_ref().is_some_and(|hint| {
2622+
hint.text
2623+
.split(|char: char| char.is_ascii_whitespace() || char == ',')
2624+
.any(|hint| hint.eq_ignore_ascii_case(expected))
2625+
})
2626+
};
2627+
let force_spill = has_hint("FORCE_AGG_SPILL");
2628+
let force_nested_loop = has_hint("FORCE_NEST_LOOP_JOIN");
2629+
if force_spill && !cfg!(feature = "spill") {
2630+
return Err(DatabaseError::UnsupportedStmt(
2631+
"FORCE_AGG_SPILL requires the `spill` feature".to_string(),
2632+
));
2633+
}
2634+
let previous_options = (self.force_spill, self.force_nested_loop);
2635+
self.force_spill = force_spill;
2636+
self.force_nested_loop = force_nested_loop;
2637+
let result = (|| {
2638+
Ok(self
2639+
.build_plan(arena)
2640+
.from_sql(from)?
2641+
.select_list_from_sql(projection)?
2642+
.where_sql(selection.as_ref())?
2643+
.aggregate_sql(group_by, having.as_ref(), orderby)?
2644+
.having()?
2645+
.window()?
2646+
.distinct_sql(distinct.as_ref())?
2647+
.order_by()?
2648+
.project()?
2649+
.select_into_sql(into.as_ref())?
2650+
.finish())
2651+
})();
2652+
(self.force_spill, self.force_nested_loop) = previous_options;
2653+
result
26312654
}
26322655

26332656
/// FIXME: temp values need to register BindContext.bind_table
@@ -3137,6 +3160,54 @@ mod tests {
31373160
Ok(())
31383161
}
31393162

3163+
#[test]
3164+
fn force_nest_loop_join_marks_join_operator() -> Result<(), DatabaseError> {
3165+
let tables = build_t1_table()?;
3166+
let plan =
3167+
tables.plan("select /*+ FORCE_NEST_LOOP_JOIN */ c1, c3 from t1 join t2 on c1 = c3")?;
3168+
let join = plan
3169+
.childrens
3170+
.iter()
3171+
.find_map(|plan| match &plan.operator {
3172+
Operator::Join(operator) => Some(operator),
3173+
_ => None,
3174+
})
3175+
.expect("query should contain a join");
3176+
3177+
assert!(join.force_nested_loop);
3178+
Ok(())
3179+
}
3180+
3181+
#[cfg(feature = "spill")]
3182+
#[test]
3183+
fn optimizer_hints_can_be_combined() -> Result<(), DatabaseError> {
3184+
let tables = build_t1_table()?;
3185+
let plan = tables.plan(
3186+
"select /*+ FORCE_AGG_SPILL, FORCE_NEST_LOOP_JOIN */ c1, count(c3) \
3187+
from t1 join t2 on c1 = c3 group by c1",
3188+
)?;
3189+
let aggregate_plan = plan
3190+
.childrens
3191+
.iter()
3192+
.find(|plan| matches!(plan.operator, Operator::Aggregate(_)))
3193+
.expect("query should contain an aggregate");
3194+
let Operator::Aggregate(aggregate) = &aggregate_plan.operator else {
3195+
unreachable!()
3196+
};
3197+
let join = aggregate_plan
3198+
.childrens
3199+
.iter()
3200+
.find_map(|plan| match &plan.operator {
3201+
Operator::Join(operator) => Some(operator),
3202+
_ => None,
3203+
})
3204+
.expect("aggregate input should contain a join");
3205+
3206+
assert!(aggregate.force_spill);
3207+
assert!(join.force_nested_loop);
3208+
Ok(())
3209+
}
3210+
31403211
#[cfg(feature = "copy")]
31413212
#[test]
31423213
fn test_copy_file_format_options() -> Result<(), DatabaseError> {

src/binder/select.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,7 @@ impl<'a: 'b, 'b, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'
884884
}
885885

886886
fn build_join_from_split_scope_predicates(
887+
&self,
887888
mut children: LogicalPlan,
888889
mut plan: LogicalPlan,
889890
join_ty: JoinType,
@@ -931,6 +932,7 @@ impl<'a: 'b, 'b, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'
931932
plan,
932933
join_condition,
933934
join_ty,
935+
self.force_nested_loop,
934936
))
935937
}
936938

@@ -1403,7 +1405,13 @@ impl<'a: 'b, 'b, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'
14031405
)?;
14041406
Self::localize_join_condition_from_join_scope(&mut on, left_len)?;
14051407

1406-
Ok(LJoinOperator::build(left, right, on, join_type))
1408+
Ok(LJoinOperator::build(
1409+
left,
1410+
right,
1411+
on,
1412+
join_type,
1413+
self.force_nested_loop,
1414+
))
14071415
}
14081416

14091417
pub(crate) fn bind_where_expr(
@@ -1500,7 +1508,7 @@ impl<'a: 'b, 'b, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'
15001508
.to_string(),
15011509
));
15021510
}
1503-
children = Self::build_join_from_split_scope_predicates(
1511+
children = self.build_join_from_split_scope_predicates(
15041512
children,
15051513
plan,
15061514
JoinType::Inner,
@@ -1909,10 +1917,7 @@ impl<'a: 'b, 'b, T: Transaction, A: AsRef<[(&'static str, DataValue)]>> Binder<'
19091917
self.context.step(QueryBindStep::Sort);
19101918

19111919
Ok(LogicalPlan::new(
1912-
Operator::Sort(SortOperator {
1913-
sort_fields,
1914-
limit: None,
1915-
}),
1920+
Operator::Sort(SortOperator { sort_fields }),
19161921
Childrens::Only(Box::new(children)),
19171922
))
19181923
}

0 commit comments

Comments
 (0)