Skip to main content

query/optimizer/
pass_distribution.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::sync::Arc;
16
17use datafusion::config::ConfigOptions;
18use datafusion::physical_optimizer::PhysicalOptimizerRule;
19use datafusion::physical_plan::projection::ProjectionExec;
20use datafusion::physical_plan::repartition::RepartitionExec;
21use datafusion::physical_plan::{ExecutionPlan, Partitioning};
22use datafusion_common::Result as DfResult;
23use datafusion_physical_expr::Distribution;
24use datafusion_physical_expr::utils::map_columns_before_projection;
25
26use crate::dist_plan::MergeScanExec;
27
28/// This is a [`PhysicalOptimizerRule`] to pass distribution requirement to
29/// [`MergeScanExec`] to avoid unnecessary shuffling.
30///
31/// This rule is expected to be run before [`EnforceDistribution`].
32///
33/// [`EnforceDistribution`]: datafusion::physical_optimizer::enforce_distribution::EnforceDistribution
34/// [`MergeScanExec`]: crate::dist_plan::MergeScanExec
35#[derive(Debug)]
36pub struct PassDistribution;
37
38impl PhysicalOptimizerRule for PassDistribution {
39    fn optimize(
40        &self,
41        plan: Arc<dyn ExecutionPlan>,
42        config: &ConfigOptions,
43    ) -> DfResult<Arc<dyn ExecutionPlan>> {
44        Self::do_optimize(plan, config)
45    }
46
47    fn name(&self) -> &str {
48        "PassDistributionRule"
49    }
50
51    fn schema_check(&self) -> bool {
52        false
53    }
54}
55
56impl PassDistribution {
57    fn do_optimize(
58        plan: Arc<dyn ExecutionPlan>,
59        _config: &ConfigOptions,
60    ) -> DfResult<Arc<dyn ExecutionPlan>> {
61        // Start from root with no requirement
62        Self::rewrite_with_distribution(plan, None)
63    }
64
65    /// Top-down rewrite that propagates distribution requirements to children.
66    fn rewrite_with_distribution(
67        plan: Arc<dyn ExecutionPlan>,
68        current_req: Option<Distribution>,
69    ) -> DfResult<Arc<dyn ExecutionPlan>> {
70        // If this is a MergeScanExec, try to apply the current requirement.
71        if let Some(merge_scan) = plan.as_any().downcast_ref::<MergeScanExec>()
72            && let Some(Distribution::HashPartitioned(hash_exprs)) = current_req.as_ref()
73        {
74            if let Partitioning::Hash(current_hash_exprs, _) = &merge_scan.properties().partitioning
75                && *current_hash_exprs == *hash_exprs
76            {
77                return Ok(plan);
78            }
79
80            if let Some(new_plan) = merge_scan
81                .try_with_new_distribution(Distribution::HashPartitioned(hash_exprs.clone()))
82            {
83                // Leaf node; no children to process
84                return Ok(Arc::new(new_plan) as _);
85            }
86
87            let partitioning = Partitioning::Hash(
88                hash_exprs.clone(),
89                merge_scan.properties().partitioning.partition_count(),
90            );
91            return Ok(Arc::new(RepartitionExec::try_new(plan, partitioning)?) as _);
92        }
93
94        // Compute per-child requirements from the current node.
95        let children = plan.children();
96        if children.is_empty() {
97            return Ok(plan);
98        }
99
100        let required = plan.required_input_distribution();
101        let mut new_children = Vec::with_capacity(children.len());
102        for (idx, child) in children.into_iter().enumerate() {
103            let child_req = match required.get(idx) {
104                Some(Distribution::UnspecifiedDistribution) if idx == 0 => {
105                    Self::map_hash_requirement_through_projection(plan.as_ref(), &current_req)
106                }
107                Some(Distribution::UnspecifiedDistribution) => None,
108                None => current_req.clone(),
109                Some(req) => Some(req.clone()),
110            };
111            let new_child = Self::rewrite_with_distribution(child.clone(), child_req)?;
112            new_children.push(new_child);
113        }
114
115        // Rebuild the node only if any child changed (pointer inequality)
116        let unchanged = plan
117            .children()
118            .into_iter()
119            .zip(new_children.iter())
120            .all(|(old, new)| Arc::ptr_eq(old, new));
121        if unchanged {
122            Ok(plan)
123        } else {
124            plan.with_new_children(new_children)
125        }
126    }
127
128    fn map_hash_requirement_through_projection(
129        plan: &dyn ExecutionPlan,
130        current_req: &Option<Distribution>,
131    ) -> Option<Distribution> {
132        let Some(Distribution::HashPartitioned(required_exprs)) = current_req else {
133            return None;
134        };
135
136        let projection = plan.as_any().downcast_ref::<ProjectionExec>()?;
137        let proj_exprs = projection
138            .expr()
139            .iter()
140            .map(|expr| (Arc::clone(&expr.expr), expr.alias.clone()))
141            .collect::<Vec<_>>();
142        let mapped = map_columns_before_projection(required_exprs, &proj_exprs);
143
144        (mapped.len() == required_exprs.len()).then_some(Distribution::HashPartitioned(mapped))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use std::collections::{BTreeMap, BTreeSet};
151
152    use api::v1::region::{RemoteDynFilterUnregister, RemoteDynFilterUpdate};
153    use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
154    use async_trait::async_trait;
155    use common_query::request::QueryRequest;
156    use datafusion::common::NullEquality;
157    use datafusion::execution::SessionStateBuilder;
158    use datafusion::physical_optimizer::PhysicalOptimizerRule;
159    use datafusion::physical_plan::joins::{HashJoinExec, PartitionMode};
160    use datafusion::physical_plan::projection::{ProjectionExec, ProjectionExpr};
161    use datafusion::physical_plan::{ExecutionPlanProperties, Partitioning};
162    use datafusion_expr::{JoinType, LogicalPlanBuilder};
163    use datafusion_physical_expr::PhysicalExpr;
164    use datafusion_physical_expr::expressions::Column as PhysicalColumn;
165    use session::ReadPreference;
166    use session::context::QueryContext;
167    use store_api::metric_engine_consts::DATA_SCHEMA_TSID_COLUMN_NAME;
168    use store_api::storage::RegionId;
169    use table::table_name::TableName;
170
171    use super::*;
172    use crate::dist_plan::RemoteDynFilterProducerId;
173    use crate::error::Result as QueryResult;
174    use crate::region_query::RegionQueryHandler;
175
176    struct NoopRegionQueryHandler;
177
178    #[async_trait]
179    impl RegionQueryHandler for NoopRegionQueryHandler {
180        async fn select_target(
181            &self,
182            _read_preference: ReadPreference,
183            _region_id: RegionId,
184        ) -> QueryResult<crate::region_query::RegionQueryTarget> {
185            unreachable!("pass distribution tests should not execute remote queries")
186        }
187
188        async fn do_get(
189            &self,
190            _target: &crate::region_query::RegionQueryTarget,
191            _request: QueryRequest,
192        ) -> QueryResult<common_recordbatch::SendableRecordBatchStream> {
193            unreachable!("pass distribution tests should not execute remote queries")
194        }
195
196        async fn handle_remote_dyn_filter_update(
197            &self,
198            _target: &crate::region_query::RegionQueryTarget,
199            _query_id: String,
200            _update: RemoteDynFilterUpdate,
201        ) -> QueryResult<()> {
202            unreachable!("pass distribution tests should not send remote dyn filter updates")
203        }
204
205        async fn handle_remote_dyn_filter_unregister(
206            &self,
207            _target: &crate::region_query::RegionQueryTarget,
208            _query_id: String,
209            _unregister: RemoteDynFilterUnregister,
210        ) -> QueryResult<()> {
211            unreachable!("pass distribution tests should not send remote dyn filter unregisters")
212        }
213    }
214
215    #[test]
216    fn passes_hash_requirement_through_projection_to_merge_scan() {
217        let schema = test_schema();
218        let left_merge_scan = Arc::new(test_merge_scan_exec(schema.clone()));
219        let right_merge_scan = Arc::new(test_merge_scan_exec(schema.clone()));
220        let left_projection = Arc::new(
221            ProjectionExec::try_new(
222                vec![
223                    ProjectionExpr::new(partition_column("greptime_value", 3), "greptime_value"),
224                    ProjectionExpr::new(
225                        partition_column(DATA_SCHEMA_TSID_COLUMN_NAME, 1),
226                        DATA_SCHEMA_TSID_COLUMN_NAME,
227                    ),
228                    ProjectionExpr::new(
229                        partition_column("greptime_timestamp", 2),
230                        "greptime_timestamp",
231                    ),
232                ],
233                left_merge_scan,
234            )
235            .unwrap(),
236        ) as Arc<dyn datafusion::physical_plan::ExecutionPlan>;
237        let join = Arc::new(
238            HashJoinExec::try_new(
239                left_projection,
240                right_merge_scan,
241                vec![
242                    (
243                        partition_column(DATA_SCHEMA_TSID_COLUMN_NAME, 1),
244                        partition_column(DATA_SCHEMA_TSID_COLUMN_NAME, 1),
245                    ),
246                    (
247                        partition_column("greptime_timestamp", 2),
248                        partition_column("greptime_timestamp", 2),
249                    ),
250                ],
251                None,
252                &JoinType::Inner,
253                None,
254                PartitionMode::Partitioned,
255                NullEquality::NullEqualsNull,
256                false,
257            )
258            .unwrap(),
259        ) as Arc<dyn datafusion::physical_plan::ExecutionPlan>;
260
261        let optimized = PassDistribution
262            .optimize(join, &ConfigOptions::default())
263            .unwrap();
264        let hash_join = optimized.as_any().downcast_ref::<HashJoinExec>().unwrap();
265        let left_projection = hash_join
266            .left()
267            .as_any()
268            .downcast_ref::<ProjectionExec>()
269            .unwrap();
270        let left_partitioning = left_projection.input().output_partitioning();
271        let right_partitioning = hash_join.right().output_partitioning();
272
273        let Partitioning::Hash(left_exprs, left_count) = left_partitioning else {
274            panic!("expected left merge scan hash partitioning");
275        };
276        let Partitioning::Hash(right_exprs, right_count) = right_partitioning else {
277            panic!("expected right merge scan hash partitioning");
278        };
279
280        assert_eq!(*left_count, 2);
281        assert_eq!(*right_count, 2);
282        assert_eq!(
283            column_names(left_exprs),
284            vec![DATA_SCHEMA_TSID_COLUMN_NAME, "greptime_timestamp"]
285        );
286        assert_eq!(
287            column_names(right_exprs),
288            vec![DATA_SCHEMA_TSID_COLUMN_NAME, "greptime_timestamp"]
289        );
290    }
291
292    #[test]
293    fn merge_scan_rejects_hash_requirement_on_partition_key_subset() {
294        let merge_scan = test_merge_scan_exec(test_schema());
295
296        let new_plan = merge_scan.try_with_new_distribution(Distribution::HashPartitioned(vec![
297            partition_column(DATA_SCHEMA_TSID_COLUMN_NAME, 1),
298        ]));
299
300        assert!(
301            new_plan.is_none(),
302            "partitioning by a subset of multi-column partition keys is not sufficient"
303        );
304    }
305
306    fn test_merge_scan_exec(schema: SchemaRef) -> MergeScanExec {
307        let session_state = SessionStateBuilder::new().with_default_features().build();
308        let partition_cols = BTreeMap::from([
309            (
310                DATA_SCHEMA_TSID_COLUMN_NAME.to_string(),
311                BTreeSet::from([datafusion_common::Column::from_name(
312                    DATA_SCHEMA_TSID_COLUMN_NAME,
313                )]),
314            ),
315            (
316                "greptime_timestamp".to_string(),
317                BTreeSet::from([datafusion_common::Column::from_name("greptime_timestamp")]),
318            ),
319        ]);
320        let plan = LogicalPlanBuilder::empty(false).build().unwrap();
321
322        MergeScanExec::new(
323            &session_state,
324            TableName::new("greptime", "public", "test"),
325            vec![RegionId::new(1, 0), RegionId::new(1, 1)],
326            plan,
327            schema.as_ref(),
328            Arc::new(NoopRegionQueryHandler),
329            QueryContext::arc(),
330            32,
331            partition_cols,
332            Some(RemoteDynFilterProducerId::new(1)),
333            false,
334        )
335        .unwrap()
336    }
337
338    fn test_schema() -> SchemaRef {
339        Arc::new(Schema::new(vec![
340            Field::new("host", DataType::Utf8, true),
341            Field::new(DATA_SCHEMA_TSID_COLUMN_NAME, DataType::UInt64, false),
342            Field::new(
343                "greptime_timestamp",
344                DataType::Timestamp(TimeUnit::Millisecond, None),
345                false,
346            ),
347            Field::new("greptime_value", DataType::Float64, true),
348        ]))
349    }
350
351    fn partition_column(name: &str, index: usize) -> Arc<dyn PhysicalExpr> {
352        Arc::new(PhysicalColumn::new(name, index))
353    }
354
355    fn column_names(exprs: &[Arc<dyn PhysicalExpr>]) -> Vec<&str> {
356        exprs
357            .iter()
358            .map(|expr| {
359                expr.as_any()
360                    .downcast_ref::<PhysicalColumn>()
361                    .unwrap()
362                    .name()
363            })
364            .collect()
365    }
366}