fix(cubesql): Prefer SQL pushdown over limitless post-processing - #11559
fix(cubesql): Prefer SQL pushdown over limitless post-processing#11559MazterQyou wants to merge 1 commit into
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Claude finished @MazterQyou's task in 3m 41s —— View job Review complete — solid fix for a real silent-truncation bug; 1 high, 3 medium, 3 low. 7 inline comments posted.Full review — click to expandTodos
OverallThis targets a genuine and nasty class of bug: a Cube query with no user The Findings
Cross-cutting concern worth a maintainer decisionThe - time_dimensions: [{ dimension: "…order_date", granularity: "month" }]
+ dimensions: ["EXTRACT(month FROM ${KibanaSampleDataEcommerce.order_date})"]Named time dimensions are what pre-aggregation matching keys off. Member expressions generally are not matchable, so queries that previously hit a pre-aggregation may now fall through to the raw data source. That's arguably still the correct trade (a fast wrong answer is worse than a slow right one), but it's a performance characteristic change that isn't mentioned in the PR description or the docs, and it lands on by default. Worth either confirming pre-agg matching still works for these shapes, or calling it out explicitly in the changelog/docs. Related: the tests that changed shape are mostly ones where a Not verifiedI did not compile or run the Rust test suite — |
| limitless_post_processing: match state { | ||
| CubePlanState::Unwrapped(_) | ||
| if self.limitless_scans > 0 && self.scan_limits == 0 => | ||
| { | ||
| match enode { | ||
| LogicalPlanLanguage::Sort(_) | ||
| | LogicalPlanLanguage::Filter(_) | ||
| | LogicalPlanLanguage::Distinct(_) | ||
| | LogicalPlanLanguage::Window(_) | ||
| | LogicalPlanLanguage::Join(_) | ||
| | LogicalPlanLanguage::CrossJoin(_) | ||
| | LogicalPlanLanguage::Union(_) | ||
| | LogicalPlanLanguage::Subquery(_) => 1, | ||
| _ => 0, | ||
| } | ||
| } | ||
| _ => 0, | ||
| } + self.limitless_post_processing, |
There was a problem hiding this comment.
Aggregate is only safe over a grouped Cube query, but the check does not look at ungrouped.
The justification above (Aggregate "re-aggregates rows the Cube query already grouped, so the cap lands on the rows the client asked for") holds for a grouped scan, but not for an ungrouped one. With ungrouped: Some(true) the Cube query returns raw rows; truncating those at non_streaming_query_max_row_limit and then aggregating in DataFusion yields a silently wrong SUM/COUNT/MIN — exactly the class of failure this PR is closing.
This is not hypothetical: the thoughtspot_pg_quarter_start_to_date_trunc-style test in this same diff now asserts
let request = logical_plan.find_cube_scan_wrapped_sql_deep().request;
...
assert_eq!(request.ungrouped, Some(true));with the comment "the aggregate above it still runs in post processing". That plan has an unlimited ungrouped scan feeding a post-processing Aggregate, so it produces truncated aggregates and CUBESQL_FAIL_ON_LIMITLESS_POST_PROCESSING=true will not flag it.
ungrouped_nodes / CubeScanUngrouped(true) is already tracked in this file (line 194), so the input is available — consider counting limitless ungrouped scans separately and penalizing Aggregate when that count is non-zero.
| // Expect no duplicates in result set: the call is a dimension of the Cube | ||
| // query, so it is grouped by rather than projected over grouped rows | ||
| assert_eq!(request.measures, Some(vec![])); | ||
| let dimensions = request.dimensions.unwrap(); | ||
| assert_eq!( | ||
| context | ||
| .convert_sql_to_cube_query(&query) | ||
| .await | ||
| .unwrap() | ||
| .as_logical_plan() | ||
| .find_cube_scan() | ||
| .request, | ||
| expected_cube_scan | ||
| dimensions.len(), | ||
| 1, | ||
| "single grouped dimension: {:?}", | ||
| dimensions | ||
| ); | ||
|
|
||
| // Expect no dublicates in result set | ||
| insta::assert_snapshot!( | ||
| format!("noninjective_{name}_from_dimension"), | ||
| context.execute_query(query).await.unwrap() | ||
| assert!( | ||
| dimensions[0].contains(fun) && dimensions[0].contains("MultiTypeCube.dim_str0"), | ||
| "{fun} of the dimension is grouped by: {}", | ||
| dimensions[0] |
There was a problem hiding this comment.
This test previously proved "no duplicates in result set" by actually executing the query against a mocked load response and snapshotting the rows (noninjective_{coalesce,nullif,left,right}_from_dimension.snap, all deleted here). The replacement only asserts that the request has one dimension whose SQL mentions the function name — it can no longer catch a regression where the grouping is right in the request but the result set still contains duplicates.
Same pattern in test_thoughtspot_quarter_start_group_by (the nonrewritable_date_trunc.snap deletion) — insta::assert_snapshot!(context.execute_query(query).await.unwrap()) was dropped for a sql.contains(...) check.
I understand why: the pushed-down request no longer matches the add_cube_load_mock key, so the execution mock stops firing. But rather than dropping end-to-end verification for five cases, it would be better to re-register the mock against the new pushed-down request so the result snapshots survive. Losing execution coverage on precisely the tests whose plans this PR changes is where a silent behavior regression would hide.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #11559 +/- ##
==========================================
- Coverage 84.05% 79.61% -4.45%
==========================================
Files 257 480 +223
Lines 81582 99584 +18002
Branches 0 3641 +3641
==========================================
+ Hits 68577 79283 +10706
- Misses 13005 19782 +6777
- Partials 0 519 +519
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
48d757f to
a3181d1
Compare
|
Claude finished @MazterQyou's task in 2m 41s —— View job Re-review after push — 3 of 5 prior findings fixed; 1 high + 2 medium still open; 1 new inline commentFull review — click to expandTodos
Fixed since the last review — resolved those threads
Still open1. High — Unchanged in this push. Worth noting the hole is wider than the ungrouped case I raised before. Even over a grouped scan, an SELECT SUM(cnt) FROM (SELECT city, COUNT(*) cnt FROM cube GROUP BY 1)The inner scan can return up to 2. Medium — subtree tally still lets a limited scan mask an unlimited sibling (new inline comment on
3. Medium — five execution snapshots still deleted (existing unresolved thread on
Related, weaker assertions that survive elsewhere: Still worth a maintainer decision (unchanged)The Docs
Not verifiedI did not compile or run the Rust test suite — |
| limitless_post_processing: match state { | ||
| CubePlanState::Unwrapped(_) | ||
| if options.penalize_limitless_post_processing | ||
| && self.limitless_scans.0 > self.scan_limits.0 => |
There was a problem hiding this comment.
The counter comparison is an improvement over scan_limits == 0, but the comment above (line 233) claims more than the arithmetic delivers:
Counting the limits as well lets
limitless_post_processingcompare the two: it only fires when some scan in the subtree is left over without a limit of its own, which keeps a limited scan from covering for an unlimited sibling in a join or a union.
Both counters are summed across all children by add_child, so limitless_scans > scan_limits is a whole-subtree tally, not a per-branch pairing. The sibling case it names is exactly the one it still misses:
Join
├── CubeScan(limit = Some(100)) -> scan_limits = 1
└── CubeScan(limit = None) -> limitless_scans = 1
1 > 1 is false, so the Join is not penalized even though its right input is truncated at the row cap. The same holds for a WrappedSelectLimit(Some(_)) sitting in an unrelated subquery branch — it pays for an unlimited scan somewhere else in the plan.
Making the pairing structural rather than numeric would close it: zero out (or decrement) limitless_scans at the node that actually introduces the limit over that scan, and let the propagated count mean "unlimited scans with no limit above them" — then the top-level test is just > 0. If a structural fix is out of scope here, the comment at line 233 should be softened, since as written it asserts the sibling case is covered.
Check List
Description of Changes Made
This PR makes the SQL API prefer pushing a query down to the data source over running row-dropping post-processing on top of an unlimited Cube query, which would otherwise silently read a result truncated to the maximum row limit, and adds
CUBESQL_FAIL_ON_LIMITLESS_POST_PROCESSING(defaultfalse) to fail with an error when no such push down is possible instead of returning truncated results. Related tests are included/adjusted.