feat: forward remote dynamic filter updates to coordinator - #635
feat: forward remote dynamic filter updates to coordinator#635jayshrivastava wants to merge 5 commits into
Conversation
60dc39a to
7bee97e
Compare
7bee97e to
53be7d0
Compare
4fd1433 to
c29bcf1
Compare
c29bcf1 to
a7bb152
Compare
a7bb152 to
8bc2dc9
Compare
e1a6b47 to
77c9528
Compare
77c9528 to
dd39b56
Compare
dd39b56 to
fceebde
Compare
5912188 to
a60f964
Compare
a60f964 to
8d666d6
Compare
51e5eda to
323cbf2
Compare
## Stack This stack of PRs implements distributed dynamic filtering #528 1. #623 <- you are here 2. #634 3. #635 4. #636 5. #637 6. #639 Closes: #529 ## Problem Post df-55 upgrade, dynamic filters should work in the worker-local case. There's no way to observe them working other than looking at metrics. ``` ┌───── Stage 2 ── tasks=1 │ AggregateExec: Final COUNT(*) │ [Stage 1] => NetworkCoalesceExec └────────────────────────────────────────────────── ┌───── Stage 1 ── tasks=2 │ HashJoinExec: orders.customer_id = selected_customers.customer_id │ DistributedLeafExec: | ... │ DistributedLeafExec: │ t0: DataSourceExec: predicate=DynamicFilter [ empty ] │ t1: DataSourceExec: predicate=DynamicFilter [ empty ] └──────────────────────────────────────────────── ``` Ideally we want the final filters visible when displaying plans. ## Solution This PR adds a new protocol which is basically identical to the metrics protocol. Even the `MetricsStore` is now just `Store` and is generic over `TaskMetrics` and `TaskCompletedDynamicFilters` (contains completed dynamic filters for a task). ```rust pub(crate) type MetricsStore = Store<TaskMetrics>; pub(crate) type CompletedDynamicFilterStore = Store<TaskCompletedDynamicFilters>; ``` Similar to the metrics protocol, workers now collect completed dynamic filters and send them back to the coordinator. ``` Coordinator Worker ----------- ------ Create independent display copies | +-- SetPlan(task 0, filter IDs) -------> Decode plan | | | | execute | | | | | | | | | | task finishes | v |<----- TaskDynamicFilters ----- Serialize completed filters from the consumers | v ``` Then, at display time, we call `apply_reports_to_distributed_leaves` which traverses the `plan_for_viz` and updates the dynamic filters for all the variants: ``` DistributedLeafExec task 0: DynamicFilter [ key@0 >= 1 AND key@0 <= 10 ] task 1: DynamicFilter [ empty ] ``` ## Notes ### Duplicate RPC Messages We will eventually have more dynamic filter RPCs which manage the worker -> coordinator -> merge -> worker flow mentioned in #553. In theory, the coordinator will know at `merge` time what the completed filters are, making the `TaskCompletedDynamicFilters` and final worker -> coordinator message in this PR irrelevant. However, I think having these mechanisms be separate is good because a) it helps us validate that the dynamic filter coordinator -> worker flow work using external "oracle", and b) there's no guarantee that the coordinator -> worker propagation happens before the query is done (ex. the `DataSourceExec` may not block execution waiting for dynamic filters), so it's good to have a separate way to know if the final `DataSourceExec` applied a filter or not. ### `AND true` and empty filters ``` DynamicFilter [ sr_returned_date_sk@0 >= 2451545 AND sr_returned_date_sk@0 <= 2451910 AND true ] AND DynamicFilter [ empty ] ``` In this filter `AND true` occurs because of apache/datafusion#24277. The first `DynamicFilter` is active but we lose the `HashTableLookupExpr` when serializing it to send back to the coordinator. The 2nd filter is `DynamicFilter [ empty ]` because this is a dynamic filter produced by a remote producer, which does not get propagated to this node yet. This will be fixed later. ### Displaying Dynamic Filters Protocol is as similar to the metrics protocol as possible. Due to double wrapping (`MetricsWrapperExec` wraps `DistributedLeafExec`, it's tricky to do the dynamic filter rewrite after doing the metrics rewrite. So `rewrite_distributed_plan_with_dynamic_filters` has to be called **first**. ```rust let plan = rewrite_distributed_plan_with_dynamic_filters(plan).await?; let plan = rewrite_distributed_plan_with_metrics(plan, DistributedMetricsFormat::Aggregated).await?; println!("{}", display_plan_ascii(plan.as_ref(), true)); ``` ## Testing - Tests in `tests/dynamic_filtering.rs`
323cbf2 to
336d392
Compare
Task-local consumer
producer F ────────────────> consumer F
shared expression; no RPC
Cross-task consumer
producer F ── boundary anchor F ──> SetPlanRequest.report_ids=[F]
│
├── wait_update() -----+
├── wait_complete() ---+--> latest observed F --RPC--> coordinator
└── query cancellation +--> stop
Derive the report allowlist from producer IDs intersected with network-boundary anchor IDs. Workers observe only allowlisted producers, while DataFusion continues updating task-local consumers directly in memory. Watch-based updates may naturally coalesce, and completion remains observed separately because it does not advance the generation.
336d392 to
8a3f76f
Compare
| } | ||
| // Runtime dynamic-filter reports are accepted by this transport change. A | ||
| // later change in the stack will retain and merge them. | ||
| WorkerToCoordinatorMsg::ProducedDynamicFilter(_) => {} |
There was a problem hiding this comment.
In this PR, we do nothing when receiving dynamic filter updates. In later PRs, we will consume these updates.
| // naturally coalesce while this observer is busy. The stream promises the latest | ||
| // observed state rather than one message per generation; completion is awaited | ||
| // separately because it does not advance the generation. | ||
| let completed = tokio::select! { |
There was a problem hiding this comment.
This PR introduces some overhead. We forward these messages even though they aren't used yet. They will be used in future PRs.
|
benchmarks run tpch/sf100 |
|
Requested by this comment. Benchmark job 51 failed for |
| struct SpecializedTaskPlan { | ||
| plan: Arc<dyn ExecutionPlan>, | ||
| work_unit_feed_declarations: Vec<WorkUnitFeedDeclaration>, | ||
| dynamic_filter_remote_producer_ids: Vec<u64>, | ||
| } | ||
|
|
There was a problem hiding this comment.
For this struct, It might be worth to:
- Move it below in this file, so that the comment above still applies to
StageCoordinator - Name it
TaskSpacializedPlanto match the method's name
| let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); | ||
|
|
||
| let local_filter = dynamic_filter(); | ||
| let local_probe = Arc::new(FilterExec::try_new(local_filter.clone(), empty(&schema))?) | ||
| as Arc<dyn ExecutionPlan>; | ||
| let local_join = join_with_filter(&schema, local_probe, Arc::clone(&local_filter))?; | ||
| assert!(dynamic_filter_remote_producer_ids(&local_join)?.is_empty()); | ||
|
|
||
| let remote_filter = dynamic_filter(); | ||
| let repartition = Arc::new(RepartitionExec::try_new( | ||
| empty(&schema), | ||
| Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 1), | ||
| )?) as Arc<dyn ExecutionPlan>; | ||
| let remote_probe = Arc::new( | ||
| NetworkShuffleExec::try_new(repartition, 1)? | ||
| .with_dynamic_filter_anchors(vec![remote_filter.clone()]), | ||
| ) as Arc<dyn ExecutionPlan>; |
There was a problem hiding this comment.
I see this being a pattern with the tests: plans get constructed manually by chaining operators together.
It's pretty hard to see what's happening and what's getting chained with what to a human eye. Do you think there's a chance we can just use normal SQL for these tests?
The main reason for having the weather and flights datasets committed to the codebase is so that we can just use SQL for building plans, rather than constructing them manually.
| let expression = Arc::clone(&dynamic_filter) as Arc<dyn PhysicalExpr>; | ||
| let (_cancel_tx, cancel_rx) = watch::channel(false); | ||
| let task_ctx = SessionContext::new().task_ctx(); | ||
| let mut stream = produced_dynamic_filter_stream(expression_id, expression, cancel_rx); |
There was a problem hiding this comment.
I really think we should change the approach to testing in general in this PR.
The produced_dynamic_filter_stream is a private function of this module, and therefore an implementation detail subject to change. By testing at this level, we are testing the implementation detail rather than the overall functionality, and the tests tend to be very verbose.
Here's one idea for change the way we approach testing in this PR: for anything related to dynamic filter collection and over-the-wire transfer, we can add some nice DataFusion metrics at the coordinator level, and during integration testing, we can perform assertion on those metrics.
With that, we'd win two things:
- Reliable tests that survive changes to the implementation details.
- Runtime metrics that give visibility around what happened.
Stack
This stack of PRs implements distributed dynamic filtering #528
Problem
The
QueryCoordinatorneeds to receive partial dynamic filter updates from workers.Solution
We introduce a new
WorkerToCoordinatorMsgwhichIn this PR makes each worker unconditionally send updates (via
wait_update()andwait_complete()) to the coordinator for anydynamic_filter_remote_producer_idsin theSetPlanRequest. The purpose ofdynamic_filter_remote_producer_idsis to exclude any dynamic filters who only have local consumers - these don't need to be forwarded to the coordinator.