Skip to content

Commit c7d3ad1

Browse files
authored
fix(cubestore): Фvoid empty SortPreservingMerge for empty sort_on (cube-js#11160)
A rolling-window pre-aggregation joins a generated time-series with the rollup table on an inequality (and, under Tesseract, scalar window-bound subqueries), which lowers to a join with an empty equi-join `on`. The empty `on` propagates as an empty `sort_on` to the index scan, which then builds a SortPreservingMergeExec with no sort expressions. Under DataFusion 46 such a merge errors at execution with "Sort expressions cannot be empty for streaming merge" once a worker has more than one partition to merge.
1 parent 71d114d commit c7d3ad1

3 files changed

Lines changed: 60 additions & 9 deletions

File tree

rust/cubestore/cubestore-sql-tests/src/tests.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub fn sql_tests(prefix: &str) -> Vec<(&'static str, TestFn)> {
5454
t("float_merge", float_merge),
5555
t("join", join),
5656
t("filtered_join", filtered_join),
57+
t("cross_join_empty_sort_on", cross_join_empty_sort_on),
5758
t("three_tables_join", three_tables_join),
5859
t(
5960
"three_tables_join_with_filter",
@@ -874,6 +875,48 @@ async fn join(service: Box<dyn SqlClient>) -> Result<(), CubeError> {
874875
Ok(())
875876
}
876877

878+
/// Reproduces CORE-593: a rolling-window pre-aggregation generates a cross/range join (empty
879+
/// equi-join `on`) over a rollup table plus GROUP BY + ORDER BY. The empty `on` propagates as an
880+
/// empty `sort_on` to the index scan, which builds a SortPreservingMergeExec with no sort
881+
/// expressions. With DataFusion 46 such a merge errors at execution ("Sort expressions cannot be
882+
/// empty for streaming merge") -- but only when the worker has more than one partition to merge,
883+
/// so the table must hold several chunks.
884+
async fn cross_join_empty_sort_on(service: Box<dyn SqlClient>) -> Result<(), CubeError> {
885+
let _ = service.exec_query("CREATE SCHEMA foo").await?;
886+
let _ = service
887+
.exec_query("CREATE TABLE foo.t (source text, n int)")
888+
.await?;
889+
890+
// Separate inserts produce separate chunks, so the index scan merges more than one partition.
891+
service
892+
.exec_query("INSERT INTO foo.t (source, n) VALUES ('a', 1), ('b', 2)")
893+
.await?;
894+
service
895+
.exec_query("INSERT INTO foo.t (source, n) VALUES ('a', 3)")
896+
.await?;
897+
service
898+
.exec_query("INSERT INTO foo.t (source, n) VALUES ('c', 4)")
899+
.await?;
900+
901+
let result = service
902+
.exec_query(
903+
"SELECT q.source, sum(q.n) FROM foo.t q \
904+
CROSS JOIN (SELECT 1 AS x UNION ALL SELECT 2 AS x) series \
905+
GROUP BY q.source ORDER BY q.source",
906+
)
907+
.await?;
908+
909+
assert_eq!(
910+
to_rows(&result),
911+
vec![
912+
vec![TableValue::String("a".to_string()), TableValue::Int(8)],
913+
vec![TableValue::String("b".to_string()), TableValue::Int(4)],
914+
vec![TableValue::String("c".to_string()), TableValue::Int(8)],
915+
]
916+
);
917+
Ok(())
918+
}
919+
877920
async fn filtered_join(service: Box<dyn SqlClient>) -> Result<(), CubeError> {
878921
let _ = service.exec_query("CREATE SCHEMA foo").await?;
879922

rust/cubestore/cubestore-sql-tests/tests/migration.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ fn main() {
3030
"repartition_multi_node_consistency".to_string(),
3131
"--skip".to_string(),
3232
"rolling_window_no_aggregates".to_string(),
33+
"--skip".to_string(),
34+
"cross_join_empty_sort_on".to_string(),
3335
];
3436
run_sql_tests("migration", extra_args, move |test_name, test_fn| {
3537
let r = Builder::new_current_thread()

rust/cubestore/cubestore/src/queryplanner/planning.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,15 +1464,21 @@ fn pick_index(
14641464

14651465
let schema = Arc::new(schema);
14661466
let create_snapshot = |index: &IdRow<Index>| {
1467-
let index_sort_on = sort_on.map(|sc| {
1468-
index
1469-
.get_row()
1470-
.columns()
1471-
.iter()
1472-
.take(sc.0.len())
1473-
.map(|c| c.get_name().clone())
1474-
.collect::<Vec<_>>()
1475-
});
1467+
// An empty join `on` (a cross/range join, as rolling-window queries produce) yields an
1468+
// empty `sort_on`. Normalize it to `None` here so every consumer of `IndexSnapshot::sort_on`
1469+
// (scan construction and `required_input_ordering`) consistently falls back to the index
1470+
// sort key instead of describing an empty ordering.
1471+
let index_sort_on = sort_on
1472+
.map(|sc| {
1473+
index
1474+
.get_row()
1475+
.columns()
1476+
.iter()
1477+
.take(sc.0.len())
1478+
.map(|c| c.get_name().clone())
1479+
.collect::<Vec<_>>()
1480+
})
1481+
.filter(|cols| !cols.is_empty());
14761482
IndexSnapshot {
14771483
index: index.clone(),
14781484
partitions: Vec::new(), // filled with results of `pick_partitions` later.

0 commit comments

Comments
 (0)