feat: plan distributed dynamic filters - #634
Open
jayshrivastava wants to merge 5 commits into
Open
Conversation
This was referenced Aug 13, 2026
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
from
August 13, 2026 19:27
a3e5a8d to
6cb1c46
Compare
jayshrivastava
commented
Aug 17, 2026
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
3 times, most recently
from
August 17, 2026 19:38
8947cd1 to
1d75f65
Compare
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
from
August 18, 2026 18:15
1d75f65 to
c54c017
Compare
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
from
August 18, 2026 18:52
c54c017 to
90cba3a
Compare
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
from
August 21, 2026 16:28
90cba3a to
ec0b443
Compare
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
3 times, most recently
from
August 22, 2026 15:00
03adcaf to
acfb3b1
Compare
Before stage split Producer worker plan
HashJoin producer F HashJoin producer F
└── NetworkShuffle └── NetworkShuffle
└── Local stage ├── Remote stage
└── consumer F └── anchor F
|
apply_expressions() finds F
Register task-specialized producer and consumer topology independently from completed-consumer display state. Preserve consumers crossing a stage boundary as non-evaluating network-boundary anchors so both static and dynamic planning retain the information needed to identify remote filters.
jayshrivastava
force-pushed
the
js/2-forward-dynamic-filter-updates-to-coordinator
branch
from
August 23, 2026 00:21
acfb3b1 to
918839a
Compare
jayshrivastava
marked this pull request as ready for review
August 23, 2026 19:28
nuno-faria
pushed a commit
to fornwall/datafusion
that referenced
this pull request
Aug 24, 2026
…che#24601) ## Which issue does this PR close? - Related to apache#18856 - Informs datafusion-contrib/datafusion-distributed#634 Does not close apache#18856: `PushedDown::No` still conflates "I will not use this filter" with "I will use it, but not for exact row-level filtering". This PR only stops that ambiguity from forcing a runtime decision. ## Rationale for this change `HashJoinExec` decides whether to compute a dynamic filter inside `execute()`, by walking the probe subtree looking for a node that holds the filter expression: ```rust // Only compute a dynamic filter when the probe subtree contains a consumer. let enable_dynamic_filter_pushdown = ... .map(|id| plan_contains_expression_id(&self.right, id)) ``` Whether a consumer exists is a planning-time property. Deciding it at execution time breaks any consumer that rewrites the plan after optimization. The concrete case is a distributed planner splitting the optimized plan into stages: ``` worker 1 HashJoinExec (dynamic filter) NetworkShuffleExec worker 2 DataSourceExec (consumes the dynamic filter) ``` At execution time on worker 1 the probe subtree ends at the network boundary, so the traversal finds nothing and the filter is silently never produced — even though the pushdown had found a consumer while the plan was still whole. Working around this requires the shuffle node to hold "anchor" references to filters it never evaluates, purely so the traversal sees them. The check itself is well motivated (apache#17527: skip build-side bounds accumulation when nothing will read the result). Its placement in `execute()` is a leftover from apache#19546, which implemented it as `Arc::strong_count`, a signal only meaningful once the whole plan is assembled. Since apache#24018 replaced refcounting with `expression_id` + `apply_expressions`, that constraint is gone — and `AggregateExec` already makes the same decision at planning time. ## What changes are included in this PR? - `HashJoinExec::handle_child_pushdown_result` runs the consumer check and only attaches the dynamic filter if the probe subtree contains a consumer, mirroring `AggregateExec::handle_child_pushdown_result`. - `HashJoinExec::execute` reduces to `self.dynamic_filter.is_some()`. - Documents the resulting contract on `HashJoinExec::with_dynamic_filter_expr`: holding a dynamic filter is what makes the join compute one, so a caller wiring one up by hand owns the consumer check. This is safe because the Post phase `FilterPushdown` rule is the last rule that mutates the plan (only `SanityCheckPlan` follows, which changes nothing), and the optimizer calls `handle_child_pushdown_result` on the node with its post-pushdown children already in place. The decision then travels as node state, surviving `replace_children` and the proto round trip. No new API, no new `PushedDown` state. As before, the discriminant is not consulted, because a node replying `PushedDown::No` may still retain the filter for statistics pruning. ## Are these changes tested? Yes. - `test_hashjoin_dynamic_filter_pushdown_is_used` is renamed to `test_hashjoin_dynamic_filter_requires_probe_consumer` (the old name referred to the now-deprecated `is_used()`) and strengthened: with no consumer the join now produces no dynamic filter at all, rather than producing one nothing reads. - New `test_hashjoin_dynamic_filter_survives_probe_subtree_replacement` reproduces the stage split — it runs filter pushdown, replaces the probe subtree with an equivalent scan that does not hold the filter, executes, and asserts the build-side bounds were still published. Both fail without the `exec.rs` change. Full workspace extended tests, sqllogictest, and `./dev/rust_lint.sh` pass. ## Are there any user-facing changes? One behavior change worth calling out: a `HashJoinExec` given a dynamic filter outside the filter pushdown rule (via the public `with_dynamic_filter_expr`) now computes it, where previously the runtime traversal could silently disable it. That is the point of the change — it is what lets a plan rewritten after optimization keep producing filters — but it does change the meaning of a public API, so this may warrant the `api change` label. A minor side effect: `gather_filters_for_pushdown` only pushes a self filter when `dynamic_filter.is_none()`, so on a plan with no consumer a repeated Post-phase run now creates and pushes a fresh filter instead of finding one already attached. Same result, slightly more work in replan loops. ## Note on overlapping work @jayshrivastava raised this in apache#18856 (comment) and has apache#24528 open, which adds a third `PushedDown` state to reach the same goal. This is the smaller alternative: it removes the runtime check without changing the pushdown protocol. It is also only possible because of the `apply_expressions` work in apache#24018. Happy to close this in favour of that approach if preferred. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jayant Shrivastava <jshrivastava03@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
This stack of PRs implements distributed dynamic filtering #528
Goal
The coordinator should know what dynamic filters exist and where to route updates.
Details
1. Dynamic Filter Registry
Each
PlannedDynamicFilterstoresThis will be used in future PRs to store incoming dynamic filter updates from workers and determine how/where to forward the updates.
Implementation
In the
StageCoordinator, we send every task to the registry and extract dynamic filters.2. Network Anchors
In this situation, the hash join does an
execute()-time check to determine if it should update its dynamic filter. It checks to see if the filter is used by any children usingapply_expressions(beforeapply_expressionswas added upstream, it was an Arc pointer strong count check to see if there were multiple references).The join sees that no plan nodes below it use the filter, so it decides not to update it.
Ideally, the hash join decides at optimization time, before distributed planning. I've opened a discussion here about it: apache/datafusion#18856 (comment). While that issue is being resolved, I propose this workaround:
We create an "anchor" to make it seem like the
NetworkShuffleExecuses the filter.Network Anchors Implementation
The implementation adds serialization overhead but is simpler. In static and dynamic planning, we recursively propagate all anchors upwards in the plan to all the network boundaries. We can revisit this implementation in future iterations. This recursive implementation is in
inject_network_boundaries.This means we serialize 8 filters in total.
However, the minimal anchors you need are like this:
In this plan, we would serialize 6 filters.
For 1 dynamic filter, the minimum filters you need to serialize are 1 (producer) + N (consumers) + 1 (network boundary). In this implementation, we serialize 1 (producer) + N (consumers) + M (all network boundaries above the consumer)
Other Notes
See #528. During dynamic planning, the sampler on the probe side of a hash join may overreport rows / cost because dynamic filters aren't being applied yet.