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>
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.