Repository navigation
fix: reset aggregate dynamic filters between recursive iterations - #26056
jayzhan211 merged 4 commits into
Conversation
adriangb
left a comment
There was a problem hiding this comment.
Are there any issues / situations where we'd reset the filter on the aggregate but not the scan?
Otherwise looks good to me!
| let _ = self | ||
| .state_watch | ||
| .send(FilterState::InProgress { generation }); |
There was a problem hiding this comment.
What's the point of this line? Do we need to add some sort of FilterState::Reset?
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @mikamikasuki, this looks right to me. Resetting the filter in place (same DynamicFilterPhysicalExpr, new value) is what keeps the scan, which holds a reference to that filter, working across iterations.
I checked out 1e1b704 and verified:
cte.sltand thedynamic_filtersunit tests pass.- Without
dynamic_filter.filter.reset(), the newcte.sltcase fails (iterations 2 and 3 returnNULL), and onmainit returns0 NULL, 1 128, 2 NULL, 3 NULL.
Two small suggestions:
-
The
shared_boundreset is not covered by a test. If I drop the loop that setsshared_boundback toScalarValue::Null,cte.sltstill passes, including with several row groups andbatch_size = 8. Results stay correct, but pruning silently stops after the first iteration: iteration 2 starts with the stale bound (128), soscalar_max(128, local)never changes,bounds_changedstays false, and the filter is never published again. A small unit test that runsreset_stateand asserts everyshared_boundisNULL(and the filter expression istrue) would pin this. -
Nit:
reset()publishes twice. It callsupdate(lit(true)), which broadcastsInProgresswhileis_completeis stilltrue, then takes the lock again to clearis_completeand broadcasts a second time. Writing the newInner(expressiontrue,generation + 1,is_complete: false) under one write lock and sending once would make the reset a single step. It is harmless today becausetruenever prunes, so this is optional.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #26056 +/- ##
==========================================
- Coverage 82.69% 82.68% -0.01%
==========================================
Files 1147 1147
Lines 447204 447237 +33
Branches 447204 447237 +33
==========================================
+ Hits 369793 369814 +21
- Misses 55001 55007 +6
- Partials 22410 22416 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
|
|
@mikamikasuki can you please help finish this PR by responding to the feedback? |
|
Btw I think this may be the right answer, but I want to us to have a plan (e.g. update the other 2 approaches to use this one) before we add a 3rd. |
|
Thanks @adriangb for the suggestion. I think in-place reset should be the only pattern, and The filter's identity is what connects the producer to the scan, and The cost is that a reset copy can't run at the same time as the original. Main already has that limitation, since the scan shares the filter either way, and I'll open follow-up issues to move |
| let mut current = self.inner.write(); | ||
| let new_generation = current.generation + 1; | ||
| *current = Inner { |
There was a problem hiding this comment.
why not reset the generation to 0? @jayzhan211 is it obvious to you? if it's not lets add a comment.
There was a problem hiding this comment.
It isn't obvious, so a comment would help. The generation has to keep increasing across a reset. current() caches the remapped expression by generation, so if reset() went back to 1 (or 0), later updates would reach the cached generation again and current() would return the previous run's expression. I tried it: with the generation reset to 1, the first update after the reset returns the old expression.
@mikamikasuki could you add a comment like:
// Keep the generation increasing: `current()` caches by generation, so going
// back would let a later update hit the previous run's cached expression.| let _ = self.state_watch.send(FilterState::InProgress { | ||
| generation: new_generation, | ||
| }); |
There was a problem hiding this comment.
Same here, why send this message and why this generation vs. e.g. 0?
There was a problem hiding this comment.
The watch channel still holds Complete from the previous run. Without this send, wait_complete() sees Complete and returns right away, before this run's filter is ready. I checked this with a subscriber held across the reset. It sends new_generation so the channel matches inner.
@mikamikasuki and a comment here like:
// The channel still holds `Complete` from the previous run; publish
// `InProgress` so `wait_complete()` waits for this run's filter.|
This PR appears to be waiting on the author to respond to reviewer feedback so marking as draft. Please mark ready to review when it is ready for another look |
|
How about I add the comments and merge this |
sounds good to me! |
…ns (apache#26669) ## Which issue does this PR close? Closes apache#26641. ## Rationale for this change Hash joins inside recursive CTEs read every probe-side row on each iteration because their dynamic filters stop updating the scan. This prevents pruning during recursive queries such as graph walks. ## What changes are included in this PR? - Reset the existing shared dynamic filter instead of dropping it. - Clear the build accumulator for each execution. - Update prepared-build test callers to propagate reset errors. - Add a recursive join regression test in cte.slt. Depends on apache#26056 for `DynamicFilterPhysicalExpr::reset()`. It hasnt been merged yet, however it has been approved, so i tought i was safe to use its implementation. ## What is the testing strategy for this PR? The new cte.slt case checks that the recursive query returns 1–5 and that the probe scan outputs 4 rows and prunes 396 rows across four iterations. ## Are there any user-facing changes? The public `HashJoinExecBuilder::reset_state` method now returns `Result<Self>;` callers must handle the result. --------- Co-authored-by: mac <mac@macdeMacBook-Pro.local> Co-authored-by: mika <spacexstarship01@outlook.com> Co-authored-by: Jay Zhan <jayzhan211@gmail.com>
…pache#26670) ## Which issue does this PR close? Closes apache#26642. ## Rationale for this change TopK dynamic filters inside recursive CTEs stop updating the scan because `SortExec::reset_state()` creates a new filter on each iteration, while the scan still references the original one. This prevents pruning during recursive queries. ## What changes are included in this PR? - Reset the existing shared dynamic filter instead of replacing it. - Rebuild the TopK filter state using the existing `DynamicFilterPhysicalExpr`. - Add a recursive TopK regression test in `cte.slt`. Depends on apache#26056 for `DynamicFilterPhysicalExpr::reset()`. It hasn't been merged yet, but it has been approved, so I based this change on its implementation. ## What is the testing strategy for this PR? The new `cte.slt` case checks that the recursive query returns 1–4 and that the Parquet scan outputs 30 rows instead of 300, pruning 27 row groups across three iterations. ## Are there any user-facing changes? No. --------- Co-authored-by: mac <mac@macdeMacBook-Pro.local> Co-authored-by: mika <spacexstarship01@outlook.com> Co-authored-by: Jay Zhan <jayzhan211@gmail.com>
…(revert apache#26670) (apache#27142) ## Which issue does this PR close? - Closes apache#27130. ## Rationale for this change As discussed in apache#27130, this reverts apache#26670 so copies made by `SortExec::reset_state()` no longer share one TopK dynamic filter. When a cached `LIMIT 1` plan was reset twice and both copies ran, the first one to finish tightened the shared filter and the second returned no rows. A plain revert brings back a different wrong result, though: a plan that has run once and is then reset still has its old bound in the scan's predicate (the scan keeps the filter it got at planning time). For `SELECT a FROM t ORDER BY a LIMIT 1` over a parquet file with pushdown enabled, the rerun returned no rows with the plain revert. So this keeps the in-place `reset()` from apache#26056 on the old filter and then gives the new plan its own filter, as @Kristofferjoha suggested keeping. | | main | plain revert | this PR | |---|---|---|---| | reset twice, run both copies (apache#27130) | 2nd: no rows | ok | ok | | run, reset, run again (parquet, pushdown) | ok | 2nd: no rows | ok | ## What changes are included in this PR? - `SortExec::reset_state` resets the old dynamic filter in place, then creates a new one for the reset plan. The `sort_concurrency` reset from apache#26242 is kept. (The plain revert did not apply cleanly because apache#26242 changed the same lines.) - Removes the recursive CTE pruning test apache#26670 added to `cte.slt`, since the scan now keeps the old filter (reset to `true`) and the pruning no longer happens, as in 55. - Adds two unit tests in `sorts/sort.rs`: the reproducer from apache#27130, and a test that the filter a scan would hold is back to `true` after `reset_state`. The second one fails with the plain revert. ## Are these changes tested? Yes. Both new tests fail on main or on the plain revert respectively, and pass here. All `datafusion-physical-plan` unit tests and sqllogictests pass. ## Are there any user-facing changes? The TopK dynamic filter inside a recursive CTE no longer prunes after the first iteration, as in 55 (the apache#26670 test's scan reads 300 rows instead of 30). Query results are unchanged. The proper fix (a fresh filter per copy that the scan also picks up, which would also cover `AggregateExec`) can follow after 56. This reopens apache#26642.
Which issue does this PR close?
Closes #26054
Rationale for this change
In a recursive CTE, an uncorrelated MIN/MAX over a Parquet table returns the correct value on the first iteration but NULL or an incorrect value on later iterations because the aggregate reuses the dynamic filter tightened during the first scan.
What changes are included in this PR?
Reset AggregateExec's dynamic filter expression and shared accumulator bounds when the plan is reused, allowing each recursive iteration to scan with fresh bounds.
What is the testing strategy for this PR?
Added a cte.slt regression for the reported recursive MAX query. Validation passed:
cargo test -p datafusion-sqllogictest --test sqllogictests -- cte.slt;cargo test -p datafusion-physical-plan --lib(2,441 passed);cargo test -p datafusion-physical-expr --lib(1,686 passed, 2 ignored);cargo fmt --all -- --check; and targeted Clippy with-A clippy::ignore_without_reason -D warnings. The unsuppressed Rust 1.99 Clippy run reports the existingignore_without_reasonlint indatafusion/physical-expr/src/planner.rs.Are there any user-facing changes?
Recursive CTE iterations now return the correct aggregate result consistently while retaining dynamic-filter pruning.