Skip to content

feat: plan distributed dynamic filters - #634

Open
jayshrivastava wants to merge 5 commits into
js/1-display-dynamic-filtersfrom
js/2-forward-dynamic-filter-updates-to-coordinator
Open

feat: plan distributed dynamic filters#634
jayshrivastava wants to merge 5 commits into
js/1-display-dynamic-filtersfrom
js/2-forward-dynamic-filter-updates-to-coordinator

Conversation

@jayshrivastava

@jayshrivastava jayshrivastava commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Stack

This stack of PRs implements distributed dynamic filtering #528

  1. coordinator: display consumer dynamic filters after execution #623
  2. feat: plan distributed dynamic filters #634 <- you are here
  3. feat: forward remote dynamic filter updates to coordinator #635
  4. coordinator: merge partial dynamic filters  #636
  5. coordinator: forward merged dynamic filters to consumers #637
  6. [do not review] worker: apply merged dynamic filters during execution #639

Goal

The coordinator should know what dynamic filters exist and where to route updates.

Details

1. Dynamic Filter Registry

  QueryCoordinator
  └── DynamicFilterRegistry
      └── filters: Map<expression_id, PlannedDynamicFilter>

Each PlannedDynamicFilter stores

  • the producers and their stage/tasks
  • the consumers and their stage/tasks

This 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 using apply_expressions (before apply_expressions was added upstream, it was an Arc pointer strong count check to see if there were multiple references).

worker 1
HashJoinExec  (dynamic_filter_predicate)
    NetworkShuffleExec

worker 2
DataSourceExec (dynamic_filter_predicate)

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 NetworkShuffleExec uses the filter.

worker 1
HashJoinExec  (dynamic_filter_predicate)
    NetworkShuffleExec  (anchor: dynamic_filter_predicate)

worker 2
DataSourceExec (dynamic_filter_predicate)

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.

stage3:
    HashJoinExec <- producer of filter1
        NetworkShuffleExec  (anchors: filter1, filter2)

stage2:
    RepartitionExec
        AggregateExec <- producer of filter2
            NetworkShuffleExec   (anchors: filter1, filter2)

stage1:
        DataSourceExec (consumer: filter1, filter2)

This means we serialize 8 filters in total.

However, the minimal anchors you need are like this:

stage3:
    HashJoinExec <- producer #1
        NetworkShuffleExec  (anchors: filter2)

stage2:
    RepartitionExec
        AggregateExec <- producer #2
            NetworkShuffleExec   (anchors: filter2)

stage1:
        DataSourceExec (consumer: filter1, filter2)

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.

@jayshrivastava jayshrivastava changed the title feat: forward completed dynamic filters to the coordinator forward dynamic filters from worker -> coordinator Aug 13, 2026
@jayshrivastava jayshrivastava changed the title forward dynamic filters from worker -> coordinator forward dynamic filters from workers -> coordinator Aug 13, 2026
@jayshrivastava jayshrivastava changed the title forward dynamic filters from workers -> coordinator worker: forward dynamic filters to coordinator Aug 13, 2026
@jayshrivastava jayshrivastava changed the title worker: forward dynamic filters to coordinator worker: forward partial dynamic filters to coordinator Aug 13, 2026
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch from a3e5a8d to 6cb1c46 Compare August 13, 2026 19:27
Comment thread src/worker/impl_coordinator_channel.rs Outdated
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch 3 times, most recently from 8947cd1 to 1d75f65 Compare August 17, 2026 19:38
@jayshrivastava jayshrivastava changed the title worker: forward partial dynamic filters to coordinator feat: plan distributed dynamic filters Aug 18, 2026
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch from 1d75f65 to c54c017 Compare August 18, 2026 18:15
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch from c54c017 to 90cba3a Compare August 18, 2026 18:52
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch from 90cba3a to ec0b443 Compare August 21, 2026 16:28
@jayshrivastava
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch 3 times, most recently from 03adcaf to acfb3b1 Compare August 22, 2026 15:00
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
jayshrivastava force-pushed the js/2-forward-dynamic-filter-updates-to-coordinator branch from acfb3b1 to 918839a Compare August 23, 2026 00:21
@jayshrivastava
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant