From 5799884109545ec74e2858bf01eab1d926bdae33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Pupier?= Date: Thu, 17 Sep 2026 15:23:15 +0200 Subject: [PATCH 1/9] [fix](ci) Update github host from macos-13 to macos-15 (#67747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macos-13 is no more available https://github.blog/changelog/2025-09-19-github-actions-macos-13-runner-image-is-closing-down/ it is causing a lot of PR jobs to wait in queue for a long time when there is a PR on 4.2 branch it has been already upgraded on main branch ### What problem does this PR solve? Issue Number: close #xxx Related PR: #xxx Problem Summary: ### Release note None ### Check List (For Author) - Test - [ ] Regression test - [ ] Unit Test - [ ] Manual test (add detailed scripts or steps below) - [ ] No need to test or manual test. Explain why: - [ ] This is a refactor/code format and no logic has been changed. - [ ] Previous test can cover this change. - [ ] No code files have been changed. - [ ] Other reason - Behavior changed: - [x] No. - [ ] Yes. - Does this need documentation? - [x] No. - [ ] Yes. ### Check List (For Reviewer who merge this PR) - [ ] Confirm the release note - [ ] Confirm test cases - [ ] Confirm document - [ ] Add branch pick label Signed-off-by: Aurélien Pupier From a5d20234ff89c1f905cbd9bfdd41d7cb5d2d9242 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Tue, 23 Jun 2026 13:25:33 +0800 Subject: [PATCH 2/9] branch-4.2: [refactor](local shuffle) Move local exchange planning from BE to FE #63366 Cherry-picked from #63366 --- .../exchange/local_exchange_sink_operator.cpp | 75 +- .../exchange/local_exchange_sink_operator.h | 35 +- .../local_exchange_source_operator.cpp | 5 +- .../exchange/local_exchange_source_operator.h | 40 +- be/src/exec/exchange/local_exchanger.h | 42 +- .../exec/operator/aggregation_sink_operator.h | 8 +- be/src/exec/operator/analytic_sink_operator.h | 8 +- .../exec/operator/assert_num_rows_operator.h | 2 +- .../distinct_streaming_aggregation_operator.h | 10 +- .../exec/operator/exchange_source_operator.h | 8 +- be/src/exec/operator/hashjoin_build_sink.h | 12 +- .../exec/operator/hashjoin_probe_operator.h | 14 +- .../nested_loop_join_build_operator.h | 6 +- .../nested_loop_join_probe_operator.h | 4 +- be/src/exec/operator/operator.cpp | 11 +- be/src/exec/operator/operator.h | 7 +- .../operator/partition_sort_sink_operator.h | 5 +- .../partitioned_hash_join_probe_operator.cpp | 2 - .../partitioned_hash_join_probe_operator.h | 14 +- .../partitioned_hash_join_sink_operator.h | 6 +- .../operator/rec_cte_anchor_sink_operator.h | 2 +- be/src/exec/operator/rec_cte_sink_operator.h | 2 +- .../exec/operator/rec_cte_source_operator.h | 2 +- be/src/exec/operator/scan_operator.h | 4 +- .../exec/operator/set_probe_sink_operator.h | 6 +- be/src/exec/operator/set_sink_operator.h | 6 +- be/src/exec/operator/set_source_operator.h | 4 +- be/src/exec/operator/sort_sink_operator.h | 10 +- .../operator/streaming_aggregation_operator.h | 6 +- .../exec/operator/table_function_operator.h | 2 +- be/src/exec/operator/union_sink_operator.h | 5 +- be/src/exec/operator/union_source_operator.h | 4 +- be/src/exec/pipeline/dependency.h | 51 +- be/src/exec/pipeline/pipeline.cpp | 6 +- be/src/exec/pipeline/pipeline.h | 15 +- .../pipeline/pipeline_fragment_context.cpp | 339 +++- .../exec/pipeline/pipeline_fragment_context.h | 30 +- be/src/runtime/exec_env_init.cpp | 11 + be/src/runtime/runtime_state.h | 7 +- be/test/exec/operator/agg_operator_test.cpp | 16 +- ...ct_streaming_aggregation_operator_test.cpp | 4 +- .../operator/streaming_agg_operator_test.cpp | 4 +- .../exec/pipeline/local_exchanger_test.cpp | 15 +- be/test/exec/pipeline/pipeline_test.cpp | 21 +- .../org/apache/doris/catalog/EnvFactory.java | 16 +- .../doris/cloud/catalog/CloudEnvFactory.java | 4 +- .../apache/doris/nereids/NereidsPlanner.java | 17 +- .../translator/PhysicalPlanTranslator.java | 52 +- .../translator/PlanTranslatorContext.java | 37 + .../doris/nereids/trees/plans/PlanType.java | 1 + .../insert/InsertIntoTableCommand.java | 16 +- .../job/UnassignedScanBucketOlapTableJob.java | 2 +- .../doris/planner/AddLocalExchange.java | 152 ++ .../apache/doris/planner/AggregationNode.java | 168 +- .../doris/planner/AnalyticEvalNode.java | 84 +- .../doris/planner/AssertNumRowsNode.java | 18 +- .../org/apache/doris/planner/CTEScanNode.java | 10 + .../org/apache/doris/planner/DataSink.java | 5 + .../apache/doris/planner/EmptySetNode.java | 10 + .../org/apache/doris/planner/ExceptNode.java | 6 + .../apache/doris/planner/ExchangeNode.java | 38 +- .../apache/doris/planner/HashJoinNode.java | 109 ++ .../apache/doris/planner/IntersectNode.java | 6 + .../doris/planner/LocalExchangeNode.java | 357 ++++ .../doris/planner/MaterializationNode.java | 18 +- .../doris/planner/NestedLoopJoinNode.java | 62 +- .../apache/doris/planner/OlapScanNode.java | 19 + .../doris/planner/PartitionSortNode.java | 44 + .../apache/doris/planner/PlanFragment.java | 2 +- .../org/apache/doris/planner/PlanNode.java | 361 +++- .../doris/planner/RecursiveCteNode.java | 50 + .../doris/planner/RecursiveCteScanNode.java | 12 +- .../org/apache/doris/planner/RepeatNode.java | 27 +- .../apache/doris/planner/RuntimeFilter.java | 2 +- .../org/apache/doris/planner/ScanNode.java | 16 +- .../org/apache/doris/planner/SelectNode.java | 18 +- .../doris/planner/SetOperationNode.java | 65 + .../org/apache/doris/planner/SortNode.java | 58 +- .../doris/planner/TableFunctionNode.java | 24 + .../org/apache/doris/planner/UnionNode.java | 2 +- .../java/org/apache/doris/qe/Coordinator.java | 5 +- .../apache/doris/qe/NereidsCoordinator.java | 11 + .../org/apache/doris/qe/SessionVariable.java | 22 + .../org/apache/doris/qe/StmtExecutor.java | 5 +- .../doris/qe/runtime/ThriftPlansBuilder.java | 3 +- .../planner/LocalShuffleNodeCoverageTest.java | 783 ++++++++ .../org/apache/doris/planner/PlanShape.java | 335 ++++ .../apache/doris/planner/PlanShapeDsl.java | 152 ++ .../doris/qe/LocalExchangePlannerTest.java | 721 ++++++++ gensrc/thrift/PaloInternalService.thrift | 3 + gensrc/thrift/Partitions.thrift | 85 + gensrc/thrift/PlanNodes.thrift | 24 +- .../unnest_order_by_list_test.out | 8 +- ...test_multilevel_join_agg_local_shuffle.out | 814 +++++++++ .../plugins/plugin_profile_plan_tree.groovy | 298 ++++ .../unnest_order_by_list_test.groovy | 10 +- ...st_enable_local_exchange_before_agg.groovy | 157 ++ ...est_local_shuffle_fe_be_consistency.groovy | 755 ++++++++ ...t_local_shuffle_global_hash_require.groovy | 410 +++++ .../test_local_shuffle_recursive_cte.groovy | 181 ++ .../test_local_shuffle_rqg_bugs.groovy | 1567 +++++++++++++++++ .../test_old_coordinator_local_shuffle.groovy | 99 ++ .../test_python_udaf_complex.groovy | 2 +- ...t_multilevel_join_agg_local_shuffle.groovy | 884 ++++++++++ 104 files changed, 9806 insertions(+), 302 deletions(-) create mode 100644 fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java create mode 100644 fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java create mode 100644 fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java create mode 100644 fe/fe-core/src/test/java/org/apache/doris/planner/PlanShape.java create mode 100644 fe/fe-core/src/test/java/org/apache/doris/planner/PlanShapeDsl.java create mode 100644 fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java create mode 100644 regression-test/data/query_p0/join/test_multilevel_join_agg_local_shuffle.out create mode 100644 regression-test/plugins/plugin_profile_plan_tree.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_enable_local_exchange_before_agg.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_fe_be_consistency.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_global_hash_require.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_recursive_cte.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_old_coordinator_local_shuffle.groovy create mode 100644 regression-test/suites/query_p0/join/test_multilevel_join_agg_local_shuffle.groovy diff --git a/be/src/exec/exchange/local_exchange_sink_operator.cpp b/be/src/exec/exchange/local_exchange_sink_operator.cpp index 10f1d52831b5c5..8432fc6bc25e9d 100644 --- a/be/src/exec/exchange/local_exchange_sink_operator.cpp +++ b/be/src/exec/exchange/local_exchange_sink_operator.cpp @@ -37,25 +37,9 @@ std::vector LocalExchangeSinkLocalState::dependencies() const { return deps; } -Status LocalExchangeSinkOperatorX::init(RuntimeState* state, ExchangeType type, - const int num_buckets, const bool use_global_hash_shuffle, - const std::map& shuffle_idx_to_instance_idx) { - _name = "LOCAL_EXCHANGE_SINK_OPERATOR(" + get_exchange_type_name(type) + ")"; - _type = type; - if (_type == ExchangeType::HASH_SHUFFLE) { - _shuffle_idx_to_instance_idx.clear(); - _use_global_shuffle = use_global_hash_shuffle; - // For shuffle join, if data distribution has been broken by previous operator, we - // should use a HASH_SHUFFLE local exchanger to shuffle data again. To be mentioned, - // we should use map shuffle idx to instance idx because all instances will be - // distributed to all BEs. Otherwise, we should use shuffle idx directly. - if (use_global_hash_shuffle) { - _shuffle_idx_to_instance_idx = shuffle_idx_to_instance_idx; - } else { - for (int i = 0; i < _num_partitions; i++) { - _shuffle_idx_to_instance_idx[i] = i; - } - } +Status LocalExchangeSinkOperatorX::_create_partitioner(RuntimeState* state, int bucket_count) { + if (_type == TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE || + _type == TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE) { if (state->query_options().__isset.enable_new_shuffle_hash_method && state->query_options().enable_new_shuffle_hash_method) { _partitioner = std::make_unique(_num_partitions); @@ -64,17 +48,47 @@ Status LocalExchangeSinkOperatorX::init(RuntimeState* state, ExchangeType type, std::make_unique>(_num_partitions); } RETURN_IF_ERROR(_partitioner->init(_texprs)); - } else if (_type == ExchangeType::BUCKET_HASH_SHUFFLE) { - DCHECK_GT(num_buckets, 0); - _partitioner = std::make_unique>(num_buckets); + } else if (_type == TLocalPartitionType::BUCKET_HASH_SHUFFLE) { + DCHECK_GT(bucket_count, 0); + _partitioner = std::make_unique>(bucket_count); RETURN_IF_ERROR(_partitioner->init(_texprs)); } return Status::OK(); } +Status LocalExchangeSinkOperatorX::init(RuntimeState* state, TLocalPartitionType::type type, + const int num_buckets, + const std::map& shuffle_idx_to_instance_idx) { + DCHECK(!_planned_by_fe); + _name = "LOCAL_EXCHANGE_SINK_OPERATOR(" + get_exchange_type_name(type) + ")"; + _type = type; + if (_type == TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE) { + // For shuffle join, if data distribution has been broken by previous operator, we + // should use a HASH_SHUFFLE local exchanger to shuffle data again. To be mentioned, + // we should use map shuffle idx to instance idx because all instances will be + // distributed to all BEs. Otherwise, we should use shuffle idx directly. + _shuffle_idx_to_instance_idx = shuffle_idx_to_instance_idx; + } else if (_type == TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE) { + _shuffle_idx_to_instance_idx.clear(); + for (int i = 0; i < _num_partitions; i++) { + _shuffle_idx_to_instance_idx[i] = i; + } + } + return _create_partitioner(state, num_buckets); +} + +Status LocalExchangeSinkOperatorX::init_partitioner(RuntimeState* state) { + DCHECK(_planned_by_fe); + // Set operator name to include exchange type (base class init(tnode) only sets generic name). + _name = "LOCAL_EXCHANGE_SINK_OPERATOR(" + get_exchange_type_name(_type) + ")"; + return _create_partitioner(state, _num_partitions); +} + Status LocalExchangeSinkOperatorX::prepare(RuntimeState* state) { RETURN_IF_ERROR(DataSinkOperatorX::prepare(state)); - if (_type == ExchangeType::HASH_SHUFFLE || _type == ExchangeType::BUCKET_HASH_SHUFFLE) { + if (_type == TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE || + _type == TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE || + _type == TLocalPartitionType::BUCKET_HASH_SHUFFLE) { RETURN_IF_ERROR(_partitioner->prepare(state, _child->row_desc())); RETURN_IF_ERROR(_partitioner->open(state)); } @@ -88,11 +102,6 @@ Status LocalExchangeSinkLocalState::init(RuntimeState* state, LocalSinkStateInfo SCOPED_TIMER(_init_timer); _compute_hash_value_timer = ADD_TIMER(custom_profile(), "ComputeHashValueTime"); _distribute_timer = ADD_TIMER(custom_profile(), "DistributeDataTime"); - if (_parent->cast()._type == ExchangeType::HASH_SHUFFLE) { - custom_profile()->add_info_string( - "UseGlobalShuffle", - std::to_string(_parent->cast()._use_global_shuffle)); - } custom_profile()->add_info_string( "PartitionExprsSize", std::to_string(_parent->cast()._partitioned_exprs_num)); @@ -107,8 +116,7 @@ Status LocalExchangeSinkLocalState::open(RuntimeState* state) { _exchanger = _shared_state->exchanger.get(); DCHECK(_exchanger != nullptr); - if (_exchanger->get_type() == ExchangeType::HASH_SHUFFLE || - _exchanger->get_type() == ExchangeType::BUCKET_HASH_SHUFFLE) { + if (is_shuffled_exchange(_exchanger->get_type())) { auto& p = _parent->cast(); RETURN_IF_ERROR(p._partitioner->clone(state, _partitioner)); } @@ -131,12 +139,11 @@ Status LocalExchangeSinkLocalState::close(RuntimeState* state, Status exec_statu std::string LocalExchangeSinkLocalState::debug_string(int indentation_level) const { fmt::memory_buffer debug_string_buffer; fmt::format_to(debug_string_buffer, - "{}, _use_global_shuffle: {}, _channel_id: {}, _num_partitions: {}, " + "{}, _channel_id: {}, _num_partitions: {}, " "_num_senders: {}, _num_sources: {}, " "_running_sink_operators: {}, _running_source_operators: {}", - Base::debug_string(indentation_level), - _parent->cast()._use_global_shuffle, _channel_id, - _exchanger->_num_partitions, _exchanger->_num_senders, _exchanger->_num_sources, + Base::debug_string(indentation_level), _channel_id, _exchanger->_num_partitions, + _exchanger->_num_senders, _exchanger->_num_sources, _exchanger->_running_sink_operators, _exchanger->_running_source_operators); return fmt::to_string(debug_string_buffer); } diff --git a/be/src/exec/exchange/local_exchange_sink_operator.h b/be/src/exec/exchange/local_exchange_sink_operator.h index 0e6844cd1fba1a..dfbf280d0d9ce9 100644 --- a/be/src/exec/exchange/local_exchange_sink_operator.h +++ b/be/src/exec/exchange/local_exchange_sink_operator.h @@ -79,6 +79,17 @@ class LocalExchangeSinkOperatorX final : public DataSinkOperatorX& shuffle_id_to_instance_idx) + : Base(operator_id, tnode, dest_id), + _type(tnode.local_exchange_node.partition_type), + _num_partitions(num_partitions), + _texprs(tnode.local_exchange_node.distribute_expr_lists), + _partitioned_exprs_num(tnode.local_exchange_node.distribute_expr_lists.size()), + _shuffle_idx_to_instance_idx(shuffle_id_to_instance_idx), + _planned_by_fe(true) {} #ifdef BE_TEST LocalExchangeSinkOperatorX(const std::vector& texprs, const std::map& bucket_seq_to_instance_idx) @@ -89,18 +100,19 @@ class LocalExchangeSinkOperatorX final : public DataSinkOperatorX& shuffle_idx_to_instance_idx) override; + // Initialize partitioner for FE-planned local exchange nodes. The FE-planned constructor + // already sets _type, _num_partitions, _texprs, and _shuffle_idx_to_instance_idx from the + // TPlanNode, but does not create the partitioner. This method creates the partitioner so + // that prepare() can call _partitioner->prepare() without null dereference. + Status init_partitioner(RuntimeState* state); + Status prepare(RuntimeState* state) override; Status sink_impl(RuntimeState* state, Block* in_block, bool eos) override; @@ -115,13 +127,20 @@ class LocalExchangeSinkOperatorX final : public DataSinkOperatorX& _texprs; const size_t _partitioned_exprs_num; std::unique_ptr _partitioner; std::map _shuffle_idx_to_instance_idx; - bool _use_global_shuffle = false; + const bool _planned_by_fe = false; }; } // namespace doris diff --git a/be/src/exec/exchange/local_exchange_source_operator.cpp b/be/src/exec/exchange/local_exchange_source_operator.cpp index ace8573d704f1c..d50448cef9f5d6 100644 --- a/be/src/exec/exchange/local_exchange_source_operator.cpp +++ b/be/src/exec/exchange/local_exchange_source_operator.cpp @@ -31,8 +31,7 @@ Status LocalExchangeSourceLocalState::init(RuntimeState* state, LocalStateInfo& DCHECK(_exchanger != nullptr); _get_block_failed_counter = ADD_COUNTER_WITH_LEVEL(custom_profile(), "GetBlockFailedTime", TUnit::UNIT, 1); - if (_exchanger->get_type() == ExchangeType::HASH_SHUFFLE || - _exchanger->get_type() == ExchangeType::BUCKET_HASH_SHUFFLE) { + if (is_shuffled_exchange(_exchanger->get_type())) { _copy_data_timer = ADD_TIMER(custom_profile(), "CopyDataTime"); } @@ -60,7 +59,7 @@ Status LocalExchangeSourceLocalState::close(RuntimeState* state) { } std::vector LocalExchangeSourceLocalState::dependencies() const { - if ((_exchanger->get_type() == ExchangeType::PASS_TO_ONE) && _channel_id != 0) { + if ((_exchanger->get_type() == TLocalPartitionType::PASS_TO_ONE) && _channel_id != 0) { // If this is a PASS_TO_ONE exchange and is not the first task, source operators always // return empty result so no dependencies here. return {}; diff --git a/be/src/exec/exchange/local_exchange_source_operator.h b/be/src/exec/exchange/local_exchange_source_operator.h index c315680901f45d..52631a13746a36 100644 --- a/be/src/exec/exchange/local_exchange_source_operator.h +++ b/be/src/exec/exchange/local_exchange_source_operator.h @@ -63,20 +63,47 @@ class LocalExchangeSourceOperatorX final : public OperatorX; LocalExchangeSourceOperatorX(ObjectPool* pool, int id) : Base(pool, id, id) {} + LocalExchangeSourceOperatorX(ObjectPool* pool, const TPlanNode& tnode, int operator_id, + const DescriptorTbl& descs) + : Base(pool, tnode, operator_id, descs), + _exchange_type(tnode.local_exchange_node.partition_type), + _planned_by_fe(true) {} #ifdef BE_TEST LocalExchangeSourceOperatorX() = default; #endif - Status init(ExchangeType type) override { + Status init(TLocalPartitionType::type type) override { + DCHECK(!_planned_by_fe); _op_name = "LOCAL_EXCHANGE_OPERATOR(" + get_exchange_type_name(type) + ")"; _exchange_type = type; return Status::OK(); } - Status prepare(RuntimeState* state) override { return Status::OK(); } + Status prepare(RuntimeState* state) override { + if (_planned_by_fe) { + RETURN_IF_ERROR(Base::prepare(state)); + // Base::prepare() resets _op_name from tnode node_type; restore the type-qualified name. + _op_name = "LOCAL_EXCHANGE_OPERATOR(" + get_exchange_type_name(_exchange_type) + ")"; + return Status::OK(); + } + return Status::OK(); + } const RowDescriptor& intermediate_row_desc() const override { + if (_planned_by_fe) { + return Base::intermediate_row_desc(); + } return _child->intermediate_row_desc(); } - RowDescriptor& row_descriptor() override { return _child->row_descriptor(); } - const RowDescriptor& row_desc() const override { return _child->row_desc(); } + RowDescriptor& row_descriptor() override { + if (_planned_by_fe) { + return Base::row_descriptor(); + } + return _child->row_descriptor(); + } + const RowDescriptor& row_desc() const override { + if (_planned_by_fe) { + return Base::row_desc(); + } + return _child->row_desc(); + } Status get_block_impl(RuntimeState* state, Block* block, bool* eos) override; @@ -85,12 +112,13 @@ class LocalExchangeSourceOperatorX final : public OperatorX; template class Exchanger : public ExchangerBase { public: - Exchanger(int running_sink_operators, int num_partitions, int free_block_limit) - : ExchangerBase(running_sink_operators, num_partitions, free_block_limit) { + Exchanger(int running_sink_operators, int num_partitions, int free_block_limit, + TLocalPartitionType::type type) + : ExchangerBase(running_sink_operators, num_partitions, free_block_limit), _type(type) { _data_queue.resize(num_partitions); _m.resize(num_partitions); for (size_t i = 0; i < num_partitions; i++) { _m[i] = std::make_unique(); } } - Exchanger(int running_sink_operators, int num_sources, int num_partitions, int free_block_limit) - : ExchangerBase(running_sink_operators, num_sources, num_partitions, free_block_limit) { + Exchanger(int running_sink_operators, int num_sources, int num_partitions, int free_block_limit, + TLocalPartitionType::type type) + : ExchangerBase(running_sink_operators, num_sources, num_partitions, free_block_limit), + _type(type) { _data_queue.resize(num_sources); _m.resize(num_sources); for (size_t i = 0; i < num_sources; i++) { @@ -244,6 +247,7 @@ class Exchanger : public ExchangerBase { } } ~Exchanger() override = default; + TLocalPartitionType::type get_type() const override { return _type; } std::string data_queue_debug_string(int i) override { return fmt::format("Data Queue {}: [size approx = {}, eos = {}]", i, _data_queue[i].data_queue.size_approx(), _data_queue[i].eos); @@ -260,6 +264,7 @@ class Exchanger : public ExchangerBase { bool _dequeue_data(BlockType& block, bool* eos, Block* data_block, int channel_id); std::vector> _data_queue; std::vector> _m; + const TLocalPartitionType::type _type; }; class LocalExchangeSourceLocalState; @@ -269,9 +274,9 @@ class ShuffleExchanger : public Exchanger { public: ENABLE_FACTORY_CREATOR(ShuffleExchanger); ShuffleExchanger(int running_sink_operators, int num_sources, int num_partitions, - int free_block_limit) + int free_block_limit, TLocalPartitionType::type type) : Exchanger(running_sink_operators, num_sources, num_partitions, - free_block_limit) { + free_block_limit, type) { DCHECK_GT(num_partitions, 0); DCHECK_GT(num_sources, 0); _partition_rows_histogram.resize(running_sink_operators); @@ -283,7 +288,6 @@ class ShuffleExchanger : public Exchanger { Status get_block(RuntimeState* state, Block* block, bool* eos, Profile&& profile, SourceInfo&& source_info) override; void close(SourceInfo&& source_info) override; - ExchangeType get_type() const override { return ExchangeType::HASH_SHUFFLE; } protected: Status _split_rows(RuntimeState* state, const std::vector& channel_ids, Block* block, @@ -299,24 +303,22 @@ class BucketShuffleExchanger final : public ShuffleExchanger { BucketShuffleExchanger(int running_sink_operators, int num_sources, int num_partitions, int free_block_limit) : ShuffleExchanger(running_sink_operators, num_sources, num_partitions, - free_block_limit) {} + free_block_limit, TLocalPartitionType::BUCKET_HASH_SHUFFLE) {} ~BucketShuffleExchanger() override = default; - ExchangeType get_type() const override { return ExchangeType::BUCKET_HASH_SHUFFLE; } }; class PassthroughExchanger final : public Exchanger { public: ENABLE_FACTORY_CREATOR(PassthroughExchanger); PassthroughExchanger(int running_sink_operators, int num_partitions, int free_block_limit) - : Exchanger(running_sink_operators, num_partitions, - free_block_limit) {} + : Exchanger(running_sink_operators, num_partitions, free_block_limit, + TLocalPartitionType::PASSTHROUGH) {} ~PassthroughExchanger() override = default; Status sink(RuntimeState* state, Block* in_block, bool eos, Profile&& profile, SinkInfo& sink_info) override; Status get_block(RuntimeState* state, Block* block, bool* eos, Profile&& profile, SourceInfo&& source_info) override; - ExchangeType get_type() const override { return ExchangeType::PASSTHROUGH; } void close(SourceInfo&& source_info) override; }; @@ -324,29 +326,28 @@ class PassToOneExchanger final : public Exchanger { public: ENABLE_FACTORY_CREATOR(PassToOneExchanger); PassToOneExchanger(int running_sink_operators, int num_partitions, int free_block_limit) - : Exchanger(running_sink_operators, num_partitions, - free_block_limit) {} + : Exchanger(running_sink_operators, num_partitions, free_block_limit, + TLocalPartitionType::PASS_TO_ONE) {} ~PassToOneExchanger() override = default; Status sink(RuntimeState* state, Block* in_block, bool eos, Profile&& profile, SinkInfo& sink_info) override; Status get_block(RuntimeState* state, Block* block, bool* eos, Profile&& profile, SourceInfo&& source_info) override; - ExchangeType get_type() const override { return ExchangeType::PASS_TO_ONE; } void close(SourceInfo&& source_info) override; }; class BroadcastExchanger final : public Exchanger { public: ENABLE_FACTORY_CREATOR(BroadcastExchanger); BroadcastExchanger(int running_sink_operators, int num_partitions, int free_block_limit) - : Exchanger(running_sink_operators, num_partitions, free_block_limit) {} + : Exchanger(running_sink_operators, num_partitions, free_block_limit, + TLocalPartitionType::BROADCAST) {} ~BroadcastExchanger() override = default; Status sink(RuntimeState* state, Block* in_block, bool eos, Profile&& profile, SinkInfo& sink_info) override; Status get_block(RuntimeState* state, Block* block, bool* eos, Profile&& profile, SourceInfo&& source_info) override; - ExchangeType get_type() const override { return ExchangeType::BROADCAST; } void close(SourceInfo&& source_info) override; }; @@ -357,8 +358,8 @@ class AdaptivePassthroughExchanger : public Exchanger { ENABLE_FACTORY_CREATOR(AdaptivePassthroughExchanger); AdaptivePassthroughExchanger(int running_sink_operators, int num_partitions, int free_block_limit) - : Exchanger(running_sink_operators, num_partitions, - free_block_limit) { + : Exchanger(running_sink_operators, num_partitions, free_block_limit, + TLocalPartitionType::ADAPTIVE_PASSTHROUGH) { _partition_rows_histogram.resize(running_sink_operators); } Status sink(RuntimeState* state, Block* in_block, bool eos, Profile&& profile, @@ -366,7 +367,6 @@ class AdaptivePassthroughExchanger : public Exchanger { Status get_block(RuntimeState* state, Block* block, bool* eos, Profile&& profile, SourceInfo&& source_info) override; - ExchangeType get_type() const override { return ExchangeType::ADAPTIVE_PASSTHROUGH; } void close(SourceInfo&& source_info) override; diff --git a/be/src/exec/operator/aggregation_sink_operator.h b/be/src/exec/operator/aggregation_sink_operator.h index 5925f15ce5380b..89ac1825818f91 100644 --- a/be/src/exec/operator/aggregation_sink_operator.h +++ b/be/src/exec/operator/aggregation_sink_operator.h @@ -156,7 +156,7 @@ class AggSinkOperatorX MOCK_REMOVE(final) : public DataSinkOperatorX::required_data_distribution( state); } @@ -167,8 +167,10 @@ class AggSinkOperatorX MOCK_REMOVE(final) : public DataSinkOperatorX::required_data_distribution(state); } return _is_colocate && _require_bucket_distribution - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, _partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, + _partition_exprs) + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, + _partition_exprs); } bool is_colocated_operator() const override { return _is_colocate; } bool is_shuffled_operator() const override { diff --git a/be/src/exec/operator/analytic_sink_operator.h b/be/src/exec/operator/analytic_sink_operator.h index af37bae331cbb3..1a202d57f2e2dd 100644 --- a/be/src/exec/operator/analytic_sink_operator.h +++ b/be/src/exec/operator/analytic_sink_operator.h @@ -218,11 +218,13 @@ class AnalyticSinkOperatorX final : public DataSinkOperatorXenable_local_exchange_before_agg() && @@ -126,11 +126,13 @@ class DistinctStreamingAggOperatorX final } if (_needs_finalize || (!_probe_expr_ctxs.empty() && !_is_streaming_preagg)) { return _is_colocate && _require_bucket_distribution - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, _partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, + _partition_exprs) + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, + _partition_exprs); } if (state->enable_distinct_streaming_agg_force_passthrough()) { - return {ExchangeType::PASSTHROUGH}; + return {TLocalPartitionType::PASSTHROUGH}; } else { return StatefulOperatorX::required_data_distribution( state); diff --git a/be/src/exec/operator/exchange_source_operator.h b/be/src/exec/operator/exchange_source_operator.h index e09aa61021960d..767bbc192fd0f2 100644 --- a/be/src/exec/operator/exchange_source_operator.h +++ b/be/src/exec/operator/exchange_source_operator.h @@ -113,13 +113,13 @@ class ExchangeSourceOperatorX final : public OperatorX { DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (OperatorX::is_serial_operator()) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } return _partition_type == TPartitionType::HASH_PARTITIONED - ? DataDistribution(ExchangeType::HASH_SHUFFLE) + ? DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE) : _partition_type == TPartitionType::BUCKET_SHFFULE_HASH_PARTITIONED - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE) - : DataDistribution(ExchangeType::NOOP); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE) + : DataDistribution(TLocalPartitionType::NOOP); } private: diff --git a/be/src/exec/operator/hashjoin_build_sink.h b/be/src/exec/operator/hashjoin_build_sink.h index af2155bab1c646..ea8e53a872681f 100644 --- a/be/src/exec/operator/hashjoin_build_sink.h +++ b/be/src/exec/operator/hashjoin_build_sink.h @@ -135,15 +135,17 @@ class HashJoinBuildSinkOperatorX MOCK_REMOVE(final) DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } else if (_is_broadcast_join) { - return _child->is_serial_operator() ? DataDistribution(ExchangeType::PASS_TO_ONE) - : DataDistribution(ExchangeType::NOOP); + return _child->is_serial_operator() ? DataDistribution(TLocalPartitionType::PASS_TO_ONE) + : DataDistribution(TLocalPartitionType::NOOP); } return _join_distribution == TJoinDistributionType::BUCKET_SHUFFLE || _join_distribution == TJoinDistributionType::COLOCATE - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, _partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, + _partition_exprs) + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, + _partition_exprs); } bool is_shuffled_operator() const override { diff --git a/be/src/exec/operator/hashjoin_probe_operator.h b/be/src/exec/operator/hashjoin_probe_operator.h index 5a771b80331f62..f53c683aa61d3b 100644 --- a/be/src/exec/operator/hashjoin_probe_operator.h +++ b/be/src/exec/operator/hashjoin_probe_operator.h @@ -136,21 +136,23 @@ class HashJoinProbeOperatorX MOCK_REMOVE(final) bool need_more_input_data(RuntimeState* state) const override; DataDistribution required_data_distribution(RuntimeState* state) const override { if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } else if (_is_broadcast_join) { if (state->enable_broadcast_join_force_passthrough()) { - return DataDistribution(ExchangeType::PASSTHROUGH); + return DataDistribution(TLocalPartitionType::PASSTHROUGH); } else { return _child && _child->is_serial_operator() - ? DataDistribution(ExchangeType::PASSTHROUGH) - : DataDistribution(ExchangeType::NOOP); + ? DataDistribution(TLocalPartitionType::PASSTHROUGH) + : DataDistribution(TLocalPartitionType::NOOP); } } return (_join_distribution == TJoinDistributionType::BUCKET_SHUFFLE || _join_distribution == TJoinDistributionType::COLOCATE - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, _partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs)); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, + _partition_exprs) + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, + _partition_exprs)); } bool is_broadcast_join() const { return _is_broadcast_join; } diff --git a/be/src/exec/operator/nested_loop_join_build_operator.h b/be/src/exec/operator/nested_loop_join_build_operator.h index a8fc817380274a..dcd35f4f807091 100644 --- a/be/src/exec/operator/nested_loop_join_build_operator.h +++ b/be/src/exec/operator/nested_loop_join_build_operator.h @@ -69,10 +69,10 @@ class NestedLoopJoinBuildSinkOperatorX final DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } - return _child->is_serial_operator() ? DataDistribution(ExchangeType::BROADCAST) - : DataDistribution(ExchangeType::NOOP); + return _child->is_serial_operator() ? DataDistribution(TLocalPartitionType::BROADCAST) + : DataDistribution(TLocalPartitionType::NOOP); } private: diff --git a/be/src/exec/operator/nested_loop_join_probe_operator.h b/be/src/exec/operator/nested_loop_join_probe_operator.h index 3037578d4a05f1..47b74a21b66f3c 100644 --- a/be/src/exec/operator/nested_loop_join_probe_operator.h +++ b/be/src/exec/operator/nested_loop_join_probe_operator.h @@ -212,9 +212,9 @@ class NestedLoopJoinProbeOperatorX final if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN || _join_op == TJoinOp::RIGHT_OUTER_JOIN || _join_op == TJoinOp::RIGHT_ANTI_JOIN || _join_op == TJoinOp::RIGHT_SEMI_JOIN || _join_op == TJoinOp::FULL_OUTER_JOIN) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } - return {ExchangeType::ADAPTIVE_PASSTHROUGH}; + return {TLocalPartitionType::ADAPTIVE_PASSTHROUGH}; } const RowDescriptor& row_desc() const override { diff --git a/be/src/exec/operator/operator.cpp b/be/src/exec/operator/operator.cpp index 3644966487560a..4de8ae9ecf68d9 100644 --- a/be/src/exec/operator/operator.cpp +++ b/be/src/exec/operator/operator.cpp @@ -149,13 +149,8 @@ Status PipelineXSinkLocalState::terminate(RuntimeState* state) { DataDistribution OperatorBase::required_data_distribution(RuntimeState* /*state*/) const { return _child && _child->is_serial_operator() && !is_source() - ? DataDistribution(ExchangeType::PASSTHROUGH) - : DataDistribution(ExchangeType::NOOP); -} - -bool OperatorBase::is_hash_shuffle(ExchangeType exchange_type) { - return exchange_type == ExchangeType::HASH_SHUFFLE || - exchange_type == ExchangeType::BUCKET_HASH_SHUFFLE; + ? DataDistribution(TLocalPartitionType::PASSTHROUGH) + : DataDistribution(TLocalPartitionType::NOOP); } bool OperatorBase::child_breaks_local_key_distribution(RuntimeState* state) const { @@ -167,7 +162,7 @@ bool OperatorBase::child_breaks_local_key_distribution(RuntimeState* state) cons } const auto child_distribution = _child->required_data_distribution(state); return child_distribution.need_local_exchange() && - !is_hash_shuffle(child_distribution.distribution_type); + !is_shuffled_exchange(child_distribution.distribution_type); } const RowDescriptor& OperatorBase::row_desc() const { diff --git a/be/src/exec/operator/operator.h b/be/src/exec/operator/operator.h index e193d57163b364..818b8637abfcdd 100644 --- a/be/src/exec/operator/operator.h +++ b/be/src/exec/operator/operator.h @@ -193,7 +193,6 @@ class OperatorBase { } protected: - [[nodiscard]] static bool is_hash_shuffle(ExchangeType exchange_type); [[nodiscard]] bool child_breaks_local_key_distribution(RuntimeState* state) const; OperatorPtr _child = nullptr; @@ -620,8 +619,8 @@ class DataSinkOperatorXBase : public OperatorBase { virtual bool reset_to_rerun(RuntimeState* state, OperatorXBase* root) const { return false; } Status init(const TDataSink& tsink) override; - [[nodiscard]] virtual Status init(RuntimeState* state, ExchangeType type, const int num_buckets, - const bool use_global_hash_shuffle, + [[nodiscard]] virtual Status init(RuntimeState* state, TLocalPartitionType::type type, + const int num_buckets, const std::map& shuffle_idx_to_instance_idx) { return Status::InternalError("init() is only implemented in local exchange!"); } @@ -868,7 +867,7 @@ class OperatorXBase : public OperatorBase { Status init(const TDataSink& tsink) override { throw Exception(Status::FatalError("should not reach here!")); } - virtual Status init(ExchangeType type) { + virtual Status init(TLocalPartitionType::type type) { throw Exception(Status::FatalError("should not reach here!")); } [[noreturn]] virtual const std::vector& runtime_filter_descs() { diff --git a/be/src/exec/operator/partition_sort_sink_operator.h b/be/src/exec/operator/partition_sort_sink_operator.h index 5dce3e12653791..a2fe6ecb0c8e99 100644 --- a/be/src/exec/operator/partition_sort_sink_operator.h +++ b/be/src/exec/operator/partition_sort_sink_operator.h @@ -96,9 +96,10 @@ class PartitionSortSinkOperatorX final : public DataSinkOperatorX(pool, tnode, operator_id, descs), - _join_distribution(tnode.hash_join_node.__isset.dist_type ? tnode.hash_join_node.dist_type - : TJoinDistributionType::NONE), _distribution_partition_exprs(tnode.__isset.distribute_expr_lists ? tnode.distribute_expr_lists[0] : std::vector {}), diff --git a/be/src/exec/operator/partitioned_hash_join_probe_operator.h b/be/src/exec/operator/partitioned_hash_join_probe_operator.h index 76721eb584ec3a..89a2441650a7ad 100644 --- a/be/src/exec/operator/partitioned_hash_join_probe_operator.h +++ b/be/src/exec/operator/partitioned_hash_join_probe_operator.h @@ -229,16 +229,8 @@ class PartitionedHashJoinProbeOperatorX final Status pull(doris::RuntimeState* state, Block* output_block, bool* eos) const override; bool need_more_input_data(RuntimeState* state) const override; - DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { - if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { - return {ExchangeType::NOOP}; - } - return (_join_distribution == TJoinDistributionType::BUCKET_SHUFFLE || - _join_distribution == TJoinDistributionType::COLOCATE - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, - _distribution_partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, - _distribution_partition_exprs)); + DataDistribution required_data_distribution(RuntimeState* state) const override { + return _inner_probe_operator->required_data_distribution(state); } size_t revocable_mem_size(RuntimeState* state) const override; @@ -286,8 +278,6 @@ class PartitionedHashJoinProbeOperatorX final RuntimeState* state, Block* output_block, bool* eos) const; - const TJoinDistributionType::type _join_distribution; - std::shared_ptr _inner_sink_operator; std::shared_ptr _inner_probe_operator; diff --git a/be/src/exec/operator/partitioned_hash_join_sink_operator.h b/be/src/exec/operator/partitioned_hash_join_sink_operator.h index a9fb27f6b330a1..b8e589bb7c9229 100644 --- a/be/src/exec/operator/partitioned_hash_join_sink_operator.h +++ b/be/src/exec/operator/partitioned_hash_join_sink_operator.h @@ -128,14 +128,14 @@ class PartitionedHashJoinSinkOperatorX DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } return _join_distribution == TJoinDistributionType::BUCKET_SHUFFLE || _join_distribution == TJoinDistributionType::COLOCATE - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, _distribution_partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, _distribution_partition_exprs); } diff --git a/be/src/exec/operator/rec_cte_anchor_sink_operator.h b/be/src/exec/operator/rec_cte_anchor_sink_operator.h index e30d38d180c13a..e07a2af716f8f3 100644 --- a/be/src/exec/operator/rec_cte_anchor_sink_operator.h +++ b/be/src/exec/operator/rec_cte_anchor_sink_operator.h @@ -68,7 +68,7 @@ class RecCTEAnchorSinkOperatorX MOCK_REMOVE(final) bool is_serial_operator() const override { return true; } DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } Status terminate(RuntimeState* state) override { diff --git a/be/src/exec/operator/rec_cte_sink_operator.h b/be/src/exec/operator/rec_cte_sink_operator.h index 34796b5658835c..4c330ac31fd2c5 100644 --- a/be/src/exec/operator/rec_cte_sink_operator.h +++ b/be/src/exec/operator/rec_cte_sink_operator.h @@ -79,7 +79,7 @@ class RecCTESinkOperatorX MOCK_REMOVE(final) : public DataSinkOperatorX { bool is_serial_operator() const override { return true; } DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } Status get_block_impl(RuntimeState* state, Block* block, bool* eos) override { diff --git a/be/src/exec/operator/scan_operator.h b/be/src/exec/operator/scan_operator.h index 929a67d0755e5c..72fdf22b322b03 100644 --- a/be/src/exec/operator/scan_operator.h +++ b/be/src/exec/operator/scan_operator.h @@ -366,9 +366,9 @@ class ScanOperatorX : public OperatorX { DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (OperatorX::is_serial_operator()) { // `is_serial_operator()` returns true means we ignore the distribution. - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } - return {ExchangeType::BUCKET_HASH_SHUFFLE}; + return {TLocalPartitionType::BUCKET_HASH_SHUFFLE}; } void set_low_memory_mode(RuntimeState* state) override { diff --git a/be/src/exec/operator/set_probe_sink_operator.h b/be/src/exec/operator/set_probe_sink_operator.h index cae10f7672667b..68ef58954466a9 100644 --- a/be/src/exec/operator/set_probe_sink_operator.h +++ b/be/src/exec/operator/set_probe_sink_operator.h @@ -101,8 +101,10 @@ class SetProbeSinkOperatorX final : public DataSinkOperatorX create_shared_state() const override { return nullptr; } diff --git a/be/src/exec/operator/set_sink_operator.h b/be/src/exec/operator/set_sink_operator.h index 26b359101984a8..c04b187d9cc390 100644 --- a/be/src/exec/operator/set_sink_operator.h +++ b/be/src/exec/operator/set_sink_operator.h @@ -112,8 +112,10 @@ class SetSinkOperatorX final : public DataSinkOperatorX { DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (_is_analytic_sort) { return _is_colocate && _require_bucket_distribution - ? DataDistribution(ExchangeType::BUCKET_HASH_SHUFFLE, _partition_exprs) - : DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs); + ? DataDistribution(TLocalPartitionType::BUCKET_HASH_SHUFFLE, + _partition_exprs) + : DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, + _partition_exprs); } else if (_merge_by_exchange) { // The current sort node is used for the ORDER BY - return {ExchangeType::PASSTHROUGH}; + return {TLocalPartitionType::PASSTHROUGH}; } else { - return {ExchangeType::NOOP}; + return {TLocalPartitionType::NOOP}; } } bool is_colocated_operator() const override { return _is_colocate; } diff --git a/be/src/exec/operator/streaming_aggregation_operator.h b/be/src/exec/operator/streaming_aggregation_operator.h index 5abdbd40fbda18..348b67ac0d684e 100644 --- a/be/src/exec/operator/streaming_aggregation_operator.h +++ b/be/src/exec/operator/streaming_aggregation_operator.h @@ -218,7 +218,7 @@ class StreamingAggOperatorX MOCK_REMOVE(final) : public StatefulOperatorXis_hash_join_probe() && state->enable_streaming_agg_hash_join_force_passthrough()) { - return DataDistribution(ExchangeType::PASSTHROUGH); + return {TLocalPartitionType::PASSTHROUGH}; } // Keep streaming aggregation on its inherited distribution unless the dedicated switch // explicitly enables a local hash exchange. @@ -228,11 +228,11 @@ class StreamingAggOperatorX MOCK_REMOVE(final) : public StatefulOperatorX::required_data_distribution( state); } - return DataDistribution(ExchangeType::HASH_SHUFFLE, _partition_exprs); + return {TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, _partition_exprs}; } private: diff --git a/be/src/exec/operator/table_function_operator.h b/be/src/exec/operator/table_function_operator.h index 0a67efb0a6c2e9..adc82d456de572 100644 --- a/be/src/exec/operator/table_function_operator.h +++ b/be/src/exec/operator/table_function_operator.h @@ -117,7 +117,7 @@ class TableFunctionOperatorX MOCK_REMOVE(final) } DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { - return {ExchangeType::PASSTHROUGH}; + return {TLocalPartitionType::PASSTHROUGH}; } Status push(RuntimeState* state, Block* input_block, bool eos) const override { diff --git a/be/src/exec/operator/union_sink_operator.h b/be/src/exec/operator/union_sink_operator.h index 43d7129d4b1556..409cf1762771ff 100644 --- a/be/src/exec/operator/union_sink_operator.h +++ b/be/src/exec/operator/union_sink_operator.h @@ -119,10 +119,11 @@ class UnionSinkOperatorX MOCK_REMOVE(final) : public DataSinkOperatorX +#include #include #include @@ -654,50 +655,44 @@ struct SetSharedState : public BasicSharedState { Status hash_table_init(); }; -enum class ExchangeType : uint8_t { - NOOP = 0, - // Shuffle data by Crc32CHashPartitioner - HASH_SHUFFLE = 1, - // Round-robin passthrough data blocks. - PASSTHROUGH = 2, - // Shuffle data by Crc32HashPartitioner (e.g. same as storage engine). - BUCKET_HASH_SHUFFLE = 3, - // Passthrough data blocks to all channels. - BROADCAST = 4, - // Passthrough data to channels evenly in an adaptive way. - ADAPTIVE_PASSTHROUGH = 5, - // Send all data to the first channel. - PASS_TO_ONE = 6, -}; +inline bool is_shuffled_exchange(TLocalPartitionType::type idx) { + return idx == TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE || + idx == TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE || + idx == TLocalPartitionType::BUCKET_HASH_SHUFFLE; +} -inline std::string get_exchange_type_name(ExchangeType idx) { +inline std::string get_exchange_type_name(TLocalPartitionType::type idx) { switch (idx) { - case ExchangeType::NOOP: + case TLocalPartitionType::NOOP: return "NOOP"; - case ExchangeType::HASH_SHUFFLE: - return "HASH_SHUFFLE"; - case ExchangeType::PASSTHROUGH: + case TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE: + return "GLOBAL_HASH_SHUFFLE"; + case TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE: + return "LOCAL_HASH_SHUFFLE"; + case TLocalPartitionType::PASSTHROUGH: return "PASSTHROUGH"; - case ExchangeType::BUCKET_HASH_SHUFFLE: + case TLocalPartitionType::BUCKET_HASH_SHUFFLE: return "BUCKET_HASH_SHUFFLE"; - case ExchangeType::BROADCAST: + case TLocalPartitionType::BROADCAST: return "BROADCAST"; - case ExchangeType::ADAPTIVE_PASSTHROUGH: + case TLocalPartitionType::ADAPTIVE_PASSTHROUGH: return "ADAPTIVE_PASSTHROUGH"; - case ExchangeType::PASS_TO_ONE: + case TLocalPartitionType::PASS_TO_ONE: return "PASS_TO_ONE"; + case TLocalPartitionType::LOCAL_MERGE_SORT: + return "LOCAL_MERGE_SORT"; } throw Exception(Status::FatalError("__builtin_unreachable")); } struct DataDistribution { - DataDistribution(ExchangeType type) : distribution_type(type) {} - DataDistribution(ExchangeType type, const std::vector& partition_exprs_) + DataDistribution(TLocalPartitionType::type type) : distribution_type(type) {} + DataDistribution(TLocalPartitionType::type type, const std::vector& partition_exprs_) : distribution_type(type), partition_exprs(partition_exprs_) {} DataDistribution(const DataDistribution& other) = default; - bool need_local_exchange() const { return distribution_type != ExchangeType::NOOP; } + bool need_local_exchange() const { return distribution_type != TLocalPartitionType::NOOP; } DataDistribution& operator=(const DataDistribution& other) = default; - ExchangeType distribution_type; + TLocalPartitionType::type distribution_type; std::vector partition_exprs; }; diff --git a/be/src/exec/pipeline/pipeline.cpp b/be/src/exec/pipeline/pipeline.cpp index b2a6ca575962a7..b8174d7475bd89 100644 --- a/be/src/exec/pipeline/pipeline.cpp +++ b/be/src/exec/pipeline/pipeline.cpp @@ -58,12 +58,12 @@ bool Pipeline::need_to_local_exchange(const DataDistribution target_data_distrib std::dynamic_pointer_cast(_operators.front()); local_exchange_source && is_hash_exchange(target_data_distribution.distribution_type)) { const auto source_exchange_type = local_exchange_source->exchange_type(); - if (source_exchange_type != ExchangeType::NOOP && !is_hash_exchange(source_exchange_type)) { + if (source_exchange_type != TLocalPartitionType::NOOP && + !is_hash_exchange(source_exchange_type)) { return true; } } - if (target_data_distribution.distribution_type != ExchangeType::BUCKET_HASH_SHUFFLE && - target_data_distribution.distribution_type != ExchangeType::HASH_SHUFFLE) { + if (!is_hash_exchange(target_data_distribution.distribution_type)) { // Always do local exchange if non-hash-partition exchanger is required. // For example, `PASSTHROUGH` exchanger is always required to distribute data evenly. return true; diff --git a/be/src/exec/pipeline/pipeline.h b/be/src/exec/pipeline/pipeline.h index 75e801ddc82f7f..3d791ae9ff59d4 100644 --- a/be/src/exec/pipeline/pipeline.h +++ b/be/src/exec/pipeline/pipeline.h @@ -69,16 +69,15 @@ class Pipeline : public std::enable_shared_from_this { [[nodiscard]] PipelineId id() const { return _pipeline_id; } - static bool is_hash_exchange(ExchangeType idx) { - return idx == ExchangeType::HASH_SHUFFLE || idx == ExchangeType::BUCKET_HASH_SHUFFLE; + static bool is_hash_exchange(TLocalPartitionType::type idx) { + return is_shuffled_exchange(idx); } - // For HASH_SHUFFLE, BUCKET_HASH_SHUFFLE, and ADAPTIVE_PASSTHROUGH, - // data is processed and shuffled on the sink. + // For the hash-shuffle types (GLOBAL_EXECUTION_HASH_SHUFFLE, LOCAL_EXECUTION_HASH_SHUFFLE, + // BUCKET_HASH_SHUFFLE) and ADAPTIVE_PASSTHROUGH, data is processed and shuffled on the sink. // Compared to PASSTHROUGH, this is a relatively heavy operation. - static bool heavy_operations_on_the_sink(ExchangeType idx) { - return idx == ExchangeType::HASH_SHUFFLE || idx == ExchangeType::BUCKET_HASH_SHUFFLE || - idx == ExchangeType::ADAPTIVE_PASSTHROUGH; + static bool heavy_operations_on_the_sink(TLocalPartitionType::type idx) { + return is_shuffled_exchange(idx) || idx == TLocalPartitionType::ADAPTIVE_PASSTHROUGH; } bool need_to_local_exchange(const DataDistribution target_data_distribution, @@ -166,7 +165,7 @@ class Pipeline : public std::enable_shared_from_this { // Input data distribution of this pipeline. We do local exchange when input data distribution // does not match the target data distribution. - DataDistribution _data_distribution {ExchangeType::NOOP}; + DataDistribution _data_distribution {TLocalPartitionType::NOOP}; // How many tasks should be created ? int _num_tasks = 1; diff --git a/be/src/exec/pipeline/pipeline_fragment_context.cpp b/be/src/exec/pipeline/pipeline_fragment_context.cpp index 06b617b833987f..18e278eaa036c9 100644 --- a/be/src/exec/pipeline/pipeline_fragment_context.cpp +++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp @@ -288,6 +288,32 @@ Status PipelineFragmentContext::_build_and_prepare_full_pipeline(ThreadPool* thr RETURN_IF_ERROR(_build_pipelines(_runtime_state->obj_pool(), *_query_ctx->desc_tbl, &_root_op, root_pipeline)); + // Propagate _num_instances from LOCAL_EXCHANGE pipelines to ancestor pipelines + // that inherited reduced num_tasks from a serial operator. + _propagate_local_exchange_num_tasks(); + + // Create deferred local exchangers now that all pipelines have final num_tasks. + RETURN_IF_ERROR(_create_deferred_local_exchangers()); + + // Raise num_tasks for pipelines whose serial non-scan operators (e.g., + // UNPARTITIONED Exchange) reduced num_tasks below _num_instances. + // Without this, fragment instances 1+ have no task for these pipelines + // and downstream operators fail with "must set shared state". + // + // This applies to ALL pipelines (not just deferred exchanger upstreams): + // fragments with UNION/INTERSECT/EXCEPT + serial Exchange in child + // pipelines also need the raise, even without FE-planned local exchange. + // + // Exception: serial scan sources (pooling scan) keep num_tasks=1 — the + // PassthroughExchanger(1, N) handles the fan-out correctly. + // NOTE: Do NOT raise pipelines whose source is a serial operator + // (Exchange or scan) — they legitimately have 1 task, and raising + // them causes crashes (e.g., 4 Exchange tasks but only 1 receives + // data). The correct fix for shared state injection across + // instances is handled by the FE: it inserts local exchange nodes + // between serial operators and their downstream consumers, creating + // proper pipeline boundaries with _num_instances tasks. + // 3. Create sink operator if (!_params.fragment.__isset.output_sink) { return Status::InternalError("No output sink in this fragment!"); @@ -305,7 +331,7 @@ Status PipelineFragmentContext::_build_and_prepare_full_pipeline(ThreadPool* thr } } // 4. Build local exchanger - if (_runtime_state->enable_local_shuffle()) { + if (_runtime_state->plan_local_shuffle()) { SCOPED_TIMER(_plan_local_exchanger_timer); RETURN_IF_ERROR(_plan_local_exchange(_params.num_buckets, _params.bucket_seq_to_instance_idx, @@ -688,6 +714,188 @@ Status PipelineFragmentContext::_build_pipelines(ObjectPool* pool, const Descrip return Status::OK(); } +Status PipelineFragmentContext::_create_deferred_local_exchangers() { + for (auto& info : _deferred_exchangers) { + // DANGER ZONE — do not "fix" this line without reading the history. + // + // sender_count seeds Exchanger::_running_sink_operators, which the source side + // waits to reach 0 via sub_running_sink_operators on each sink LocalState close. + // The correct value is THIS pipeline-instance's sink task count, which is exactly + // info.upstream_pipe->num_tasks() — one PipelineTask per task, one close per task. + // + // Tempting wrong fix #1: `std::max(num_tasks, _num_instances)` to mirror the + // BE-planned path in _add_local_exchange_impl (~line 1023). THIS BREAKS the + // common FE-planned shape of `serial scan → LE(PT) → ...`: upstream_pipe + // genuinely has num_tasks=1, only 1 close arrives, but seed becomes + // _num_instances so _running_sink_operators never reaches 0 — downstream + // sources hang on SHUFFLE_DATA_DEPENDENCY (e.g. MTMV refresh from + // mtmv_up_down_job_p0/load.groovy stays at Status=RUNNING and regressed + // exactly this way). BE-planned mode uses max() because its + // `cur_pipe` is the source-side pipeline (always raised to _num_instances by + // add_pipeline) — not analogous to our `upstream_pipe` here, which is the + // sink-side pipeline that may legitimately stay at 1 for serial sources. + // + // Tempting wrong fix #2: multiply by _num_instances on the theory shared_state + // is shared across all instances. Same hang — each fragment-instance + // PipelineFragmentContext has its OWN _op_id_to_shared_state map, so the + // exchanger is per-instance, not per-BE. num_tasks() is already the right + // close-count for one instance. + // + // If a hang shows up with `_running_sink_operators < 0`, the bug is upstream: + // _propagate_local_exchange_num_tasks left num_tasks too low (or too high) for + // this fragment shape. Fix THAT pass, not this seed value. + const int sender_count = info.upstream_pipe->num_tasks(); + switch (info.partition_type) { + case TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE: + case TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE: + info.shared_state->exchanger = ShuffleExchanger::create_unique( + sender_count, _num_instances, info.num_partitions, info.free_blocks_limit, + info.partition_type); + break; + case TLocalPartitionType::BUCKET_HASH_SHUFFLE: + info.shared_state->exchanger = BucketShuffleExchanger::create_unique( + sender_count, _num_instances, info.num_partitions, info.free_blocks_limit); + break; + case TLocalPartitionType::PASSTHROUGH: + info.shared_state->exchanger = PassthroughExchanger::create_unique( + sender_count, _num_instances, info.free_blocks_limit); + break; + case TLocalPartitionType::BROADCAST: + info.shared_state->exchanger = BroadcastExchanger::create_unique( + sender_count, _num_instances, info.free_blocks_limit); + break; + case TLocalPartitionType::PASS_TO_ONE: + if (_runtime_state->enable_share_hash_table_for_broadcast_join()) { + info.shared_state->exchanger = PassToOneExchanger::create_unique( + sender_count, _num_instances, info.free_blocks_limit); + } else { + info.shared_state->exchanger = BroadcastExchanger::create_unique( + sender_count, _num_instances, info.free_blocks_limit); + } + break; + case TLocalPartitionType::ADAPTIVE_PASSTHROUGH: + info.shared_state->exchanger = AdaptivePassthroughExchanger::create_unique( + sender_count, _num_instances, info.free_blocks_limit); + break; + case TLocalPartitionType::NOOP: + case TLocalPartitionType::LOCAL_MERGE_SORT: + // FE-planned LocalExchangeNode currently never emits NOOP or LOCAL_MERGE_SORT + // through the deferred-exchanger path. NOOP means "no exchange needed" and + // is filtered out before reaching here; LOCAL_MERGE_SORT is planned by the + // legacy BE path only. Crash in debug to surface the protocol violation if + // that ever changes; return an error in release to avoid silently corrupting + // execution. + DCHECK(false) << "FE-planned local exchange should not emit partition_type=" + << static_cast(info.partition_type); + return Status::InternalError("FE-planned local exchange emitted unsupported type: " + + std::to_string(static_cast(info.partition_type))); + default: + // New TLocalPartitionType added on FE side without a BE handler here. + DCHECK(false) << "Unhandled TLocalPartitionType in deferred exchangers: " + << static_cast(info.partition_type); + return Status::InternalError("Unsupported FE-planned local exchange type: " + + std::to_string(static_cast(info.partition_type))); + } + } + _deferred_exchangers.clear(); + return Status::OK(); +} + +void PipelineFragmentContext::_propagate_local_exchange_num_tasks() { + // Only runs when FE has planned local exchanges and BE deferred their construction. + // In legacy mode (enable_local_shuffle_planner=false) BE plans LE itself via + // _plan_local_exchange and _deferred_exchangers stays empty — the legacy path + // already gets its num_tasks right at construction time, so the propagate passes + // would be no-ops and are skipped. This is a transitional design: once the FE + // planner is the only planner, the propagation logic itself should degrade into + // a pure assertion that the FE plan already wired the right num_tasks everywhere. + if (_deferred_exchangers.empty()) { + return; + } + // Reconcile num_tasks across paired pipelines created by pipeline-splitting operators + // (AGG, SORT, JOIN): they share state via inject_shared_state and must agree, or + // instance 1+ tasks access null shared_state. A pipeline's num_tasks is fully + // determined by its source operator plus its upstreams: + // - LocalExchangeSource -> _num_instances (the LE re-parallelizes) + // - serial source -> its reduced count (kept as-is, typically 1) + // - otherwise (splitter) -> inherit from upstreams: raise to _num_instances if any + // upstream was raised by an LE, then lower to a serial + // upstream's count (lower wins). + // Visiting each pipeline only after all its upstreams (topological order over _dag) lets + // a single sweep reach the same fixpoint the previous two while-loops iterated to — those + // only existed to reconcile the top-down build's parent-inherited num_tasks guesses. + std::map id_to_pipe; + std::map> downstreams_of; + std::map in_degree; + for (auto& p : _pipelines) { + id_to_pipe[p->id()] = p; + in_degree.try_emplace(p->id(), 0); + } + for (const auto& [downstream_id, upstream_ids] : _dag) { + for (auto upstream_id : upstream_ids) { + downstreams_of[upstream_id].push_back(downstream_id); + in_degree[downstream_id]++; + } + } + std::vector ready; + for (const auto& [id, deg] : in_degree) { + if (deg == 0) { + ready.push_back(id); + } + } + size_t visited = 0; + while (!ready.empty()) { + const auto id = ready.back(); + ready.pop_back(); + visited++; + auto pit = id_to_pipe.find(id); + if (pit != id_to_pipe.end()) { + auto& pipe = pit->second; + const auto& ops = pipe->operators(); + const bool le_source = + !ops.empty() && dynamic_cast(ops.front().get()); + const bool serial_source = !ops.empty() && ops.front()->is_serial_operator(); + if (le_source) { + pipe->set_num_tasks(_num_instances); + } else if (!serial_source) { + int target = pipe->num_tasks(); + const auto up_it = _dag.find(id); + if (up_it != _dag.end()) { + // raise: any upstream already at _num_instances (e.g. an LE source) + for (auto upstream_id : up_it->second) { + auto uit = id_to_pipe.find(upstream_id); + if (uit != id_to_pipe.end() && uit->second->num_tasks() >= _num_instances) { + target = _num_instances; + break; + } + } + // lower: a serial upstream with fewer tasks (wins over the raise above) + for (auto upstream_id : up_it->second) { + auto uit = id_to_pipe.find(upstream_id); + if (uit != id_to_pipe.end() && uit->second->num_tasks() < target && + !uit->second->operators().empty() && + uit->second->operators().front()->is_serial_operator()) { + target = uit->second->num_tasks(); + } + } + } + pipe->set_num_tasks(target); + } + } + for (auto down : downstreams_of[id]) { + if (--in_degree[down] == 0) { + ready.push_back(down); + } + } + } + // The pipeline DAG is acyclic; if a future change introduces a back-edge, some pipelines + // stay unvisited (in_degree never reaches 0) — fail loudly rather than silently leaving + // their num_tasks unreconciled. + DCHECK_EQ(visited, in_degree.size()) + << "pipeline num_tasks topological sweep visited " << visited << " of " + << in_degree.size() << " pipelines (cycle in _dag?)"; +} + Status PipelineFragmentContext::_create_tree_helper( ObjectPool* pool, const std::vector& tnodes, const DescriptorTbl& descs, OperatorPtr parent, int* node_idx, OperatorPtr* root, PipelinePtr& cur_pipe, int child_idx, @@ -721,7 +929,7 @@ Status PipelineFragmentContext::_create_tree_helper( *root = op; } /** - * `ExchangeType::HASH_SHUFFLE` should be used if an operator is followed by a shuffled operator (shuffled hash join, union operator followed by co-located operators). + * `TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE` should be used if an operator is followed by a shuffled operator (shuffled hash join, union operator followed by co-located operators). * * For plan: * LocalExchange(id=0) -> Aggregation(id=1) -> ShuffledHashJoin(id=2) @@ -741,7 +949,7 @@ Status PipelineFragmentContext::_create_tree_helper( : op->is_shuffled_operator())) && Pipeline::is_hash_exchange(required_data_distribution.distribution_type)) || (followed_by_shuffled_operator && - required_data_distribution.distribution_type == ExchangeType::NOOP); + required_data_distribution.distribution_type == TLocalPartitionType::NOOP); current_require_bucket_distribution = ((require_bucket_distribution || @@ -749,7 +957,7 @@ Status PipelineFragmentContext::_create_tree_helper( : op->is_colocated_operator())) && Pipeline::is_hash_exchange(required_data_distribution.distribution_type)) || (require_bucket_distribution && - required_data_distribution.distribution_type == ExchangeType::NOOP); + required_data_distribution.distribution_type == TLocalPartitionType::NOOP); if (num_children == 0) { _use_serial_source = op->is_serial_operator(); @@ -808,28 +1016,35 @@ Status PipelineFragmentContext::_add_local_exchange_impl( sink_id, local_exchange_id, use_global_hash_shuffle ? _total_instances : _num_instances, data_distribution.partition_exprs, bucket_seq_to_instance_idx); if (bucket_seq_to_instance_idx.empty() && - data_distribution.distribution_type == ExchangeType::BUCKET_HASH_SHUFFLE) { - data_distribution.distribution_type = ExchangeType::HASH_SHUFFLE; + data_distribution.distribution_type == TLocalPartitionType::BUCKET_HASH_SHUFFLE) { + data_distribution.distribution_type = + use_global_hash_shuffle ? TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE + : TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE; + } + if (!use_global_hash_shuffle && + data_distribution.distribution_type == TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE) { + data_distribution.distribution_type = TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE; } RETURN_IF_ERROR(new_pip->set_sink(sink)); RETURN_IF_ERROR(new_pip->sink()->init(_runtime_state.get(), data_distribution.distribution_type, - num_buckets, use_global_hash_shuffle, - shuffle_idx_to_instance_idx)); + num_buckets, shuffle_idx_to_instance_idx)); // 2. Create and initialize LocalExchangeSharedState. std::shared_ptr shared_state = LocalExchangeSharedState::create_shared(_num_instances); switch (data_distribution.distribution_type) { - case ExchangeType::HASH_SHUFFLE: + case TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE: + case TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE: shared_state->exchanger = ShuffleExchanger::create_unique( std::max(cur_pipe->num_tasks(), _num_instances), _num_instances, use_global_hash_shuffle ? _total_instances : _num_instances, _runtime_state->query_options().__isset.local_exchange_free_blocks_limit ? cast_set( _runtime_state->query_options().local_exchange_free_blocks_limit) - : 0); + : 0, + data_distribution.distribution_type); break; - case ExchangeType::BUCKET_HASH_SHUFFLE: + case TLocalPartitionType::BUCKET_HASH_SHUFFLE: shared_state->exchanger = BucketShuffleExchanger::create_unique( std::max(cur_pipe->num_tasks(), _num_instances), _num_instances, num_buckets, _runtime_state->query_options().__isset.local_exchange_free_blocks_limit @@ -837,7 +1052,7 @@ Status PipelineFragmentContext::_add_local_exchange_impl( _runtime_state->query_options().local_exchange_free_blocks_limit) : 0); break; - case ExchangeType::PASSTHROUGH: + case TLocalPartitionType::PASSTHROUGH: shared_state->exchanger = PassthroughExchanger::create_unique( cur_pipe->num_tasks(), _num_instances, _runtime_state->query_options().__isset.local_exchange_free_blocks_limit @@ -845,7 +1060,7 @@ Status PipelineFragmentContext::_add_local_exchange_impl( _runtime_state->query_options().local_exchange_free_blocks_limit) : 0); break; - case ExchangeType::BROADCAST: + case TLocalPartitionType::BROADCAST: shared_state->exchanger = BroadcastExchanger::create_unique( cur_pipe->num_tasks(), _num_instances, _runtime_state->query_options().__isset.local_exchange_free_blocks_limit @@ -853,7 +1068,7 @@ Status PipelineFragmentContext::_add_local_exchange_impl( _runtime_state->query_options().local_exchange_free_blocks_limit) : 0); break; - case ExchangeType::PASS_TO_ONE: + case TLocalPartitionType::PASS_TO_ONE: if (_runtime_state->enable_share_hash_table_for_broadcast_join()) { // If shared hash table is enabled for BJ, hash table will be built by only one task shared_state->exchanger = PassToOneExchanger::create_unique( @@ -871,7 +1086,7 @@ Status PipelineFragmentContext::_add_local_exchange_impl( : 0); } break; - case ExchangeType::ADAPTIVE_PASSTHROUGH: + case TLocalPartitionType::ADAPTIVE_PASSTHROUGH: shared_state->exchanger = AdaptivePassthroughExchanger::create_unique( std::max(cur_pipe->num_tasks(), _num_instances), _num_instances, _runtime_state->query_options().__isset.local_exchange_free_blocks_limit @@ -982,9 +1197,9 @@ Status PipelineFragmentContext::_add_local_exchange( Pipeline::heavy_operations_on_the_sink(data_distribution.distribution_type)) { RETURN_IF_ERROR(_add_local_exchange_impl( cast_set(new_pip->operators().size()), pool, new_pip, - add_pipeline(new_pip, pip_idx + 2), DataDistribution(ExchangeType::PASSTHROUGH), - do_local_exchange, num_buckets, bucket_seq_to_instance_idx, - shuffle_idx_to_instance_idx)); + add_pipeline(new_pip, pip_idx + 2), + DataDistribution(TLocalPartitionType::PASSTHROUGH), do_local_exchange, num_buckets, + bucket_seq_to_instance_idx, shuffle_idx_to_instance_idx)); } return Status::OK(); } @@ -1025,10 +1240,10 @@ Status PipelineFragmentContext::_plan_local_exchange( do_local_exchange = false; // Plan local exchange for each operator. for (; idx < ops.size();) { - if (ops[idx]->required_data_distribution(_runtime_state.get()).need_local_exchange()) { + auto _le_req = ops[idx]->required_data_distribution(_runtime_state.get()); + if (_le_req.need_local_exchange()) { RETURN_IF_ERROR(_add_local_exchange( - pip_idx, idx, ops[idx]->node_id(), _runtime_state->obj_pool(), pip, - ops[idx]->required_data_distribution(_runtime_state.get()), + pip_idx, idx, ops[idx]->node_id(), _runtime_state->obj_pool(), pip, _le_req, &do_local_exchange, num_buckets, bucket_seq_to_instance_idx, shuffle_idx_to_instance_idx)); } @@ -1764,6 +1979,88 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo RETURN_IF_ERROR(cur_pipe->add_operator(op, _parallel_instances)); break; } + case TPlanNodeType::LOCAL_EXCHANGE_NODE: { + op = std::make_shared(pool, tnode, next_operator_id(), descs); + // The downstream pipeline (containing LocalExchangeSource) must have + // _num_instances tasks — matching BE-native _inherit_pipeline_properties + // which sets pipe_with_source.set_num_tasks(_num_instances). + // Without this, when the parent pipeline was reduced by a serial operator + // (e.g., serial Exchange with use_serial_exchange=true, or UNPARTITIONED + // Exchange), the downstream inherits the reduced num_tasks via + // add_pipeline(parent). The deferred exchanger creates _num_instances + // channels but only fewer source tasks initialize mem_counters — the + // sink round-robins to all channels and crashes on uninitialized ones. + RETURN_IF_ERROR(cur_pipe->add_operator(op, _parallel_instances)); + // Restore downstream pipeline's num_tasks (mirroring _inherit_pipeline_properties: + // downstream keeps _num_instances, upstream gets the serial/reduced count) + cur_pipe->set_num_tasks(_num_instances); + + const auto downstream_pipeline_id = cur_pipe->id(); + if (!_dag.contains(downstream_pipeline_id)) { + _dag.insert({downstream_pipeline_id, {}}); + } + cur_pipe = add_pipeline(cur_pipe); + // If this local exchange was inserted because of a serial scan (is_serial_operator), + // the upstream pipeline (cur_pipe) should have num_tasks=1 (only 1 scan task). + // We set this now so the exchanger is created with the correct sender count. + // Child operators added later (serial scan) will also set num_tasks=1, which is + // consistent with this. + if (op->is_serial_operator() && _parallel_instances > 0) { + cur_pipe->set_num_tasks(_parallel_instances); + } + _dag[downstream_pipeline_id].push_back(cur_pipe->id()); + int num_partitions = 0; + std::map shuffle_id_to_instance_idx; + auto partition_type = tnode.local_exchange_node.partition_type; + switch (partition_type) { + case TLocalPartitionType::BUCKET_HASH_SHUFFLE: + num_partitions = _params.num_buckets; + shuffle_id_to_instance_idx = _params.bucket_seq_to_instance_idx; + break; + case TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE: + for (int i = 0; i < _num_instances; i++) { + shuffle_id_to_instance_idx[i] = i; + } + num_partitions = _num_instances; + break; + case TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE: + num_partitions = _total_instances; + shuffle_id_to_instance_idx = _params.shuffle_idx_to_instance_idx; + break; + default: + break; + } + auto local_exchange_id = op->operator_id(); + auto sink_id = next_sink_operator_id(); + DataSinkOperatorPtr sink = std::make_shared( + sink_id, local_exchange_id, tnode, num_partitions, shuffle_id_to_instance_idx); + sink_ops.push_back(sink); + RETURN_IF_ERROR(cur_pipe->set_sink(sink)); + RETURN_IF_ERROR(cur_pipe->sink()->init(tnode, _runtime_state.get())); + + // For FE-planned local exchange, we need to: + // 1. Initialize the partitioner for hash shuffle types + // 2. Defer exchanger creation until after the full plan tree is built + // (child operators like serial ExchangeNode may change cur_pipe->num_tasks()) + // 3. Register shared state so pipeline tasks can find it + RETURN_IF_ERROR(static_cast(cur_pipe->sink()) + ->init_partitioner(_runtime_state.get())); + + int free_blocks_limit = + _runtime_state->query_options().__isset.local_exchange_free_blocks_limit + ? cast_set( + _runtime_state->query_options().local_exchange_free_blocks_limit) + : 0; + auto shared_state = LocalExchangeSharedState::create_shared(_num_instances); + shared_state->create_source_dependencies(_num_instances, local_exchange_id, + local_exchange_id, "LOCAL_EXCHANGE_OPERATOR"); + shared_state->create_sink_dependency(sink_id, local_exchange_id, "LOCAL_EXCHANGE_SINK"); + _op_id_to_shared_state.insert({local_exchange_id, {shared_state, shared_state->sink_deps}}); + // Defer exchanger creation: sender count depends on final upstream num_tasks + _deferred_exchangers.push_back({shared_state, cur_pipe, partition_type, num_partitions, + free_blocks_limit, local_exchange_id, sink_id}); + break; + } default: return Status::InternalError("Unsupported exec type in pipeline: {}", print_plan_node_type(tnode.node_type)); diff --git a/be/src/exec/pipeline/pipeline_fragment_context.h b/be/src/exec/pipeline/pipeline_fragment_context.h index f5cb2fc22f9e4a..75359bd39601a4 100644 --- a/be/src/exec/pipeline/pipeline_fragment_context.h +++ b/be/src/exec/pipeline/pipeline_fragment_context.h @@ -18,6 +18,7 @@ #pragma once #include +#include #include #include @@ -49,6 +50,7 @@ class TDataSink; class TPipelineFragmentParams; class Dependency; +struct LocalExchangeSharedState; class PipelineFragmentContext : public TaskExecutionContext { public: @@ -325,8 +327,11 @@ class PipelineFragmentContext : public TaskExecutionContext { std::mutex _state_map_lock; - int _operator_id = 0; - int _sink_operator_id = 0; + // Start from -1 so all operator IDs are negative. This avoids collision with + // unpaired sinks (OlapTableSink etc.) whose hardcoded dest_id=0 would otherwise + // match the first operator's ID when FE-planned LocalExchangeNode is the root. + int _operator_id = -1; + int _sink_operator_id = -1; /** * Some states are shared by tasks in different pipeline task (e.g. local exchange , broadcast join). * @@ -347,6 +352,27 @@ class PipelineFragmentContext : public TaskExecutionContext { std::map _pip_id_to_pipeline; std::vector> _runtime_filter_mgr_map; + // Deferred exchanger creation info for FE-planned local exchanges. + // Exchanger sender count depends on the upstream pipeline's final num_tasks, + // which is only known after the full plan tree is built (child operators like + // serial ExchangeNode may reduce num_tasks). So we defer exchanger creation + // until after _build_pipelines completes. + struct DeferredExchangerInfo { + std::shared_ptr shared_state; + PipelinePtr upstream_pipe; + TLocalPartitionType::type partition_type; + int num_partitions; + int free_blocks_limit; + int local_exchange_id; + int sink_id; + }; + std::vector _deferred_exchangers; + Status _create_deferred_local_exchangers(); + // After _build_pipelines, propagate _num_instances from FE-planned LOCAL_EXCHANGE + // pipelines upward through the DAG to ancestor pipelines that inherited reduced + // num_tasks from a serial operator. + void _propagate_local_exchange_num_tasks(); + //Here are two types of runtime states: // - _runtime state is at the Fragment level. // - _task_runtime_states is at the task level, unique to each task. diff --git a/be/src/runtime/exec_env_init.cpp b/be/src/runtime/exec_env_init.cpp index a3cf2d5db6fc66..e90eae7c4fed28 100644 --- a/be/src/runtime/exec_env_init.cpp +++ b/be/src/runtime/exec_env_init.cpp @@ -633,6 +633,17 @@ Status ExecEnv::init_mem_env() { } else { fd_number = static_cast(l.rlim_cur); } +#ifdef __APPLE__ + // On macOS, rlim_cur can be RLIM_INFINITY (INT64_MAX), which causes + // fd_number / 100 * percentage to overflow and crash cast_set. + // Linux kernels cap this via fs.nr_open (default 1M), so only macOS needs this. + { + constexpr uint64_t max_fd = UINT32_MAX >> 2; + if (fd_number > max_fd) { + fd_number = max_fd; + } + } +#endif int64_t segment_cache_capacity = 0; if (config::is_cloud_mode()) { diff --git a/be/src/runtime/runtime_state.h b/be/src/runtime/runtime_state.h index 3184b74a7445c7..1ae337ac3b23e2 100644 --- a/be/src/runtime/runtime_state.h +++ b/be/src/runtime/runtime_state.h @@ -426,8 +426,11 @@ class RuntimeState { BeExecVersionManager::check_be_exec_version(_query_options.be_exec_version)); return _query_options.be_exec_version; } - bool enable_local_shuffle() const { - return _query_options.__isset.enable_local_shuffle && _query_options.enable_local_shuffle; + bool plan_local_shuffle() const { + // If local shuffle is enabled and not planned by local shuffle planner, we should plan local shuffle in BE. + return _query_options.__isset.enable_local_shuffle && _query_options.enable_local_shuffle && + (!_query_options.__isset.enable_local_shuffle_planner || + !_query_options.enable_local_shuffle_planner); } MOCK_FUNCTION bool enable_local_exchange() const { diff --git a/be/test/exec/operator/agg_operator_test.cpp b/be/test/exec/operator/agg_operator_test.cpp index 1934de3b59f437..ada2e287418a8a 100644 --- a/be/test/exec/operator/agg_operator_test.cpp +++ b/be/test/exec/operator/agg_operator_test.cpp @@ -96,7 +96,8 @@ struct MockAggSourceOperator : public AggSourceOperatorX { class MockDistributionOperator final : public OperatorX { public: - MockDistributionOperator(ExchangeType exchange_type) : _exchange_type(exchange_type) {} + MockDistributionOperator(TLocalPartitionType::type exchange_type) + : _exchange_type(exchange_type) {} Status get_block_impl(RuntimeState* /*state*/, Block* /*block*/, bool* eos) override { *eos = true; @@ -108,7 +109,7 @@ class MockDistributionOperator final : public OperatorX { } private: - ExchangeType _exchange_type; + TLocalPartitionType::type _exchange_type; }; std::shared_ptr create_agg_sink_op(OperatorContext& ctx, bool is_merge, @@ -130,11 +131,11 @@ TEST(AggOperatorRequiredDistributionTest, require_hash_shuffle_after_non_hash_ch sink_op->_partition_exprs.emplace_back(); sink_op->_needs_finalize = false; OperatorPtr child = - std::make_shared(ExchangeType::ADAPTIVE_PASSTHROUGH); + std::make_shared(TLocalPartitionType::ADAPTIVE_PASSTHROUGH); sink_op->_child = child; const auto distribution = sink_op->required_data_distribution(&ctx.state); - EXPECT_EQ(ExchangeType::HASH_SHUFFLE, distribution.distribution_type); + EXPECT_EQ(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, distribution.distribution_type); } TEST(AggOperatorRequiredDistributionTest, toggle_hash_shuffle_for_safe_child) { @@ -164,7 +165,7 @@ TEST(AggOperatorRequiredDistributionTest, require_hash_shuffle_after_non_hash_lo auto sink_op = std::make_shared(); sink_op->_needs_finalize = false; OperatorPtr child = std::make_shared(); - EXPECT_TRUE(child->init(ExchangeType::ADAPTIVE_PASSTHROUGH).ok()); + EXPECT_TRUE(child->init(TLocalPartitionType::ADAPTIVE_PASSTHROUGH).ok()); sink_op->_child = child; TExpr distinct_agg_expr; @@ -176,11 +177,12 @@ TEST(AggOperatorRequiredDistributionTest, require_hash_shuffle_after_non_hash_lo sink_op->update_operator(tnode, false, false); const auto distribution = sink_op->required_data_distribution(&ctx.state); - EXPECT_EQ(ExchangeType::HASH_SHUFFLE, distribution.distribution_type); + EXPECT_EQ(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, distribution.distribution_type); Pipeline pipeline(0, 4, 4); EXPECT_TRUE(pipeline.add_operator(child, 0).ok()); - pipeline.set_data_distribution(DataDistribution(ExchangeType::HASH_SHUFFLE)); + pipeline.set_data_distribution( + DataDistribution(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE)); EXPECT_TRUE(pipeline.need_to_local_exchange(distribution, 1)); } diff --git a/be/test/exec/operator/distinct_streaming_aggregation_operator_test.cpp b/be/test/exec/operator/distinct_streaming_aggregation_operator_test.cpp index 17282356625d9a..1e6976e07bc4f3 100644 --- a/be/test/exec/operator/distinct_streaming_aggregation_operator_test.cpp +++ b/be/test/exec/operator/distinct_streaming_aggregation_operator_test.cpp @@ -105,11 +105,11 @@ TEST_F(DistinctStreamingAggOperatorTest, require_hash_shuffle_after_non_hash_loc op->_probe_expr_ctxs = MockSlotRef::create_mock_contexts(0, std::make_shared()); OperatorPtr child = std::make_shared(); - EXPECT_TRUE(child->init(ExchangeType::ADAPTIVE_PASSTHROUGH).ok()); + EXPECT_TRUE(child->init(TLocalPartitionType::ADAPTIVE_PASSTHROUGH).ok()); op->_child = child; const auto distribution = op->required_data_distribution(state.get()); - EXPECT_EQ(ExchangeType::HASH_SHUFFLE, distribution.distribution_type); + EXPECT_EQ(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, distribution.distribution_type); } TEST_F(DistinctStreamingAggOperatorTest, test2) { diff --git a/be/test/exec/operator/streaming_agg_operator_test.cpp b/be/test/exec/operator/streaming_agg_operator_test.cpp index 4596f040b0349e..6ab337513fd430 100644 --- a/be/test/exec/operator/streaming_agg_operator_test.cpp +++ b/be/test/exec/operator/streaming_agg_operator_test.cpp @@ -161,11 +161,11 @@ TEST_F(StreamingAggOperatorTest, require_hash_shuffle_after_non_hash_local_excha op->_partition_exprs.emplace_back(); OperatorPtr child = std::make_shared(); - EXPECT_TRUE(child->init(ExchangeType::ADAPTIVE_PASSTHROUGH).ok()); + EXPECT_TRUE(child->init(TLocalPartitionType::ADAPTIVE_PASSTHROUGH).ok()); EXPECT_TRUE(op->set_child(child)); const auto distribution = op->required_data_distribution(state.get()); - EXPECT_EQ(ExchangeType::HASH_SHUFFLE, distribution.distribution_type); + EXPECT_EQ(TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE, distribution.distribution_type); } TEST_F(StreamingAggOperatorTest, test2) { diff --git a/be/test/exec/pipeline/local_exchanger_test.cpp b/be/test/exec/pipeline/local_exchanger_test.cpp index 6a4419ec810dcd..4245d87bf3824d 100644 --- a/be/test/exec/pipeline/local_exchanger_test.cpp +++ b/be/test/exec/pipeline/local_exchanger_test.cpp @@ -91,8 +91,9 @@ TEST_F(LocalExchangerTest, ShuffleExchanger) { _local_states.resize(num_sources); auto profile = std::make_shared(""); auto shared_state = LocalExchangeSharedState::create_shared(num_partitions); - shared_state->exchanger = ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, - free_block_limit); + shared_state->exchanger = + ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, free_block_limit, + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); auto sink_dep = std::make_shared(0, 0, "LOCAL_EXCHANGE_SINK_DEPENDENCY", true); sink_dep->set_shared_state(shared_state.get()); shared_state->sink_deps.push_back(sink_dep); @@ -1176,8 +1177,9 @@ TEST_F(LocalExchangerTest, TestShuffleExchangerWrongMap) { _local_states.resize(num_sources); auto profile = std::make_shared(""); auto shared_state = LocalExchangeSharedState::create_shared(num_partitions); - shared_state->exchanger = ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, - free_block_limit); + shared_state->exchanger = + ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, free_block_limit, + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); auto sink_dep = std::make_shared(0, 0, "LOCAL_EXCHANGE_SINK_DEPENDENCY", true); sink_dep->set_shared_state(shared_state.get()); shared_state->sink_deps.push_back(sink_dep); @@ -1316,8 +1318,9 @@ TEST_F(LocalExchangerTest, ShuffleExchangerRestoreOutputBlockOnAddRowsError) { auto profile = std::make_shared(""); auto shared_state = LocalExchangeSharedState::create_shared(num_partitions); - shared_state->exchanger = ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, - free_block_limit); + shared_state->exchanger = + ShuffleExchanger::create_unique(num_sink, num_sources, num_partitions, free_block_limit, + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); auto sink_dep = std::make_shared(0, 0, "LOCAL_EXCHANGE_SINK_DEPENDENCY", true); sink_dep->set_shared_state(shared_state.get()); shared_state->sink_deps.push_back(sink_dep); diff --git a/be/test/exec/pipeline/pipeline_test.cpp b/be/test/exec/pipeline/pipeline_test.cpp index d5d0b5028d6300..714d668bffcb89 100644 --- a/be/test/exec/pipeline/pipeline_test.cpp +++ b/be/test/exec/pipeline/pipeline_test.cpp @@ -509,7 +509,7 @@ TEST_F(PipelineTest, PLAN_LOCAL_EXCHANGE) { DescriptorTbl* desc; OperatorPtr op; _build_fragment_context(); - EXPECT_EQ(_runtime_state.front()->enable_local_shuffle(), true); + EXPECT_EQ(_runtime_state.front()->plan_local_shuffle(), true); auto cur_pipe = _build_pipeline(parallelism); { auto tnode = TPlanNodeBuilder(_next_node_id(), TPlanNodeType::EXCHANGE_NODE) @@ -584,11 +584,12 @@ TEST_F(PipelineTest, PLAN_LOCAL_EXCHANGE) { } { cur_pipe->init_data_distribution(_runtime_state.back().get()); - EXPECT_EQ(cur_pipe->data_distribution().distribution_type, ExchangeType::HASH_SHUFFLE); + EXPECT_EQ(cur_pipe->data_distribution().distribution_type, + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); EXPECT_EQ(cur_pipe->sink() ->required_data_distribution(_runtime_state.back().get()) .distribution_type, - ExchangeType::NOOP); + TLocalPartitionType::NOOP); EXPECT_EQ(cur_pipe->need_to_local_exchange( cur_pipe->sink()->required_data_distribution(_runtime_state.back().get()), 1), @@ -597,11 +598,11 @@ TEST_F(PipelineTest, PLAN_LOCAL_EXCHANGE) { { cur_pipe->operators().front()->set_serial_operator(); cur_pipe->init_data_distribution(_runtime_state.back().get()); - EXPECT_EQ(cur_pipe->data_distribution().distribution_type, ExchangeType::NOOP); + EXPECT_EQ(cur_pipe->data_distribution().distribution_type, TLocalPartitionType::NOOP); EXPECT_EQ(cur_pipe->sink() ->required_data_distribution(_runtime_state.back().get()) .distribution_type, - ExchangeType::PASSTHROUGH); + TLocalPartitionType::PASSTHROUGH); EXPECT_EQ(cur_pipe->need_to_local_exchange( cur_pipe->sink()->required_data_distribution(_runtime_state.back().get()), 1), @@ -620,7 +621,7 @@ TEST_F(PipelineTest, PLAN_HASH_JOIN) { // Build pipeline DescriptorTbl* desc; _build_fragment_context(); - EXPECT_EQ(_runtime_state.front()->enable_local_shuffle(), true); + EXPECT_EQ(_runtime_state.front()->plan_local_shuffle(), true); { TTupleDescriptor tuple0 = TTupleDescriptorBuilder().set_id(0).build(); TSlotDescriptor slot0 = @@ -903,12 +904,12 @@ TEST_F(PipelineTest, PLAN_HASH_JOIN) { if (pip_idx == 1) { // Pipeline(ExchangeOperator(id=1, HASH_PARTITIONED) -> HashJoinBuildOperator(id=0)) EXPECT_EQ(_pipelines[pip_idx]->data_distribution().distribution_type, - ExchangeType::HASH_SHUFFLE); + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); EXPECT_EQ(_pipelines[pip_idx] ->sink() ->required_data_distribution(_runtime_state.back().get()) .distribution_type, - ExchangeType::HASH_SHUFFLE); + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); EXPECT_EQ(_pipelines[pip_idx]->need_to_local_exchange( _pipelines[pip_idx]->sink()->required_data_distribution( _runtime_state.back().get()), @@ -919,7 +920,7 @@ TEST_F(PipelineTest, PLAN_HASH_JOIN) { _pipelines[pip_idx]->set_data_distribution( _pipelines[pip_idx]->children().front()->data_distribution()); EXPECT_EQ(_pipelines[pip_idx]->data_distribution().distribution_type, - ExchangeType::HASH_SHUFFLE); + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); EXPECT_EQ(_pipelines[pip_idx]->need_to_local_exchange( _pipelines[pip_idx]->sink()->required_data_distribution( _runtime_state.back().get()), @@ -930,7 +931,7 @@ TEST_F(PipelineTest, PLAN_HASH_JOIN) { .back() ->required_data_distribution(_runtime_state.back().get()) .distribution_type, - ExchangeType::HASH_SHUFFLE); + TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE); EXPECT_EQ(_pipelines[pip_idx]->need_to_local_exchange( _pipelines[pip_idx]->operators().back()->required_data_distribution( _runtime_state.back().get()), diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/EnvFactory.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/EnvFactory.java index 79bed23147da09..42ac307cf5109b 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/catalog/EnvFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/EnvFactory.java @@ -145,7 +145,7 @@ public BrokerLoadJob createBrokerLoadJob() { public Coordinator createCoordinator(ConnectContext context, Planner planner, StatsErrorEstimator statsErrorEstimator) { - if (planner instanceof NereidsPlanner && SessionVariable.canUseNereidsDistributePlanner(context)) { + if (planner instanceof NereidsPlanner && hasNereidsDistributedPlans((NereidsPlanner) planner)) { return new NereidsCoordinator(context, (NereidsPlanner) planner, statsErrorEstimator); } return new Coordinator(context, planner, statsErrorEstimator); @@ -153,12 +153,24 @@ public Coordinator createCoordinator(ConnectContext context, Planner planner, public Coordinator createCoordinator(ConnectContext context, Planner planner, StatsErrorEstimator statsErrorEstimator, long jobId) { - if (planner instanceof NereidsPlanner && SessionVariable.canUseNereidsDistributePlanner(context)) { + if (planner instanceof NereidsPlanner && hasNereidsDistributedPlans((NereidsPlanner) planner)) { return new NereidsCoordinator(context, (NereidsPlanner) planner, statsErrorEstimator, jobId); } return new Coordinator(context, planner, statsErrorEstimator, jobId); } + // Dispatch decision must mirror what FE planning actually did. SessionVariable + // checks (parsedStatement state, session vars) can drift from plan-time reality — + // e.g. dict refresh runs distribute() unconditionally for PhysicalDictionarySink + // even though canUseNereidsDistributePlanner(context) returns false because + // parsedStatement is never set on that path, sending the query to legacy + // Coordinator and producing a hang. The distributedPlans field is the + // single source of truth: it is populated iff FE did distribute planning. + protected static boolean hasNereidsDistributedPlans(NereidsPlanner planner) { + FragmentIdMapping distributedPlans = planner.getDistributedPlans(); + return distributedPlans != null && !distributedPlans.isEmpty(); + } + // Used for broker load task/export task/update coordinator public Coordinator createCoordinator(Long jobId, TUniqueId queryId, DescriptorTable descTable, List fragments, List scanNodes, diff --git a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnvFactory.java b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnvFactory.java index 437c1cc0ba23c6..b3d469c41ff160 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnvFactory.java +++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnvFactory.java @@ -158,7 +158,7 @@ public BrokerLoadJob createBrokerLoadJob() { @Override public Coordinator createCoordinator(ConnectContext context, Planner planner, StatsErrorEstimator statsErrorEstimator) { - if (planner instanceof NereidsPlanner && SessionVariable.canUseNereidsDistributePlanner()) { + if (planner instanceof NereidsPlanner && hasNereidsDistributedPlans((NereidsPlanner) planner)) { return new NereidsCoordinator(context, (NereidsPlanner) planner, statsErrorEstimator); } return new CloudCoordinator(context, planner, statsErrorEstimator); @@ -167,7 +167,7 @@ public Coordinator createCoordinator(ConnectContext context, Planner planner, @Override public Coordinator createCoordinator(ConnectContext context, Planner planner, StatsErrorEstimator statsErrorEstimator, long jobId) { - if (planner instanceof NereidsPlanner && SessionVariable.canUseNereidsDistributePlanner()) { + if (planner instanceof NereidsPlanner && hasNereidsDistributedPlans((NereidsPlanner) planner)) { return new NereidsCoordinator(context, (NereidsPlanner) planner, statsErrorEstimator, jobId); } return new CloudCoordinator(context, planner, statsErrorEstimator, jobId); diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java index cbbb918fa34a0e..79a6db7d454bc6 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java @@ -75,6 +75,7 @@ import org.apache.doris.nereids.trees.plans.physical.PhysicalRelation; import org.apache.doris.nereids.trees.plans.physical.PhysicalSqlCache; import org.apache.doris.nereids.trees.plans.physical.TopnFilter; +import org.apache.doris.planner.AddLocalExchange; import org.apache.doris.planner.PlanFragment; import org.apache.doris.planner.PlanNodeId; import org.apache.doris.planner.Planner; @@ -127,6 +128,7 @@ public class NereidsPlanner extends Planner { private DescriptorTable descTable; private FragmentIdMapping distributedPlans; + private PlanTranslatorContext planTranslatorContext; // The cost of optimized plan private double cost = 0; private LogicalPlanAdapter logicalPlanAdapter; @@ -612,7 +614,7 @@ protected void splitFragments(PhysicalPlan resultPlan) { return; } - PlanTranslatorContext planTranslatorContext = new PlanTranslatorContext(cascadesContext); + this.planTranslatorContext = new PlanTranslatorContext(cascadesContext); PhysicalPlanTranslator physicalPlanTranslator = new PhysicalPlanTranslator(planTranslatorContext, statementContext.getConnectContext().getStatsErrorEstimator()); SessionVariable sessionVariable = cascadesContext.getConnectContext().getSessionVariable(); @@ -718,6 +720,19 @@ protected void distribute(PhysicalPlan physicalPlan, ExplainLevel explainLevel) splitFragments(physicalPlan); doDistribute(canUseNereidsDistributePlanner, explainLevel); + + addLocalExchangeAfterDistribute(); + } + + private void addLocalExchangeAfterDistribute() { + SessionVariable sessionVariable = cascadesContext.getConnectContext().getSessionVariable(); + if (!sessionVariable.isEnableLocalShufflePlanner() || !sessionVariable.isEnableLocalShuffle()) { + return; + } + AddLocalExchange adder = new AddLocalExchange(); + if (distributedPlans != null && !distributedPlans.isEmpty()) { + adder.addLocalExchange(distributedPlans, planTranslatorContext); + } } protected void doDistribute(boolean canUseNereidsDistributePlanner, ExplainLevel explainLevel) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java index be66a89526c57d..092c50b217834a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -97,6 +97,7 @@ import org.apache.doris.nereids.properties.DistributionSpecStorageAny; import org.apache.doris.nereids.properties.DistributionSpecStorageGather; import org.apache.doris.nereids.properties.OrderKey; +import org.apache.doris.nereids.properties.PhysicalProperties; import org.apache.doris.nereids.rules.implementation.LogicalWindowToPhysicalWindow.WindowFrameGroup; import org.apache.doris.nereids.rules.rewrite.MergeLimits; import org.apache.doris.nereids.stats.StatsErrorEstimator; @@ -412,6 +413,7 @@ public PlanFragment visitPhysicalDistribute(PhysicalDistribute d // target data partition DataPartition targetDataPartition = toDataPartition(targetDistribution, validOutputIds, context); exchangeNode.setPartitionType(targetDataPartition.getType()); + exchangeNode.setDistributeExprLists(getDistributeExpr(distribute)); exchangeNode.setChildrenDistributeExprLists(upstreamDistributeExprs); // its source partition is targetDataPartition. and outputPartition is UNPARTITIONED now, will be set when // visit its SinkNode @@ -826,6 +828,7 @@ public PlanFragment visitPhysicalFileScan(PhysicalFileScan fileScan, PlanTransla fileScan.getTableSnapshot().ifPresent(fileQueryScanNode::setQueryTableSnapshot); fileScan.getScanParams().ifPresent(fileQueryScanNode::setScanParams); } + scanNode.setDistributeExprLists(getDistributeExpr(fileScan)); return getPlanFragmentForPhysicalFileScan(fileScan, context, scanNode, table, tupleDescriptor); } @@ -846,6 +849,7 @@ public PlanFragment visitPhysicalEmptyRelation(PhysicalEmptyRelation emptyRelati PlanFragment planFragment = createPlanFragment(emptySetNode, DataPartition.UNPARTITIONED, emptyRelation); context.addPlanFragment(planFragment); + emptySetNode.setDistributeExprLists(getDistributeExpr(emptyRelation)); updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), emptyRelation); return planFragment; } @@ -909,6 +913,7 @@ public PlanFragment visitPhysicalHudiScan(PhysicalHudiScan fileScan, PlanTransla hudiScanNode.setQueryTableSnapshot(hudiScan.getTableSnapshot().get()); } hudiScanNode.setSelectedPartitions(fileScan.getSelectedPartitions()); + hudiScanNode.setDistributeExprLists(getDistributeExpr(hudiScan)); return getPlanFragmentForPhysicalFileScan(fileScan, context, scanNode, table, tupleDescriptor); } @@ -940,6 +945,7 @@ private PlanFragment getPlanFragmentForPhysicalFileScan(PhysicalFileScan fileSca DataPartition dataPartition = DataPartition.RANDOM; PlanFragment planFragment = createPlanFragment(scanNode, dataPartition, fileScan); context.addPlanFragment(planFragment); + scanNode.setDistributeExprLists(getDistributeExpr(fileScan)); updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), fileScan); return planFragment; } @@ -1014,6 +1020,7 @@ private PlanFragment computePhysicalOlapScan(PhysicalOlapScan olapScan, PlanTran context.getScanContext()); olapScanNode.setNereidsId(olapScan.getId()); context.getNereidsIdToPlanNodeIdMap().put(olapScan.getId(), olapScanNode.getId()); + olapScanNode.setDistributeExprLists(getDistributeExpr(olapScan)); // translate score topn info if (!olapScan.getScoreOrderKeys().isEmpty()) { @@ -1189,6 +1196,8 @@ public PlanFragment visitPhysicalOneRowRelation(PhysicalOneRowRelation oneRowRel PlanFragment planFragment = createPlanFragment(unionNode, DataPartition.UNPARTITIONED, oneRowRelation); context.addPlanFragment(planFragment); updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), oneRowRelation); + + unionNode.setDistributeExprLists(getDistributeExpr(oneRowRelation)); return planFragment; } @@ -1221,6 +1230,7 @@ public PlanFragment visitPhysicalSchemaScan(PhysicalSchemaScan schemaScan, PlanT context.addScanNode(scanNode, schemaScan); PlanFragment planFragment = createPlanFragment(scanNode, DataPartition.RANDOM, schemaScan); context.addPlanFragment(planFragment); + scanNode.setDistributeExprLists(getDistributeExpr(schemaScan)); updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), schemaScan); return planFragment; } @@ -1237,6 +1247,7 @@ public PlanFragment visitPhysicalWorkTableReference(PhysicalWorkTableReference w PlanFragment planFragment = createPlanFragment(scanNode, DataPartition.RANDOM, workTableReference); context.addPlanFragment(planFragment); + scanNode.setDistributeExprLists(getDistributeExpr(workTableReference)); updateLegacyPlanIdToPhysicalPlan(planFragment.getPlanRoot(), workTableReference); return planFragment; } @@ -1263,6 +1274,7 @@ public PlanFragment visitPhysicalTVFRelation(PhysicalTVFRelation tvfRelation, Pl ((SlotReference) slot).getOriginalColumn().map(Column::getName).orElse(slot.getName())); } } + scanNode.setDistributeExprLists(getDistributeExpr(tvfRelation)); scanNode.setNereidsId(tvfRelation.getId()); context.getNereidsIdToPlanNodeIdMap().put(tvfRelation.getId(), scanNode.getId()); Utils.execWithUncheckedException(scanNode::init); @@ -1382,6 +1394,7 @@ public PlanFragment visitPhysicalHashAggregate( AggregationNode aggregationNode = new AggregationNode(context.nextPlanNodeId(), inputPlanFragment.getPlanRoot(), aggInfo); + aggregationNode.setDistributeExprLists(getDistributeExpr(aggregate)); aggregationNode.setChildrenDistributeExprLists(distributeExprLists); aggregationNode.setNereidsId(aggregate.getId()); @@ -1512,6 +1525,7 @@ public PlanFragment visitPhysicalAssertNumRows(PhysicalAssertNumRows gener .collect(Collectors.toCollection(ArrayList::new)); TableFunctionNode tableFunctionNode = new TableFunctionNode(context.nextPlanNodeId(), currentFragment.getPlanRoot(), tupleDescriptor.getId(), functionCalls, outputSlotIds, conjuncts); + tableFunctionNode.setDistributeExprLists(getDistributeExpr(generate)); tableFunctionNode.setNereidsId(generate.getId()); context.getNereidsIdToPlanNodeIdMap().put(generate.getId(), tableFunctionNode.getId()); addPlanRoot(currentFragment, tableFunctionNode, generate); @@ -1759,7 +1775,8 @@ public PlanFragment visitPhysicalHashJoin( // NOTICE: We must visit from right to left, to ensure the last fragment is root fragment PlanFragment rightFragment = hashJoin.child(1).accept(this, context); PlanFragment leftFragment = hashJoin.child(0).accept(this, context); - List> distributeExprLists = getDistributeExprs(physicalHashJoin.left(), physicalHashJoin.right()); + List> distributeExprLists + = getDistributeExprs(physicalHashJoin.left(), physicalHashJoin.right()); if (JoinUtils.shouldNestedLoopJoin(hashJoin)) { throw new RuntimeException("Physical hash join could not execute without equal join condition."); @@ -1810,6 +1827,7 @@ public PlanFragment visitPhysicalHashJoin( markConjuncts, null, null, null, hashJoin.isMarkJoin()); hashJoinNode.setNereidsId(hashJoin.getId()); context.getNereidsIdToPlanNodeIdMap().put(hashJoin.getId(), hashJoinNode.getId()); + hashJoinNode.setDistributeExprLists(getDistributeExpr(hashJoin)); hashJoinNode.setChildrenDistributeExprLists(distributeExprLists); PlanFragment currentFragment = connectJoinNode(hashJoinNode, leftFragment, rightFragment, context, hashJoin); @@ -2049,7 +2067,8 @@ public PlanFragment visitPhysicalNestedLoopJoin( // PhysicalPlan plan, PlanVisitor visitor, Context context). PlanFragment rightFragment = nestedLoopJoin.child(1).accept(this, context); PlanFragment leftFragment = nestedLoopJoin.child(0).accept(this, context); - List> distributeExprLists = getDistributeExprs(nestedLoopJoin.child(0), nestedLoopJoin.child(1)); + List> distributeExprLists + = getDistributeExprs(nestedLoopJoin.child(0), nestedLoopJoin.child(1)); PlanNode leftFragmentPlanRoot = leftFragment.getPlanRoot(); PlanNode rightFragmentPlanRoot = rightFragment.getPlanRoot(); @@ -2070,6 +2089,7 @@ public PlanFragment visitPhysicalNestedLoopJoin( null, null, null, nestedLoopJoin.isMarkJoin()); nestedLoopJoinNode.setNereidsId(nestedLoopJoin.getId()); context.getNereidsIdToPlanNodeIdMap().put(nestedLoopJoin.getId(), nestedLoopJoinNode.getId()); + nestedLoopJoinNode.setDistributeExprLists(getDistributeExpr(nestedLoopJoin)); nestedLoopJoinNode.setChildrenDistributeExprLists(distributeExprLists); if (nestedLoopJoin.getStats() != null) { nestedLoopJoinNode.setCardinality((long) nestedLoopJoin.getStats().getRowCount()); @@ -2277,6 +2297,7 @@ public PlanFragment visitPhysicalPartitionTopN(PhysicalPartitionTopN> distributeExprLists = getDistributeExprs(partitionTopN.child(0)); PartitionSortNode partitionSortNode = translatePartitionSortNode( partitionTopN, inputFragment.getPlanRoot(), context); + partitionSortNode.setDistributeExprLists(getDistributeExpr(partitionTopN)); partitionSortNode.setChildrenDistributeExprLists(distributeExprLists); addPlanRoot(inputFragment, partitionSortNode, partitionTopN); // in pipeline engine, we use parallel scan by default, but it broke the rule of data distribution @@ -2477,6 +2498,7 @@ public PlanFragment visitPhysicalRecursiveUnion(PhysicalRecursiveUnion> resultExpressionLists = Lists.newArrayList(); @@ -2660,6 +2683,7 @@ public PlanFragment visitPhysicalQuickSort(PhysicalQuickSort sor if (!sort.getSortPhase().isMerge()) { // For localSort or Gather->Sort, we just need to add sortNode SortNode sortNode = translateSortNode(sort, inputFragment.getPlanRoot(), context); + sortNode.setDistributeExprLists(getDistributeExpr(sort)); sortNode.setChildrenDistributeExprLists(distributeExprLists); addPlanRoot(inputFragment, sortNode, sort); } else { @@ -2676,6 +2700,7 @@ public PlanFragment visitPhysicalQuickSort(PhysicalQuickSort sor inputFragment.getChild(0).getSink().setMerge(true); } sortNode.setMergeByExchange(); + sortNode.setDistributeExprLists(getDistributeExpr(sort)); sortNode.setChildrenDistributeExprLists(distributeExprLists); } return inputFragment; @@ -2724,6 +2749,9 @@ public PlanFragment visitPhysicalTopN(PhysicalTopN topN, PlanTra } } } + sortNode.setDistributeExprLists( + CollectionUtils.isEmpty(distributeExprLists) ? null : distributeExprLists.get(0) + ); sortNode.setChildrenDistributeExprLists(distributeExprLists); addPlanRoot(inputFragment, sortNode, topN); } else { @@ -2737,6 +2765,9 @@ public PlanFragment visitPhysicalTopN(PhysicalTopN topN, PlanTra return inputFragment; } ExchangeNode exchangeNode = (ExchangeNode) inputFragment.getPlanRoot(); + exchangeNode.setDistributeExprLists( + CollectionUtils.isEmpty(distributeExprLists) ? null : distributeExprLists.get(0) + ); exchangeNode.setChildrenDistributeExprLists(distributeExprLists); exchangeNode.setMergeInfo(((SortNode) exchangeNode.getChild(0)).getSortInfo()); if (inputFragment.hasChild(0) && inputFragment.getChild(0).getSink() != null) { @@ -2819,6 +2850,7 @@ public PlanFragment visitPhysicalRepeat(PhysicalRepeat repeat, P allSlotId, repeat.computeGroupingFunctionsValues()); repeatNode.setNereidsId(repeat.getId()); context.getNereidsIdToPlanNodeIdMap().put(repeat.getId(), repeatNode.getId()); + repeatNode.setDistributeExprLists(getDistributeExpr(repeat)); repeatNode.setChildrenDistributeExprLists(distributeExprLists); addPlanRoot(inputPlanFragment, repeatNode, repeat); updateLegacyPlanIdToPhysicalPlan(inputPlanFragment.getPlanRoot(), repeat); @@ -2912,6 +2944,7 @@ public PlanFragment visitPhysicalWindow(PhysicalWindow physicalW ); analyticEvalNode.setNereidsId(physicalWindow.getId()); context.getNereidsIdToPlanNodeIdMap().put(physicalWindow.getId(), analyticEvalNode.getId()); + analyticEvalNode.setDistributeExprLists(getDistributeExpr(physicalWindow)); analyticEvalNode.setChildrenDistributeExprLists(distributeExprLists); PlanNode root = inputPlanFragment.getPlanRoot(); if (root instanceof SortNode) { @@ -2949,6 +2982,7 @@ public PlanFragment visitPhysicalLazyMaterialize(PhysicalLazyMaterialize rowStoreFlags = new ArrayList<>(); for (Relation relation : materialize.getRelations()) { @@ -3549,15 +3583,25 @@ private boolean findOlapScanNodesByPassExchangeNode(PlanNode root) { return false; } - private List> getDistributeExprs(Plan ... children) { + private List> getDistributeExprs(Plan... plans) { List> distributeExprLists = Lists.newArrayList(); - for (Plan child : children) { + for (Plan child : plans) { DistributionSpec spec = ((PhysicalPlan) child).getPhysicalProperties().getDistributionSpec(); distributeExprLists.add(getDistributeExpr(child.getOutputExprIds(), spec)); } return distributeExprLists; } + private List getDistributeExpr(PhysicalPlan physicalPlan) { + // physicalProperties is set during property derivation; guard against translator-only + // paths and test fixtures that bypass the derivation step. + PhysicalProperties props = physicalPlan.getPhysicalProperties(); + if (props == null) { + return Lists.newArrayList(); + } + return getDistributeExpr(physicalPlan.getOutputExprIds(), props.getDistributionSpec()); + } + private List getDistributeExpr(List childOutputIds, DistributionSpec spec) { if (spec instanceof DistributionSpecHash) { DistributionSpecHash distributionSpecHash = (DistributionSpecHash) spec; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java index 55496fa2ac5c98..d7624c918192c7 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java @@ -119,6 +119,27 @@ public class PlanTranslatorContext { private final Map> statsUnknownColumnsMap = Maps.newHashMap(); private final RuntimeFilterContextV2 runtimeFilterV2Context; + // Per-node "is there a serial operator between me and the pipeline's sink" flag. + // Mirrors BE's any_of(operators[idx..end], is_serial_operator) check used by + // _add_local_exchange / need_to_local_exchange to skip LE insertion when an ancestor + // in the same pipeline is already serial (the whole pipeline runs with 1 task, so an + // extra LE would be a no-op). Written by AddLocalExchange entry + PlanNode.enforceRequire + // step 1 (root → leaf during traversal). Read by PlanNode.enforceRequire step 4 (Layer 1 + // skip) and by child overrides that compute their require. Reset to false at fragment + // root and across pipeline boundaries (see shouldResetSerialFlagForChild). + private final Map serialAncestorInPipelineMap = Maps.newHashMap(); + + // Per-node "is there a downstream operator that depends on hash distribution for + // correctness, with HASH/NOOP path connecting it to me" flag. Mirrors BE's + // _followed_by_shuffled_operator propagation in pipeline_fragment_context.cpp. + // Written by PlanNode.enforceRequire step 1b (root → leaf). Read by SetOperationNode + // to decide whether to propagate hash requirement to its inputs (only when downstream + // needs shuffle for correctness, not just for performance like StreamingAgg pre-agg). + private final Map shuffledAncestorMap = Maps.newHashMap(); + + // Whether the current fragment uses LocalShuffleAssignedJob (pooling scan with + // ignoreDataDistribution → _parallel_instances=1 in BE). When true, serial operators + // indicate real pipeline bottlenecks needing PASSTHROUGH fan-out (heavy_ops). private boolean isTopMaterializeNode = true; private final Set virtualColumnIds = Sets.newHashSet(); @@ -248,6 +269,22 @@ public PlanNodeId nextPlanNodeId() { return nodeIdGenerator.getNextId(); } + public void setHasSerialAncestorInPipeline(PlanNode node, boolean hasSerialAncestorInPipeline) { + serialAncestorInPipelineMap.put(node.getId(), hasSerialAncestorInPipeline); + } + + public boolean hasSerialAncestorInPipeline(PlanNode node) { + return serialAncestorInPipelineMap.getOrDefault(node.getId(), false); + } + + public void setHasShuffleForCorrectnessAncestor(PlanNode node, boolean value) { + shuffledAncestorMap.put(node.getId(), value); + } + + public boolean hasShuffleForCorrectnessAncestor(PlanNode node) { + return shuffledAncestorMap.getOrDefault(node.getId(), false); + } + public SlotDescriptor addSlotDesc(TupleDescriptor t) { return descTable.addSlotDescriptor(t); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java index 776d18d1dad2ce..5af5c754ef58e9 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/PlanType.java @@ -143,6 +143,7 @@ public enum PlanType { PHYSICAL_RECURSIVE_CTE, PHYSICAL_RECURSIVE_CTE_RECURSIVE_CHILD, PHYSICAL_DISTRIBUTE, + PHYSICAL_LOCAL_DISTRIBUTE, PHYSICAL_EXCEPT, PHYSICAL_FILTER, PHYSICAL_GENERATE, diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java index 20f21f5b8b2f2a..9dad84f9dcc3fc 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java @@ -82,7 +82,9 @@ import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor; import org.apache.doris.nereids.util.RelationUtil; import org.apache.doris.planner.DataSink; +import org.apache.doris.planner.LocalExchangeNode; import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PlanNode; import org.apache.doris.qe.ConnectContext; import org.apache.doris.qe.ConnectContext.ConnectType; import org.apache.doris.qe.Coordinator; @@ -645,8 +647,18 @@ private void applyInsertPlanStatistic(FastInsertIntoValuesPlanner planner) { return; } for (PlanFragment fragment : planner.getFragments()) { - if (fragment.getPlanRoot() instanceof FileScanNode) { - FileScanNode fileScanNode = (FileScanNode) fragment.getPlanRoot(); + // The FE local-shuffle planner may wrap the fragment root with one or more + // LocalExchangeNodes (e.g. a PASSTHROUGH fan-out above a serial FileScanNode). + // Peel those off before checking the actual operator, otherwise streaming / + // S3 INSERTs leave LoadStatistic.fileNum and totalFileSizeB at 0 and tests + // like job_p0.streaming_job.test_streaming_insert_job that inspect + // loadStatistic.fileNumber / fileSize fail. + PlanNode root = fragment.getPlanRoot(); + while (root instanceof LocalExchangeNode && !root.getChildren().isEmpty()) { + root = root.getChild(0); + } + if (root instanceof FileScanNode) { + FileScanNode fileScanNode = (FileScanNode) root; // Prefer distinct file count; fall back to split count for batch-mode scans. int fileNum = fileScanNode.getSelectedFileNum() >= 0 ? fileScanNode.getSelectedFileNum() diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java index 4a75e46fdeddee..9a6782e95a7044 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java @@ -264,7 +264,7 @@ private Pair splitSerialScanSource(BucketSca Map> serialScanRanges = Maps.newLinkedHashMap(); Map> nonSerialScanRanges = Maps.newLinkedHashMap(); for (ScanNode scanNode : scanNodes) { - if (scanNode.isSerialOperator()) { + if (scanNode.isSerialNode()) { collectScanRanges(totalScanSource, scanNode, serialScanRanges); } else { collectScanRanges(totalScanSource, scanNode, nonSerialScanRanges); diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java new file mode 100644 index 00000000000000..e1d607ea61551d --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java @@ -0,0 +1,152 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.planner; + +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.nereids.trees.plans.distribute.DistributedPlan; +import org.apache.doris.nereids.trees.plans.distribute.FragmentIdMapping; +import org.apache.doris.nereids.trees.plans.distribute.PipelineDistributedPlan; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; +import org.apache.doris.planner.LocalExchangeNode.RequireHash; + +/** + * FE-side local exchange planner — inserts {@link LocalExchangeNode} into each fragment's + * plan tree so that within-fragment data redistribution is decided at planning time + * instead of at BE pipeline-build time. + * + *

When this runs

+ * Invoked from {@code NereidsPlanner.addLocalExchangeAfterDistribute()} right after + * {@code DistributePlanner} has assigned instances to fragments and before the plan is + * serialized to BE. Gated by session variable {@code enable_local_shuffle_planner} + * (default true) and {@code enable_local_shuffle}; when either is off this pass is + * skipped entirely and BE falls back to its own {@code _plan_local_exchange}. The two + * paths are mutually exclusive: BE consults {@code runtime_state.h::plan_local_shuffle()} + * to know whether it should plan LE itself. + * + *

What it changes

+ *
    + *
  • For each fragment with {@code maxPerBeInstances > 1}, walks the plan tree + * bottom-up via {@link PlanNode#enforceAndDeriveLocalExchange} and inserts + * LocalExchangeNodes where children's output distribution doesn't satisfy the + * parent's requirement.
  • + *
  • May wrap the fragment root with an extra PASSTHROUGH LE so the data sink + * (DataStreamSink / OlapTableSink) runs with the full instance count even when + * the root operator is serial — see {@link #addLocalExchangeForFragment}.
  • + *
  • Does NOT modify the fragment sink itself, fragment boundaries, or instance + * assignment.
  • + *
+ * + *

Per-BE instance semantics

+ * Skips fragments where every BE has at most 1 instance. Using a global instance count + * would insert LE for "2 BEs × 1 instance" cases, which BE's own + * {@code _plan_local_exchange} would not — leading to pipeline task-count mismatch and + * deadlock. See {@link #addLocalExchange}. + * + *

Reading order

+ * Start with {@link PlanNode#enforceRequire} (the recursion engine), then individual + * {@code enforceAndDeriveLocalExchange} overrides on PlanNode subclasses. + */ +public class AddLocalExchange { + /** addLocalExchange with distributed plans, skipping single-instance fragments. + * BE's _plan_local_exchange checks _num_instances which is the per-BE instance count. + * With _num_instances<=1 all pipelines on that BE have 1 task so local exchange is a no-op. + * We must use the same per-BE semantics: skip when every BE has at most 1 instance. + * Using global instanceCount would insert LE for fragments where 2 BEs each have 1 instance + * (global=2, per-BE=1), causing pipeline task mismatch and deadlock. */ + public void addLocalExchange(FragmentIdMapping distributedPlans, + PlanTranslatorContext context) { + for (DistributedPlan plan : distributedPlans.values()) { + PipelineDistributedPlan pipePlan = (PipelineDistributedPlan) plan; + long maxPerBeInstances = pipePlan.getInstanceJobs().stream() + .collect(java.util.stream.Collectors.groupingBy( + j -> j.getAssignedWorker().id(), java.util.stream.Collectors.counting())) + .values().stream().mapToLong(Long::longValue).max().orElse(0); + if (maxPerBeInstances <= 1) { + continue; + } + PlanFragment fragment = pipePlan.getFragmentJob().getFragment(); + addLocalExchangeForFragment(fragment, context); + } + } + + private void addLocalExchangeForFragment(PlanFragment fragment, PlanTranslatorContext context) { + DataSink sink = fragment.getSink(); + LocalExchangeTypeRequire require = sink == null + ? LocalExchangeTypeRequire.noRequire() : sink.getLocalExchangeTypeRequire(); + PlanNode root = fragment.getPlanRoot(); + context.setHasSerialAncestorInPipeline(root, false); + Pair output = root + .enforceAndDeriveLocalExchange(context, null, require); + PlanNode newRoot = output.first; + // The fragment data sink (DataStreamSink, OlapTableSink) runs in the same pipeline + // as the root. If the root will be serial on BE, the sink pipeline has 1 task — + // only instance 0 sends data, others hang or miss writes. + // Insert PASSTHROUGH fan-out so sink runs with _num_instances tasks. + // This matches BE-native's default required_data_distribution(): + // _child->is_serial_operator() ? PASSTHROUGH : NOOP + if (newRoot.isSerialOperatorOnBe(context.getConnectContext())) { + newRoot = new LocalExchangeNode(context.nextPlanNodeId(), newRoot, + LocalExchangeType.PASSTHROUGH, null); + } + if (newRoot != root) { + fragment.setPlanRoot(newRoot); + } + } + + public static boolean isColocated(PlanNode plan) { + if (plan instanceof AggregationNode) { + return ((AggregationNode) plan).isColocate() && isColocated(plan.getChild(0)); + } else if (plan instanceof OlapScanNode) { + return true; + } else if (plan instanceof SelectNode) { + return isColocated(plan.getChild(0)); + } else if (plan instanceof HashJoinNode) { + return ((HashJoinNode) plan).isColocate() + && (isColocated(plan.getChild(0)) || isColocated(plan.getChild(1))); + } else if (plan instanceof SetOperationNode) { + if (!((SetOperationNode) plan).isColocate()) { + return false; + } + for (PlanNode child : plan.getChildren()) { + if (isColocated(child)) { + return true; + } + } + return false; + } else { + return false; + } + } + + public static LocalExchangeType resolveExchangeType(LocalExchangeTypeRequire require) { + // Only generic RequireHash adapts to LOCAL_EXECUTION_HASH_SHUFFLE. + // Explicit RequireSpecific (GLOBAL_EXECUTION_HASH_SHUFFLE, BUCKET_HASH_SHUFFLE, etc.) + // must never be degraded — if they appear in an invalid context, the plan is wrong. + // + // Always prefer LOCAL_EXECUTION_HASH_SHUFFLE for FE-planned intra-fragment hash exchanges. + // GLOBAL_EXECUTION_HASH_SHUFFLE requires shuffle_idx_to_instance_idx which may be empty + // for fragments with non-hash sinks (UNPARTITIONED/MERGE). LOCAL_HASH is always safe + // since it partitions by local instance count without needing external shuffle maps. + if (require instanceof RequireHash) { + return LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE; + } + return require.preferType(); + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java index 1f475cc85fe406..8bd8a87b69899d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java @@ -26,7 +26,13 @@ import org.apache.doris.analysis.SlotDescriptor; import org.apache.doris.analysis.SlotRef; import org.apache.doris.analysis.SortInfo; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.planner.normalize.Normalizer; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TAggregationNode; import org.apache.doris.thrift.TExplainLevel; @@ -263,7 +269,7 @@ public String getNodeExplainString(String detailPrefix, TExplainLevel detailLeve // If `GroupingExprs` is empty and agg need to finalize, the result must be output by single instance @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return aggInfo.getGroupingExprs().isEmpty() && needsFinalize; } @@ -271,6 +277,10 @@ public void setColocate(boolean colocate) { isColocate = colocate; } + public boolean isColocate() { + return isColocate; + } + public void setSortByGroupKey(SortInfo sortByGroupKey) { this.sortByGroupKey = sortByGroupKey; } @@ -282,4 +292,160 @@ public boolean isQueryCacheCandidate() { public void setQueryCacheCandidate(boolean queryCacheCandidate) { this.queryCacheCandidate = queryCacheCandidate; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + + ConnectContext connectContext = translatorContext.getConnectContext(); + SessionVariable sessionVariable = connectContext.getSessionVariable(); + // PR #62438: when false, non-finalize agg falls back to BE base class. + boolean enableLeBeforeAgg = sessionVariable.enableLocalExchangeBeforeAgg; + boolean hasKeys = !aggInfo.getGroupingExprs().isEmpty(); + + // Each branch mirrors the corresponding BE operator's required_data_distribution() + // check order 1:1. The helper baseClassRequire() expands BE's base class behavior. + LocalExchangeTypeRequire requireChild; + if (canUseDistinctStreamingAgg(sessionVariable)) { + // DistinctStreamingAggOperatorX. Two flavors share this operator class: + // - streaming preagg (useStreamingPreagg=true): performance-only, + // flag controls + // - non-streaming dedup (useStreamingPreagg=false): correctness-required, + // always HASH regardless of flag + // Diverges from BE: BE's `!_needs_finalize && !enable_local_exchange_before_agg` + // early return catches non-streaming dedup too, causing the same family of + // wrong-result bug as AggSink (DORIS-25413). + if (needsFinalize && !hasKeys) { + requireChild = LocalExchangeTypeRequire.noRequire(); + } else if (!needsFinalize && useStreamingPreagg && !enableLeBeforeAgg) { + requireChild = baseClassRequire(connectContext); + } else if (needsFinalize || (hasKeys && !useStreamingPreagg)) { + requireChild = AddLocalExchange.isColocated(this) + ? LocalExchangeTypeRequire.requireHash() + : parentRequire.autoRequireHash(); + } else if (sessionVariable.enableDistinctStreamingAggForcePassthrough) { + requireChild = LocalExchangeTypeRequire.requirePassthrough(); + } else { + requireChild = baseClassRequire(connectContext); + } + } else if (useStreamingPreagg) { + // StreamingAggOperatorX + if (children.get(0) instanceof HashJoinNode + && sessionVariable.enableStreamingAggHashJoinForcePassthrough) { + requireChild = LocalExchangeTypeRequire.requirePassthrough(); + } else if (!needsFinalize && !enableLeBeforeAgg) { + requireChild = baseClassRequire(connectContext); + } else if (!hasKeys) { + requireChild = needsFinalize + ? LocalExchangeTypeRequire.noRequire() + : baseClassRequire(connectContext); + } else { + requireChild = LocalExchangeTypeRequire.requireHash(); + } + } else { + // AggSinkOperatorX — covers finalize phase AND non-finalize phases (LOCAL + // preagg / FIRST_MERGE dedup). Streaming preagg goes through the StreamingAgg + // branch above, not here. + // + // Phase semantics for !needsFinalize: + // - FIRST / SECOND (LOCAL phase, !isMerge): performance-only, flag controls + // - FIRST_MERGE (correctness-required): always HASH regardless of flag + // + // Diverges from BE here: BE's `!_needs_finalize && !enable_local_exchange_before_agg` + // early return also catches FIRST_MERGE, dropping the HASH requirement and + // causing wrong-result (e.g. PASSTHROUGH over serial child breaks the + // group-by-key invariant — DORIS-25413). + if (!hasKeys) { + requireChild = needsFinalize + ? LocalExchangeTypeRequire.noRequire() + : baseClassRequire(connectContext); + } else if (!needsFinalize && !aggInfo.isMerge() && !enableLeBeforeAgg) { + // LOCAL phase (FIRST preagg / SECOND distinct local) + user opted out + // of pre-agg LE → base class decides: serial child → PASSTHROUGH + // (parallelism), non-serial child → NOOP (no LE). + requireChild = baseClassRequire(connectContext); + } else if (!needsFinalize || AddLocalExchange.isColocated(this)) { + // FIRST_MERGE (correctness) or finalize+colocate → HASH. + requireChild = parentRequire.autoRequireHash(); + } else if (hasPartitionExprs(parentRequire)) { + // FE-only heuristic: finalize non-colocate with parent hash requirement + // → inherit parent's specific hash type. + requireChild = parentRequire.autoRequireHash(); + } else { + // FE-only heuristic: finalize non-colocate without parent hash → skip + // LE (child Exchange already provides hash distribution). + requireChild = LocalExchangeTypeRequire.noRequire(); + } + } + + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, requireChild); + children = Lists.newArrayList(enforceResult.first); + return Pair.of(this, enforceResult.second); + } + + /** BE base class required_data_distribution: serial child → PASSTHROUGH, else → NOOP. */ + private LocalExchangeTypeRequire baseClassRequire(ConnectContext connectContext) { + return children.get(0).isSerialOperatorOnBe(connectContext) + ? LocalExchangeTypeRequire.requirePassthrough() + : LocalExchangeTypeRequire.noRequire(); + } + + @Override + protected List getSemanticPartitionExprs() { + return aggInfo.getGroupingExprs(); + } + + @Override + protected List getLocalExchangeDistributeExprs(int childIndex, boolean followedByShuffled) { + // Mirror BE AggSinkOperatorX::update_operator / StreamingAggOperatorX::update_operator: + // _partition_exprs = (distribute_expr_lists set && (followed_by_shuffled || has_distinct)) + // ? distribute_expr_lists[0] : grouping_exprs + // The HASH LocalExchange must partition by _partition_exprs so a streaming partial preagg + // locally collapses same-key rows. Using child distribution (default) for a non-shuffled + // chain scatters same-group rows across N instances, leaving partial_preagg essentially a + // no-op and breaking row-arrival order at downstream merge-finalize (e.g. group_concat). + List childDist = getChildDistributeExprList(childIndex); + // Multi-distinct aggregates are detected by function name. Nereids rewrites + // count/sum(distinct ...) into dedicated MultiDistinct* functions constructed with + // distinct=false and a "multi_distinct_" name, so by this legacy FunctionCallExpr layer + // isDistinct() is already false and the function name is the only remaining signal — + // there is no structural flag to test here. + boolean hasDistinct = aggInfo.getAggregateExprs().stream() + .map(FunctionCallExpr::getFnName) + .filter(name -> name != null) + .map(name -> name.getFunction()) + .filter(name -> name != null) + .anyMatch(name -> name.startsWith("multi_distinct_")); + if (childDist != null && !childDist.isEmpty() && (followedByShuffled || hasDistinct)) { + return childDist; + } + return Lists.newArrayList(aggInfo.getGroupingExprs()); + } + + @Override + public boolean requiresShuffleForCorrectness() { + // Mirrors BE's AggSinkOperatorX::is_shuffled_operator() exactly: + // finalize agg with group keys needs hash-distributed input for correctness. + // GLOBAL dedup (!needsFinalize) is intentionally NOT included here — if a + // GLOBAL dedup exists, a finalize agg always sits above it (e.g. DISTINCT_GLOBAL + // above DISTINCT_LOCAL/GLOBAL_DEDUP), and the finalize agg propagates the flag + // down via inheritedShuffled. A solo finalize agg satisfies hash distribution + // through its own child requirement. + return needsFinalize && !aggInfo.getGroupingExprs().isEmpty(); + } + + private boolean canUseDistinctStreamingAgg(SessionVariable sessionVariable) { + return aggInfo.getAggregateExprs().isEmpty() && sortByGroupKey == null + && sessionVariable.enableDistinctStreamingAggregation; + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + // Non-streaming AGG is a pipeline breaker: child is in AGG_Sink pipeline, + // parent is in AGG_Source pipeline. Reset inherited serial flag from parent + // (different pipeline), but enforceRequire still adds this node's own + // isSerialNode() so the child sees AGG_Sink's serial status correctly. + return !useStreamingPreagg; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java index 5184b2190008ed..063abb43907c9c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java @@ -25,6 +25,10 @@ import org.apache.doris.analysis.ExprSubstitutionMap; import org.apache.doris.analysis.OrderByElement; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TAnalyticNode; import org.apache.doris.thrift.TExplainLevel; @@ -220,7 +224,85 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { * all data should be input in this node to ensure the global ordering by colB. */ @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return partitionExprs.isEmpty(); } + + /** + * Mirrors BE's + * {@code AnalyticSinkOperatorX::is_shuffled_operator() = !_partition_by_eq_expr_ctxs.empty()} + * (be/src/exec/operator/analytic_sink_operator.h:226). With PARTITION BY, input must be + * hash-partitioned by partition keys, so downstream UnionNode / SetOperationNode under + * us must pre-shuffle their branches to match — the framework propagates this through + * {@link PlanTranslatorContext#hasShuffleForCorrectnessAncestor}. + */ + @Override + public boolean requiresShuffleForCorrectness() { + return !partitionExprs.isEmpty(); + } + + @Override + protected List getSemanticPartitionExprs() { + return partitionExprs; + } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + LocalExchangeTypeRequire requireChild; + LocalExchangeType outputType = null; + if (partitionExprs.isEmpty()) { + // Serial AnalyticEval (OVER() with no PARTITION BY): + // Must NOT have any LocalExchange between AnalyticEval and its child. + // On BE, AnalyticSink and AnalyticSource share state (source_deps/sink_deps). + // A LocalExchange below would restore the AnalyticSink pipeline to _num_instances + // tasks while the serial AnalyticSource pipeline stays at 1 task. + // + // Use enforceRequire with noRequire to traverse children, then strip any + // LocalExchange the child inserted (e.g., Exchange wrapping itself with PASSTHROUGH). + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, LocalExchangeTypeRequire.noRequire()); + PlanNode newChild = enforceResult.first; + if (newChild instanceof LocalExchangeNode) { + newChild = newChild.getChild(0); + } + children = Lists.newArrayList(newChild); + // Return NOOP: the serial AnalyticSource pipeline has 1 task, we don't provide + // fan-out ourselves. The parent's enforceRequire framework-level serial check + // will see our serial status and insert PASSTHROUGH LE above us if needed. + return Pair.of(this, LocalExchangeType.NOOP); + } else if (orderByElements.isEmpty()) { + if (AddLocalExchange.isColocated(this)) { + requireChild = LocalExchangeTypeRequire.requireHash(); + outputType = AddLocalExchange.resolveExchangeType( + LocalExchangeTypeRequire.requireHash()); + } else { + // Non-colocated analytic with PARTITION BY but no ORDER BY: + // The parent SortNode (mergeByExchange) will insert PASSTHROUGH above us, + // which is what BE does natively. Don't force a hash exchange here. + requireChild = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } + } else if (children.get(0).isSerialOperatorOnBe(translatorContext.getConnectContext())) { + // BE base class: _child->is_serial_operator() ? PASSTHROUGH : NOOP + requireChild = LocalExchangeTypeRequire.requirePassthrough(); + outputType = LocalExchangeType.PASSTHROUGH; + } else { + requireChild = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } + + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, requireChild); + children = Lists.newArrayList(enforceResult.first); + if (outputType == null) { + outputType = enforceResult.second; + } + return Pair.of(this, outputType); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AssertNumRowsNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AssertNumRowsNode.java index 210743b94011d9..ed16516d38ade4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AssertNumRowsNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AssertNumRowsNode.java @@ -19,12 +19,18 @@ import org.apache.doris.analysis.AssertNumRowsElement; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TAssertNumRowsNode; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; import org.apache.doris.thrift.TPlanNodeType; +import com.google.common.collect.Lists; + /** * Assert num rows node is used to determine whether the number of rows is less than desired num of rows. * The rows are the result of subqueryString. @@ -89,7 +95,17 @@ public int getNumInstances() { } @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return true; } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + + Pair enforceResult = enforceRequire( + translatorContext, children.get(0), 0, LocalExchangeTypeRequire.requirePassthrough()); + children = Lists.newArrayList(enforceResult.first); + return Pair.of(this, LocalExchangeType.PASSTHROUGH); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/CTEScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/CTEScanNode.java index 7f5cf61b4c6560..ab3fd89b8a5c80 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/CTEScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/CTEScanNode.java @@ -18,7 +18,11 @@ package org.apache.doris.planner; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TPlanNode; import org.apache.doris.thrift.TScanRangeLocations; @@ -55,4 +59,10 @@ public List getScanRangeLocations(long maxScanRangeLength) // NO real action to be taken, just a wrapper return null; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + return Pair.of(this, LocalExchangeType.NOOP); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/DataSink.java b/fe/fe-core/src/main/java/org/apache/doris/planner/DataSink.java index 1c55def772c475..3754c7270672f0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/DataSink.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/DataSink.java @@ -28,6 +28,7 @@ import org.apache.doris.datasource.iceberg.IcebergExternalTable; import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable; import org.apache.doris.datasource.odbc.sink.OdbcTableSink; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.thrift.TDataSink; import org.apache.doris.thrift.TExplainLevel; @@ -89,4 +90,8 @@ public boolean isMerge() { public void setMerge(boolean merge) { isMerge = merge; } + + public LocalExchangeTypeRequire getLocalExchangeTypeRequire() { + return LocalExchangeTypeRequire.noRequire(); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/EmptySetNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/EmptySetNode.java index 4b880c1562fdf1..aa26eebe5ee7f1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/EmptySetNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/EmptySetNode.java @@ -18,6 +18,10 @@ package org.apache.doris.planner; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TPlanNode; import org.apache.doris.thrift.TPlanNodeType; @@ -49,4 +53,10 @@ protected void toThrift(TPlanNode msg) { public int getNumInstances() { return 1; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + return Pair.of(this, LocalExchangeType.NOOP); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ExceptNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ExceptNode.java index ae728da571b6e7..1052b6bd1184ff 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ExceptNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ExceptNode.java @@ -30,4 +30,10 @@ public ExceptNode(PlanNodeId id, TupleId tupleId) { protected void toThrift(TPlanNode msg) { toThrift(msg, TPlanNodeType.EXCEPT_NODE); } + + @Override + public boolean requiresShuffleForCorrectness() { + // BE: SetSinkOperatorX / SetSourceOperatorX.is_shuffled_operator() = true (unconditional). + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java index 69883fe798857d..793db2fcc051a0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java @@ -23,6 +23,10 @@ import org.apache.doris.analysis.SortInfo; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.qe.ConnectContext; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExchangeNode; @@ -112,10 +116,7 @@ public void setMergeInfo(SortInfo info) { @Override protected void toThrift(TPlanNode msg) { - // If this fragment has another scan node, this exchange node is serial or not should be decided by the scan - // node. - msg.setIsSerialOperator((isSerialOperator() || fragment.hasSerialScanNode()) - && fragment.useSerialSource(ConnectContext.get())); + msg.setIsSerialOperator(isSerialOperatorOnBe(ConnectContext.get())); msg.node_type = TPlanNodeType.EXCHANGE_NODE; msg.exchange_node = new TExchangeNode(); for (TupleId tid : tupleIds) { @@ -172,20 +173,45 @@ public void setRightChildOfBroadcastHashJoin(boolean value) { * because this loading job relies on the global ordering of column `k1` and `v1`. * * So FRAGMENT 0 should not use serial source. + * + * Important: this method does NOT call fragment.useSerialSource() — that path would + * recurse into hasNullAwareLeftAntiJoin and walk the entire plan tree, and was + * previously found to blow the stack on deep plans. The fragment-level gating is + * applied in {@link #isSerialOperatorOnBe} instead. */ @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return (ConnectContext.get() != null && ConnectContext.get().getSessionVariable().isUseSerialExchange() || partitionType == TPartitionType.UNPARTITIONED) && mergeInfo == null; } + @Override + public boolean isSerialOperatorOnBe(ConnectContext context) { + return fragment != null + && (isSerialNode() || fragment.hasSerialScanNode()) + && fragment.useSerialSource(context); + } + @Override public boolean hasSerialChildren() { - return isSerialOperator(); + return isSerialNode(); } @Override public boolean hasSerialScanChildren() { return false; } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + // Report actual distribution. Serial handling is done by the framework + // (enforceRequire step 2.5 overrides serial child output to NOOP). + if (partitionType == TPartitionType.HASH_PARTITIONED) { + return Pair.of(this, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + } else if (partitionType == TPartitionType.BUCKET_SHFFULE_HASH_PARTITIONED) { + return Pair.of(this, LocalExchangeType.BUCKET_HASH_SHUFFLE); + } + return Pair.of(this, LocalExchangeType.NOOP); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java index 3cb733ceb0e3e6..9d9f7b0fd4ddc4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java @@ -26,8 +26,12 @@ import org.apache.doris.analysis.JoinOperator; import org.apache.doris.analysis.SlotId; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; import org.apache.doris.info.TableRefInfo; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; import org.apache.doris.nereids.trees.expressions.ExprId; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TEqJoinCondition; import org.apache.doris.thrift.TExplainLevel; @@ -178,6 +182,19 @@ public void setColocate(boolean colocate, String reason) { colocateReason = reason; } + public boolean isColocate() { + return isColocate; + } + + @Override + public boolean requiresShuffleForCorrectness() { + // BE: HashJoinBuild/Probe.is_shuffled_operator() = PARTITIONED || BUCKET_SHUFFLE || COLOCATE. + // (BROADCAST and NONE are not shuffled — they don't depend on hash distribution.) + return distrMode == DistributionMode.PARTITIONED + || distrMode == DistributionMode.BUCKET_SHUFFLE + || isColocate; + } + public Map getHashOutputExprSlotIdMap() { return hashOutputExprSlotIdMap; } @@ -381,4 +398,96 @@ public List getOtherJoinConjuncts() { public List getMarkJoinConjuncts() { return markJoinConjuncts; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + + LocalExchangeTypeRequire probeSideRequire; + LocalExchangeTypeRequire buildSideRequire; + LocalExchangeType outputType = null; + + if (joinOp == JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN) { + buildSideRequire = probeSideRequire = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } else if (distrMode == DistributionMode.BROADCAST) { + // BE HashJoinProbeOperatorX::required_data_distribution (probe side): + // enable_broadcast_join_force_passthrough ? PASSTHROUGH + // : (_child->is_serial_operator() ? PASSTHROUGH : NOOP) + // We mirror the force-passthrough session variable to match BE. NOTE: for a + // *non-serial* probe this is currently a no-op — enforceRequire only inserts a + // PASSTHROUGH local exchange to fan a serial (1-task) source out to N tasks; an + // already-N-task source satisfies passthrough so no exchange is added (verified on + // a 4-BE cluster: identical plan and results vs BE-native, no crash). Keeping the + // check matches BE's intent and is in place should the framework later force the + // exchange; a true rebalance of a non-serial probe is a perf-only follow-up. + // getConnectContext() can be null (unit-test mocks); treat as no force. + boolean forcePassthrough = translatorContext.getConnectContext() != null + && translatorContext.getConnectContext().getSessionVariable() + .enableBroadcastJoinForcePassthrough; + boolean probeChildSerial = children.get(0).isSerialOperatorOnBe( + translatorContext.getConnectContext()); + boolean buildChildSerial = children.get(1).isSerialOperatorOnBe( + translatorContext.getConnectContext()); + boolean probePassthrough = forcePassthrough || probeChildSerial; + probeSideRequire = probePassthrough + ? LocalExchangeTypeRequire.requirePassthrough() + : LocalExchangeTypeRequire.noRequire(); + buildSideRequire = buildChildSerial + ? LocalExchangeTypeRequire.requirePassToOne() + : LocalExchangeTypeRequire.noRequire(); + // For serial or force-passthrough probe: output is PASSTHROUGH. + // For a non-serial probe without the flag: propagate the probe's distribution. + outputType = probePassthrough ? LocalExchangeType.PASSTHROUGH : null; + } else if (isColocate() || isBucketShuffle()) { + // Both probe and build sides require BUCKET_HASH_SHUFFLE: the bucket distribution + // must be preserved on both inputs. A serial child on either side is handled the + // same way (serial exchange returns NOOP → enforceRequire() inserts the LE). + probeSideRequire = LocalExchangeTypeRequire.requireBucketHash(); + // For BUCKET_SHUFFLE with serial build child: use requireBucketHash() (not + // requirePassToOne()). Unlike BROADCAST joins, BUCKET_SHUFFLE has no shared + // hash table mechanism — PASS_TO_ONE routes all data to task 0 while tasks 1..N-1 + // build empty hash tables, losing rows. BUCKET_HASH_SHUFFLE correctly distributes + // build data by bucket to match the probe side's bucket distribution. + // The serial exchange returns NOOP, so enforceRequire() will insert a + // BUCKET_HASH_SHUFFLE local exchange (with PASSTHROUGH fan-out for heavy-ops + // bottleneck avoidance). + buildSideRequire = LocalExchangeTypeRequire.requireBucketHash(); + outputType = AddLocalExchange.resolveExchangeType( + LocalExchangeTypeRequire.requireBucketHash()); + } else { + // PARTITIONED (shuffle) join: both sides enter via global hash exchange. + // Require GLOBAL specifically so that any inserted exchange uses the same + // instance mapping as the cross-fragment exchange. LOCAL hash has a different + // modulus (per-BE instance count vs total instance count) and would cause + // join mismatches (DORIS-26101). + // + // Exception: serial source (use_serial_exchange=true + pooling). The serial + // exchange sends to a single BE so shuffle_idx_to_instance_idx has only one + // entry — GLOBAL hash would route data to non-existent indices (DORIS-26120). + // Fall back to generic requireHash() which resolves to LOCAL, matching BE's + // _use_serial_source behavior. + boolean serialSource = fragment != null + && fragment.useSerialSource(translatorContext.getConnectContext()); + buildSideRequire = probeSideRequire = serialSource + ? LocalExchangeTypeRequire.requireHash() + : LocalExchangeTypeRequire.requireGlobalExecutionHash(); + outputType = null; // derived from probeResult.second below + } + + Pair probeResult = enforceRequire( + translatorContext, children.get(0), 0, probeSideRequire); + Pair buildResult = enforceRequire( + translatorContext, children.get(1), 1, buildSideRequire); + this.children = Lists.newArrayList(probeResult.first, buildResult.first); + if (outputType == null) { + outputType = probeResult.second; + } + return Pair.of(this, outputType); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return childIndex == 1; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/IntersectNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/IntersectNode.java index 89f4002ea4d33c..5d784c03152aa1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/IntersectNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/IntersectNode.java @@ -30,4 +30,10 @@ public IntersectNode(PlanNodeId id, TupleId tupleId) { protected void toThrift(TPlanNode msg) { toThrift(msg, TPlanNodeType.INTERSECT_NODE); } + + @Override + public boolean requiresShuffleForCorrectness() { + // BE: SetSinkOperatorX / SetSourceOperatorX.is_shuffled_operator() = true (unconditional). + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java new file mode 100644 index 00000000000000..9b69b87be393e9 --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java @@ -0,0 +1,357 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// This file is copied from +// https://github.com/apache/impala/blob/branch-2.9.0/fe/src/main/java/org/apache/impala/ExchangeNode.java +// and modified by Doris + +package org.apache.doris.planner; + +import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.statistics.StatisticalType; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TExpr; +import org.apache.doris.thrift.TLocalExchangeNode; +import org.apache.doris.thrift.TLocalPartitionType; +import org.apache.doris.thrift.TPlanNode; +import org.apache.doris.thrift.TPlanNodeType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** LocalExchangeNode */ +public class LocalExchangeNode extends PlanNode { + public static final String EXCHANGE_NODE = "LOCAL-EXCHANGE"; + + private LocalExchangeType exchangeType; + + /** + * use for Nereids only. + */ + public LocalExchangeNode(PlanNodeId id, PlanNode inputNode, LocalExchangeType exchangeType) { + this(id, inputNode, exchangeType, null); + } + + public LocalExchangeNode(PlanNodeId id, PlanNode inputNode, LocalExchangeType exchangeType, + List distributeExprs) { + super(id, inputNode, EXCHANGE_NODE, StatisticalType.EXCHANGE_NODE); + this.offset = 0; + this.limit = -1; + this.conjuncts = Collections.emptyList(); + this.children.add(inputNode); + this.exchangeType = exchangeType; + this.fragment = inputNode.getFragment(); + + List hashExprs = distributeExprs; + boolean isHashShuffle = (exchangeType == LocalExchangeType.BUCKET_HASH_SHUFFLE + || exchangeType == LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE + || exchangeType == LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + if (isHashShuffle && hashExprs != null && !hashExprs.isEmpty()) { + setDistributeExprLists(hashExprs); + } + TupleDescriptor outputTupleDesc = inputNode.getOutputTupleDesc(); + updateTupleIds(outputTupleDesc); + } + + public void updateTupleIds(TupleDescriptor outputTupleDesc) { + if (outputTupleDesc != null) { + clearTupleIds(); + tupleIds.add(outputTupleDesc.getId()); + } else { + clearTupleIds(); + tupleIds.addAll(getChild(0).getOutputTupleIds()); + } + } + + @Override + protected void toThrift(TPlanNode msg) { + // FE-planned LocalExchangeNode itself must stay non-serial. In the BE-planned path, + // the serial semantics belong to the upstream scan/exchange pipeline rather than the + // downstream LocalExchangeSource pipeline. Marking LocalExchangeNode as serial would + // incorrectly reduce the downstream pipeline's task count to 1. + msg.setIsSerialOperator(false); + + msg.node_type = TPlanNodeType.LOCAL_EXCHANGE_NODE; + msg.local_exchange_node = new TLocalExchangeNode(); + msg.local_exchange_node.setPartitionType(exchangeType.toThrift()); + + if (exchangeType.isHashShuffle()) { + List thriftDistributeExprLists = new ArrayList<>(); + for (Expr expr : distributeExprLists()) { + thriftDistributeExprLists.add(expr.treeToThrift()); + } + msg.local_exchange_node.setDistributeExprLists(thriftDistributeExprLists); + } + } + + private List distributeExprLists() { + if (distributeExprLists == null) { + return Collections.emptyList(); + } + return distributeExprLists; + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return prefix + "type: " + exchangeType.name() + "\n"; + } + + public LocalExchangeType getExchangeType() { + return exchangeType; + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } + + /** + * Describes what a parent operator demands of its child's output distribution. + * Returned by the parent in {@code enforceAndDeriveLocalExchange} and consumed by + * {@link PlanNode#enforceRequire}, which decides whether to insert a LocalExchangeNode. + * + *

How to pick the right require when overriding {@code enforceAndDeriveLocalExchange}: + *

    + *
  • {@link NoRequire} — "I don't care about the child's distribution". Use for + * operators whose correctness doesn't depend on partitioning (e.g. base default, + * limit, select). The framework still upgrades this to {@code requirePassthrough} + * automatically when the child turns out to be serial — see + * {@link PlanNode#enforceRequire} step 3.
  • + * + *
  • {@link RequireHash} (via {@code requireHash()}) — "I need hash-partitioned + * input, any flavour of hash will do". Accepts {@code GLOBAL_EXECUTION_HASH_SHUFFLE}, + * {@code LOCAL_EXECUTION_HASH_SHUFFLE}, and {@code BUCKET_HASH_SHUFFLE}. This is + * the right choice for shuffled correctness consumers (finalize AggSink with keys, + * partitioned HashJoin, Intersect, Except) — the upstream may already provide a + * compatible flavour and we shouldn't insert a redundant exchange.
  • + * + *
  • {@link RequireSpecific} (via {@code requirePassthrough()}, + * {@code requireBroadcast()}, {@code requireBucketHash()}, + * {@code requireGlobalExecutionHash()}, etc.) — "I need exactly this exchange type". + * Use only when the operator's correctness or efficiency hinges on that exact + * type (e.g. NLJ probe wants ADAPTIVE_PASSTHROUGH; BucketShuffle join build wants + * BUCKET_HASH_SHUFFLE). Note: PASSTHROUGH is satisfied by ADAPTIVE_PASSTHROUGH + * (superset), but other specific types require exact match.
  • + *
+ * + *

Rule of thumb: prefer {@code requireHash()} over + * {@code requireSpecific(GLOBAL_EXECUTION_HASH_SHUFFLE)} unless you genuinely need to + * reject other hash flavours. RequireSpecific is fragile because the upstream may + * legitimately output a different (still correct) hash type. + */ + public interface LocalExchangeTypeRequire { + boolean satisfy(LocalExchangeType provide); + + LocalExchangeType preferType(); + + default LocalExchangeTypeRequire autoRequireHash() { + return RequireHash.INSTANCE; + } + + static NoRequire noRequire() { + return NoRequire.INSTANCE; + } + + static RequireHash requireHash() { + return RequireHash.INSTANCE; + } + + static RequireSpecific requirePassthrough() { + return requireSpecific(LocalExchangeType.PASSTHROUGH); + } + + static RequireSpecific requirePassToOne() { + return requireSpecific(LocalExchangeType.PASS_TO_ONE); + } + + static RequireSpecific requireBroadcast() { + return requireSpecific(LocalExchangeType.BROADCAST); + } + + static RequireSpecific requireAdaptivePassthrough() { + return requireSpecific(LocalExchangeType.ADAPTIVE_PASSTHROUGH); + } + + static RequireSpecific requireBucketHash() { + return requireSpecific(LocalExchangeType.BUCKET_HASH_SHUFFLE); + } + + static RequireSpecific requireGlobalExecutionHash() { + return requireSpecific(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + } + + static RequireSpecific requireSpecific(LocalExchangeType require) { + return new RequireSpecific(require); + } + + default LocalExchangeType noopTo(LocalExchangeType defaultType) { + LocalExchangeType preferType = preferType(); + return (preferType == LocalExchangeType.NOOP) ? defaultType : preferType; + } + } + + /** NoRequire */ + public static class NoRequire implements LocalExchangeTypeRequire { + public static final NoRequire INSTANCE = new NoRequire(); + + @Override + public boolean satisfy(LocalExchangeType provide) { + return true; + } + + @Override + public LocalExchangeType preferType() { + return LocalExchangeType.NOOP; + } + } + + /** RequireHash */ + public static class RequireHash implements LocalExchangeTypeRequire { + public static final RequireHash INSTANCE = new RequireHash(); + + @Override + public boolean satisfy(LocalExchangeType provide) { + switch (provide) { + case GLOBAL_EXECUTION_HASH_SHUFFLE: + case LOCAL_EXECUTION_HASH_SHUFFLE: + case BUCKET_HASH_SHUFFLE: + return true; + default: + return false; + } + } + + @Override + public LocalExchangeType preferType() { + // GLOBAL is the safe abstract default for a generic "any hash" requirement: it is the + // unconditionally-valid hash partition (full cross-backend redistribution). LOCAL only + // rebalances within a backend, so it is correct only when each key's rows are already + // backend-local — a precondition. AddLocalExchange.resolveExchangeType() deliberately + // specializes RequireHash to LOCAL_EXECUTION_HASH_SHUFFLE for FE-planned intra-fragment + // exchanges (where that precondition holds and GLOBAL's shuffle_idx_to_instance_idx may be + // empty); that override is scoped to that path, so the default here stays GLOBAL. + return LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE; + } + + @Override + public LocalExchangeTypeRequire autoRequireHash() { + return this; + } + } + + public static class RequireSpecific implements LocalExchangeTypeRequire { + LocalExchangeType requireType; + + public RequireSpecific(LocalExchangeType requireType) { + this.requireType = requireType; + } + + @Override + public boolean satisfy(LocalExchangeType provide) { + if (requireType == provide) { + return true; + } + // ADAPTIVE_PASSTHROUGH is a superset of PASSTHROUGH — both fan out data + // from fewer to more tasks. BE's need_to_local_exchange treats them as + // compatible, so ADAPTIVE_PASSTHROUGH satisfies a PASSTHROUGH requirement. + if (requireType == LocalExchangeType.PASSTHROUGH + && provide == LocalExchangeType.ADAPTIVE_PASSTHROUGH) { + return true; + } + return false; + } + + @Override + public LocalExchangeType preferType() { + return requireType; + } + + @Override + public LocalExchangeTypeRequire autoRequireHash() { + if (requireType == LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE + || requireType == LocalExchangeType.BUCKET_HASH_SHUFFLE) { + return this; + } + return RequireHash.INSTANCE; + } + } + + public enum LocalExchangeType { + NOOP, + GLOBAL_EXECUTION_HASH_SHUFFLE, + LOCAL_EXECUTION_HASH_SHUFFLE, + BUCKET_HASH_SHUFFLE, + PASSTHROUGH, + ADAPTIVE_PASSTHROUGH, + BROADCAST, + PASS_TO_ONE, + LOCAL_MERGE_SORT; + + public boolean isHashShuffle() { + switch (this) { + case GLOBAL_EXECUTION_HASH_SHUFFLE: + case LOCAL_EXECUTION_HASH_SHUFFLE: + case BUCKET_HASH_SHUFFLE: + return true; + default: + return false; + } + } + + // Mirrors BE Pipeline::heavy_operations_on_the_sink(): + // HASH_SHUFFLE, BUCKET_HASH_SHUFFLE, and ADAPTIVE_PASSTHROUGH perform + // heavy computation on the sink side. When the upstream pipeline has only + // 1 task (serial/pooling scan), a PASSTHROUGH fan-out must be inserted + // before these exchanges to avoid a single-task bottleneck. + public boolean isHeavyOperation() { + switch (this) { + case GLOBAL_EXECUTION_HASH_SHUFFLE: + case LOCAL_EXECUTION_HASH_SHUFFLE: + case BUCKET_HASH_SHUFFLE: + case ADAPTIVE_PASSTHROUGH: + return true; + default: + return false; + } + } + + public TLocalPartitionType toThrift() { + switch (this) { + case GLOBAL_EXECUTION_HASH_SHUFFLE: + return TLocalPartitionType.GLOBAL_EXECUTION_HASH_SHUFFLE; + case LOCAL_EXECUTION_HASH_SHUFFLE: + return TLocalPartitionType.LOCAL_EXECUTION_HASH_SHUFFLE; + case BUCKET_HASH_SHUFFLE: + return TLocalPartitionType.BUCKET_HASH_SHUFFLE; + case PASSTHROUGH: + return TLocalPartitionType.PASSTHROUGH; + case ADAPTIVE_PASSTHROUGH: + return TLocalPartitionType.ADAPTIVE_PASSTHROUGH; + case BROADCAST: + return TLocalPartitionType.BROADCAST; + case PASS_TO_ONE: + return TLocalPartitionType.PASS_TO_ONE; + case LOCAL_MERGE_SORT: + return TLocalPartitionType.LOCAL_MERGE_SORT; + default: { + throw new IllegalStateException("Unsupported LocalExchangeType: " + this); + } + } + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/MaterializationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/MaterializationNode.java index 8674ef85af9451..1530a6dd41998a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/MaterializationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/MaterializationNode.java @@ -20,6 +20,10 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.catalog.Column; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.qe.ConnectContext; import org.apache.doris.resource.computegroup.ComputeGroup; import org.apache.doris.statistics.StatisticalType; @@ -33,6 +37,8 @@ import org.apache.doris.thrift.TPlanNode; import org.apache.doris.thrift.TPlanNodeType; +import com.google.common.collect.Lists; + import java.util.ArrayList; import java.util.List; @@ -226,7 +232,17 @@ public void setTopMaterializeNode(boolean topMaterializeNode) { } @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return true; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + Pair enforceResult = enforceRequire( + translatorContext, children.get(0), 0, LocalExchangeTypeRequire.requirePassthrough()); + children = Lists.newArrayList(); + children.add(enforceResult.first); + return Pair.of(this, LocalExchangeType.PASSTHROUGH); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/NestedLoopJoinNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/NestedLoopJoinNode.java index b3bc3cbdf89fb0..e46214e4f6567e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/NestedLoopJoinNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/NestedLoopJoinNode.java @@ -23,6 +23,10 @@ import org.apache.doris.analysis.SlotId; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TNestedLoopJoinNode; @@ -188,8 +192,64 @@ public String getNodeExplainString(String detailPrefix, TExplainLevel detailLeve * Probe-side must have full data so join is a serial operator. */ @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return joinOp == JoinOperator.RIGHT_OUTER_JOIN || joinOp == JoinOperator.RIGHT_ANTI_JOIN || joinOp == JoinOperator.RIGHT_SEMI_JOIN || joinOp == JoinOperator.FULL_OUTER_JOIN; } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + + // Pooling mode: the fragment uses serial source (pooling scan or serial exchange). + // NLJ build side needs BROADCAST in pooling mode so all probe tasks see full build data. + boolean childUsePoolingScan = fragment.useSerialSource(translatorContext.getConnectContext()); + + LocalExchangeTypeRequire probeSideRequire; + LocalExchangeTypeRequire buildSideRequire; + LocalExchangeType outputType; + if (joinOp == JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN) { + probeSideRequire = buildSideRequire = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } else if (isSerialNode()) { + // RIGHT_OUTER/RIGHT_SEMI/RIGHT_ANTI/FULL_OUTER: probe side must be serial (1 task). + // Build side: noRequire() — inserting BROADCAST would inflate build pipeline's + // num_tasks while probe stays at 1, crashing in set_ready_to_read(). + probeSideRequire = LocalExchangeTypeRequire.noRequire(); + buildSideRequire = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } else if (childUsePoolingScan) { + probeSideRequire = LocalExchangeTypeRequire.requireAdaptivePassthrough(); + buildSideRequire = LocalExchangeTypeRequire.requireBroadcast(); + outputType = LocalExchangeType.ADAPTIVE_PASSTHROUGH; + } else { + probeSideRequire = LocalExchangeTypeRequire.requireAdaptivePassthrough(); + buildSideRequire = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.ADAPTIVE_PASSTHROUGH; + } + + // Both sides use enforceRequire — it handles serial flag propagation, satisfy + // check (skip LE when child already outputs the required type, e.g., chained NLJs), + // serial ancestor skip, and serial child fallback (auto-upgrade noRequire to + // requirePassthrough when child is serial but this node is not). + PlanNode probeSide = enforceRequire( + translatorContext, children.get(0), 0, probeSideRequire).first; + PlanNode buildSide = enforceRequire( + translatorContext, children.get(1), 1, buildSideRequire).first; + this.children = Lists.newArrayList(probeSide, buildSide); + return Pair.of(this, outputType); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + // Build side (child 1) is a separate pipeline in BE. Normally, + // the serial-ancestor flag should be reset across pipeline boundaries. + // BUT when NLJ itself is serial (RIGHT_OUTER/ANTI/SEMI/FULL_OUTER), + // the probe pipeline has num_tasks=1. If we reset the flag, the + // build-side Exchange may insert PASSTHROUGH (restoring num_tasks to + // _num_instances), creating more build tasks than probe tasks. The + // extra build tasks have a NLJ shared state with empty source_deps, + // crashing in set_ready_to_read(). + return childIndex == 1 && !isSerialNode(); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java index cd8743ba39e57b..f24451a7ed7fee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java @@ -58,6 +58,8 @@ import org.apache.doris.info.PartitionNamesInfo; import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; import org.apache.doris.nereids.trees.plans.ScoreRangeInfo; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.planner.normalize.Normalizer; import org.apache.doris.planner.normalize.PartitionRangePredicateNormalizer; import org.apache.doris.qe.ConnectContext; @@ -1248,6 +1250,9 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { if (isPointQuery()) { output.append(prefix).append("SHORT-CIRCUIT\n"); } + if (fragment.useSerialSource(ConnectContext.get())) { + output.append(prefix).append("POOLING-SCAN\n"); + } if (!CollectionUtils.isEmpty(rewrittenProjectList)) { output.append(prefix).append("rewrittenProjectList: ").append( @@ -1623,4 +1628,18 @@ public long getCatalogId() { } return super.getCatalogId(); } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, + LocalExchangeTypeRequire parentRequire) { + boolean useSerialSource = fragment != null + && fragment.useSerialSource(translatorContext.getConnectContext()); + if (useSerialSource) { + return Pair.of(this, LocalExchangeType.NOOP); + } + // Non-pooling OlapScan has bucket distribution — each instance scans specific buckets + return Pair.of(this, LocalExchangeType.BUCKET_HASH_SHUFFLE); + } + } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PartitionSortNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PartitionSortNode.java index f648010d56a090..13fdcfc4e7a476 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PartitionSortNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PartitionSortNode.java @@ -19,8 +19,12 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.SortInfo; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; import org.apache.doris.nereids.trees.plans.PartitionTopnPhase; import org.apache.doris.nereids.trees.plans.WindowFuncType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPartTopNPhase; @@ -169,4 +173,44 @@ protected void toThrift(TPlanNode msg) { partitionSortNode.setPtopnPhase(pTopNPhase); msg.partition_sort_node = partitionSortNode; } + + // NOTE: unlike SortNode (analytic_sort) and AnalyticEvalNode (with PARTITION BY), we + // intentionally do NOT override requiresShuffleForCorrectness() here, mirroring BE's + // PartitionSortSinkOperatorX which does not override is_shuffled_operator() either + // (be/src/exec/operator/partition_sort_sink_operator.h). The require on the child + // below is sufficient to insert the necessary HASH LE for TWO_PHASE_GLOBAL_PTOPN + // directly; the propagation flag would only matter if a SetOperationNode could sit + // between this node and the data source within the same fragment, which in practice + // does not happen because PartitionSort's two-phase shape places an ExchangeNode + // (fragment boundary) between the GLOBAL phase and the LOCAL phase / scans below. + // If FE ever plans PartitionSort + Union in a single fragment, both BE's + // is_shuffled_operator and this method must be updated together — never let FE + // diverge from BE here. + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + LocalExchangeTypeRequire requireChild; + LocalExchangeType outputType; + if (phase == PartitionTopnPhase.TWO_PHASE_GLOBAL_PTOPN) { + // Use requireHash() so resolveExchangeType() can downgrade to LOCAL_EXECUTION_HASH_SHUFFLE, + // matching BE-native behavior where _use_serial_source=true causes LOCAL (not GLOBAL) hash. + // Output type is derived from the child's actual output (may be LOCAL or GLOBAL depending + // on whether a new exchange was inserted or the existing upstream exchange already satisfied). + requireChild = LocalExchangeTypeRequire.requireHash(); + outputType = null; + } else { + requireChild = LocalExchangeTypeRequire.requirePassthrough(); + outputType = LocalExchangeType.PASSTHROUGH; + } + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, requireChild); + this.children = Lists.newArrayList(enforceResult.first); + return Pair.of(this, outputType != null ? outputType : enforceResult.second); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanFragment.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanFragment.java index 1b4e35d38216d8..39bb9948427753 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanFragment.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanFragment.java @@ -529,7 +529,7 @@ public boolean useSerialSource(ConnectContext context) { // parallelism of non-serial operators. // For bucket shuffle / colocate join fragment, always use serial source if the bucket scan nodes are // serial. - && (hasSerialScanNode() || (sink instanceof DataStreamSink && !planRoot.isSerialOperator() + && (hasSerialScanNode() || (sink instanceof DataStreamSink && !planRoot.isSerialNode() && planRoot.hasSerialChildren())); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java index bd03966d5576a9..18556071315bd4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java @@ -33,6 +33,9 @@ import org.apache.doris.common.TreeNode; import org.apache.doris.common.UserException; import org.apache.doris.datasource.iceberg.source.IcebergScanNode; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.planner.normalize.Normalizer; import org.apache.doris.qe.ConnectContext; import org.apache.doris.statistics.PlanStats; @@ -164,7 +167,13 @@ public abstract class PlanNode extends TreeNode implements PlanStats { protected int nereidsId = -1; - private List> childrenDistributeExprLists = new ArrayList<>(); + // Per-child hash-distribution key exprs: childrenDistributeExprLists.get(i) is the expr list + // used to (re)partition this node's i-th child's input — consumed by getChildDistributeExprList() + // when deriving local-exchange keys. + protected List> childrenDistributeExprLists = new ArrayList<>(); + // This node's own output hash-distribution key exprs — serialized to BE for its LocalExchange / + // shuffle (see distributeExprLists()). + protected List distributeExprLists = new ArrayList<>(); private List intermediateOutputTupleDescList = Lists.newArrayList(); private List> intermediateProjectListList = Lists.newArrayList(); @@ -565,7 +574,7 @@ private void treeToThriftHelper(TPlan container) { TPlanNode msg = new TPlanNode(); msg.node_id = id.asInt(); msg.setNereidsId(nereidsId); - msg.setIsSerialOperator(isSerialOperator() && fragment.useSerialSource(ConnectContext.get())); + msg.setIsSerialOperator(isSerialOperatorOnBe(ConnectContext.get())); msg.num_children = children.size(); msg.limit = limit; for (TupleId tid : tupleIds) { @@ -922,14 +931,97 @@ public void foreachDownInCurrentFragment(Consumer visitor) { }); } - // Operators need to be executed serially. (e.g. finalized agg without key) - public boolean isSerialOperator() { + /** + * Node-level "is this operator inherently serial" property — answers without looking + * at the fragment. Default false; subclasses override (e.g. finalized agg without key, + * UNPARTITIONED ExchangeNode with merge sort). + * + * Use ONLY in framework-internal places where we are already iterating within a + * fragment whose serial-source mode is fixed: {@link #shouldResetSerialFlagForChild} + * inputs, {@link #createLocalExchange} heavy-op gate, and child.isSerialNode() checks + * embedded inside an enforceRequire path. Do NOT use it when computing a + * {@link LocalExchangeNode.LocalExchangeTypeRequire} on a child — call + * {@link #isSerialOperatorOnBe} instead. + */ + public boolean isSerialNode() { + return false; + } + + /** + * Whether this node will be reported to BE as {@code is_serial_operator=true}, i.e. it + * actually runs with one task on BE. Composes {@link #isSerialNode} with the fragment's + * {@code useSerialSource(context)} — when the fragment is not in serial-source mode + * even an isSerialNode()=true operator still runs with N tasks. + * + *

This is the API to use when deciding what {@code LocalExchangeTypeRequire} to + * declare for a child in {@code enforceAndDeriveLocalExchange}. Using + * {@link #isSerialNode} directly there will compute the wrong require under + * non-serial-source fragments and misses the {@code hasSerialScanNode()} contribution + * that {@link ExchangeNode#isSerialOperatorOnBe} layers in. Getting this wrong + * silently produces wrong results (serial child feeds N-task parent without LE). + * + *

Must match the condition in {@code toThrift()/treeToThriftHelper()}; subclasses + * (ExchangeNode) override to fold in {@code hasSerialScanNode()}. + */ + public boolean isSerialOperatorOnBe(ConnectContext context) { + return fragment != null && isSerialNode() && fragment.useSerialSource(context); + } + + /** + * "I depend on hash distribution for correctness, not just performance optimization." + * Used by UnionNode to decide whether to propagate hash requirement to its inputs: + * when a downstream operator requires shuffle for correctness, Union must pre-shuffle + * its inputs so the merged output is hash-distributed. + * + * Default is false; only operators that truly need hash for correctness override + * (finalize AggSink with group keys, HashJoin PARTITIONED/BUCKET_SHUFFLE, Intersect, + * Except, analytic SortNode, partition-by AnalyticEvalNode). Operators that request + * hash for performance only (StreamingAgg pre-agg with enable_local_exchange_before_agg) + * MUST NOT override — that would cause SetOperationNode to over-insert HASH LE on + * every union branch even when nothing downstream actually needs correctness shuffling. + * + * Mirrors BE's OperatorBase::is_shuffled_operator(). + * + *

Propagation example — multi-distinct over UNION

+ *
+     *   AggGlobal(finalize, hasKeys)             ← override = true (chain start)
+     *     └─ Agg(DISTINCT_LOCAL, !finalize)      ← override = false, inherits via
+     *                                              inheritedShuffled in enforceRequire 1b
+     *          └─ Agg(FIRST_MERGE, !finalize)    ← override = false, inherits
+     *               └─ Agg(FIRST_LOCAL, ...)     ← override = false, inherits
+     *                    └─ Union                ← reads inheritedShuffled=true and
+     *                                              pre-shuffles each branch
+     *                         ├─ Scan_t1
+     *                         └─ Scan_t2
+     * 
+ * + * Only the top-level correctness consumer needs to override true. Mid-chain + * merge / local phases do NOT need to — the flag flows down through + * {@link PlanTranslatorContext#hasShuffleForCorrectnessAncestor} automatically as long + * as every link in the chain requires HASH or NOOP (see {@code enforceRequire} step 1b). + * + *

What happens if you forget to override

+ *
    + *
  • Short chain (top consumer directly above Union): Union doesn't + * pre-shuffle its branches, but {@code enforceRequire} inserts a fallback + * LE(HASH) between the consumer and Union. Data result is still correct, + * but the plan shape differs from BE-planned mode (one extra fan-in→fan-out).
  • + *
  • Long chain: same outcome as short chain, because the fallback LE + * is inserted at the consumer/Union boundary regardless of chain length.
  • + *
  • The real wrong-result risk is when {@code enforceRequire}'s fallback + * LE is skipped — e.g. Layer 1 skip when a serial ancestor sits between the + * consumer and Union. In practice top-level correctness consumers (finalize + * agg, hash join, etc.) are not under serial ancestors so this is rare, but + * the override is the principled fix.
  • + *
+ */ + public boolean requiresShuffleForCorrectness() { return false; } public boolean hasSerialChildren() { if (children.isEmpty()) { - return isSerialOperator(); + return isSerialNode(); } return children.stream().allMatch(PlanNode::hasSerialChildren); } @@ -1045,4 +1137,263 @@ private String mergeIcebergAccessPathsWithId( } return StringUtils.join(mergeDisplayAccessPaths, ", "); } + + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + ArrayList newChildren = Lists.newArrayList(); + for (int i = 0; i < children.size(); i++) { + Pair childOutput + = enforceRequire(translatorContext, children.get(i), i, LocalExchangeTypeRequire.noRequire()); + newChildren.add(childOutput.first); + } + this.children = newChildren; + return Pair.of(this, LocalExchangeType.NOOP); + } + + /** + * Unified framework method: propagate serial flag → recurse child → satisfy check → Layer 1 skip → insert LE. + * Replaces the old enforceChild/enforceChildExchange/forceEnforceChildExchange trio. + * + *

Data flow

+ *
    + *
  • serial-ancestor flag ({@link PlanTranslatorContext#hasSerialAncestorInPipeline}) + * — flows root → leaf during traversal. Mirrors BE's + * {@code any_of(operators[idx..end], is_serial_operator)} check used by + * {@code _add_local_exchange} to skip LE insertion when an ancestor in the same + * pipeline is already serial. Reset at pipeline boundaries via + * {@link #shouldResetSerialFlagForChild}.
  • + *
  • shuffle-for-correctness flag + * ({@link PlanTranslatorContext#hasShuffleForCorrectnessAncestor}) — also flows + * root → leaf. Mirrors BE's {@code _followed_by_shuffled_operator}: tells a + * child whether some downstream operator depends on hash distribution for + * correctness, so {@code SetOperationNode} can pre-shuffle union branches.
  • + *
  • return value {@code Pair} — first is the + * (possibly LE-wrapped) child; second is the actual output distribution as + * observed by the parent. Caller's {@code require.satisfy(output)} decides + * whether more LE is needed.
  • + *
  • parent.require describes the constraint on the child output — + * computed inside the parent's {@code enforceAndDeriveLocalExchange} per child.
  • + *
+ * + *

Invariants

+ *
    + *
  • Where a serial → non-serial transition needs redistribution, framework step 3 inserts + * the LE (e.g. a serial source fanned out via PASSTHROUGH). This is not a hard invariant: + * a serial child feeding a parent that requires PASSTHROUGH / noRequire (TableFunction, + * NLJ, Agg) is already correct and needs no LE, so it is intentionally not validated by a + * post-pass.
  • + *
  • {@code LocalExchangeNode} itself is always non-serial — setting it serial + * would defeat its purpose of fanning a 1-task pipeline back to N tasks.
  • + *
  • For pipeline-breaking parents ({@code shouldResetSerialFlagForChild=true}), + * the child starts a fresh pipeline so {@code hasSerialAncestor} is reset; the + * node's own {@code isSerialNode()} still composes in for the child's view.
  • + *
  • {@code RequireHash} accepts any hash flavour; {@code RequireSpecific} demands + * an exact match (with the one PASSTHROUGH/ADAPTIVE_PASSTHROUGH compatibility). + * Pick the looser one whenever correctness allows — see + * {@link LocalExchangeNode.LocalExchangeTypeRequire}.
  • + *
+ * + *

Layers

+ * Layer 1 (shouldSkipLE): mirrors BE's need_to_local_exchange — skip when this node or + * an ancestor in the same pipeline is serial (operators[idx..end] has serial → skip). + * Layer 2 (require/output): each Node declares require and output in enforceAndDeriveLocalExchange. + */ + protected Pair enforceRequire( + PlanTranslatorContext translatorContext, PlanNode child, int childIndex, + LocalExchangeTypeRequire require) { + // 1. Propagate serial-ancestor flag to child. + // For pipeline-splitting operators (shouldReset=true, e.g. non-streaming AGG): + // Drop inherited serial flag from parent (parent is in a different pipeline), + // but keep this node's own serial status (child is in the same pipeline as this + // node's sink, e.g. Exchange is in AGG_Sink pipeline). + // For non-splitting operators (shouldReset=false, e.g. streaming AGG): + // Inherit parent's serial flag + this node's own. + boolean inheritedSerial = shouldResetSerialFlagForChild(childIndex) + ? false : translatorContext.hasSerialAncestorInPipeline(this); + // Use isSerialOperatorOnBe (= isSerialNode && fragment.useSerialSource) instead of the + // raw isSerialNode(). BE's OperatorBase reads the Thrift `is_serial_operator` flag — + // which is what FE writes via isSerialOperatorOnBe — so when the fragment is not in + // serial-source mode, BE treats this operator as non-serial regardless of isSerialNode. + // Using isSerialNode here would set the child's serial-ancestor flag wider than BE's + // view and over-skip required LocalExchanges downstream. + boolean childHasSerialAncestor = inheritedSerial + || isSerialOperatorOnBe(translatorContext.getConnectContext()); + translatorContext.setHasSerialAncestorInPipeline(child, childHasSerialAncestor); + + // 1b. Propagate shuffle-for-correctness-ancestor flag to child. + // Mirrors BE's _followed_by_shuffled_operator: a downstream operator needs hash + // distribution for correctness, and the chain to here goes through HASH or NOOP + // requirements (so the dependency is preserved). + // propagate = ((inheritedShuffled || self.requiresShuffleForCorrectness) + // && require is hash) + // || (inheritedShuffled && require is noop/passthrough) + boolean inheritedShuffled = translatorContext.hasShuffleForCorrectnessAncestor(this); + boolean selfOrInheritedShuffled = inheritedShuffled || requiresShuffleForCorrectness(); + boolean requireIsHash = require.preferType().isHashShuffle(); + boolean requireIsNoop = require.preferType() == LocalExchangeNode.LocalExchangeType.NOOP; + boolean childShuffledAncestor = (selfOrInheritedShuffled && requireIsHash) + || (inheritedShuffled && requireIsNoop); + translatorContext.setHasShuffleForCorrectnessAncestor(child, childShuffledAncestor); + + // 2. Recurse child (Layer 2: child declares its own require/output) + Pair childOutput = + child.enforceAndDeriveLocalExchange(translatorContext, this, require); + + // Steps 2.5 and 3 both react to a serial child but address different concerns: + // - Step 2.5 rewrites the OUTPUT-side view (what we tell satisfy/parent about + // the child's actual distribution). A serial pipeline runs with 1 task so + // its distribution claim is meaningless — flatten to NOOP so the satisfy + // check below doesn't get fooled by a stale "I output BUCKET_HASH" claim. + // - Step 3 rewrites the REQUIRE-side decision (what we want from the child). + // If we previously asked for nothing (noRequire) but the child turns out + // to be serial and we're not, upgrade to requirePassthrough so an LE is + // inserted to restore parallelism. + + // 2.5. Serial child override (output side): if child is serial on BE, force its + // reported output to NOOP. Distribution is irrelevant when the child runs + // with 1 task; downstream parallelism is restored either by step 3 (LE + // insertion) or skipped entirely by step 4b (we're also serial). + if (childOutput.first.isSerialOperatorOnBe(translatorContext.getConnectContext())) { + childOutput = Pair.of(childOutput.first, LocalExchangeType.NOOP); + } + + // 3. Framework-level serial child check (require side, mirrors BE base class + // required_data_distribution): if child will be serial on BE but this node is + // not serial, the pipeline has a 1-task serial child feeding an N-task non-serial + // parent. Without LE, pipeline splits (AGG/JOIN) create paired pipelines with + // mismatched num_tasks → crash. Upgrade noRequire to requirePassthrough so an + // LE is inserted below to restore parallelism. + if (require instanceof LocalExchangeNode.NoRequire + && childOutput.first.isSerialOperatorOnBe(translatorContext.getConnectContext()) + && !isSerialOperatorOnBe(translatorContext.getConnectContext())) { + require = LocalExchangeTypeRequire.requirePassthrough(); + } + + // 4. Satisfy check: child output meets requirement → done + if (require.satisfy(childOutput.second)) { + return childOutput; + } + + // 4. Layer 1: skip LE when serial operator or ancestor in same pipeline + // Equivalent to BE's need_to_local_exchange: any_of(operators[idx..end], is_serial) → skip. + // Use isSerialOperatorOnBe (not isSerialNode) because BE's Pipeline::need_to_local_exchange + // checks op->is_serial_operator() which reads the Thrift flag set from isSerialOperatorOnBe; + // when fragment.useSerialSource is false, BE treats this node as non-serial. + if (translatorContext.hasSerialAncestorInPipeline(this) + || isSerialOperatorOnBe(translatorContext.getConnectContext())) { + return childOutput; + } + + // 5. Resolve exchange type and create LE node + LocalExchangeType preferType = AddLocalExchange.resolveExchangeType(require); + List distributeExprs = getLocalExchangeDistributeExprs(childIndex, selfOrInheritedShuffled); + PlanNode leNode = createLocalExchange(translatorContext, childOutput.first, preferType, distributeExprs); + return Pair.of(leNode, preferType); + } + + /** + * Create a LocalExchangeNode wrapping child with the given exchange type. + * No child-type skip — matches BE's _add_local_exchange which inserts LE for any child + * type without checking instanceof. + * + * Handles heavy-ops bottleneck avoidance (mirrors BE pipeline_fragment_context.cpp): + * when upstream has 1 task (serial source) and exchange is heavy (hash/bucket/adaptive), + * insert a PASSTHROUGH fan-out first to avoid single-task bottleneck on the heavy + * exchange sink. Only applies to local-shuffle (pooling scan) fragments. + */ + protected PlanNode createLocalExchange(PlanTranslatorContext translatorContext, + PlanNode child, LocalExchangeType exchangeType, List distributeExprs) { + if (fragment != null && fragment.useSerialSource(translatorContext.getConnectContext()) + && exchangeType.isHeavyOperation() && child.isSerialNode()) { + PlanNode ptNode = new LocalExchangeNode(translatorContext.nextPlanNodeId(), + child, LocalExchangeType.PASSTHROUGH, null); + return new LocalExchangeNode(translatorContext.nextPlanNodeId(), ptNode, + exchangeType, distributeExprs); + } + return new LocalExchangeNode(translatorContext.nextPlanNodeId(), child, + exchangeType, distributeExprs); + } + + /** + * Whether the child at {@code childIndex} starts a new pipeline context, causing + * its serial-ancestor flag to be reset to {@code false} rather than inherited from this node. + * Override to return {@code true} for pipeline-splitting nodes (LocalExchangeNode) and nodes + * whose children run in an independent pipeline segment (SortNode before analytic, etc.). + */ + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return false; + } + + protected List getChildDistributeExprList(int childIndex) { + if ((childrenDistributeExprLists == null || childrenDistributeExprLists.size() <= childIndex)) { + return null; + } else { + return childrenDistributeExprLists.get(childIndex); + } + } + + /** + * Return distribute exprs used as the hash key when {@link #enforceRequire} inserts a + * LocalExchange between this node and {@code child[childIndex]}. Default returns the + * child's output distribution ({@code childrenDistributeExprLists[childIndex]}). + * + *

Subclasses override this to mirror BE-specific {@code _partition_exprs} logic. For + * example BE's {@code AggSinkOperatorX::update_operator} picks + * {@code grouping_exprs} when {@code !_followed_by_shuffled_operator && !has_distinct}, + * even though the child outputs a different (hash) distribution — and the LE inserted + * before the streaming preagg must partition by {@code grouping_exprs} so a local + * partial reduce actually collapses same-key rows. Using the default (child + * distribution) here would scatter same-group rows across instances and degrade the + * preagg to a no-op, also breaking row-arrival order at downstream merge-finalize. + * + * @param childIndex which child + * @param followedByShuffled whether the chain at this node is followed by a shuffled + * operator (mirrors BE's {@code _followed_by_shuffled_operator}) + */ + protected List getLocalExchangeDistributeExprs(int childIndex, boolean followedByShuffled) { + return getChildDistributeExprList(childIndex); + } + + /** + * Returns the operator's own semantically-defined partition expressions + * (e.g. GROUP BY exprs for aggregation, PARTITION BY exprs for analytic). + * Corresponds to BE's fallback path: tnode.agg_node.grouping_exprs / + * tnode.analytic_node.partition_exprs when _followed_by_shuffled_operator=false. + * Override in subclasses that have intrinsic partition keys. + */ + protected List getSemanticPartitionExprs() { + return null; + } + + /** + * Returns true if there are effective (non-empty) partition expressions, + * mirroring BE's _partition_exprs logic: + * _followed_by_shuffled_operator=true → distribute_expr_lists[0] (child distribute key) + * _followed_by_shuffled_operator=false → semantic partition exprs (grouping / partition by) + * parentRequire.preferType().isHashShuffle() corresponds to _followed_by_shuffled_operator=true. + */ + protected boolean hasPartitionExprs(LocalExchangeTypeRequire parentRequire) { + if (parentRequire.preferType().isHashShuffle()) { + List childExprs = getChildDistributeExprList(0); + return childExprs != null && !childExprs.isEmpty(); + } + List semanticExprs = getSemanticPartitionExprs(); + return semanticExprs != null && !semanticExprs.isEmpty(); + } + + public List> getChildrenDistributeExprLists() { + return childrenDistributeExprLists; + } + + public List getDistributeExprLists() { + return distributeExprLists; + } + + public void setDistributeExprLists(List distributeExprLists) { + if (distributeExprLists == null) { + this.distributeExprLists = Collections.emptyList(); + } else { + this.distributeExprLists = distributeExprLists; + } + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteNode.java index c1531813d3cea9..8dc65f1df1d244 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteNode.java @@ -19,6 +19,10 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; @@ -28,6 +32,7 @@ import com.google.common.base.MoreObjects; import com.google.common.collect.Lists; +import java.util.ArrayList; import java.util.List; public class RecursiveCteNode extends PlanNode { @@ -84,4 +89,49 @@ public String toString() { .add("tid", tupleIds.get(0).asInt()) .add("isUnionAll", isUnionAll).toString(); } + + @Override + public boolean isSerialNode() { + // Mirror BE's RecCTESourceOperatorX::is_serial_operator() which always returns true: + // the recursive driver runs sequentially in one task, so downstream consumers must see + // RecursiveCteNode as serial too. Without this, FE planner leaves the producer + // fragment with parallel=N senders but only one actually emits data — the cross- + // fragment Exchange receiver expects N senders done and hangs waiting on the other + // N-1. Marking serial here lets AddLocalExchange#addLocalExchangeForFragment wrap + // the root with a PASSTHROUGH LE that fans the serial RecCte output out to N + // parallel sinks, matching BE-native _plan_local_exchange behaviour. + return true; + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + // Recurse into children to give them a chance to plan local exchanges below + // themselves, but never insert one *directly* under RecursiveCteNode: + // - ThriftPlansBuilder locates the recursive sender fragment via + // `getChild(1).getChild(0).getFragment()`; a LocalExchangeNode wrapper + // would shift that path off the cross-fragment ExchangeNode and pull the + // wrong fragment into `fragmentsToReset`. + // - BE's RecCTESourceOperatorX wires the anchor / recursive side pipelines + // directly against the Exchange children (pipeline_fragment_context.cpp + // REC_CTE_NODE handling); injecting an extra LE pipeline between them + // mis-routes the rerun signal and crashes BE during execution. + // Both issues are pure shape mismatches — RecursiveCteNode's children are + // already the cross-fragment ExchangeNode receivers, which BE drives serially + // itself, so no FE-side fan-out is needed here. + ArrayList newChildren = Lists.newArrayList(); + for (int i = 0; i < children.size(); i++) { + PlanNode child = children.get(i); + Pair childOutput = child.enforceAndDeriveLocalExchange( + translatorContext, this, LocalExchangeTypeRequire.noRequire()); + newChildren.add(childOutput.first); + } + this.children = newChildren; + return Pair.of(this, LocalExchangeType.NOOP); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteScanNode.java index bdf97bd5200666..d7112c36a61ac8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/RecursiveCteScanNode.java @@ -19,6 +19,10 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; @@ -65,7 +69,13 @@ protected void toThrift(TPlanNode msg) { } @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return true; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + return Pair.of(this, LocalExchangeType.NOOP); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/RepeatNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/RepeatNode.java index 4289b5b32178c6..e00575d91d5ce0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/RepeatNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/RepeatNode.java @@ -21,6 +21,10 @@ import org.apache.doris.analysis.GroupByClause; import org.apache.doris.analysis.GroupingInfo; import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; @@ -104,11 +108,30 @@ public String getNodeExplainString(String detailPrefix, TExplainLevel detailLeve // Determined by its child. @Override - public boolean isSerialOperator() { - return children.get(0).isSerialOperator(); + public boolean isSerialNode() { + return children.get(0).isSerialNode(); } public GroupingInfo getGroupingInfo() { return groupingInfo; } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + // REPEAT (rollup/grouping sets) is NOT distribution-preserving: it NULLs grouping + // columns per set and produces GROUPING_ID, which is part of the downstream agg hash + // key but does not exist below the repeat. Forwarding the parent HASH require down + // would push the local exchange before the row expansion AND hash by the child + // distribution (a single upstream shuffle key) instead of the agg grouping_exprs, + // collapsing rows onto one instance (tpcds q67, +73%). Recurse with noRequire so the + // parent inserts its hash local exchange ABOVE the repeat using its own grouping_exprs + // (mirrors BE, whose RepeatOperatorX has a NOOP required_data_distribution). + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, + LocalExchangeTypeRequire.noRequire()); + children = new java.util.ArrayList<>(); + children.add(enforceResult.first); + return Pair.of(this, enforceResult.second); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java b/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java index 79a2e9c47735d0..e644d8609743f8 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java @@ -229,7 +229,7 @@ public TRuntimeFilterDesc toThrift() { for (RuntimeFilterTarget target : targets) { tFilter.putToPlanIdToTargetExpr(target.node.getId().asInt(), target.expr.treeToThrift()); hasSerialTargets = hasSerialTargets - || (target.node.isSerialOperator() && target.node.fragment.useSerialSource(ConnectContext.get())); + || target.node.isSerialOperatorOnBe(ConnectContext.get()); } boolean enableSyncFilterSize = ConnectContext.get() != null diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java index b3def9bc8c682c..0b21ab3723f983 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ScanNode.java @@ -43,11 +43,15 @@ import org.apache.doris.catalog.TableIf; import org.apache.doris.cloud.catalog.CloudPartition; import org.apache.doris.common.Config; +import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; import org.apache.doris.datasource.FederationBackendPolicy; import org.apache.doris.datasource.SplitAssignment; import org.apache.doris.datasource.SplitGenerator; import org.apache.doris.datasource.SplitSource; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.qe.ConnectContext; import org.apache.doris.rpc.RpcException; import org.apache.doris.statistics.StatisticalType; @@ -760,7 +764,7 @@ public ScanContext getScanContext() { } @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { ConnectContext context = ConnectContext.get(); if (context == null) { return numScanBackends() <= 0; @@ -774,7 +778,15 @@ public boolean isSerialOperator() { @Override public boolean hasSerialScanChildren() { - return isSerialOperator(); + return isSerialNode(); + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + // Base ScanNode returns NOOP — only OlapScanNode overrides with BUCKET_HASH_SHUFFLE + // for non-pooling scans that have bucket distribution. + return Pair.of(this, LocalExchangeType.NOOP); } public void setDesc(TupleDescriptor desc) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SelectNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SelectNode.java index d604d5cf33a909..85448f69e1a12f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SelectNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SelectNode.java @@ -20,6 +20,10 @@ package org.apache.doris.planner; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; @@ -64,7 +68,17 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { // Determined by its child. @Override - public boolean isSerialOperator() { - return children.get(0).isSerialOperator(); + public boolean isSerialNode() { + return children.get(0).isSerialNode(); + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, parentRequire); + this.children = new ArrayList<>(); + this.children.add(enforceResult.first); + return Pair.of(this, enforceResult.second); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java index 93ccf5e935e6cb..9772d18947c63e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java @@ -19,6 +19,11 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.HashJoinNode.DistributionMode; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExceptNode; import org.apache.doris.thrift.TExplainLevel; @@ -35,6 +40,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -194,4 +200,63 @@ public int getNumInstances() { numInstances = Math.max(1, numInstances); return numInstances; } + + public boolean isBucketShuffle() { + return distributionMode.equals(DistributionMode.BUCKET_SHUFFLE); + } + + public boolean isColocate() { + return isColocate; + } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + LocalExchangeTypeRequire requireChild; + LocalExchangeType outputType; + if (this instanceof UnionNode) { + // Propagate parent's hash requirement to children ONLY when a downstream operator + // requires shuffle for correctness (not just performance optimization). Matches BE's + // UnionSinkOperatorX which returns GLOBAL_HASH(_distribute_exprs) whenever + // _followed_by_shuffled_operator=true. The flag is propagated by enforceRequire + // from operators with requiresShuffleForCorrectness()=true (finalize agg, hash join, + // intersect/except) through hash/noop links. + // See PlanNode.requiresShuffleForCorrectness() for a chain-propagation example. + boolean canPropagateHash = translatorContext.hasShuffleForCorrectnessAncestor(this); + requireChild = canPropagateHash ? parentRequire.autoRequireHash() : LocalExchangeTypeRequire.noRequire(); + outputType = canPropagateHash + ? AddLocalExchange.resolveExchangeType(requireChild) + : LocalExchangeType.NOOP; + } else { + // Intersect / Except + if (AddLocalExchange.isColocated(this)) { + requireChild = LocalExchangeTypeRequire.requireBucketHash(); + outputType = LocalExchangeType.BUCKET_HASH_SHUFFLE; + } else { + // PARTITIONED intersect/except: all children enter via global hash + // exchange. Require GLOBAL so any inserted exchange matches the + // cross-fragment instance mapping (same fix as HashJoinNode DORIS-26101). + // Exception: serial source → fall back to LOCAL (DORIS-26120). + boolean serialSource = fragment != null + && fragment.useSerialSource(translatorContext.getConnectContext()); + requireChild = serialSource + ? LocalExchangeTypeRequire.requireHash() + : LocalExchangeTypeRequire.requireGlobalExecutionHash(); + outputType = AddLocalExchange.resolveExchangeType(requireChild); + } + } + + ArrayList newChildren = Lists.newArrayList(); + for (int i = 0; i < children.size(); i++) { + newChildren.add(enforceRequire(translatorContext, children.get(i), i, requireChild).first); + } + this.children = newChildren; + return Pair.of(this, outputType); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } + } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java index 7e8b10399866e5..227f4e4ef0650e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java @@ -23,6 +23,9 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.SortInfo; import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.qe.ConnectContext; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; @@ -254,10 +257,22 @@ protected String debugString() { // If it's analytic sort or not merged by a followed exchange node, it must output the global ordered data. @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return !isAnalyticSort && !mergeByexchange; } + /** + * Mirrors BE's {@code SortSinkOperatorX::is_shuffled_operator() = _is_analytic_sort} + * (be/src/exec/operator/sort_sink_operator.h:95). Analytic-sort requires partitioned + * input by analytic partition keys, so downstream UnionNode / SetOperationNode under + * us must pre-shuffle their branches to match — the framework propagates this through + * {@link PlanTranslatorContext#hasShuffleForCorrectnessAncestor}. + */ + @Override + public boolean requiresShuffleForCorrectness() { + return isAnalyticSort; + } + public void setColocate(boolean colocate) { isColocate = colocate; } @@ -282,4 +297,45 @@ public void setTopnFilterTargets( List> topnFilterTargets) { this.topnFilterTargets = topnFilterTargets; } + + @Override + public Pair enforceAndDeriveLocalExchange(PlanTranslatorContext translatorContext, + PlanNode parent, LocalExchangeTypeRequire parentRequire) { + + LocalExchangeTypeRequire requireChild; + LocalExchangeType outputType = null; + if (isAnalyticSort) { + // BE: SortSink._is_analytic_sort=true → required_data_distribution() = HASH. + // This sort serves a parent AnalyticEvalNode (window function) and requires + // data partitioned by the analytic's partition exprs. + if (AddLocalExchange.isColocated(this)) { + requireChild = LocalExchangeTypeRequire.requireHash(); + outputType = AddLocalExchange.resolveExchangeType( + LocalExchangeTypeRequire.requireHash()); + } else { + requireChild = parentRequire.autoRequireHash(); + } + } else if (mergeByexchange) { + // BE: SortSink._merge_by_exchange=true → required_data_distribution() = PASSTHROUGH. + requireChild = LocalExchangeTypeRequire.requirePassthrough(); + outputType = LocalExchangeType.PASSTHROUGH; + } else { + // BE: else → NOOP + requireChild = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } + + Pair enforceResult + = enforceRequire(translatorContext, children.get(0), 0, requireChild); + this.children = Lists.newArrayList(enforceResult.first); + if (outputType == null) { + outputType = enforceResult.second; + } + return Pair.of(this, outputType); + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/TableFunctionNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/TableFunctionNode.java index 08cfc95bc45ef9..8cf7594d471ba1 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/TableFunctionNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/TableFunctionNode.java @@ -20,6 +20,10 @@ import org.apache.doris.analysis.Expr; import org.apache.doris.analysis.SlotId; import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.statistics.StatisticalType; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPlanNode; @@ -118,4 +122,24 @@ protected void toThrift(TPlanNode msg) { msg.table_function_node.addToOutputSlotIds(slotId.asInt()); } } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + // Mirrors BE TableFunctionOperatorX::required_data_distribution() which always + // returns PASSTHROUGH, regardless of child's serial status. + // + // Conceptual model: TableFunction requires PASSTHROUGH input but outputs + // "unknown distribution" (NOOP). This means downstream operators (e.g. Sort) + // must independently evaluate their own requirements against NOOP, naturally + // triggering exchange insertion when they require PASSTHROUGH. + // + // In BE, need_to_local_exchange() Step 4 treats non-hash exchanges (PASSTHROUGH) + // as always needing insertion, so "PASSTHROUGH doesn't satisfy PASSTHROUGH" — + // which is equivalent to our FE model of require=PASSTHROUGH, output=NOOP. + Pair enforceResult = enforceRequire( + translatorContext, children.get(0), 0, LocalExchangeTypeRequire.requirePassthrough()); + children = Lists.newArrayList(enforceResult.first); + return Pair.of(this, LocalExchangeType.NOOP); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/UnionNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/UnionNode.java index 09d366f5456dc5..a1f99377f43b3d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/UnionNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/UnionNode.java @@ -38,7 +38,7 @@ protected void toThrift(TPlanNode msg) { // If it is a union without children which means it will output some constant values, we should use a serial union // to output non-duplicated data. @Override - public boolean isSerialOperator() { + public boolean isSerialNode() { return children.isEmpty(); } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java index c582dccf7f935f..4c0ef0a9c129de 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java @@ -366,7 +366,6 @@ public Coordinator(ConnectContext context, Planner planner) { } else { distributedPlans = ((NereidsPlanner) planner).getDistributedPlans(); } - setFromUserProperty(context); this.queryGlobals.setNowString(TimeUtils.getDatetimeFormatWithTimeZone().format(LocalDateTime.now())); @@ -439,6 +438,8 @@ private void initQueryOptions(ConnectContext context) { this.queryOptions.setFeProcessUuid(ExecuteEnv.getInstance().getProcessUUID()); this.queryOptions.setMysqlRowBinaryFormat( context.getCommand() == MysqlCommand.COM_STMT_EXECUTE); + // Old Coordinator never plans local exchange in FE. Force BE to plan its own. + this.queryOptions.setEnableLocalShufflePlanner(false); } public ConnectContext getConnectContext() { @@ -3203,7 +3204,7 @@ private void assignScanRanges(PlanFragmentId fragmentId, int parallelExecInstanc Optional node = scanNodes.stream().filter( scanNode -> scanNode.getId().asInt() == scanId).findFirst(); Preconditions.checkArgument(node.isPresent()); - FInstanceExecParam instanceParamToScan = node.get().isSerialOperator() + FInstanceExecParam instanceParamToScan = node.get().isSerialNode() ? firstInstanceParam : instanceParam; if (!instanceParamToScan.perNodeScanRanges.containsKey(nodeScanRange.getKey())) { range.put(nodeScanRange.getKey(), Lists.newArrayList()); diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java b/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java index 638128326c354c..18303eab5ef4a2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java @@ -104,6 +104,7 @@ public NereidsCoordinator(ConnectContext context, setForInsert(-1L); } + syncLocalShufflePlannerOption(); Preconditions.checkState(!planner.getFragments().isEmpty() && coordinatorContext.instanceNum.get() > 0, "Fragment and Instance can not be empty˚"); } @@ -118,6 +119,7 @@ public NereidsCoordinator(ConnectContext context, // we don't need to check the dataSink, Because setting jobId means this must be a load operation setForInsert(jobId); + syncLocalShufflePlannerOption(); Preconditions.checkState(!planner.getFragments().isEmpty() && coordinatorContext.instanceNum.get() > 0, "Fragment and Instance can not be empty˚"); } @@ -135,11 +137,20 @@ public NereidsCoordinator(Long jobId, TUniqueId queryId, DescriptorTable descTab // same reason in `setForInsert` this.coordinatorContext.queryOptions.setDisableFileCache(true); this.needEnqueue = false; + syncLocalShufflePlannerOption(); Preconditions.checkState(!fragments.isEmpty() && coordinatorContext.instanceNum.get() > 0, "Fragment and Instance can not be empty˚"); } + private void syncLocalShufflePlannerOption() { + coordinatorContext.queryOptions.setEnableLocalShufflePlanner( + coordinatorContext.distributedPlans != null + && !coordinatorContext.distributedPlans.isEmpty() + && coordinatorContext.connectContext != null + && coordinatorContext.connectContext.getSessionVariable().isEnableLocalShufflePlanner()); + } + @Override public void exec() throws Exception { enqueue(coordinatorContext.connectContext); diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java index 4432844120bdaa..fa6409f1325537 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java @@ -377,6 +377,8 @@ public String toString() { public static final String ENABLE_LOCAL_SHUFFLE = "enable_local_shuffle"; + public static final String ENABLE_LOCAL_SHUFFLE_PLANNER = "enable_local_shuffle_planner"; + public static final String FORCE_TO_LOCAL_SHUFFLE = "force_to_local_shuffle"; public static final String BUCKET_SHUFFLE_DOWNGRADE_RATIO = "bucket_shuffle_downgrade_ratio"; @@ -1707,6 +1709,12 @@ public enum IgnoreSplitType { "Whether to enable local shuffle on pipelineX engine."}, needForward = true) private boolean enableLocalShuffle = true; + @VariableMgr.VarAttr( + name = ENABLE_LOCAL_SHUFFLE_PLANNER, fuzzy = false, varType = VariableAnnotation.EXPERIMENTAL, + description = {"是否在FE规划Local Shuffle", + "Whether to plan local shuffle in frontend"}, needForward = true) + private boolean enableLocalShufflePlanner = true; + @VariableMgr.VarAttr( name = FORCE_TO_LOCAL_SHUFFLE, fuzzy = false, varType = VariableAnnotation.EXPERIMENTAL, description = {"是否在 pipelineX 引擎上强制开启 local shuffle 优化", @@ -4960,6 +4968,18 @@ public void setEnableLocalShuffle(boolean enableLocalShuffle) { this.enableLocalShuffle = enableLocalShuffle; } + public boolean isEnableLocalShuffle() { + return enableLocalShuffle; + } + + public boolean isEnableLocalShufflePlanner() { + return enableLocalShufflePlanner; + } + + public void setEnableLocalShufflePlanner(boolean enableLocalShufflePlanner) { + this.enableLocalShufflePlanner = enableLocalShufflePlanner; + } + public boolean enablePushDownNoGroupAgg() { return enablePushDownNoGroupAgg; } @@ -5930,6 +5950,8 @@ public TQueryOptions toThrift() { // Set Iceberg write target file size tResult.setIcebergWriteTargetFileSizeBytes(icebergWriteTargetFileSizeBytes); + tResult.setEnableLocalShufflePlanner(enableLocalShufflePlanner); + tResult.setFileCacheQueryLimitBytes(fileCacheQueryLimitBytes); return tResult; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java index b349cf9160d649..28849167603f3d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java @@ -104,6 +104,7 @@ import org.apache.doris.nereids.trees.plans.commands.insert.InsertOverwriteTableCommand; import org.apache.doris.nereids.trees.plans.commands.insert.OlapGroupCommitInsertExecutor; import org.apache.doris.nereids.trees.plans.commands.insert.OlapInsertExecutor; +import org.apache.doris.nereids.trees.plans.commands.merge.MergeIntoCommand; import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import org.apache.doris.nereids.trees.plans.physical.PhysicalSqlCache; import org.apache.doris.planner.GroupCommitScanNode; @@ -1172,8 +1173,10 @@ public boolean isProfileSafeStmt() { // 1. CreateTableCommand(mainly for create as select). // 2. LoadCommand. // 3. InsertOverwriteTableCommand. + // 4. MergeIntoCommand (merge into ... using ...). if ((plan instanceof Command) && !(plan instanceof LoadCommand) - && !(plan instanceof CreateTableCommand) && !(plan instanceof InsertOverwriteTableCommand)) { + && !(plan instanceof CreateTableCommand) && !(plan instanceof InsertOverwriteTableCommand) + && !(plan instanceof MergeIntoCommand)) { // Commands like SHOW QUERY PROFILE will not have profile. return false; } else { diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java index 5b3d8b24f6df4d..53d9042c90ddf0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java @@ -342,7 +342,8 @@ private static Multiset computeInstanceNumPerWorker( return workerCounter; } - private static Map computeExchangeSenderNum(PipelineDistributedPlan distributedPlan) { + private static Map computeExchangeSenderNum( + PipelineDistributedPlan distributedPlan) { Map senderNum = Maps.newLinkedHashMap(); for (Entry kv : distributedPlan.getInputs().entries()) { ExchangeNode exchangeNode = kv.getKey(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java new file mode 100644 index 00000000000000..7288ba49ca2756 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -0,0 +1,783 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.planner; + +import org.apache.doris.analysis.AssertNumRowsElement; +import org.apache.doris.analysis.BinaryPredicate; +import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.GroupingInfo; +import org.apache.doris.analysis.JoinOperator; +import org.apache.doris.analysis.OrderByElement; +import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.analysis.SortInfo; +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.Pair; +import org.apache.doris.common.UserException; +import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; +import org.apache.doris.nereids.trees.plans.PartitionTopnPhase; +import org.apache.doris.nereids.trees.plans.WindowFuncType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TPartitionType; +import org.apache.doris.thrift.TPlanNode; +import org.apache.doris.thrift.TScanRangeLocations; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +public class LocalShuffleNodeCoverageTest { + private static final AtomicInteger NEXT_ID = new AtomicInteger(1); + + @Test + public void testSelectNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + SelectNode selectWithNoopChild = new SelectNode(nextPlanNodeId(), childNoop); + Pair output = selectWithNoopChild.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + + // resolveExchangeType with RequireHash always returns LOCAL_EXECUTION_HASH_SHUFFLE + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, childNoop.lastRequire.getClass()); + assertChildLocalExchangeType(selectWithNoopChild, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + TrackingPlanNode childBucket = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.BUCKET_HASH_SHUFFLE); + SelectNode selectWithBucketChild = new SelectNode(nextPlanNodeId(), childBucket); + Pair bucketOutput = selectWithBucketChild.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketOutput.second); + Assertions.assertSame(childBucket, selectWithBucketChild.getChild(0)); + } + + @Test + public void testRepeatNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + GroupingInfo groupingInfo = Mockito.mock(GroupingInfo.class); + TupleDescriptor outputTuple = new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())); + Mockito.when(groupingInfo.getOutputTupleDesc()).thenReturn(outputTuple); + Mockito.when(groupingInfo.getPreRepeatExprs()).thenReturn(Collections.emptyList()); + + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + RepeatNode repeatNode = new RepeatNode(nextPlanNodeId(), childNoop, groupingInfo, + Collections.singletonList(Collections.emptySet()), Collections.emptySet(), + Collections.singletonList(Collections.emptyList())); + Pair output = repeatNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + // RepeatNode must NOT forward the parent's HASH require to its child: it recurses + // with noRequire (so no hash LE is pushed below the Repeat) and reports the child's + // own distribution (NOOP) so the parent places the hash LE ABOVE the Repeat instead. + // Pre-fix this forwarded RequireHash and inserted a LOCAL_HASH LE below the Repeat + // (tpcds q67 skew). + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, childNoop.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(childNoop, repeatNode.getChild(0)); + } + + @Test + public void testTableFunctionNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TableFunctionNode tableFunctionNode = new TableFunctionNode(nextPlanNodeId(), childNoop, + new TupleId(NEXT_ID.getAndIncrement()), new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + + // TableFunctionNode always requires PASSTHROUGH from child and outputs NOOP. + // This mirrors BE's TableFunctionOperatorX::required_data_distribution() override. + // Parent's requireHash is ignored — TableFunction's own PASSTHROUGH requirement takes precedence. + Pair output = tableFunctionNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + assertChildLocalExchangeType(tableFunctionNode, 0, LocalExchangeType.PASSTHROUGH); + } + + @Test + public void testPartitionSortNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + SortInfo sortInfo = Mockito.mock(SortInfo.class); + TupleDescriptor sortTuple = new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())); + Mockito.when(sortInfo.getOrderingExprs()).thenReturn(Collections.emptyList()); + Mockito.when(sortInfo.getIsAscOrder()).thenReturn(Collections.emptyList()); + Mockito.when(sortInfo.getSortTupleDescriptor()).thenReturn(sortTuple); + + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + PartitionSortNode globalTopnNode = new PartitionSortNode(nextPlanNodeId(), childNoop, + WindowFuncType.ROW_NUMBER, Collections.emptyList(), sortInfo, false, 1, + PartitionTopnPhase.TWO_PHASE_GLOBAL_PTOPN); + Pair globalOutput = globalTopnNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + // enforceRequire resolves RequireHash to LOCAL_EXECUTION_HASH_SHUFFLE (FE-planned always uses LOCAL) + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, globalOutput.second); + assertChildLocalExchangeType(globalTopnNode, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + TrackingPlanNode childNoop2 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + PartitionSortNode passthroughNode = new PartitionSortNode(nextPlanNodeId(), childNoop2, + WindowFuncType.ROW_NUMBER, Collections.emptyList(), sortInfo, false, 1, + PartitionTopnPhase.TWO_PHASE_LOCAL_PTOPN); + Pair passthroughOutput = passthroughNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, passthroughOutput.second); + assertChildLocalExchangeType(passthroughNode, 0, LocalExchangeType.PASSTHROUGH); + } + + @Test + public void testMaterializationNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TupleDescriptor tupleDescriptor = new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())); + TestMaterializationNode node = new TestMaterializationNode(nextPlanNodeId(), tupleDescriptor, childNoop); + + // MaterializationNode.isSerialNode() returns true. Without a fragment context, + // isSerialOperatorOnBe() returns false (fragment == null guard), so the framework + // does not skip the Layer 1 check and inserts a LocalExchange(PASSTHROUGH) to satisfy + // MaterializationNode's requirePassthrough() requirement on its child. + // In production with fragment.useSerialSource=true, isSerialOperatorOnBe would be + // true and the framework would skip the LE — the test exercises the fragment-less + // unit-test path which reflects the BE behavior when the fragment is not in + // serial-source mode. + Pair output = node.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, output.second); + Assertions.assertInstanceOf(LocalExchangeNode.class, node.getChild(0)); + } + + @Test + public void testCteAndRecursiveNodesAndEmptySet() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + + CTEScanNode cteScanNode = new CTEScanNode(ScanContext.EMPTY); + Pair cteOutput = cteScanNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, cteOutput.second); + + RecursiveCteScanNode recursiveScanNode = new RecursiveCteScanNode("r", nextPlanNodeId(), + new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + Pair recursiveScanOutput = recursiveScanNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, recursiveScanOutput.second); + + EmptySetNode emptySetNode = new EmptySetNode(nextPlanNodeId(), + new ArrayList<>(Collections.singletonList(new TupleId(NEXT_ID.getAndIncrement())))); + Pair emptyOutput = emptySetNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, emptyOutput.second); + + TrackingPlanNode recursiveChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + RecursiveCteNode recursiveNode = new RecursiveCteNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement()), + "r", true); + recursiveNode.addChild(recursiveChild); + Pair recursiveOutput = recursiveNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, recursiveOutput.second); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, recursiveChild.lastRequire.getClass()); + } + + @Test + public void testHashJoinNodeBranches() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + List eqConjuncts = Collections.singletonList(Mockito.mock(BinaryPredicate.class)); + + TrackingPlanNode probe = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode build = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode broadcastJoin = new HashJoinNode(nextPlanNodeId(), probe, build, JoinOperator.INNER_JOIN, + eqConjuncts, Collections.emptyList(), null, null, false); + broadcastJoin.setDistributionMode(DistributionMode.BROADCAST); + Pair broadcastOutput = broadcastJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, broadcastOutput.second); + Assertions.assertSame(probe, broadcastJoin.getChild(0)); + Assertions.assertSame(build, broadcastJoin.getChild(1)); + + TrackingPlanNode probe2 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode build2 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode bucketJoin = new HashJoinNode(nextPlanNodeId(), probe2, build2, JoinOperator.INNER_JOIN, + eqConjuncts, Collections.emptyList(), null, null, false); + bucketJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair bucketOutput = bucketJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketOutput.second); + assertChildLocalExchangeType(bucketJoin, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + assertChildLocalExchangeType(bucketJoin, 1, LocalExchangeType.BUCKET_HASH_SHUFFLE); + + TrackingScanNode probeScan = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildPlan = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode hashJoin = new HashJoinNode(nextPlanNodeId(), probeScan, buildPlan, JoinOperator.INNER_JOIN, + eqConjuncts, Collections.emptyList(), null, null, false); + hashJoin.setDistributionMode(DistributionMode.PARTITIONED); + Pair hashOutput = hashJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + // PARTITIONED join requires GLOBAL hash to match cross-fragment exchange (DORIS-26101) + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, hashOutput.second); + assertChildLocalExchangeType(hashJoin, 0, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(hashJoin, 1, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + + // DORIS-26101: PARTITIONED join with probe child already providing GLOBAL hash + // (e.g. upstream ExchangeNode) should satisfy requireGlobalExecutionHash without + // inserting a new exchange. + TrackingPlanNode probeGlobal = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + TrackingPlanNode buildGlobal = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + HashJoinNode partitionedSatisfied = new HashJoinNode(nextPlanNodeId(), probeGlobal, buildGlobal, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + partitionedSatisfied.setDistributionMode(DistributionMode.PARTITIONED); + Pair satisfiedOutput = partitionedSatisfied.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, satisfiedOutput.second); + Assertions.assertSame(probeGlobal, partitionedSatisfied.getChild(0), + "no exchange should be inserted when child already provides GLOBAL hash"); + Assertions.assertSame(buildGlobal, partitionedSatisfied.getChild(1)); + + // DORIS-26120: PARTITIONED join with serial source falls back to LOCAL hash + // because GLOBAL shuffle_idx_to_instance_idx is incomplete for serial exchange. + TrackingScanNode probeSerial = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildSerial = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode serialPartitioned = new HashJoinNode(nextPlanNodeId(), probeSerial, buildSerial, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + serialPartitioned.setDistributionMode(DistributionMode.PARTITIONED); + serialPartitioned.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialPartitioned.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair serialPartOutput = serialPartitioned.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, serialPartOutput.second); + assertChildLocalExchangeType(serialPartitioned, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(serialPartitioned, 1, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + TrackingPlanNode probe3 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode build3 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode nullAwareJoin = new HashJoinNode(nextPlanNodeId(), probe3, build3, + JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + Pair nullAwareOutput = nullAwareJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, nullAwareOutput.second); + Assertions.assertSame(probe3, nullAwareJoin.getChild(0)); + Assertions.assertSame(build3, nullAwareJoin.getChild(1)); + + SerialTrackingPlanNode serialProbe = new SerialTrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + serialProbe.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialProbe.fragment.useSerialSource(Mockito.any())).thenReturn(true); + TrackingPlanNode nonSerialBuild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + nonSerialBuild.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(nonSerialBuild.fragment.useSerialSource(Mockito.any())).thenReturn(true); + HashJoinNode serialProbeBroadcast = new HashJoinNode(nextPlanNodeId(), serialProbe, nonSerialBuild, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + serialProbeBroadcast.setDistributionMode(DistributionMode.BROADCAST); + // BROADCAST serial check uses fragment.useSerialSource() on the HashJoinNode itself + serialProbeBroadcast.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialProbeBroadcast.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair serialProbeOutput = serialProbeBroadcast.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, serialProbeOutput.second); + assertChildLocalExchangeType(serialProbeBroadcast, 0, LocalExchangeType.PASSTHROUGH); + Assertions.assertSame(nonSerialBuild, serialProbeBroadcast.getChild(1)); + + TrackingPlanNode nonSerialProbe = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + nonSerialProbe.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(nonSerialProbe.fragment.useSerialSource(Mockito.any())).thenReturn(true); + SerialTrackingPlanNode serialBuild = new SerialTrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + serialBuild.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialBuild.fragment.useSerialSource(Mockito.any())).thenReturn(true); + HashJoinNode serialBuildBroadcast = new HashJoinNode(nextPlanNodeId(), nonSerialProbe, serialBuild, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + serialBuildBroadcast.setDistributionMode(DistributionMode.BROADCAST); + serialBuildBroadcast.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialBuildBroadcast.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair serialBuildOutput = serialBuildBroadcast.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, serialBuildOutput.second); + Assertions.assertSame(nonSerialProbe, serialBuildBroadcast.getChild(0)); + assertChildLocalExchangeType(serialBuildBroadcast, 1, LocalExchangeType.PASS_TO_ONE); + } + + @Test + public void testLocalExchangeNodeIsNotSerializedAsSerialOperator() { + SerialTrackingScanNode serialScan = new SerialTrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + LocalExchangeNode localExchangeNode = new LocalExchangeNode(nextPlanNodeId(), serialScan, + LocalExchangeType.PASSTHROUGH); + localExchangeNode.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(localExchangeNode.fragment.hasSerialScanNode()).thenReturn(true); + Mockito.when(localExchangeNode.fragment.useSerialSource(Mockito.any())).thenReturn(true); + + TPlanNode thriftNode = new TPlanNode(); + localExchangeNode.toThrift(thriftNode); + + Assertions.assertFalse(thriftNode.isIsSerialOperator(), + "local exchange source pipeline should not be marked serial in thrift"); + } + + @Test + public void testLayer1SkipUsesIsSerialOperatorOnBeNotIsSerialNode() { + // Guard against regression of the isSerialNode -> isSerialOperatorOnBe fix + // (PR #63366 review feedback). When a node's isSerialNode()=true but its + // fragment is NOT in serial-source mode (fragment.useSerialSource(ctx)=false), + // BE's Thrift `is_serial_operator` flag is false, so BE's + // Pipeline::need_to_local_exchange does NOT skip local exchange. + // + // The FE framework must mirror that — both the serial-ancestor propagation + // (enforceRequire step 1) and the Layer 1 skip (enforceRequire step 4b) + // must consult isSerialOperatorOnBe(ctx), not the raw isSerialNode(). + // Otherwise we over-skip required LocalExchanges in fragments where + // ignore_storage_data_distribution=false / NAAJ / query-cache disables + // serial-source mode at the fragment level. + // + // Setup: a node with isSerialNode()=true but fragment.useSerialSource(ctx)=false. + // It declares requireHash on its child whose output is NOOP. If Layer 1 used + // the raw isSerialNode(), the framework would skip LE. With the fix it must + // insert a LocalExchange(LOCAL_EXECUTION_HASH_SHUFFLE). + PlanTranslatorContext ctx = new PlanTranslatorContext(); + TrackingPlanNode childNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + SerialNodeInNonSerialFragment parent = new SerialNodeInNonSerialFragment( + nextPlanNodeId(), childNoop); + parent.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(parent.fragment.useSerialSource(Mockito.any())).thenReturn(false); + + Pair output = parent.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + Assertions.assertInstanceOf(LocalExchangeNode.class, parent.getChild(0), + "Layer 1 must NOT skip LE when fragment.useSerialSource=false, " + + "even if isSerialNode()=true — BE treats the node as non-serial."); + } + + @Test + public void testNestedLoopJoinNodeBranches() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + List tupleIds = Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())); + + TrackingPlanNode probe = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode build = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + NestedLoopJoinNode defaultJoin = new NestedLoopJoinNode(nextPlanNodeId(), probe, build, tupleIds, + JoinOperator.INNER_JOIN, false); + defaultJoin.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(defaultJoin.fragment.useSerialSource(Mockito.any())).thenReturn(false); + Pair defaultOutput = defaultJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.ADAPTIVE_PASSTHROUGH, defaultOutput.second); + assertChildLocalExchangeType(defaultJoin, 0, LocalExchangeType.ADAPTIVE_PASSTHROUGH); + Assertions.assertSame(build, defaultJoin.getChild(1)); + + TrackingScanNode probeScan = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + NestedLoopJoinNode serialSourceJoin = new NestedLoopJoinNode(nextPlanNodeId(), probeScan, buildNoop, + Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())), JoinOperator.INNER_JOIN, false); + serialSourceJoin.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialSourceJoin.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair serialOutput = serialSourceJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.ADAPTIVE_PASSTHROUGH, serialOutput.second); + assertChildLocalExchangeType(serialSourceJoin, 0, LocalExchangeType.ADAPTIVE_PASSTHROUGH); + assertChildLocalExchangeType(serialSourceJoin, 1, LocalExchangeType.BROADCAST); + + // RIGHT_OUTER/FULL_OUTER: probe side must use NOOP (serial processing for unmatched rows). + // BE: NestedLoopJoinProbeOperatorX returns NOOP for these join types. + TrackingPlanNode probeRight = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildRight = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + NestedLoopJoinNode rightOuterJoin = new NestedLoopJoinNode(nextPlanNodeId(), probeRight, buildRight, + Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())), + JoinOperator.RIGHT_OUTER_JOIN, false); + rightOuterJoin.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(rightOuterJoin.fragment.useSerialSource(Mockito.any())).thenReturn(false); + Pair rightOuterOutput = rightOuterJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, rightOuterOutput.second); + Assertions.assertSame(probeRight, rightOuterJoin.getChild(0)); + + TrackingPlanNode probe2 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode build2 = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + NestedLoopJoinNode nullAwareJoin = new NestedLoopJoinNode(nextPlanNodeId(), probe2, build2, + Lists.newArrayList(new TupleId(NEXT_ID.getAndIncrement())), + JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN, false); + nullAwareJoin.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(nullAwareJoin.fragment.useSerialSource(Mockito.any())).thenReturn(false); + Pair nullAwareOutput = nullAwareJoin.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, nullAwareOutput.second); + } + + @Test + public void testSetOperationAndAssertNumRowsNode() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + + UnionNode unionNode = new UnionNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); + TrackingPlanNode unionChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + unionNode.addChild(unionChild); + // UnionNode propagates parent hash require to children only when a downstream operator + // requires shuffle for correctness. Simulate that via the context flag. + ctx.setHasShuffleForCorrectnessAncestor(unionNode, true); + Pair unionOutput = unionNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, unionOutput.second); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, unionChild.lastRequire.getClass()); + + IntersectNode intersectNode = new IntersectNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); + intersectNode.setColocate(false); + TrackingScanNode left = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode right = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + intersectNode.addChild(left); + intersectNode.addChild(right); + Pair intersectOutput = intersectNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + // PARTITIONED intersect requires GLOBAL hash (DORIS-26100) + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, intersectOutput.second); + assertChildLocalExchangeType(intersectNode, 0, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(intersectNode, 1, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + + // Colocated ExceptNode with OlapScan children: OlapScan already provides BUCKET_HASH_SHUFFLE, + // so requireBucketHash() is satisfied and no LocalExchangeNode is inserted. + ExceptNode exceptNode = new ExceptNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); + exceptNode.setColocate(true); + FakeOlapScanNode exceptLeft = new FakeOlapScanNode(nextPlanNodeId()); + FakeOlapScanNode exceptRight = new FakeOlapScanNode(nextPlanNodeId()); + exceptNode.addChild(exceptLeft); + exceptNode.addChild(exceptRight); + Pair exceptOutput = exceptNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, exceptOutput.second); + // OlapScan already satisfies requireBucketHash(), so children are passed through unchanged. + Assertions.assertSame(exceptLeft, exceptNode.getChild(0)); + Assertions.assertSame(exceptRight, exceptNode.getChild(1)); + + TrackingPlanNode assertChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + AssertNumRowsElement assertElement = Mockito.mock(AssertNumRowsElement.class); + Mockito.when(assertElement.getDesiredNumOfRows()).thenReturn(1L); + Mockito.when(assertElement.getSubqueryString()).thenReturn("subquery"); + Mockito.when(assertElement.getAssertion()).thenReturn(AssertNumRowsElement.Assertion.EQ); + AssertNumRowsNode assertNode = new AssertNumRowsNode(nextPlanNodeId(), assertChild, + assertElement, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + // AssertNumRowsNode.isSerialNode()=true. Without a fragment context, + // isSerialOperatorOnBe()=false so the framework falls through Layer 1 and inserts + // a LocalExchange(PASSTHROUGH) — same fragment-less rationale as the + // MaterializationNode test above. In production with fragment.useSerialSource=true + // the LE would be skipped. + Pair assertOutput = assertNode.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, assertOutput.second); + Assertions.assertInstanceOf(LocalExchangeNode.class, assertNode.getChild(0)); + } + + @Test + public void testSortNodeBranches() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + SortInfo sortInfo = mockSortInfo(); + + TrackingPlanNode mergeChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + SortNode mergeSort = new SortNode(nextPlanNodeId(), mergeChild, sortInfo, false); + mergeSort.setMergeByExchange(); + Pair mergeOutput = mergeSort.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, mergeOutput.second); + assertChildLocalExchangeType(mergeSort, 0, LocalExchangeType.PASSTHROUGH); + + // Non-merge, non-analytic SortNode: isSerialNode()=true → enforceChild skips exchange. + // Output is still PASSTHROUGH (hardcoded for useSerialSource + ScanNode child). + SerialTrackingScanNode serialScan = new SerialTrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + SortNode scanSort = new SortNode(nextPlanNodeId(), serialScan, sortInfo, false); + scanSort.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(scanSort.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair scanOutput = scanSort.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + // Non-merge, non-analytic SortNode: isSerialNode()=true, requireChild=noRequire, + // outputType=NOOP. enforceRequire shouldSkipLE skips because Sort itself is serial. + Assertions.assertEquals(LocalExchangeType.NOOP, scanOutput.second); + // SortNode is serial → enforceRequire skips exchange → child unchanged. + Assertions.assertSame(serialScan, scanSort.getChild(0)); + + // Analytic sort (mergeByexchange=false): sort before analytic with partition + orderBy. + // AnalyticEvalNode returns NOOP (non-serial, has partition+order), SortNode enforceChild + // inserts LOCAL_EXECUTION_HASH_SHUFFLE (RequireHash → resolveExchangeType → LOCAL). + AnalyticEvalNode analyticChild = new AnalyticEvalNode(nextPlanNodeId(), + new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP), + Collections.emptyList(), Collections.singletonList(Mockito.mock(Expr.class)), + Collections.singletonList(new OrderByElement(Mockito.mock(Expr.class), true, true)), + null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + analyticChild.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(analyticChild.fragment.useSerialSource(Mockito.any())).thenReturn(false); + SortNode analyticSort = new SortNode(nextPlanNodeId(), analyticChild, sortInfo, false); + analyticSort.setIsAnalyticSort(true); // Must set for isSerialNode() to return false + Pair analyticOutput = analyticSort.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, analyticOutput.second); + assertChildLocalExchangeType(analyticSort, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + // Outer merge-sort above analytic (mergeByexchange=true): BE SortSink._merge_by_exchange=true → PASSTHROUGH. + // Should NOT insert GLOBAL_HASH even though child is AnalyticEvalNode. + AnalyticEvalNode analyticChild2 = new AnalyticEvalNode(nextPlanNodeId(), + new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP), + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + analyticChild2.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(analyticChild2.fragment.useSerialSource(Mockito.any())).thenReturn(false); + SortNode mergeAnalyticSort = new SortNode(nextPlanNodeId(), analyticChild2, sortInfo, false); + mergeAnalyticSort.setMergeByExchange(); + Pair mergeAnalyticOutput = mergeAnalyticSort.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, mergeAnalyticOutput.second); + } + + @Test + public void testAnalyticEvalNodeBranches() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + + TrackingPlanNode noPartitionChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + AnalyticEvalNode noPartition = new AnalyticEvalNode(nextPlanNodeId(), noPartitionChild, + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + // No partition → isSerialNode()=true → returns NOOP (serial nodes let framework handle). + Pair noPartitionOutput = noPartition.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, noPartitionOutput.second); + Assertions.assertSame(noPartitionChild, noPartition.getChild(0)); + + // Analytic with partition but no orderBy, non-colocated → noRequire/NOOP. + // (Non-colocated analytic relies on parent SortNode to handle distribution.) + TrackingScanNode hashChild = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + AnalyticEvalNode hashAnalytic = new AnalyticEvalNode(nextPlanNodeId(), hashChild, + Collections.emptyList(), Collections.singletonList(Mockito.mock(Expr.class)), + Collections.emptyList(), null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + Pair hashOutput = hashAnalytic.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, hashOutput.second); + // No exchange inserted — child remains unchanged. + Assertions.assertSame(hashChild, hashAnalytic.getChild(0)); + + SerialTrackingScanNode serialScan = new SerialTrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + AnalyticEvalNode orderedAnalytic = new AnalyticEvalNode(nextPlanNodeId(), serialScan, + Collections.emptyList(), Collections.singletonList(Mockito.mock(Expr.class)), + Collections.singletonList(new OrderByElement(Mockito.mock(Expr.class), true, true)), + null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + orderedAnalytic.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(orderedAnalytic.fragment.useSerialSource(Mockito.any())).thenReturn(true); + Pair orderedOutput = orderedAnalytic.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + // Serial AnalyticEval returns NOOP — lets framework serial check handle fan-out + Assertions.assertEquals(LocalExchangeType.NOOP, orderedOutput.second); + } + + @Test + public void testExchangeNodeBranches() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + + ExchangeNode hashExchange = new ExchangeNode(nextPlanNodeId(), + new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP)); + hashExchange.setPartitionType(TPartitionType.HASH_PARTITIONED); + Pair hashOutput = hashExchange.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, hashOutput.second); + + ExchangeNode bucketExchange = new ExchangeNode(nextPlanNodeId(), + new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP)); + bucketExchange.setPartitionType(TPartitionType.BUCKET_SHFFULE_HASH_PARTITIONED); + Pair bucketOutput = bucketExchange.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketOutput.second); + + ExchangeNode noopExchange = new ExchangeNode(nextPlanNodeId(), + new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP)); + noopExchange.setPartitionType(TPartitionType.UNPARTITIONED); + Pair noopOutput = noopExchange.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, noopOutput.second); + } + + private static PlanNodeId nextPlanNodeId() { + return new PlanNodeId(NEXT_ID.getAndIncrement()); + } + + private static void assertChildLocalExchangeType(PlanNode node, int index, LocalExchangeType expectedType) { + Assertions.assertTrue(node.getChild(index) instanceof LocalExchangeNode, + "expected child " + index + " to be LocalExchangeNode"); + LocalExchangeNode exchangeNode = (LocalExchangeNode) node.getChild(index); + Assertions.assertEquals(expectedType, exchangeNode.getExchangeType()); + } + + private static SortInfo mockSortInfo() { + SortInfo sortInfo = Mockito.mock(SortInfo.class); + TupleDescriptor sortTuple = new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement())); + Mockito.when(sortInfo.getOrderingExprs()).thenReturn(Collections.emptyList()); + Mockito.when(sortInfo.getIsAscOrder()).thenReturn(Collections.emptyList()); + Mockito.when(sortInfo.getSortTupleDescriptor()).thenReturn(sortTuple); + return sortInfo; + } + + /** + * Helper for testLayer1SkipUsesIsSerialOperatorOnBeNotIsSerialNode: a PlanNode that + * reports isSerialNode()=true but whose fragment can be mocked to return + * useSerialSource=false, exercising the discrepancy the review fix targets. + */ + private static class SerialNodeInNonSerialFragment extends PlanNode { + SerialNodeInNonSerialFragment(PlanNodeId id, PlanNode child) { + super(id, Lists.newArrayList(new TupleId(id.asInt() + 20000)), + "SERIAL_NODE_IN_NON_SERIAL_FRAGMENT"); + children.add(child); + } + + @Override + public boolean isSerialNode() { + return true; + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, + LocalExchangeTypeRequire parentRequire) { + // Require hash so the satisfy() check fails on the child's NOOP output, + // forcing the framework into Layer 1 — which is where the + // isSerialNode/isSerialOperatorOnBe choice matters. + Pair result = enforceRequire(translatorContext, + children.get(0), 0, LocalExchangeTypeRequire.requireHash()); + children = Lists.newArrayList(result.first); + return Pair.of(this, result.second); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + + private static class TrackingPlanNode extends PlanNode { + private final LocalExchangeType providedType; + private LocalExchangeTypeRequire lastRequire; + + TrackingPlanNode(PlanNodeId id, LocalExchangeType providedType) { + super(id, Lists.newArrayList(new TupleId(id.asInt() + 10000)), "TRACKING"); + this.providedType = providedType; + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + this.lastRequire = parentRequire; + return Pair.of(this, providedType); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + + private static class SerialTrackingPlanNode extends TrackingPlanNode { + SerialTrackingPlanNode(PlanNodeId id, LocalExchangeType providedType) { + super(id, providedType); + } + + @Override + public boolean isSerialNode() { + return true; + } + } + + private static class TrackingScanNode extends ScanNode { + private final LocalExchangeType providedType; + private LocalExchangeTypeRequire lastRequire; + + TrackingScanNode(PlanNodeId id, LocalExchangeType providedType) { + super(id, new TupleDescriptor(new TupleId(id.asInt() + 20000)), "TRACKING_SCAN", ScanContext.EMPTY); + this.providedType = providedType; + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, LocalExchangeTypeRequire parentRequire) { + this.lastRequire = parentRequire; + return Pair.of(this, providedType); + } + + @Override + protected void createScanRangeLocations() throws UserException { + } + + @Override + public List getScanRangeLocations(long maxScanRangeLength) { + return Collections.emptyList(); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + + private static class SerialTrackingScanNode extends TrackingScanNode { + SerialTrackingScanNode(PlanNodeId id, LocalExchangeType providedType) { + super(id, providedType); + } + + @Override + public boolean isSerialNode() { + return true; + } + } + + private static class FakeOlapScanNode extends OlapScanNode { + FakeOlapScanNode(PlanNodeId id) { + super(id, mockTupleDescriptor(id), "FAKE_OLAP_SCAN", ScanContext.EMPTY); + } + + @Override + protected void createScanRangeLocations() throws UserException { + } + + @Override + public List getScanRangeLocations(long maxScanRangeLength) { + return Collections.emptyList(); + } + + private static TupleDescriptor mockTupleDescriptor(PlanNodeId id) { + TupleDescriptor desc = Mockito.mock(TupleDescriptor.class); + org.apache.doris.catalog.OlapTable table = Mockito.mock(org.apache.doris.catalog.OlapTable.class); + Mockito.when(desc.getId()).thenReturn(new TupleId(id.asInt() + 30000)); + Mockito.when(desc.getTable()).thenReturn(table); + Mockito.when(desc.getSlots()).thenReturn(new ArrayList()); + Mockito.when(table.getDistributionColumnNames()).thenReturn(Collections.emptySet()); + return desc; + } + } + + private static class TestMaterializationNode extends MaterializationNode { + TestMaterializationNode(PlanNodeId id, TupleDescriptor desc, PlanNode child) { + super(id, desc, child); + } + + @Override + public void initNodeInfo() { + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShape.java b/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShape.java new file mode 100644 index 00000000000000..39022c4283a0d5 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShape.java @@ -0,0 +1,335 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.planner; + +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; + +import com.google.common.collect.ImmutableList; + +import java.util.List; +import java.util.function.Predicate; + +/** + * Plan-shape DSL for asserting {@link PlanNode} tree structure in tests. + * Inspired by Trino's {@code PlanMatchPattern}. + * + *

Use with {@code import static org.apache.doris.planner.PlanShape.*;} to + * drop the {@code PlanShape.} prefix from call sites: + *

{@code
+ * import static org.apache.doris.planner.PlanShape.*;
+ * import static org.apache.doris.planner.LocalExchangeNode.LocalExchangeType.*;
+ *
+ * assertMatchesAnyFragment(planner.getFragments(),
+ *     anyTree(
+ *         agg(
+ *             localExchange(LOCAL_EXECUTION_HASH_SHUFFLE,
+ *                 olapScan("t1")))));
+ * }
+ * + *

This reads bottom-up: somewhere in the plan tree there must be an + * {@link AggregationNode} whose direct child is a + * {@link LocalExchangeNode}(LOCAL_EXECUTION_HASH_SHUFFLE) whose direct child + * is an {@link OlapScanNode} on table {@code t1}. + * + *

Add a predicate with the fluent {@link #where(Predicate)} method. + * The lambda parameter is typed to the most specific {@link PlanNode} + * subclass declared by the factory — no explicit cast is required: + *

{@code
+ * agg(...).where(a -> a.isColocate())
+ * }
+ * + * @param the {@link PlanNode} subclass this shape matches against; lets + * {@link #where(Predicate)} infer the lambda parameter type automatically. + */ +public final class PlanShape { + private final Class nodeClass; + private final Predicate filter; + private final List> children; + private final boolean anyTreeMatch; + private final String label; + + private PlanShape(Class nodeClass, Predicate filter, + List> children, boolean anyTreeMatch, String label) { + this.nodeClass = nodeClass; + this.filter = filter; + this.children = children; + this.anyTreeMatch = anyTreeMatch; + this.label = label; + } + + // ---- generic factories ---- + + /** Match a node of the given class with the given direct children patterns. */ + public static PlanShape node(Class cls, PlanShape... children) { + return new PlanShape<>(cls, null, ImmutableList.copyOf(children), false, cls.getSimpleName()); + } + + /** + * Skip any number of intermediate nodes; the {@code inner} pattern can match + * anywhere in the subtree rooted at the current node (including the node itself). + */ + public static PlanShape anyTree(PlanShape inner) { + return new PlanShape<>(PlanNode.class, null, ImmutableList.of(inner), true, "anyTree"); + } + + /** Match any single node (regardless of class) that has the given children. */ + public static PlanShape anyNode(PlanShape... children) { + return new PlanShape<>(PlanNode.class, null, ImmutableList.copyOf(children), false, "anyNode"); + } + + /** + * Match a node that is NOT an instance of {@code excludedClass}, with the given + * direct children patterns. Useful for asserting that a particular node type is + * absent at a specific position. + * + *

Typical use: assert that an LE was skipped where the framework should not + * have inserted one. + *

{@code
+     * agg(anyNot(LocalExchangeNode.class, olapScan("t1")))
+     * }
+ * reads as "AggregationNode whose direct child is NOT a LocalExchangeNode and + * is itself an OlapScan('t1')". + */ + public static PlanShape anyNot(Class excludedClass, + PlanShape... children) { + return new PlanShape<>(PlanNode.class, + n -> !excludedClass.isInstance(n), + ImmutableList.copyOf(children), false, + "anyNot(" + excludedClass.getSimpleName() + ")"); + } + + // ---- convenience wrappers for common Doris nodes ---- + + public static PlanShape localExchange(LocalExchangeType type, PlanShape... children) { + return new PlanShape<>(LocalExchangeNode.class, + le -> le.getExchangeType() == type, + ImmutableList.copyOf(children), false, + "LocalExchange(" + type + ")"); + } + + public static PlanShape agg(PlanShape... children) { + return node(AggregationNode.class, children); + } + + public static PlanShape olapScan(String tableName, PlanShape... children) { + return new PlanShape<>(OlapScanNode.class, + s -> tableName.equalsIgnoreCase(s.getOlapTable().getName()), + ImmutableList.copyOf(children), false, + "OlapScan(" + tableName + ")"); + } + + public static PlanShape olapScan() { + return node(OlapScanNode.class); + } + + public static PlanShape exchange(PlanShape... children) { + return node(ExchangeNode.class, children); + } + + public static PlanShape hashJoin(PlanShape... children) { + return node(HashJoinNode.class, children); + } + + public static PlanShape sort(PlanShape... children) { + return node(SortNode.class, children); + } + + public static PlanShape analytic(PlanShape... children) { + return node(AnalyticEvalNode.class, children); + } + + public static PlanShape union(PlanShape... children) { + return node(UnionNode.class, children); + } + + public static PlanShape nestedLoopJoin(PlanShape... children) { + return node(NestedLoopJoinNode.class, children); + } + + public static PlanShape partitionSort(PlanShape... children) { + return node(PartitionSortNode.class, children); + } + + // ---- chained predicate ---- + + /** + * Constrain the current node to also satisfy {@code predicate}. The lambda + * parameter type is inferred from the receiver's generic type parameter + * (e.g. {@link AggregationNode} for {@link #agg}); no explicit cast required. + */ + public PlanShape where(Predicate predicate) { + Predicate combined = filter == null ? predicate : filter.and(predicate); + return new PlanShape<>(nodeClass, combined, children, anyTreeMatch, label); + } + + // ---- assertion entry points ---- + + /** Assert that {@code shape} matches the plan tree rooted at {@code root}. */ + public static void assertMatches(PlanNode root, PlanShape shape) { + shape.assertMatchesImpl(root); + } + + /** + * Assert that {@code shape} matches the plan tree of at least one fragment. + * Useful when the interesting fragment is buried among several (typical for + * distributed plans with scan / shuffle fragments). + */ + public static void assertMatchesAnyFragment(List fragments, PlanShape shape) { + for (PlanFragment fragment : fragments) { + if (shape.match(fragment.getPlanRoot()).matched) { + return; + } + } + StringBuilder dump = new StringBuilder(); + for (PlanFragment fragment : fragments) { + dump.append("[fragment ").append(fragment.getFragmentId()).append("]\n"); + dump.append(indent(dumpTree(fragment.getPlanRoot()), " ")); + } + throw new AssertionError("Plan shape did not match any of the " + fragments.size() + + " fragments.\n expected: " + shape.describe() + "\n fragments:\n" + + indent(dump.toString(), " ")); + } + + private void assertMatchesImpl(PlanNode root) { + MatchResult r = match(root); + if (!r.matched) { + throw new AssertionError("Plan shape mismatch.\n" + + " expected: " + describe() + "\n" + + " failure : " + r.failureReason + "\n" + + " actual tree:\n" + indent(dumpTree(root), " ")); + } + } + + // ---- matching impl ---- + + private MatchResult match(PlanNode node) { + if (anyTreeMatch) { + return findInSubtree(node, children.get(0)); + } + if (!nodeClass.isInstance(node)) { + return MatchResult.fail("expected " + label + " but got " + + node.getClass().getSimpleName() + "#" + node.getId().asInt()); + } + if (filter != null && !filter.test(nodeClass.cast(node))) { + return MatchResult.fail("predicate failed on " + label + "#" + node.getId().asInt()); + } + if (children.isEmpty()) { + return MatchResult.OK; + } + if (node.getChildren().size() != children.size()) { + return MatchResult.fail(label + "#" + node.getId().asInt() + " expected " + + children.size() + " children, got " + node.getChildren().size()); + } + for (int i = 0; i < children.size(); i++) { + MatchResult r = children.get(i).match(node.getChild(i)); + if (!r.matched) { + return MatchResult.fail("at " + label + "#" + node.getId().asInt() + ".child[" + i + + "]: " + r.failureReason); + } + } + return MatchResult.OK; + } + + private static MatchResult findInSubtree(PlanNode root, PlanShape inner) { + MatchResult r = inner.match(root); + if (r.matched) { + return r; + } + for (PlanNode child : root.getChildren()) { + r = findInSubtree(child, inner); + if (r.matched) { + return r; + } + } + return MatchResult.fail("no node in subtree matches " + inner.describe()); + } + + private String describe() { + if (anyTreeMatch) { + return "anyTree(" + children.get(0).describe() + ")"; + } + StringBuilder sb = new StringBuilder(label); + if (!children.isEmpty()) { + sb.append("("); + for (int i = 0; i < children.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(children.get(i).describe()); + } + sb.append(")"); + } + return sb.toString(); + } + + /** + * Render a plan tree as a text outline (one node per line, indented by depth). + * Useful for debugging when writing new shape assertions — print the actual + * plan, then copy the structure into a {@code PlanShape} pattern. + */ + public static String prettyPrint(PlanNode root) { + return dumpTree(root); + } + + private static String dumpTree(PlanNode root) { + StringBuilder sb = new StringBuilder(); + dumpTree(root, sb, 0); + return sb.toString(); + } + + private static void dumpTree(PlanNode node, StringBuilder sb, int depth) { + for (int i = 0; i < depth; i++) { + sb.append(" "); + } + sb.append(node.getClass().getSimpleName()).append("#").append(node.getId().asInt()); + if (node instanceof LocalExchangeNode) { + sb.append("(").append(((LocalExchangeNode) node).getExchangeType()).append(")"); + } + sb.append("\n"); + for (PlanNode child : node.getChildren()) { + dumpTree(child, sb, depth + 1); + } + } + + private static String indent(String text, String prefix) { + StringBuilder sb = new StringBuilder(); + for (String line : text.split("\n", -1)) { + if (!line.isEmpty()) { + sb.append(prefix); + } + sb.append(line).append("\n"); + } + return sb.toString(); + } + + private static final class MatchResult { + final boolean matched; + final String failureReason; + + private MatchResult(boolean matched, String failureReason) { + this.matched = matched; + this.failureReason = failureReason; + } + + static final MatchResult OK = new MatchResult(true, null); + + static MatchResult fail(String reason) { + return new MatchResult(false, reason); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShapeDsl.java b/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShapeDsl.java new file mode 100644 index 00000000000000..4e5ce68073f900 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/PlanShapeDsl.java @@ -0,0 +1,152 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.planner; + +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; + +import java.util.List; + +/** + * Implement this interface in a test class to use the {@link PlanShape} DSL + * factories without a {@code PlanShape.} prefix and without {@code static} + * imports (which are forbidden by Doris's checkstyle rule + * {@code AvoidStaticImport}). + * + *

Each factory is a {@code default} method that delegates to the static one + * on {@link PlanShape}. The shortened {@code LocalExchangeType} aliases + * (LOCAL_HASH, GLOBAL_HASH, BUCKET_HASH, etc.) are interface constants so + * implementing classes can reference them by bare name. + * + *

Example: + *

{@code
+ * public class MyTest extends TestWithFeService implements PlanShapeDsl {
+ *     @Test
+ *     public void testAggOverScan() {
+ *         assertPlanShape(sql,
+ *             anyTree(agg(localExchange(LOCAL_HASH, olapScan("t1")))));
+ *     }
+ * }
+ * }
+ */ +public interface PlanShapeDsl { + + // ---- shortened LocalExchangeType aliases ---- + // Avoids spelling LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE in every test. + + LocalExchangeType LOCAL_HASH = LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE; + LocalExchangeType GLOBAL_HASH = LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE; + LocalExchangeType BUCKET_HASH = LocalExchangeType.BUCKET_HASH_SHUFFLE; + LocalExchangeType PT = LocalExchangeType.PASSTHROUGH; + LocalExchangeType ADAPTIVE_PT = LocalExchangeType.ADAPTIVE_PASSTHROUGH; + LocalExchangeType BROADCAST_LE = LocalExchangeType.BROADCAST; + LocalExchangeType PASS_TO_ONE_LE = LocalExchangeType.PASS_TO_ONE; + LocalExchangeType NOOP_LE = LocalExchangeType.NOOP; + + // ---- structural factories ---- + + default PlanShape node(Class cls, PlanShape... children) { + return PlanShape.node(cls, children); + } + + default PlanShape anyTree(PlanShape inner) { + return PlanShape.anyTree(inner); + } + + default PlanShape anyNode(PlanShape... children) { + return PlanShape.anyNode(children); + } + + default PlanShape anyNot(Class excludedClass, + PlanShape... children) { + return PlanShape.anyNot(excludedClass, children); + } + + // ---- Doris-specific node factories ---- + + default PlanShape localExchange(LocalExchangeType type, + PlanShape... children) { + return PlanShape.localExchange(type, children); + } + + default PlanShape agg(PlanShape... children) { + return PlanShape.agg(children); + } + + default PlanShape olapScan(String tableName, PlanShape... children) { + return PlanShape.olapScan(tableName, children); + } + + default PlanShape olapScan() { + return PlanShape.olapScan(); + } + + default PlanShape exchange(PlanShape... children) { + return PlanShape.exchange(children); + } + + default PlanShape hashJoin(PlanShape... children) { + return PlanShape.hashJoin(children); + } + + default PlanShape sort(PlanShape... children) { + return PlanShape.sort(children); + } + + default PlanShape analytic(PlanShape... children) { + return PlanShape.analytic(children); + } + + default PlanShape union(PlanShape... children) { + return PlanShape.union(children); + } + + default PlanShape nestedLoopJoin(PlanShape... children) { + return PlanShape.nestedLoopJoin(children); + } + + default PlanShape partitionSort(PlanShape... children) { + return PlanShape.partitionSort(children); + } + + default PlanShape repeat(PlanShape... children) { + return PlanShape.node(RepeatNode.class, children); + } + + // ---- assertion entry points ---- + + default void assertMatches(PlanNode root, PlanShape shape) { + PlanShape.assertMatches(root, shape); + } + + default void assertMatchesAnyFragment(List fragments, PlanShape shape) { + PlanShape.assertMatchesAnyFragment(fragments, shape); + } + + /** + * Print all fragments' plan trees to stderr. Useful for one-off debugging + * when writing a new shape assertion: print first, copy the structure into a + * {@link PlanShape} pattern, then remove the call. Not intended to be left + * in committed tests. + */ + default void printFragmentPlans(List fragments) { + for (PlanFragment f : fragments) { + System.err.println("=== fragment " + f.getFragmentId() + " ==="); + System.err.println(PlanShape.prettyPrint(f.getPlanRoot())); + } + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java new file mode 100644 index 00000000000000..66e7578f0fa544 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java @@ -0,0 +1,721 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.qe; + +import org.apache.doris.analysis.TupleDescriptor; +import org.apache.doris.analysis.TupleId; +import org.apache.doris.common.UserException; +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.planner.AddLocalExchange; +import org.apache.doris.planner.LocalExchangeNode; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PlanNode; +import org.apache.doris.planner.PlanNodeId; +import org.apache.doris.planner.PlanShape; +import org.apache.doris.planner.PlanShapeDsl; +import org.apache.doris.planner.ScanContext; +import org.apache.doris.planner.ScanNode; +import org.apache.doris.thrift.TExplainLevel; +import org.apache.doris.thrift.TPlanNode; +import org.apache.doris.thrift.TScanRangeLocations; +import org.apache.doris.utframe.TestWithFeService; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +public class LocalExchangePlannerTest extends TestWithFeService implements PlanShapeDsl { + @Override + protected int backendNum() { + return 3; + } + + @Override + protected void runBeforeAll() throws Exception { + createDatabase("test"); + useDatabase("test"); + createTable("CREATE TABLE test.t1 (k1 INT, k2 INT, v1 INT) " + + "DISTRIBUTED BY HASH(k1) BUCKETS 6 " + + "PROPERTIES ('replication_num'='1')"); + createTable("CREATE TABLE test.t2 (k1 INT, k2 INT, v2 INT) " + + "DISTRIBUTED BY HASH(k1) BUCKETS 6 " + + "PROPERTIES ('replication_num'='1')"); + } + + // ---- helpers ---- + + /** + * Apply the default local-shuffle session config and let the caller tweak it. + * Mirrors Trino's {@code Session.builder(defaultSession).setSystemProperty(...).build()} + * pattern — tests only express what differs from the default, not the full set. + */ + protected void setupLocalShuffleSession(java.util.function.Consumer tweaks) + throws Exception { + SessionVariable sv = connectContext.getSessionVariable(); + sv.setEnableLocalShufflePlanner(true); + sv.setEnableLocalShuffle(true); + sv.setEnableNereidsDistributePlanner(true); + sv.setIgnoreStorageDataDistribution(true); + sv.setPipelineTaskNum("4"); + sv.setForceToLocalShuffle(false); + if (tweaks != null) { + tweaks.accept(sv); + } + } + + /** + * Run the SQL through the planner and assert the resulting distributed plan + * matches {@code shape} in at least one fragment. Replaces the boilerplate + * {@code executeNereidsSql + cast planner + collect fragments + manual asserts} + * with a one-liner, in the spirit of Trino's + * {@code assertDistributedPlan(sql, pattern)}. + */ + protected void assertPlanShape(String sql, PlanShape shape) throws Exception { + StmtExecutor executor = executeNereidsSql("explain distributed plan " + sql); + NereidsPlanner planner = (NereidsPlanner) executor.planner(); + assertMatchesAnyFragment(planner.getFragments(), shape); + } + + /** + * Debug-only helper: run the SQL and dump every fragment's plan tree to stderr. + * Use this when crafting a new {@link #assertPlanShape} assertion to see what + * the real plan looks like, then replace this call with the proper DSL pattern. + */ + protected void dumpPlan(String sql) throws Exception { + StmtExecutor executor = executeNereidsSql("explain distributed plan " + sql); + NereidsPlanner planner = (NereidsPlanner) executor.planner(); + System.err.println("=== dump for SQL: " + sql + " ==="); + printFragmentPlans(planner.getFragments()); + } + + /** + * Assert that NO local exchange of the given type appears anywhere in any + * fragment's plan tree. Companion to {@link #assertPlanShape} for negative + * checks where pinning the full shape would be brittle. + */ + protected void assertNoLocalExchangeOfType(String sql, LocalExchangeType excludedType) throws Exception { + StmtExecutor executor = executeNereidsSql("explain distributed plan " + sql); + NereidsPlanner planner = (NereidsPlanner) executor.planner(); + EnumSet types = collectLocalExchangeTypes(planner.getFragments()); + Assertions.assertFalse(types.contains(excludedType), + "expected no " + excludedType + " in plan, actual: " + types); + } + + @Test + public void testAggFromScanShapeDsl() throws Exception { + // Same scenario as testAggFromScanUsesLocalExecutionHashShuffle but uses the + // PlanShape DSL to assert a *structural* relationship rather than just + // "explain contains the string LOCAL_EXECUTION_HASH_SHUFFLE somewhere". + // + // With ignore_storage_data_distribution=true the scan is serial (pooling + // mode); AggSink requires HASH; framework's heavy-op fan-out kicks in and + // inserts a PASSTHROUGH LE *before* the HASH LE so the 1-task scan does + // not bottleneck the hash redistribution. The full chain is: + // AggregationNode → LE(LOCAL_HASH) → LE(PASSTHROUGH) → OlapScan(t1) + // + // A substring check on explain text could not distinguish this from a + // plan that only has the LOCAL_HASH LE — DSL pins the structure. + setupLocalShuffleSession(null); + assertPlanShape("select k1, k2, count(*) from test.t1 group by k1, k2", + anyTree( + agg( + localExchange(LOCAL_HASH, + localExchange(PT, + olapScan("t1")))))); + } + + @Test + public void testAggWithoutKeyTwoPhase() throws Exception { + // doc rule "Agg / 没有 groupby key" → PASSTHROUGH. + // count(*) generates a two-phase aggregation: + // FINAL AggregationNode → ExchangeNode (UNPARTITIONED, serial) + // → PARTIAL AggregationNode (serial, no keys) + // → LE(PASSTHROUGH) (fan-out from 1-task pooling scan) + // → OlapScan(t1) + // The intermediate LE(PT) is the heavy-op fan-out for the pooling scan; + // the serial PARTIAL agg sits right above it. + setupLocalShuffleSession(null); + assertPlanShape("select count(*) from test.t1", + anyTree( + agg( + anyTree( + agg( + localExchange(PT, + olapScan("t1"))))))); + } + + @Test + public void testBroadcastJoinPoolingShapeDsl() throws Exception { + // doc rule "HashJoin / BROADCAST / 池化": + // probe ← LE(PASSTHROUGH) ← scan + // build ← LE(PASS_TO_ONE) ← Exchange (cross-fragment broadcast) + // Matches doc Example 2 plus the pooling variant (PASS_TO_ONE instead of NOOP + // on the serial build-side exchange). + setupLocalShuffleSession(null); + assertPlanShape("select * from test.t1 a join [broadcast] test.t2 b on a.k1=b.k1", + anyTree( + hashJoin( + localExchange(PT, + olapScan()), + localExchange(PASS_TO_ONE_LE, + anyTree(exchange()))))); + } + + @Test + public void testNlJoinPoolingShapeDsl() throws Exception { + // doc rule "NL join / 池化": build BROADCAST, probe ADAPTIVE_PASSTHROUGH. + // With pooling, the probe-side scan is serial (1 task) so the framework + // additionally inserts an inner LE(PASSTHROUGH) below the ADAPTIVE_PASSTHROUGH + // to fan the serial scan out before adaptive redistribution. The build side + // comes through a cross-fragment Exchange (broadcast). + setupLocalShuffleSession(null); + assertPlanShape("select * from test.t1 a, test.t2 b where a.k1 > b.k1", + anyTree( + nestedLoopJoin( + localExchange(ADAPTIVE_PT, + localExchange(PT, + anyTree(olapScan()))), + localExchange(BROADCAST_LE, + anyTree(exchange()))))); + } + + @Test + public void testNullAwareLeftAntiJoinHasNoLocalExchange() throws Exception { + // doc rule "HashJoin / NULL_AWARE_LEFT_ANTI_JOIN": NOOP/NOOP/NOOP — no LE + // inserted on either side, in either direction. + // HashJoin(NULL_AWARE_LEFT_ANTI_JOIN) + // ← OlapScan(t1) (probe, no LE) + // ← Exchange ← OlapScan(t2) (build, no LE) + setupLocalShuffleSession(null); + assertPlanShape("select k1 from test.t1 where k1 not in (select k1 from test.t2)", + anyTree( + hashJoin( + olapScan(), + anyTree(exchange())) + .where(j -> j.getJoinOp() + == org.apache.doris.analysis.JoinOperator.NULL_AWARE_LEFT_ANTI_JOIN))); + } + + @Test + public void testAnalyticNoPartitionByHasNoLocalExchange() throws Exception { + // doc rule "Analytic / 无 partition by": serial path with PASSTHROUGH require + // upstream. Because the analytic fragment uses a cross-fragment UNPARTITIONED + // Exchange to gather data into a single instance, no LE is inserted between + // the AnalyticEvalNode and the Exchange — the Exchange already serves as the + // serial source. + setupLocalShuffleSession(null); + assertPlanShape("select k1, row_number() over () from test.t1", + anyTree( + analytic( + anyTree(exchange())))); + } + + // -- Tier A: scenarios borrowed from Trino's TestAddExchangesPlans -- + + @Test + public void testUnionDistinctTwoPhaseAgg() throws Exception { + // Borrowed from Trino's testRepartitionForUnionWithAnyTableScans. + // UNION (not UNION ALL) implies DISTINCT — Doris realises that with a + // two-phase Aggregation above the UnionNode. The cross-fragment Exchanges + // feeding the Union pre-shuffle the scan outputs; the LE(PT) above the + // Union is the heavy-op fan-out for the partial agg over the gathered union. + // FINAL Agg ← Exchange ← PARTIAL Agg ← LE(PT) ← Union ← {Exchange ← scan} x 2 + setupLocalShuffleSession(null); + assertPlanShape("select k1 from test.t1 union select k1 from test.t2", + anyTree( + agg( + anyTree( + agg( + localExchange(PT, + union( + anyTree(olapScan()), + anyTree(olapScan())))))))); + } + + @Test + public void testUnionAllBeforeHashJoin() throws Exception { + // Borrowed from Trino's testRepartitionForUnionAllBeforeHashJoin. + // UNION ALL feeds into a hash join — the join's hash requirement is + // satisfied by the cross-fragment Exchanges sitting under each Union branch + // (data is already hash-distributed by the time it reaches Union), so no + // intra-fragment LE is needed on either probe or build side. + setupLocalShuffleSession(null); + assertPlanShape("select * from (select k1 from test.t1 union all select k1 from test.t2) u " + + "join test.t1 t3 on u.k1=t3.k1", + anyTree( + hashJoin( + union( + anyTree(olapScan()), + anyTree(olapScan())), + anyTree(exchange())))); + } + + @Test + public void testWindowPartitionByBucketKey() throws Exception { + // Borrowed from Trino's testWindowIsExactlyPartitioned. + // PARTITION BY uses the table's bucket key (k1) — Doris's analytic eval + // is colocate-eligible. With pooling, the chain is: + // AnalyticEval ← Sort ← LE(LOCAL_HASH) ← LE(PT) ← scan + // The inner LE(PT) is the heavy-op fan-out for the serial pooling scan. + setupLocalShuffleSession(null); + assertPlanShape("select k1, row_number() over (partition by k1) from test.t1", + anyTree( + analytic( + sort( + localExchange(LOCAL_HASH, + localExchange(PT, + olapScan("t1"))))))); + } + + @Test + public void testWindowPartitionByNonBucketKey() throws Exception { + // Borrowed from Trino's testRowNumberIsExactlyPartitioned (negative variant). + // PARTITION BY uses a non-bucket key (k2) — colocate is not eligible, so + // the analytic eval lives in its own fragment fed by a cross-fragment + // hash-partitioned Exchange. No intra-fragment LE inside the analytic fragment. + setupLocalShuffleSession(null); + assertPlanShape("select k1, row_number() over (partition by k2) from test.t1", + anyTree( + analytic( + sort( + anyTree(exchange()))))); + } + + @Test + public void testNestedUnionAll() throws Exception { + // Borrowed from Trino's testNestedUnionAll. + // Three-way UNION ALL flattens into a single UnionNode with three + // cross-fragment Exchange children. No LE since there's no downstream + // consumer requiring hash distribution. + setupLocalShuffleSession(null); + assertPlanShape( + "select k1 from test.t1 union all " + + "(select k1 from test.t2 union all select k1 from test.t1)", + anyTree( + union( + anyTree(olapScan()), + anyTree(olapScan()), + anyTree(olapScan())))); + } + + @Test + public void testGroupedAggOverNlj() throws Exception { + // Borrowed from Trino's testGroupedAggregationAboveUnionAllCrossJoined + // (NLJ + agg variant). NLJ output is ADAPTIVE_PASSTHROUGH; the outer Agg + // requires HASH on k1. Because ADAPTIVE_PASSTHROUGH does not satisfy HASH, + // an LE(LOCAL_HASH) is inserted between the NLJ output and the Agg. + // Agg ← LE(LOCAL_HASH) ← NLJ + // ├─ LE(ADAPTIVE_PT) ← LE(PT) ← scan(t1) + // └─ LE(BROADCAST) ← Exchange ← scan(t2) + setupLocalShuffleSession(null); + assertPlanShape("select a.k1, count(*) from test.t1 a, test.t2 b where a.k1 > b.k1 group by a.k1", + anyTree( + agg( + localExchange(LOCAL_HASH, + nestedLoopJoin( + localExchange(ADAPTIVE_PT, + localExchange(PT, + anyTree(olapScan()))), + localExchange(BROADCAST_LE, + anyTree(exchange()))))))); + } + + @Test + public void testTopNQualifyPartitionSort() throws Exception { + // Borrowed from Trino's testTopNRowNumberIsExactlyPartitioned. + // ROW_NUMBER() filtered by `rn = 1` triggers Doris's PartitionSortNode + // optimisation (LOCAL phase pre-trims rows before the global Analytic). + // The chain becomes: + // AnalyticEval ← Sort ← LE(LOCAL_HASH) ← PartitionSort ← LE(PT) ← scan + // The LE(PT) under PartitionSort is the heavy-op fan-out for the pooling scan; + // the LE(LOCAL_HASH) above PartitionSort enforces hash partitioning for the + // global ROW_NUMBER computation. + setupLocalShuffleSession(null); + assertPlanShape( + "select k1, k2 from (select k1, k2, row_number() over (partition by k1 order by k2) rn " + + "from test.t1) t where rn = 1", + anyTree( + analytic( + sort( + localExchange(LOCAL_HASH, + partitionSort( + localExchange(PT, + olapScan("t1")))))))); + } + + @Test + public void testAggOverBroadcastJoin() throws Exception { + // Borrowed from Trino's testGroupedAggregationAboveUnionAll variant. + // count(*) over a broadcast join generates a two-phase aggregation; the + // partial agg sits directly on top of the HashJoin and the final agg lives + // in a separate fragment (count merge): + // FINAL Agg ← Exchange ← PARTIAL Agg ← HashJoin + // ├─ LE(PT) ← scan + // └─ LE(PASS_TO_ONE) ← Exchange ← scan + setupLocalShuffleSession(null); + assertPlanShape("select count(*) from test.t1 a join [broadcast] test.t2 b on a.k1=b.k1", + anyTree( + agg( + anyTree( + agg( + hashJoin( + localExchange(PT, + olapScan()), + localExchange(PASS_TO_ONE_LE, + anyTree(exchange())))))))); + } + + @Test + public void testNonSerialScanKeepsBucketHashDistribution() throws Exception { + // Non-pooling scan with pipelineTaskNum=1 → the BUCKET_HASH_SHUFFLE output of + // the colocated scan is preserved end-to-end; no LOCAL_EXECUTION_HASH_SHUFFLE + // is ever introduced. Only a serial-source PASSTHROUGH appears (for the + // SortNode's merge-by-exchange). + setupLocalShuffleSession(sv -> { + sv.setIgnoreStorageDataDistribution(false); + try { + sv.setPipelineTaskNum("1"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertNoLocalExchangeOfType( + "select k1, count(*) from test.t1 group by k1 order by k1", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testJoinPlanContainsHashShuffle() throws Exception { + // Pooling hash join under an aggregate. Both sides of the join feed through + // local exchanges; the agg above the join requires LOCAL_EXECUTION_HASH_SHUFFLE. + // Agg → LE(LOCAL_HASH) → HashJoin + // ← LE(PASSTHROUGH) ← OlapScan(t1) (probe) + // ← LE(PASS_TO_ONE) ← Exchange ← OlapScan(t2) (build) + setupLocalShuffleSession(null); + assertPlanShape( + "select a.k1, count(*) from test.t1 a join test.t2 b on a.k1 = b.k1 group by a.k1", + anyTree( + agg( + localExchange(LOCAL_HASH, + hashJoin( + localExchange(PT, + olapScan("t1")), + localExchange(PASS_TO_ONE_LE, + anyTree(exchange()))))))); + } + + @Test + public void testNoopLocalExchangeNotInjected() throws Exception { + // A simple LIMIT scan plan should contain no local exchanges of any kind — + // and most importantly, no synthesized NOOP node. doc rule "NOOP is meta, + // never materialized": the planner uses NOOP as a 'skip' signal during + // resolution but never instantiates a LocalExchangeNode with type NOOP. + setupLocalShuffleSession(null); + assertNoLocalExchangeOfType("select * from test.t1 limit 1", LocalExchangeType.NOOP); + } + + @Test + public void testHashShuffleHasDistributeExprs() throws Exception { + // Same scan→agg plan as testAggFromScanShapeDsl, but with a predicate that + // checks the inserted LE(LOCAL_HASH) carries non-empty distribute expressions + // (without which the BE would not know which columns to hash on). + setupLocalShuffleSession(null); + assertPlanShape("select k1, k2, count(*) from test.t1 group by k1, k2", + anyTree( + agg( + localExchange(LOCAL_HASH, + localExchange(PT, olapScan("t1"))) + .where(le -> le.getDistributeExprLists() != null + && !le.getDistributeExprLists().isEmpty())))); + } + + @Test + public void testStreamingAggHashShuffleUsesGroupingExprs() throws Exception { + // Regression for: FE-planned LE(LOCAL_HASH) under a streaming partial agg used + // the child's table distribution (e.g. `k1`) instead of the grouping keys + // (e.g. `k2`). BE's AggSinkOperatorX/StreamingAggOperatorX::update_operator + // picks `_partition_exprs = grouping_exprs` when the chain is NOT followed by + // a shuffled operator (the common case where the streaming preagg sits at + // fragment root with only a cross-fragment HASH ExchangeSink above). Using + // child distribution instead scatters same-group rows across N instances, + // turning the partial preagg into a no-op and corrupting row-arrival order at + // downstream merge-finalize (manifests as e.g. non-deterministic + // group_concat / py_json_array_agg output). + // + // Table t1 is DISTRIBUTED BY HASH(k1). GROUP BY k2 forces a cross-fragment + // exchange (and thus a two-phase aggregation): the streaming partial agg lives + // in the lower fragment, with an FE-inserted LE(LOCAL_HASH) below it. The fix + // makes that LE carry [k2] (the grouping key) rather than [k1] (the child + // table distribution). + setupLocalShuffleSession(null); + assertPlanShape( + "select k2, count(*) from test.t1 group by k2", + anyTree( + agg( + localExchange(LOCAL_HASH, + localExchange(PT, olapScan("t1"))) + .where(le -> { + List exprs = + le.getDistributeExprLists(); + if (exprs == null || exprs.size() != 1) { + return false; + } + org.apache.doris.analysis.Expr e = exprs.get(0); + return e instanceof org.apache.doris.analysis.SlotRef + && "k2".equals(((org.apache.doris.analysis.SlotRef) e).getCol()); + })))); + } + + @Test + public void testRequireHashSatisfyAllHashShuffleTypes() { + LocalExchangeNode.LocalExchangeTypeRequire requireHash = LocalExchangeNode.LocalExchangeTypeRequire.requireHash(); + Assertions.assertTrue(requireHash.satisfy(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE)); + Assertions.assertTrue(requireHash.satisfy(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE)); + Assertions.assertTrue(requireHash.satisfy(LocalExchangeType.BUCKET_HASH_SHUFFLE)); + Assertions.assertFalse(requireHash.satisfy(LocalExchangeType.PASSTHROUGH)); + } + + @Test + public void testSetOperationUnderAggHasHashShuffle() throws Exception { + // Non-pooling UNION ALL under an agg. The outer agg's group key requires a + // LOCAL_EXECUTION_HASH_SHUFFLE directly above the UnionNode (above each + // branch's pre-agg). + setupLocalShuffleSession(sv -> sv.setIgnoreStorageDataDistribution(false)); + assertPlanShape( + "select k1, count(*) from (select k1 from test.t1 union all select k1 from test.t2) u group by k1", + anyTree( + agg( + localExchange(LOCAL_HASH, + union( + anyTree(olapScan()), + anyTree(olapScan())))))); + } + + @Test + public void testAnalyticPlanContainsPassthroughAndLocalHashShuffle() throws Exception { + // doc rule "Analytic / 有 partition by / 池化": LE(LOCAL_HASH) for partition + // redistribution, plus a LE(PASSTHROUGH) heavy-op fan-out below it for the + // 1-task pooling scan, then LE(PASSTHROUGH) above the AnalyticEval for the + // outer order-by merge. + // SortNode → LE(PASSTHROUGH) → AnalyticEval → SortNode + // → LE(LOCAL_HASH) → LE(PASSTHROUGH) → scan + setupLocalShuffleSession(null); + assertPlanShape( + "select k1, k2, row_number() over(partition by k1 order by k2) from test.t1 order by k1, k2", + anyTree( + sort( + localExchange(PT, + analytic( + sort( + localExchange(LOCAL_HASH, + localExchange(PT, + olapScan("t1"))))))))); + } + + @Test + public void testGroupingSetsPlanContainsHashShuffle() throws Exception { + // Non-pooling grouping sets keeps the colocated BUCKET_HASH_SHUFFLE output of + // the scan all the way through Repeat→Agg; no LE(LOCAL_HASH) is needed. + setupLocalShuffleSession(sv -> sv.setIgnoreStorageDataDistribution(false)); + assertNoLocalExchangeOfType( + "select k1, k2, sum(v1) from test.t1 group by grouping sets((k1), (k1, k2))", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testRepeatNoRequireKeepsHashLocalExchangeAboveRepeat() throws Exception { + // Behavior 1 of the RepeatNode fix — noRequire (tpcds q67, +73%). + // RepeatNode recurses with noRequire() instead of forwarding the streaming + // agg's HASH require to its child. So when the pooling scan upstream does NOT + // already provide the distribution, the parent inserts the LE(LOCAL_HASH) + // ABOVE the Repeat, never below it: + // Agg <- LE(LOCAL_HASH) <- LE(PASSTHROUGH) <- Repeat <- scan + // Pinning Repeat with repeat() (not anyTree) distinguishes the fixed plan from + // the buggy one (buggy forwarded the require, so the LE landed below the + // Repeat, hashing the pre-repeat rows by the child's single upstream key and + // collapsing them onto one instance). + setupLocalShuffleSession(null); + assertPlanShape( + "select k1, k2, count(*) from test.t1 group by grouping sets((k1), (k1, k2))", + anyTree( + agg( + localExchange(LOCAL_HASH, + localExchange(PT, + repeat(anyTree(olapScan("t1")))))))); + } + + @Test + public void testRepeatReturnsChildDistributionSkipsRedundantHash() throws Exception { + // Behavior 2 of the RepeatNode fix — return enforceResult.second (tpcds q70). + // RepeatNode reports its child's real output distribution to the parent (not + // NOOP). With a non-pooling colocate scan, the child's BUCKET_HASH + // distribution propagates through the Repeat and already satisfies the agg's + // hash requirement, so the parent's satisfy-check SKIPS inserting any LE — no + // LOCAL_HASH appears. Had RepeatNode returned NOOP (the discarded v1), the + // satisfy-check would fail and force a redundant LE(LOCAL_HASH) that + // re-shuffles the post-repeat rows into skew. + setupLocalShuffleSession(sv -> sv.setIgnoreStorageDataDistribution(false)); + assertNoLocalExchangeOfType( + "select k1, k2, count(*) from test.t1 group by grouping sets((k1), (k1, k2))", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testLocalAndGlobalExecutionHashShufflePreferType() { + LocalExchangeNode.LocalExchangeTypeRequire requireHash = LocalExchangeNode.LocalExchangeTypeRequire.requireHash(); + LocalExchangeNode.LocalExchangeTypeRequire requireBucketHash + = LocalExchangeNode.LocalExchangeTypeRequire.requireBucketHash(); + LocalExchangeNode.LocalExchangeTypeRequire requireGlobalHash + = LocalExchangeNode.LocalExchangeTypeRequire.requireGlobalExecutionHash(); + + LocalExchangeType localType = AddLocalExchange.resolveExchangeType(requireHash); + LocalExchangeType globalType = AddLocalExchange.resolveExchangeType(requireHash); + // Explicit GLOBAL_EXECUTION_HASH_SHUFFLE must NOT be degraded, even on a scan path. + // If it appears on a scan path, the plan is wrong — not something resolveExchangeType should fix. + LocalExchangeType explicitGlobalOnScanType = AddLocalExchange.resolveExchangeType(requireGlobalHash); + + // shouldUseLocalExecutionHash always returns true → RequireHash always resolves to LOCAL + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, localType); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, globalType); + // Explicit GLOBAL (RequireSpecific) must NOT be degraded. + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, explicitGlobalOnScanType); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, requireBucketHash.preferType()); + } + + @Test + public void testMixedPlanWithPoolingScan() throws Exception { + // Pooling: grouping-sets sub-query JOINed and re-aggregated. Probe side + // wraps the inner agg/Repeat with LE(LOCAL_HASH) over LE(PASSTHROUGH); build + // side comes through LE(PASS_TO_ONE) over an inter-fragment Exchange. + // Agg → HashJoin + // ← Agg → LE(LOCAL_HASH) → LE(PASSTHROUGH) → Repeat → scan(t1) + // ← LE(PASS_TO_ONE) → Exchange → scan(t2) + setupLocalShuffleSession(null); + assertPlanShape( + "select u.k1, count(*) from (select k1, k2 from test.t1 group by grouping sets((k1), (k1, k2))) u " + + "join test.t2 b on u.k1 = b.k1 group by u.k1", + anyTree( + agg( + hashJoin( + agg( + localExchange(LOCAL_HASH, + localExchange(PT, + anyTree(olapScan("t1"))))), + localExchange(PASS_TO_ONE_LE, + anyTree(exchange())))))); + } + + @Test + public void testUnionAllScanAndValues() throws Exception { + // Tier B (borrowed from Trino): UNION ALL of a real OlapScan and inline + // VALUES rows. The values branches flow through their own fragments and + // land at a UnionNode that gathers via Exchange. Verifies the + // scan-side LE(PASSTHROUGH) heavy-op fan-out is still inserted while the + // values branches contribute no local exchanges (already serial sources). + setupLocalShuffleSession(null); + assertPlanShape("select k1 from test.t1 union all select 1 union all select 2", + anyTree( + union( + anyTree(exchange())))); + } + + private EnumSet collectLocalExchangeTypes(List fragments) { + EnumSet types = EnumSet.noneOf(LocalExchangeType.class); + for (PlanFragment fragment : fragments) { + collect(fragment.getPlanRoot(), types); + } + return types; + } + + private List collectLocalExchangeNodes(List fragments) { + List nodes = new ArrayList<>(); + for (PlanFragment fragment : fragments) { + collectLocalExchangeNode(fragment.getPlanRoot(), nodes); + } + return nodes; + } + + private String collectFragmentExplain(List fragments) { + StringBuilder explain = new StringBuilder(); + for (PlanFragment fragment : fragments) { + explain.append(fragment.getExplainString(TExplainLevel.NORMAL)); + } + return explain.toString(); + } + + private void collect(PlanNode node, EnumSet types) { + if (node instanceof LocalExchangeNode) { + types.add(((LocalExchangeNode) node).getExchangeType()); + } + for (PlanNode child : node.getChildren()) { + collect(child, types); + } + } + + private void collectLocalExchangeNode(PlanNode node, List nodes) { + if (node instanceof LocalExchangeNode) { + nodes.add((LocalExchangeNode) node); + } + for (PlanNode child : node.getChildren()) { + collectLocalExchangeNode(child, nodes); + } + } + + private static class MockPlanNode extends PlanNode { + MockPlanNode(PlanNodeId id) { + super(id, "MOCK-PLAN"); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + + private static class MockScanNode extends ScanNode { + MockScanNode(PlanNodeId id) { + super(id, new TupleDescriptor(new TupleId(id.asInt())), "MOCK-SCAN", ScanContext.EMPTY); + } + + @Override + protected void createScanRangeLocations() throws UserException { + } + + @Override + public List getScanRangeLocations(long maxScanRangeLength) { + return java.util.Collections.emptyList(); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } +} diff --git a/gensrc/thrift/PaloInternalService.thrift b/gensrc/thrift/PaloInternalService.thrift index 6c298939158e92..1bf88a86e93b6f 100644 --- a/gensrc/thrift/PaloInternalService.thrift +++ b/gensrc/thrift/PaloInternalService.thrift @@ -499,6 +499,9 @@ struct TQueryOptions { // Candidate row ratio threshold against segment rows. Existing default is 0.3. 220: optional double ann_index_candidate_rows_percent_threshold = 0.3 + // enable plan local exchange node in fe + 223: optional bool enable_local_shuffle_planner; + // Controls expression-based ZoneMap pruning for readers that honor this option. // FileScannerV2 always enables safe expression ZoneMap pruning. 224: optional bool enable_expr_zonemap_filter = true diff --git a/gensrc/thrift/Partitions.thrift b/gensrc/thrift/Partitions.thrift index 738636961157ac..9b57a7078f76f6 100644 --- a/gensrc/thrift/Partitions.thrift +++ b/gensrc/thrift/Partitions.thrift @@ -56,6 +56,91 @@ enum TPartitionType { MERGE_PARTITIONED = 9 } +enum TLocalPartitionType { + // NOOP: no local exchange. the consumer keeps the producer's existing data distribution and + // instance count. `DataDistribution::need_local_exchange()` returns false for NOOP, so the + // planner inserts no LocalExchangeNode at all; in the FE-planned path NOOP never reaches BE's + // exchanger factory (it is filtered out earlier). + NOOP = 0, + // used to resume the global hash distribution because other distribution break the global hash distribution, + // such as PASSTHROUGH. and then JoinNode can shuffle data by the same hash distribution. + // + // for example: look here, need resume to GLOBAL_EXECUTION_HASH_SHUFFLE + // ↓ + // Node -> LocalExchangeNode(PASSTHROUGH) → JoinNode → LocalExchangeNode(GLOBAL_EXECUTION_HASH_SHUFFLE) → JoinNode + // ExchangeNode(BROADCAST) ↗ ↑ + // ExchangeNode(GLOBAL_EXECUTION_HASH_SHUFFLE) + GLOBAL_EXECUTION_HASH_SHUFFLE = 1, + // used to rebalance data within a backend to add parallelism. this is a performance rebalance, + // NOT a correctness requirement. + // + // for example: look here, need use LOCAL_EXECUTION_HASH_SHUFFLE to rebalance data + // ↓ + // Scan(hash(id)) -> LocalExchangeNode(LOCAL_EXECUTION_HASH_SHUFFLE(id, name)) → AggregationNode(group by(id,name)) + // + // group by (id, name) is already correct on a scan that is hash-distributed by id, because id is a subset + // of the grouping keys: every (id, name) group is fully contained in the backend that owns its id, so no + // reshuffle is required for correctness. but when there are few distinct id values the data is concentrated + // on a few local instances; LOCAL_EXECUTION_HASH_SHUFFLE re-partitions by the full key (id, name) and + // spreads the aggregation across all local instances of the backend (hash mod local instance count, mapping + // instance i -> i), purely to add parallelism. + // + // conversely, the reverse plan scan(hash(id, name)) -> agg(group by id) is NOT a local-exchange case: + // hash(id, name) spreads the rows of a single id across different backends (e.g. (5, A) on be1 and + // (5, B) on be2), so re-partitioning them by id requires a cross-backend shuffle + // (GLOBAL_EXECUTION_HASH_SHUFFLE / a network exchange), which a within-backend local exchange cannot do. + // and we can not use GLOBAL_EXECUTION_HASH_SHUFFLE(id, name) here, because + // `TPipelineFragmentParams.shuffle_idx_to_instance_idx` is used to mapping partial global instance index to local + // instance index, and discard the other backend's instance index, the data not belong to the local instance will be + // discarded, which cause data loss. + LOCAL_EXECUTION_HASH_SHUFFLE = 2, + // BUCKET_HASH_SHUFFLE: hash distribute_expr_lists and route each row to the instance that owns its + // bucket, via `TPipelineFragmentParams.bucket_seq_to_instance_idx` (implemented by + // BucketShuffleExchanger, a ShuffleExchanger specialization). this preserves the table's + // tablet/bucket layout so operators that must agree on bucket->instance placement line up without a + // global reshuffle -- i.e. colocate joins and bucket-shuffle joins. + // + // for example, a bucket-shuffle join on a table bucketed by id: + // Scan(bucketed by id) -> LocalExchangeNode(BUCKET_HASH_SHUFFLE(id)) -> HashJoin(... on id = ...) + // each id-bucket lands on the same instance on both inputs, so matching rows meet locally. + BUCKET_HASH_SHUFFLE = 3, + // PASSTHROUGH: round-robin whole blocks across the local instances WITHOUT re-partitioning the data + // (PassthroughExchanger sends block N to instance `N % local_instance_count`; rows are never + // re-hashed or split). used only to even out work / add parallelism when the consumer does not care + // how rows are partitioned -- e.g. fanning a serial (1-task) producer out to N instances, or feeding + // an operator with no distribution requirement. + // + // for example, fan a serial source out to 3 instances: + // SerialNode(1 task) -> LocalExchangeNode(PASSTHROUGH) -> Project(3 tasks) + // block0->inst0, block1->inst1, block2->inst2, block3->inst0, ... + PASSTHROUGH = 4, + // ADAPTIVE_PASSTHROUGH: starts by round-robining each block's ROWS evenly across all instances (so + // they fill up evenly even when there are only a few large blocks), then -- once it has seen + // >= local_instance_count blocks -- switches to cheap whole-block PASSTHROUGH for the rest + // (AdaptivePassthroughExchanger). this is round-robin, NOT a hash shuffle, and the switch is driven + // by block count, not data content. used where we want an even initial spread plus low steady-state + // overhead, e.g. the input of a non-grouping / streaming aggregation. + ADAPTIVE_PASSTHROUGH = 5, + // BROADCAST: copy every incoming block to ALL local instances (BroadcastExchanger enqueues the same + // block to every channel), so each instance sees the full input. used for the build side of a + // broadcast join -- every probe instance needs the complete build input to build its own hash table. + // + // for example: + // Scan(build side) -> LocalExchangeNode(BROADCAST) -> HashJoin(build) + BROADCAST = 6, + // PASS_TO_ONE: funnel all rows to a single local instance (channel 0); every other instance gets EOS + // immediately and produces nothing (PassToOneExchanger). used for a broadcast join with a shared + // hash table, where only instance 0 needs the build data and the others share its hash table. + // NOTE: BE only uses PassToOneExchanger when `enable_share_hash_table_for_broadcast_join` is on; + // when it is off the same PASS_TO_ONE type degrades to BROADCAST (each instance keeps its own copy). + PASS_TO_ONE = 7, + // LOCAL_MERGE_SORT: k-way merge of several already-sorted local inputs into one globally sorted + // stream on a single instance (paired with LocalMergeSortSourceOperator, for a SortNode with + // use_local_merge). only the legacy BE-side local-exchange planner emits this; the FE-planned path + // never produces it, so BE's FE-planned exchanger factory rejects it as a protocol violation. + LOCAL_MERGE_SORT = 8 +} + enum TDistributionType { UNPARTITIONED = 0, diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift index 5c190e10188a3c..85935b9060c15f 100644 --- a/gensrc/thrift/PlanNodes.thrift +++ b/gensrc/thrift/PlanNodes.thrift @@ -62,7 +62,9 @@ enum TPlanNodeType { GROUP_COMMIT_SCAN_NODE = 33, MATERIALIZATION_NODE = 34, REC_CTE_NODE = 35, - REC_CTE_SCAN_NODE = 36 + REC_CTE_SCAN_NODE = 36, + BUCKETED_AGGREGATION_NODE = 37, + LOCAL_EXCHANGE_NODE = 38 } struct TKeyRange { @@ -1534,6 +1536,24 @@ struct TExchangeNode { 4: optional Partitions.TPartitionType partition_type } +struct TLocalExchangeNode { + 1: optional Partitions.TLocalPartitionType partition_type + // when partition_type in (GLOBAL_EXECUTION_HASH_SHUFFLE, LOCAL_EXECUTION_HASH_SHUFFLE, BUCKET_HASH_SHUFFLE), + // the distribute_expr_lists is not null, and the legacy `TPlanNode.distribute_expr_lists` is deprecated + // + // the hash computation: + // 1. for BUCKET_HASH_SHUFFLE, use distribution_exprs to compute hash value and mod by + // `TPipelineFragmentParams.num_buckets`, and mapping bucket index to local instance id by + // `TPipelineFragmentParams.bucket_seq_to_instance_idx` + // 2. for LOCAL_EXECUTION_HASH_SHUFFLE, use distribution_exprs to compute hash value and mod by + // `TPipelineFragmentParams.local_params.size`, and backend will mapping instance index to local instance + // by `i -> i`, for example: 1 -> 1, 2 -> 2, ... + // 3. for GLOBAL_EXECUTION_HASH_SHUFFLE, use distribution_exprs to compute hash value and mod by + // `TPipelineFragmentParams.total_instances`, and mapping global instance index to local instance by + // `TPipelineFragmentParams.shuffle_idx_to_instance_idx` + 2: optional list distribute_expr_lists +} + struct TOlapRewriteNode { 1: required list columns 2: required list column_types @@ -1751,6 +1771,8 @@ struct TPlanNode { 50: optional list> distribute_expr_lists 51: optional bool is_serial_operator 52: optional TRecCTEScanNode rec_cte_scan_node + 54: optional TLocalExchangeNode local_exchange_node + // COUNT(*) and COUNT(col) share push_down_agg_type_opt=COUNT, but file readers need to know // whether a projected scan slot is the aggregate argument or merely the placeholder retained by // column pruning. Empty means row-count semantics; non-empty identifies explicit COUNT columns. diff --git a/regression-test/data/nereids_function_p0/gen_function/unnest_order_by_list_test.out b/regression-test/data/nereids_function_p0/gen_function/unnest_order_by_list_test.out index 31141b95eaeaa5..f2f86a8bc5c993 100644 --- a/regression-test/data/nereids_function_p0/gen_function/unnest_order_by_list_test.out +++ b/regression-test/data/nereids_function_p0/gen_function/unnest_order_by_list_test.out @@ -63,13 +63,13 @@ Alice Charlie -- !window_function_order_by_unnested_value -- -1 English 90 1 1 English 85 2 -1 Math 95 1 -1 Math 80 2 +1 English 90 1 1 Math 70 3 -2 Math 75 1 +1 Math 80 2 +1 Math 95 1 2 Math 60 2 +2 Math 75 1 -- !order_by_after_where_and_unnest -- 1 0 80 diff --git a/regression-test/data/query_p0/join/test_multilevel_join_agg_local_shuffle.out b/regression-test/data/query_p0/join/test_multilevel_join_agg_local_shuffle.out new file mode 100644 index 00000000000000..2b6efe71e0b95e --- /dev/null +++ b/regression-test/data/query_p0/join/test_multilevel_join_agg_local_shuffle.out @@ -0,0 +1,814 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !bucket_shuffle_broadcast_agg_stage_1_result_on -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_agg_stage_1_result_off -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_agg_stage_2_result_on -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_agg_stage_2_result_off -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_agg_stage_3_result_on -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_agg_stage_3_result_off -- +1 52 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !shuffle_broadcast_broadcast_agg_stage_1_result_on -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !shuffle_broadcast_broadcast_agg_stage_1_result_off -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !shuffle_broadcast_broadcast_agg_stage_2_result_on -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !shuffle_broadcast_broadcast_agg_stage_2_result_off -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !shuffle_broadcast_broadcast_agg_stage_3_result_on -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !shuffle_broadcast_broadcast_agg_stage_3_result_off -- +1 10 62 10 +2 5 54 20 +3 5 38 30 +4 6 49 40 + +-- !bucket_broadcast_shuffle_agg_stage_1_result_on -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_broadcast_shuffle_agg_stage_1_result_off -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_broadcast_shuffle_agg_stage_2_result_on -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_broadcast_shuffle_agg_stage_2_result_off -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_broadcast_shuffle_agg_stage_3_result_on -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_broadcast_shuffle_agg_stage_3_result_off -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !alternating_bucket_bucket_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_bucket_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_bucket_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_bucket_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_bucket_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_bucket_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_shuffle_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_bucket_broadcast_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_bucket_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_shuffle_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_shuffle_broadcast_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_bucket_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_shuffle_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_bucket_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_bucket_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_shuffle_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_shuffle_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_broadcast_result_on -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !alternating_broadcast_broadcast_broadcast_result_off -- +1 63 16 11 +2 16 24 22 +3 16 35 33 +4 19 46 44 + +-- !window_union_all_bucket_row_number_result_on -- +1 52 4 40 +2 21 3 60 +3 16 2 60 +4 20 2 80 + +-- !window_union_all_bucket_row_number_result_off -- +1 52 4 40 +2 21 3 60 +3 16 2 60 +4 20 2 80 + +-- !window_union_all_bucket_window_sum_result_on -- +1 10 13 40 +2 6 7 60 +3 3 8 60 +4 3 10 80 + +-- !window_union_all_bucket_window_sum_result_off -- +1 10 13 40 +2 6 7 60 +3 3 8 60 +4 3 10 80 + +-- !window_union_all_shuffle_row_number_result_on -- +1 208 8 44 +2 21 3 21 +3 16 2 16 +4 20 2 18 + +-- !window_union_all_shuffle_row_number_result_off -- +1 208 8 44 +2 21 3 21 +3 16 2 16 +4 20 2 18 + +-- !window_union_all_shuffle_window_sum_result_on -- +1 36 26 44 +2 6 7 21 +3 3 8 16 +4 3 10 18 + +-- !window_union_all_shuffle_window_sum_result_off -- +1 36 26 44 +2 6 7 21 +3 3 8 16 +4 3 10 18 + +-- !window_union_all_broadcast_row_number_result_on -- +1 52 4 40 +2 21 3 60 +3 16 2 60 +4 20 2 80 + +-- !window_union_all_broadcast_row_number_result_off -- +1 52 4 40 +2 21 3 60 +3 16 2 60 +4 20 2 80 + +-- !window_union_all_broadcast_window_sum_result_on -- +1 10 13 40 +2 6 7 60 +3 3 8 60 +4 3 10 80 + +-- !window_union_all_broadcast_window_sum_result_off -- +1 10 13 40 +2 6 7 60 +3 3 8 60 +4 3 10 80 + +-- !window_except_bucket_row_number_result_on -- +1 10 2 20 +2 10 2 40 +3 5 1 30 +4 6 1 40 + +-- !window_except_bucket_row_number_result_off -- +1 10 2 20 +2 10 2 40 +3 5 1 30 +4 6 1 40 + +-- !window_except_bucket_window_sum_result_on -- +1 3 5 20 +2 3 5 40 +3 1 5 30 +4 1 6 40 + +-- !window_except_bucket_window_sum_result_off -- +1 3 5 20 +2 3 5 40 +3 1 5 30 +4 1 6 40 + +-- !window_except_shuffle_row_number_result_on -- +1 40 4 22 +2 10 2 14 +3 5 1 8 +4 6 1 9 + +-- !window_except_shuffle_row_number_result_off -- +1 40 4 22 +2 10 2 14 +3 5 1 8 +4 6 1 9 + +-- !window_except_shuffle_window_sum_result_on -- +1 10 10 22 +2 3 5 14 +3 1 5 8 +4 1 6 9 + +-- !window_except_shuffle_window_sum_result_off -- +1 10 10 22 +2 3 5 14 +3 1 5 8 +4 1 6 9 + +-- !window_except_broadcast_row_number_result_on -- +1 10 2 20 +2 10 2 40 +3 5 1 30 +4 6 1 40 + +-- !window_except_broadcast_row_number_result_off -- +1 10 2 20 +2 10 2 40 +3 5 1 30 +4 6 1 40 + +-- !window_except_broadcast_window_sum_result_on -- +1 3 5 20 +2 3 5 40 +3 1 5 30 +4 1 6 40 + +-- !window_except_broadcast_window_sum_result_off -- +1 3 5 20 +2 3 5 40 +3 1 5 30 +4 1 6 40 + +-- !window_intersect_bucket_row_number_result_on -- + +-- !window_intersect_bucket_row_number_result_off -- + +-- !window_intersect_bucket_window_sum_result_on -- + +-- !window_intersect_bucket_window_sum_result_off -- + +-- !window_intersect_shuffle_row_number_result_on -- + +-- !window_intersect_shuffle_row_number_result_off -- + +-- !window_intersect_shuffle_window_sum_result_on -- + +-- !window_intersect_shuffle_window_sum_result_off -- + +-- !window_intersect_broadcast_row_number_result_on -- + +-- !window_intersect_broadcast_row_number_result_off -- + +-- !window_intersect_broadcast_window_sum_result_on -- + +-- !window_intersect_broadcast_window_sum_result_off -- + +-- !bucket_broadcast_agg_result_on -- +1 26 10 +2 9 20 +3 8 30 +4 10 40 + +-- !bucket_broadcast_agg_result_off -- +1 26 10 +2 9 20 +3 8 30 +4 10 40 + +-- !partitioned_broadcast_agg_result_on -- +1 32 10 +2 19 20 +3 13 30 +4 15 40 + +-- !partitioned_broadcast_agg_result_off -- +1 32 10 +2 19 20 +3 13 30 +4 15 40 + +-- !bucket_partitioned_agg_result_on -- +1 52 44 +2 9 14 +3 8 8 +4 10 9 + +-- !bucket_partitioned_agg_result_off -- +1 52 44 +2 9 14 +3 8 8 +4 10 9 + +-- !all_three_multilevel_agg_result_on -- +1 96 10 +2 23 20 +3 16 30 +4 19 40 + +-- !all_three_multilevel_agg_result_off -- +1 96 10 +2 23 20 +3 16 30 +4 19 40 + +-- !agg_join_agg_mix_result_on -- +1 10 11 10 +2 5 7 20 +3 5 8 30 +4 6 9 40 + +-- !agg_join_agg_mix_result_off -- +1 10 11 10 +2 5 7 20 +3 5 8 30 +4 6 9 40 + +-- !double_broadcast_after_bucket_result_on -- +1 26 20 +2 9 40 +3 8 60 +4 10 80 + +-- !double_broadcast_after_bucket_result_off -- +1 26 20 +2 9 40 +3 8 60 +4 10 80 + +-- !partitioned_join_between_two_aggs_then_broadcast_result_on -- +1 16 10 +2 12 20 +3 13 30 +4 15 40 + +-- !partitioned_join_between_two_aggs_then_broadcast_result_off -- +1 16 10 +2 12 20 +3 13 30 +4 15 40 + +-- !bucket_shuffle_broadcast_two_stage_agg_result_on -- +1 52 44 10 +2 9 14 20 +3 8 8 30 +4 10 9 40 + +-- !bucket_shuffle_broadcast_two_stage_agg_result_off -- +1 52 44 10 +2 9 14 20 +3 8 8 30 +4 10 9 40 + +-- !left_join_null_preserving_with_multilevel_agg_result_on -- +1 5 44 +2 5 14 +3 5 8 +4 6 9 + +-- !left_join_null_preserving_with_multilevel_agg_result_off -- +1 5 44 +2 5 14 +3 5 8 +4 6 9 + +-- !seven_layer_bucket_shuffle_broadcast_result_on -- +1 37 10 +2 16 20 +3 16 30 +4 19 40 + +-- !seven_layer_bucket_shuffle_broadcast_result_off -- +1 37 10 +2 16 20 +3 16 30 +4 19 40 + +-- !eight_layer_mixed_join_agg_chain_result_on -- +1 52 44 20 +2 9 14 40 +3 8 8 60 +4 10 9 80 + +-- !eight_layer_mixed_join_agg_chain_result_off -- +1 52 44 20 +2 9 14 40 +3 8 8 60 +4 10 9 80 + +-- !seven_layer_left_join_mix_result_on -- +1 10 11 10 +2 5 7 20 +3 5 8 30 +4 6 9 40 + +-- !seven_layer_left_join_mix_result_off -- +1 10 11 10 +2 5 7 20 +3 5 8 30 +4 6 9 40 + +-- !broadcast_shuffle_broadcast_nested_agg_result_on -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !broadcast_shuffle_broadcast_nested_agg_result_off -- +1 26 11 10 +2 9 7 20 +3 8 8 30 +4 10 9 40 + +-- !window_union_join_agg_result_on -- +1 13 14 +2 7 23 +3 8 32 +4 10 42 + +-- !window_union_join_agg_result_off -- +1 13 14 +2 7 23 +3 8 32 +4 10 42 + +-- !window_except_join_agg_result_on -- +1 1 10 +2 1 20 +3 1 30 + +-- !window_except_join_agg_result_off -- +1 1 10 +2 1 20 +3 1 30 + +-- !window_intersect_shuffle_agg_result_on -- +1 22 2 +2 7 1 +3 8 1 +4 9 1 + +-- !window_intersect_shuffle_agg_result_off -- +1 22 2 +2 7 1 +3 8 1 +4 9 1 + +-- !window_union_except_broadcast_agg_result_on -- +1 13 4 10 +2 7 3 20 +3 8 2 30 +4 10 2 40 + +-- !window_union_except_broadcast_agg_result_off -- +1 13 4 10 +2 7 3 20 +3 8 2 30 +4 10 2 40 + +-- !window_setop_join_agg_chain_result_on -- +1 20 2 22 +2 10 2 14 +3 5 1 8 +4 6 1 9 + +-- !window_setop_join_agg_chain_result_off -- +1 20 2 22 +2 10 2 14 +3 5 1 8 +4 6 1 9 diff --git a/regression-test/plugins/plugin_profile_plan_tree.groovy b/regression-test/plugins/plugin_profile_plan_tree.groovy new file mode 100644 index 00000000000000..c8c8d05cad4349 --- /dev/null +++ b/regression-test/plugins/plugin_profile_plan_tree.groovy @@ -0,0 +1,298 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import org.apache.doris.regression.suite.Suite + +/** + * Parses the MergedProfile section of a Doris query profile and returns + * the pipeline operator tree in an explain-like format. + * + * Profile text structure (MergedProfile section): + * MergedProfile: + * Fragments: + * Fragment N: ← indent level A + * Pipeline N(instance_num=M): ← indent level B (B = A + 2) + * - WaitWorkerTime: ... ← pipeline stats (skip) + * OPERATOR_NAME(...): ← indent level C (C = B + 2) + * - PlanInfo ← plan info marker + * - key: value ← plan info item + * CommonCounters: ← counter section start (stop reading plan info) + * - ... + * + * Usage in a test suite: + * // From raw profile text: + * def tree = profile_plan_tree(profileText) + * logger.info(tree) + * + * // From a query_id (fetches profile via HTTP): + * def tree = profile_plan_tree_from_id(queryId) + * logger.info(tree) + */ + +// --------------------------------------------------------------------------- +// profile_plan_tree(profileText) → formatted String +// --------------------------------------------------------------------------- +Suite.metaClass.profile_plan_tree = { String profileText -> + + // ── Locate MergedProfile section ────────────────────────────────────── + def mergedStart = profileText.indexOf("MergedProfile:") + if (mergedStart == -1) { + return "(no MergedProfile section found in profile)" + } + def mergedSection = profileText.substring(mergedStart) + + // ── Line-by-line state machine ───────────────────────────────────────── + // We detect indent levels dynamically: the first Fragment line sets the + // baseline; Pipeline and Operator levels follow by relative indentation. + int fragmentIndent = -1 // indent of "Fragment N:" lines + int pipelineIndent = -1 // indent of "Pipeline N(...):" lines + int operatorIndent = -1 // indent of "OPERATOR_NAME(...):" lines + int planInfoIndent = -1 // indent of "- PlanInfo" lines + int planItemIndent = -1 // indent of "- key: value" plan info items + int counterIndent = -1 // indent of "CommonCounters:" / "CustomCounters:" + + // Parsed tree: list of fragments, each with pipelines, each with operators + def fragments = [] // [{name, pipelines:[{name,instanceNum,ops:[{name,planInfo:[]}]}]}] + + def curFragName = null + def curPipeName = null + def curOpName = null + def curPlanInfo = [] // accumulated plan-info lines for current operator + def inPlanInfo = false + def curPipeOps = [] // operators in current pipeline + def curFragPipes = [] // pipelines in current fragment + + def flushOp = { + if (curOpName != null) { + curPipeOps << [name: curOpName, planInfo: new ArrayList(curPlanInfo)] + curOpName = null + curPlanInfo.clear() + inPlanInfo = false + } + } + + def flushPipeline = { + flushOp() + if (curPipeName != null) { + curFragPipes << [name: curPipeName, ops: new ArrayList(curPipeOps)] + curPipeName = null + curPipeOps.clear() + } + } + + def flushFragment = { + flushPipeline() + if (curFragName != null) { + fragments << [name: curFragName, pipelines: new ArrayList(curFragPipes)] + curFragName = null + curFragPipes.clear() + } + } + + for (def rawLine : mergedSection.split("\n")) { + // Count leading spaces + int spaces = 0 + for (char c : rawLine.toCharArray()) { + if (c == ' ') spaces++ + else break + } + def content = rawLine.trim() + if (content.isEmpty()) continue + + // ── Fragment ─────────────────────────────────────────────────────── + if (content =~ /^Fragment \d+:$/) { + if (fragmentIndent == -1) fragmentIndent = spaces + if (spaces == fragmentIndent) { + flushFragment() + curFragName = content[0..-2] // strip trailing ':' + // Reset derived indent markers when a new fragment begins + pipelineIndent = -1 + operatorIndent = -1 + planInfoIndent = -1 + planItemIndent = -1 + counterIndent = -1 + continue + } + } + + // Only process further if we are inside a Fragment + if (curFragName == null) continue + + // ── Pipeline ─────────────────────────────────────────────────────── + if (content =~ /^Pipeline \d+\(instance_num=\d+\):$/) { + if (pipelineIndent == -1) pipelineIndent = spaces + if (spaces == pipelineIndent) { + flushPipeline() + def m = content =~ /^Pipeline (\d+)\(instance_num=(\d+)\):$/ + if (m.find()) { + curPipeName = "Pipeline ${m.group(1)} (instances=${m.group(2)})" + } else { + curPipeName = content[0..-2] + } + // Reset operator-level indent markers per pipeline + operatorIndent = -1 + planInfoIndent = -1 + planItemIndent = -1 + counterIndent = -1 + continue + } + } + + // Only process further if we are inside a Pipeline + if (curPipeName == null) continue + + // ── CommonCounters / CustomCounters ──────────────────────────────── + // These appear inside an operator block and signal end of PlanInfo. + if (content == "CommonCounters:" || content == "CustomCounters:") { + if (counterIndent == -1) counterIndent = spaces + if (spaces == counterIndent) { + inPlanInfo = false + continue + } + } + + // ── PlanInfo marker ──────────────────────────────────────────────── + if (content == "- PlanInfo") { + if (planInfoIndent == -1) planInfoIndent = spaces + if (spaces == planInfoIndent) { + inPlanInfo = true + continue + } + } + + // ── PlanInfo item ────────────────────────────────────────────────── + if (inPlanInfo && content.startsWith("- ")) { + if (planItemIndent == -1) planItemIndent = spaces + if (spaces == planItemIndent) { + curPlanInfo << content.substring(2) // strip leading "- " + continue + } + } + + // ── Operator line ────────────────────────────────────────────────── + // Operator names start with an uppercase letter, contain only + // A-Z, 0-9, _, (, ) characters, and end with ':'. + // Skip counter section headers (CommonCounters / CustomCounters already handled above). + if (content.endsWith(":") && content =~ /^[A-Z][A-Z0-9_]/) { + // Ignore pure counter/info headers that are not operators + if (content == "CommonCounters:" || content == "CustomCounters:" || + content == "PlanInfo:") { + continue + } + if (operatorIndent == -1) operatorIndent = spaces + if (spaces == operatorIndent) { + flushOp() + curOpName = content[0..-2] // strip trailing ':' + inPlanInfo = false + planInfoIndent = -1 + planItemIndent = -1 + counterIndent = -1 + continue + } + } + } + + flushFragment() + + // ── Format output ────────────────────────────────────────────────────── + // Similar to explain plan: + // Fragment N: + // Pipeline M (instances=K): + // OPERATOR_NAME (...) + // | plan-info-key: value + // | ... + if (fragments.isEmpty()) { + return "(MergedProfile found but no fragments could be parsed)" + } + + def sb = new StringBuilder() + for (def frag : fragments) { + sb.append("${frag.name}:\n") + for (def pipe : frag.pipelines) { + sb.append(" ${pipe.name}:\n") + for (def op : pipe.ops) { + sb.append(" ${op.name}\n") + for (def pi : op.planInfo) { + sb.append(" | ${pi}\n") + } + } + } + } + + return sb.toString() +} + +// --------------------------------------------------------------------------- +// profile_plan_tree_from_id(queryId) → formatted String +// Fetches profile via HTTP then calls profile_plan_tree. +// --------------------------------------------------------------------------- +// Fetch the raw profile text for a query id via the FE HTTP API. +Suite.metaClass.profile_text_from_id = { String queryId -> + Suite suite = delegate as Suite + def dst = 'http://' + suite.context.config.feHttpAddress + def conn = new URL("${dst}/api/profile/text?query_id=${queryId}").openConnection() + conn.setRequestMethod("GET") + def user = suite.context.config.feHttpUser ?: "root" + def pass = suite.context.config.feHttpPassword ?: "" + def encoding = Base64.getEncoder().encodeToString("${user}:${pass}".getBytes("UTF-8")) + conn.setRequestProperty("Authorization", "Basic ${encoding}") + conn.setConnectTimeout(5000) + conn.setReadTimeout(15000) + return conn.getInputStream().getText() +} + +Suite.metaClass.profile_plan_tree_from_id = { String queryId -> + Suite suite = delegate as Suite + def profileText = suite.profile_text_from_id(queryId) + def tree = suite.profile_plan_tree(profileText) + return tree.split('\n').findAll { !it.startsWith(' | ') }.join('\n') +} + +// --------------------------------------------------------------------------- +// profile_plan_tree_from_sql(testSql) → formatted String +// Executes the SQL with profiling enabled and SQL cache disabled, polls until +// the profile is fully collected (master's "Profile Completion State: COMPLETE", +// set once all BE fragment profiles have been merged), then returns the tree. +// --------------------------------------------------------------------------- +Suite.metaClass.profile_plan_tree_from_sql = { String testSql -> + Suite suite = delegate as Suite + suite.sql "set enable_profile=true;" + suite.sql "set enable_sql_cache=false;" + suite.sql testSql + def qid = suite.sql("select last_query_id()")[0][0] as String + // Wait for the profile to be fully collected using master's structured completion state + // (#64392): the "Profile Completion State" field of /rest/v1/query_profile becomes "COMPLETE" + // once all BE fragment profiles are merged. Poll that instead of a fixed sleep (~30s budget). + def profileAction = new org.apache.doris.regression.action.ProfileAction(suite.context) + for (int i = 0; i < 60; i++) { + try { + boolean complete = profileAction.getProfileList().any { + it["Profile ID"]?.toString() == qid && + it["Profile Completion State"]?.toString() == "COMPLETE" + } + if (complete) { + break + } + } catch (Exception e) { + // profile list not available yet — keep polling + } + Thread.sleep(500) + } + return suite.profile_plan_tree_from_id(qid) +} + +logger.info("Added 'profile_plan_tree', 'profile_plan_tree_from_id' and 'profile_plan_tree_from_sql' to Suite") diff --git a/regression-test/suites/nereids_function_p0/gen_function/unnest_order_by_list_test.groovy b/regression-test/suites/nereids_function_p0/gen_function/unnest_order_by_list_test.groovy index af502abe1fef71..8f441532fee672 100644 --- a/regression-test/suites/nereids_function_p0/gen_function/unnest_order_by_list_test.groovy +++ b/regression-test/suites/nereids_function_p0/gen_function/unnest_order_by_list_test.groovy @@ -87,11 +87,11 @@ suite("unnest_order_by_list_test", "unnest") { (2, 'Math', [60, 75]);""" // Test using the unnested value within the ORDER BY clause of a window function. - qt_window_function_order_by_unnested_value """ - SELECT - user_id, - subject, - s.val, + order_qt_window_function_order_by_unnested_value """ + SELECT + user_id, + subject, + s.val, RANK() OVER (PARTITION BY user_id, subject ORDER BY s.val DESC) as score_rank FROM ${tb_name2}, UNNEST(history_scores) AS s(val);""" diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_enable_local_exchange_before_agg.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_enable_local_exchange_before_agg.groovy new file mode 100644 index 00000000000000..33fd45951f66a7 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_enable_local_exchange_before_agg.groovy @@ -0,0 +1,157 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Regression test for enable_local_exchange_before_agg (apache/doris#62438). + * + * When enable_local_exchange_before_agg=true (default), BE inserts HASH local exchange + * before pre-agg operators. When enable_local_shuffle_planner=true, FE does the same. + * This test verifies correctness under both combinations. + */ +suite("test_enable_local_exchange_before_agg", "p0") { + + sql "DROP TABLE IF EXISTS le_agg_t1" + sql """ + CREATE TABLE le_agg_t1 ( + k1 INT NOT NULL, + k2 VARCHAR(32), + v1 INT, + v2 BIGINT + ) ENGINE=OLAP + DUPLICATE KEY(k1) + DISTRIBUTED BY HASH(k1) BUCKETS 8 + PROPERTIES ("replication_num" = "1") + """ + + sql """INSERT INTO le_agg_t1 VALUES + (1, 'a', 10, 100), (1, 'a', 20, 200), (1, 'b', 30, 300), + (2, 'a', 40, 400), (2, 'b', 50, 500), (2, 'b', 60, 600), + (3, 'c', 70, 700), (3, 'c', 80, 800), (3, 'd', 90, 900), + (4, 'a', 100, 1000), (4, 'd', 110, 1100), (4, 'd', 120, 1200), + (5, 'e', 130, 1300), (5, 'e', 140, 1400), (5, 'f', 150, 1500) + """ + + def PH = "PLACEHOLDER" + + def beHints = """/*+SET_VAR( + parallel_pipeline_task_num=4, + enable_local_shuffle=true, + ignore_storage_data_distribution=true, + enable_local_shuffle_planner=false, + enable_local_exchange_before_agg=true + )*/""" + + def feHints = """/*+SET_VAR( + parallel_pipeline_task_num=4, + enable_local_shuffle=true, + ignore_storage_data_distribution=true, + enable_local_shuffle_planner=true, + enable_local_exchange_before_agg=true + )*/""" + + def noLeBeforeAggHints = """/*+SET_VAR( + parallel_pipeline_task_num=4, + enable_local_shuffle=true, + ignore_storage_data_distribution=true, + enable_local_shuffle_planner=true, + enable_local_exchange_before_agg=false + )*/""" + + // Forces LOCAL preagg through the non-streaming AggSink / DistinctStreaming paths + // (instead of the StreamingAgg branch). Combined with enable_local_exchange_before_agg=false, + // this exercises the phase-aware fix: + // - AggSink: !isMerge() LOCAL phase → base (PASSTHROUGH/NOOP) + // FIRST_MERGE → HASH (correctness, regardless of flag) + // - DistinctStreaming: useStreamingPreagg=true → base + // useStreamingPreagg=false → HASH (correctness, regardless of flag) + def disableStreamingHints = """/*+SET_VAR( + parallel_pipeline_task_num=4, + enable_local_shuffle=true, + ignore_storage_data_distribution=true, + enable_local_shuffle_planner=true, + enable_local_exchange_before_agg=false, + disable_streaming_preaggregations=true + )*/""" + + def disableStreamingFlagOnHints = """/*+SET_VAR( + parallel_pipeline_task_num=4, + enable_local_shuffle=true, + ignore_storage_data_distribution=true, + enable_local_shuffle_planner=true, + enable_local_exchange_before_agg=true, + disable_streaming_preaggregations=true + )*/""" + + def queries = [ + "simple_agg_bucket_key": + "SELECT ${PH} k1, SUM(v1), COUNT(*) FROM le_agg_t1 GROUP BY k1 ORDER BY k1", + "simple_agg_non_bucket_key": + "SELECT ${PH} k2, SUM(v1), MAX(v2) FROM le_agg_t1 GROUP BY k2 ORDER BY k2", + "multi_key_agg": + "SELECT ${PH} k1, k2, SUM(v1) FROM le_agg_t1 GROUP BY k1, k2 ORDER BY k1, k2", + "distinct_non_bucket": + "SELECT ${PH} DISTINCT k2 FROM le_agg_t1 ORDER BY k2", + "count_distinct": + "SELECT ${PH} k1, COUNT(DISTINCT k2), SUM(v1) FROM le_agg_t1 GROUP BY k1 ORDER BY k1", + "agg_having": + "SELECT ${PH} k2, SUM(v1) AS s FROM le_agg_t1 GROUP BY k2 HAVING SUM(v1) > 100 ORDER BY k2", + "agg_after_join": + "SELECT ${PH} a.k2, SUM(a.v1) FROM le_agg_t1 a JOIN le_agg_t1 b ON a.k1 = b.k1 GROUP BY a.k2 ORDER BY a.k2", + "grouping_sets": + "SELECT ${PH} k1, k2, SUM(v1) FROM le_agg_t1 GROUP BY GROUPING SETS ((k1), (k2), (k1, k2)) ORDER BY k1, k2", + "window_over_agg": + "SELECT ${PH} k1, s, SUM(s) OVER (ORDER BY k1) AS running FROM (SELECT k1, SUM(v1) AS s FROM le_agg_t1 GROUP BY k1) t ORDER BY k1", + "multi_distinct": + "SELECT ${PH} COUNT(DISTINCT k1), COUNT(DISTINCT k2) FROM le_agg_t1", + ] + + // Part 1: FE-planned (enable_local_exchange_before_agg=true) vs BE-planned baseline + logger.info("=== Part 1: FE vs BE with enable_local_exchange_before_agg=true ===") + queries.each { name, template -> + def beResult = sql(template.replace(PH, beHints)) + def feResult = sql(template.replace(PH, feHints)) + assertEquals(beResult, feResult, "[${name}] FE-planned differs from BE-planned") + logger.info("[${name}] PASSED") + } + + // Part 2: enable_local_exchange_before_agg=false — verify no crash/hang and correct results + logger.info("=== Part 2: enable_local_exchange_before_agg=false ===") + queries.each { name, template -> + def beResult = sql(template.replace(PH, beHints)) + def noLeResult = sql(template.replace(PH, noLeBeforeAggHints)) + assertEquals(beResult, noLeResult, "[${name}] enable_local_exchange_before_agg=false differs from baseline") + logger.info("[${name}] enable_local_exchange_before_agg=false PASSED") + } + + // Part 3: disable_streaming_preaggregations=true — forces AggSink / DistinctStreaming + // non-streaming paths. Combined with the two flag values, exercises the phase-aware + // fix on both LOCAL (performance, flag controls) and MERGE/non-streaming-dedup + // (correctness, always HASH) sub-paths. + logger.info("=== Part 3: disable_streaming_preaggregations=true ===") + queries.each { name, template -> + def beResult = sql(template.replace(PH, beHints)) + def disableStreamingFlagOnResult = sql(template.replace(PH, disableStreamingFlagOnHints)) + assertEquals(beResult, disableStreamingFlagOnResult, + "[${name}] disable_streaming+flag=true differs from baseline") + def disableStreamingResult = sql(template.replace(PH, disableStreamingHints)) + assertEquals(beResult, disableStreamingResult, + "[${name}] disable_streaming+flag=false differs from baseline") + logger.info("[${name}] disable_streaming both flag values PASSED") + } + + logger.info("=== All enable_local_exchange_before_agg tests completed ===") +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_fe_be_consistency.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_fe_be_consistency.groovy new file mode 100644 index 00000000000000..6173aa8148e2a4 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_fe_be_consistency.groovy @@ -0,0 +1,755 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Verify FE-planned local exchange (enable_local_shuffle_planner=true) produces + * the same query results as BE-native local exchange (enable_local_shuffle_planner=false) + * across a wide set of operator/plan shapes. + * + * Only data correctness is asserted — the two planners legitimately differ on the + * exact exchange counts/types they emit, so plan-shape comparison is intentionally + * not done here. (Earlier iterations of this suite did that for diagnosis; those + * comparisons are flaky in practice and are not appropriate for a regression net.) + */ +suite("test_local_shuffle_fe_be_consistency") { + + def setVarBase = "disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0" + + // Run the given SQL twice — once with enable_local_shuffle_planner=true (FE planner) + // and once with =false (BE-native) — and assert the result rows are identical. + // knownDiff is accepted for source compatibility but has no effect anymore. + def checkConsistencyWithSql = { String tag, String testSql, boolean knownDiff = false -> + def sqlOn = testSql.replaceFirst(/(?i)\/\*\+SET_VAR\(([^)]*)\)\s*\*\//, "/*+SET_VAR(enable_local_shuffle_planner=true,\$1)*/") + def sqlOff = testSql.replaceFirst(/(?i)\/\*\+SET_VAR\(([^)]*)\)\s*\*\//, "/*+SET_VAR(enable_local_shuffle_planner=false,\$1)*/") + if (!testSql.contains("/*+SET_VAR")) { + sqlOn = testSql.replaceFirst(/(?i)^\s*(SELECT)\s+/, "SELECT /*+SET_VAR(enable_local_shuffle_planner=true,${setVarBase})*/ ") + sqlOff = testSql.replaceFirst(/(?i)^\s*(SELECT)\s+/, "SELECT /*+SET_VAR(enable_local_shuffle_planner=false,${setVarBase})*/ ") + } + check_sql_equal(sqlOn, sqlOff) + } + + // ============================================================ + // Common settings + // ============================================================ + sql "SET enable_nereids_planner=true" + sql "SET enable_fallback_to_original_planner=false" + sql "SET runtime_filter_mode=off" + sql "SET parallel_pipeline_task_num=4" + sql "SET enable_sql_cache=false" + // Keep local shuffle feature globally enabled; only toggle the planner flag + sql "SET enable_local_shuffle=true" + // Disable ignore_storage_data_distribution to get predictable plans from scans + sql "SET ignore_storage_data_distribution=false" + + // ============================================================ + // Table setup + // ls_t1: HASH(k1) 8 buckets + // ls_t2: HASH(k1) 8 buckets (same distribution → colocate-eligible) + // ls_t3: HASH(k4) 5 buckets (different distribution) + // ls_serial: HASH(k1) 2 buckets (for serial-scan tests: 2 < parallel_pipeline_task_num=4) + // ============================================================ + sql "DROP TABLE IF EXISTS ls_t1" + sql "DROP TABLE IF EXISTS ls_t2" + sql "DROP TABLE IF EXISTS ls_t3" + sql "DROP TABLE IF EXISTS ls_serial" + + sql """ + CREATE TABLE ls_t1 ( + k1 INT NOT NULL, + k2 INT, + v1 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k2) + DISTRIBUTED BY HASH(k1) BUCKETS 8 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE ls_t2 ( + k1 INT NOT NULL, + k3 INT, + v2 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k3) + DISTRIBUTED BY HASH(k1) BUCKETS 8 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE ls_t3 ( + k1 INT NOT NULL, + k4 INT, + v3 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k4) + DISTRIBUTED BY HASH(k4) BUCKETS 5 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + INSERT INTO ls_t1 VALUES + (1, 10, 2), (1, 11, 3), (2, 20, 4), (2, 21, 1), + (3, 30, 5), (4, 40, 6), (5, 50, 7), (6, 60, 8), + (7, 70, 9), (8, 80, 10), (9, 90, 11), (10, 100, 12) + """ + + sql """ + INSERT INTO ls_t2 VALUES + (1, 100, 7), (1, 101, 1), (2, 200, 2), (3, 300, 3), + (4, 400, 4), (5, 500, 5), (6, 600, 6), (7, 700, 7) + """ + + sql """ + INSERT INTO ls_t3 VALUES + (1, 1001, 5), (1, 1001, 6), (2, 1002, 7), + (3, 1003, 8), (4, 1004, 9), (5, 1005, 10) + """ + + sql """ + CREATE TABLE ls_serial ( + k1 INT NOT NULL, + k2 INT, + v1 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k2) + DISTRIBUTED BY HASH(k1) BUCKETS 2 + PROPERTIES ("replication_num" = "1") + """ + sql """ + INSERT INTO ls_serial VALUES + (1, 10, 2), (2, 20, 4), (3, 30, 5), (4, 40, 6) + """ + + // SET_VAR prefix used in most test SQLs (disables plan reorder/colocate for deterministic plans) + def sv = "/*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0)*/" + // Same as sv but forces serial source path (default in many environments) + def svSerialSource = "/*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=true,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0)*/" + + // ================================================================ + // Section 1: AggSink / StreamingAgg scenarios + // BE operator: AggSinkOperatorX / StreamingAggOperatorX + // ================================================================ + + // 1-1: AggSink finalize, no group key + // Finalize phase: FE uses noRequire() (needsFinalize && groupingExprs.isEmpty()) → NOOP + // Streaming pre-agg phase: FE uses requirePassthrough() (else branch, groupingExprs.isEmpty()) + // BE: does not add exchanges for no-group-key agg (NOOP) + // Known diff: FE inserts PASSTHROUGH for streaming pre-agg; BE skips exchanges entirely + checkConsistencyWithSql("agg_finalize_no_group_key", + "SELECT ${sv} count(*) FROM ls_t1") + + // 1-2: AggSink 1-phase, bucket key (k1) → no mismatch (distribution matches) + // BE: BUCKET_HASH_SHUFFLE for sink, scan already provides BUCKET_HASH_SHUFFLE + // → need_to_local_exchange returns false (both hash types match) + checkConsistencyWithSql("agg_1phase_bucket_key", + "SELECT ${sv} k1, count(*) AS cnt FROM ls_t1 GROUP BY k1 ORDER BY k1") + + // 1-2b: Same SQL under serial-source mode (ignore_storage_data_distribution=true) + // This explicitly validates FE/BE consistency under serial-source planning path. + checkConsistencyWithSql("agg_1phase_bucket_key_serial_source", + "SELECT ${svSerialSource} k1, count(*) AS cnt FROM ls_t1 GROUP BY k1 ORDER BY k1") + + // 1-2c: Finalize agg, serial/pooling scan, bucket key (k1), ls_serial (2 buckets). + // Pooling scan + bucket-key colocate agg: BE inserts PASSTHROUGH fan-out (heavy_ops + // bottleneck avoidance before LOCAL_HASH_SHUFFLE) + LOCAL_HASH_SHUFFLE. + // FE mirrors with heavy_ops check in enforceChild. + checkConsistencyWithSql("agg_finalize_serial_pooling_bucket", + "SELECT ${svSerialSource} k1, count(*) AS cnt FROM ls_serial GROUP BY k1 ORDER BY k1") + + // 1-2c2: Same finalize agg with bucket key, but non-pooling (ignore_storage_data_distribution=false). + // No serial source → no heavy_ops PASSTHROUGH fan-out needed. + // Known mismatch on clusters where ls_serial (2 BUCKETS) tablets land on different BEs: + // FE sees global instanceCount=2 → inserts LE; each BE sees local _num_instances=1 → skips. + // This is a pre-existing FE/BE instanceCount discrepancy, not a planner bug. + checkConsistencyWithSql("agg_finalize_non_pooling_bucket", + "SELECT ${sv} k1, count(*) AS cnt FROM ls_serial GROUP BY k1 ORDER BY k1") + + // 1-2d: Agg, serial/pooling scan, non-bucket key (k2), ls_serial. + checkConsistencyWithSql("agg_finalize_serial_pooling_non_bucket", + "SELECT ${svSerialSource} k2, count(*) AS cnt FROM ls_serial GROUP BY k2 ORDER BY k2") + + // 1-3: AggSink 1-phase, non-bucket key (k2) + // BE: GLOBAL_EXECUTION_HASH_SHUFFLE vs BUCKET_HASH_SHUFFLE from scan + // → inserts GLOBAL_HASH_SHUFFLE (or LOCAL_HASH_SHUFFLE for local execution) + checkConsistencyWithSql("agg_1phase_non_bucket_key", + "SELECT ${sv} k2, count(*) AS cnt FROM ls_t1 GROUP BY k2 ORDER BY k2") + + // 1-4: AggSink 1-phase, multi-column non-bucket key (k1,k2) + // Even though k1 is the bucket key, (k1,k2) is not → GLOBAL_HASH_SHUFFLE + checkConsistencyWithSql("agg_1phase_multi_key_non_bucket", + "SELECT ${sv} k1, k2, count(*) AS cnt FROM ls_t1 GROUP BY k1, k2 ORDER BY k1, k2") + + // 1-5: Two-phase agg (pre-agg + finalize), non-bucket key + // Tests that both streaming agg pre-phase and finalize phases are handled correctly + checkConsistencyWithSql("agg_two_phase_non_bucket", + "SELECT ${sv} k2, sum(v1) AS s FROM ls_t1 GROUP BY k2 ORDER BY k2") + + // ================================================================ + // Section 2: DistinctStreamingAgg scenarios + // BE operator: DistinctStreamingAggOperatorX + // ================================================================ + + // 2-1: DISTINCT on bucket key → no extra exchange needed (distribution matches) + checkConsistencyWithSql("distinct_bucket_key", + "SELECT ${sv} DISTINCT k1 FROM ls_t1 ORDER BY k1") + + // 2-2: DISTINCT on non-bucket key + checkConsistencyWithSql("distinct_non_bucket_key", + "SELECT ${sv} DISTINCT k2 FROM ls_t1 ORDER BY k2") + + // 2-3: DISTINCT on multiple non-bucket keys + checkConsistencyWithSql("distinct_multi_non_bucket", + "SELECT ${sv} DISTINCT k1, k2 FROM ls_t1 ORDER BY k1, k2") + + // ================================================================ + // Section 3: AnalyticSink / SortSink (analytic) scenarios + // BE operators: AnalyticSinkOperatorX, SortSinkOperatorX + // ================================================================ + + // 3-1: Analytic window, no PARTITION BY → serial path, no exchange needed + checkConsistencyWithSql("analytic_no_partition", + "SELECT ${sv} k1, sum(v1) OVER() AS s FROM ls_t1 ORDER BY k1, s") + + // 3-2: Analytic window, PARTITION BY non-bucket key → GLOBAL_HASH_SHUFFLE + // Also triggers SortSink (analytic sort) → GLOBAL_HASH_SHUFFLE + checkConsistencyWithSql("analytic_partition_non_bucket", + "SELECT ${sv} k1, k2, row_number() OVER(PARTITION BY k2 ORDER BY k1) AS rn FROM ls_t1 ORDER BY k2, k1, rn") + + // 3-3: Analytic window, PARTITION BY bucket key → BUCKET_HASH_SHUFFLE (or no extra exchange) + // SortSink(analytic): if colocate+bucket → BUCKET_HASH_SHUFFLE + checkConsistencyWithSql("analytic_partition_bucket_key", + "SELECT ${sv} k1, sum(v1) OVER(PARTITION BY k1) AS s FROM ls_t1 ORDER BY k1, s") + + // 3-4: ORDER BY sort (SortSink._merge_by_exchange=true) → PASSTHROUGH + checkConsistencyWithSql("sort_order_by", + "SELECT ${sv} * FROM ls_t1 ORDER BY k1, k2 LIMIT 10") + + // ================================================================ + // Section 4: PartitionSortSink scenarios + // BE operator: PartitionSortSinkOperatorX + // ================================================================ + + // 4-1: PartitionSort TWO_PHASE_GLOBAL (triggered by QUALIFY / ROW_NUMBER with LIMIT) + // → GLOBAL_EXECUTION_HASH_SHUFFLE + checkConsistencyWithSql("partition_sort_two_phase_global", + """SELECT ${sv} k1, k2, v1 + FROM ( + SELECT k1, k2, v1, + ROW_NUMBER() OVER(PARTITION BY k2 ORDER BY v1 DESC) AS rn + FROM ls_t1 + ) t + WHERE rn <= 2 + ORDER BY k1, k2""") + + // 4-2: PartitionSort single phase (TWO_PHASE_LOCAL or ONE_PHASE) → PASSTHROUGH + // Note: This depends on the optimizer's choice; TopN on non-partitioned window + checkConsistencyWithSql("partition_sort_single_phase", + """SELECT ${sv} k1, k2, v1 + FROM ( + SELECT k1, k2, v1, + ROW_NUMBER() OVER(PARTITION BY k2 ORDER BY v1 DESC) AS rn + FROM ls_t1 + ) t + WHERE rn = 1 + ORDER BY k1, k2""") + + // ================================================================ + // Section 5: HashJoinProbe / HashJoinBuildSink scenarios + // BE operators: HashJoinProbeOperatorX, HashJoinBuildSinkOperatorX + // ================================================================ + + // 5-1: Broadcast join — probe NOOP (or PASSTHROUGH if serial), build PASS_TO_ONE (if serial) + checkConsistencyWithSql("hash_join_broadcast", + """SELECT ${sv} a.k1, a.v1, b.v2 + FROM ls_t1 a JOIN [broadcast] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1, a.k2""") + + // 5-2: Shuffle (PARTITIONED) join → probe GLOBAL_HASH_SHUFFLE, build GLOBAL_HASH_SHUFFLE + checkConsistencyWithSql("hash_join_shuffle", + """SELECT ${sv} a.k1, a.v1, b.v2 + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1, a.k2""") + + // 5-3: LEFT OUTER shuffle join + // Known diff: In single-BE environments, BE's pipeline-level need_to_local_exchange() + // may skip exchanges when num_tasks_of_parent<=1, while FE still inserts PASSTHROUGH + // because it lacks pipeline-level task count information. + checkConsistencyWithSql("hash_join_left_outer_shuffle", + """SELECT ${sv} a.k1, a.v1, b.v2 + FROM ls_t1 a LEFT OUTER JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1, a.k2""") + + // 5-4: RIGHT OUTER shuffle join + checkConsistencyWithSql("hash_join_right_outer_shuffle", + """SELECT ${sv} a.k1, a.v1, b.v2 + FROM ls_t1 a RIGHT OUTER JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1, b.k3""") + + // 5-5: FULL OUTER shuffle join + checkConsistencyWithSql("hash_join_full_outer_shuffle", + """SELECT ${sv} a.k1, a.v1, b.v2 + FROM ls_t1 a FULL OUTER JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1, b.k3""") + + // 5-6: LEFT SEMI shuffle join + checkConsistencyWithSql("hash_join_left_semi_shuffle", + """SELECT ${sv} a.k1, a.v1 + FROM ls_t1 a LEFT SEMI JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1""") + + // 5-7: LEFT ANTI shuffle join + // Known diff: Same as LEFT OUTER — BE pipeline-level num_tasks_of_parent<=1 check + // skips exchanges in single-BE environments; FE cannot replicate this. + checkConsistencyWithSql("hash_join_left_anti_shuffle", + """SELECT ${sv} a.k1, a.v1 + FROM ls_t1 a LEFT ANTI JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1""") + + // 5-8: NULL_AWARE_LEFT_ANTI_JOIN (NOT IN subquery with nullable) → NOOP + // BE: both probe and build NOOP + checkConsistencyWithSql("hash_join_null_aware_left_anti", + """SELECT ${sv} k1, v1 FROM ls_t1 + WHERE k1 NOT IN (SELECT k1 FROM ls_t2) + ORDER BY k1""") + + // ================================================================ + // Section 6: NestedLoopJoin scenarios + // BE operators: NestedLoopJoinProbeOperatorX, NestedLoopJoinBuildSinkOperatorX + // ================================================================ + + // 6-1: NLJ INNER (cross/theta join) → probe ADAPTIVE_PASSTHROUGH, build BROADCAST (serial) + // FE: requireAdaptivePassthrough for probe; BE: ADAPTIVE_PASSTHROUGH for probe + checkConsistencyWithSql("nlj_inner_theta", + """SELECT ${sv} a.k1, b.k1 AS bk1 + FROM ls_t1 a, ls_t2 b WHERE a.k1 > b.k1 + ORDER BY a.k1, bk1 LIMIT 20""") + + // 6-2: NLJ LEFT OUTER + checkConsistencyWithSql("nlj_left_outer", + """SELECT ${sv} a.k1, b.k1 AS bk1 + FROM ls_t1 a LEFT OUTER JOIN ls_t2 b ON a.k1 > b.k1 + ORDER BY a.k1, bk1 LIMIT 20""") + + // 6-3: NLJ RIGHT OUTER → BE probe: NOOP; FE: ADAPTIVE_PASSTHROUGH (known difference) + // FE uses requireAdaptivePassthrough unconditionally for non-NULL_AWARE NLJ + // BE uses NOOP for RIGHT_OUTER/RIGHT_SEMI/RIGHT_ANTI/FULL_OUTER + checkConsistencyWithSql("nlj_right_outer", """ + SELECT ${sv} a.k1, b.k1 AS bk1 + FROM ls_t1 a RIGHT OUTER JOIN ls_t2 b ON a.k1 < b.k1 + ORDER BY a.k1, bk1 LIMIT 20 + """) + + // 6-4: NLJ FULL OUTER → same known difference as RIGHT_OUTER + checkConsistencyWithSql("nlj_full_outer", """ + SELECT ${sv} a.k1, b.k1 AS bk1 + FROM ls_t1 a FULL OUTER JOIN ls_t2 b ON a.k1 > b.k1 + ORDER BY a.k1, bk1 LIMIT 20 + """) + + // ================================================================ + // Section 7: Set operations (INTERSECT / EXCEPT) + // BE operators: SetSinkOperatorX, SetProbeSinkOperatorX, SetSourceOperatorX + // ================================================================ + + // 7-1: INTERSECT → GLOBAL_HASH_SHUFFLE for both set sink and set probe sink + checkConsistencyWithSql("set_intersect", + """SELECT ${sv} k1 FROM ls_t1 + INTERSECT + SELECT k1 FROM ls_t2 + ORDER BY k1""") + + // 7-2: EXCEPT → GLOBAL_HASH_SHUFFLE + checkConsistencyWithSql("set_except", + """SELECT ${sv} k1 FROM ls_t1 + EXCEPT + SELECT k1 FROM ls_t2 + ORDER BY k1""") + + // 7-3: Three-way INTERSECT + // FE and BE are consistent: + // - ls_t1/ls_t2 (colocated on k1): DISTINCT_STREAMING_AGG with needsFinalize=true + // → BE returns NOOP/HASH (already satisfied), FE requireHash (already satisfied) → no exchange + // - ls_t3 (non-colocated, partition on k4): DISTINCT_STREAMING_AGG with needsFinalize=false + // → BE returns PASSTHROUGH (enable_distinct_streaming_agg_force_passthrough=true), + // FE enableDistinctStreamingAggForcePassthrough=true → requirePassthrough → insert PASSTHROUGH + checkConsistencyWithSql("set_intersect_three_way", + """SELECT ${sv} k1 FROM ls_t1 + INTERSECT + SELECT k1 FROM ls_t2 + INTERSECT + SELECT k1 FROM ls_t3 + ORDER BY k1""") + + // ================================================================ + // Section 8: UNION scenarios + // BE operators: UnionSinkOperatorX, UnionSourceOperatorX + // ================================================================ + + // 8-1: UNION ALL (no downstream shuffled op → base default) + checkConsistencyWithSql("union_all_simple", + """SELECT ${sv} k1, v1 FROM ls_t1 + UNION ALL + SELECT k1, v2 FROM ls_t2 + ORDER BY k1, v1""") + + // 8-2: UNION ALL feeding into GROUP BY (union followed by shuffled agg) + checkConsistencyWithSql("union_all_followed_by_agg", + """SELECT ${sv} k1, count(*) AS cnt + FROM ( + SELECT k1, v1 AS v FROM ls_t1 + UNION ALL + SELECT k1, v2 AS v FROM ls_t2 + ) u + GROUP BY k1 + ORDER BY k1""") + + // 8-3: UNION followed by analytic window + checkConsistencyWithSql("union_all_followed_by_window", + """SELECT ${sv} k1, SUM(v) OVER(PARTITION BY k1) AS sv + FROM ( + SELECT k1, v1 AS v FROM ls_t1 + UNION ALL + SELECT k1, v2 AS v FROM ls_t2 + ) u + ORDER BY k1, sv""") + + // ================================================================ + // Section 9: TableFunction and AssertNumRows + // BE operators: TableFunctionOperatorX, AssertNumRowsOperatorX + // ================================================================ + + // 9-1: TableFunction (non-pooling) → PASSTHROUGH×2 + // BE TableFunctionOperatorX overrides required_data_distribution() to always return + // PASSTHROUGH; need_to_local_exchange Step 4 always inserts non-hash exchanges. + // So: OlapScan → PT → TableFunc → PT → Sort. Total: 2 PASSTHROUGH. + // FE mirrors: TableFunctionNode requires PASSTHROUGH from child (outputs NOOP), + // SortNode independently inserts PASSTHROUGH for mergeByExchange. + checkConsistencyWithSql("table_function", + """SELECT ${sv} k1, e1 FROM ls_t1 + LATERAL VIEW explode_numbers(v1) tmp AS e1 + ORDER BY k1, e1 LIMIT 20""") + + // 9-1b: TableFunction (pooling scan) → PASSTHROUGH×2 + // Same as 9-1: TableFunctionOperatorX always requires PASSTHROUGH regardless of child. + // Pooling scan (serial) → PT fan-out → TableFunc → PT → Sort. Total: 2 PASSTHROUGH. + // FE mirrors: TableFunctionNode requires PASSTHROUGH (outputs NOOP), + // SortNode independently inserts PASSTHROUGH for mergeByExchange. + checkConsistencyWithSql("table_function_pooling", + """SELECT ${svSerialSource} k1, e1 FROM ls_t1 + LATERAL VIEW explode_numbers(v1) tmp AS e1 + ORDER BY k1, e1 LIMIT 20""") + + // 9-2: AssertNumRows (scalar subquery) → PASSTHROUGH + // Known diff: In single-BE environments, FE and BE may disagree on instance counts + // for fragments containing AssertNumRows, leading to different exchange decisions. + checkConsistencyWithSql("assert_num_rows", + """SELECT ${sv} k1, (SELECT count(*) FROM ls_t2 WHERE ls_t2.k1 = ls_t1.k1) AS cnt + FROM ls_t1 + ORDER BY k1""") + + // ================================================================ + // Section 10: Mixed / multi-level scenarios + // ================================================================ + + // 10-1: Agg after shuffle join (k1 is bucket key → no extra exchange after join) + checkConsistencyWithSql("agg_after_shuffle_join_bucket_key", + """SELECT ${sv} a.k1, count(*) AS cnt + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + GROUP BY a.k1 + ORDER BY a.k1""") + + // 10-2: Agg after shuffle join on non-bucket column + // GROUP BY k2 ≠ join key k1. BE's StreamingAggOperatorX sees child is_hash_join_probe() + // and returns PASSTHROUGH (enable_streaming_agg_hash_join_force_passthrough=true by default), + // splitting the pipeline at the streaming pre-agg/join boundary. + // FE replicates this: AggregationNode detects useStreamingPreagg && child is HashJoinNode + // → requirePassthrough. Both FE and BE produce 18 PASSTHROUGH exchanges. + checkConsistencyWithSql("agg_after_shuffle_join_non_bucket_key", + """SELECT ${sv} a.k2, count(*) AS cnt + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + GROUP BY a.k2 + ORDER BY a.k2""") + + // 10-3: Agg after broadcast join + // ScanNode returns BUCKET_HASH_SHUFFLE (mirroring BE's ScanOperator). HashJoinNode + // (broadcast, non-serial probe) propagates probe side's distribution as its own + // output type (instead of hardcoding NOOP). AggNode sees BUCKET_HASH_SHUFFLE, + // RequireHash.satisfy(BUCKET_HASH_SHUFFLE)=true → no redundant hash exchange. + // Mirrors BE's !(hash && hash) check in need_to_local_exchange. + checkConsistencyWithSql("agg_after_broadcast_join", + """SELECT ${sv} a.k1, count(*) AS cnt + FROM ls_t1 a JOIN [broadcast] ls_t2 b ON a.k1 = b.k1 + GROUP BY a.k1 + ORDER BY a.k1""") + + // 10-4: Window after UNION + // Known diff: In single-BE environments, FE instance-count-based skipping and + // BE pipeline-level num_tasks checks can diverge for union+window fragments. + checkConsistencyWithSql("window_after_union", + """SELECT ${sv} k1, SUM(v) OVER(PARTITION BY k1) AS sv + FROM ( + SELECT k1, v1 AS v FROM ls_t1 + UNION ALL + SELECT k1, v2 AS v FROM ls_t2 + ) u + ORDER BY k1, sv""") + + // 10-5: Multi-level join + agg + window + checkConsistencyWithSql("join_agg_window_multilevel", + """SELECT ${sv} t.k1, t.cnt, + row_number() OVER(ORDER BY t.cnt DESC, t.k1 ASC) AS rn + FROM ( + SELECT a.k1, count(*) AS cnt + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + GROUP BY a.k1 + ) t + ORDER BY t.k1""") + + // 10-6: Two shuffle joins chained + checkConsistencyWithSql("two_shuffle_joins", + """SELECT ${sv} a.k1, b.k3, c.k4 + FROM ls_t1 a + JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + JOIN [shuffle] ls_t3 c ON a.k1 = c.k1 + ORDER BY a.k1, b.k3 LIMIT 20""") + + // 10-7: Complex: join → agg → agg (double-layer aggregation after join) + // Known diff: Multi-level agg fragments may have single instances in single-BE + // environments, causing FE/BE exchange decision divergence. + checkConsistencyWithSql("complex_join_double_agg", + """SELECT ${sv} z.k1, SUM(z.metric) AS total + FROM ( + SELECT y.k1, SUM(y.metric) AS metric + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS metric + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + GROUP BY a.k1 + ) y + GROUP BY y.k1 + ) z + GROUP BY z.k1 + ORDER BY z.k1""") + + // 10-8: Agg then INTERSECT + checkConsistencyWithSql("agg_then_intersect", + """SELECT ${sv} k1 + FROM (SELECT k1, count(*) AS cnt FROM ls_t1 GROUP BY k1 HAVING cnt > 0) a + INTERSECT + SELECT k1 + FROM (SELECT k1, count(*) AS cnt FROM ls_t2 GROUP BY k1 HAVING cnt > 0) b + ORDER BY k1""") + + // 10-9: Shuffle join then DISTINCT + checkConsistencyWithSql("shuffle_join_then_distinct", + """SELECT ${sv} DISTINCT a.k1 + FROM ls_t1 a JOIN [shuffle] ls_t2 b ON a.k1 = b.k1 + ORDER BY a.k1""") + + // ================================================================ + // Section 11: AggSink LOCAL_HASH_SHUFFLE scenarios + // Scenarios where BE's need_to_local_exchange() inserts GLOBAL/BUCKET + // hash exchange because the source distribution is not hash-compatible. + // + // Key rule in pipeline.cpp need_to_local_exchange(): + // If source is BUCKET_HASH and sink requires GLOBAL_HASH → both are hash + // → need_to_local_exchange returns false → NO local exchange. + // But if source is PASSTHROUGH/NOOP → not both hash → insert GLOBAL_HASH. + // ================================================================ + + // 11-1: force_to_local_shuffle=true + non-bucket finalize agg + // With force_to_local_shuffle, OlapScanNode.isSerialOperator()=true even with 8 tablets. + // Optimizer puts agg in a separate finalize fragment receiving hash-partitioned data, + // so no LOCAL_HASH_SHUFFLE is generated — only PASSTHROUGH for the NLJ/scan boundary. + checkConsistencyWithSql("agg_finalize_force_local_shuffle_non_bucket", + """SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true, + ignore_storage_data_distribution=false,parallel_pipeline_task_num=4, + force_to_local_shuffle=true,enable_local_shuffle=true)*/ k2, count(*) AS cnt + FROM ls_t1 GROUP BY k2 ORDER BY k2""") + + // 11-2: force_to_local_shuffle=true + bucket-key finalize agg + // GROUP BY k1 (bucket key of ls_t1): colocate agg stays in same fragment as scan. + // FE: AggNode is colocate → requireHash. BE: AggSink returns BUCKET_HASH. + // Result MATCH: [PASSTHROUGH:9], consistent with other bucket-key colocate cases. + checkConsistencyWithSql("agg_finalize_force_local_shuffle_bucket_key", + """SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true, + ignore_storage_data_distribution=false,parallel_pipeline_task_num=4, + force_to_local_shuffle=true,enable_local_shuffle=true)*/ k1, count(*) AS cnt + FROM ls_t1 GROUP BY k1 ORDER BY k1""") + + // 11-3: NLJ (theta join) → finalize agg on non-bucket key + // GROUP BY k2 (non-bucket): optimizer puts agg in a separate finalize fragment + // receiving data via a hash-partitioned inter-fragment exchange on k2. + // Within each fragment, the distributions are compatible → no LOCAL_HASH_SHUFFLE. + // Result MATCH: [ADAPTIVE_PASSTHROUGH:5, PASSTHROUGH:5] + checkConsistencyWithSql("agg_after_nlj_non_bucket", + """SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true, + ignore_storage_data_distribution=false,parallel_pipeline_task_num=4, + auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0)*/ a.k2, count(*) AS cnt + FROM ls_t1 a, ls_t2 b WHERE a.k1 > b.k1 + GROUP BY a.k2 ORDER BY a.k2""") + + // 11-4: NLJ (theta join) → finalize agg on bucket key (LOCAL_HASH_SHUFFLE test) + // GROUP BY k1 (bucket key): colocate agg stays in same pipeline as NLJ probe. + // The NLJ probe requires ADAPTIVE_PASSTHROUGH → local exchange inserted. + // After that exchange, the next pipeline has LocalExchangeSource (PASSTHROUGH distribution) + // feeding into AggSink (BUCKET_HASH_SHUFFLE for colocate k1 agg). + // PASSTHROUGH source ≠ BUCKET_HASH target, not both-hash → need_to_local_exchange=true + // → BE inserts LOCAL_HASH_SHUFFLE (BUCKET type). + // FE: AggNode isColocated=true → requireHash → inserts LocalExchangeNode. + // Result MATCH: [ADAPTIVE_PASSTHROUGH:9, LOCAL_HASH_SHUFFLE:9, PASSTHROUGH:9] + // This is a primary test for regular AggSink generating LOCAL_HASH_SHUFFLE. + checkConsistencyWithSql("agg_after_nlj_bucket_key", + """SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true, + ignore_storage_data_distribution=false,parallel_pipeline_task_num=4, + auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0)*/ a.k1, count(*) AS cnt + FROM ls_t1 a, ls_t2 b WHERE a.k1 > b.k1 + GROUP BY a.k1 ORDER BY a.k1""") + + // ================================================================ + // Section 12: Nested NLJ with pooling scan + // Tests that FE correctly inserts local exchange on ALL NLJ build sides, + // even when the NLJ's direct children are not ScanNodes (e.g., nested NLJ + // or ExchangeNode). Without the fix, the serial Exchange on NLJ(outer)'s + // build side would reduce num_tasks to 1, causing "must set shared state, + // in CROSS_JOIN_OPERATOR" for instances 1+. + // ================================================================ + + // 12-1: Nested NLJ with pooling scan — the regression case from RQG. + // Two LEFT JOINs with non-equi conditions → two nested NLJ operators. + // The outer NLJ's build side is an Exchange (UNPARTITIONED, serial). + // FE must insert a BROADCAST local exchange there to fan out to all instances. + // Without the fix in NestedLoopJoinNode (removing instanceof ScanNode check), + // FE wouldn't insert the local exchange → "must set shared state" error. + // BE-native also fails on this query with "_num_remaining_senders: -N", + // so we only verify FE mode produces correct results (skip profile comparison). + // knownDiff=true to tolerate the BE failure in profile comparison. + checkConsistencyWithSql("nested_nlj_pooling_scan", + """SELECT ${svSerialSource} count(a.k1) AS cnt, a.v1 + FROM ls_serial a + LEFT JOIN ls_serial b ON b.k2 >= b.k2 + LEFT JOIN ls_serial c ON b.k1 >= b.k1 + WHERE a.k1 IS NOT NULL + GROUP BY a.v1 + ORDER BY cnt, a.v1""", true) + + // 12-2: Same nested NLJ but non-pooling (ignore_storage_data_distribution=false). + // FE uses manual force-enforce to always insert ADAPTIVE_PASSTHROUGH + // on NLJ probe side, matching BE's need_to_local_exchange Step 4 behavior. + // Known mismatch on clusters where ls_serial (2 BUCKETS) tablets span multiple BEs: + // same FE/BE instanceCount discrepancy as agg_finalize_non_pooling_bucket. + checkConsistencyWithSql("nested_nlj_non_pooling", + """SELECT ${sv} count(a.k1) AS cnt, a.v1 + FROM ls_serial a + LEFT JOIN ls_serial b ON b.k2 >= b.k2 + LEFT JOIN ls_serial c ON b.k1 >= b.k1 + WHERE a.k1 IS NOT NULL + GROUP BY a.v1 + ORDER BY cnt, a.v1""") + + // ================================================================ + // Section 13: Pooling scan + operators requiring shared state + // Regression cases from RQG build 183677 — serial Exchange on build + // side of various operators (Agg, Sort, Union/Repeat) reduced pipeline + // num_tasks, causing "must set shared state" errors. + // Fixed by restoring the num_tasks raise in _create_deferred_local_exchangers + // for non-scan serial operators. + // ================================================================ + + // 13-1: NLJ + AGG with pooling scan. + // NLJ creates pipeline boundary; serial Exchange on build side + // needs raise to _num_instances for AGG shared state injection. + // knownDiff=true: pooling scan + NLJ has FE/BE exchange count + // differences (same root cause as nested_nlj_pooling_scan). + checkConsistencyWithSql("agg_after_nlj_pooling_scan", + """SELECT ${svSerialSource} a.v1, MAX(a.k1) AS mx + FROM ls_serial a LEFT JOIN ls_serial b ON b.k2 < b.k2 + WHERE a.k1 IS NOT NULL + GROUP BY a.v1 + ORDER BY a.v1, mx""", true) + + // 13-2: GROUPING SETS with pooling scan — generates REPEAT (union-like) + // operator internally. Serial Exchange reduces num_tasks, causing + // "must set shared state, in UNION_OPERATOR / SORT_OPERATOR". + // Known issue: deadlocks on clusters where ls_serial (2 BUCKETS) tablets span + // multiple BEs — FE inserts LE (global instanceCount=2) but each BE has + // _num_instances=1 causing pipeline task mismatch. Pre-existing FE/BE discrepancy. + checkConsistencyWithSql("grouping_sets_pooling_scan", + """SELECT ${svSerialSource} k1, k2, SUM(v1) AS sv + FROM ls_serial + GROUP BY GROUPING SETS ((k1, k2), (k1), ()) + ORDER BY k1, k2, sv""") + + // 13-3: Window function + GROUPING SETS with pooling scan. + // Combines analytic (Sort shared state) and GROUPING SETS (Repeat/Union) + // — both need correct num_tasks for shared state injection. + // Same instanceCount discrepancy as grouping_sets_pooling_scan. + checkConsistencyWithSql("window_grouping_sets_pooling_scan", + """SELECT ${svSerialSource} k1, SUM(v1), + ROW_NUMBER() OVER (ORDER BY k1) AS rn + FROM ls_serial + GROUP BY GROUPING SETS ((k1), ()) + ORDER BY k1, rn""") + + // ============================================================ + // 14. RQG bug cases — serial NLJ + pooling scan (Bug 13 from rqg_bugs) + // Serial NLJ (RIGHT_OUTER) with pooling scan. Previously crashed because + // FE inserted BROADCAST on build side inflating num_tasks while probe stayed + // serial. Fixed: serial NLJ sets buildSideRequire=noRequire(). + // ============================================================ + checkConsistencyWithSql("rqg_serial_nlj_right_outer_pooling", + """SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=0, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true, + disable_join_reorder=true)*/ + b.k1 AS field1 + FROM ls_serial a + RIGHT OUTER JOIN ls_serial b ON a.v1 > b.v1 + GROUP BY field1 + ORDER BY field1 ASC""") + + // GLOBAL_HASH_SHUFFLE fix (Bug 10 from rqg_bugs) — self-join + NLJ with serial exchange + checkConsistencyWithSql("rqg_global_hash_shuffle_self_join_nlj", + """SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + ignore_storage_data_distribution=true, + disable_join_reorder=true, disable_colocate_plan=true)*/ + a.k1 AS field1, a.v1 AS field2 + FROM ls_t1 a + LEFT JOIN ls_t1 b ON a.k1 = b.k2 + LEFT JOIN ls_t1 c ON a.k1 > b.k2 + WHERE a.v1 > 5 + GROUP BY field1, field2 + ORDER BY field1, field2""") + + // FULL OUTER JOIN + GROUP BY with serial exchange (Bug 11 from rqg_bugs) + checkConsistencyWithSql("rqg_global_hash_full_outer_join", + """SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + ignore_storage_data_distribution=true)*/ + a.k1, b.k1, count(1) + FROM ls_t1 a + FULL OUTER JOIN ls_t2 b ON a.k1 = b.k1 + WHERE b.k1 = 2 + GROUP BY a.k1, b.k1 + ORDER BY 1, 2, 3""") + + // Same pattern but simpler: NLJ with subquery + pooling, no GROUPING SETS + checkConsistencyWithSql("nlj_subquery_pooling", + """SELECT ${svSerialSource} k1, (SELECT COUNT(*) FROM ls_t2) + FROM ls_t1 + ORDER BY k1""") + +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_global_hash_require.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_global_hash_require.groovy new file mode 100644 index 00000000000000..f2aff5525b5ebc --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_global_hash_require.groovy @@ -0,0 +1,410 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * DORIS-26100 / DORIS-26101: FE local shuffle planner inserts + * LOCAL_EXECUTION_HASH_SHUFFLE before downstream PARTITIONED consumers + * (hash join, intersect/except). LOCAL hash has per-BE modulus, incompatible + * with the global exchange on the sibling side. + * + * Fix: PARTITIONED hash join and Intersect/Except use + * requireGlobalExecutionHash() so any inserted exchange uses GLOBAL modulus. + * + * Each case compares FE planner result against local-shuffle-off baseline. + */ +suite("test_local_shuffle_global_hash_require") { + + def feHints = """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + enable_local_exchange_before_agg=false, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=8, + parallel_exchange_instance_num=8, + query_timeout=600, + prefer_join_method=shuffle, + enable_local_shuffle=true, + enable_local_shuffle_planner=true + )*/""" + + def offHints = """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + enable_local_exchange_before_agg=false, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=8, + parallel_exchange_instance_num=8, + query_timeout=600, + prefer_join_method=shuffle, + enable_local_shuffle=false, + enable_local_shuffle_planner=false + )*/""" + + // ============================================================ + // DORIS-26101: aggregate -> CROSS JOIN -> shuffle hash join + // ============================================================ + sql "DROP TABLE IF EXISTS ls_cross_a" + sql "DROP TABLE IF EXISTS ls_cross_dim" + sql """CREATE TABLE ls_cross_a (id INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(id,g) DISTRIBUTED BY HASH(id) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_cross_dim (id INT, g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(id,g) DISTRIBUTED BY HASH(g) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_cross_a + SELECT CAST(number AS INT), CAST(number AS INT), CAST(number*10+1 AS INT) + FROM numbers("number"="23")""" + sql """INSERT INTO ls_cross_dim + SELECT CAST(number AS INT), CAST(number%23 AS INT), CAST(1000+number AS INT) + FROM numbers("number"="713")""" + + def cross_baseline = sql """SELECT ${offHints} x.g, COUNT(*) c, SUM(d.w) sw + FROM (SELECT a.g, one.v FROM (SELECT g, SUM(v) sv FROM ls_cross_a GROUP BY g) a + CROSS JOIN (SELECT 1 v) one) x + JOIN [shuffle] ls_cross_dim d ON x.g = d.g + GROUP BY x.g ORDER BY x.g""" + + def cross_fe = sql """SELECT ${feHints} x.g, COUNT(*) c, SUM(d.w) sw + FROM (SELECT a.g, one.v FROM (SELECT g, SUM(v) sv FROM ls_cross_a GROUP BY g) a + CROSS JOIN (SELECT 1 v) one) x + JOIN [shuffle] ls_cross_dim d ON x.g = d.g + GROUP BY x.g ORDER BY x.g""" + + assertEquals(23, cross_baseline.size()) + assertEquals(cross_baseline, cross_fe, + "DORIS-26101: aggregate -> CROSS JOIN -> shuffle join") + + // ============================================================ + // DORIS-26100 case 1: aggregate -> table function -> shuffle join + // ============================================================ + sql "DROP TABLE IF EXISTS ls_tf_a" + sql "DROP TABLE IF EXISTS ls_tf_dim" + sql """CREATE TABLE ls_tf_a (id INT, g INT, s VARCHAR(64)) + ENGINE=OLAP DUPLICATE KEY(id,g) DISTRIBUTED BY HASH(id) BUCKETS 11 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_tf_dim (g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_tf_a VALUES + (0,0,'a,b'),(1,1,'c'),(2,2,''),(3,3,null),(4,4,'d,e,f'),(5,5,'z')""" + sql """INSERT INTO ls_tf_dim VALUES + (0,1),(1,11),(2,21),(3,31),(4,41),(5,51),(6,61),(7,71)""" + + def tf_baseline = sql """SELECT ${offHints} x.g, COUNT(*) c, SUM(d.w) sw + FROM (SELECT a.g, e FROM (SELECT g, MAX(s) s FROM ls_tf_a GROUP BY g) a + LATERAL VIEW explode_split_outer(a.s, ',') lv AS e) x + JOIN [shuffle] ls_tf_dim d ON x.g=d.g + GROUP BY x.g ORDER BY x.g""" + + def tf_fe = sql """SELECT ${feHints} x.g, COUNT(*) c, SUM(d.w) sw + FROM (SELECT a.g, e FROM (SELECT g, MAX(s) s FROM ls_tf_a GROUP BY g) a + LATERAL VIEW explode_split_outer(a.s, ',') lv AS e) x + JOIN [shuffle] ls_tf_dim d ON x.g=d.g + GROUP BY x.g ORDER BY x.g""" + + assertEquals(6, tf_baseline.size()) + assertEquals(tf_baseline, tf_fe, + "DORIS-26100: aggregate -> table function -> shuffle join") + + // ============================================================ + // DORIS-26100 case 2: aggregate -> NAAJ -> shuffle join + // ============================================================ + def naajHints = { ls_on -> + """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, broadcast_row_count_limit=0, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=16, + parallel_exchange_instance_num=8, + query_timeout=600, + enable_local_shuffle=${ls_on}, + enable_local_shuffle_planner=${ls_on} + )*/""" + } + + sql "DROP TABLE IF EXISTS ls_naaj_a" + sql "DROP TABLE IF EXISTS ls_naaj_bnn" + sql "DROP TABLE IF EXISTS ls_naaj_dim" + sql """CREATE TABLE ls_naaj_a (k INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(k,g) DISTRIBUTED BY HASH(k) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_naaj_bnn (g INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_naaj_dim (g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_naaj_a + SELECT CAST(number AS INT), CAST(number%17 AS INT), CAST(number*10+1 AS INT) + FROM numbers("number"="68")""" + sql """INSERT INTO ls_naaj_bnn + SELECT CAST(number AS INT) FROM numbers("number"="17") + WHERE number NOT IN (1,7,8,9,14,16)""" + sql """INSERT INTO ls_naaj_dim + SELECT CAST(number%17 AS INT), CAST(100+number AS INT) + FROM numbers("number"="85")""" + + def naaj_baseline = sql """SELECT ${naajHints('false')} y.g, COUNT(*) AS c, SUM(d.w) AS s + FROM (SELECT x.g FROM (SELECT g, COUNT(*) cnt FROM ls_naaj_a GROUP BY g) x + WHERE x.g NOT IN (SELECT g FROM ls_naaj_bnn)) y + JOIN [shuffle] ls_naaj_dim d ON y.g = d.g + GROUP BY y.g ORDER BY y.g""" + + def naaj_fe = sql """SELECT ${naajHints('true')} y.g, COUNT(*) AS c, SUM(d.w) AS s + FROM (SELECT x.g FROM (SELECT g, COUNT(*) cnt FROM ls_naaj_a GROUP BY g) x + WHERE x.g NOT IN (SELECT g FROM ls_naaj_bnn)) y + JOIN [shuffle] ls_naaj_dim d ON y.g = d.g + GROUP BY y.g ORDER BY y.g""" + + assertEquals(6, naaj_baseline.size()) + assertEquals(naaj_baseline, naaj_fe, + "DORIS-26100: aggregate -> NAAJ -> shuffle join") + + // ============================================================ + // DORIS-26100 case 3: analytic (ROW_NUMBER) -> shuffle join + // ============================================================ + def analyticHints = { ls_on -> + """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, broadcast_row_count_limit=0, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=16, + parallel_exchange_instance_num=8, + query_timeout=600, + ignore_storage_data_distribution=false, + use_serial_exchange=false, + experimental_use_serial_exchange=false, + enable_local_shuffle=${ls_on}, + enable_local_shuffle_planner=${ls_on} + )*/""" + } + + sql "DROP TABLE IF EXISTS ls_analytic_a" + sql "DROP TABLE IF EXISTS ls_analytic_dim" + sql """CREATE TABLE ls_analytic_a (pk INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(pk,g) DISTRIBUTED BY HASH(pk) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_analytic_dim (g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_analytic_a + SELECT CAST(number AS INT), CAST(number%23 AS INT), CAST(number*10+1 AS INT) + FROM numbers("number"="920")""" + sql """INSERT INTO ls_analytic_dim + SELECT CAST(number%23 AS INT), CAST(1000+number AS INT) + FROM numbers("number"="713")""" + + def analytic_baseline = sql """SELECT ${analyticHints('false')} + x.g, COUNT(*) AS c, SUM(x.rn) AS srn, SUM(d.w) AS sw + FROM (SELECT g, pk, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM ls_analytic_a) x + JOIN [shuffle] ls_analytic_dim d ON x.g = d.g + GROUP BY x.g ORDER BY x.g""" + + def analytic_fe = sql """SELECT ${analyticHints('true')} + x.g, COUNT(*) AS c, SUM(x.rn) AS srn, SUM(d.w) AS sw + FROM (SELECT g, pk, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM ls_analytic_a) x + JOIN [shuffle] ls_analytic_dim d ON x.g = d.g + GROUP BY x.g ORDER BY x.g""" + + assertEquals(23, analytic_baseline.size()) + assertEquals(analytic_baseline, analytic_fe, + "DORIS-26100: analytic -> shuffle join") + + // ============================================================ + // DORIS-26100 case 4: analytic -> INTERSECT + // ============================================================ + sql "DROP TABLE IF EXISTS ls_aniset_a" + sql "DROP TABLE IF EXISTS ls_aniset_dim" + sql """CREATE TABLE ls_aniset_a (pk INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(pk,g) DISTRIBUTED BY HASH(pk) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_aniset_dim (g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_aniset_a + SELECT CAST(number AS INT), CAST(number%23 AS INT), CAST(number*10+1 AS INT) + FROM numbers("number"="920")""" + sql """INSERT INTO ls_aniset_dim + SELECT CAST(number AS INT), CAST(100+number AS INT) + FROM numbers("number"="23")""" + + def intersectHints = { ls_on -> + """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, broadcast_row_count_limit=0, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=16, + parallel_exchange_instance_num=8, + query_timeout=600, + ignore_storage_data_distribution=false, + use_serial_exchange=false, + experimental_use_serial_exchange=false, + enable_partition_topn=false, + enable_local_shuffle=${ls_on}, + enable_local_shuffle_planner=${ls_on} + )*/""" + } + + def intersect_baseline = sql """SELECT ${intersectHints('false')} g FROM ( + SELECT g FROM (SELECT g, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM ls_aniset_a) x WHERE rn > 0 + INTERSECT + SELECT g FROM ls_aniset_dim) t ORDER BY g""" + + def intersect_fe = sql """SELECT ${intersectHints('true')} g FROM ( + SELECT g FROM (SELECT g, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM ls_aniset_a) x WHERE rn > 0 + INTERSECT + SELECT g FROM ls_aniset_dim) t ORDER BY g""" + + assertEquals(23, intersect_baseline.size()) + assertEquals(intersect_baseline, intersect_fe, + "DORIS-26100: analytic -> INTERSECT") + + // ============================================================ + // DORIS-26103: UNION ALL -> PartitionTopN analytic -> INTERSECT + // ============================================================ + sql "DROP TABLE IF EXISTS ls_upset_a" + sql "DROP TABLE IF EXISTS ls_upset_b" + sql "DROP TABLE IF EXISTS ls_upset_dim" + sql """CREATE TABLE ls_upset_a (pk INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(pk,g) DISTRIBUTED BY HASH(pk) BUCKETS 13 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_upset_b (pk INT, g INT, v INT) + ENGINE=OLAP DUPLICATE KEY(pk,g) DISTRIBUTED BY HASH(pk) BUCKETS 11 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_upset_dim (g INT, w INT) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 17 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO ls_upset_a + SELECT CAST(number AS INT), CAST(number%23 AS INT), CAST(number*10+1 AS INT) + FROM numbers("number"="920")""" + sql """INSERT INTO ls_upset_b + SELECT CAST(number+10000 AS INT), CAST(number%23 AS INT), CAST(number*20+3 AS INT) + FROM numbers("number"="920")""" + sql """INSERT INTO ls_upset_dim + SELECT CAST(number AS INT), CAST(100+number AS INT) + FROM numbers("number"="23")""" + + def ptopnHints = { ls_on -> + """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, broadcast_row_count_limit=0, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=16, + parallel_exchange_instance_num=8, + query_timeout=600, + ignore_storage_data_distribution=false, + use_serial_exchange=false, + experimental_use_serial_exchange=false, + enable_partition_topn=true, + global_partition_topn_threshold=1, + enable_local_shuffle=${ls_on}, + enable_local_shuffle_planner=${ls_on} + )*/""" + } + + def ptopn_baseline = sql """SELECT ${ptopnHints('false')} g FROM ( + SELECT g, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM (SELECT g, pk FROM ls_upset_a UNION ALL SELECT g, pk FROM ls_upset_b) u + ) x WHERE rn <= 3 + INTERSECT + SELECT g FROM ls_upset_dim + ORDER BY g""" + + def ptopn_fe = sql """SELECT ${ptopnHints('true')} g FROM ( + SELECT g, ROW_NUMBER() OVER(PARTITION BY g ORDER BY pk) AS rn + FROM (SELECT g, pk FROM ls_upset_a UNION ALL SELECT g, pk FROM ls_upset_b) u + ) x WHERE rn <= 3 + INTERSECT + SELECT g FROM ls_upset_dim + ORDER BY g""" + + assertEquals(23, ptopn_baseline.size()) + assertEquals(ptopn_baseline, ptopn_fe, + "DORIS-26103: UNION ALL -> PartitionTopN -> INTERSECT") + + // ============================================================ + // DORIS-26120: serial exchange + shuffle join → GLOBAL hash + // shuffle_idx_to_instance_idx incomplete → Rows mismatched. + // Fix: fall back to LOCAL hash when fragment uses serial source. + // ============================================================ + sql "DROP TABLE IF EXISTS ls_serial_fact" + sql "DROP TABLE IF EXISTS ls_serial_dim" + sql """CREATE TABLE ls_serial_fact (pk INT NOT NULL, g INT NOT NULL) + ENGINE=OLAP DUPLICATE KEY(pk,g) DISTRIBUTED BY HASH(pk) BUCKETS 1 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE ls_serial_dim (g INT NOT NULL) + ENGINE=OLAP DUPLICATE KEY(g) DISTRIBUTED BY HASH(g) BUCKETS 1 + PROPERTIES ("replication_num"="1")""" + sql "INSERT INTO ls_serial_fact VALUES (1, 1)" + sql "INSERT INTO ls_serial_dim VALUES (1)" + + def serial_baseline = sql """SELECT /*+SET_VAR( + enable_sql_cache=false, + enable_local_shuffle=false, + enable_local_shuffle_planner=false, + use_serial_exchange=true, + parallel_pipeline_task_num=4, + ignore_storage_data_distribution=true + )*/ a.g AS left_g, b.g AS right_g + FROM ls_serial_fact a JOIN [shuffle] ls_serial_dim b ON a.g = b.g + ORDER BY left_g, right_g""" + + def serial_fe = sql """SELECT /*+SET_VAR( + enable_sql_cache=false, + enable_local_shuffle=true, + enable_local_shuffle_planner=true, + use_serial_exchange=true, + parallel_pipeline_task_num=4, + ignore_storage_data_distribution=true + )*/ a.g AS left_g, b.g AS right_g + FROM ls_serial_fact a JOIN [shuffle] ls_serial_dim b ON a.g = b.g + ORDER BY left_g, right_g""" + + assertEquals(1, serial_baseline.size()) + assertEquals(serial_baseline, serial_fe, + "DORIS-26120: serial exchange + shuffle join should not error") +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_recursive_cte.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_recursive_cte.groovy new file mode 100644 index 00000000000000..6b5edeefbd24a7 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_recursive_cte.groovy @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Regression for DORIS-25865: FE local-shuffle planner used to insert a + * LocalExchangeNode directly under RecursiveCteNode, which collided with two + * RecursiveCte invariants: + * + * 1. ThriftPlansBuilder locates the recursive sender fragment via + * `recursiveCteNode.getChild(1).getChild(0).getFragment()`. An extra LE + * wrapper shifted that path off the cross-fragment ExchangeNode and + * pulled the RecCTE producer fragment itself into `fragmentsToReset`. + * BE then rejected with `[INTERNAL_ERROR]Fragment N contains a recursive + * CTE node` during `RecCTESourceOperatorX::prepare()`. + * + * 2. BE's `RecCTESourceOperatorX::is_serial_operator()` always returns true, + * but `RecursiveCteNode.isSerialNode()` on the FE side defaulted to + * false. Without the serial marker, the FE planner left the producer + * fragment with parallel=N sender pipelines while RecCte actually emits + * data from a single instance — the cross-fragment Exchange receiver + * waited forever on the N-1 silent senders and the query hung. + * + * Fix lives in `RecursiveCteNode`: + * - override `isSerialNode()` to return true (mirrors BE), + * - override `enforceAndDeriveLocalExchange` to bypass the framework's + * `enforceRequire` so no LE is inserted between RecCte and its Exchange + * children (children's own subtrees still get LE planning). + * + * This test asserts: + * - planner=true succeeds (no "Fragment N contains a recursive CTE node"); + * - results between planner=true and planner=false are identical for the + * three downstream-consumer shapes the JIRA listed: aggregate, window, + * grouping sets; + * - the negative control (RecCte directly consumed by SELECT) still works + * in both modes — it would pass even with the original bug, but covers + * the simple path so a regression there is caught immediately. + */ +suite("test_local_shuffle_recursive_cte", "nereids_p0") { + + sql "SET enable_nereids_planner=true" + sql "SET enable_fallback_to_original_planner=false" + sql "SET enable_sql_cache=false" + sql "SET enable_local_shuffle=true" + sql "SET parallel_pipeline_task_num=4" + sql "SET runtime_filter_mode=off" + + // For each SQL, run it twice — once with FE planner, once with BE planner — + // and assert result rows are identical. Plan shape intentionally not asserted: + // the two planners legitimately differ on LE placement. + def checkConsistency = { String tag, String testSql -> + def sqlOn = """SELECT /*+SET_VAR(enable_local_shuffle_planner=true)*/""" + testSql.replaceFirst(/(?i)^\s*select/, "") + def sqlOff = """SELECT /*+SET_VAR(enable_local_shuffle_planner=false)*/""" + testSql.replaceFirst(/(?i)^\s*select/, "") + check_sql_equal(sqlOn, sqlOff) + } + + // ============================================================ + // Case 1 — Recursive CTE consumed by aggregate + // + // Original error with FE planner: + // errCode = 2, detailMessage = [INTERNAL_ERROR] + // Fragment N contains a recursive CTE node + // ============================================================ + checkConsistency("rec_cte_agg", """ + SELECT n_mod, count(*) AS c, sum(s) AS total + FROM ( + WITH RECURSIVE cte(n, s) AS ( + SELECT CAST(1 AS INT), CAST(1 AS BIGINT) + UNION ALL + SELECT CAST(n + 1 AS INT), CAST(s + n + 1 AS BIGINT) + FROM cte WHERE n < 30 + ) + SELECT n % 7 AS n_mod, s FROM cte + ) t + GROUP BY n_mod + ORDER BY n_mod + """) + + // ============================================================ + // Case 2 — Recursive CTE consumed by window function + // + // Original failure with FE planner: hung indefinitely because the + // producer fragment had parallel=N sender pipelines but only one of + // them actually emits data. Fixed by marking RecursiveCteNode serial + // so AddLocalExchange wraps the root with a PASSTHROUGH LE that fans + // the single producer out to N parallel sinks. + // ============================================================ + checkConsistency("rec_cte_window", """ + SELECT n, sum(n) OVER (PARTITION BY n % 5) AS sum_n + FROM ( + WITH RECURSIVE cte(n) AS ( + SELECT CAST(1 AS INT) + UNION ALL + SELECT CAST(n + 1 AS INT) FROM cte WHERE n < 30 + ) + SELECT n FROM cte + ) t + ORDER BY n + """) + + // ============================================================ + // Case 3 — Recursive CTE consumed by GROUPING SETS + // + // Suggested by the JIRA reporter as a third "downstream operator that + // introduces additional fragments / local exchanges" — together with + // aggregate and window it covers the original failure pattern. + // ============================================================ + checkConsistency("rec_cte_grouping_sets", """ + SELECT n_mod, n_bucket, count(*) AS c, sum(s) AS total + FROM ( + WITH RECURSIVE cte(n, s) AS ( + SELECT CAST(1 AS INT), CAST(1 AS BIGINT) + UNION ALL + SELECT CAST(n + 1 AS INT), CAST(s + n + 1 AS BIGINT) + FROM cte WHERE n < 30 + ) + SELECT n % 7 AS n_mod, n % 3 AS n_bucket, s FROM cte + ) t + GROUP BY GROUPING SETS ((n_mod), (n_bucket), (n_mod, n_bucket)) + ORDER BY n_mod NULLS LAST, n_bucket NULLS LAST + """) + + // ============================================================ + // Negative control — RecCte directly consumed by SELECT. + // This path didn't generate the extra fragments needed to trigger the + // original bug, but exercising it ensures the fix doesn't regress the + // simple consumer shape. + // ============================================================ + checkConsistency("rec_cte_select", """ + SELECT n + FROM ( + WITH RECURSIVE cte(n) AS ( + SELECT CAST(1 AS INT) + UNION ALL + SELECT CAST(n + 1 AS INT) FROM cte WHERE n < 5 + ) + SELECT n FROM cte + ) t + ORDER BY n + """) + + // ============================================================ + // Case 4 — RecCte feeding a hash JOIN + // + // Another downstream consumer that introduces an extra fragment via a + // shuffle join. Verifies the serial-RecCte → PASSTHROUGH LE wrap also + // works when the consumer requires hash distribution. + // ============================================================ + checkConsistency("rec_cte_join", """ + SELECT a.n, b.n AS m + FROM ( + WITH RECURSIVE cte(n) AS ( + SELECT CAST(1 AS INT) + UNION ALL + SELECT CAST(n + 1 AS INT) FROM cte WHERE n < 10 + ) + SELECT n FROM cte + ) a JOIN ( + WITH RECURSIVE cte(n) AS ( + SELECT CAST(2 AS INT) + UNION ALL + SELECT CAST(n + 2 AS INT) FROM cte WHERE n < 10 + ) + SELECT n FROM cte + ) b ON a.n = b.n + ORDER BY a.n + """) +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy new file mode 100644 index 00000000000000..e369dc0f11eb18 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy @@ -0,0 +1,1567 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Regression tests for bugs discovered by RQG testing on the local-exchange2 branch. + * + * These queries triggered "must set shared state" errors or incorrect results + * in RQG build 183992. Common conditions: + * - use_serial_exchange=true (makes ALL Exchanges serial, not just UNPARTITIONED) + * - enable_local_shuffle_planner=true (FE-planned local exchange) + * - parallel_pipeline_task_num > 1 + * + * Error types reproduced: + * 1. must set shared state, in AGGREGATION_OPERATOR + * 2. must set shared state, in SORT_OPERATOR + * 3. incorrect results with GROUPING SETS + scalar subquery + window function + */ +suite("test_local_shuffle_rqg_bugs") { + + // ============================================================ + // Table setup — mirrors RQG table structure + // 10 buckets to match RQG (replication_num=1 for single-BE testing) + // ============================================================ + sql "DROP TABLE IF EXISTS rqg_t1" + sql "DROP TABLE IF EXISTS rqg_t2" + sql "DROP TABLE IF EXISTS rqg_t3" + sql "DROP TABLE IF EXISTS rqg_t4" + + sql """ + CREATE TABLE rqg_t1 ( + pk INT NOT NULL, + col_int_undef_signed INT, + col_int_undef_signed2 INT, + col_int_undef_signed_not_null INT NOT NULL + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE rqg_t2 ( + pk INT NOT NULL, + col_int_undef_signed INT, + col_int_undef_signed2 INT, + col_bigint_undef_signed_not_null BIGINT NOT NULL, + col_decimal_38_10__undef_signed_not_null DECIMAL(38,10) NOT NULL + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + // Table for build 184181 GLOBAL_HASH_SHUFFLE bugs — needs varchar + bigint columns + sql """ + CREATE TABLE rqg_t3 ( + pk INT NOT NULL, + col_bigint_undef_signed BIGINT, + col_varchar_10__undef_signed VARCHAR(10), + col_varchar_64__undef_signed VARCHAR(64) + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + // Second table for FULL OUTER JOIN case (col_bigint_undef_signed_not_null) + sql """ + CREATE TABLE rqg_t4 ( + pk INT NOT NULL, + col_bigint_undef_signed_not_null BIGINT NOT NULL + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + INSERT INTO rqg_t3 VALUES + (0, -94, 'Abc', 'hello world'), + (1, 672609, 'Xyz', null), + (2, -3766684, 'Pqr', 'test string'), + (3, 5070261, 'abc', 'another row'), + (4, null, 'def', 'value four'), + (5, -86, 'XgpxlHBLEM', null), + (6, 21910, 'abc', 'they'), + (7, -63, 'zzzz', 'some text'), + (8, -8276281, 'AHlvNtoGLO', 'longer string here'), + (9, -101, 'mid', 'final row') + """ + + sql """ + INSERT INTO rqg_t4 VALUES + (0, 0), (1, 1), (2, 2), (3, 3), (4, 4), + (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), + (10, 2), (11, 2), (12, 2), (13, 3), (14, 4), + (15, 5), (16, 2), (17, 2), (18, 2), (19, 9) + """ + + // Insert enough rows to exercise multiple pipeline tasks + sql """ + INSERT INTO rqg_t1 VALUES + (0, 0, 10, 0), (1, 1, 11, 1), (2, 2, 12, 2), (3, 3, 13, 3), + (4, 4, 14, 4), (5, 5, 15, 5), (6, 6, 16, 6), (7, 7, 17, 7), + (8, 8, 18, 8), (9, 9, 19, 9), (10, 0, 20, 10), (11, 1, 21, 11), + (12, 2, 22, 12), (13, 3, 23, 13), (14, 4, 24, 14), (15, 5, 25, 15), + (16, 6, 26, 16), (17, 7, 27, 17), (18, 8, 28, 18), (19, 9, 29, 19) + """ + + sql """ + INSERT INTO rqg_t2 VALUES + (0, 0, 10, 100, 1.5), (1, 1, 11, 101, 2.5), (2, 2, 12, 102, 3.5), + (3, 3, 13, 103, 4.5), (4, 4, 14, 104, 5.5), (5, 5, 15, 105, 6.5), + (6, 6, 16, 106, 7.5), (7, 7, 17, 107, 8.5), (8, 8, 18, 108, 9.5), + (9, 9, 19, 109, 10.5), (10, 0, 20, 110, 11.5), (11, 1, 21, 111, 12.5), + (12, 2, 22, 112, 13.5), (13, 3, 23, 113, 14.5), (14, 4, 24, 114, 15.5), + (15, 5, 25, 115, 16.5), (16, 6, 26, 116, 17.5), (17, 7, 27, 117, 18.5), + (18, 8, 28, 118, 19.5), (19, 9, 29, 119, 20.5) + """ + + // Wait for the inserted data to be visible — poll the actual row counts instead of a fixed sleep. + for (int i = 0; i < 60; i++) { + def c1 = sql "SELECT COUNT(*) FROM rqg_t1" + def c2 = sql "SELECT COUNT(*) FROM rqg_t2" + if (c1[0][0] == 20 && c2[0][0] == 20) { + break + } + sleep(200) + } + + // ============================================================ + // Common settings + // ============================================================ + sql "SET enable_nereids_planner=true" + sql "SET enable_fallback_to_original_planner=false" + sql "SET runtime_filter_mode=off" + sql "SET enable_profile=true" + sql "SET enable_sql_cache=false" + sql "SET enable_local_shuffle=true" + + // ============================================================ + // Bug 1: must set shared state, in AGGREGATION_OPERATOR + // RQG case: eliminate_group_by_uniform.case_id_11007680713 + // Key conditions: use_serial_exchange=true, parallel_pipeline_task_num=3 + // SQL: EXCEPT with count(*) GROUP BY on both sides + // ============================================================ + + // Test with FE planner (the buggy path) + logger.info("=== Bug 1a: AGG shared state - EXCEPT with serial exchange (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=3, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_common_expr_pushdown=false, + disable_streaming_preaggregations=true)*/ + col_int_undef_signed_not_null as col1, + col_int_undef_signed_not_null as col2, + 0 as col3, count(1) + FROM rqg_t1 + GROUP BY col1, col2, col3 + EXCEPT + SELECT col_bigint_undef_signed_not_null as col1, + col_decimal_38_10__undef_signed_not_null as col2, + 5 as col3, count(1) + FROM rqg_t2 + GROUP BY col1, col2, col3 + """ + logger.info("Bug 1a: PASSED (no crash)") + } catch (Throwable t) { + logger.error("Bug 1a FAILED: ${t.message}") + assertTrue(false, "Bug 1a: must set shared state in AGGREGATION_OPERATOR: ${t.message}") + } + + // Compare with BE native planner + logger.info("=== Bug 1b: AGG shared state - EXCEPT with serial exchange (BE native) ===") + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=3, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_common_expr_pushdown=false, + disable_streaming_preaggregations=true)*/ + col_int_undef_signed_not_null as col1, + col_int_undef_signed_not_null as col2, + 0 as col3, count(1) + FROM rqg_t1 + GROUP BY col1, col2, col3 + EXCEPT + SELECT col_bigint_undef_signed_not_null as col1, + col_decimal_38_10__undef_signed_not_null as col2, + 5 as col3, count(1) + FROM rqg_t2 + GROUP BY col1, col2, col3 + """ + logger.info("Bug 1b: PASSED (no crash)") + } catch (Throwable t) { + logger.error("Bug 1b FAILED: ${t.message}") + assertTrue(false, "Bug 1b: BE native also fails: ${t.message}") + } + + // ============================================================ + // Bug 2: must set shared state, in SORT_OPERATOR + // RQG case: grouping_set.case_id_5308471751 + // Key conditions: use_serial_exchange=true, parallel_pipeline_task_num=5 + // SQL: GROUPING SETS + window function (PERCENT_RANK) + // ============================================================ + + logger.info("=== Bug 2a: SORT shared state - GROUPING SETS + window (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=5, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true)*/ + SUM(PERCENT_RANK() OVER (PARTITION BY col_int_undef_signed2 ORDER BY col_int_undef_signed2)) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed2),(pk, pk),(col_int_undef_signed)) + """ + logger.info("Bug 2a: PASSED (no crash)") + } catch (Throwable t) { + logger.error("Bug 2a FAILED: ${t.message}") + assertTrue(false, "Bug 2a: must set shared state in SORT_OPERATOR: ${t.message}") + } + + logger.info("=== Bug 2b: SORT shared state - GROUPING SETS + window (BE native) ===") + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=5, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true)*/ + SUM(PERCENT_RANK() OVER (PARTITION BY col_int_undef_signed2 ORDER BY col_int_undef_signed2)) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed2),(pk, pk),(col_int_undef_signed)) + """ + logger.info("Bug 2b: PASSED (no crash)") + } catch (Throwable t) { + logger.error("Bug 2b FAILED: ${t.message}") + assertTrue(false, "Bug 2b: BE native also fails: ${t.message}") + } + + // ============================================================ + // Bug 3: incorrect results with GROUPING SETS + scalar subquery + window + // RQG case: grouping_set.case_id_5694495756 + // Key conditions: parallel_pipeline_task_num=2, disable_streaming_preaggregations=true + // Expected: all rows same value; Actual: values split proportionally (1/3, 2/3) + // ============================================================ + + logger.info("=== Bug 3: incorrect results - GROUPING SETS + subquery + window ===") + // FE planner + def result_fe = sql """ + SELECT /*+SET_VAR(parallel_pipeline_task_num=2, + enable_local_shuffle_planner=true, + disable_streaming_preaggregations=true, + enable_share_hash_table_for_broadcast_join=true)*/ + SUM((SELECT MAX(col_int_undef_signed2) FROM rqg_t1)) + OVER (PARTITION BY pk ORDER BY pk) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed2, pk),(pk), (pk)) + """ + // BE native + def result_be = sql """ + SELECT /*+SET_VAR(parallel_pipeline_task_num=2, + enable_local_shuffle_planner=false, + disable_streaming_preaggregations=true, + enable_share_hash_table_for_broadcast_join=true)*/ + SUM((SELECT MAX(col_int_undef_signed2) FROM rqg_t1)) + OVER (PARTITION BY pk ORDER BY pk) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed2, pk),(pk), (pk)) + """ + logger.info("Bug 3 FE result rows: ${result_fe.size()}, first few: ${result_fe.take(5)}") + logger.info("Bug 3 BE result rows: ${result_be.size()}, first few: ${result_be.take(5)}") + + // FE planner and BE native must produce identical results (the bug was values split + // proportionally instead of equal). Assert row count and order-insensitive content so a + // recurrence fails the suite. + assertEquals(result_be.size(), result_fe.size(), "Bug 3: FE/BE row count mismatch") + assertEquals(result_be.collect { it.toString() }.sort(), result_fe.collect { it.toString() }.sort(), + "Bug 3: FE/BE result mismatch") + + // ============================================================ + // Bug 4: Simplified AGG shared state — single table GROUP BY with serial exchange + // Minimal reproduction attempt + // ============================================================ + + logger.info("=== Bug 4: Simplified AGG shared state ===") + for (int ppt : [2, 3, 4, 5]) { + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true)*/ + col_int_undef_signed, count(*) + FROM rqg_t1 + GROUP BY col_int_undef_signed + UNION ALL + SELECT col_int_undef_signed, count(*) + FROM rqg_t2 + GROUP BY col_int_undef_signed + """ + logger.info("Bug 4 ppt=${ppt}: PASSED") + } catch (Throwable t) { + logger.error("Bug 4 ppt=${ppt} FAILED: ${t.message}") + assertTrue(false, "Bug 4 ppt=${ppt}: AGG shared state crash with serial exchange: ${t.message}") + } + } + + // ============================================================ + // Bug 5: GROUPING SETS + window variations with serial exchange + // More variations to find minimal repro + // ============================================================ + + logger.info("=== Bug 5: GROUPING SETS + window variations ===") + for (int ppt : [2, 3, 4, 5]) { + try { + sql """ + SELECT /*+SET_VAR(use_serial_exchange=true,parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true)*/ + pk, col_int_undef_signed, + ROW_NUMBER() OVER (ORDER BY pk) + FROM rqg_t1 + GROUP BY GROUPING SETS ((pk, col_int_undef_signed), (pk), ()) + ORDER BY pk + """ + logger.info("Bug 5 ppt=${ppt}: PASSED") + } catch (Throwable t) { + logger.error("Bug 5 ppt=${ppt} FAILED: ${t.message}") + assertTrue(false, "Bug 5 ppt=${ppt}: GROUPING SETS + window crash with serial exchange: ${t.message}") + } + } + + // ============================================================ + // Bug 6: must set shared state, in CROSS_JOIN_OPERATOR + // Root cause: nested NLJ + pooling scan — FE planner skipped BROADCAST + // local exchange on outer NLJ's build side because child was NLJ (not ScanNode). + // Fixed in NestedLoopJoinNode.enforceAndDeriveLocalExchange by using + // fragment.useSerialSource() instead of instanceof ScanNode check. + // This was the root cause of 989 RQG test failures (build 183677). + // ============================================================ + + logger.info("=== Bug 6: CROSS_JOIN shared state - nested NLJ + pooling scan (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, + broadcast_row_count_limit=0, + query_timeout=60)*/ + count(a.pk) AS cnt, a.col_int_undef_signed + FROM rqg_t1 a + LEFT JOIN rqg_t1 b ON b.col_int_undef_signed >= b.col_int_undef_signed + LEFT JOIN rqg_t1 c ON b.pk >= b.pk + WHERE a.pk IS NOT NULL + GROUP BY a.col_int_undef_signed + ORDER BY cnt, a.col_int_undef_signed + """ + logger.info("Bug 6: PASSED (no CROSS_JOIN_OPERATOR shared state error)") + } catch (Throwable t) { + logger.error("Bug 6 FAILED: ${t.message}") + assertTrue(false, "Bug 6: must set shared state in CROSS_JOIN_OPERATOR: ${t.message}") + } + + // ============================================================ + // Bug 7: DataStreamSink hang — sender fragment with pooling scan + // Root cause: FE planner did not insert PASSTHROUGH at the root of pooling scan + // sender fragments. With pooling scan, only instance 0 creates pipeline tasks, + // so only 1 EOS is sent. The downstream ExchangeNode expects _num_instances EOSes + // and hangs indefinitely. + // Fixed in AddLocalExchange.addLocalExchangeForFragment: insert PASSTHROUGH + // when isLocalShuffle && newRoot.isSerialOperator(). + // Any NLJ + pooling scan query triggers this via the UNPARTITIONED sender fragments. + // ============================================================ + + logger.info("=== Bug 7: DataStreamSink hang - NLJ + pooling scan sender (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, + broadcast_row_count_limit=0, + query_timeout=60)*/ + a.col_int_undef_signed, MAX(a.pk) AS mx + FROM rqg_t1 a + LEFT JOIN rqg_t1 b ON b.col_int_undef_signed < b.col_int_undef_signed + WHERE a.pk IS NOT NULL + GROUP BY a.col_int_undef_signed + ORDER BY a.col_int_undef_signed, mx + """ + logger.info("Bug 7: PASSED (no hang)") + } catch (Throwable t) { + logger.error("Bug 7 FAILED: ${t.message}") + assertTrue(false, "Bug 7: DataStreamSink hang (query timed out or crashed): ${t.message}") + } + + // ============================================================ + // Bug 8: must set shared state, in SORT_OPERATOR / UNION_OPERATOR + // Root cause: FE planner + pooling scan + GROUPING SETS. Serial UNPARTITIONED + // Exchange reduces downstream pipeline num_tasks to 1. SORT and UNION operators + // need _num_instances tasks to inject shared state for all instances. + // Fixed by: (1) restoring num_tasks raise for non-scan serial operators in BE + // deferred exchanger creation (commit 920d43d), and (2) FE inserting PASSTHROUGH + // after serial ExchangeNode in pooling scan fragments (commit d2e7fa2). + // ============================================================ + + logger.info("=== Bug 8a: SORT/UNION shared state - GROUPING SETS + pooling scan (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + disable_streaming_preaggregations=true, + query_timeout=60)*/ + pk, col_int_undef_signed, SUM(col_int_undef_signed_not_null) AS sv + FROM rqg_t1 + GROUP BY GROUPING SETS ((pk, col_int_undef_signed), (pk), ()) + ORDER BY pk, col_int_undef_signed, sv + """ + logger.info("Bug 8a: PASSED (no SORT/UNION_OPERATOR shared state error)") + } catch (Throwable t) { + logger.error("Bug 8a FAILED: ${t.message}") + assertTrue(false, "Bug 8a: must set shared state in SORT/UNION_OPERATOR: ${t.message}") + } + + logger.info("=== Bug 8b: SORT shared state - window + GROUPING SETS + pooling scan (FE planner) ===") + try { + sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + disable_streaming_preaggregations=true, + query_timeout=60)*/ + pk, SUM(col_int_undef_signed_not_null) AS sv, + ROW_NUMBER() OVER (ORDER BY pk) AS rn + FROM rqg_t1 + GROUP BY GROUPING SETS ((pk), ()) + ORDER BY pk, sv, rn + """ + logger.info("Bug 8b: PASSED (no SORT_OPERATOR shared state error)") + } catch (Throwable t) { + logger.error("Bug 8b FAILED: ${t.message}") + assertTrue(false, "Bug 8b: must set shared state in SORT_OPERATOR (window+grouping_sets): ${t.message}") + } + + // ============================================================ + // Bug 9: FE/BE result inconsistency — agg after NLJ + pooling scan + // Root cause: StreamingAgg used fragment.useSerialSource()=true to require + // PASSTHROUGH from child, but when child is NLJ (not directly a serial scan), + // NLJ outputs ADAPTIVE_PASSTHROUGH. FE wrongly inserted an extra PASSTHROUGH + // exchange between StreamingAgg and NLJ (5 extra LOCAL_EXCHANGE_SINK_OPERATOR + // entries vs BE native). + // Fixed in AggregationNode: only requirePassthrough when + // children.get(0).isSerialOperator()=true, mirroring BE _child->is_serial_operator(). + // ============================================================ + + logger.info("=== Bug 9: FE/BE result consistency - agg after NLJ + pooling scan ===") + def bug9_fe = sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, + broadcast_row_count_limit=0)*/ + a.col_int_undef_signed, MAX(a.pk) AS mx + FROM rqg_t1 a LEFT JOIN rqg_t1 b ON b.col_int_undef_signed < b.col_int_undef_signed + WHERE a.pk IS NOT NULL + GROUP BY a.col_int_undef_signed + ORDER BY a.col_int_undef_signed, mx + """ + def bug9_be = sql """ + SELECT /*+SET_VAR(ignore_storage_data_distribution=true, + parallel_pipeline_task_num=4, + enable_local_shuffle_planner=false, + disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, + broadcast_row_count_limit=0)*/ + a.col_int_undef_signed, MAX(a.pk) AS mx + FROM rqg_t1 a LEFT JOIN rqg_t1 b ON b.col_int_undef_signed < b.col_int_undef_signed + WHERE a.pk IS NOT NULL + GROUP BY a.col_int_undef_signed + ORDER BY a.col_int_undef_signed, mx + """ + logger.info("Bug 9 FE rows: ${bug9_fe.size()}, BE rows: ${bug9_be.size()}") + assertEquals(bug9_be.size(), bug9_fe.size(), "Bug 9: FE/BE row count mismatch") + assertEquals(bug9_be, bug9_fe, "Bug 9: FE/BE result mismatch for agg after NLJ + pooling scan") + logger.info("Bug 9: PASSED (FE/BE results match)") + + // ============================================================ + // Bug 10: GLOBAL_HASH_SHUFFLE Rows mismatched — self-join + NLJ + // RQG case: 906784672 (build 184181) + // Root cause: HashJoinNode used requireGlobalExecutionHash() → GLOBAL local exchange + // inserted when use_serial_exchange=true; shuffle_idx_to_instance_idx map has only + // 4 entries (1/BE) but GLOBAL hash needs N*dop entries → most rows unrouted (0 actual rows). + // Fixed: changed to requireHash() so resolveExchangeType() downgrades to LOCAL hash. + // SQL: self-join (table1 LEFT JOIN table1 table2 ON pk=col_bigint_undef_signed) + // then NLJ (LEFT JOIN table1 table3 ON pk > col_bigint_undef_signed) + // ============================================================ + + logger.info("=== Bug 10: GLOBAL_HASH_SHUFFLE Rows mismatched - self-join + NLJ (build 184181 case 906784672) ===") + def bug10_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_join_reorder=true, disable_colocate_plan=true)*/ + table1.pk AS field1, table1.col_bigint_undef_signed AS field2 + FROM rqg_t3 AS table1 + LEFT JOIN rqg_t3 AS table2 ON table1.pk = table2.col_bigint_undef_signed + LEFT JOIN rqg_t3 AS table3 ON table1.pk > table2.col_bigint_undef_signed + WHERE (table1.col_varchar_10__undef_signed > 'AHlvNtoGLO' + AND table1.col_varchar_10__undef_signed < 'zzzz') + OR (table1.col_bigint_undef_signed = table1.pk AND table1.col_varchar_64__undef_signed IS NULL) + OR (table1.pk != table1.pk AND table1.pk <> 2) + GROUP BY field1, field2 + ORDER BY field1, field2 + """ + def bug10_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_join_reorder=true, disable_colocate_plan=true)*/ + table1.pk AS field1, table1.col_bigint_undef_signed AS field2 + FROM rqg_t3 AS table1 + LEFT JOIN rqg_t3 AS table2 ON table1.pk = table2.col_bigint_undef_signed + LEFT JOIN rqg_t3 AS table3 ON table1.pk > table2.col_bigint_undef_signed + WHERE (table1.col_varchar_10__undef_signed > 'AHlvNtoGLO' + AND table1.col_varchar_10__undef_signed < 'zzzz') + OR (table1.col_bigint_undef_signed = table1.pk AND table1.col_varchar_64__undef_signed IS NULL) + OR (table1.pk != table1.pk AND table1.pk <> 2) + GROUP BY field1, field2 + ORDER BY field1, field2 + """ + logger.info("Bug 10 FE rows: ${bug10_fe.size()}, BE rows: ${bug10_be.size()}") + assertEquals(bug10_be.size(), bug10_fe.size(), "Bug 10: FE/BE row count mismatch (GLOBAL_HASH_SHUFFLE Rows mismatched)") + assertEquals(bug10_be, bug10_fe, "Bug 10: FE/BE result mismatch for self-join + NLJ") + logger.info("Bug 10: PASSED") + + // ============================================================ + // Bug 11: GLOBAL_HASH_SHUFFLE Rows mismatched — FULL OUTER JOIN + GROUP BY + // RQG case: 11007681241 (build 184181) + // Same root cause as Bug 10. + // SQL: FULL OUTER JOIN on col_bigint_undef_signed_not_null with WHERE + GROUP BY + // ============================================================ + + logger.info("=== Bug 11: GLOBAL_HASH_SHUFFLE Rows mismatched - FULL OUTER JOIN + GROUP BY (build 184181 case 11007681241) ===") + def bug11_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + t1.col_bigint_undef_signed_not_null, t2.col_bigint_undef_signed_not_null, count(1) + FROM rqg_t4 t1 + FULL OUTER JOIN rqg_t2 t2 + ON t1.col_bigint_undef_signed_not_null = t2.col_bigint_undef_signed_not_null + WHERE t2.col_bigint_undef_signed_not_null = 2 + GROUP BY t1.col_bigint_undef_signed_not_null, t2.col_bigint_undef_signed_not_null + ORDER BY 1, 2, 3 + """ + def bug11_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + t1.col_bigint_undef_signed_not_null, t2.col_bigint_undef_signed_not_null, count(1) + FROM rqg_t4 t1 + FULL OUTER JOIN rqg_t2 t2 + ON t1.col_bigint_undef_signed_not_null = t2.col_bigint_undef_signed_not_null + WHERE t2.col_bigint_undef_signed_not_null = 2 + GROUP BY t1.col_bigint_undef_signed_not_null, t2.col_bigint_undef_signed_not_null + ORDER BY 1, 2, 3 + """ + logger.info("Bug 11 FE rows: ${bug11_fe.size()}, BE rows: ${bug11_be.size()}") + assertEquals(bug11_be.size(), bug11_fe.size(), "Bug 11: FE/BE row count mismatch (GLOBAL_HASH_SHUFFLE Rows mismatched)") + assertEquals(bug11_be, bug11_fe, "Bug 11: FE/BE result mismatch for FULL OUTER JOIN + GROUP BY") + logger.info("Bug 11: PASSED") + + // ============================================================ + // Bug 12: GLOBAL_HASH_SHUFFLE Rows mismatched — LEFT JOIN + VARCHAR predicates + MIN() + // RQG case: 906784662 (build 184181) + // Same root cause as Bug 10/11. + // SQL: LEFT JOIN on pk with VARCHAR NOT IN / BETWEEN / IN predicates, MIN() aggregate + // ============================================================ + + logger.info("=== Bug 12: GLOBAL_HASH_SHUFFLE Rows mismatched - LEFT JOIN + VARCHAR predicates (build 184181 case 906784662) ===") + def bug12_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_join_reorder=true, disable_colocate_plan=true)*/ + table1.pk AS field1, MIN(table1.pk) AS field2 + FROM rqg_t3 AS table1 + LEFT JOIN rqg_t1 AS table2 ON table2.pk = table1.pk + WHERE table1.col_varchar_64__undef_signed NOT IN ('they') + AND table1.col_varchar_10__undef_signed BETWEEN 'AHlvNtoGLO' AND 'z' + AND table1.pk IN (3, 6, 8, 9, 2) + GROUP BY field1 + ORDER BY field1, field2 ASC + """ + def bug12_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_join_reorder=true, disable_colocate_plan=true)*/ + table1.pk AS field1, MIN(table1.pk) AS field2 + FROM rqg_t3 AS table1 + LEFT JOIN rqg_t1 AS table2 ON table2.pk = table1.pk + WHERE table1.col_varchar_64__undef_signed NOT IN ('they') + AND table1.col_varchar_10__undef_signed BETWEEN 'AHlvNtoGLO' AND 'z' + AND table1.pk IN (3, 6, 8, 9, 2) + GROUP BY field1 + ORDER BY field1, field2 ASC + """ + logger.info("Bug 12 FE rows: ${bug12_fe.size()}, BE rows: ${bug12_be.size()}") + assertEquals(bug12_be.size(), bug12_fe.size(), "Bug 12: FE/BE row count mismatch (GLOBAL_HASH_SHUFFLE Rows mismatched)") + assertEquals(bug12_be, bug12_fe, "Bug 12: FE/BE result mismatch for LEFT JOIN + VARCHAR predicates") + logger.info("Bug 12: PASSED") + + // ============================================================ + // Bug 13: NLJ COREDUMP — serial NLJ + pooling scan + BROADCAST build side + // RQG build 184430, query c0dafc1bed0f4910 + // Root cause: serial NLJ (RIGHT_OUTER) with pooling scan inserted BROADCAST + // local exchange on build side, inflating build pipeline num_tasks to _num_instances + // while probe pipeline stayed at 1 task. Instance 1+ created build tasks without + // corresponding probe tasks → source_deps empty → set_ready_to_read() crash. + // Fixed: serial NLJ sets buildSideRequire=noRequire() to match BE-native + // num_tasks_of_parent()<=1 skip logic. + // ============================================================ + + logger.info("=== Bug 13: NLJ COREDUMP - serial NLJ + pooling scan (FE planner) ===") + try { + def bug13_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=0, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true, + disable_join_reorder=true)*/ + t2.col_bigint_undef_signed_not_null AS field1 + FROM rqg_t4 AS t1 + RIGHT OUTER JOIN rqg_t2 AS t2 ON t1.col_bigint_undef_signed_not_null > t2.col_bigint_undef_signed_not_null + GROUP BY field1 + ORDER BY field1 ASC + """ + def bug13_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=0, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true, + disable_join_reorder=true)*/ + t2.col_bigint_undef_signed_not_null AS field1 + FROM rqg_t4 AS t1 + RIGHT OUTER JOIN rqg_t2 AS t2 ON t1.col_bigint_undef_signed_not_null > t2.col_bigint_undef_signed_not_null + GROUP BY field1 + ORDER BY field1 ASC + """ + logger.info("Bug 13 FE rows: ${bug13_fe.size()}, BE rows: ${bug13_be.size()}") + assertEquals(bug13_be.size(), bug13_fe.size(), "Bug 13: FE/BE row count mismatch (NLJ COREDUMP)") + assertEquals(bug13_be, bug13_fe, "Bug 13: FE/BE result mismatch for serial NLJ + pooling scan") + logger.info("Bug 13: PASSED (no crash, results match)") + } catch (Throwable t) { + logger.error("Bug 13 FAILED: ${t.message}") + assertTrue(false, "Bug 13: NLJ COREDUMP (serial NLJ + pooling scan): ${t.message}") + } + + // ============================================================ + // Bug 14: BUCKET_SHUFFLE join + serial build Exchange — must set shared state + // RQG build 184563, cases 906784706/906784783/906784987/906785006 + // Root cause: BUCKET_SHUFFLE join build side ExchangeNode marked serial in + // pooling scan fragment → build pipeline num_tasks reduced to 1 → + // instance 1+ have probe tasks without build tasks → shared state injection + // fails. Fixed: isBucketShuffle() branch checks buildChildSerial and uses + // requirePassToOne() to restore num_tasks, matching BE-native behavior. + // Requires replication_num=3 + [shuffle] hint to force BUCKET_SHUFFLE plan. + // ============================================================ + + logger.info("=== Bug 14: BUCKET_SHUFFLE join + serial build Exchange (FE planner) ===") + // Need replication_num=3 for BUCKET_SHUFFLE. Check if allow_replica_on_same_host is enabled. + def allowSameHost = sql "ADMIN SHOW FRONTEND CONFIG LIKE 'allow_replica_on_same_host'" + if (allowSameHost[0][1].toString() == "true") { + sql "DROP TABLE IF EXISTS rqg_t5_rep3" + sql "DROP TABLE IF EXISTS rqg_t6_rep3" + try { + sql """ + CREATE TABLE rqg_t5_rep3 ( + pk INT NULL, + col_varchar_10__undef_signed VARCHAR(10) NULL, + col_bigint_undef_signed BIGINT NULL, + col_varchar_64__undef_signed VARCHAR(64) NULL + ) DUPLICATE KEY(pk, col_varchar_10__undef_signed) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "3") + """ + sql """ + CREATE TABLE rqg_t6_rep3 ( + pk INT NULL, + col_varchar_10__undef_signed VARCHAR(10) NULL, + col_bigint_undef_signed BIGINT NULL, + col_varchar_64__undef_signed VARCHAR(64) NULL + ) DUPLICATE KEY(pk, col_varchar_10__undef_signed) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "3") + """ + sql """ + INSERT INTO rqg_t5_rep3 VALUES + (0,'abc',-94,'hello'),(1,'xyz',672609,null),(2,'pqr',-3766684,'test'), + (3,'abc',5070261,'another'),(4,'def',null,'value'),(5,'so',-86,null), + (6,'abc',21910,'they'),(7,'zzzz',-63,'some'),(8,'xPLflvBEcW',-8276281,'longer'), + (9,'mid',-101,'final') + """ + sql """ + INSERT INTO rqg_t6_rep3 VALUES + (0,'aaa',100,'world'),(1,'bbb',200,null),(2,'ccc',300,'foo'), + (3,'ddd',400,'bar'),(4,'eee',500,'baz'),(5,'fff',600,null), + (6,'ggg',700,'qux'),(7,'hhh',800,'quux'),(8,'iii',900,'corge'), + (9,'jjj',1000,'grault') + """ + Thread.sleep(3000) + + def bug14_fe = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=3, + disable_streaming_preaggregations=true, + enable_sql_cache=false, + disable_join_reorder=true)*/ + table1.pk AS field1 + FROM rqg_t5_rep3 AS table1 + LEFT OUTER JOIN [shuffle] rqg_t6_rep3 AS table2 ON table1.pk = table2.pk + WHERE table1.col_varchar_10__undef_signed >= 'so' + GROUP BY field1 + ORDER BY field1 + """ + def bug14_be = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=3, + disable_streaming_preaggregations=true, + enable_sql_cache=false, + disable_join_reorder=true)*/ + table1.pk AS field1 + FROM rqg_t5_rep3 AS table1 + LEFT OUTER JOIN [shuffle] rqg_t6_rep3 AS table2 ON table1.pk = table2.pk + WHERE table1.col_varchar_10__undef_signed >= 'so' + GROUP BY field1 + ORDER BY field1 + """ + logger.info("Bug 14 FE rows: ${bug14_fe.size()}, BE rows: ${bug14_be.size()}") + assertEquals(bug14_be.size(), bug14_fe.size(), "Bug 14: FE/BE row count mismatch (BUCKET_SHUFFLE must set shared state)") + assertEquals(bug14_be, bug14_fe, "Bug 14: FE/BE result mismatch for BUCKET_SHUFFLE + serial build Exchange") + logger.info("Bug 14: PASSED (no crash, results match)") + } catch (Throwable t) { + logger.error("Bug 14 FAILED: ${t.message}") + assertTrue(false, "Bug 14: BUCKET_SHUFFLE must set shared state: ${t.message}") + } + } else { + logger.info("Bug 14: SKIPPED (allow_replica_on_same_host not enabled, cannot create replication_num=3 tables)") + } + + // ============================================================ + // Bug 15: BUCKET_SHUFFLE join wrong results with serial exchange — PASS_TO_ONE data loss + // Root cause: When serial exchange feeds BUCKET_SHUFFLE join build side, + // PASS_TO_ONE routes ALL build data to task 0. Unlike BROADCAST joins, + // BUCKET_SHUFFLE has no shared hash table mechanism — tasks 1..N-1 build + // empty hash tables and lose rows during probe. Fixed by using + // BUCKET_HASH_SHUFFLE instead of PASS_TO_ONE for BUCKET_SHUFFLE build side. + // Tables use 3 buckets so pptn=4 triggers serial scan on single BE (3 < 4*1). + // ============================================================ + + logger.info("=== Bug 15: BUCKET_SHUFFLE join wrong results with serial PASS_TO_ONE ===") + + sql "DROP TABLE IF EXISTS rqg_t7_3bucket" + sql "DROP TABLE IF EXISTS rqg_t8_3bucket" + + sql """ + CREATE TABLE rqg_t7_3bucket ( + pk INT NOT NULL, + col_int INT NULL, + col_varchar VARCHAR(64) NULL + ) DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 3 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE rqg_t8_3bucket ( + pk INT NOT NULL, + col_int INT NULL, + col_varchar VARCHAR(64) NULL + ) DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 3 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + INSERT INTO rqg_t7_3bucket VALUES + (0, 10, 'aaa'), (1, 20, 'bbb'), (2, 30, 'ccc'), + (3, 40, 'ddd'), (4, 50, 'eee'), (5, 60, 'fff'), + (6, 70, 'ggg'), (7, 80, 'hhh'), (8, 90, 'iii'), (9, 100, 'jjj') + """ + + sql """ + INSERT INTO rqg_t8_3bucket VALUES + (0, 10, 'aaa'), (1, 20, 'bbb'), (2, 30, 'ccc'), + (3, 40, 'ddd'), (4, 50, 'eee'), (5, 60, 'fff'), + (6, 70, 'ggg'), (7, 80, 'hhh'), (8, 90, 'iii'), (9, 100, 'jjj') + """ + + Thread.sleep(3000) + + try { + // pptn=4 with 3 buckets on 1 BE: 3 < 4*1 → serial scan → serial exchange + // This triggers the PASS_TO_ONE bug for BUCKET_SHUFFLE build side. + // Also test with higher pptn values to cover more cases. + for (int ppt : [4, 6, 8]) { + def bug15_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + enable_sql_cache=false, + disable_join_reorder=true)*/ + t1.pk, t1.col_int, t2.col_varchar + FROM rqg_t7_3bucket t1 + INNER JOIN [shuffle] rqg_t8_3bucket t2 ON t1.pk = t2.pk + ORDER BY t1.pk + """ + def bug15_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + enable_sql_cache=false, + disable_join_reorder=true)*/ + t1.pk, t1.col_int, t2.col_varchar + FROM rqg_t7_3bucket t1 + INNER JOIN [shuffle] rqg_t8_3bucket t2 ON t1.pk = t2.pk + ORDER BY t1.pk + """ + logger.info("Bug 15 ppt=${ppt}: FE rows=${bug15_fe.size()}, BE rows=${bug15_be.size()}") + assertEquals(10, bug15_fe.size(), "Bug 15 ppt=${ppt}: expected 10 rows from FE planner, got ${bug15_fe.size()}") + assertEquals(bug15_be, bug15_fe, "Bug 15 ppt=${ppt}: FE/BE result mismatch for BUCKET_SHUFFLE + serial exchange") + } + + // Also test LEFT OUTER JOIN to verify no rows lost on probe side + def bug15_left_fe = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=6, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + enable_sql_cache=false, + disable_join_reorder=true)*/ + t1.pk, t2.col_int + FROM rqg_t7_3bucket t1 + LEFT OUTER JOIN [shuffle] rqg_t8_3bucket t2 ON t1.pk = t2.pk + ORDER BY t1.pk + """ + def bug15_left_be = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=6, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_share_hash_table_for_broadcast_join=false, + enable_sql_cache=false, + disable_join_reorder=true)*/ + t1.pk, t2.col_int + FROM rqg_t7_3bucket t1 + LEFT OUTER JOIN [shuffle] rqg_t8_3bucket t2 ON t1.pk = t2.pk + ORDER BY t1.pk + """ + assertEquals(10, bug15_left_fe.size(), "Bug 15 LEFT JOIN: expected 10 rows from FE planner") + assertEquals(bug15_left_be, bug15_left_fe, "Bug 15 LEFT JOIN: FE/BE result mismatch") + + logger.info("Bug 15: PASSED (no wrong results, all pptn values correct)") + } catch (Throwable t) { + logger.error("Bug 15 FAILED: ${t.message}") + assertTrue(false, "Bug 15: BUCKET_SHUFFLE wrong results with serial PASS_TO_ONE: ${t.message}") + } + + // ============================================================ + // Bug 16 & 17: Serial AnalyticEval crash and DataStreamSink hang + // with LocalShuffleAssignedJob (multiple instances on one BE) + // + // Bug 16 (crash): Exchange wraps itself with PASSTHROUGH LocalExchange. + // This restores AnalyticSink pipeline to _num_instances tasks while + // serial AnalyticSource stays at 1 task. For instance_idx > 0, + // source_deps is empty → DCHECK crash. + // + // Bug 17 (hang): After fixing the crash, serial AnalyticSource reduces + // all downstream pipeline tasks to 1 via add_pipeline() inheritance. + // Only instance 0 runs DataStreamSink → receiver expects _num_instances + // EOSes → hang. + // + // Both triggered by: OVER() with no PARTITION BY + GROUPING SETS + + // pptn=0 (auto-parallel) + disable_streaming_preaggregations=true + // RQG build 186195, query IDs: 7f3178a77c2c4b6b, 71887f7bf804c0c, 5dd9fcad234c4484 + // ============================================================ + sql "DROP TABLE IF EXISTS rqg_analytic_t1" + sql """ + CREATE TABLE rqg_analytic_t1 ( + pk INT NOT NULL, + col_int_undef_signed INT + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + sql """ + INSERT INTO rqg_analytic_t1 VALUES + (1, 10), (2, 20), (3, 30), (4, 40), (5, 50), + (6, 60), (7, 70), (8, 80), (9, 90), (10, 100), + (11, 10), (12, 20), (13, 30), (14, 40), (15, 50), + (16, 60), (17, 70), (18, 80), (19, 90), (20, 100) + """ + + try { + logger.info("Bug 16+17: Testing serial AnalyticEval with GROUPING SETS") + + // Baseline: pptn=1 (no multi-instance, no local shuffle) + def bug16_baseline = sql """ + SELECT /*+SET_VAR(parallel_pipeline_task_num=1, + enable_sql_cache=false, + disable_streaming_preaggregations=true)*/ + COUNT(MIN(col_int_undef_signed) OVER()) + FROM rqg_analytic_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed, pk), (), (pk)) + ORDER BY 1 + """ + assertEquals(41, bug16_baseline.size(), "Bug 16 baseline: expected 41 rows") + + // Test with pptn=0 (auto-parallel, triggers LocalShuffleAssignedJob) + for (int ppt : [0, 2, 4, 8]) { + def bug16_result = sql """ + SELECT /*+SET_VAR(parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_streaming_preaggregations=true)*/ + COUNT(MIN(col_int_undef_signed) OVER()) + FROM rqg_analytic_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed, pk), (), (pk)) + ORDER BY 1 + """ + assertEquals(bug16_baseline, bug16_result, + "Bug 16+17 pptn=${ppt}: result mismatch with serial AnalyticEval") + } + + // Also test with use_serial_exchange=true (makes ALL exchanges serial) + def bug16_serial = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=0, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_streaming_preaggregations=true)*/ + COUNT(MIN(col_int_undef_signed) OVER()) + FROM rqg_analytic_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed, pk), (), (pk)) + ORDER BY 1 + """ + assertEquals(bug16_baseline, bug16_serial, + "Bug 16+17 serial_exchange: result mismatch") + + logger.info("Bug 16+17: PASSED (no crash, no hang, correct results)") + } catch (Throwable t) { + logger.error("Bug 16+17 FAILED: ${t.message}") + assertTrue(false, "Bug 16+17: Serial AnalyticEval crash/hang: ${t.message}") + } + + // Bug 18: DCHECK crash in Pipeline::set_num_tasks when PASSTHROUGH LE is inserted + // between serial NLJ and its child Exchange. + // Root cause: ExchangeNode.enforceAndDeriveLocalExchange wraps UNPARTITIONED serial + // Exchange with PASSTHROUGH LE. On BE, NLJ_PROBE (serial) sets pipeline num_tasks=1, + // then the LE handler's set_num_tasks(_num_instances) overrides it to N, triggering + // DCHECK (serial operator in pipeline with num_tasks > 1). + // Fix: skip PASSTHROUGH wrapping when hasSerialAncestorInPipeline is true. + // Query: LEFT JOIN with always-true self-ref condition (table.pk = table.pk) creates + // RIGHT_OUTER NLJ (serial). With pptn>1 and ignore_data_distribution, the fragment + // gets N instances but NLJ forces 1 task. + try { + logger.info("Bug 18: Testing serial NLJ with PASSTHROUGH LE crash") + // Use existing rqg_t1 table (10 rows, 10 buckets) + def bug18_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + enable_sql_cache=false)*/ + table1.col_int_undef_signed AS field1 + FROM rqg_t1 AS table1 + LEFT JOIN rqg_t1 AS table2 + ON table2.pk = table2.pk + WHERE table1.pk BETWEEN 2 AND 11 + GROUP BY field1 + ORDER BY 1 + """ + + // Test with various pptn values — crash requires pptn > 1 + for (int ppt : [4, 7]) { + def bug18_result = sql """ + SELECT /*+SET_VAR(use_serial_exchange=false, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=false, + enable_broadcast_join_force_passthrough=true, + enable_parallel_scan=false)*/ + table1.col_int_undef_signed AS field1 + FROM rqg_t1 AS table1 + LEFT JOIN rqg_t1 AS table2 + ON table2.pk = table2.pk + WHERE table1.pk BETWEEN 2 AND 11 + GROUP BY field1 + ORDER BY 1 + """ + assertEquals(bug18_baseline, bug18_result, + "Bug 18 pptn=${ppt}: result mismatch with serial NLJ + local exchange") + } + logger.info("Bug 18: PASSED (no crash, correct results)") + } catch (Throwable t) { + logger.error("Bug 18 FAILED: ${t.message}") + assertTrue(false, "Bug 18: Serial NLJ PASSTHROUGH LE crash: ${t.message}") + } + + // Bug 19: source_deps.size()=0 crash in NLJ build sink. + // Root cause: serial NLJ (RIGHT_OUTER) resets serial ancestor flag for build side. + // Exchange(UNPARTITIONED) on build side sees hasSerialAncestorInPipeline=false and + // inserts PASSTHROUGH LE. This restores build pipeline num_tasks to _num_instances + // while probe pipeline stays at 1. The extra build tasks have NLJ shared state with + // empty source_deps → crash in set_ready_to_read(). + // Fix: shouldResetSerialFlagForChild(1) returns false when NLJ is serial. + // Differs from Bug 18 in fuzzy vars: enable_share_hash_table=true, broadcast_passthrough=false. + try { + logger.info("Bug 19: Testing serial NLJ build-side source_deps crash") + def bug19_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + enable_sql_cache=false)*/ + table1.col_int_undef_signed AS field1 + FROM rqg_t1 AS table1 + LEFT JOIN rqg_t1 AS table2 + ON table2.pk = table2.pk + WHERE table1.pk BETWEEN 2 AND 11 + GROUP BY field1 + ORDER BY 1 + """ + + for (int ppt : [2, 4]) { + def bug19_result = sql """ + SELECT /*+SET_VAR(use_serial_exchange=false, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=true, + enable_broadcast_join_force_passthrough=false, + enable_parallel_scan=true, + disable_streaming_preaggregations=true)*/ + table1.col_int_undef_signed AS field1 + FROM rqg_t1 AS table1 + LEFT JOIN rqg_t1 AS table2 + ON table2.pk = table2.pk + WHERE table1.pk BETWEEN 2 AND 11 + GROUP BY field1 + ORDER BY 1 + """ + assertEquals(bug19_baseline, bug19_result, + "Bug 19 pptn=${ppt}: result mismatch with serial NLJ build side crash") + } + logger.info("Bug 19: PASSED (no crash, correct results)") + } catch (Throwable t) { + logger.error("Bug 19 FAILED: ${t.message}") + assertTrue(false, "Bug 19: Serial NLJ build-side source_deps crash: ${t.message}") + } + + // Bug 20: Hang (ASAN: COREDUMP source_deps.size()=0 in AggSinkOperatorX) when + // use_serial_exchange=true + RIGHT JOIN + GROUP BY in non-pooling fragment. + // Root cause: serial HASH Exchange in non-pooling fragment returned NOOP, causing FE + // to insert LOCAL_EXECUTION_HASH_SHUFFLE LE. On BE, serial Exchange pipeline has 1 task + // but LE downstream has _num_instances tasks. AggSink on instances 1+ has empty source_deps. + // Fix: ExchangeNode.enforceAndDeriveLocalExchange() returns actual distribution type + // (GLOBAL_EXECUTION_HASH_SHUFFLE/BUCKET_HASH_SHUFFLE) for serial Exchange in non-pooling + // fragments, preventing LE insertion. + // Requires 3+ BEs to reproduce (single BE has _num_instances=1, no hang). + try { + logger.info("Bug 20: Testing serial exchange + agg hang in non-pooling fragment") + // Baseline uses same fuzzy vars but with planner=false (BE-native). + // This way we compare FE-planned vs BE-native under identical conditions, + // not against "correct" results — use_serial_exchange=true itself may have + // pre-existing BE bugs with certain pptn values. + def bug20_baseline_sql = { int ppt -> """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true)*/ + table1.pk AS field1 + FROM rqg_t1 AS table1 + RIGHT OUTER JOIN rqg_t1 AS table2 ON table1.pk = table2.pk + LEFT JOIN rqg_t1 AS table3 ON table3.pk = table1.pk + WHERE table1.col_int_undef_signed IS NOT NULL OR table1.pk <> 10 + GROUP BY field1 + ORDER BY 1 + """ } + for (int ppt : [3, 4, 7]) { + def bug20_baseline = sql bug20_baseline_sql(ppt) + def bug20_result = sql """ + SELECT /*+SET_VAR(use_serial_exchange=true, + parallel_pipeline_task_num=${ppt}, + enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + enable_share_hash_table_for_broadcast_join=false, + disable_streaming_preaggregations=true)*/ + table1.pk AS field1 + FROM rqg_t1 AS table1 + RIGHT OUTER JOIN rqg_t1 AS table2 ON table1.pk = table2.pk + LEFT JOIN rqg_t1 AS table3 ON table3.pk = table1.pk + WHERE table1.col_int_undef_signed IS NOT NULL OR table1.pk <> 10 + GROUP BY field1 + ORDER BY 1 + """ + assertEquals(bug20_baseline, bug20_result, + "Bug 20 pptn=${ppt}: result mismatch with serial exchange + agg hang") + } + logger.info("Bug 20: PASSED (no hang, correct results)") + } catch (Throwable t) { + logger.error("Bug 20 FAILED: ${t.message}") + assertTrue(false, "Bug 20: Serial exchange + agg hang: ${t.message}") + } + + // ============================================================ + // Bug 21: Multi-distinct COUNT on many-bucket table → COREDUMP + // RQG build 186737/186929/186952: AggSinkOperatorX::sink → set_ready_to_read + // with empty source_deps. + // + // Root cause: AGG operators (streaming, distinct-streaming, serialize) requested + // PASSTHROUGH from non-ScanNode serial children (Exchange, AGG), inserting a + // PASSTHROUGH LE that created a pipeline split disconnecting AggSink↔AggSource + // shared state. + // + // Fix: restrict AGG PASSTHROUGH requests to ScanNode children only. + // Triggered by: multi-distinct COUNT/MIN with MultiCastDataSinks feeding + // serial UNPARTITIONED Exchanges into streaming AGG fragments. + // ============================================================ + sql "DROP TABLE IF EXISTS rqg_t5_many_buckets" + sql """ + CREATE TABLE rqg_t5_many_buckets ( + pk INT NOT NULL, + col_int_undef_signed INT, + col_date_undef_signed DATE, + col_date_undef_signed2 DATE, + col_varchar_1024__undef_signed VARCHAR(1024) + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 56 + PROPERTIES ("replication_num" = "1") + """ + sql """INSERT INTO rqg_t5_many_buckets VALUES + (1,1,'2023-12-09','2024-06-01','s1'),(2,2,'2023-03-15','2024-01-20','s2'), + (3,3,'2023-07-22','2024-03-10',NULL),(4,4,'2023-12-09','2024-06-01','s4'), + (5,5,'2023-01-05','2024-09-15','s5'),(6,6,'2023-08-11','2024-02-28','s6'), + (7,7,'2023-04-18','2024-07-04',NULL),(8,8,'2023-11-25','2024-05-12','s8'), + (9,9,'2023-06-30','2024-11-19','s9'),(10,10,'2023-02-14','2024-08-07','s10') + """ + + try { + logger.info("Bug 21: Testing multi-distinct COUNT on many-bucket table (COREDUMP fix)") + for (int ppt : [4, 6]) { + // Test without use_serial_exchange + def bug21_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + query_timeout=60)*/ + MIN(distinct col_date_undef_signed), + COUNT(distinct col_date_undef_signed2), + COUNT(distinct col_int_undef_signed) + FROM rqg_t5_many_buckets + WHERE col_int_undef_signed = col_int_undef_signed + LIMIT 1000 + """ + def bug21_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + query_timeout=60)*/ + MIN(distinct col_date_undef_signed), + COUNT(distinct col_date_undef_signed2), + COUNT(distinct col_int_undef_signed) + FROM rqg_t5_many_buckets + WHERE col_int_undef_signed = col_int_undef_signed + LIMIT 1000 + """ + assertEquals(bug21_baseline, bug21_result, + "Bug 21 pptn=${ppt}: multi-distinct COUNT result mismatch (was COREDUMP)") + } + logger.info("Bug 21: PASSED (no crash, correct results)") + } catch (Throwable t) { + // Timeout/hang is a real failure mode for this bug: an EOS/close-count mismatch in the + // coupled pipelines can hang instead of crashing, so a timeout here may be the very + // regression we are testing. Let it fail too — do not mask it as SKIPPED. + logger.error("Bug 21 FAILED: ${t.message}") + assertTrue(false, "Bug 21: Multi-distinct COUNT COREDUMP/hang: ${t.message}") + } + + // ============================================================ + // Bug 22: AGG/SORT above FE-planned LOCAL_EXCHANGE → COREDUMP + // (set_ready_to_read DCHECK failure with empty source_deps) + // + // Root cause: when FE inserts LOCAL_EXCHANGE_NODE below a pipeline- + // splitting operator (AGG, SORT), LOCAL_EXCHANGE restores its immediate + // pipeline to _num_instances tasks, but ancestor pipelines (e.g., + // AggSource) still carry the reduced num_tasks from the serial operator. + // This causes instance 1+ to create AggSink tasks but not AggSource + // tasks, leaving source_deps uninitialized → DCHECK in set_ready_to_read. + // + // Fix: _propagate_local_exchange_num_tasks() walks the DAG upward from + // LOCAL_EXCHANGE and raises ancestor pipeline num_tasks to _num_instances. + // ============================================================ + try { + logger.info("Bug 22: Testing AGG/SORT above LOCAL_EXCHANGE num_tasks propagation") + for (int ppt : [4, 6]) { + // 22a: Simple AGG with GROUP BY over pooling scan + def bug22a_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + col_int_undef_signed, COUNT(*), SUM(col_int_undef_signed2) + FROM rqg_t1 + GROUP BY col_int_undef_signed + ORDER BY 1 + """ + def bug22a_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + col_int_undef_signed, COUNT(*), SUM(col_int_undef_signed2) + FROM rqg_t1 + GROUP BY col_int_undef_signed + ORDER BY 1 + """ + assertEquals(bug22a_baseline, bug22a_result, + "Bug 22a pptn=${ppt}: AGG GROUP BY result mismatch (was COREDUMP)") + + // 22b: SORT + AGG (two pipeline splits above LOCAL_EXCHANGE) + def bug22b_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + col_int_undef_signed, COUNT(*) AS cnt + FROM rqg_t1 + GROUP BY col_int_undef_signed + ORDER BY cnt DESC, col_int_undef_signed + """ + def bug22b_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + col_int_undef_signed, COUNT(*) AS cnt + FROM rqg_t1 + GROUP BY col_int_undef_signed + ORDER BY cnt DESC, col_int_undef_signed + """ + assertEquals(bug22b_baseline, bug22b_result, + "Bug 22b pptn=${ppt}: SORT+AGG result mismatch (was COREDUMP)") + + // 22c: JOIN + AGG (join probe pipeline also needs num_tasks propagation) + def bug22c_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + t1.col_int_undef_signed, COUNT(*) + FROM rqg_t1 t1 JOIN rqg_t2 t2 ON t1.pk = t2.pk + GROUP BY t1.col_int_undef_signed + ORDER BY 1 + """ + def bug22c_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + t1.col_int_undef_signed, COUNT(*) + FROM rqg_t1 t1 JOIN rqg_t2 t2 ON t1.pk = t2.pk + GROUP BY t1.col_int_undef_signed + ORDER BY 1 + """ + assertEquals(bug22c_baseline, bug22c_result, + "Bug 22c pptn=${ppt}: JOIN+AGG result mismatch (was COREDUMP)") + + // 22d: AGG without GROUP BY (scalar agg, PASS_TO_ONE exchange) + def bug22d_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + COUNT(*), SUM(col_int_undef_signed), AVG(col_int_undef_signed2) + FROM rqg_t1 + """ + def bug22d_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${ppt}, + ignore_storage_data_distribution=true, + enable_sql_cache=false)*/ + COUNT(*), SUM(col_int_undef_signed), AVG(col_int_undef_signed2) + FROM rqg_t1 + """ + assertEquals(bug22d_baseline, bug22d_result, + "Bug 22d pptn=${ppt}: scalar AGG result mismatch") + } + logger.info("Bug 22: PASSED (no crash, correct results)") + } catch (Throwable t) { + logger.error("Bug 22 FAILED: ${t.message}") + assertTrue(false, "Bug 22: AGG/SORT num_tasks propagation: ${t.message}") + } + + // ==================== Bug 23 ==================== + // canUseDistinctStreamingAgg + GROUPING SETS + serial scan → missing LE + // When enable_distinct_streaming_aggregation=true, AggregationNode's + // canUseDistinctStreamingAgg path set requireChild=noRequire() without + // checking child serial status → serial RepeatNode feeds directly into + // non-serial AggregationNode → shared_state mismatch on multi-BE. + // Fix: add isSerialOperatorOnBe check in the noRequire branch. + try { + for (def pptn : [2, 4]) { + def bug23_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=false, + parallel_pipeline_task_num=${pptn}, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_streaming_preaggregations=false, + enable_distinct_streaming_aggregation=true)*/ + col_int_undef_signed, count(*) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed), (pk), ()) + ORDER BY 1, 2 + """ + def bug23_result = sql """ + SELECT /*+SET_VAR(enable_local_shuffle_planner=true, + parallel_pipeline_task_num=${pptn}, + ignore_storage_data_distribution=true, + enable_sql_cache=false, + disable_streaming_preaggregations=false, + enable_distinct_streaming_aggregation=true)*/ + col_int_undef_signed, count(*) + FROM rqg_t1 + GROUP BY GROUPING SETS ((col_int_undef_signed), (pk), ()) + ORDER BY 1, 2 + """ + assertEquals(bug23_baseline, bug23_result, + "Bug 23 pptn=${pptn}: GROUPING SETS + distinct streaming agg result mismatch") + } + logger.info("Bug 23: PASSED (no crash, correct results)") + } catch (Throwable t) { + logger.error("Bug 23 FAILED: ${t.message}") + assertTrue(false, "Bug 23: canUseDistinctStreamingAgg + GROUPING SETS: ${t.message}") + } + + // ============================================================ + // Bug 24: BUCKET_SHUFFLE join + pooling scan + local shuffle + // causes data loss when destination routing uses all + // instances instead of firstInstancePerWorker. + // + // Root cause: filterInstancesWhichCanReceiveDataFromRemote() had + // a special branch for BUCKET_SHUFFLE that returned all instances + // (40) as destinations, but BE native local exchange creates only + // 4 receiver tasks (one per BE). Destination mismatch causes rows + // sent to non-existent receivers to be lost. + // + // Trigger conditions: + // - BUCKET_SHUFFLE join plan (requires multi-BE + specific pptn) + // - ignore_storage_data_distribution=true (pooling scan) + // - enable_local_shuffle=true + // - pptn that makes scan serial (scanRanges < pptn * numBE) + // ============================================================ + try { + logger.info("Bug 24: BUCKET_SHUFFLE + pooling scan destination routing") + sql "DROP TABLE IF EXISTS bug24_t1" + sql "DROP TABLE IF EXISTS bug24_t2" + + sql """ + CREATE TABLE bug24_t1 ( + pk INT NOT NULL, + val VARCHAR(64), + INDEX idx_val (val) USING INVERTED + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + CREATE TABLE bug24_t2 ( + pk INT NOT NULL, + val VARCHAR(64), + INDEX idx_val (val) USING INVERTED + ) ENGINE=OLAP + DUPLICATE KEY(pk) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + + // Insert 20 rows into t1, 50 into t2 + for (int i = 1; i <= 20; i++) { + sql "INSERT INTO bug24_t1 VALUES (${i}, 'row_${i}')" + } + for (int i = 1; i <= 50; i++) { + sql "INSERT INTO bug24_t2 VALUES (${i}, 'row_${i}')" + } + + // Baseline: no local shuffle + def bug24_baseline = sql """ + SELECT /*+SET_VAR(enable_local_shuffle=false,enable_sql_cache=false)*/ + count(*) FROM ( + SELECT * FROM bug24_t1 AS t1 + LEFT JOIN (SELECT * FROM bug24_t2) AS t2 ON t1.pk = t2.pk + ORDER BY t2.pk DESC, t1.pk DESC LIMIT 21 + ) t + """ + + // Test with multiple pptn values to catch the specific trigger + for (int pptn : [1, 2, 3, 4, 5, 8, 10]) { + def result = sql """ + SELECT /*+SET_VAR( + parallel_pipeline_task_num=${pptn}, + ignore_storage_data_distribution=true, + enable_local_shuffle=true, + enable_local_shuffle_planner=false, + enable_sql_cache=false + )*/ count(*) FROM ( + SELECT * FROM bug24_t1 AS t1 + LEFT JOIN (SELECT * FROM bug24_t2) AS t2 ON t1.pk = t2.pk + ORDER BY t2.pk DESC, t1.pk DESC LIMIT 21 + ) t + """ + assertEquals(bug24_baseline, result, + "Bug 24 pptn=${pptn} planner=false: BUCKET_SHUFFLE+pooling result mismatch") + + def result2 = sql """ + SELECT /*+SET_VAR( + parallel_pipeline_task_num=${pptn}, + ignore_storage_data_distribution=true, + enable_local_shuffle=true, + enable_local_shuffle_planner=true, + enable_sql_cache=false + )*/ count(*) FROM ( + SELECT * FROM bug24_t1 AS t1 + LEFT JOIN (SELECT * FROM bug24_t2) AS t2 ON t1.pk = t2.pk + ORDER BY t2.pk DESC, t1.pk DESC LIMIT 21 + ) t + """ + assertEquals(bug24_baseline, result2, + "Bug 24 pptn=${pptn} planner=true: BUCKET_SHUFFLE+pooling result mismatch") + } + logger.info("Bug 24: PASSED") + } catch (Throwable t) { + logger.error("Bug 24 FAILED: ${t.message}") + assertTrue(false, "Bug 24: BUCKET_SHUFFLE+pooling destination routing: ${t.message}") + } + + // Bug 25: COLOCATE JOIN + NLJ CROSS JOIN probe side → wrong BUCKET_HASH_SHUFFLE + // isColocated() traverses subtree and returns false when NLJ is in the probe side, + // causing COLOCATE JOIN to fall into generic requireHash() → LOCAL_EXECUTION_HASH_SHUFFLE + // which breaks bucket distribution → result mismatch. + // Fix: use isColocate() directly on the HashJoinNode instead of subtree check. + logger.info("Bug 25: COLOCATE JOIN with NLJ CROSS JOIN probe side") + try { + sql """ + CREATE TABLE IF NOT EXISTS bug25_t20 ( + pk INT, col1 INT + ) DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + sql """ + CREATE TABLE IF NOT EXISTS bug25_t24 ( + pk INT, col1 INT + ) DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + sql """ + CREATE TABLE IF NOT EXISTS bug25_t7 ( + pk INT, col1 INT + ) DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num" = "1") + """ + sql "TRUNCATE TABLE bug25_t20" + sql "TRUNCATE TABLE bug25_t24" + sql "TRUNCATE TABLE bug25_t7" + (1..20).each { i -> sql "INSERT INTO bug25_t20 VALUES ($i, $i)" } + (1..24).each { i -> sql "INSERT INTO bug25_t24 VALUES ($i, $i)" } + (1..7).each { i -> sql "INSERT INTO bug25_t7 VALUES ($i, $i)" } + + def query = """ + WITH cte1 AS ( + SELECT t1.pk FROM bug25_t20 AS t1 CROSS JOIN bug25_t24 AS alias1 + ), + cte2 AS ( + SELECT t1.pk FROM bug25_t20 AS t1 + INNER JOIN bug25_t7 AS alias2 ON t1.pk = alias2.pk + ) + SELECT cte1.pk AS pk1 FROM cte1 + RIGHT OUTER JOIN cte2 AS alias3 ON cte1.pk = alias3.pk + LIMIT 66666666 + """ + + for (int pptn : [0, 1, 2, 4]) { + def feResult = sql """ + /*+SET_VAR(enable_local_shuffle_planner=true, + ignore_storage_data_distribution=true, + parallel_pipeline_task_num=${pptn}, + enable_sql_cache=false)*/ ${query} + """ + def beResult = sql """ + /*+SET_VAR(enable_local_shuffle_planner=false, + ignore_storage_data_distribution=true, + parallel_pipeline_task_num=${pptn}, + enable_sql_cache=false)*/ ${query} + """ + assertEquals(beResult.size(), feResult.size(), + "Bug 25 pptn=${pptn}: FE rows=${feResult.size()}, BE rows=${beResult.size()}") + } + logger.info("Bug 25: PASSED") + } catch (Throwable t) { + logger.error("Bug 25 FAILED: ${t.message}") + assertTrue(false, "Bug 25: COLOCATE+NLJ CROSS probe: ${t.message}") + } + + logger.info("=== All RQG bug reproduction tests completed ===") +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_old_coordinator_local_shuffle.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_old_coordinator_local_shuffle.groovy new file mode 100644 index 00000000000000..79270a58c36cf5 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_old_coordinator_local_shuffle.groovy @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Regression test: old Coordinator + enable_local_shuffle_planner must not hang. + * + * When canUseNereidsDistributePlanner=false (e.g. proxyExecute forwarding), + * FE uses old Coordinator which does not plan local exchange. If + * enable_local_shuffle_planner=true was passed to BE, BE would skip its own + * _plan_local_exchange, leaving no LE at all — causing pooling fragments to + * hang on SHUFFLE_DATA_DEPENDENCY. + * + * Fix: old Coordinator forces enableLocalShufflePlanner=false in query options + * when distributedPlans is null, so BE falls back to native LE planning. + */ +suite("test_old_coordinator_local_shuffle") { + + sql "DROP TABLE IF EXISTS oc_t0" + sql "DROP TABLE IF EXISTS oc_t1" + + sql """ + CREATE TABLE oc_t0 ( + pk INT NULL, + k1 INT NULL, + v1 INT NULL + ) ENGINE=OLAP + UNIQUE KEY(pk, k1) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ( + 'replication_allocation' = 'tag.location.default: 1', + 'enable_unique_key_merge_on_write' = 'true' + ) + """ + + sql """ + CREATE TABLE oc_t1 ( + pk INT NULL, + k1 INT NULL, + v1 INT NULL + ) ENGINE=OLAP + UNIQUE KEY(pk, k1) + DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ( + 'replication_allocation' = 'tag.location.default: 1', + 'enable_unique_key_merge_on_write' = 'true' + ) + """ + + sql "INSERT INTO oc_t0 VALUES (1,1,10),(2,2,20),(3,3,30),(4,4,40),(5,5,50)" + sql "INSERT INTO oc_t1 VALUES (1,1,100),(2,2,200),(3,3,300),(6,6,600),(7,7,700)" + + // Test 1: old Coordinator + default enable_local_shuffle_planner=true + // This simulates the proxyExecute forwarding scenario where + // canUseNereidsDistributePlanner=false. + sql "SET enable_nereids_distribute_planner = false" + sql "SET enable_local_shuffle = true" + sql "SET ignore_storage_data_distribution = true" + sql "SET query_timeout = 30" + + sql """ + MERGE INTO oc_t0 t USING oc_t1 s + ON t.pk = s.pk AND t.k1 = s.k1 + WHEN MATCHED THEN UPDATE SET v1 = s.v1 + WHEN NOT MATCHED THEN INSERT (pk, k1, v1) VALUES (s.pk, s.k1, s.v1) + """ + + def result = sql "SELECT * FROM oc_t0 ORDER BY pk" + assertEquals(7, result.size()) + + // Test 2: INSERT INTO SELECT with FE-planned LE should not double-insert LE. + // NereidsCoordinator has distributedPlans != null, so enableLocalShufflePlanner + // stays true and BE does not add its own LE on top of FE's. + sql "SET enable_nereids_distribute_planner = true" + sql "SET enable_local_shuffle_planner = true" + + sql "TRUNCATE TABLE oc_t0" + sql """ + INSERT INTO oc_t0 + SELECT number, number % 10, number * 100 + FROM numbers('number' = '50') + """ + + def cnt = sql "SELECT COUNT(*) FROM oc_t0" + assertEquals(50, cnt[0][0] as int) +} diff --git a/regression-test/suites/pythonudf_complex_p0/test_python_udaf_complex.groovy b/regression-test/suites/pythonudf_complex_p0/test_python_udaf_complex.groovy index 96ca48f13defbc..344ec21e77d48c 100644 --- a/regression-test/suites/pythonudf_complex_p0/test_python_udaf_complex.groovy +++ b/regression-test/suites/pythonudf_complex_p0/test_python_udaf_complex.groovy @@ -476,7 +476,7 @@ suite("test_python_udaf_complex") { ); """ - qt_json_array_agg """ + order_qt_json_array_agg """ SELECT category, py_json_array_agg(CAST(id AS STRING)) AS id_array FROM udaf_test_data GROUP BY category diff --git a/regression-test/suites/query_p0/join/test_multilevel_join_agg_local_shuffle.groovy b/regression-test/suites/query_p0/join/test_multilevel_join_agg_local_shuffle.groovy new file mode 100644 index 00000000000000..12917553d354fb --- /dev/null +++ b/regression-test/suites/query_p0/join/test_multilevel_join_agg_local_shuffle.groovy @@ -0,0 +1,884 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_multilevel_join_agg_local_shuffle", "nereids_p0") { + sql "DROP TABLE IF EXISTS test_multilevel_join_agg_local_shuffle_a" + sql "DROP TABLE IF EXISTS test_multilevel_join_agg_local_shuffle_b" + sql "DROP TABLE IF EXISTS test_multilevel_join_agg_local_shuffle_c" + sql "DROP TABLE IF EXISTS test_multilevel_join_agg_local_shuffle_d" + + sql "SET enable_nereids_planner=true" + sql "SET enable_fallback_to_original_planner=false" + sql "SET enable_local_shuffle=true" + sql "SET runtime_filter_mode=off" + + sql """ + CREATE TABLE test_multilevel_join_agg_local_shuffle_a ( + k1 INT, + k2 INT, + v1 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k2) + DISTRIBUTED BY HASH(k1) BUCKETS 8 + PROPERTIES ( + "replication_num" = "1" + ) + """ + + sql """ + CREATE TABLE test_multilevel_join_agg_local_shuffle_b ( + k1 INT, + k3 INT, + v2 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k3) + DISTRIBUTED BY HASH(k1) BUCKETS 8 + PROPERTIES ( + "replication_num" = "1" + ) + """ + + sql """ + CREATE TABLE test_multilevel_join_agg_local_shuffle_c ( + k1 INT, + k4 INT, + v3 INT + ) ENGINE=OLAP + DUPLICATE KEY(k1, k4) + DISTRIBUTED BY HASH(k4) BUCKETS 5 + PROPERTIES ( + "replication_num" = "1" + ) + """ + + sql """ + CREATE TABLE test_multilevel_join_agg_local_shuffle_d ( + k1 INT, + flag INT + ) ENGINE=OLAP + DUPLICATE KEY(k1) + DISTRIBUTED BY HASH(k1) BUCKETS 1 + PROPERTIES ( + "replication_num" = "1" + ) + """ + + sql """ + INSERT INTO test_multilevel_join_agg_local_shuffle_a VALUES + (1, 10, 2), + (1, 11, 3), + (2, 20, 4), + (2, 21, 1), + (3, 30, 5), + (4, 40, 6) + """ + + sql """ + INSERT INTO test_multilevel_join_agg_local_shuffle_b VALUES + (1, 100, 7), + (1, 101, 1), + (2, 200, 2), + (3, 300, 3), + (4, 400, 4) + """ + + sql """ + INSERT INTO test_multilevel_join_agg_local_shuffle_c VALUES + (1, 1001, 5), + (1, 1001, 6), + (2, 1002, 7), + (3, 1003, 8), + (4, 1004, 9) + """ + + sql """ + INSERT INTO test_multilevel_join_agg_local_shuffle_d VALUES + (1, 10), + (2, 20), + (3, 30), + (4, 40) + """ + + def checkCase = { String tag, String sqlBody -> + def sqlOn = sqlBody.replace("/*+SET_VAR(", "/*+SET_VAR(enable_local_shuffle_planner=true,") + def sqlOff = sqlBody.replace("/*+SET_VAR(", "/*+SET_VAR(enable_local_shuffle_planner=false,") + + // Plan-shape assertions removed: the FE local-shuffle planner emits its LocalExchange + // nodes *after* the Nereids physical plan, so `explain shape plan` shape is independent + // of enable_local_shuffle_planner. When the shape *does* differ, it's usually a stats- + // dependent rewrite (e.g. cost-based InferSetOperatorDistinct) and the shape check + // becomes flaky across environments. Result-equality + cross-mode equality below give + // us the actual coverage we need. + sql "SET enable_local_shuffle_planner=true" + "order_qt_${tag}_result_on" "${sqlBody}" + + sql "SET enable_local_shuffle_planner=false" + "order_qt_${tag}_result_off" "${sqlBody}" + + check_sql_equal(sqlOn, sqlOff) + } + + def buildAggLayers = { String rawSql, int aggStages -> + String currentSql = rawSql + for (int stage = 1; stage <= aggStages; stage++) { + String alias = "agg_stage_${stage}" + currentSql = """ + SELECT ${alias}.k1, + SUM(${alias}.metric_a) AS metric_a, + SUM(${alias}.metric_b) AS metric_b, + MAX(${alias}.flag_metric) AS flag_metric + FROM ( + ${currentSql} + ) ${alias} + GROUP BY ${alias}.k1 + """ + } + return """ + SELECT final_q.k1, + SUM(final_q.metric_a) AS total_metric_a, + SUM(final_q.metric_b) AS total_metric_b, + MAX(final_q.flag_metric) AS max_flag_metric + FROM ( + ${currentSql} + ) final_q + GROUP BY final_q.k1 + ORDER BY final_q.k1 + """ + } + + def buildAlternatingCase = { String join1, String join2, String join3, String tag -> + String stage1Sql = """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + a.k1, + CAST(SUM(a.v1 + b.v2) AS BIGINT) AS metric_a, + CAST(MAX(a.v1) AS BIGINT) AS metric_b, + CAST(MAX(a.k1) AS BIGINT) AS flag_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN ${join1} test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + """ + + String stage2Sql = """ + SELECT s1.k1, + SUM(s1.metric_a + c.v3) AS metric_a, + SUM(s1.metric_b) AS metric_b, + MAX(s1.flag_metric) AS flag_metric + FROM ( + ${stage1Sql} + ) s1 + JOIN ${join2} ( + SELECT k4 - 1000 AS k1, v3 + FROM test_multilevel_join_agg_local_shuffle_c + ) c + ON s1.k1 = c.k1 + GROUP BY s1.k1 + """ + + String stage3Sql = """ + SELECT s2.k1, + SUM(s2.metric_a) AS metric_a, + SUM(s2.metric_b + d.flag) AS metric_b, + MAX(s2.flag_metric + d.flag) AS flag_metric + FROM ( + ${stage2Sql} + ) s2 + JOIN ${join3} test_multilevel_join_agg_local_shuffle_d d + ON s2.k1 = d.k1 + GROUP BY s2.k1 + """ + + return """ + SELECT final_q.k1, + SUM(final_q.metric_a) AS total_metric_a, + SUM(final_q.metric_b) AS total_metric_b, + MAX(final_q.flag_metric) AS max_flag_metric + FROM ( + ${stage3Sql} + ) final_q + GROUP BY final_q.k1 + ORDER BY final_q.k1 + """ + } + + def joinModeConfigs = [ + [tag: "bucket", hint: ""], + [tag: "shuffle", hint: "[shuffle]"], + [tag: "broadcast", hint: "[broadcast]"] + ] + + def setOpConfigs = [ + [tag: "union_all", body: "SELECT k1, v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a UNION ALL SELECT k1, v2 AS mix_value FROM test_multilevel_join_agg_local_shuffle_b"], + [tag: "except", body: "SELECT k1, v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a EXCEPT SELECT k1, flag AS mix_value FROM test_multilevel_join_agg_local_shuffle_d WHERE k1 = 4"], + [tag: "intersect", body: "SELECT k1, v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a INTERSECT SELECT k1, v2 AS mix_value FROM test_multilevel_join_agg_local_shuffle_b"], + ] + + def buildWindowSetOpCase = { Map setOpCfg, Map joinCfg, String windowTag -> + String windowExpr = windowTag == "row_number" + ? "ROW_NUMBER() OVER (PARTITION BY mid_q.k1 ORDER BY mid_q.mix_value DESC) AS metric_b" + : "SUM(mid_q.mix_value) OVER (PARTITION BY mid_q.k1) AS metric_b" + String metricAExpr = windowTag == "row_number" + ? "SUM(mid_q.mix_value) OVER (PARTITION BY mid_q.k1) AS metric_a" + : "ROW_NUMBER() OVER (PARTITION BY mid_q.k1 ORDER BY mid_q.mix_value DESC) AS metric_a" + String joinRhs = joinCfg.tag == "shuffle" + ? "(SELECT k4 - 1000 AS k1, v3, CAST(v3 AS BIGINT) AS flag_value FROM test_multilevel_join_agg_local_shuffle_c) rhs" + : "test_multilevel_join_agg_local_shuffle_d rhs" + String joinCond = joinCfg.tag == "shuffle" ? "mid_q.k1 = rhs.k1" : "mid_q.k1 = rhs.k1" + String metricCExpr = joinCfg.tag == "shuffle" ? "CAST(rhs.flag_value AS BIGINT)" : "CAST(rhs.flag AS BIGINT)" + return """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + final_q.k1, + SUM(final_q.metric_a) AS total_metric_a, + MAX(final_q.metric_b) AS max_metric_b, + SUM(final_q.metric_c) AS total_metric_c + FROM ( + SELECT mid_q.k1, + ${metricAExpr}, + ${windowExpr}, + ${metricCExpr} AS metric_c + FROM ( + SELECT base_q.k1, base_q.mix_value + FROM ( + ${setOpCfg.body} + ) base_q + ) mid_q + JOIN ${joinCfg.hint} ${joinRhs} + ON ${joinCond} + ) final_q + GROUP BY final_q.k1 + ORDER BY final_q.k1 + """ + } + + def layeredJoinCases = [ + [ + tag: "bucket_shuffle_broadcast", + rawSql: """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + ab.k1, + CAST(ab.bucket_metric AS BIGINT) AS metric_a, + CAST(c1.v3 AS BIGINT) AS metric_b, + d.flag AS flag_metric + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS bucket_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) ab + JOIN [shuffle] ( + SELECT k4 - 1000 AS k1, v3 + FROM test_multilevel_join_agg_local_shuffle_c + ) c1 + ON ab.k1 = c1.k1 + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON ab.k1 = d.k1 + """ + ], + [ + tag: "shuffle_broadcast_broadcast", + rawSql: """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + a.k1, + CAST(a.v1 AS BIGINT) AS metric_a, + CAST(c1.v3 + d1.flag AS BIGINT) AS metric_b, + d2.flag AS flag_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN [shuffle] ( + SELECT k4 - 1000 AS k1, v3 + FROM test_multilevel_join_agg_local_shuffle_c + ) c1 + ON a.k1 = c1.k1 + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d1 + ON a.k1 = d1.k1 + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d2 + ON a.k1 = d2.k1 + """ + ], + [ + tag: "bucket_broadcast_shuffle", + rawSql: """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + abd.k1, + CAST(abd.bucket_metric AS BIGINT) AS metric_a, + CAST(c1.shuffle_metric AS BIGINT) AS metric_b, + abd.flag_metric AS flag_metric + FROM ( + SELECT ab.k1, + SUM(ab.bucket_metric) AS bucket_metric, + MAX(d.flag) AS flag_metric + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS bucket_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) ab + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON ab.k1 = d.k1 + GROUP BY ab.k1 + ) abd + JOIN [shuffle] ( + SELECT k4 - 1000 AS k1, SUM(v3) AS shuffle_metric + FROM test_multilevel_join_agg_local_shuffle_c + GROUP BY k4 - 1000 + ) c1 + ON abd.k1 = c1.k1 + """ + ] + ] + + layeredJoinCases.each { cfg -> + (1..3).each { aggStage -> + checkCase("${cfg.tag}_agg_stage_${aggStage}", buildAggLayers(cfg.rawSql, aggStage)) + } + } + + joinModeConfigs.each { firstJoin -> + joinModeConfigs.each { secondJoin -> + joinModeConfigs.each { thirdJoin -> + checkCase( + "alternating_${firstJoin.tag}_${secondJoin.tag}_${thirdJoin.tag}", + buildAlternatingCase(firstJoin.hint, secondJoin.hint, thirdJoin.hint, + "alternating_${firstJoin.tag}_${secondJoin.tag}_${thirdJoin.tag}")) + } + } + } + + setOpConfigs.each { setOpCfg -> + joinModeConfigs.each { joinCfg -> + ["row_number", "window_sum"].each { windowTag -> + checkCase( + "window_${setOpCfg.tag}_${joinCfg.tag}_${windowTag}", + buildWindowSetOpCase(setOpCfg, joinCfg, windowTag)) + } + } + } + + checkCase("bucket_broadcast_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + x.k1, SUM(x.bucket_sum) AS total_sum, MAX(d.flag) AS max_flag + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS bucket_sum + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) x + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON x.k1 = d.k1 + GROUP BY x.k1 + ORDER BY x.k1 + """) + + checkCase("partitioned_broadcast_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + x.k1, SUM(x.shuffle_sum) AS total_sum, MAX(d.flag) AS max_flag + FROM ( + SELECT a.k1, SUM(a.v1 + c.v3) AS shuffle_sum + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON a.k1 + 1000 = c.k4 + GROUP BY a.k1 + ) x + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON x.k1 = d.k1 + GROUP BY x.k1 + ORDER BY x.k1 + """) + + checkCase("bucket_partitioned_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + t.k1, SUM(t.metric1) AS total_metric1, MAX(t.metric2) AS max_metric2 + FROM ( + SELECT a.k1, + SUM(a.v1 + b.v2) AS metric1, + SUM(c.v3) AS metric2 + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON b.k1 + 1000 = c.k4 + GROUP BY a.k1 + ) t + GROUP BY t.k1 + ORDER BY t.k1 + """) + + checkCase("all_three_multilevel_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + z.k1, SUM(z.metric) AS total_metric, MAX(z.flag) AS max_flag + FROM ( + SELECT y.k1, + SUM(y.metric) AS metric, + MAX(d.flag) AS flag + FROM ( + SELECT a.k1, + SUM(a.v1 + b.v2 + c.v3) AS metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON b.k1 + 1000 = c.k4 + GROUP BY a.k1 + ) y + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON y.k1 = d.k1 + GROUP BY y.k1 + ) z + GROUP BY z.k1 + ORDER BY z.k1 + """) + + checkCase("agg_join_agg_mix", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + l.k1, l.sa, r.sb, MAX(d.flag) AS max_flag + FROM ( + SELECT a.k1, SUM(a.v1) AS sa + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) l + JOIN [shuffle] ( + SELECT c.k4 - 1000 AS k1, SUM(c.v3) AS sb + FROM test_multilevel_join_agg_local_shuffle_c c + GROUP BY c.k4 - 1000 + ) r + ON l.k1 = r.k1 + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON l.k1 = d.k1 + GROUP BY l.k1, l.sa, r.sb + ORDER BY l.k1 + """) + + checkCase("double_broadcast_after_bucket", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + z.k1, SUM(z.metric) AS total_metric, MAX(z.flag_sum) AS max_flag_sum + FROM ( + SELECT x.k1, + SUM(x.bucket_sum) AS metric, + MAX(d1.flag + d2.flag) AS flag_sum + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS bucket_sum + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) x + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d1 + ON x.k1 = d1.k1 + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d2 + ON x.k1 = d2.k1 + GROUP BY x.k1 + ) z + GROUP BY z.k1 + ORDER BY z.k1 + """) + + checkCase("partitioned_join_between_two_aggs_then_broadcast", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + m.k1, SUM(m.left_sum + m.right_sum) AS total_metric, MAX(d.flag) AS max_flag + FROM ( + SELECT l.k1, l.left_sum, r.right_sum + FROM ( + SELECT a.k1, SUM(a.v1) AS left_sum + FROM test_multilevel_join_agg_local_shuffle_a a + GROUP BY a.k1 + ) l + JOIN [shuffle] ( + SELECT c.k4 - 1000 AS k1, SUM(c.v3) AS right_sum + FROM test_multilevel_join_agg_local_shuffle_c c + GROUP BY c.k4 - 1000 + ) r + ON l.k1 = r.k1 + ) m + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON m.k1 = d.k1 + GROUP BY m.k1 + ORDER BY m.k1 + """) + + checkCase("bucket_shuffle_broadcast_two_stage_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + q.k1, SUM(q.metric_a) AS total_a, SUM(q.metric_b) AS total_b, MAX(q.flag) AS max_flag + FROM ( + SELECT y.k1, + SUM(y.metric_a) AS metric_a, + SUM(y.metric_b) AS metric_b, + MAX(d.flag) AS flag + FROM ( + SELECT a.k1, + SUM(a.v1 + b.v2) AS metric_a, + SUM(c.v3) AS metric_b + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON a.k1 + 1000 = c.k4 + GROUP BY a.k1 + ) y + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON y.k1 = d.k1 + GROUP BY y.k1 + ) q + GROUP BY q.k1 + ORDER BY q.k1 + """) + + checkCase("left_join_null_preserving_with_multilevel_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + s.k1, SUM(s.left_metric) AS total_left_metric, SUM(s.right_metric) AS total_right_metric + FROM ( + SELECT a.k1, + SUM(a.v1) AS left_metric, + SUM(IFNULL(t.right_metric, 0)) AS right_metric + FROM test_multilevel_join_agg_local_shuffle_a a + LEFT JOIN ( + SELECT b.k1, SUM(c.v3) AS right_metric + FROM test_multilevel_join_agg_local_shuffle_b b + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON b.k1 + 1000 = c.k4 + GROUP BY b.k1 + ) t + ON a.k1 = t.k1 + GROUP BY a.k1 + ) s + GROUP BY s.k1 + ORDER BY s.k1 + """) + + checkCase("seven_layer_bucket_shuffle_broadcast", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + o.k1, SUM(o.final_metric) AS total_metric, MAX(o.final_flag) AS max_flag + FROM ( + SELECT n.k1, + SUM(n.stage5_metric + n.stage6_metric) AS final_metric, + MAX(n.stage7_flag) AS final_flag + FROM ( + SELECT m.k1, + SUM(m.stage3_metric) AS stage5_metric, + SUM(m.stage4_metric) AS stage6_metric, + MAX(d2.flag) AS stage7_flag + FROM ( + SELECT l.k1, + SUM(l.stage1_metric) AS stage3_metric, + SUM(l.stage2_metric) AS stage4_metric + FROM ( + SELECT x.k1, + SUM(x.bucket_metric) AS stage1_metric, + SUM(y.shuffle_metric) AS stage2_metric + FROM ( + SELECT a.k1, SUM(a.v1 + b.v2) AS bucket_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) x + JOIN [shuffle] ( + SELECT c.k4 - 1000 AS k1, SUM(c.v3) AS shuffle_metric + FROM test_multilevel_join_agg_local_shuffle_c c + GROUP BY c.k4 - 1000 + ) y + ON x.k1 = y.k1 + GROUP BY x.k1 + ) l + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d1 + ON l.k1 = d1.k1 + GROUP BY l.k1 + ) m + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d2 + ON m.k1 = d2.k1 + GROUP BY m.k1 + ) n + GROUP BY n.k1 + ) o + GROUP BY o.k1 + ORDER BY o.k1 + """) + + checkCase("eight_layer_mixed_join_agg_chain", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + q.k1, SUM(q.metric_a) AS total_a, SUM(q.metric_b) AS total_b, MAX(q.metric_c) AS max_c + FROM ( + SELECT p.k1, + SUM(p.stage6_a) AS metric_a, + SUM(p.stage7_b) AS metric_b, + MAX(p.stage8_c) AS metric_c + FROM ( + SELECT n.k1, + SUM(n.stage4_a) AS stage6_a, + SUM(n.stage5_b) AS stage7_b, + MAX(d2.flag + n.stage5_c) AS stage8_c + FROM ( + SELECT m.k1, + SUM(m.stage2_a) AS stage4_a, + SUM(m.stage3_b) AS stage5_b, + MAX(d1.flag) AS stage5_c + FROM ( + SELECT l.k1, + SUM(l.bucket_metric) AS stage2_a, + SUM(l.shuffle_metric) AS stage3_b + FROM ( + SELECT a.k1, + SUM(a.v1 + b.v2) AS bucket_metric, + SUM(c.v3) AS shuffle_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON b.k1 + 1000 = c.k4 + GROUP BY a.k1 + ) l + GROUP BY l.k1 + ) m + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d1 + ON m.k1 = d1.k1 + GROUP BY m.k1 + ) n + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d2 + ON n.k1 = d2.k1 + GROUP BY n.k1 + ) p + GROUP BY p.k1 + ) q + GROUP BY q.k1 + ORDER BY q.k1 + """) + + checkCase("seven_layer_left_join_mix", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + z.k1, SUM(z.left_total) AS total_left, SUM(z.right_total) AS total_right, MAX(z.flag_metric) AS max_flag_metric + FROM ( + SELECT y.k1, + SUM(y.stage5_left) AS left_total, + SUM(y.stage6_right) AS right_total, + MAX(y.stage7_flag) AS flag_metric + FROM ( + SELECT x.k1, + SUM(x.stage3_left) AS stage5_left, + SUM(IFNULL(x.stage4_right, 0)) AS stage6_right, + MAX(d.flag) AS stage7_flag + FROM ( + SELECT l.k1, + SUM(l.stage1_left) AS stage3_left, + SUM(IFNULL(r.stage2_right, 0)) AS stage4_right + FROM ( + SELECT a.k1, SUM(a.v1) AS stage1_left + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) l + LEFT JOIN ( + SELECT c.k4 - 1000 AS k1, SUM(c.v3) AS stage2_right + FROM test_multilevel_join_agg_local_shuffle_c c + GROUP BY c.k4 - 1000 + ) r + ON l.k1 = r.k1 + GROUP BY l.k1 + ) x + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON x.k1 = d.k1 + GROUP BY x.k1 + ) y + GROUP BY y.k1 + ) z + GROUP BY z.k1 + ORDER BY z.k1 + """) + + checkCase("broadcast_shuffle_broadcast_nested_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + z.k1, + SUM(z.m1) AS total_m1, + SUM(z.m2) AS total_m2, + MAX(z.m3) AS max_m3 + FROM ( + SELECT y.k1, + SUM(y.s3) AS m1, + SUM(y.s4) AS m2, + MAX(y.s5) AS m3 + FROM ( + SELECT x.k1, + SUM(x.s1) AS s3, + SUM(x.s2) AS s4, + MAX(d1.flag) AS s5 + FROM ( + SELECT l.k1, + SUM(l.left_metric) AS s1, + SUM(r.right_metric) AS s2 + FROM ( + SELECT a.k1, + SUM(a.v1 + b.v2) AS left_metric + FROM test_multilevel_join_agg_local_shuffle_a a + JOIN test_multilevel_join_agg_local_shuffle_b b + ON a.k1 = b.k1 + GROUP BY a.k1 + ) l + JOIN [shuffle] ( + SELECT c.k4 - 1000 AS k1, + SUM(c.v3) AS right_metric + FROM test_multilevel_join_agg_local_shuffle_c c + GROUP BY c.k4 - 1000 + ) r + ON l.k1 = r.k1 + GROUP BY l.k1 + ) x + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d1 + ON x.k1 = d1.k1 + GROUP BY x.k1 + ) y + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d2 + ON y.k1 = d2.k1 + GROUP BY y.k1 + ) z + GROUP BY z.k1 + ORDER BY z.k1 + """) + + checkCase("window_union_join_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + q.k1, + SUM(q.metric_a) AS total_metric_a, + MAX(q.metric_b) AS max_metric_b + FROM ( + SELECT w.k1, + SUM(w.mix_value) AS metric_a, + MAX(d.flag + w.rn) AS metric_b + FROM ( + SELECT u.k1, + u.mix_value, + ROW_NUMBER() OVER (PARTITION BY u.k1 ORDER BY u.mix_value DESC) AS rn + FROM ( + SELECT a.k1, a.v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a a + UNION ALL + SELECT b.k1, b.v2 AS mix_value FROM test_multilevel_join_agg_local_shuffle_b b + ) u + ) w + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON w.k1 = d.k1 + GROUP BY w.k1 + ) q + GROUP BY q.k1 + ORDER BY q.k1 + """) + + checkCase("window_except_join_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + t.k1, + SUM(t.rn) AS total_rn, + MAX(t.flag_metric) AS max_flag_metric + FROM ( + SELECT e.k1, + ROW_NUMBER() OVER (PARTITION BY e.k1 ORDER BY e.k1) AS rn, + MAX(d.flag) OVER (PARTITION BY e.k1) AS flag_metric + FROM ( + SELECT k1 FROM test_multilevel_join_agg_local_shuffle_a + EXCEPT + SELECT k1 FROM test_multilevel_join_agg_local_shuffle_c WHERE k4 = 1004 + ) e + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON e.k1 = d.k1 + ) t + GROUP BY t.k1 + ORDER BY t.k1 + """) + + checkCase("window_intersect_shuffle_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + x.k1, + SUM(x.metric_a) AS total_metric_a, + MAX(x.metric_b) AS max_metric_b + FROM ( + SELECT i.k1, + SUM(c.v3) OVER (PARTITION BY i.k1) AS metric_a, + ROW_NUMBER() OVER (PARTITION BY i.k1 ORDER BY c.v3 DESC) AS metric_b + FROM ( + SELECT k1 FROM test_multilevel_join_agg_local_shuffle_a + INTERSECT + SELECT k1 FROM test_multilevel_join_agg_local_shuffle_b + ) i + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON i.k1 + 1000 = c.k4 + ) x + GROUP BY x.k1 + ORDER BY x.k1 + """) + + checkCase("window_union_except_broadcast_agg", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + final_q.k1, + SUM(final_q.metric_a) AS total_metric_a, + SUM(final_q.metric_b) AS total_metric_b, + MAX(final_q.metric_c) AS max_metric_c + FROM ( + SELECT s.k1, + SUM(s.mix_value) AS metric_a, + MAX(s.rn) AS metric_b, + MAX(d.flag) AS metric_c + FROM ( + SELECT u.k1, + u.mix_value, + ROW_NUMBER() OVER (PARTITION BY u.k1 ORDER BY u.mix_value DESC) AS rn + FROM ( + SELECT k1, v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a + UNION ALL + SELECT k1, v2 AS mix_value FROM test_multilevel_join_agg_local_shuffle_b + EXCEPT + SELECT k1, flag AS mix_value FROM test_multilevel_join_agg_local_shuffle_d WHERE k1 = 4 + ) u + ) s + JOIN [broadcast] test_multilevel_join_agg_local_shuffle_d d + ON s.k1 = d.k1 + GROUP BY s.k1 + ) final_q + GROUP BY final_q.k1 + ORDER BY final_q.k1 + """) + + checkCase("window_setop_join_agg_chain", """ + SELECT /*+SET_VAR(disable_join_reorder=true,disable_colocate_plan=true,ignore_storage_data_distribution=false,parallel_pipeline_task_num=4,auto_broadcast_join_threshold=-1,broadcast_row_count_limit=0) */ + outer_q.k1, + SUM(outer_q.metric_a) AS total_metric_a, + MAX(outer_q.metric_b) AS max_metric_b, + SUM(outer_q.metric_c) AS total_metric_c + FROM ( + SELECT mid_q.k1, + SUM(mid_q.window_metric) AS metric_a, + MAX(mid_q.window_rank) AS metric_b, + SUM(c.v3) AS metric_c + FROM ( + SELECT base_q.k1, + SUM(base_q.mix_value) OVER (PARTITION BY base_q.k1) AS window_metric, + ROW_NUMBER() OVER (PARTITION BY base_q.k1 ORDER BY base_q.mix_value DESC) AS window_rank + FROM ( + SELECT k1, v1 AS mix_value FROM test_multilevel_join_agg_local_shuffle_a + UNION ALL + SELECT k1, v2 AS mix_value FROM test_multilevel_join_agg_local_shuffle_b + INTERSECT + SELECT k1, v3 AS mix_value FROM test_multilevel_join_agg_local_shuffle_c WHERE k4 >= 1001 + ) base_q + ) mid_q + JOIN [shuffle] test_multilevel_join_agg_local_shuffle_c c + ON mid_q.k1 + 1000 = c.k4 + GROUP BY mid_q.k1 + ) outer_q + GROUP BY outer_q.k1 + ORDER BY outer_q.k1 + """) +} From ed70649510aa0a044e00386b8332a5b6be08e326 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Tue, 30 Jun 2026 10:33:27 +0800 Subject: [PATCH 3/9] branch-4.2: [opt](local shuffle) bucket-shuffle dest spreading + bucket-to-hash parallelism upgrade #64793 Cherry-picked from #64793 --- be/src/exec/exchange/vdata_stream_recvr.h | 15 +- .../operator/exchange_source_operator.cpp | 13 +- .../exec/operator/exchange_source_operator.h | 36 ++++ .../pipeline/pipeline_fragment_context.cpp | 10 +- be/src/runtime/runtime_state.cpp | 3 +- .../translator/PlanTranslatorContext.java | 31 +++ .../ChildrenPropertiesRegulator.java | 9 +- .../plans/distribute/DistributePlanner.java | 57 +++++- .../doris/planner/AddLocalExchange.java | 105 ++++++++++ .../apache/doris/planner/ExchangeNode.java | 7 + .../apache/doris/planner/HashJoinNode.java | 96 +++++++-- .../apache/doris/planner/RuntimeFilter.java | 37 ++++ .../org/apache/doris/qe/SessionVariable.java | 40 +++- .../planner/LocalShuffleNodeCoverageTest.java | 160 ++++++++++++++- gensrc/thrift/PlanNodes.thrift | 6 + .../test_local_shuffle_bucket_upgrade.groovy | 193 ++++++++++++++++++ .../test_local_shuffle_rqg_bugs.groovy | 75 +++++-- 17 files changed, 839 insertions(+), 54 deletions(-) create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_bucket_upgrade.groovy diff --git a/be/src/exec/exchange/vdata_stream_recvr.h b/be/src/exec/exchange/vdata_stream_recvr.h index c465e8b597cbde..7ae485bc249f67 100644 --- a/be/src/exec/exchange/vdata_stream_recvr.h +++ b/be/src/exec/exchange/vdata_stream_recvr.h @@ -194,7 +194,20 @@ class VDataStreamRecvr::SenderQueue { void close(); - void set_dependency(std::shared_ptr dependency) { _source_dependency = dependency; } + void set_dependency(std::shared_ptr dependency) { + // Assign under _lock: set_source_ready() (reached via decrement_senders/cancel/close + // on other threads) reads _source_dependency while holding _lock, so a lock-free + // shared_ptr assignment here would race with that read. + std::lock_guard l(_lock); + _source_dependency = dependency; + // A queue created with zero senders (bucket-shuffle orphan instance, see + // ExchangeLocalState::create_stream_recvr) never goes through decrement_senders, + // so the usual reached-zero set_ready never fires — mark it ready at wiring time + // or its task blocks forever on SHUFFLE_DATA_DEPENDENCY. + if (_num_remaining_senders == 0) { + set_source_ready(l); + } + } protected: void add_blocks_memory_usage(int64_t size); diff --git a/be/src/exec/operator/exchange_source_operator.cpp b/be/src/exec/operator/exchange_source_operator.cpp index c3cf61fd461153..658bf1f0ed8517 100644 --- a/be/src/exec/operator/exchange_source_operator.cpp +++ b/be/src/exec/operator/exchange_source_operator.cpp @@ -65,9 +65,17 @@ std::string ExchangeSourceOperatorX::debug_string(int indentation_level) const { void ExchangeLocalState::create_stream_recvr(RuntimeState* state) { auto& p = _parent->cast(); + int num_senders = p.num_senders(); + if (p.is_bucket_shuffle_orphan_instance(local_task_idx)) { + // Bucket-routed senders open one channel per destination entry (one per bucket), + // so an instance owning no bucket never gets a channel — and never gets EOS. + // Start its receiver with zero senders so it reports EOS immediately instead of + // blocking forever (K-of-N destination spread). + num_senders = 0; + } stream_recvr = state->exec_env()->vstream_mgr()->create_recvr( - state, _memory_used_counter, state->fragment_instance_id(), p.node_id(), - p.num_senders(), custom_profile(), p.is_merging(), + state, _memory_used_counter, state->fragment_instance_id(), p.node_id(), num_senders, + custom_profile(), p.is_merging(), std::max(20480, config::exchg_node_buffer_size_bytes / (p.is_merging() ? p.num_senders() : 1))); } @@ -76,6 +84,7 @@ Status ExchangeLocalState::init(RuntimeState* state, LocalStateInfo& info) { RETURN_IF_ERROR(Base::init(state, info)); SCOPED_TIMER(exec_time_counter()); SCOPED_TIMER(_init_timer); + local_task_idx = info.task_idx; create_stream_recvr(state); const auto& queues = stream_recvr->sender_queues(); deps.resize(queues.size()); diff --git a/be/src/exec/operator/exchange_source_operator.h b/be/src/exec/operator/exchange_source_operator.h index 767bbc192fd0f2..317263ed093db4 100644 --- a/be/src/exec/operator/exchange_source_operator.h +++ b/be/src/exec/operator/exchange_source_operator.h @@ -19,6 +19,9 @@ #include +#include +#include + #include "exec/operator/operator.h" #include "exprs/vexpr_fwd.h" @@ -76,6 +79,9 @@ class ExchangeLocalState : public PipelineXLocalState<> { doris::VExprContextSPtrs ordering_expr_ctxs; int64_t num_rows_skipped; bool is_ready; + // per-BE local instance index (LocalStateInfo::task_idx), used for bucket-shuffle + // orphan detection in create_stream_recvr — see is_bucket_shuffle_orphan_instance. + int local_task_idx = 0; std::vector> deps; @@ -111,6 +117,34 @@ class ExchangeSourceOperatorX final : public OperatorX { [[nodiscard]] int num_senders() const { return _num_senders; } [[nodiscard]] bool is_merging() const { return _is_merging; } + // Instances that bucket-routed senders can address: values of the fragment's + // bucket_seq_to_instance_idx map. Senders open one channel per destination entry + // (one per bucket), so an instance owning no bucket never gets a channel — and + // never gets EOS. Such orphan instances must start their receiver with zero + // senders or they block forever (K-of-N destination spread). + void set_bucket_dest_instances(const std::map& bucket_seq_to_instance_idx) { + for (const auto& [bucket_seq, instance_idx] : bucket_seq_to_instance_idx) { + _bucket_dest_instances.insert(instance_idx); + } + _has_bucket_dest_instances = true; + } + + // local_task_idx is the per-BE local instance index (LocalStateInfo::task_idx) — the + // same numbering as bucket_seq_to_instance_idx values (built per worker on FE). Do NOT + // pass per_fragment_instance_idx here: that is sender_id = the GLOBAL index across all + // workers, which only coincides with the local index on the first worker (single-BE + // tests pass, multi-BE silently drops every later worker's buckets). + // + // Ownership-based orphan detection is only valid when destinations follow bucket + // ownership, i.e. the non-serial (FE planner dest spread) mode. A serial exchange's + // destinations funnel to the first instance per worker regardless of bucket ownership, + // and BE's serial-exchange mechanics already close the other receivers. + [[nodiscard]] bool is_bucket_shuffle_orphan_instance(int local_task_idx) const { + return !is_serial_operator() && + _partition_type == TPartitionType::BUCKET_SHFFULE_HASH_PARTITIONED && + _has_bucket_dest_instances && !_bucket_dest_instances.contains(local_task_idx); + } + DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { if (OperatorX::is_serial_operator()) { return {TLocalPartitionType::NOOP}; @@ -127,6 +161,8 @@ class ExchangeSourceOperatorX final : public OperatorX { const int _num_senders; const bool _is_merging; const TPartitionType::type _partition_type; + std::set _bucket_dest_instances; + bool _has_bucket_dest_instances = false; // use in merge sort size_t _offset; diff --git a/be/src/exec/pipeline/pipeline_fragment_context.cpp b/be/src/exec/pipeline/pipeline_fragment_context.cpp index 18e278eaa036c9..bccb92fbf5dddd 100644 --- a/be/src/exec/pipeline/pipeline_fragment_context.cpp +++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp @@ -1578,8 +1578,14 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo ? _params.per_exch_num_senders.find(tnode.node_id)->second : 0; DCHECK_GT(num_senders, 0); - op = std::make_shared(pool, tnode, next_operator_id(), descs, - num_senders); + auto exchange_op = std::make_shared( + pool, tnode, next_operator_id(), descs, num_senders); + if (!_params.bucket_seq_to_instance_idx.empty()) { + // Lets bucket-routed exchanges detect orphan instances (owning no bucket) that + // no sender channel will ever address — their receivers must start at EOS. + exchange_op->set_bucket_dest_instances(_params.bucket_seq_to_instance_idx); + } + op = exchange_op; RETURN_IF_ERROR(cur_pipe->add_operator(op, _parallel_instances)); fe_with_old_version = !tnode.__isset.is_serial_operator; break; diff --git a/be/src/runtime/runtime_state.cpp b/be/src/runtime/runtime_state.cpp index 0054dd0aa824d7..0616eeda9b1471 100644 --- a/be/src/runtime/runtime_state.cpp +++ b/be/src/runtime/runtime_state.cpp @@ -574,7 +574,8 @@ Status RuntimeState::register_consumer_runtime_filter( const TRuntimeFilterDesc& desc, bool need_local_merge, int node_id, std::shared_ptr* consumer_filter) { _registered_runtime_filter_ids.insert(desc.filter_id); - bool need_merge = desc.has_remote_targets || need_local_merge; + bool need_merge = desc.has_remote_targets || need_local_merge || + (desc.__isset.force_local_merge && desc.force_local_merge); RuntimeFilterMgr* mgr = need_merge ? global_runtime_filter_mgr() : local_runtime_filter_mgr(); RETURN_IF_ERROR(mgr->register_consumer_filter(this, desc, node_id, consumer_filter)); // Stamp the consumer with the current recursive CTE stage so that incoming publish RPCs diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java index d7624c918192c7..31fd67585b8b34 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PlanTranslatorContext.java @@ -137,6 +137,21 @@ public class PlanTranslatorContext { // needs shuffle for correctness, not just for performance like StreamingAgg pre-agg). private final Map shuffledAncestorMap = Maps.newHashMap(); + // Whether the fragment currently being processed by AddLocalExchange is eligible for the + // bucket → local-hash parallelism upgrade: a pooled bucket-join fragment whose per-BE + // instance count exceeds (buckets-with-data per BE) × local_shuffle_bucket_upgrade_ratio. + // Computed once per fragment in AddLocalExchange.addLocalExchange from the distributed + // plan's LocalShuffleBucketJoinAssignedJob assignments; read by + // HashJoinNode.enforceAndDeriveLocalExchange. + private boolean currentFragmentBucketUpgradeEligible = false; + + // Per-node "a bucket join above me in this fragment already upgraded to local hash" flag. + // An upgraded join marks its direct children so a stacked bucket join below keeps its + // BUCKET_HASH_SHUFFLE requires: if it also upgraded, its LOCAL hash output (keyed by ITS + // join keys) would type-satisfy the upper join's requireSpecific(LOCAL_EXECUTION_HASH) + // and suppress the LE that re-aligns data to the upper join's keys → wrong results. + private final Map bucketUpgradedAncestorMap = Maps.newHashMap(); + // Whether the current fragment uses LocalShuffleAssignedJob (pooling scan with // ignoreDataDistribution → _parallel_instances=1 in BE). When true, serial operators // indicate real pipeline bottlenecks needing PASSTHROUGH fan-out (heavy_ops). @@ -285,6 +300,22 @@ public boolean hasShuffleForCorrectnessAncestor(PlanNode node) { return shuffledAncestorMap.getOrDefault(node.getId(), false); } + public void setCurrentFragmentBucketUpgradeEligible(boolean eligible) { + this.currentFragmentBucketUpgradeEligible = eligible; + } + + public boolean isCurrentFragmentBucketUpgradeEligible() { + return currentFragmentBucketUpgradeEligible; + } + + public void setHasBucketUpgradedAncestor(PlanNode node, boolean value) { + bucketUpgradedAncestorMap.put(node.getId(), value); + } + + public boolean hasBucketUpgradedAncestor(PlanNode node) { + return bucketUpgradedAncestorMap.getOrDefault(node.getId(), false); + } + public SlotDescriptor addSlotDesc(TupleDescriptor t) { return descTable.addSlotDescriptor(t); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java index 43aa2d928308ee..18e38d1abe41b0 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java @@ -58,6 +58,8 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.util.ArrayList; import java.util.List; @@ -304,9 +306,10 @@ private boolean isBucketShuffleDownGrade(Plan oneSidePlan) { int bucketNum = candidate.getTable().getDefaultDistributionInfo().getBucketNum(); int totalBucketNum = prunedPartNum * bucketNum; ConnectContext connectContext = ConnectContext.get(); - // <= 0 disables the downgrade entirely, so a test or a tuning session can keep - // bucket shuffle (the anchored side needs no re-shuffle) regardless of how many - // instances the cluster has. + // <= 0 disables the downgrade entirely: with the FE local shuffle planner's + // bucket -> local-hash upgrade (local_shuffle_bucket_upgrade_ratio), few-bucket + // bucket shuffle no longer funnels, so keeping bucket shuffle (anchored side + // needs no re-shuffle) can beat downgrading to shuffle join. double downgradeRatio = connectContext.getSessionVariable().getBucketShuffleDowngradeRatio(); if (downgradeRatio <= 0) { return false; diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java index 94b65bdd0abc8d..60dc4dac643374 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java @@ -31,6 +31,7 @@ import org.apache.doris.nereids.trees.plans.distribute.worker.job.BucketScanSource; import org.apache.doris.nereids.trees.plans.distribute.worker.job.DefaultScanSource; import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleAssignedJob; +import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleBucketJoinAssignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.StaticAssignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.UnassignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.UnassignedJobBuilder; @@ -216,7 +217,7 @@ private void linkPipelinePlan( List receiverInstances = filterInstancesWhichCanReceiveDataFromRemote( receiverPlan, enableShareHashTableForBroadcastJoin, linkNode); if (linkNode.getPartitionType() == TPartitionType.BUCKET_SHFFULE_HASH_PARTITIONED) { - receiverInstances = getDestinationsByBuckets(receiverPlan, receiverInstances); + receiverInstances = getDestinationsByBuckets(receiverPlan, receiverInstances, linkNode); } DataSink sink = senderPlan.getFragmentJob().getFragment().getSink(); @@ -236,12 +237,34 @@ private void linkPipelinePlan( private List getDestinationsByBuckets( PipelineDistributedPlan joinSide, - List receiverInstances) { + List receiverInstances, + ExchangeNode linkNode) { UnassignedScanBucketOlapTableJob bucketJob = (UnassignedScanBucketOlapTableJob) joinSide.getFragmentJob(); int bucketNum = bucketJob.getOlapScanNodes().get(0).getBucketNum(); + // The spread is only valid for a NON-serial exchange: a serial exchange + // (use_serial_exchange / UNPARTITIONED) receives through one task per worker and + // expects funnel destinations; spreading them loses every row addressed to a + // non-first instance. Mirrors the !is_serial_operator() gate on the BE orphan + // receiver fix. + if (isEnableLocalShufflePlanner() + && !linkNode.isSerialOperatorOnBe(statementContext.getConnectContext()) + && !joinSide.getInstanceJobs().isEmpty() + && joinSide.getInstanceJobs().stream() + .allMatch(LocalShuffleBucketJoinAssignedJob.class::isInstance)) { + // When FE local shuffle planner is on, spread bucket destinations across all pooled + // instances by their assigned join buckets — the same bucket -> instance mapping as + // bucket_seq_to_instance_id sent to BE — instead of funneling every bucket of a worker + // into its first instance and relying on BE local exchange to fan out. + return sortDestinationInstancesByJoinBuckets(joinSide, bucketNum); + } return sortDestinationInstancesByBuckets(joinSide, receiverInstances, bucketNum); } + private boolean isEnableLocalShufflePlanner() { + ConnectContext connectContext = statementContext.getConnectContext(); + return connectContext != null && connectContext.getSessionVariable().isEnableLocalShufflePlanner(); + } + private List filterInstancesWhichCanReceiveDataFromRemote( PipelineDistributedPlan receiverPlan, boolean enableShareHashTableForBroadcastJoin, @@ -257,6 +280,36 @@ private List filterInstancesWhichCanReceiveDataFromRemote( } } + private List sortDestinationInstancesByJoinBuckets( + PipelineDistributedPlan plan, int bucketNum) { + AssignedJob[] instances = new AssignedJob[bucketNum]; + for (AssignedJob instanceJob : plan.getInstanceJobs()) { + LocalShuffleBucketJoinAssignedJob localShuffleJob = (LocalShuffleBucketJoinAssignedJob) instanceJob; + for (Integer bucketIndex : localShuffleJob.getAssignedJoinBucketIndexes()) { + if (instances[bucketIndex] != null) { + throw new IllegalStateException( + "Multi instances assigned same join bucket: " + instances[bucketIndex] + + " and " + instanceJob + ); + } + instances[bucketIndex] = instanceJob; + } + } + + for (int i = 0; i < instances.length; i++) { + if (instances[i] == null) { + instances[i] = new StaticAssignedJob( + i, + new TUniqueId(-1, -1), + plan.getFragmentJob(), + DummyWorker.INSTANCE, + new DefaultScanSource(ImmutableMap.of()) + ); + } + } + return Arrays.asList(instances); + } + private List sortDestinationInstancesByBuckets( PipelineDistributedPlan plan, List unsorted, int bucketNum) { AssignedJob[] instances = new AssignedJob[bucketNum]; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java index e1d607ea61551d..1adea56c13e66d 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AddLocalExchange.java @@ -22,9 +22,18 @@ import org.apache.doris.nereids.trees.plans.distribute.DistributedPlan; import org.apache.doris.nereids.trees.plans.distribute.FragmentIdMapping; import org.apache.doris.nereids.trees.plans.distribute.PipelineDistributedPlan; +import org.apache.doris.nereids.trees.plans.distribute.worker.job.AssignedJob; +import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleBucketJoinAssignedJob; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; import org.apache.doris.planner.LocalExchangeNode.RequireHash; +import org.apache.doris.qe.ConnectContext; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; /** * FE-side local exchange planner — inserts {@link LocalExchangeNode} into each fragment's @@ -81,11 +90,107 @@ public void addLocalExchange(FragmentIdMapping distributedPlans if (maxPerBeInstances <= 1) { continue; } + context.setCurrentFragmentBucketUpgradeEligible( + isBucketUpgradeEligible(pipePlan, maxPerBeInstances, context)); PlanFragment fragment = pipePlan.getFragmentJob().getFragment(); addLocalExchangeForFragment(fragment, context); } } + /** + * Bucket → local-hash parallelism upgrade eligibility . + * + * A pooled bucket-join fragment runs its bucket joins at bucket-count parallelism: + * each LocalShuffleBucketJoinAssignedJob owns a disjoint set of join buckets and only + * instances with buckets do join work (e.g. 8 buckets/BE but 16 instances/BE → 8 idle). + * When nothing above the join needs bucket alignment, HashJoinNode can re-distribute + * both sides with LOCAL_EXECUTION_HASH_SHUFFLE to use all instances — see + * {@link HashJoinNode#enforceAndDeriveLocalExchange}. + * + * This method computes the per-fragment numeric condition from the actual instance + * assignment: maxPerBeInstances > maxBucketsWithDataPerWorker × ratio. The ratio comes + * from session variable {@code local_shuffle_bucket_upgrade_ratio}; values <= 1 disable + * the upgrade entirely (a required parallelism gain of at most 1x means no gain). + */ + private boolean isBucketUpgradeEligible(PipelineDistributedPlan pipePlan, + long maxPerBeInstances, PlanTranslatorContext context) { + ConnectContext connectContext = context.getConnectContext(); + if (connectContext == null || connectContext.getSessionVariable() == null) { + return false; + } + double ratio = connectContext.getSessionVariable().getLocalShuffleBucketUpgradeRatio(); + List instanceJobs = pipePlan.getInstanceJobs(); + if (instanceJobs.isEmpty() + || !instanceJobs.stream().allMatch(LocalShuffleBucketJoinAssignedJob.class::isInstance)) { + // Only pooled bucket-join fragments have the bucket-count parallelism cap. + return false; + } + Map> bucketsPerWorker = new HashMap<>(); + Map instancesPerWorker = new HashMap<>(); + Map coresPerWorker = new HashMap<>(); + for (AssignedJob job : instanceJobs) { + long workerId = job.getAssignedWorker().id(); + bucketsPerWorker.computeIfAbsent(workerId, k -> new HashSet<>()) + .addAll(((LocalShuffleBucketJoinAssignedJob) job).getAssignedJoinBucketIndexes()); + instancesPerWorker.merge(workerId, 1, Integer::sum); + coresPerWorker.computeIfAbsent(workerId, k -> resolveWorkerCores(job.getAssignedWorker())); + } + // Conservative: every worker that owns buckets must clear the gain bar. The gain is + // computed on EFFECTIVE parallelism (capped by the BE's executor threads): when the + // bucket count already saturates the cores, adding instances cannot speed the join + // up and the extra local exchange is a pure cost. + boolean anyBuckets = false; + for (Map.Entry> entry : bucketsPerWorker.entrySet()) { + int buckets = entry.getValue().size(); + if (buckets == 0) { + continue; + } + anyBuckets = true; + int instances = instancesPerWorker.getOrDefault(entry.getKey(), 0); + int cores = coresPerWorker.getOrDefault(entry.getKey(), Integer.MAX_VALUE); + if (!shouldUpgradeBucketParallelism(ratio, + Math.min(instances, cores), Math.min(buckets, cores))) { + return false; + } + } + return anyBuckets; + } + + /** + * Effective execution threads of the worker's backend (pipelineExecutorSize, falling + * back to cpuCores). Values <= 1 mean the heartbeat has not reported yet — treat the + * capacity as unknown/uncapped rather than blocking the upgrade. + */ + private static int resolveWorkerCores( + org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorker worker) { + if (worker instanceof org.apache.doris.nereids.trees.plans.distribute.worker.BackendWorker) { + org.apache.doris.system.Backend backend = + ((org.apache.doris.nereids.trees.plans.distribute.worker.BackendWorker) worker).getBackend(); + int size = backend.getPipelineExecutorSize(); + if (size <= 1) { + size = backend.getCputCores(); + } + if (size > 1) { + return size; + } + } + return Integer.MAX_VALUE; + } + + /** + * Pure numeric gate for the bucket → local-hash upgrade. + * ratio <= 1 (including 0 and negatives) always disables; otherwise upgrade when the + * per-BE instance count exceeds buckets-with-data × ratio (i.e. the parallelism gain + * is at least the configured multiple). + */ + static boolean shouldUpgradeBucketParallelism(double ratio, long maxPerBeInstances, + long maxBucketsPerWorker) { + if (ratio <= 1.0) { + return false; + } + return maxBucketsPerWorker > 0 && maxPerBeInstances > maxBucketsPerWorker * ratio; + } + private void addLocalExchangeForFragment(PlanFragment fragment, PlanTranslatorContext context) { DataSink sink = fragment.getSink(); LocalExchangeTypeRequire require = sink == null diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java index 793db2fcc051a0..cae8b81ecd5292 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/ExchangeNode.java @@ -187,6 +187,13 @@ public boolean isSerialNode() { @Override public boolean isSerialOperatorOnBe(ConnectContext context) { + if (context != null && context.getSessionVariable().isEnableLocalShufflePlanner()) { + // When FE local shuffle planner is on, decouple exchange from scan's serial flag. + // Scan pooling is handled by LE(PT) after scan; exchange keeps its own parallelism. + return fragment != null + && isSerialNode() + && fragment.useSerialSource(context); + } return fragment != null && (isSerialNode() || fragment.hasSerialScanNode()) && fragment.useSerialSource(context); diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java index 9d9f7b0fd4ddc4..4ae1d480b5fb67 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java @@ -440,31 +440,65 @@ public Pair enforceAndDeriveLocalExchange( // For a non-serial probe without the flag: propagate the probe's distribution. outputType = probePassthrough ? LocalExchangeType.PASSTHROUGH : null; } else if (isColocate() || isBucketShuffle()) { - // Both probe and build sides require BUCKET_HASH_SHUFFLE: the bucket distribution - // must be preserved on both inputs. A serial child on either side is handled the - // same way (serial exchange returns NOOP → enforceRequire() inserts the LE). - probeSideRequire = LocalExchangeTypeRequire.requireBucketHash(); - // For BUCKET_SHUFFLE with serial build child: use requireBucketHash() (not - // requirePassToOne()). Unlike BROADCAST joins, BUCKET_SHUFFLE has no shared - // hash table mechanism — PASS_TO_ONE routes all data to task 0 while tasks 1..N-1 - // build empty hash tables, losing rows. BUCKET_HASH_SHUFFLE correctly distributes - // build data by bucket to match the probe side's bucket distribution. - // The serial exchange returns NOOP, so enforceRequire() will insert a - // BUCKET_HASH_SHUFFLE local exchange (with PASSTHROUGH fan-out for heavy-ops - // bottleneck avoidance). - buildSideRequire = LocalExchangeTypeRequire.requireBucketHash(); - outputType = AddLocalExchange.resolveExchangeType( - LocalExchangeTypeRequire.requireBucketHash()); + if (canUpgradeBucketToLocalHash(translatorContext, parentRequire)) { + // Bucket → local-hash parallelism upgrade (bucket-to-hash upgrade): the fragment + // has noticeably more instances than buckets-with-data (see + // AddLocalExchange.isBucketUpgradeEligible) and nothing above this join needs + // bucket alignment — re-distribute both sides by their distribute keys with + // LOCAL_EXECUTION_HASH_SHUFFLE so the join runs at full instance parallelism + // instead of being capped at bucket count. The LE keys come from + // childrenDistributeExprLists (pairwise-aligned per side, a subset of the + // equi-join keys), so both sides keep hashing the same values and the + // per-instance build/probe pairing stays correct. + // + // requireSpecific (not requireHash) on purpose: the children's + // BUCKET_HASH_SHUFFLE output must NOT satisfy this require, otherwise no LE + // is inserted and the join stays bucket-capped. + // + // Mark direct children so a stacked bucket join below keeps its BUCKET + // requires: if it also upgraded, its LOCAL hash output (keyed by ITS join + // keys) would type-satisfy our requireSpecific(LOCAL_EXECUTION_HASH) and + // suppress the LE that re-aligns data to OUR keys → wrong results. + translatorContext.setHasBucketUpgradedAncestor(children.get(0), true); + translatorContext.setHasBucketUpgradedAncestor(children.get(1), true); + probeSideRequire = LocalExchangeTypeRequire.requireSpecific( + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + buildSideRequire = LocalExchangeTypeRequire.requireSpecific( + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + // Whole-chain upgrade: a stacked bucket join below an upgraded one also + // upgrades (16-way instead of bucket-capped), but must NOT let its LOCAL + // hash claim type-satisfy the upper join's requireSpecific(LOCAL) — the + // keys may differ (each level hashes its own distribute exprs). Claim NOOP + // so the upper join always inserts its own re-align LE; that LE existed in + // the bucket world too (bucket claim never satisfied LOCAL require), so + // the chain upgrade is pure parallelism gain. + outputType = translatorContext.hasBucketUpgradedAncestor(this) + ? LocalExchangeType.NOOP + : null; // null: derived from probeResult.second below + } else { + probeSideRequire = LocalExchangeTypeRequire.requireBucketHash(); + // For BUCKET_SHUFFLE with serial build child: use requireBucketHash() (not + // requirePassToOne()). Unlike BROADCAST joins, BUCKET_SHUFFLE has no shared + // hash table mechanism — PASS_TO_ONE routes all data to task 0 while tasks 1..N-1 + // build empty hash tables, losing rows. BUCKET_HASH_SHUFFLE correctly distributes + // build data by bucket to match the probe side's bucket distribution. + // The serial exchange returns NOOP, so enforceRequire() will insert a + // BUCKET_HASH_SHUFFLE local exchange (with PASSTHROUGH fan-out for heavy-ops + // bottleneck avoidance). + buildSideRequire = LocalExchangeTypeRequire.requireBucketHash(); + outputType = AddLocalExchange.resolveExchangeType( + LocalExchangeTypeRequire.requireBucketHash()); + } } else { // PARTITIONED (shuffle) join: both sides enter via global hash exchange. // Require GLOBAL specifically so that any inserted exchange uses the same // instance mapping as the cross-fragment exchange. LOCAL hash has a different // modulus (per-BE instance count vs total instance count) and would cause - // join mismatches (DORIS-26101). + // join mismatches (cross-fragment exchange key mismatch). // // Exception: serial source (use_serial_exchange=true + pooling). The serial // exchange sends to a single BE so shuffle_idx_to_instance_idx has only one - // entry — GLOBAL hash would route data to non-existent indices (DORIS-26120). + // entry — GLOBAL hash would route data to non-existent indices (serial source global hash fallback). // Fall back to generic requireHash() which resolves to LOCAL, matching BE's // _use_serial_source behavior. boolean serialSource = fragment != null @@ -490,4 +524,32 @@ public Pair enforceAndDeriveLocalExchange( protected boolean shouldResetSerialFlagForChild(int childIndex) { return childIndex == 1; } + + /** + * Whether this bucket-shuffle / colocate join may upgrade its children requires from + * BUCKET_HASH_SHUFFLE to LOCAL_EXECUTION_HASH_SHUFFLE for higher parallelism: + *
    + *
  • the fragment passed the numeric gate (instances vs buckets-with-data × ratio), + * computed once per fragment in {@code AddLocalExchange};
  • + *
  • stacked bucket joins below an upgraded one also upgrade, but report NOOP + * output so the upper join's re-align LE is always inserted — see the + * whole-chain note in {@code enforceAndDeriveLocalExchange};
  • + *
  • the parent does not require bucket distribution of our output (an upper + * bucket join's probe/build require — upgrading here would break the bucket + * alignment it depends on);
  • + *
  • both sides have non-empty distribute exprs — they become the LOCAL hash LE + * keys, an exprs-less hash exchange would be meaningless.
  • + *
+ */ + private boolean canUpgradeBucketToLocalHash(PlanTranslatorContext translatorContext, + LocalExchangeTypeRequire parentRequire) { + if (!translatorContext.isCurrentFragmentBucketUpgradeEligible() + || parentRequire.preferType() == LocalExchangeType.BUCKET_HASH_SHUFFLE) { + return false; + } + List probeExprs = getChildDistributeExprList(0); + List buildExprs = getChildDistributeExprList(1); + return probeExprs != null && !probeExprs.isEmpty() + && buildExprs != null && !buildExprs.isEmpty(); + } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java b/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java index e644d8609743f8..8cc9e390aea89a 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/RuntimeFilter.java @@ -213,6 +213,29 @@ public boolean isFinalized() { return finalized; } + /** + * DFS from {@code node} down to {@code target} within the fragment (stopping at + * ExchangeNode boundaries). Returns null if target is not under node, otherwise + * whether the path crosses a LocalExchangeNode. + */ + private static Boolean pathCrossesLocalExchange(PlanNode node, PlanNode target) { + if (node == target) { + return false; + } + for (PlanNode child : node.getChildren()) { + if (child instanceof ExchangeNode) { + // fragment boundary: a target behind it is a remote target, handled by + // has_remote_targets + continue; + } + Boolean sub = pathCrossesLocalExchange(child, target); + if (sub != null) { + return sub || child instanceof LocalExchangeNode; + } + } + return null; + } + /** * Serializes a runtime filter to Thrift. */ @@ -226,11 +249,25 @@ public TRuntimeFilterDesc toThrift() { tFilter.setHasRemoteTargets(hasRemoteTargets); boolean hasSerialTargets = false; + boolean forceLocalMerge = false; for (RuntimeFilterTarget target : targets) { tFilter.putToPlanIdToTargetExpr(target.node.getId().asInt(), target.expr.treeToThrift()); hasSerialTargets = hasSerialTargets || target.node.isSerialOperatorOnBe(ConnectContext.get()); + // Truthful merge signal: if a LocalExchangeNode sits between the builder join + // and a same-fragment target scan, per-instance partial filters are not aligned + // with the scan's data slice and must be merged before being applied. BE used to + // infer this from the target scan's is_serial_operator (scan pooled => LE + // in between), which silently breaks once the scan is parallelized; this bit is + // computed from the actual plan after FE local exchange planning. In BE-planned + // mode (planner off) the FE tree has no LocalExchangeNodes and the bit stays + // false — the serial-flag inference still covers that world. + if (!forceLocalMerge && target.isLocalTarget) { + Boolean crossed = pathCrossesLocalExchange(builderNode, target.node); + forceLocalMerge = crossed != null && crossed; + } } + tFilter.setForceLocalMerge(forceLocalMerge); boolean enableSyncFilterSize = ConnectContext.get() != null && ConnectContext.get().getSessionVariable().enableSyncRuntimeFilterSize(); diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java index fa6409f1325537..8fdc69d71a7619 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java +++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java @@ -379,10 +379,12 @@ public String toString() { public static final String ENABLE_LOCAL_SHUFFLE_PLANNER = "enable_local_shuffle_planner"; - public static final String FORCE_TO_LOCAL_SHUFFLE = "force_to_local_shuffle"; + public static final String LOCAL_SHUFFLE_BUCKET_UPGRADE_RATIO = "local_shuffle_bucket_upgrade_ratio"; public static final String BUCKET_SHUFFLE_DOWNGRADE_RATIO = "bucket_shuffle_downgrade_ratio"; + public static final String FORCE_TO_LOCAL_SHUFFLE = "force_to_local_shuffle"; + public static final String ENABLE_LOCAL_MERGE_SORT = "enable_local_merge_sort"; public static final String ENABLE_SHARED_EXCHANGE_SINK_BUFFER = "enable_shared_exchange_sink_buffer"; @@ -1721,6 +1723,18 @@ public enum IgnoreSplitType { "Whether to force to local shuffle on pipelineX engine."}) private boolean forceToLocalShuffle = false; + @VariableMgr.VarAttr( + name = LOCAL_SHUFFLE_BUCKET_UPGRADE_RATIO, fuzzy = false, varType = VariableAnnotation.EXPERIMENTAL, + description = {"FE规划Local Shuffle时, 当池化bucket join所在fragment的每BE实例数大于" + + "每BE有数据分桶数的该倍数时, 将join两侧的桶分布本地重分发为hash分布以突破桶数并发上限。" + + "必须大于1才生效; 小于等于1(含0和负数)时关闭该优化", + "When FE plans local shuffle and a pooled bucket join fragment has more instances" + + " per BE than (buckets-with-data per BE) * this ratio, re-distribute both join" + + " sides with local hash instead of bucket hash so join parallelism is no longer" + + " capped at bucket count. Only takes effect when > 1; values <= 1 (including 0" + + " and negatives) disable the upgrade."}, needForward = true) + private double localShuffleBucketUpgradeRatio = 1.5; + @VariableMgr.VarAttr( name = BUCKET_SHUFFLE_DOWNGRADE_RATIO, fuzzy = false, varType = VariableAnnotation.EXPERIMENTAL, description = {"当一侧基表总桶数小于总实例数的该倍数时, 放弃bucket shuffle join降级为shuffle join。" @@ -4980,6 +4994,22 @@ public void setEnableLocalShufflePlanner(boolean enableLocalShufflePlanner) { this.enableLocalShufflePlanner = enableLocalShufflePlanner; } + public double getLocalShuffleBucketUpgradeRatio() { + return localShuffleBucketUpgradeRatio; + } + + public void setLocalShuffleBucketUpgradeRatio(double localShuffleBucketUpgradeRatio) { + this.localShuffleBucketUpgradeRatio = localShuffleBucketUpgradeRatio; + } + + public double getBucketShuffleDowngradeRatio() { + return bucketShuffleDowngradeRatio; + } + + public void setBucketShuffleDowngradeRatio(double bucketShuffleDowngradeRatio) { + this.bucketShuffleDowngradeRatio = bucketShuffleDowngradeRatio; + } + public boolean enablePushDownNoGroupAgg() { return enablePushDownNoGroupAgg; } @@ -6676,14 +6706,6 @@ public void setForceToLocalShuffle(boolean forceToLocalShuffle) { this.forceToLocalShuffle = forceToLocalShuffle; } - public double getBucketShuffleDowngradeRatio() { - return bucketShuffleDowngradeRatio; - } - - public void setBucketShuffleDowngradeRatio(double bucketShuffleDowngradeRatio) { - this.bucketShuffleDowngradeRatio = bucketShuffleDowngradeRatio; - } - public boolean isFetchAllFeForSystemTable() { return fetchAllFeForSystemTable; } diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java index 7288ba49ca2756..5ffe61ce8f578e 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -24,6 +24,7 @@ import org.apache.doris.analysis.JoinOperator; import org.apache.doris.analysis.OrderByElement; import org.apache.doris.analysis.SlotDescriptor; +import org.apache.doris.analysis.SlotRef; import org.apache.doris.analysis.SortInfo; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; @@ -229,12 +230,12 @@ public void testHashJoinNodeBranches() { hashJoin.setDistributionMode(DistributionMode.PARTITIONED); Pair hashOutput = hashJoin.enforceAndDeriveLocalExchange( ctx, null, LocalExchangeTypeRequire.requireHash()); - // PARTITIONED join requires GLOBAL hash to match cross-fragment exchange (DORIS-26101) + // PARTITIONED join requires GLOBAL hash to match cross-fragment exchange Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, hashOutput.second); assertChildLocalExchangeType(hashJoin, 0, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); assertChildLocalExchangeType(hashJoin, 1, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); - // DORIS-26101: PARTITIONED join with probe child already providing GLOBAL hash + // PARTITIONED join with probe child already providing GLOBAL hash // (e.g. upstream ExchangeNode) should satisfy requireGlobalExecutionHash without // inserting a new exchange. TrackingPlanNode probeGlobal = new TrackingPlanNode(nextPlanNodeId(), @@ -251,7 +252,7 @@ public void testHashJoinNodeBranches() { "no exchange should be inserted when child already provides GLOBAL hash"); Assertions.assertSame(buildGlobal, partitionedSatisfied.getChild(1)); - // DORIS-26120: PARTITIONED join with serial source falls back to LOCAL hash + // PARTITIONED join with serial source falls back to LOCAL hash // because GLOBAL shuffle_idx_to_instance_idx is incomplete for serial exchange. TrackingScanNode probeSerial = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); TrackingPlanNode buildSerial = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); @@ -312,6 +313,157 @@ public void testHashJoinNodeBranches() { assertChildLocalExchangeType(serialBuildBroadcast, 1, LocalExchangeType.PASS_TO_ONE); } + private static List> mockDistributeExprLists() { + return Lists.newArrayList( + Collections.singletonList(Mockito.mock(SlotRef.class)), + Collections.singletonList(Mockito.mock(SlotRef.class))); + } + + @Test + public void testHashJoinBucketUpgradeToLocalHash() { + List eqConjuncts = Collections.singletonList(Mockito.mock(BinaryPredicate.class)); + + // 1. Eligible fragment + parent doesn't need bucket → both sides re-distributed + // with LOCAL_EXECUTION_HASH_SHUFFLE, output reports LOCAL hash. + PlanTranslatorContext upgradeCtx = new PlanTranslatorContext(); + upgradeCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode probeBucket = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.BUCKET_HASH_SHUFFLE); + TrackingPlanNode buildNoop = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode upgradedJoin = new HashJoinNode(nextPlanNodeId(), probeBucket, buildNoop, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + upgradedJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + upgradedJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair upgradedOutput = upgradedJoin.enforceAndDeriveLocalExchange( + upgradeCtx, null, LocalExchangeTypeRequire.requireHash()); + // BUCKET claim must NOT satisfy the upgrade's requireSpecific(LOCAL_EXECUTION_HASH): + // an LE is inserted on both sides. + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, upgradedOutput.second); + assertChildLocalExchangeType(upgradedJoin, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(upgradedJoin, 1, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + // 2. Child already providing LOCAL hash satisfies the upgraded require — no extra LE. + PlanTranslatorContext satisfiedCtx = new PlanTranslatorContext(); + satisfiedCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode probeLocal = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + TrackingPlanNode buildLocal = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + HashJoinNode satisfiedJoin = new HashJoinNode(nextPlanNodeId(), probeLocal, buildLocal, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + satisfiedJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + satisfiedJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair satisfiedUpgrade = satisfiedJoin.enforceAndDeriveLocalExchange( + satisfiedCtx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, satisfiedUpgrade.second); + Assertions.assertSame(probeLocal, satisfiedJoin.getChild(0)); + Assertions.assertSame(buildLocal, satisfiedJoin.getChild(1)); + + // 3. Parent requires bucket distribution (upper bucket join) → no upgrade even when + // the fragment is eligible: children keep BUCKET_HASH_SHUFFLE. + PlanTranslatorContext parentBucketCtx = new PlanTranslatorContext(); + parentBucketCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode probeForBucketParent = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildForBucketParent = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode bucketParentJoin = new HashJoinNode(nextPlanNodeId(), probeForBucketParent, + buildForBucketParent, JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), + null, null, false); + bucketParentJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + bucketParentJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair bucketParentOutput = bucketParentJoin.enforceAndDeriveLocalExchange( + parentBucketCtx, null, LocalExchangeTypeRequire.requireBucketHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketParentOutput.second); + assertChildLocalExchangeType(bucketParentJoin, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + assertChildLocalExchangeType(bucketParentJoin, 1, LocalExchangeType.BUCKET_HASH_SHUFFLE); + + // 4. Fragment not eligible (ratio gate failed / not a pooled bucket fragment) → + // existing behavior untouched. + PlanTranslatorContext ineligibleCtx = new PlanTranslatorContext(); + TrackingPlanNode probeIneligible = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildIneligible = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode ineligibleJoin = new HashJoinNode(nextPlanNodeId(), probeIneligible, buildIneligible, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + ineligibleJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + ineligibleJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair ineligibleOutput = ineligibleJoin.enforceAndDeriveLocalExchange( + ineligibleCtx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, ineligibleOutput.second); + assertChildLocalExchangeType(ineligibleJoin, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + assertChildLocalExchangeType(ineligibleJoin, 1, LocalExchangeType.BUCKET_HASH_SHUFFLE); + + // 5. Stacked bucket joins: the whole chain upgrades. The inner join (direct probe + // child of the upgraded one) also upgrades its children to LOCAL hash, but + // reports NOOP output so the outer join always inserts its own re-align LE + // (keys may differ between levels). + PlanTranslatorContext stackedCtx = new PlanTranslatorContext(); + stackedCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode innerProbe = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode innerBuild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode innerJoin = new HashJoinNode(nextPlanNodeId(), innerProbe, innerBuild, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + innerJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + innerJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + TrackingPlanNode outerBuild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode outerJoin = new HashJoinNode(nextPlanNodeId(), innerJoin, outerBuild, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + outerJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + outerJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair stackedOutput = outerJoin.enforceAndDeriveLocalExchange( + stackedCtx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, stackedOutput.second); + // outer upgraded: probe side wrapped with LOCAL hash LE (re-aligning inner's output) + assertChildLocalExchangeType(outerJoin, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(outerJoin, 1, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + // inner upgraded too (whole-chain): its children get LOCAL hash LEs + assertChildLocalExchangeType(innerJoin, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(innerJoin, 1, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + // 6. Colocate join takes the same upgrade path. + PlanTranslatorContext colocateCtx = new PlanTranslatorContext(); + colocateCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode colocateProbe = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode colocateBuild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode colocateJoin = new HashJoinNode(nextPlanNodeId(), colocateProbe, colocateBuild, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + colocateJoin.setChildrenDistributeExprLists(mockDistributeExprLists()); + colocateJoin.setColocate(true, "test"); + Pair colocateOutput = colocateJoin.enforceAndDeriveLocalExchange( + colocateCtx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, colocateOutput.second); + assertChildLocalExchangeType(colocateJoin, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + assertChildLocalExchangeType(colocateJoin, 1, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + + // 7. Missing distribute exprs → no upgrade (the LOCAL hash LE would have no keys). + PlanTranslatorContext noExprCtx = new PlanTranslatorContext(); + noExprCtx.setCurrentFragmentBucketUpgradeEligible(true); + TrackingPlanNode probeNoExpr = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode buildNoExpr = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashJoinNode noExprJoin = new HashJoinNode(nextPlanNodeId(), probeNoExpr, buildNoExpr, + JoinOperator.INNER_JOIN, eqConjuncts, Collections.emptyList(), null, null, false); + noExprJoin.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + Pair noExprOutput = noExprJoin.enforceAndDeriveLocalExchange( + noExprCtx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, noExprOutput.second); + assertChildLocalExchangeType(noExprJoin, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + assertChildLocalExchangeType(noExprJoin, 1, LocalExchangeType.BUCKET_HASH_SHUFFLE); + } + + + @Test + public void testShouldUpgradeBucketParallelismGate() { + // ratio <= 1 (including 0 and negatives) always disables — the knob doubles as the + // off switch: requiring at most 1x parallelism gain means no gain. + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(0, 16, 8)); + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(-1, 16, 8)); + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(1.0, 16, 8)); + // active threshold: instances must exceed buckets-with-data × ratio + Assertions.assertTrue(AddLocalExchange.shouldUpgradeBucketParallelism(1.5, 16, 8)); + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(1.5, 12, 8)); + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(2.0, 16, 8)); + Assertions.assertTrue(AddLocalExchange.shouldUpgradeBucketParallelism(1.5, 256, 8)); + // no buckets with data → nothing to upgrade + Assertions.assertFalse(AddLocalExchange.shouldUpgradeBucketParallelism(1.5, 16, 0)); + } + @Test public void testLocalExchangeNodeIsNotSerializedAsSerialOperator() { SerialTrackingScanNode serialScan = new SerialTrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); @@ -441,7 +593,7 @@ public void testSetOperationAndAssertNumRowsNode() { intersectNode.addChild(right); Pair intersectOutput = intersectNode.enforceAndDeriveLocalExchange( ctx, null, LocalExchangeTypeRequire.requireHash()); - // PARTITIONED intersect requires GLOBAL hash (DORIS-26100) + // PARTITIONED intersect requires GLOBAL hash Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, intersectOutput.second); assertChildLocalExchangeType(intersectNode, 0, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); assertChildLocalExchangeType(intersectNode, 1, LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift index 85935b9060c15f..ea80d95b5f8fd7 100644 --- a/gensrc/thrift/PlanNodes.thrift +++ b/gensrc/thrift/PlanNodes.thrift @@ -1689,6 +1689,12 @@ struct TRuntimeFilterDesc { 16: optional bool sync_filter_size; // Deprecated 17: optional bool build_bf_by_runtime_size; + + // True when a local exchange sits between the filter builder (join) and a same-fragment + // target scan: per-instance partial filters are then NOT aligned with the scan's data + // slice and must be merged before being applied. Computed truthfully by FE after local + // exchange planning; replaces inferring this from the target scan's is_serial_operator. + 21: optional bool force_local_merge; } diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_bucket_upgrade.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_bucket_upgrade.groovy new file mode 100644 index 00000000000000..8d493c54e874c4 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_bucket_upgrade.groovy @@ -0,0 +1,193 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Bucket -> local-hash parallelism upgrade. + * + * A pooled bucket-join fragment runs its bucket joins at bucket-count parallelism + * (only instances owning buckets do join work). When nothing above the join needs + * bucket alignment and per-BE instances > buckets-with-data x ratio + * (session var local_shuffle_bucket_upgrade_ratio, > 1 enables, <= 1 disables), + * the FE planner re-distributes both join sides with LOCAL_EXECUTION_HASH_SHUFFLE + * so the join uses all instances. + * + * Shape notes (verified against a live cluster): + * - LocalExchangeNodes only appear in EXPLAIN DISTRIBUTED PLAN (plain EXPLAIN + * renders the tree before AddLocalExchange runs). + * - Whether a bucket-shuffle join forms is cluster-dependent (Nereids downgrades it + * when totalBucketNum < totalInstanceNum * bucket_shuffle_downgrade_ratio). The suite + * pins bucket_shuffle_downgrade_ratio=0 to keep it forming, but the plan-shape checks + * still gate on "did a BUCKET_HASH_SHUFFLE local exchange actually appear?" and skip + * if not, so the test never hard-fails on an environment where it didn't form. + * - The upgrade fires when min(task_num=16, cores) / min(buckets=4, cores) > 1.1, i.e. + * on any machine with >= 5 cores (5/4 = 1.25 > 1.1); CI and dev machines comfortably + * exceed this. Bucket counts are kept low (4/3/3) so a modest core count is enough. + * - The aggregation above must NOT group by the bucket key: a colocate agg + * requires bucket distribution of the join output and correctly blocks the + * upgrade via the parentRequire gate. + */ +suite("test_local_shuffle_bucket_upgrade") { + + def hints = { ls_on, ratio -> + """/*+SET_VAR( + enable_sql_cache=false, disable_join_reorder=true, + disable_colocate_plan=true, + auto_broadcast_join_threshold=-1, broadcast_row_count_limit=0, + experimental_force_to_local_shuffle=true, + experimental_enable_parallel_scan=false, + enable_runtime_filter_prune=false, + enable_runtime_filter_partition_prune=false, + runtime_filter_type='IN,MIN_MAX', + parallel_pipeline_task_num=16, + parallel_exchange_instance_num=8, + query_timeout=600, + bucket_shuffle_downgrade_ratio=0, + local_shuffle_bucket_upgrade_ratio=${ratio}, + enable_local_shuffle=${ls_on}, + enable_local_shuffle_planner=${ls_on} + )*/""" + } + + sql "DROP TABLE IF EXISTS lsbu_fact" + sql "DROP TABLE IF EXISTS lsbu_probe" + sql "DROP TABLE IF EXISTS lsbu_probe2" + sql """CREATE TABLE lsbu_fact (k INT, v BIGINT) + ENGINE=OLAP DUPLICATE KEY(k) DISTRIBUTED BY HASH(k) BUCKETS 4 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE lsbu_probe (pk INT, k INT, w BIGINT) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 3 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE lsbu_probe2 (pk INT, k INT, w BIGINT) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 3 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO lsbu_fact + SELECT CAST(number%50 AS INT), number*10+1 + FROM numbers("number"="200")""" + sql """INSERT INTO lsbu_probe + SELECT CAST(number AS INT), CAST(number%50 AS INT), 1000+number + FROM numbers("number"="300")""" + sql """INSERT INTO lsbu_probe2 + SELECT CAST(number AS INT), CAST(number%50 AS INT), 2000+number + FROM numbers("number"="170")""" + + // group key pk%10 is NOT the bucket key, so the agg above does not require + // bucket distribution and the upgrade is allowed. + def singleJoin = { h -> + """SELECT ${h} p.pk % 10 AS g, COUNT(*) c, SUM(f.v) sv, SUM(p.w) sw + FROM lsbu_fact f JOIN lsbu_probe p ON p.k = f.k + GROUP BY g ORDER BY g""" + } + + // ---------- 1. plan shape (EXPLAIN DISTRIBUTED PLAN: post-AddLocalExchange) ---------- + // The upgrade replaces the bucket join's BUCKET_HASH_SHUFFLE local exchange with + // LOCAL_EXECUTION_HASH_SHUFFLE. "BUCKET_HASH_SHUFFLE" names ONLY a local-exchange type + // (the join op prints "BUCKET_SHUFFLE", the network sink "BUCKET_SHFFULE_HASH_PARTITIONED"), + // so it is an unambiguous, fragment-local signal of the thing being upgraded — unlike + // LOCAL_EXECUTION_HASH, which an agg-finalize fragment may also carry on a multi-BE + // cluster regardless of the gate. + // + // First confirm a bucket-shuffle local exchange actually formed; if it did not (cluster + // shaped the join differently), there is nothing to upgrade, so skip rather than fail. + def countBucketHashLe = { String planText -> planText.split("BUCKET_HASH_SHUFFLE").length - 1 } + + def bucketText = (sql "EXPLAIN DISTRIBUTED PLAN ${singleJoin(hints('true', '0'))}").toString() + int bucketLeCount = countBucketHashLe(bucketText) + if (bucketLeCount == 0) { + logger.warn("bucket-shuffle join did not form in this environment; " + + "skipping single-join upgrade plan-shape checks") + } else { + // ratio=1.1 upgrades the bucket join → all BUCKET_HASH local exchanges are gone + def upgradedText = (sql "EXPLAIN DISTRIBUTED PLAN ${singleJoin(hints('true', '1.1'))}").toString() + assertEquals(0, countBucketHashLe(upgradedText), + "ratio=1.1 must upgrade away the bucket join's BUCKET_HASH_SHUFFLE local exchanges") + + // ratio <= 1 disables the upgrade → bucket-hash local exchanges unchanged + def ratioOneText = (sql "EXPLAIN DISTRIBUTED PLAN ${singleJoin(hints('true', '1'))}").toString() + assertEquals(bucketLeCount, countBucketHashLe(ratioOneText), + "ratio=1 must keep the upgrade off (<=1 disables)") + } + + // Note: whether a group-by-bucket-key agg blocks the upgrade depends on the agg + // shape the optimizer picks (a colocate one-phase agg requires bucket distribution + // and blocks it; a two-phase agg does not). That parentRequire gate is covered + // deterministically by LocalShuffleNodeCoverageTest; here we only pin correctness. + def bucketKeyAgg = { h -> + """SELECT ${h} f.k AS g, COUNT(*) c, SUM(p.w) sw + FROM lsbu_fact f JOIN lsbu_probe p ON p.k = f.k + GROUP BY g ORDER BY g""" + } + def bka_baseline = sql bucketKeyAgg(hints('false', '0')) + def bka_upgraded = sql bucketKeyAgg(hints('true', '1.1')) + assertEquals(50, bka_baseline.size()) + assertEquals(bka_baseline, bka_upgraded, + "group-by-bucket-key agg over (possibly upgraded) bucket join must stay correct") + + // ---------- 2. correctness: single bucket join ---------- + def single_baseline = sql singleJoin(hints('false', '0')) + def single_bucket = sql singleJoin(hints('true', '0')) + def single_upgraded = sql singleJoin(hints('true', '1.1')) + + assertEquals(10, single_baseline.size()) + assertEquals(single_baseline, single_bucket, + "bucket join (upgrade off) must match local-shuffle-off baseline") + assertEquals(single_baseline, single_upgraded, + "upgraded bucket join must match local-shuffle-off baseline") + + // ---------- 3. correctness: stacked bucket joins ---------- + def stackedJoin = { h -> + """SELECT ${h} p1.pk % 10 AS g, COUNT(*) c, SUM(f.v) sv, SUM(p1.w) s1, SUM(p2.w) s2 + FROM lsbu_fact f + JOIN lsbu_probe p1 ON p1.k = f.k + JOIN lsbu_probe2 p2 ON p2.k = f.k + GROUP BY g ORDER BY g""" + } + + // whole-chain shape: at an eligible ratio every level of the stacked bucket chain + // upgrades (the lower join reports NOOP so the upper re-align LE is kept), so all + // BUCKET_HASH local exchanges are upgraded away. Skip if the chain didn't form here. + def stackedBucketText = (sql "EXPLAIN DISTRIBUTED PLAN ${stackedJoin(hints('true', '0'))}").toString() + if (countBucketHashLe(stackedBucketText) == 0) { + logger.warn("stacked bucket-shuffle chain did not form in this environment; " + + "skipping stacked upgrade plan-shape check") + } else { + def stackedUpgradedText = (sql "EXPLAIN DISTRIBUTED PLAN ${stackedJoin(hints('true', '1.1'))}").toString() + assertEquals(0, countBucketHashLe(stackedUpgradedText), + "ratio=1.1 must upgrade away the stacked bucket chain's BUCKET_HASH local exchanges") + } + + // Forced-RF killer case: with the upgrade, the join build is hash-sliced; the + // per-instance IN/MIN_MAX partial filters MUST be merged before application + // (TRuntimeFilterDesc.force_local_merge). Before that fix this query silently + // lost up to 96% of its rows. + def rfHints = { ratio -> + hints('true', ratio).replace(")*/", + ", enable_runtime_filter_prune=false, runtime_filter_type='IN,MIN_MAX')*/") + } + def single_up_rf = sql "SELECT ${rfHints('1.1')} p.pk % 10 AS g, COUNT(*) c, SUM(f.v) sv, SUM(p.w) sw FROM lsbu_fact f JOIN lsbu_probe p ON p.k = f.k GROUP BY g ORDER BY g" + assertEquals(single_baseline, single_up_rf, + "upgraded bucket join with forced IN/MIN_MAX runtime filters must stay correct") + + def stacked_baseline = sql stackedJoin(hints('false', '0')) + def stacked_bucket = sql stackedJoin(hints('true', '0')) + def stacked_upgraded = sql stackedJoin(hints('true', '1.1')) + + assertEquals(10, stacked_baseline.size()) + assertEquals(stacked_baseline, stacked_bucket, + "stacked bucket joins (upgrade off) must match local-shuffle-off baseline") + assertEquals(stacked_baseline, stacked_upgraded, + "stacked bucket joins (upgrade on) must match local-shuffle-off baseline") +} diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy index e369dc0f11eb18..ca3fe027c47fe7 100644 --- a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy @@ -19,7 +19,7 @@ * Regression tests for bugs discovered by RQG testing on the local-exchange2 branch. * * These queries triggered "must set shared state" errors or incorrect results - * in RQG build 183992. Common conditions: + * in RQG testing. Common conditions: * - use_serial_exchange=true (makes ALL Exchanges serial, not just UNPARTITIONED) * - enable_local_shuffle_planner=true (FE-planned local exchange) * - parallel_pipeline_task_num > 1 @@ -65,7 +65,7 @@ suite("test_local_shuffle_rqg_bugs") { PROPERTIES ("replication_num" = "1") """ - // Table for build 184181 GLOBAL_HASH_SHUFFLE bugs — needs varchar + bigint columns + // Table for RQG testing GLOBAL_HASH_SHUFFLE bugs — needs varchar + bigint columns sql """ CREATE TABLE rqg_t3 ( pk INT NOT NULL, @@ -354,7 +354,7 @@ suite("test_local_shuffle_rqg_bugs") { // local exchange on outer NLJ's build side because child was NLJ (not ScanNode). // Fixed in NestedLoopJoinNode.enforceAndDeriveLocalExchange by using // fragment.useSerialSource() instead of instanceof ScanNode check. - // This was the root cause of 989 RQG test failures (build 183677). + // This was the root cause of 989 RQG test failures (RQG testing). // ============================================================ logger.info("=== Bug 6: CROSS_JOIN shared state - nested NLJ + pooling scan (FE planner) ===") @@ -513,7 +513,7 @@ suite("test_local_shuffle_rqg_bugs") { // ============================================================ // Bug 10: GLOBAL_HASH_SHUFFLE Rows mismatched — self-join + NLJ - // RQG case: 906784672 (build 184181) + // RQG regression case // Root cause: HashJoinNode used requireGlobalExecutionHash() → GLOBAL local exchange // inserted when use_serial_exchange=true; shuffle_idx_to_instance_idx map has only // 4 entries (1/BE) but GLOBAL hash needs N*dop entries → most rows unrouted (0 actual rows). @@ -522,7 +522,7 @@ suite("test_local_shuffle_rqg_bugs") { // then NLJ (LEFT JOIN table1 table3 ON pk > col_bigint_undef_signed) // ============================================================ - logger.info("=== Bug 10: GLOBAL_HASH_SHUFFLE Rows mismatched - self-join + NLJ (build 184181 case 906784672) ===") + logger.info("=== Bug 10: GLOBAL_HASH_SHUFFLE Rows mismatched - self-join + NLJ (RQG testing case 906784672) ===") def bug10_fe = sql """ SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, enable_local_shuffle_planner=true, @@ -564,12 +564,12 @@ suite("test_local_shuffle_rqg_bugs") { // ============================================================ // Bug 11: GLOBAL_HASH_SHUFFLE Rows mismatched — FULL OUTER JOIN + GROUP BY - // RQG case: 11007681241 (build 184181) + // RQG regression case // Same root cause as Bug 10. // SQL: FULL OUTER JOIN on col_bigint_undef_signed_not_null with WHERE + GROUP BY // ============================================================ - logger.info("=== Bug 11: GLOBAL_HASH_SHUFFLE Rows mismatched - FULL OUTER JOIN + GROUP BY (build 184181 case 11007681241) ===") + logger.info("=== Bug 11: GLOBAL_HASH_SHUFFLE Rows mismatched - FULL OUTER JOIN + GROUP BY (RQG testing case 11007681241) ===") def bug11_fe = sql """ SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, enable_local_shuffle_planner=true, @@ -603,12 +603,12 @@ suite("test_local_shuffle_rqg_bugs") { // ============================================================ // Bug 12: GLOBAL_HASH_SHUFFLE Rows mismatched — LEFT JOIN + VARCHAR predicates + MIN() - // RQG case: 906784662 (build 184181) + // RQG regression case // Same root cause as Bug 10/11. // SQL: LEFT JOIN on pk with VARCHAR NOT IN / BETWEEN / IN predicates, MIN() aggregate // ============================================================ - logger.info("=== Bug 12: GLOBAL_HASH_SHUFFLE Rows mismatched - LEFT JOIN + VARCHAR predicates (build 184181 case 906784662) ===") + logger.info("=== Bug 12: GLOBAL_HASH_SHUFFLE Rows mismatched - LEFT JOIN + VARCHAR predicates (RQG testing case 906784662) ===") def bug12_fe = sql """ SELECT /*+SET_VAR(use_serial_exchange=true, parallel_pipeline_task_num=4, enable_local_shuffle_planner=true, @@ -646,7 +646,7 @@ suite("test_local_shuffle_rqg_bugs") { // ============================================================ // Bug 13: NLJ COREDUMP — serial NLJ + pooling scan + BROADCAST build side - // RQG build 184430, query c0dafc1bed0f4910 + // RQG testing // Root cause: serial NLJ (RIGHT_OUTER) with pooling scan inserted BROADCAST // local exchange on build side, inflating build pipeline num_tasks to _num_instances // while probe pipeline stayed at 1 task. Instance 1+ created build tasks without @@ -696,7 +696,7 @@ suite("test_local_shuffle_rqg_bugs") { // ============================================================ // Bug 14: BUCKET_SHUFFLE join + serial build Exchange — must set shared state - // RQG build 184563, cases 906784706/906784783/906784987/906785006 + // RQG testing // Root cause: BUCKET_SHUFFLE join build side ExchangeNode marked serial in // pooling scan fragment → build pipeline num_tasks reduced to 1 → // instance 1+ have probe tasks without build tasks → shared state injection @@ -925,7 +925,7 @@ suite("test_local_shuffle_rqg_bugs") { // // Both triggered by: OVER() with no PARTITION BY + GROUPING SETS + // pptn=0 (auto-parallel) + disable_streaming_preaggregations=true - // RQG build 186195, query IDs: 7f3178a77c2c4b6b, 71887f7bf804c0c, 5dd9fcad234c4484 + // RQG testing // ============================================================ sql "DROP TABLE IF EXISTS rqg_analytic_t1" sql """ @@ -1160,9 +1160,58 @@ suite("test_local_shuffle_rqg_bugs") { assertTrue(false, "Bug 20: Serial exchange + agg hang: ${t.message}") } + // ============================================================ + // Bug 20b: count(distinct)+std + RIGHT JOIN returns inflated distinct count + // when use_serial_exchange=true + enable_local_exchange_before_agg=false. + // Root cause (BE-planned): AggSink early-return ignored that the serial exchange + // child breaks the HASH(s) invariant via PASSTHROUGH fan-out; fixed upstream by + // child_breaks_local_key_distribution (#63766). The FE planner fixes it + // structurally: requires are semantic, a hash LE is inserted instead of + // PASSTHROUGH. This case pins both paths. + // ============================================================ + try { + logger.info("Bug 20b: count(distinct) under serial exchange") + sql "DROP TABLE IF EXISTS rqg_25413_t1" + sql "DROP TABLE IF EXISTS rqg_25413_t2" + sql """CREATE TABLE rqg_25413_t1 (pk INT NOT NULL, s VARCHAR(64) NOT NULL, d DECIMAL(10,2) NOT NULL) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE rqg_25413_t2 (pk INT NOT NULL, dt DATETIME NOT NULL) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5 + PROPERTIES ("replication_num"="1")""" + sql """INSERT INTO rqg_25413_t1 + SELECT CAST(number AS INT), concat('s', CAST(number % 29 AS INT)), + CAST(number * 13 % 1000 AS DECIMAL(10,2)) + FROM numbers("number"="200")""" + sql """INSERT INTO rqg_25413_t2 + SELECT CAST(number AS INT), + date_add('2000-01-01 00:00:00', INTERVAL CAST(number % 3000 AS INT) DAY) + FROM numbers("number"="200")""" + + def q25413 = { vars -> """ + SELECT /*+SET_VAR(${vars})*/ + count(distinct t1.s) AS cnt_distinct, std(t1.d) AS std_val + FROM rqg_25413_t1 t1 + RIGHT JOIN rqg_25413_t2 t2 ON t1.pk = t2.pk + WHERE t2.dt < '2005-01-01 00:00:00' + """ } + def base25413 = "enable_sql_cache=false, enable_local_exchange_before_agg=false, parallel_pipeline_task_num=4" + def expected25413 = sql q25413(base25413) + for (planner in ['false', 'true']) { + def actual = sql q25413( + "${base25413}, experimental_use_serial_exchange=true, enable_local_shuffle_planner=${planner}") + assertEquals(expected25413, actual, + "Bug 20b planner=${planner}: distinct count must not be inflated under serial exchange") + } + logger.info("Bug 20b: PASSED") + } catch (Throwable t) { + logger.error("Bug 20b FAILED: ${t.message}") + assertTrue(false, "Bug 20b: ${t.message}") + } + // ============================================================ // Bug 21: Multi-distinct COUNT on many-bucket table → COREDUMP - // RQG build 186737/186929/186952: AggSinkOperatorX::sink → set_ready_to_read + // AggSinkOperatorX::sink → set_ready_to_read // with empty source_deps. // // Root cause: AGG operators (streaming, distinct-streaming, serialize) requested From a672e5a5e67d63ee9c9050ba3d3fca3c461c6b6c Mon Sep 17 00:00:00 2001 From: 924060929 Date: Wed, 8 Jul 2026 14:37:41 +0800 Subject: [PATCH 4/9] branch-4.2: [fix](local shuffle) address all receiver instances for a non-serial exchange #65348 Cherry-picked from #65348 --- .../plans/distribute/DistributePlanner.java | 17 ++- ...tributePlannerReceiverDestinationTest.java | 120 ++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlannerReceiverDestinationTest.java diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java index 60dc4dac643374..16dfd4b083769f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlanner.java @@ -30,7 +30,6 @@ import org.apache.doris.nereids.trees.plans.distribute.worker.job.AssignedJobBuilder; import org.apache.doris.nereids.trees.plans.distribute.worker.job.BucketScanSource; import org.apache.doris.nereids.trees.plans.distribute.worker.job.DefaultScanSource; -import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleAssignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleBucketJoinAssignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.StaticAssignedJob; import org.apache.doris.nereids.trees.plans.distribute.worker.job.UnassignedJob; @@ -50,6 +49,7 @@ import org.apache.doris.thrift.TPartitionType; import org.apache.doris.thrift.TUniqueId; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Throwables; import com.google.common.collect.ImmutableMap; import com.google.common.collect.LinkedHashMultimap; @@ -265,13 +265,20 @@ private boolean isEnableLocalShufflePlanner() { return connectContext != null && connectContext.getSessionVariable().isEnableLocalShufflePlanner(); } - private List filterInstancesWhichCanReceiveDataFromRemote( + @VisibleForTesting + List filterInstancesWhichCanReceiveDataFromRemote( PipelineDistributedPlan receiverPlan, boolean enableShareHashTableForBroadcastJoin, ExchangeNode linkNode) { - boolean useLocalShuffle = receiverPlan.getInstanceJobs().stream() - .anyMatch(LocalShuffleAssignedJob.class::isInstance); - if (useLocalShuffle) { + // Funnel to the first instance per worker only when the exchange runs a single receiver + // per worker on BE, i.e. it is serial. A non-serial exchange builds a live receiver on + // every instance, each waiting for the full sender set, so the sender must address all of + // them; funneling would leave the non-first instances blocked forever on an EOS that never + // arrives. This must not key on LocalShuffleAssignedJob: once the FE local-shuffle planner + // decoupled an exchange's serial flag from the fragment's serial scan, a local-shuffle + // fragment can host a non-serial RANDOM/HASH exchange, and only the BUCKET_SHUFFLE path + // re-spreads its destinations (see getDestinationsByBuckets). + if (linkNode.isSerialOperatorOnBe(statementContext.getConnectContext())) { return getFirstInstancePerWorker(receiverPlan.getInstanceJobs()); } else if (enableShareHashTableForBroadcastJoin && linkNode.isRightChildOfBroadcastHashJoin()) { return getFirstInstancePerWorker(receiverPlan.getInstanceJobs()); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlannerReceiverDestinationTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlannerReceiverDestinationTest.java new file mode 100644 index 00000000000000..220062dfb95602 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/DistributePlannerReceiverDestinationTest.java @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.trees.plans.distribute; + +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorker; +import org.apache.doris.nereids.trees.plans.distribute.worker.job.AssignedJob; +import org.apache.doris.nereids.trees.plans.distribute.worker.job.LocalShuffleAssignedJob; +import org.apache.doris.planner.ExchangeNode; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.qe.ConnectContext; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; + +/** + * Verifies that DistributePlanner picks the receiver instances a remote sender addresses + * based on whether the exchange is serial on BE -- not on whether the receiver fragment + * happens to use local shuffle. + * + * A non-serial exchange runs a live receiver on every instance, so the sender must address + * them all. Funneling such an exchange to the first instance per worker leaves the other + * instances waiting forever for an EOS that no sender sends (query hangs until timeout). + */ +public class DistributePlannerReceiverDestinationTest { + + private DistributePlanner newPlanner() { + List noFragments = Lists.newArrayList(); + return new DistributePlanner(Mockito.mock(StatementContext.class), noFragments, false, false); + } + + private AssignedJob instanceOn(DistributedPlanWorker worker) { + LocalShuffleAssignedJob instance = Mockito.mock(LocalShuffleAssignedJob.class); + Mockito.when(instance.getAssignedWorker()).thenReturn(worker); + return instance; + } + + private ExchangeNode exchange(boolean serialOnBe, boolean rightChildOfBroadcastJoin) { + ExchangeNode node = Mockito.mock(ExchangeNode.class); + Mockito.when(node.isSerialOperatorOnBe(Mockito.nullable(ConnectContext.class))).thenReturn(serialOnBe); + Mockito.when(node.isRightChildOfBroadcastHashJoin()).thenReturn(rightChildOfBroadcastJoin); + return node; + } + + private PipelineDistributedPlan receiverWith(List instances) { + PipelineDistributedPlan plan = Mockito.mock(PipelineDistributedPlan.class); + Mockito.when(plan.getInstanceJobs()).thenReturn(instances); + return plan; + } + + @Test + public void nonSerialExchangeAddressesEveryReceiverInstance() { + // 4 receiver instances across 2 workers (e.g. a bucket-to-hash upgraded fragment) + DistributedPlanWorker w0 = Mockito.mock(DistributedPlanWorker.class); + DistributedPlanWorker w1 = Mockito.mock(DistributedPlanWorker.class); + AssignedJob i0 = instanceOn(w0); + AssignedJob i1 = instanceOn(w0); + AssignedJob i2 = instanceOn(w1); + AssignedJob i3 = instanceOn(w1); + List all = Lists.newArrayList(i0, i1, i2, i3); + + List destinations = newPlanner().filterInstancesWhichCanReceiveDataFromRemote( + receiverWith(all), false, exchange(false, false)); + + // Non-serial exchange: every instance owns a live receiver, so all must be addressed. + Assertions.assertEquals(all, destinations); + } + + @Test + public void serialExchangeFunnelsToFirstInstancePerWorker() { + DistributedPlanWorker w0 = Mockito.mock(DistributedPlanWorker.class); + DistributedPlanWorker w1 = Mockito.mock(DistributedPlanWorker.class); + AssignedJob i0 = instanceOn(w0); + AssignedJob i1 = instanceOn(w0); + AssignedJob i2 = instanceOn(w1); + AssignedJob i3 = instanceOn(w1); + List all = Lists.newArrayList(i0, i1, i2, i3); + + List destinations = newPlanner().filterInstancesWhichCanReceiveDataFromRemote( + receiverWith(all), false, exchange(true, false)); + + // Serial exchange: BE runs a single receiver per worker, funnel to the first instance. + Assertions.assertEquals(Lists.newArrayList(i0, i2), destinations); + } + + @Test + public void broadcastShareHashTableFunnelsEvenWhenNonSerial() { + DistributedPlanWorker w0 = Mockito.mock(DistributedPlanWorker.class); + DistributedPlanWorker w1 = Mockito.mock(DistributedPlanWorker.class); + AssignedJob i0 = instanceOn(w0); + AssignedJob i1 = instanceOn(w0); + AssignedJob i2 = instanceOn(w1); + List all = Lists.newArrayList(i0, i1, i2); + + List destinations = newPlanner().filterInstancesWhichCanReceiveDataFromRemote( + receiverWith(all), true, exchange(false, true)); + + // Shared-hash-table broadcast build still funnels one build per worker. + Assertions.assertEquals(Lists.newArrayList(i0, i2), destinations); + } +} From 9414d1094f7ab4b127a4f4ebce9e4bee87bc2331 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Fri, 17 Jul 2026 11:19:10 +0800 Subject: [PATCH 5/9] branch-4.2: [opt](local shuffle) support bucket shuffle for set operation #65129 Cherry-picked from #65129 --- .../translator/PhysicalPlanTranslator.java | 29 ++ .../ChildOutputPropertyDeriver.java | 76 +++ .../ChildrenPropertiesRegulator.java | 133 +++++- .../properties/RequestPropertyDeriver.java | 51 +- .../job/UnassignedScanBucketOlapTableJob.java | 24 +- .../apache/doris/nereids/util/JoinUtils.java | 5 +- .../doris/planner/LocalExchangeNode.java | 11 +- .../doris/planner/SetOperationNode.java | 20 +- .../UnassignedScanBucketOlapTableJobTest.java | 157 +++++++ .../planner/LocalShuffleNodeCoverageTest.java | 49 ++ .../bucket_shuffle_set_operation.out | 304 ++++++++++++ .../bucket_shuffle_set_operation.groovy | 442 ++++++++++++++++++ 12 files changed, 1278 insertions(+), 23 deletions(-) create mode 100644 fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java create mode 100644 regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out create mode 100644 regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java index 092c50b217834a..0cbd2562c588ee 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -2628,6 +2628,35 @@ public PlanFragment visitPhysicalSetOperation( setOperationNode.setColocate(true); } + // Storage-bucketed children only appear when the FE local shuffle planner is active: + // ChildrenPropertiesRegulator and RequestPropertyDeriver both gate the bucket-shuffle + // alternative on enableLocalShufflePlanner. Gate the marker on the same flag so the + // dependency is explicit and a future planner change that produced a STORAGE_BUCKETED + // distribution outside the local-shuffle planner cannot silently mark BUCKET_SHUFFLE here. + // + // Within that gate a storage-bucketed child means the regulator chose the bucket shuffle + // alternative (it enforces the other children onto the basic child's buckets), so the marker + // simply follows that decision. It must not re-check the table id independently: the basic + // child selection in the regulator is the single place that vets the layout, and re-checking + // here could suppress a bucket shuffle the property model already committed to and desync a + // parent that aligned to the set operation output. + // + // Unlike hash join, BUCKET_SHUFFLE is not exclusive with isColocate above: for a set + // operation isColocate describes the bucket-aligned scheduling of the fragment (the + // basic child scans buckets directly), while BUCKET_SHUFFLE describes how the other + // children arrive (bucket-shuffle exchanges). Both routes converge to the same + // bucket-hash local exchange requirement in SetOperationNode.enforceAndDeriveLocalExchange. + if (context.getSessionVariable() != null + && context.getSessionVariable().isEnableLocalShufflePlanner()) { + for (Plan child : setOperation.children()) { + PhysicalPlan childPhysicalPlan = (PhysicalPlan) child; + if (JoinUtils.isStorageBucketed(childPhysicalPlan.getPhysicalProperties())) { + setOperationNode.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + break; + } + } + } + return setOperationFragment; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java index 8c89d45891bdb9..575cf5a4518f2c 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildOutputPropertyDeriver.java @@ -73,8 +73,10 @@ import com.google.common.collect.Maps; import com.google.common.collect.Sets; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -453,6 +455,80 @@ public PhysicalProperties visitPhysicalSetOperation(PhysicalSetOperation setOper if (childrenDistribution.stream().allMatch(DistributionSpecGather.class::isInstance)) { return PhysicalProperties.GATHER; } + + // After ChildrenPropertiesRegulator the children distributions are already legal, so the + // output is derived by describing what the children provide: + // 1. one or more NATURAL children: output the first NATURAL child's distribution + // (several NATURAL children behave like colocate); + // 2. no NATURAL but some STORAGE_BUCKETED child: output its STORAGE_BUCKETED distribution; + // 3. all EXECUTION_BUCKETED children: output the execution hash (the generic loop below); + // 4. anything else (e.g. random): output a non-specific property (also below). + // When the basic child does not directly output its shuffle columns, or the children do + // not agree, this falls through to the generic loop below, which is equivalence-set aware + // and checks that every child maps its shuffle columns to the same set-operation output + // positions before it claims a bucketed output. The basic child is recomputed from the + // children distributions instead of being carried as mutable planner state, because mutable + // state does not survive the with-copies in chooseBestPlan() and the + // RecomputePhysicalPropertiesPostProcessor re-derivation, while this recomputation is + // deterministic on any copy of the plan. + int distributeToChildIndex = -1; + int firstStorageBucketedIndex = -1; + for (int i = 0; i < childrenDistribution.size(); i++) { + if (childrenDistribution.get(i) instanceof DistributionSpecHash) { + ShuffleType childShuffleType + = ((DistributionSpecHash) childrenDistribution.get(i)).getShuffleType(); + if (childShuffleType == ShuffleType.NATURAL) { + distributeToChildIndex = i; + break; + } else if (childShuffleType == ShuffleType.STORAGE_BUCKETED + && firstStorageBucketedIndex < 0) { + firstStorageBucketedIndex = i; + } + } + } + if (distributeToChildIndex < 0) { + distributeToChildIndex = firstStorageBucketedIndex; + } + if (distributeToChildIndex >= 0) { + DistributionSpecHash childDistribution + = (DistributionSpecHash) childrenDistribution.get(distributeToChildIndex); + List childToIndex = setOperation.getRegularChildrenOutputs().get(distributeToChildIndex); + Map idToOutputIndex = new LinkedHashMap<>(); + for (int j = 0; j < childToIndex.size(); j++) { + idToOutputIndex.put(childToIndex.get(j).getExprId(), j); + } + + List orderedShuffledColumns = childDistribution.getOrderedShuffledColumns(); + List setOperationDistributeColumnIds = new ArrayList<>(); + for (ExprId tableDistributeColumnId : orderedShuffledColumns) { + Integer index = idToOutputIndex.get(tableDistributeColumnId); + if (index == null) { + break; + } + setOperationDistributeColumnIds.add(setOperation.getOutput().get(index).getExprId()); + } + // check whether the set operation output all distribution columns of the child + if (setOperationDistributeColumnIds.size() == orderedShuffledColumns.size()) { + // Keep the basic child's specific storage layout as the set operation output. When + // the basic child is on the right (shuffleToRight) the output rows are physically + // placed by the right child's storage bucket function, so advertising that layout is + // truthful: a parent can co-locate this set operation against the same layout, while + // a sibling on a different layout is re-aligned rather than wrongly co-located. (The + // earlier "Can not find tablet ... in the bucket" failure came from advertising a + // layout-less EXECUTION_BUCKETED here, which erased the table id so two different- + // layout set operations looked co-locatable; preserving the layout fixes that.) + return new PhysicalProperties( + new DistributionSpecHash( + setOperationDistributeColumnIds, + childDistribution.getShuffleType(), + childDistribution.getTableId(), + childDistribution.getSelectedIndexId(), + childDistribution.getPartitionIds() + ) + ); + } + } + for (int i = 0; i < childrenDistribution.size(); i++) { DistributionSpec childDistribution = childrenDistribution.get(i); if (!(childDistribution instanceof DistributionSpecHash)) { diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java index 18e38d1abe41b0..bdecb5e66f04cd 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/ChildrenPropertiesRegulator.java @@ -57,6 +57,7 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -72,6 +73,7 @@ * to process must shuffle except project and filter */ public class ChildrenPropertiesRegulator extends PlanVisitor>, Void> { + private static final Logger LOG = LogManager.getLogger(ChildrenPropertiesRegulator.class); private final GroupExpression parent; private final List children; private final List originChildrenProperties; @@ -646,19 +648,102 @@ public List> visitPhysicalSetOperation(PhysicalSetOpera } else if (requiredDistributionSpec instanceof DistributionSpecHash) { // TODO: should use the most common hash spec as basic DistributionSpecHash basic = (DistributionSpecHash) requiredDistributionSpec; - for (int i = 0; i < originChildrenProperties.size(); i++) { - DistributionSpecHash current - = (DistributionSpecHash) originChildrenProperties.get(i).getDistributionSpec(); - if (current.getShuffleType() != ShuffleType.EXECUTION_BUCKETED - || !bothSideShuffleKeysAreSameOrder(basic, current, - (DistributionSpecHash) requiredProperties.get(0).getDistributionSpec(), - (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec())) { - PhysicalProperties target = calAnotherSideRequired( - ShuffleType.EXECUTION_BUCKETED, basic, current, - (DistributionSpecHash) requiredProperties.get(0).getDistributionSpec(), - (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec()); + int bucketShuffleBasicIndex = -1; + double basicRowCount = -1; + + // find the bucket shuffle basic index: the largest natural / storage-bucketed child + // keeps its bucket distribution, every other child is bucket-shuffled to it. + // RequestPropertyDeriver only asks ShuffleType.REQUIRE when set-op bucket shuffle + // is allowed, so the required shuffle type is the single source of truth here: + // for any other required type keep bucketShuffleBasicIndex = -1 and fall back to + // the execution-bucketed (partitioned) shuffle below. + // isBucketShuffleDownGrade reuses the join-side heuristics on purpose, including + // the enable_bucket_shuffle_join switch and bucket_shuffle_downgrade_ratio: bucket + // shuffle for set operation belongs to the same optimization family as bucket + // shuffle join, so the join switches govern both instead of introducing a separate + // session variable. + if (basic.getShuffleType() == ShuffleType.REQUIRE) { + try { + ImmutableSet supportBucketShuffleTypes = ImmutableSet.of( + ShuffleType.NATURAL, + ShuffleType.STORAGE_BUCKETED + ); + for (int i = 0; i < originChildrenProperties.size(); i++) { + PhysicalProperties originChildrenProperty = originChildrenProperties.get(i); + DistributionSpec childDistribution = originChildrenProperty.getDistributionSpec(); + // The table id is deliberately not checked here: DistributionSpecHash.satisfy + // aligns the other children by shuffle type and columns regardless of the table + // id, and a basic child with an unknown layout (a hash join output with the table + // id cleared to -1 by withShuffleTypeAndForbidColocateJoin) produces a + // STORAGE_BUCKETED output, which couldColocateJoin never co-locates (it requires + // NATURAL on both sides) so it cannot mislead a parent into a wrong co-location. + if (childDistribution instanceof DistributionSpecHash + && supportBucketShuffleTypes.contains( + ((DistributionSpecHash) childDistribution).getShuffleType()) + && canMapBucketKeysToRequire((DistributionSpecHash) childDistribution, + (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec()) + && !(isBucketShuffleDownGrade(setOperation.child(i)))) { + Statistics stats = setOperation.child(i).getStats(); + double rowCount = stats.getRowCount(); + if (rowCount > basicRowCount) { + basicRowCount = rowCount; + bucketShuffleBasicIndex = i; + } + } + } + } catch (Throwable t) { + // catch stats exception + LOG.warn("Can not find the most (bucket num, rowCount): " + t, t); + bucketShuffleBasicIndex = -1; + } + } + + if (bucketShuffleBasicIndex >= 0) { + // use bucket shuffle + DistributionSpecHash notShuffleSideRequire + = (DistributionSpecHash) requiredProperties.get(bucketShuffleBasicIndex) + .getDistributionSpec(); + + DistributionSpecHash notNeedShuffleOutput + = (DistributionSpecHash) originChildrenProperties.get(bucketShuffleBasicIndex) + .getDistributionSpec(); + + for (int i = 0; i < originChildrenProperties.size(); i++) { + if (i == bucketShuffleBasicIndex) { + continue; + } + + DistributionSpecHash currentRequire + = (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec(); + + // The enforced child is bucket-shuffled to the basic child's buckets by the + // storage hash on shuffleSideIds. Only the shuffle type and the column order + // carry the alignment: DistributionSpecHash.satisfy compares shuffle type and + // columns (the equivalence set), never the storage layout, and the set operation + // output is STORAGE_BUCKETED which couldColocateJoin never co-locates, so the + // basic child's table / index / partition ids are inert here. + List shuffleSideIds = calAnotherSideRequiredShuffleIds( + notNeedShuffleOutput, notShuffleSideRequire, currentRequire); + PhysicalProperties target = new PhysicalProperties( + new DistributionSpecHash(shuffleSideIds, ShuffleType.STORAGE_BUCKETED)); updateChildEnforceAndCost(i, target); } + } else { + // use partitioned shuffle + for (int i = 0; i < originChildrenProperties.size(); i++) { + DistributionSpecHash current + = (DistributionSpecHash) originChildrenProperties.get(i).getDistributionSpec(); + if (current.getShuffleType() != ShuffleType.EXECUTION_BUCKETED + || !bothSideShuffleKeysAreSameOrder(basic, current, + (DistributionSpecHash) requiredProperties.get(0).getDistributionSpec(), + (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec())) { + PhysicalProperties target = calAnotherSideRequired( + ShuffleType.EXECUTION_BUCKETED, basic, current, + (DistributionSpecHash) requiredProperties.get(0).getDistributionSpec(), + (DistributionSpecHash) requiredProperties.get(i).getDistributionSpec()); + updateChildEnforceAndCost(i, target); + } + } } } return ImmutableList.of(originChildrenProperties); @@ -733,6 +818,32 @@ private boolean bothSideShuffleKeysAreSameOrder( } } + /** + * Whether every bucket key of the candidate basic child can be mapped into the child's + * required hash columns (directly or through its equivalence sets). When the candidate's + * bucket key is wider than the set operation output (e.g. a table bucketed by (k, v) + * feeding INTERSECT on k only), the mapping is impossible and choosing it as the basic + * child would fail calAnotherSideRequiredShuffleIds, so the caller falls back to the + * execution-bucketed shuffle instead. + */ + private boolean canMapBucketKeysToRequire(DistributionSpecHash childOutput, DistributionSpecHash childRequired) { + for (ExprId scanId : childOutput.getOrderedShuffledColumns()) { + int index = childRequired.getOrderedShuffledColumns().indexOf(scanId); + if (index == -1) { + for (ExprId alternativeExpr : childOutput.getEquivalenceExprIdsOf(scanId)) { + index = childRequired.getOrderedShuffledColumns().indexOf(alternativeExpr); + if (index != -1) { + break; + } + } + } + if (index == -1) { + return false; + } + } + return true; + } + /** * calculate the shuffle side hash key right orders. * For example, diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java index e95eabceec692a..ba4961a4535aea 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/properties/RequestPropertyDeriver.java @@ -68,6 +68,7 @@ import org.apache.doris.nereids.util.AggregateUtils; import org.apache.doris.nereids.util.JoinUtils; import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; import org.apache.doris.statistics.Statistics; import com.google.common.base.Preconditions; @@ -341,16 +342,18 @@ public Void visitPhysicalSetOperation(PhysicalSetOperation setOperation, PlanCon if (distributionRequestFromParent instanceof DistributionSpecHash) { // shuffle according to parent require DistributionSpecHash distributionSpecHash = (DistributionSpecHash) distributionRequestFromParent; - addRequestPropertyToChildren(createHashRequestAccordingToParent( - setOperation, distributionSpecHash, context)); + addRequestPropertyToChildren(downgradeRequireWhenBucketShuffleNotAllowed( + createHashRequestAccordingToParent(setOperation, distributionSpecHash, context))); } else { // shuffle all column // TODO: for wide table, may be we should add a upper limit of shuffle columns + ShuffleType setOperationShuffleType = setOperationBucketShuffleAllowed() + ? ShuffleType.REQUIRE : ShuffleType.EXECUTION_BUCKETED; addRequestPropertyToChildren(setOperation.getRegularChildrenOutputs().stream() .map(childOutputs -> childOutputs.stream() .map(SlotReference::getExprId) .collect(ImmutableList.toImmutableList())) - .map(l -> PhysicalProperties.createHash(l, ShuffleType.EXECUTION_BUCKETED)) + .map(l -> PhysicalProperties.createHash(l, setOperationShuffleType)) .collect(Collectors.toList())); } return null; @@ -370,9 +373,8 @@ public Void visitPhysicalUnion(PhysicalUnion union, PlanContext context) { DistributionSpec distributionRequestFromParent = requestPropertyFromParent.getDistributionSpec(); if (distributionRequestFromParent instanceof DistributionSpecHash) { DistributionSpecHash distributionSpecHash = (DistributionSpecHash) distributionRequestFromParent; - List requestHash - = createHashRequestAccordingToParent(union, distributionSpecHash, context); - addRequestPropertyToChildren(requestHash); + addRequestPropertyToChildren(downgradeRequireWhenBucketShuffleNotAllowed( + createHashRequestAccordingToParent(union, distributionSpecHash, context))); } } @@ -577,6 +579,43 @@ private boolean shouldUseParent(List parentHashExprIds, PhysicalHashAggr return combinedNdv > AggregateUtils.LOW_NDV_THRESHOLD; } + /** + * A ShuffleType.REQUIRE request lets ChildrenPropertiesRegulator choose the bucket + * shuffle alternative for the set operation. That needs either no local shuffle at all + * (every pipeline runs a single task per instance, so the bucket alignment holds + * naturally) or the FE local-shuffle planner (which plans the correct bucket-hash + * local exchanges): with the BE-planned local shuffle the backend cannot infer the + * correct local shuffle type for the set sink/probe and computes wrong results. + * It also requires the nereids distribute planner: the legacy coordinator only + * supports bucket-shuffle-partitioned sinks whose dest fragment contains a bucket + * shuffle join. + */ + private boolean setOperationBucketShuffleAllowed() { + return connectContext != null + && SessionVariable.canUseNereidsDistributePlanner(connectContext) + && (!connectContext.getSessionVariable().isEnableLocalShuffle() + || connectContext.getSessionVariable().isEnableLocalShufflePlanner()); + } + + /** + * The parent may pass ShuffleType.REQUIRE down through + * {@link #createHashRequestAccordingToParent}; downgrade it to EXECUTION_BUCKETED so the + * regulator does not pick the bucket shuffle alternative when it is not allowed. + */ + private List downgradeRequireWhenBucketShuffleNotAllowed( + List requests) { + if (setOperationBucketShuffleAllowed()) { + return requests; + } + return requests.stream().map(request -> { + DistributionSpecHash requestHash = (DistributionSpecHash) request.getDistributionSpec(); + return requestHash.getShuffleType() == ShuffleType.REQUIRE + ? PhysicalProperties.createHash( + requestHash.getOrderedShuffledColumns(), ShuffleType.EXECUTION_BUCKETED) + : request; + }).collect(Collectors.toList()); + } + private List createHashRequestAccordingToParent( SetOperation setOperation, DistributionSpecHash distributionRequestFromParent, PlanContext context) { List requiredPropertyList = diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java index 9a6782e95a7044..47c38dc0fa4551 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJob.java @@ -31,11 +31,14 @@ import org.apache.doris.nereids.util.Utils; import org.apache.doris.planner.ExchangeNode; import org.apache.doris.planner.HashJoinNode; +import org.apache.doris.planner.IntersectNode; import org.apache.doris.planner.OlapScanNode; import org.apache.doris.planner.PlanFragment; import org.apache.doris.planner.ScanNode; +import org.apache.doris.planner.SetOperationNode; import org.apache.doris.qe.ConnectContext; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; @@ -164,7 +167,9 @@ protected List insideMachineParallelization( // so we should fill up this instance List hashJoinNodes = fragment.getPlanRoot() .collectInCurrentFragment(HashJoinNode.class::isInstance); - if (shouldFillUpInstances(hashJoinNodes)) { + List setOperationNodes = fragment.getPlanRoot() + .collectInCurrentFragment(SetOperationNode.class::isInstance); + if (shouldFillUpInstances(hashJoinNodes, setOperationNodes)) { return fillUpInstances(assignedJobs); } @@ -294,7 +299,8 @@ private void collectScanRanges( } } - private boolean shouldFillUpInstances(List hashJoinNodes) { + @VisibleForTesting + static boolean shouldFillUpInstances(List hashJoinNodes, List setOperationNodes) { for (HashJoinNode hashJoinNode : hashJoinNodes) { if (!hashJoinNode.isBucketShuffle()) { continue; @@ -308,6 +314,20 @@ private boolean shouldFillUpInstances(List hashJoinNodes) { return true; } } + + for (SetOperationNode setOperationNode : setOperationNodes) { + // INTERSECT does not need missing-bucket receiver fill-up: a bucket the basic child + // does not scan is empty in the anchor, so the intersect result for that bucket is + // empty and the other children's rows shuffled there produce nothing. Note the node + // here is the legacy planner IntersectNode, not the Nereids algebra Intersect. + if (setOperationNode instanceof IntersectNode) { + continue; + } + if (setOperationNode.isBucketShuffle()) { + return true; + } + } + return false; } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/JoinUtils.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/JoinUtils.java index fc12594452aeb9..d91532d3e507b3 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/util/JoinUtils.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/util/JoinUtils.java @@ -207,7 +207,10 @@ && isStorageBucketed(join.left().getPhysicalProperties())) { return false; } - private static boolean isStorageBucketed(PhysicalProperties physicalProperties) { + /** + * Whether the given physical properties advertise a storage-bucketed distribution. + */ + public static boolean isStorageBucketed(PhysicalProperties physicalProperties) { DistributionSpec distributionSpec = physicalProperties.getDistributionSpec(); if (!(distributionSpec instanceof DistributionSpecHash)) { return false; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java index 9b69b87be393e9..69e6833d0c2a28 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/LocalExchangeNode.java @@ -283,8 +283,15 @@ public LocalExchangeType preferType() { @Override public LocalExchangeTypeRequire autoRequireHash() { - if (requireType == LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE - || requireType == LocalExchangeType.BUCKET_HASH_SHUFFLE) { + // Callers are pass-through operators (union / streaming agg / sort) that report + // resolveExchangeType(requireChild) upward while leaving row placement to their + // children. A specific hash require must therefore be forwarded as-is: degrading + // LOCAL_EXECUTION_HASH_SHUFFLE to the generic RequireHash lets a bucket-distributed + // child satisfy the requirement and keep its bucket placement, while the operator + // still claims LOCAL_EXECUTION_HASH_SHUFFLE to its parent — the parent (e.g. a + // bucket join upgraded to local hash) then skips its re-align local exchange and + // the mixed placements compute wrong results. + if (requireType.isHashShuffle()) { return this; } return RequireHash.INSTANCE; diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java index 9772d18947c63e..64e1cd90eab9d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java @@ -73,6 +73,8 @@ public abstract class SetOperationNode extends PlanNode { protected final TupleId tupleId; + private DistributionMode distributionMode = DistributionMode.PARTITIONED; + private boolean isColocate = false; protected SetOperationNode(PlanNodeId id, TupleId tupleId, String planNodeName, StatisticalType statisticalType) { @@ -201,6 +203,14 @@ public int getNumInstances() { return numInstances; } + public DistributionMode getDistributionMode() { + return distributionMode; + } + + public void setDistributionMode(DistributionMode distributionMode) { + this.distributionMode = distributionMode; + } + public boolean isBucketShuffle() { return distributionMode.equals(DistributionMode.BUCKET_SHUFFLE); } @@ -229,7 +239,15 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans : LocalExchangeType.NOOP; } else { // Intersect / Except - if (AddLocalExchange.isColocated(this)) { + if (AddLocalExchange.isColocated(this) || isBucketShuffle()) { + // COLOCATE / BUCKET_SHUFFLE: every child is distributed by the basic child's + // storage bucket function (basic side scans buckets directly, other sides come + // from bucket-shuffle exchanges), so all children must stay aligned by that + // bucket function locally. requireBucketHash keeps bucket-distributed children + // as-is and re-aligns a serial (NOOP-claim) child with a BUCKET_HASH_SHUFFLE + // local exchange — same pattern as HashJoinNode's colocate/bucket-shuffle + // branch. An execution-hash require here would locally re-partition one side + // by a different hash function and break build/probe alignment. requireChild = LocalExchangeTypeRequire.requireBucketHash(); outputType = LocalExchangeType.BUCKET_HASH_SHUFFLE; } else { diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java new file mode 100644 index 00000000000000..e5020e2b8a695e --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/distribute/worker/job/UnassignedScanBucketOlapTableJobTest.java @@ -0,0 +1,157 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.trees.plans.distribute.worker.job; + +import org.apache.doris.nereids.StatementContext; +import org.apache.doris.nereids.trees.plans.distribute.worker.ScanWorkerSelector; +import org.apache.doris.planner.DataPartition; +import org.apache.doris.planner.ExceptNode; +import org.apache.doris.planner.ExchangeNode; +import org.apache.doris.planner.HashJoinNode; +import org.apache.doris.planner.IntersectNode; +import org.apache.doris.planner.OlapScanNode; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.SetOperationNode; +import org.apache.doris.planner.UnionNode; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.OriginStatement; +import org.apache.doris.thrift.TUniqueId; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.BitSet; +import java.util.List; + +public class UnassignedScanBucketOlapTableJobTest { + + @Test + public void testDegreeOfParallelismWithExchangeNodes() { + ConnectContext connectContext = new ConnectContext(); + connectContext.setThreadLocalInfo(); + connectContext.setQueryId(new TUniqueId(1, 1)); + connectContext.getSessionVariable().parallelPipelineTaskNum = 1; + connectContext.getSessionVariable().colocateMaxParallelNum = 128; + StatementContext statementContext = new StatementContext( + connectContext, new OriginStatement("select * from t", 0)); + connectContext.setStatementContext(statementContext); + + OlapScanNode olapScanNode = Mockito.mock(OlapScanNode.class); + Mockito.when(olapScanNode.getTotalTabletsNum()).thenReturn(100L); + + PlanFragment fragment = Mockito.mock(PlanFragment.class); + Mockito.when(fragment.getDataPartition()).thenReturn(DataPartition.RANDOM); + Mockito.when(fragment.getParallelExecNum()).thenReturn(5); + + ScanWorkerSelector scanWorkerSelector = Mockito.mock(ScanWorkerSelector.class); + + // Non-empty exchangeToChildJob simulates bucket shuffle join fragment. + ExchangeNode exchangeNode = Mockito.mock(ExchangeNode.class); + UnassignedJob mockChild = Mockito.mock(UnassignedJob.class); + Mockito.when(mockChild.getAllChildrenTypes()).thenReturn(new BitSet()); + ArrayListMultimap exchangeToChildJob = ArrayListMultimap.create(); + exchangeToChildJob.put(exchangeNode, mockChild); + + UnassignedScanBucketOlapTableJob unassignedJob = new UnassignedScanBucketOlapTableJob( + statementContext, + fragment, + ImmutableList.of(olapScanNode), + exchangeToChildJob, + scanWorkerSelector + ); + + // maxParallel = 3 (buckets), parallelExecNum = 5 + // Base class: min(maxParallel, max(parallelExecNum, 1)) = min(3, 5) = 3 + int result = unassignedJob.degreeOfParallelism(3, false); + Assertions.assertEquals(3, result); + } + + @Test + public void testDegreeOfParallelismWithoutExchangeNodes() { + ConnectContext connectContext = new ConnectContext(); + connectContext.setThreadLocalInfo(); + connectContext.setQueryId(new TUniqueId(2, 2)); + connectContext.getSessionVariable().parallelPipelineTaskNum = 1; + connectContext.getSessionVariable().colocateMaxParallelNum = 128; + StatementContext statementContext = new StatementContext( + connectContext, new OriginStatement("select * from t", 0)); + connectContext.setStatementContext(statementContext); + + OlapScanNode olapScanNode = Mockito.mock(OlapScanNode.class); + Mockito.when(olapScanNode.getTotalTabletsNum()).thenReturn(100L); + Mockito.when(olapScanNode.shouldUseOneInstance(Mockito.any())).thenReturn(false); + + PlanFragment fragment = Mockito.mock(PlanFragment.class); + Mockito.when(fragment.getDataPartition()).thenReturn(DataPartition.RANDOM); + Mockito.when(fragment.getParallelExecNum()).thenReturn(5); + + ScanWorkerSelector scanWorkerSelector = Mockito.mock(ScanWorkerSelector.class); + + // Empty exchangeToChildJob simulates pure colocate scan (no exchange nodes). + ArrayListMultimap exchangeToChildJob = ArrayListMultimap.create(); + + UnassignedScanBucketOlapTableJob unassignedJob = new UnassignedScanBucketOlapTableJob( + statementContext, + fragment, + ImmutableList.of(olapScanNode), + exchangeToChildJob, + scanWorkerSelector + ); + + // Tablet strategy: min(max(tabletNum=100, parallelExecNum=5), colocateMaxParallelNum=128) = 100 + int result = unassignedJob.degreeOfParallelism(3, false); + Assertions.assertEquals(100, result); + } + + @Test + public void testShouldFillUpInstancesSkipsBucketShuffleIntersectOnly() { + List noJoins = ImmutableList.of(); + + // A bucket-shuffle INTERSECT must NOT trigger missing-bucket receiver fill-up: a bucket the + // basic child does not scan is empty in the anchor, so the intersect result for that bucket + // is empty and the other children's rows shuffled there produce nothing. The skip must match + // the legacy planner IntersectNode; the translated node is never an instance of the Nereids + // algebra Intersect, so checking that type would silently never skip. + IntersectNode bucketShuffleIntersect = Mockito.mock(IntersectNode.class); + Mockito.when(bucketShuffleIntersect.isBucketShuffle()).thenReturn(true); + Assertions.assertFalse(UnassignedScanBucketOlapTableJob.shouldFillUpInstances( + noJoins, ImmutableList.of(bucketShuffleIntersect))); + + // Bucket-shuffle UNION / EXCEPT still need fill-up: the other children's rows shuffled into + // buckets the basic child does not scan must be received to be produced (union) or to + // subtract against (except) correctly. + UnionNode bucketShuffleUnion = Mockito.mock(UnionNode.class); + Mockito.when(bucketShuffleUnion.isBucketShuffle()).thenReturn(true); + Assertions.assertTrue(UnassignedScanBucketOlapTableJob.shouldFillUpInstances( + noJoins, ImmutableList.of(bucketShuffleUnion))); + + ExceptNode bucketShuffleExcept = Mockito.mock(ExceptNode.class); + Mockito.when(bucketShuffleExcept.isBucketShuffle()).thenReturn(true); + Assertions.assertTrue(UnassignedScanBucketOlapTableJob.shouldFillUpInstances( + noJoins, ImmutableList.of(bucketShuffleExcept))); + + // A union that did not choose bucket shuffle does not trigger fill-up. + UnionNode plainUnion = Mockito.mock(UnionNode.class); + Mockito.when(plainUnion.isBucketShuffle()).thenReturn(false); + Assertions.assertFalse(UnassignedScanBucketOlapTableJob.shouldFillUpInstances( + noJoins, ImmutableList.of(plainUnion))); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java index 5ffe61ce8f578e..e171ebe166ce2b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -53,6 +53,33 @@ public class LocalShuffleNodeCoverageTest { private static final AtomicInteger NEXT_ID = new AtomicInteger(1); + @Test + public void testRequireSpecificAutoRequireHashPreservesSpecificHash() { + // Pass-through operators (union / streaming agg / sort) forward their parent's specific + // hash requirement downward via autoRequireHash() while leaving row placement to their + // children. Every hash flavour must be forwarded unchanged: degrading a specific + // LOCAL_EXECUTION_HASH_SHUFFLE requirement to the generic RequireHash would let a + // bucket-distributed child satisfy it and keep its bucket placement while the operator + // still advertised LOCAL_EXECUTION_HASH_SHUFFLE upward, so a bucket join upgraded to + // local hash above it would skip its realign local exchange and compute wrong results. + for (LocalExchangeType hashType : new LocalExchangeType[] { + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, + LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, + LocalExchangeType.BUCKET_HASH_SHUFFLE}) { + LocalExchangeNode.RequireSpecific require = new LocalExchangeNode.RequireSpecific(hashType); + LocalExchangeTypeRequire forwarded = require.autoRequireHash(); + Assertions.assertSame(require, forwarded, + "specific hash require " + hashType + " must be forwarded unchanged"); + Assertions.assertEquals(hashType, forwarded.preferType()); + } + + // A non-hash specific require still relaxes to the generic RequireHash, whose preferType + // is GLOBAL_EXECUTION_HASH_SHUFFLE. + LocalExchangeTypeRequire relaxed = + new LocalExchangeNode.RequireSpecific(LocalExchangeType.PASSTHROUGH).autoRequireHash(); + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, relaxed.preferType()); + } + @Test public void testSelectNode() { PlanTranslatorContext ctx = new PlanTranslatorContext(); @@ -613,6 +640,28 @@ public void testSetOperationAndAssertNumRowsNode() { Assertions.assertSame(exceptLeft, exceptNode.getChild(0)); Assertions.assertSame(exceptRight, exceptNode.getChild(1)); + // Bucket-shuffle IntersectNode (not colocated): exercises the `|| isBucketShuffle()` leg. + // Every child is distributed by the basic child's storage bucket function (via bucket-shuffle + // exchanges), so the output is BUCKET_HASH_SHUFFLE and each serial (NOOP) child is re-aligned + // with a BUCKET_HASH_SHUFFLE local exchange. Without the isBucketShuffle() branch this would + // fall into the partitioned (GLOBAL hash) leg and re-partition one side by a different hash + // function, breaking alignment. setColocate(false) keeps isColocated() false (SetOperationNode + // returns false immediately when isColocate() is false), so the branch is reached through + // isBucketShuffle() alone. + IntersectNode bucketIntersect = new IntersectNode(nextPlanNodeId(), + new TupleId(NEXT_ID.getAndIncrement())); + bucketIntersect.setColocate(false); + bucketIntersect.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + TrackingPlanNode bucketLeft = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode bucketRight = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + bucketIntersect.addChild(bucketLeft); + bucketIntersect.addChild(bucketRight); + Pair bucketIntersectOutput = bucketIntersect.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketIntersectOutput.second); + assertChildLocalExchangeType(bucketIntersect, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + assertChildLocalExchangeType(bucketIntersect, 1, LocalExchangeType.BUCKET_HASH_SHUFFLE); + TrackingPlanNode assertChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); AssertNumRowsElement assertElement = Mockito.mock(AssertNumRowsElement.class); Mockito.when(assertElement.getDesiredNumOfRows()).thenReturn(1L); diff --git a/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out new file mode 100644 index 00000000000000..1ec75925ec2da0 --- /dev/null +++ b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out @@ -0,0 +1,304 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !bucket_shuffle_union_with_all_column_shape -- +PhysicalResultSink +--hashJoin[INNER_JOIN colocated] hashCondition=((a.id = b.id)) otherCondition=() +----PhysicalUnion[bucketShuffle] +------PhysicalOlapScan[bucket_shuffle_set_operation1] +------PhysicalDistribute[DistributionSpecHash] +--------PhysicalOlapScan[bucket_shuffle_set_operation2] +----PhysicalOlapScan[bucket_shuffle_set_operation1] + +Hint log: +Used: [shuffle]_1 +UnUsed: +SyntaxError: + +-- !bucket_shuffle_union_with_all_column_result -- +1 1 1 1 +1 1 1 1 +2 2 2 2 +2 2 2 2 +3 3 3 3 +3 3 3 3 + +-- !bucket_shuffle_intersect_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalProject +------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_intersect_result -- +1 +2 +3 + +-- !bucket_shuffle_intersect_with_all_column_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_intersect_with_all_column_result -- +1 1 +2 2 +3 3 + +-- !no_bucket_shuffle_intersect_shape -- +PhysicalResultSink +--PhysicalIntersect +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !no_bucket_shuffle_intersect_result -- +1 +2 +3 + +-- !bucket_shuffle_to_left_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalProject +------PhysicalOlapScan[bucket_shuffle_set_operation3] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation1] + +-- !bucket_shuffle_to_left_result -- +1 +2 +3 + +-- !bucket_shuffle_to_right_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalProject +------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation3] + +-- !bucket_shuffle_to_right_result -- +1 +2 +3 + +-- !bucket_shuffle_except_1_shape -- +PhysicalResultSink +--PhysicalExcept[bucketShuffle] +----PhysicalProject +------filter((bucket_shuffle_set_operation1.id = 1)) +--------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------filter((bucket_shuffle_set_operation2.id = 1)) +----------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_except_1_result -- + +-- !bucket_shuffle_except_2_shape -- +PhysicalResultSink +--PhysicalExcept[bucketShuffle] +----PhysicalProject +------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------filter((bucket_shuffle_set_operation2.id = 1)) +----------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_except_2_result -- +2 +3 + +-- !bucket_shuffle_join_as_basic_child_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalProject +------hashJoin[INNER_JOIN broadcast] hashCondition=((a.id = b.id)) otherCondition=() +--------PhysicalProject +----------PhysicalOlapScan[bucket_shuffle_set_operation1(a)] +--------PhysicalProject +----------PhysicalOlapScan[bucket_shuffle_set_operation2(b)] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation3] + +-- !bucket_shuffle_join_as_basic_child_result -- +1 +2 +3 + +-- !bucket_shuffle_nested_set_operation_shape -- +PhysicalResultSink +--PhysicalUnion +----PhysicalDistribute[DistributionSpecExecutionAny] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation3] +----PhysicalDistribute[DistributionSpecExecutionAny] +------PhysicalIntersect[bucketShuffle] +--------PhysicalProject +----------hashJoin[INNER_JOIN broadcast] hashCondition=((a.id = b.id)) otherCondition=() +------------PhysicalProject +--------------PhysicalOlapScan[bucket_shuffle_set_operation1(a)] +------------PhysicalProject +--------------PhysicalOlapScan[bucket_shuffle_set_operation2(b)] +--------PhysicalDistribute[DistributionSpecHash] +----------PhysicalProject +------------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_nested_set_operation_result -- +1 +1 +2 +2 +3 +3 + +-- !bucket_shuffle_when_local_shuffle_off_shape -- +PhysicalResultSink +--PhysicalIntersect[bucketShuffle] +----PhysicalProject +------PhysicalOlapScan[bucket_shuffle_set_operation1] +----PhysicalDistribute[DistributionSpecHash] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation2] + +-- !bucket_shuffle_when_local_shuffle_off_result -- +1 +2 +3 + +-- !union_parent_hash_shape_when_local_shuffle_planner_off -- +PhysicalResultSink +--PhysicalProject +----hashJoin[INNER_JOIN bucketShuffle] hashCondition=((u.id = b.id)) otherCondition=() +------PhysicalUnion +--------PhysicalDistribute[DistributionSpecHash] +----------PhysicalProject +------------PhysicalOlapScan[bucket_shuffle_set_operation1] +--------PhysicalDistribute[DistributionSpecHash] +----------PhysicalProject +------------PhysicalOlapScan[bucket_shuffle_set_operation2] +------PhysicalProject +--------PhysicalOlapScan[bucket_shuffle_set_operation3(b)] + +Hint log: +Used: [shuffle]_1 +UnUsed: +SyntaxError: + +-- !union_parent_hash_when_local_shuffle_planner_off -- +1 +1 +2 +2 +3 +3 + +-- !plain_intersect_when_local_shuffle_planner_off -- +1 +2 +3 + +-- !plain_intersect_when_nereids_distribute_planner_off -- +1 +2 +3 + +-- !union_parent_hash_when_nereids_distribute_planner_off -- +1 +1 +2 +2 +3 +3 + +-- !intersect_right_basic_parent_hash -- +1 +2 +3 + +-- !bucket_shuffle_union_fill_up -- +0 +0 +1 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +2 +2 +3 +4 +4 +5 +6 +6 +7 +8 +9 + +-- !bucket_shuffle_equivalent_key_fill_up -- +0 +1 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +2 +2 +3 +4 +5 +6 +7 +8 +9 + +-- !bucket_shuffle_two_equivalent_keys -- +0 0 +1 1 +10 10 +11 11 +12 12 +13 13 +14 14 +15 15 +16 16 +17 17 +18 18 +19 19 +2 2 +2 2 +3 3 +4 4 +5 5 +6 6 +7 7 +8 8 +9 9 + +-- !multi_column_bucket_key_intersect -- +1 +2 +3 + diff --git a/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy new file mode 100644 index 00000000000000..ee118bccbd5730 --- /dev/null +++ b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy @@ -0,0 +1,442 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("bucket_shuffle_set_operation") { + multi_sql """ + drop table if exists bucket_shuffle_set_operation1; + create table bucket_shuffle_set_operation1(id int, value int) distributed by hash(id) buckets 10 properties('replication_num'='1'); + insert into bucket_shuffle_set_operation1 values(1, 1), (2, 2), (3, 3); + + drop table if exists bucket_shuffle_set_operation2; + create table bucket_shuffle_set_operation2(id int, value int) distributed by hash(id) buckets 10 properties('replication_num'='1'); + insert into bucket_shuffle_set_operation2 values(1, 1), (2, 2), (3, 3); + + drop table if exists bucket_shuffle_set_operation3; + create table bucket_shuffle_set_operation3(id int, value int) distributed by hash(id) buckets 11 properties('replication_num'='1'); + insert into bucket_shuffle_set_operation3 values(1, 1), (2, 2), (3, 3); + + set runtime_filter_mode=off; + """ + + // make bucket shuffle set operation stable + sql "set parallel_pipeline_task_num=5" + // disable the bucket shuffle downgrade so the chosen shapes do not depend on the + // backend count / parallelism of the environment running this suite + sql "set bucket_shuffle_downgrade_ratio=0" + + def checkShapeAndResult = { String tag, String sqlStr -> + quickTest(tag + "_shape", "explain shape plan " + sqlStr) + quickTest(tag + "_result", sqlStr, true) + } + + checkShapeAndResult("bucket_shuffle_union_with_all_column", """ + select * + from ( + select * from bucket_shuffle_set_operation1 + union all + select * from bucket_shuffle_set_operation2 + )a + join[shuffle] ( + select * + from bucket_shuffle_set_operation1 + )b + on a.id=b.id""") + + checkShapeAndResult("bucket_shuffle_intersect", """ + select id from bucket_shuffle_set_operation1 + intersect + select id from bucket_shuffle_set_operation2""") + + checkShapeAndResult("bucket_shuffle_intersect_with_all_column", """ + select * from bucket_shuffle_set_operation1 + intersect + select * from bucket_shuffle_set_operation2""") + + checkShapeAndResult("no_bucket_shuffle_intersect", """ + select value from bucket_shuffle_set_operation1 + intersect + select value from bucket_shuffle_set_operation2""") + + checkShapeAndResult("bucket_shuffle_to_left", """ + select id from bucket_shuffle_set_operation3 + intersect + select id from bucket_shuffle_set_operation1 + """) + + checkShapeAndResult("bucket_shuffle_to_right", """ + select id from bucket_shuffle_set_operation1 + intersect + select id from bucket_shuffle_set_operation3 + """) + + checkShapeAndResult("bucket_shuffle_except_1", """ + select id from bucket_shuffle_set_operation1 where id=1 + except + select id from bucket_shuffle_set_operation2 + """) + + checkShapeAndResult("bucket_shuffle_except_2", """ + select id from bucket_shuffle_set_operation1 + except + select id from bucket_shuffle_set_operation2 where id=1 + """) + + // The basic child of a bucket-shuffle set operation can be a join output instead of a + // direct scan. In that shape the local exchange planned for the basic side must still + // partition by the storage bucket function: an execution-hash local exchange would not + // align with the bucket-distributed side and the set operation would compute wrong results. + checkShapeAndResult("bucket_shuffle_join_as_basic_child", """ + select a.id from bucket_shuffle_set_operation1 a + join bucket_shuffle_set_operation2 b on a.id = b.id + intersect + select id from bucket_shuffle_set_operation3""") + + // a set operation child can itself be a set operation whose output claims a bucket + // distribution; the outer set operation must only treat its children as bucket-aligned + // when they share the same storage layout + checkShapeAndResult("bucket_shuffle_nested_set_operation", """ + select id from bucket_shuffle_set_operation3 + union all + (select a.id from bucket_shuffle_set_operation1 a + join bucket_shuffle_set_operation2 b on a.id = b.id + intersect + select id from bucket_shuffle_set_operation2)""") + + // when local shuffle is disabled entirely, every pipeline runs a single task per + // instance so the bucket alignment holds naturally and bucket shuffle is still allowed + sql "set enable_local_shuffle=false" + checkShapeAndResult("bucket_shuffle_when_local_shuffle_off", """ + select id from bucket_shuffle_set_operation1 + intersect + select id from bucket_shuffle_set_operation2""") + sql "set enable_local_shuffle=true" + + // A shuffle join above the union pushes a hash request into the union + // (createHashRequestAccordingToParent, the parent-hash request path). When the FE does not + // plan the local shuffle, that request must be downgraded so the union does not choose + // bucket shuffle, while the result stays correct. + def unionParentHashSql = """ + select b.id from ( + select id from bucket_shuffle_set_operation1 + union all + select id from bucket_shuffle_set_operation2 + ) u join[shuffle] bucket_shuffle_set_operation3 b on u.id = b.id + """ + sql "set enable_local_shuffle_planner=false" + // Golden shape: the union must stay a plain PhysicalUnion whose two children are each a + // PhysicalDistribute[DistributionSpecHash]. That is the actual proof that the parent hash + // request was pushed down into the union (createHashRequestAccordingToParent) and downgraded + // to execution hash, not merely that the PhysicalUnion line lacks the [bucketShuffle] tag. + // If that path regressed, the optimizer could instead keep an unbucketed union and add a + // single PhysicalDistribute[DistributionSpecHash] above it for the shuffle join; the golden + // shape below (union children are distributes, not direct scans) would catch that. + qt_union_parent_hash_shape_when_local_shuffle_planner_off("explain shape plan " + unionParentHashSql) + explain { + sql "shape plan " + unionParentHashSql + check { String e -> + def unionIndex = e.indexOf("PhysicalUnion") + assertTrue(unionIndex >= 0) + // the union must not be a bucket shuffle union when the FE local shuffle planner is off + assertFalse(e.substring(unionIndex, + Math.min(unionIndex + "PhysicalUnion".length() + 20, e.length())).contains("bucketShuffle")) + // and the parent hash request must have been pushed into the union: each union child + // arrives through a PhysicalDistribute[DistributionSpecHash], so no union child is a + // direct scan. + def afterUnion = e.substring(unionIndex) + def joinProbeIndex = afterUnion.indexOf("bucket_shuffle_set_operation3") + def unionSubtree = joinProbeIndex >= 0 ? afterUnion.substring(0, joinProbeIndex) : afterUnion + assertEquals(2, unionSubtree.split("PhysicalDistribute\\[DistributionSpecHash\\]", -1).length - 1) + } + } + order_qt_union_parent_hash_when_local_shuffle_planner_off unionParentHashSql + sql "set enable_local_shuffle_planner=true" + + // A plain intersect/except without a parent hash request goes through + // visitPhysicalSetOperation directly (not the parent-hash request path); with the FE local + // shuffle planner disabled it must not choose bucket shuffle either, and the result stays + // correct. + sql "set enable_local_shuffle_planner=false" + explain { + sql "shape plan select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation2" + check { String e -> + assertFalse(e.contains("bucketShuffle")) + } + } + order_qt_plain_intersect_when_local_shuffle_planner_off "select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation2" + sql "set enable_local_shuffle_planner=true" + + // Set operation bucket shuffle is gated on setOperationBucketShuffleAllowed() = + // enableLocalShufflePlanner && canUseNereidsDistributePlanner. The enable_local_shuffle_planner + // =false cases above only exercise the first conjunct; this block guards the second one. With + // local shuffle and the FE local-shuffle planner both enabled but the Nereids distribute planner + // disabled, the set operation must still downgrade (no [bucketShuffle]) and keep correct results. + sql "set enable_local_shuffle=true" + sql "set enable_local_shuffle_planner=true" + sql "set enable_nereids_distribute_planner=false" + // plain intersect goes through visitPhysicalSetOperation directly + explain { + sql "shape plan select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation2" + check { String e -> + assertFalse(e.contains("bucketShuffle")) + } + } + order_qt_plain_intersect_when_nereids_distribute_planner_off "select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation2" + // the parent-hash union path (createHashRequestAccordingToParent) must downgrade too + explain { + sql "shape plan " + unionParentHashSql + check { String e -> + def unionIndex = e.indexOf("PhysicalUnion") + assertTrue(unionIndex >= 0) + assertFalse(e.substring(unionIndex, + Math.min(unionIndex + "PhysicalUnion".length() + 20, e.length())).contains("bucketShuffle")) + } + } + order_qt_union_parent_hash_when_nereids_distribute_planner_off unionParentHashSql + sql "set enable_nereids_distribute_planner=true" + + // The right child can be selected as the bucket-shuffle basic child (larger row count). The + // set operation output must then advertise a non-specific distribution rather than a plain + // execution hash: otherwise two such set operations with different storage layouts are + // co-located under a join and fail bucket assignment ("Can not find tablet ... in the + // bucket"). r1 / r2 are larger than the small tables so they become the basic child on the + // right, and they are different tables so their bucket layouts differ. + sql "drop table if exists bucket_shuffle_set_operation_r1" + sql "create table bucket_shuffle_set_operation_r1(id int) distributed by hash(id) buckets 10 properties('replication_num'='1')" + sql "insert into bucket_shuffle_set_operation_r1 select number from numbers('number'='20')" + sql "drop table if exists bucket_shuffle_set_operation_r2" + sql "create table bucket_shuffle_set_operation_r2(id int) distributed by hash(id) buckets 10 properties('replication_num'='1')" + sql "insert into bucket_shuffle_set_operation_r2 select number from numbers('number'='20')" + sql "alter table bucket_shuffle_set_operation_r1 modify column id set stats ('row_count'='1000', 'ndv'='1000', 'min_value'='0', 'max_value'='19')" + sql "alter table bucket_shuffle_set_operation_r2 modify column id set stats ('row_count'='1000', 'ndv'='1000', 'min_value'='0', 'max_value'='19')" + def intersectRightBasicSql = """ + select t.id from + (select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation_r1) t + join[shuffle] + (select id from bucket_shuffle_set_operation1 intersect select id from bucket_shuffle_set_operation_r2) s + on t.id = s.id""" + // Prove the shuffleToRight branch (distributeToChildIndex > 0) is actually covered, under a + // parent hash consumer. The join[shuffle] hint forces the parent to be a hash consumer of the + // two set-operation outputs. Each INTERSECT must keep the right table (r1 / r2, the larger row + // count) as the direct bucketed basic child while the left op1 is the enforced + // PhysicalDistribute[DistributionSpecHash] side. Because visitPhysicalSetOperation keeps the + // right basic child's specific storage layout, the two intersects carry different table ids + // (r1 vs r2), so the parent re-aligns them with a bucket-shuffle join instead of co-locating + // them (which, if the layout were erased to a layout-less execution hash, would fail bucket + // assignment with "Can not find tablet"). If the right side stopped being selected as basic, or + // the layout were dropped so the join co-located, these assertions fail even though the result + // stays 1, 2, 3. + explain { + sql "shape plan " + intersectRightBasicSql + check { String e -> + assertTrue(e.contains("PhysicalIntersect[bucketShuffle]")) + // the parent join re-aligns the two different-layout outputs (bucket-shuffle hash + // consumer) and does not co-locate them + assertTrue(e.contains("hashJoin[INNER_JOIN bucketShuffle]"), + "parent must re-align the two different-layout outputs with a bucket-shuffle join") + assertFalse(e.contains("colocated"), "parent must not co-locate the two right-basic intersects") + def lines = e.split("\n").findAll { it =~ /Physical|hashJoin/ } + def depth = { String s -> (s =~ /^-*/)[0].length() } + def parentOf = { int idx -> + for (int k = idx - 1; k >= 0; k--) { + if (depth(lines[k]) < depth(lines[idx])) { + return lines[k] + } + } + return "" + } + // each INTERSECT keeps r1 / r2 (the larger, right side) as its direct bucketed basic child + ["bucket_shuffle_set_operation_r1", "bucket_shuffle_set_operation_r2"].each { rt -> + int i = lines.findIndexOf { it.contains("PhysicalOlapScan[" + rt + "]") } + assertTrue(i >= 0, rt + " scan not found in shape") + assertTrue(parentOf(i).contains("PhysicalIntersect[bucketShuffle]"), + rt + " must be the direct bucketed basic child of the intersect (right basic)") + } + // and each INTERSECT's other (left) child arrives through an enforced + // PhysicalDistribute[DistributionSpecHash] directly under the intersect + int enforcedLeftCount = 0 + lines.eachWithIndex { String ln, int i -> + if (ln.contains("PhysicalDistribute[DistributionSpecHash]") + && parentOf(i).contains("PhysicalIntersect[bucketShuffle]")) { + enforcedLeftCount++ + } + } + assertTrue(enforcedLeftCount >= 2, + "each intersect must shuffle its left child through a PhysicalDistribute[DistributionSpecHash]") + } + } + order_qt_intersect_right_basic_parent_hash intersectRightBasicSql + + // A non-intersect bucket-shuffle set operation (UNION ALL) whose basic / anchor child is a + // direct bucketed scan that is bucket-pruned by an IN predicate on the distribution key, so + // it only scans a subset of the buckets. The other child is shuffled onto the anchor's + // storage layout and has rows in the buckets the pruned anchor does not scan. Because the + // union is a non-intersect bucket-shuffle set operation, UnassignedScanBucketOlapTableJob + // must fill up receiver instances for those missing buckets; without the fill-up the other + // child's rows in the missing buckets would have no destination instance and be lost. + // + // The setup forces the pruned scan to be the anchor: fill_anchor has huge injected stats so + // the IN-filtered branch still wins the largest-row-count basic-child selection, while + // fill_spread has a mismatched bucket count (11 vs 10) so it cannot be the natural anchor and + // must be shuffled. The bucket-shuffle join above the union supplies the parent hash request + // that makes the union choose bucket shuffle, and the join probe fill_probe has yet another + // bucket count (7) so the join is a real shuffle in its own fragment and does not co-locate a + // full-bucket scan into the union fragment (which would otherwise cover the missing buckets + // and hide the fill-up). + sql "drop table if exists bucket_shuffle_set_operation_fill_anchor" + sql """create table bucket_shuffle_set_operation_fill_anchor(id int) + distributed by hash(id) buckets 10 properties('replication_num'='1')""" + sql "insert into bucket_shuffle_set_operation_fill_anchor select number from numbers('number'='20')" + sql "drop table if exists bucket_shuffle_set_operation_fill_spread" + sql """create table bucket_shuffle_set_operation_fill_spread(id int) + distributed by hash(id) buckets 11 properties('replication_num'='1')""" + sql "insert into bucket_shuffle_set_operation_fill_spread select number from numbers('number'='20')" + sql "drop table if exists bucket_shuffle_set_operation_fill_probe" + sql """create table bucket_shuffle_set_operation_fill_probe(id int) + distributed by hash(id) buckets 7 properties('replication_num'='1')""" + sql "insert into bucket_shuffle_set_operation_fill_probe select number from numbers('number'='20')" + sql """alter table bucket_shuffle_set_operation_fill_anchor modify column id + set stats ('row_count'='1000000', 'ndv'='20', 'min_value'='0', 'max_value'='19')""" + sql """alter table bucket_shuffle_set_operation_fill_spread modify column id + set stats ('row_count'='50', 'ndv'='20', 'min_value'='0', 'max_value'='19')""" + sql """alter table bucket_shuffle_set_operation_fill_probe modify column id + set stats ('row_count'='30', 'ndv'='20', 'min_value'='0', 'max_value'='19')""" + def bucketShuffleUnionFillUpSql = """ + select t.id from ( + select id from bucket_shuffle_set_operation_fill_anchor where id in (0, 2, 4, 6) + union all + select id from bucket_shuffle_set_operation_fill_spread + ) t join[shuffle] bucket_shuffle_set_operation_fill_probe c on t.id = c.id""" + explain { + sql "shape plan " + bucketShuffleUnionFillUpSql + check { String e -> + assertTrue(e.contains("PhysicalUnion[bucketShuffle]")) + } + } + order_qt_bucket_shuffle_union_fill_up bucketShuffleUnionFillUpSql + + // Same missing-bucket fill-up contract, but the pruned basic child exposes its storage bucket + // key only through an equivalent slot: the union's basic child is a bucket-shuffle join output + // that projects the join key bucket_shuffle_set_operation2.id AS k, so the storage bucket column + // (fill_anchor.id) is hidden and only the equivalent k is visible in the set-operation output. + // The alignment proof must resolve the bucket key through its hash equivalence set (mirroring + // ChildrenPropertiesRegulator.canMapBucketKeysToRequire); a direct ExprId lookup would report + // the union as not bucket-aligned, drop the BUCKET_SHUFFLE marker, and skip + // UnassignedScanBucketOlapTableJob.fillUpInstances(), losing the shuffled side's rows in the + // buckets the pruned basic child does not scan. + def bucketShuffleEquivalentKeyFillUpSql = """ + select t.k from ( + select b.id as k from bucket_shuffle_set_operation_fill_anchor a + join[shuffle] bucket_shuffle_set_operation2 b on a.id = b.id + where a.id in (0, 2, 4, 6) + union all + select id from bucket_shuffle_set_operation_fill_spread + ) t join[shuffle] bucket_shuffle_set_operation_fill_probe c on t.k = c.id""" + explain { + sql "shape plan " + bucketShuffleEquivalentKeyFillUpSql + check { String e -> + assertTrue(e.contains("PhysicalUnion[bucketShuffle]")) + } + } + order_qt_bucket_shuffle_equivalent_key_fill_up bucketShuffleEquivalentKeyFillUpSql + + // Same shape but the hidden bucket key has two visible equivalent set-output columns: the join + // chain a.id = b.id = c.id makes fill_anchor.id equivalent to both projected columns x (c.id) + // and y (b.id), and the parent hashes the union output on the later one (y). The bucket key + // alignment must resolve to the same output position across children (the enforced sibling is + // shuffled by y), which is why the proof intersects each child's candidate equivalent positions + // rather than letting the basic child pick its first visible equivalent (x) and disagree with + // the sibling on y. + def bucketShuffleTwoEquivalentKeysSql = """ + select t.x, t.y from ( + select c.id as x, b.id as y from bucket_shuffle_set_operation_fill_anchor a + join[shuffle] bucket_shuffle_set_operation2 b on a.id = b.id + join[shuffle] bucket_shuffle_set_operation3 c on a.id = c.id + where a.id in (0, 2, 4, 6) + union all + select id, id from bucket_shuffle_set_operation_fill_spread + ) t join[shuffle] bucket_shuffle_set_operation_fill_probe p on t.y = p.id""" + explain { + sql "shape plan " + bucketShuffleTwoEquivalentKeysSql + check { String e -> + assertTrue(e.contains("PhysicalUnion[bucketShuffle]")) + } + } + order_qt_bucket_shuffle_two_equivalent_keys bucketShuffleTwoEquivalentKeysSql + + // The basic child candidate can be bucketed by MORE columns than the set operation distributes + // on: a table distributed by hash(k, v) feeding an INTERSECT on only k. The (k, v) bucket key is + // wider than the single-column requirement, so canMapBucketKeysToRequire() must reject it as the + // bucket-shuffle basic and the set operation falls back to execution-hash shuffle. Without that + // guard the planner hits a checkState in calAnotherSideRequiredShuffleIds and fails to plan. Two + // such tables (different bucket counts so neither is a natural colocate basic) force the fallback + // on both sides. + sql "drop table if exists bucket_shuffle_set_operation_kv" + sql """create table bucket_shuffle_set_operation_kv(k int, v int) + distributed by hash(k, v) buckets 10 properties('replication_num'='1')""" + sql "insert into bucket_shuffle_set_operation_kv values (1, 1), (2, 2), (3, 3)" + sql "drop table if exists bucket_shuffle_set_operation_kv2" + sql """create table bucket_shuffle_set_operation_kv2(k int, v int) + distributed by hash(k, v) buckets 11 properties('replication_num'='1')""" + sql "insert into bucket_shuffle_set_operation_kv2 values (1, 1), (2, 2), (3, 3)" + def multiColumnBucketKeyIntersectSql = """ + select k from bucket_shuffle_set_operation_kv + intersect + select k from bucket_shuffle_set_operation_kv2""" + explain { + sql "shape plan " + multiColumnBucketKeyIntersectSql + check { String e -> + assertFalse(e.contains("bucketShuffle")) + } + } + order_qt_multi_column_bucket_key_intersect multiColumnBucketKeyIntersectSql + + explain { + sql """ + select id, id as id2 from (select nullable(id) as id from bucket_shuffle_set_operation1)a + intersect + (select id, id as id2 from bucket_shuffle_set_operation3) + """ + + check { String e -> + def index = e.indexOf("VINTERSECT") + e = e.substring(index) + + // extract following 6 lines of VINTERSECT: + // + // VINTERSECT(325) + // | runtime filters: RF000[min_max] <- id[#4](-1/1/1048576), RF001[in_or_bloom] <- id[#4](-1/1/1048576) + // | distribute expr lists: id2[#5] + // | distribute expr lists: id2[#9] + // | + + def lines = e.split("\n") + boolean checked = false + for (int i = 1; i < Math.min(6, lines.length); ++i) { + if (lines[i].contains("distribute expr lists")) { + def line = lines[i].substring(lines[i].indexOf(":") + 1) + + // because left shuffle to right, and right only distribute 1 column(id) + // so we should ensure left shuffle to right only distribute by 1 column, + // not distribute 2 columns + assertTrue(line.trim().split(",").length == 1) + checked = true + } + } + assertTrue(checked) + } + } +} \ No newline at end of file From 101b105343d63eabadbf0339402a9f5e6156c2c8 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Mon, 3 Aug 2026 15:30:48 +0800 Subject: [PATCH 6/9] branch-4.2: [fix](local shuffle) make set operation and analytic advertise the placement their input really is on #66295 Cherry-picked from #66295 --- .../doris/planner/AnalyticEvalNode.java | 8 +- .../doris/planner/SetOperationNode.java | 88 ++++-- .../org/apache/doris/planner/SortNode.java | 16 +- .../planner/LocalShuffleNodeCoverageTest.java | 51 ++++ .../qe/LocalExchangePlacementAuditTest.java | 254 ++++++++++++++++++ .../doris/qe/LocalExchangePlannerTest.java | 33 +++ .../bucket_shuffle_set_operation.out | 46 ++++ .../bucket_shuffle_set_operation.groovy | 74 ++++- 8 files changed, 535 insertions(+), 35 deletions(-) create mode 100644 fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlacementAuditTest.java diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java index 063abb43907c9c..c16b62010868e2 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java @@ -273,9 +273,13 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans return Pair.of(this, LocalExchangeType.NOOP); } else if (orderByElements.isEmpty()) { if (AddLocalExchange.isColocated(this)) { + // requireHash() is the generic hash require, which BUCKET_HASH_SHUFFLE also + // satisfies — a bucket-distributed child then keeps its bucket placement and no + // local exchange is inserted. Leave outputType null so the real placement is + // reported upward; hardcoding LOCAL_EXECUTION_HASH_SHUFFLE would let a parent + // that requires exactly that type (a bucket join upgraded to local hash) skip + // its realign local exchange and pair up mismatched placements. requireChild = LocalExchangeTypeRequire.requireHash(); - outputType = AddLocalExchange.resolveExchangeType( - LocalExchangeTypeRequire.requireHash()); } else { // Non-colocated analytic with PARTITION BY but no ORDER BY: // The parent SortNode (mergeByExchange) will insert PASSTHROUGH above us, diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java index 64e1cd90eab9d4..384d3ea6df8245 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SetOperationNode.java @@ -224,6 +224,14 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans PlanNode parent, LocalExchangeTypeRequire parentRequire) { LocalExchangeTypeRequire requireChild; LocalExchangeType outputType; + // COLOCATE / BUCKET_SHUFFLE: every child is distributed by the basic child's storage + // bucket function (basic side scans buckets directly, other sides come from + // bucket-shuffle exchanges), so all children must stay aligned by that bucket function + // locally. requireBucketHash keeps bucket-distributed children as-is and re-aligns a + // serial (NOOP-claim) child with a BUCKET_HASH_SHUFFLE local exchange — same pattern as + // HashJoinNode's colocate/bucket-shuffle branch. An execution-hash require here would + // locally re-partition one side by a different hash function and break the alignment. + boolean bucketAligned = AddLocalExchange.isColocated(this) || isBucketShuffle(); if (this instanceof UnionNode) { // Propagate parent's hash requirement to children ONLY when a downstream operator // requires shuffle for correctness (not just performance optimization). Matches BE's @@ -233,42 +241,72 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans // intersect/except) through hash/noop links. // See PlanNode.requiresShuffleForCorrectness() for a chain-propagation example. boolean canPropagateHash = translatorContext.hasShuffleForCorrectnessAncestor(this); - requireChild = canPropagateHash ? parentRequire.autoRequireHash() : LocalExchangeTypeRequire.noRequire(); - outputType = canPropagateHash - ? AddLocalExchange.resolveExchangeType(requireChild) - : LocalExchangeType.NOOP; - } else { - // Intersect / Except - if (AddLocalExchange.isColocated(this) || isBucketShuffle()) { - // COLOCATE / BUCKET_SHUFFLE: every child is distributed by the basic child's - // storage bucket function (basic side scans buckets directly, other sides come - // from bucket-shuffle exchanges), so all children must stay aligned by that - // bucket function locally. requireBucketHash keeps bucket-distributed children - // as-is and re-aligns a serial (NOOP-claim) child with a BUCKET_HASH_SHUFFLE - // local exchange — same pattern as HashJoinNode's colocate/bucket-shuffle - // branch. An execution-hash require here would locally re-partition one side - // by a different hash function and break build/probe alignment. + if (!canPropagateHash) { + requireChild = LocalExchangeTypeRequire.noRequire(); + outputType = LocalExchangeType.NOOP; + } else if (bucketAligned) { + // A union does not need its branches aligned for its own semantics — it only + // concatenates — but the downstream correctness consumer does, and the generic + // hash require cannot deliver that for a bucket-aligned union: the branch that + // arrives through a bucket-shuffle exchange already satisfies it and keeps its + // bucket placement, while the branch that scans its own buckets is serial under + // a pooling scan, claims NOOP, and gets re-partitioned by execution hash. The + // same key then sits in two different pipeline tasks and the consumer computes + // per-task results — e.g. both rows of a window partition get row_number()=1. requireChild = LocalExchangeTypeRequire.requireBucketHash(); outputType = LocalExchangeType.BUCKET_HASH_SHUFFLE; } else { - // PARTITIONED intersect/except: all children enter via global hash - // exchange. Require GLOBAL so any inserted exchange matches the - // cross-fragment instance mapping (same fix as HashJoinNode DORIS-26101). - // Exception: serial source → fall back to LOCAL (DORIS-26120). - boolean serialSource = fragment != null - && fragment.useSerialSource(translatorContext.getConnectContext()); - requireChild = serialSource - ? LocalExchangeTypeRequire.requireHash() - : LocalExchangeTypeRequire.requireGlobalExecutionHash(); + requireChild = parentRequire.autoRequireHash(); outputType = AddLocalExchange.resolveExchangeType(requireChild); } + } else if (bucketAligned) { + // Intersect / Except, colocate or bucket shuffle. Unlike a union these always need + // their children aligned, so there is no shuffle-for-correctness gate here. + requireChild = LocalExchangeTypeRequire.requireBucketHash(); + outputType = LocalExchangeType.BUCKET_HASH_SHUFFLE; + } else { + // PARTITIONED intersect/except: all children enter via global hash + // exchange. Require GLOBAL so any inserted exchange matches the + // cross-fragment instance mapping, same as HashJoinNode's partitioned branch. + // Exception: a serial source sends to a single BE, so its + // shuffle_idx_to_instance_idx has only one entry and GLOBAL would route rows to + // indices that do not exist — fall back to the generic hash require, which + // resolves to LOCAL. + boolean serialSource = fragment != null + && fragment.useSerialSource(translatorContext.getConnectContext()); + requireChild = serialSource + ? LocalExchangeTypeRequire.requireHash() + : LocalExchangeTypeRequire.requireGlobalExecutionHash(); + outputType = AddLocalExchange.resolveExchangeType(requireChild); } ArrayList newChildren = Lists.newArrayList(); + LocalExchangeType branchPlacement = null; + boolean branchesAgree = true; for (int i = 0; i < children.size(); i++) { - newChildren.add(enforceRequire(translatorContext, children.get(i), i, requireChild).first); + Pair branch + = enforceRequire(translatorContext, children.get(i), i, requireChild); + newChildren.add(branch.first); + if (i == 0) { + branchPlacement = branch.second; + } else if (branchPlacement != branch.second) { + branchesAgree = false; + } } this.children = newChildren; + + // Only advertise a hash placement the branches really are on. requireBucketHash / + // requireGlobalExecutionHash pin the branches to one type, so those branches keep the + // outputType computed above; the generic requireHash is satisfied by GLOBAL / LOCAL / + // BUCKET alike, so a branch may keep an existing placement and the hardcoded type would + // be a claim about data that never moved. Report what the branches actually agreed on, + // and NOOP when they did not — a parent that needs one placement then inserts its own + // local exchange instead of trusting one branch's placement as the whole output's. + if (outputType.isHashShuffle() && !(branchesAgree && branchPlacement == outputType)) { + outputType = branchesAgree && branchPlacement != null && branchPlacement.isHashShuffle() + ? branchPlacement + : LocalExchangeType.NOOP; + } return Pair.of(this, outputType); } diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java index 227f4e4ef0650e..392849a4eebb54 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/SortNode.java @@ -308,13 +308,15 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans // BE: SortSink._is_analytic_sort=true → required_data_distribution() = HASH. // This sort serves a parent AnalyticEvalNode (window function) and requires // data partitioned by the analytic's partition exprs. - if (AddLocalExchange.isColocated(this)) { - requireChild = LocalExchangeTypeRequire.requireHash(); - outputType = AddLocalExchange.resolveExchangeType( - LocalExchangeTypeRequire.requireHash()); - } else { - requireChild = parentRequire.autoRequireHash(); - } + // requireHash() is the generic hash require: BUCKET_HASH_SHUFFLE satisfies it too, + // so a bucket-distributed child keeps its bucket placement and no local exchange is + // inserted. outputType must therefore stay null and be derived from enforceResult — + // hardcoding LOCAL_EXECUTION_HASH_SHUFFLE here would advertise a placement the data + // is not on, and a parent asking for exactly LOCAL_EXECUTION_HASH_SHUFFLE (a bucket + // join upgraded to local hash) would skip its realign local exchange. + requireChild = AddLocalExchange.isColocated(this) + ? LocalExchangeTypeRequire.requireHash() + : parentRequire.autoRequireHash(); } else if (mergeByexchange) { // BE: SortSink._merge_by_exchange=true → required_data_distribution() = PASSTHROUGH. requireChild = LocalExchangeTypeRequire.requirePassthrough(); diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java index e171ebe166ce2b..02e68ba6b59def 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -609,9 +609,60 @@ public void testSetOperationAndAssertNumRowsNode() { ctx.setHasShuffleForCorrectnessAncestor(unionNode, true); Pair unionOutput = unionNode.enforceAndDeriveLocalExchange( ctx, null, LocalExchangeTypeRequire.requireHash()); + // The single branch really is re-partitioned by LOCAL_EXECUTION_HASH_SHUFFLE (it claimed + // NOOP, so enforceRequire inserted the exchange), so advertising that type is truthful. Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, unionOutput.second); Assertions.assertEquals(LocalExchangeNode.RequireHash.class, unionChild.lastRequire.getClass()); + // A branch that already is on a hash placement is reported as-is instead of being + // relabelled: the union claimed LOCAL_EXECUTION_HASH_SHUFFLE unconditionally before, which + // was a claim about data that never moved. + UnionNode passthroughUnion = new UnionNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); + TrackingPlanNode globalHashChild = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE); + passthroughUnion.addChild(globalHashChild); + ctx.setHasShuffleForCorrectnessAncestor(passthroughUnion, true); + Pair passthroughOutput = passthroughUnion + .enforceAndDeriveLocalExchange(ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE, passthroughOutput.second); + Assertions.assertSame(globalHashChild, passthroughUnion.getChild(0)); + + // Bucket-shuffle UnionNode under a shuffle-for-correctness consumer: the branches are + // aligned by the basic child's storage bucket function, so the union must require + // BUCKET_HASH_SHUFFLE from every branch instead of the generic hash. With the generic + // require the branch arriving through a bucket-shuffle exchange satisfies it and keeps + // its bucket placement while a serial (NOOP-claim) branch is re-partitioned by execution + // hash, splitting one key across two pipeline tasks (duplicate row_number()=1). + UnionNode bucketUnion = new UnionNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); + bucketUnion.setColocate(false); + bucketUnion.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + TrackingPlanNode bucketUnionLeft = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + TrackingPlanNode bucketUnionRight = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.BUCKET_HASH_SHUFFLE); + bucketUnion.addChild(bucketUnionLeft); + bucketUnion.addChild(bucketUnionRight); + ctx.setHasShuffleForCorrectnessAncestor(bucketUnion, true); + Pair bucketUnionOutput = bucketUnion.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.BUCKET_HASH_SHUFFLE, bucketUnionOutput.second); + // the serial branch is re-aligned by bucket hash ... + assertChildLocalExchangeType(bucketUnion, 0, LocalExchangeType.BUCKET_HASH_SHUFFLE); + // ... and the branch that already is bucket-distributed keeps its placement untouched + Assertions.assertSame(bucketUnionRight, bucketUnion.getChild(1)); + + // Without a downstream shuffle-for-correctness consumer a bucket-shuffle union still + // requires nothing: UNION ALL only concatenates, so no branch has to move. + UnionNode bucketUnionNoConsumer = new UnionNode(nextPlanNodeId(), + new TupleId(NEXT_ID.getAndIncrement())); + bucketUnionNoConsumer.setDistributionMode(DistributionMode.BUCKET_SHUFFLE); + TrackingPlanNode noConsumerChild = new TrackingPlanNode(nextPlanNodeId(), + LocalExchangeType.BUCKET_HASH_SHUFFLE); + bucketUnionNoConsumer.addChild(noConsumerChild); + Pair noConsumerOutput = bucketUnionNoConsumer.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeType.NOOP, noConsumerOutput.second); + Assertions.assertSame(noConsumerChild, bucketUnionNoConsumer.getChild(0)); + IntersectNode intersectNode = new IntersectNode(nextPlanNodeId(), new TupleId(NEXT_ID.getAndIncrement())); intersectNode.setColocate(false); TrackingScanNode left = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlacementAuditTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlacementAuditTest.java new file mode 100644 index 00000000000000..5e231e1a243c57 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlacementAuditTest.java @@ -0,0 +1,254 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.qe; + +import org.apache.doris.nereids.NereidsPlanner; +import org.apache.doris.planner.ExchangeNode; +import org.apache.doris.planner.HashJoinNode; +import org.apache.doris.planner.LocalExchangeNode; +import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; +import org.apache.doris.planner.OlapScanNode; +import org.apache.doris.planner.PlanFragment; +import org.apache.doris.planner.PlanNode; +import org.apache.doris.planner.SetOperationNode; +import org.apache.doris.thrift.TPartitionType; +import org.apache.doris.utframe.TestWithFeService; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Audit: for every multi-input operator that depends on hash placement, all of its input + * branches must end up on the SAME placement function. + * + *

Motivation: {@code RequireHash} means "I need hash-partitioned input"; it is satisfied by + * GLOBAL_EXECUTION_HASH_SHUFFLE, LOCAL_EXECUTION_HASH_SHUFFLE and BUCKET_HASH_SHUFFLE alike. + * It does NOT mean "all my branches must use the same hash function". A multi-input operator + * that hands the generic require to every branch can therefore end up with one branch keeping + * a storage-bucket placement while another is re-partitioned by execution hash — the same key + * then sits in two different pipeline tasks. This audit walks the finished plan and flags that + * mix instead of relying on a reviewer noticing it. + */ +public class LocalExchangePlacementAuditTest extends TestWithFeService { + @Override + protected int backendNum() { + return 3; + } + + @Override + protected void runBeforeAll() throws Exception { + createDatabase("test"); + useDatabase("test"); + // b1/b2 share bucket count so the optimizer may bucket-shuffle one onto the other; + // b3 has a different bucket count so it must be re-shuffled. + createTable("CREATE TABLE test.b1 (k INT, k2 INT, v INT) DISTRIBUTED BY HASH(k) BUCKETS 6 " + + "PROPERTIES ('replication_num'='1')"); + createTable("CREATE TABLE test.b2 (k INT, k2 INT, v INT) DISTRIBUTED BY HASH(k) BUCKETS 6 " + + "PROPERTIES ('replication_num'='1')"); + createTable("CREATE TABLE test.b3 (k INT, k2 INT, v INT) DISTRIBUTED BY HASH(k) BUCKETS 7 " + + "PROPERTIES ('replication_num'='1')"); + } + + /** The placement a branch actually lands on, as observed from the finished plan tree. */ + private static LocalExchangeType effectivePlacement(PlanNode node, ConnectContext ctx) { + if (node instanceof LocalExchangeNode) { + LocalExchangeType type = ((LocalExchangeNode) node).getExchangeType(); + // A PASSTHROUGH/BROADCAST/... wrapper does not decide hash placement; look through it. + return type.isHashShuffle() ? type : effectivePlacement(node.getChild(0), ctx); + } + if (node instanceof ExchangeNode) { + TPartitionType partitionType = ((ExchangeNode) node).getPartitionType(); + if (partitionType == TPartitionType.HASH_PARTITIONED) { + return LocalExchangeType.GLOBAL_EXECUTION_HASH_SHUFFLE; + } + if (partitionType == TPartitionType.BUCKET_SHFFULE_HASH_PARTITIONED) { + return LocalExchangeType.BUCKET_HASH_SHUFFLE; + } + return LocalExchangeType.NOOP; + } + if (node instanceof OlapScanNode) { + // Mirrors OlapScanNode.enforceAndDeriveLocalExchange: a pooling (serial) scan claims + // nothing, a non-pooling bucket scan claims its storage bucket distribution. + boolean pooling = node.getFragment() != null && node.getFragment().useSerialSource(ctx); + return pooling ? LocalExchangeType.NOOP : LocalExchangeType.BUCKET_HASH_SHUFFLE; + } + // Pass-through operators report their child's placement upward. + if (node.getChildren().size() == 1) { + return effectivePlacement(node.getChild(0), ctx); + } + return LocalExchangeType.NOOP; + } + + /** Returns "" when the plan is consistent, otherwise a description of the mixed placement. */ + private static String findMixedPlacement(List fragments, ConnectContext ctx) { + StringBuilder problems = new StringBuilder(); + for (PlanFragment fragment : fragments) { + walk(fragment.getPlanRoot(), problems, ctx); + } + return problems.toString(); + } + + private static void walk(PlanNode node, StringBuilder problems, ConnectContext ctx) { + boolean placementSensitive = node instanceof SetOperationNode || node instanceof HashJoinNode; + if (placementSensitive && node.getChildren().size() > 1) { + Map> byPlacement = new LinkedHashMap<>(); + for (int i = 0; i < node.getChildren().size(); i++) { + LocalExchangeType placement = effectivePlacement(node.getChild(i), ctx); + if (placement.isHashShuffle()) { + byPlacement.computeIfAbsent(placement, k -> new ArrayList<>()).add(i); + } + } + if (byPlacement.size() > 1) { + problems.append(node.getClass().getSimpleName()) + .append('(').append(node.getId().asInt()).append(") mixes ") + .append(byPlacement).append('\n'); + } + } + for (PlanNode child : node.getChildren()) { + walk(child, problems, ctx); + } + } + + private String audit(String label, String sql, boolean pooling, boolean bucketUpgrade) + throws Exception { + SessionVariable sv = connectContext.getSessionVariable(); + sv.setEnableLocalShufflePlanner(true); + sv.setEnableLocalShuffle(true); + sv.setEnableNereidsDistributePlanner(true); + // ignore_storage_data_distribution is the real pooling switch: useSerialSource() gates on + // it. force_to_local_shuffle only forces ScanNode.isSerialNode(), which a small table + // already satisfies via `scanRangeNum < parallelExecInstanceNum * numScanBackends`, so + // flipping it alone would not give a non-pooling arm at all. + sv.setIgnoreStorageDataDistribution(pooling); + sv.setForceToLocalShuffle(pooling); + sv.setPipelineTaskNum(bucketUpgrade ? "16" : "8"); + sv.setBucketShuffleDowngradeRatio(0); + // ratio <= 1 disables the upgrade entirely; 1.01 makes it fire whenever instances + // slightly exceed buckets-with-data, which is what a bucket join above a mis-claiming + // child needs in order to be fooled by that claim. + sv.setLocalShuffleBucketUpgradeRatio(bucketUpgrade ? 1.01 : 1.5); + sv.disableColocatePlan = true; + + StmtExecutor executor = executeNereidsSql("explain distributed plan " + sql); + NereidsPlanner planner = (NereidsPlanner) executor.planner(); + String problems = findMixedPlacement(planner.getFragments(), connectContext); + return problems.isEmpty() ? "" : "MIXED | pooling=" + pooling + " upgrade=" + bucketUpgrade + + " | " + label + " | " + problems.trim().replace('\n', ';'); + } + + @Test + public void auditPlacementConsistency() { + List failures = new ArrayList<>(); + // set operation kind x consumer kind x which side needs re-shuffling + String[][] cases = { + {"union+window(partition by bucket key)", + "select k, row_number() over (partition by k order by v) from " + + "(select k, v from test.b1 union all select k, v from test.b2) u"}, + {"union+window(partition by non-bucket key)", + "select k2, row_number() over (partition by k2 order by v) from " + + "(select k2, v from test.b1 union all select k2, v from test.b2) u"}, + {"union+window(no order by)", + "select k, row_number() over (partition by k) from " + + "(select k from test.b1 union all select k from test.b2) u"}, + {"union+agg", + "select k, count(*) from (select k from test.b1 union all select k from test.b2) u group by k"}, + {"union+agg(distinct)", + "select k, count(distinct v) from " + + "(select k, v from test.b1 union all select k, v from test.b2) u group by k"}, + {"union+shuffle join", + "select u.k from (select k from test.b1 union all select k from test.b2) u " + + "join[shuffle] test.b3 t on u.k = t.k"}, + {"union+bucket join", + "select u.k from (select k from test.b1 union all select k from test.b2) u " + + "join test.b1 t on u.k = t.k"}, + {"union of different bucket counts + window", + "select k, row_number() over (partition by k order by v) from " + + "(select k, v from test.b1 union all select k, v from test.b3) u"}, + {"3-way union + window", + "select k, row_number() over (partition by k order by v) from " + + "(select k, v from test.b1 union all select k, v from test.b2 " + + "union all select k, v from test.b3) u"}, + {"union(scan, values) + window", + "select k, row_number() over (partition by k order by v) from " + + "(select k, v from test.b1 union all select 1, 2) u"}, + {"intersect+window", + "select k, row_number() over (partition by k order by k) from " + + "(select k from test.b1 intersect select k from test.b2) u"}, + {"except+window", + "select k, row_number() over (partition by k order by k) from " + + "(select k from test.b1 except select k from test.b2) u"}, + {"intersect(join as basic child)+window", + "select k, row_number() over (partition by k order by k) from " + + "(select a.k from test.b1 a join test.b2 b on a.k=b.k intersect " + + "select k from test.b3) u"}, + {"nested union under intersect + window", + "select k, row_number() over (partition by k order by k) from " + + "((select k from test.b1 union all select k from test.b2) " + + "intersect select k from test.b3) u"}, + {"union under union + window", + "select k, row_number() over (partition by k order by v) from " + + "(select k, v from test.b1 union all " + + "(select k, v from test.b2 union all select k, v from test.b3)) u"}, + {"window(partition by bucket key) under bucket join", + "select w.k from (select k, row_number() over (partition by k) rn from test.b1) w " + + "join test.b1 t on w.k = t.k"}, + {"window(partition by bucket key, no order by) under shuffle join", + "select w.k from (select k, row_number() over (partition by k) rn from test.b1) w " + + "join[shuffle] test.b3 t on w.k = t.k"}, + {"window(partition by bucket key, order by) under bucket join", + "select w.k from (select k, row_number() over (partition by k order by v) rn " + + "from test.b1) w join test.b1 t on w.k = t.k"}, + {"window(no order by) under bucket join on 2 tables", + "select w.k from (select k, row_number() over (partition by k) rn from test.b1) w " + + "join test.b2 t on w.k = t.k"}, + {"intersect under bucket join", + "select u.k from (select k from test.b1 intersect select k from test.b3) u " + + "join test.b1 t on u.k = t.k"}, + {"union under bucket join under window", + "select k, row_number() over (partition by k order by c) from " + + "(select u.k k, count(*) c from " + + "(select k from test.b1 union all select k from test.b2) u " + + "join test.b1 t on u.k = t.k group by u.k) x"}, + {"agg over union under bucket join", + "select u.k from (select k, count(*) c from " + + "(select k from test.b1 union all select k from test.b2) x group by k) u " + + "join test.b1 t on u.k = t.k"}, + }; + for (boolean pooling : new boolean[] {true, false}) { + for (boolean bucketUpgrade : new boolean[] {false, true}) { + for (String[] c : cases) { + try { + String failure = audit(c[0], c[1], pooling, bucketUpgrade); + if (!failure.isEmpty()) { + failures.add(failure); + } + } catch (Exception e) { + failures.add("PLANFAIL | pooling=" + pooling + " upgrade=" + bucketUpgrade + + " | " + c[0] + " | " + e); + } + } + } + } + Assertions.assertTrue(failures.isEmpty(), String.join("\n", failures)); + } +} diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java index 66e7578f0fa544..275df40cffaa34 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java @@ -531,6 +531,39 @@ public void testAnalyticPlanContainsPassthroughAndLocalHashShuffle() throws Exce olapScan("t1"))))))))); } + @Test + public void testBucketShuffleUnionUnderWindowStaysBucketAligned() throws Exception { + // Regression: UNION ALL feeding a window function returned duplicate row_number()=1 for + // the same partition key. The union is bucket-shuffled (t1 keeps its storage buckets, + // t2 arrives through a bucket-shuffle exchange onto them), and the analytic sort below + // the AnalyticEval requires hash-partitioned input. With the generic hash requirement + // the t2 branch satisfied it and kept its bucket placement while the t1 branch — serial + // under the pooling scan, so it claims no distribution — was re-partitioned by + // LOCAL_EXECUTION_HASH_SHUFFLE. The two placements put one partition key in two + // different pipeline tasks and the analytic numbered each task from 1. + // + // AnalyticEval ← Sort ← Union ← LE(BUCKET_HASH) ← LE(PT) ← scan(t1) + // ← Exchange (bucket shuffle) ← scan(t2) + setupLocalShuffleSession(sv -> { + sv.setForceToLocalShuffle(true); + sv.setBucketShuffleDowngradeRatio(0); + }); + String sql = "select k1, row_number() over (partition by k1 order by k2) from (" + + "select k1, k2 from test.t1 union all select k1, k2 from test.t2) u"; + assertPlanShape(sql, + anyTree( + analytic( + sort( + union( + localExchange(BUCKET_HASH, + localExchange(PT, + olapScan("t1"))), + anyTree(exchange())))))); + // The mixed-placement signature of the bug: an execution-hash local exchange sitting + // next to a bucket-distributed sibling branch. + assertNoLocalExchangeOfType(sql, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + @Test public void testGroupingSetsPlanContainsHashShuffle() throws Exception { // Non-pooling grouping sets keeps the colocated BUCKET_HASH_SHUFFLE output of diff --git a/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out index 1ec75925ec2da0..dcb9d11753baa2 100644 --- a/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out +++ b/regression-test/data/query_p0/set_operations/bucket_shuffle_set_operation.out @@ -161,6 +161,52 @@ PhysicalResultSink 3 3 +-- !union_under_window -- +1 1 1 +1 1 2 +2 2 1 +2 2 2 +3 3 1 +3 3 2 + +-- !union_under_window_no_order_by -- +1 1 1 +1 1 2 +2 2 1 +2 2 2 +3 3 1 +3 3 2 + +-- !union_unequal_buckets_under_window -- +1 1 1 +1 1 2 +2 2 1 +2 2 2 +3 3 1 +3 3 2 + +-- !three_way_union_under_window -- +1 1 1 +1 1 2 +1 1 3 +2 2 1 +2 2 2 +2 2 3 +3 3 1 +3 3 2 +3 3 3 + +-- !nested_union_under_window -- +1 1 1 +1 1 2 +1 1 3 +2 2 1 +2 2 2 +2 2 3 +3 3 1 +3 3 2 +3 3 3 + -- !bucket_shuffle_when_local_shuffle_off_shape -- PhysicalResultSink --PhysicalIntersect[bucketShuffle] diff --git a/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy index ee118bccbd5730..6f10c2ca96bc33 100644 --- a/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy +++ b/regression-test/suites/query_p0/set_operations/bucket_shuffle_set_operation.groovy @@ -116,6 +116,78 @@ suite("bucket_shuffle_set_operation") { intersect select id from bucket_shuffle_set_operation2)""") + // A window function above a bucket-shuffle UNION ALL is a shuffle-for-correctness consumer: + // the analytic sink needs every row of a window partition inside one pipeline task. The union + // branches are aligned by the basic child's storage bucket function, so the union must require + // a bucket-hash local exchange from every branch. Requiring the generic hash instead let the + // branch arriving through a bucket-shuffle exchange keep its bucket placement while the branch + // scanning its own buckets (serial under a pooling scan, so it claims no distribution) was + // re-partitioned by execution hash: one window partition ended up split across two pipeline + // tasks and row_number() restarted at 1 in each. + // force_to_local_shuffle pins the pooling scan so the shape does not depend on the backend + // count of the environment running this suite. + // The variants below were not guessed: LocalExchangePlacementAuditTest walks finished plans and + // flags any set operation whose branches end up on different hash placements, and these are + // every shape it flagged — partition-by with and without ORDER BY, unequal bucket counts, + // three branches, and a nested union. + sql "set force_to_local_shuffle=true" + + // The ordered golden results prove both the row count and that every window partition + // contains each row_number() exactly once. A partition split across pipeline tasks would + // produce duplicate row_number values inside one id. + order_qt_union_under_window """ + select id, value, row_number() over (partition by id order by value) rn + from ( + select id, value from bucket_shuffle_set_operation1 + union all + select id, value from bucket_shuffle_set_operation2 + ) u + order by id, value, rn""" + + order_qt_union_under_window_no_order_by """ + select id, value, row_number() over (partition by id) rn + from ( + select id, value from bucket_shuffle_set_operation1 + union all + select id, value from bucket_shuffle_set_operation2 + ) u + order by id, value, rn""" + + // the branches have different bucket counts, so one must be re-shuffled onto the other's + // buckets rather than keeping its own + order_qt_union_unequal_buckets_under_window """ + select id, value, row_number() over (partition by id order by value) rn + from ( + select id, value from bucket_shuffle_set_operation1 + union all + select id, value from bucket_shuffle_set_operation3 + ) u + order by id, value, rn""" + + order_qt_three_way_union_under_window """ + select id, value, row_number() over (partition by id order by value) rn + from ( + select id, value from bucket_shuffle_set_operation1 + union all + select id, value from bucket_shuffle_set_operation2 + union all + select id, value from bucket_shuffle_set_operation3 + ) u + order by id, value, rn""" + + order_qt_nested_union_under_window """ + select id, value, row_number() over (partition by id order by value) rn + from ( + select id, value from bucket_shuffle_set_operation1 + union all + (select id, value from bucket_shuffle_set_operation2 + union all + select id, value from bucket_shuffle_set_operation3) + ) u + order by id, value, rn""" + + sql "set force_to_local_shuffle=false" + // when local shuffle is disabled entirely, every pipeline runs a single task per // instance so the bucket alignment holds naturally and bucket shuffle is still allowed sql "set enable_local_shuffle=false" @@ -439,4 +511,4 @@ suite("bucket_shuffle_set_operation") { assertTrue(checked) } } -} \ No newline at end of file +} From 5b69818687493c9eb7f0947e4d9781c22ace47d0 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Tue, 18 Aug 2026 19:55:10 +0800 Subject: [PATCH 7/9] branch-4.2: [fix](local shuffle) Require hash input for distinct finalize agg without group keys #66570 Cherry-picked from #66570 --- .../apache/doris/planner/AggregationNode.java | 75 +++-- .../planner/LocalShuffleNodeCoverageTest.java | 295 ++++++++++++++++++ .../doris/qe/LocalExchangePlannerTest.java | 106 +++++++ .../test_local_shuffle_rqg_bugs.out | 6 + .../test_local_shuffle_rqg_bugs.groovy | 49 +++ 5 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java index 8bd8a87b69899d..50818d00fa49eb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AggregationNode.java @@ -302,6 +302,8 @@ public Pair enforceAndDeriveLocalExchange( // PR #62438: when false, non-finalize agg falls back to BE base class. boolean enableLeBeforeAgg = sessionVariable.enableLocalExchangeBeforeAgg; boolean hasKeys = !aggInfo.getGroupingExprs().isEmpty(); + boolean selfOrInheritedShuffled = translatorContext.hasShuffleForCorrectnessAncestor(this) + || requiresShuffleForCorrectness(); // Each branch mirrors the corresponding BE operator's required_data_distribution() // check order 1:1. The helper baseClassRequire() expands BE's base class behavior. @@ -355,7 +357,16 @@ public Pair enforceAndDeriveLocalExchange( // early return also catches FIRST_MERGE, dropping the HASH requirement and // causing wrong-result (e.g. PASSTHROUGH over serial child breaks the // group-by-key invariant — DORIS-25413). - if (!hasKeys) { + if (!hasPartitionRequirement(selfOrInheritedShuffled)) { + // No effective partition key (no group keys, and no child distribute + // exprs set for a DISTINCT / followed-by-shuffle agg): the input + // distribution is irrelevant. A finalize agg with an effective key + // emits per-instance scalar values (sum0(multi_distinct_count(...)) + // above) that the parent sums, so same-key rows must stay in a single + // instance — this mirrors BE's `_partition_exprs` exactly, and keeps + // a directly called multi_distinct_count(col) (no distribute exprs) + // on the no-requirement path instead of collapsing it onto a zero-key + // HASH exchange. requireChild = needsFinalize ? LocalExchangeTypeRequire.noRequire() : baseClassRequire(connectContext); @@ -368,13 +379,16 @@ public Pair enforceAndDeriveLocalExchange( // FIRST_MERGE (correctness) or finalize+colocate → HASH. requireChild = parentRequire.autoRequireHash(); } else if (hasPartitionExprs(parentRequire)) { - // FE-only heuristic: finalize non-colocate with parent hash requirement - // → inherit parent's specific hash type. + // finalize non-colocate with a parent hash requirement → inherit the + // parent's specific hash type. requireChild = parentRequire.autoRequireHash(); } else { - // FE-only heuristic: finalize non-colocate without parent hash → skip - // LE (child Exchange already provides hash distribution). - requireChild = LocalExchangeTypeRequire.noRequire(); + // finalize non-colocate without a parent hash requirement: the input + // must still be key-aligned (group/distinct key), so require HASH + // explicitly instead of trusting the child's distribution. When the + // child already provides hash distribution, satisfy() passes and no + // LE is inserted, so this is safe and free in the common case. + requireChild = LocalExchangeTypeRequire.requireHash(); } } @@ -391,6 +405,34 @@ private LocalExchangeTypeRequire baseClassRequire(ConnectContext connectContext) : LocalExchangeTypeRequire.noRequire(); } + /** + * Whether this agg needs key-aligned (hash-partitioned) input from its child. + * Mirrors BE AggSinkOperatorX::update_operator's `_partition_exprs` exactly: + * non-empty grouping exprs, or the child distribute exprs when the plan set + * them for a DISTINCT (or followed-by-shuffle) agg. The test is on the + * *effective* key, not the function name: a directly called + * multi_distinct_count(col) has neither distribute exprs nor grouping exprs, + * so it stays on the no-requirement path — a zero-key HASH exchange would + * collapse the whole input onto one task per BE. A finalize agg with an + * effective key emits per-instance scalar values (the + * sum0(multi_distinct_count(...)) above) that the parent sums, so same-key + * rows must stay in a single instance. + */ + private boolean hasPartitionRequirement(boolean followedByShuffled) { + return !getLocalExchangeDistributeExprs(0, followedByShuffled).isEmpty(); + } + + private boolean hasDistinctAggregate() { + // Multi-distinct aggregates are detected by function name. Nereids rewrites + // count/sum/group_concat(distinct ...) into dedicated MultiDistinct* functions + // constructed with distinct=false, so by this legacy FunctionCallExpr layer + // isDistinct() is already false and the function name is the only signal. + return aggInfo.getAggregateExprs().stream() + .map(FunctionCallExpr::getFnName) + .map(name -> name.getFunction()) + .anyMatch(name -> name.startsWith("multi_distinct_")); + } + @Override protected List getSemanticPartitionExprs() { return aggInfo.getGroupingExprs(); @@ -406,18 +448,7 @@ protected List getLocalExchangeDistributeExprs(int childIndex, boolean fol // chain scatters same-group rows across N instances, leaving partial_preagg essentially a // no-op and breaking row-arrival order at downstream merge-finalize (e.g. group_concat). List childDist = getChildDistributeExprList(childIndex); - // Multi-distinct aggregates are detected by function name. Nereids rewrites - // count/sum(distinct ...) into dedicated MultiDistinct* functions constructed with - // distinct=false and a "multi_distinct_" name, so by this legacy FunctionCallExpr layer - // isDistinct() is already false and the function name is the only remaining signal — - // there is no structural flag to test here. - boolean hasDistinct = aggInfo.getAggregateExprs().stream() - .map(FunctionCallExpr::getFnName) - .filter(name -> name != null) - .map(name -> name.getFunction()) - .filter(name -> name != null) - .anyMatch(name -> name.startsWith("multi_distinct_")); - if (childDist != null && !childDist.isEmpty() && (followedByShuffled || hasDistinct)) { + if (childDist != null && !childDist.isEmpty() && (followedByShuffled || hasDistinctAggregate())) { return childDist; } return Lists.newArrayList(aggInfo.getGroupingExprs()); @@ -426,13 +457,17 @@ protected List getLocalExchangeDistributeExprs(int childIndex, boolean fol @Override public boolean requiresShuffleForCorrectness() { // Mirrors BE's AggSinkOperatorX::is_shuffled_operator() exactly: - // finalize agg with group keys needs hash-distributed input for correctness. + // finalize agg with partition exprs (group keys, or child distribute + // exprs set for a DISTINCT aggregate) needs hash-distributed input for + // correctness. The effective-key test is the node's own requirement + // (followedByShuffled=false); inherited shuffle state is added by the + // caller via selfOrInheritedShuffled. // GLOBAL dedup (!needsFinalize) is intentionally NOT included here — if a // GLOBAL dedup exists, a finalize agg always sits above it (e.g. DISTINCT_GLOBAL // above DISTINCT_LOCAL/GLOBAL_DEDUP), and the finalize agg propagates the flag // down via inheritedShuffled. A solo finalize agg satisfies hash distribution // through its own child requirement. - return needsFinalize && !aggInfo.getGroupingExprs().isEmpty(); + return needsFinalize && !getLocalExchangeDistributeExprs(0, false).isEmpty(); } private boolean canUseDistinctStreamingAgg(SessionVariable sessionVariable) { diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java index 02e68ba6b59def..59c26166c3f51b 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -17,9 +17,11 @@ package org.apache.doris.planner; +import org.apache.doris.analysis.AggregateInfo; import org.apache.doris.analysis.AssertNumRowsElement; import org.apache.doris.analysis.BinaryPredicate; import org.apache.doris.analysis.Expr; +import org.apache.doris.analysis.FunctionCallExpr; import org.apache.doris.analysis.GroupingInfo; import org.apache.doris.analysis.JoinOperator; import org.apache.doris.analysis.OrderByElement; @@ -28,6 +30,7 @@ import org.apache.doris.analysis.SortInfo; import org.apache.doris.analysis.TupleDescriptor; import org.apache.doris.analysis.TupleId; +import org.apache.doris.catalog.FunctionName; import org.apache.doris.common.Pair; import org.apache.doris.common.UserException; import org.apache.doris.nereids.glue.translator.PlanTranslatorContext; @@ -35,6 +38,8 @@ import org.apache.doris.nereids.trees.plans.WindowFuncType; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeTypeRequire; +import org.apache.doris.qe.ConnectContext; +import org.apache.doris.qe.SessionVariable; import org.apache.doris.thrift.TExplainLevel; import org.apache.doris.thrift.TPartitionType; import org.apache.doris.thrift.TPlanNode; @@ -855,6 +860,296 @@ public void testExchangeNodeBranches() { Assertions.assertEquals(LocalExchangeType.NOOP, noopOutput.second); } + @Test + public void testAggregationNodeDistinctFinalizeRequiresHash() { + // count(distinct k) without group-by: the finalize merge agg emits per-instance + // scalar values that the parent sums (sum0(multi_distinct_count(...)) above), so + // the input must be hash-partitioned by the distinct key. Pre-fix this agg got + // NoRequire and a PASSTHROUGH local exchange below scattered same-key rows across + // instances → the parent double-counted (result = correct × task count). + for (String fn : new String[] {"multi_distinct_count", "multi_distinct_sum", + "multi_distinct_group_concat"}) { + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction(fn)), /* groupByExprs */ true, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass(), + fn + " finalize agg must require hash input"); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + } + + @Test + public void testAggregationNodeDistinctFinalizeWithParentHashRequirement() { + // A parent that already requires hash must not change the agg's own hash demand. + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), /* groupByExprs */ true, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.requireHash()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testAggregationNodeDirectMultiDistinctNoKeyStaysNoRequire() { + // A directly called scalar multi_distinct_count(col) has isDistinct=false and + // no child distribute exprs (SplitAggWithoutDistinct builds a LOCAL aggregate + // without partition exprs). It must NOT be given a HASH requirement — a + // zero-key HASH exchange would collapse the whole input onto one task per BE. + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), + /* groupByExprs */ true, /* merge */ true, /* needsFinalize */ true, + LocalExchangeType.NOOP, null); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, agg.child.lastRequire.getClass(), + "direct multi_distinct with no effective key must stay NoRequire"); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(agg.child, agg.node.getChild(0)); + } + + @Test + public void testAggregationNodeNoPartitionNonFinalizeBaseClassRequire() { + // COUNT(*)-style non-finalize (LOCAL) agg: no partition requirement, so + // the non-finalize arm of the first branch falls back to base class + // behavior (NOOP for a non-serial child). The agg exprs are non-empty + // (a plain count function) so the AggSink branch is exercised rather + // than DistinctStreamingAgg. + AggContext agg = buildAggContext( + Collections.singletonList(plainAggregateFunction("count")), /* groupByExprs */ true, + /* merge */ false, /* needsFinalize */ false, LocalExchangeType.NOOP, null); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, agg.child.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(agg.child, agg.node.getChild(0)); + } + + @Test + public void testAggregationNodeNoPartitionFinalizeStaysNoRequire() { + // COUNT(*)-style agg (no group keys, no DISTINCT aggregates) genuinely has no + // partition requirement: the input distribution is irrelevant. + AggContext agg = buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* groupByExprs */ true, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, null); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, agg.child.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(agg.child, agg.node.getChild(0)); + } + + @Test + public void testAggregationNodeDistinctLocalPhaseDefaultLeRequiresHash() { + // LOCAL (FIRST/SECOND, non-merge, non-finalize) phase of a distinct agg with the + // default enable_local_exchange_before_agg=true: BE requires HASH here + // (partition_exprs non-empty), so the FE must mirror that. + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), /* groupByExprs */ true, + /* merge */ false, /* needsFinalize */ false, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass(), + "LOCAL distinct phase with default LE requires hash"); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testAggregationNodeDistinctLocalPhaseWithLeDisabledStaysNoRequire() { + // LOCAL distinct phase + enable_local_exchange_before_agg=false → base class + // behavior (NOOP for a non-serial child): user explicitly opted out of pre-agg LE. + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), /* groupByExprs */ true, + /* merge */ false, /* needsFinalize */ false, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + SessionVariable sessionVariable = new SessionVariable(); + sessionVariable.enableLocalExchangeBeforeAgg = false; + Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, agg.child.lastRequire.getClass(), + "LOCAL distinct phase with LE disabled keeps no alignment requirement"); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(agg.child, agg.node.getChild(0)); + } + + @Test + public void testAggregationNodeDistinctFirstMergeRequiresHash() { + // FIRST_MERGE (correctness-required) keeps the hash demand even when the + // user opts out of pre-agg local exchanges (enable_local_exchange_before_agg + // = false): removing the !isMerge() exemption must not weaken it. + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), /* groupByExprs */ true, + /* merge */ true, /* needsFinalize */ false, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + SessionVariable sessionVariable = new SessionVariable(); + sessionVariable.enableLocalExchangeBeforeAgg = false; + Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass(), + "FIRST_MERGE must keep the hash demand with enable_local_exchange_before_agg=false"); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testAggregationNodeGroupByFinalizeRequiresHash() { + // GROUP BY finalize agg requires hash input; when the parent has no hash + // requirement the semantic partition exprs (group keys) drive the decision. + AggContext agg = buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* groupByExprs */ false, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, null); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testAggregationNodeGroupByLocalPhaseWithLeDisabledStaysNoRequire() { + // GROUP BY local phase + enable_local_exchange_before_agg=false → base class + // behavior (NOOP for a non-serial child): user explicitly opted out of pre-agg LE. + // aggExprs is non-empty so the AggSink branch is exercised (an empty aggExprs + // would route through DistinctStreamingAgg with its own hash logic). + AggContext agg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), /* groupByExprs */ false, + /* merge */ false, /* needsFinalize */ false, LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + SessionVariable sessionVariable = new SessionVariable(); + sessionVariable.enableLocalExchangeBeforeAgg = false; + Mockito.when(agg.connectContext.getSessionVariable()).thenReturn(sessionVariable); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.NoRequire.class, agg.child.lastRequire.getClass()); + Assertions.assertEquals(LocalExchangeType.NOOP, output.second); + Assertions.assertSame(agg.child, agg.node.getChild(0)); + } + + @Test + public void testAggregationNodeRequiresShuffleForCorrectness() { + // Mirrors BE is_shuffled_operator(): finalize agg with partition exprs + // (group keys or DISTINCT aggregates) needs hash-distributed input. + AggContext distinctAgg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), + /* groupByExprs */ true, /* merge */ true, /* needsFinalize */ true, + LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + Assertions.assertTrue(distinctAgg.node.requiresShuffleForCorrectness(), + "distinct finalize agg must require shuffle for correctness"); + + AggContext noPartitionAgg = buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* groupByExprs */ true, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, null); + Assertions.assertFalse(noPartitionAgg.node.requiresShuffleForCorrectness(), + "COUNT(*) finalize agg has no partition requirement"); + + AggContext groupByAgg = buildAggContext(Collections.singletonList(plainAggregateFunction("count")), /* groupByExprs */ false, + /* merge */ true, /* needsFinalize */ true, LocalExchangeType.NOOP, null); + Assertions.assertTrue(groupByAgg.node.requiresShuffleForCorrectness(), + "GROUP BY finalize agg must require shuffle for correctness"); + + AggContext localAgg = buildAggContext(Collections.singletonList(multiDistinctFunction("multi_distinct_count")), + /* groupByExprs */ true, /* merge */ false, /* needsFinalize */ false, + LocalExchangeType.NOOP, KEYED_DISTRIBUTE_EXPRS); + Assertions.assertFalse(localAgg.node.requiresShuffleForCorrectness(), + "non-finalize agg does not require shuffle for correctness"); + } + + @Test + public void testAggregationNodeInheritedShuffleUsesChildDistributeExprs() { + // An intermediate agg that inherits a shuffle-for-correctness ancestor (e.g. + // DISTINCT_GLOBAL/FIRST_MERGE chain above a Union) keeps the child distribute + // exprs as its hash key even though the agg itself has no DISTINCT functions. + // The grouping key is deliberately different from the child distribution key: + // dropping the inherited state or selecting the grouping key must fail this test. + Expr groupingExpr = Mockito.mock(Expr.class, "groupingExpr"); + Expr childDistributeExpr = Mockito.mock(Expr.class, "childDistributeExpr"); + List childDistributeExprs = Collections.singletonList(childDistributeExpr); + AggContext agg = buildAggContext( + Collections.singletonList(plainAggregateFunction("count")), + Collections.singletonList(groupingExpr), /* merge */ true, + /* needsFinalize */ false, LocalExchangeType.NOOP, childDistributeExprs); + Mockito.when(agg.ctx.hasShuffleForCorrectnessAncestor(agg.node)).thenReturn(true); + Pair output = agg.node.enforceAndDeriveLocalExchange( + agg.ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeNode.RequireHash.class, agg.child.lastRequire.getClass(), + "inherited shuffle ancestor must keep the hash demand"); + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, output.second); + assertChildLocalExchangeType(agg.node, 0, LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + LocalExchangeNode exchangeNode = (LocalExchangeNode) agg.node.getChild(0); + Assertions.assertEquals(childDistributeExprs, exchangeNode.getDistributeExprLists(), + "inherited intermediate agg must hash by the child's distribution key"); + Assertions.assertNotEquals(Collections.singletonList(groupingExpr), exchangeNode.getDistributeExprLists(), + "the grouping key must not replace the inherited child distribution key"); + } + + /** A non-empty child distribute expr list, as fragment planning sets for a keyed DISTINCT agg. */ + private static final List KEYED_DISTRIBUTE_EXPRS = + Collections.singletonList(Mockito.mock(Expr.class)); + + private static class AggContext { + final AggregationNode node; + final PlanTranslatorContext ctx; + final TrackingPlanNode child; + final ConnectContext connectContext; + + AggContext(AggregationNode node, PlanTranslatorContext ctx, TrackingPlanNode child, + ConnectContext connectContext) { + this.node = node; + this.ctx = ctx; + this.child = child; + this.connectContext = connectContext; + } + } + + /** + * noGroupByExprs == true → no group keys (mirrors the scalar COUNT(DISTINCT)); + * distributeExprs != null → the plan set child distribute exprs for this agg + * (as fragment planning does for a DISTINCT agg), which is what makes + * hasPartitionRequirement() true for a keyed agg. + */ + private static AggContext buildAggContext(List aggExprs, boolean noGroupByExprs, + boolean merge, boolean needsFinalize, LocalExchangeType childProvided, + List distributeExprs) { + List groupingExprs = noGroupByExprs + ? Collections.emptyList() : Collections.singletonList(Mockito.mock(Expr.class)); + return buildAggContext(aggExprs, groupingExprs, merge, needsFinalize, + childProvided, distributeExprs); + } + + private static AggContext buildAggContext(List aggExprs, List groupingExprs, + boolean merge, boolean needsFinalize, LocalExchangeType childProvided, + List distributeExprs) { + PlanTranslatorContext ctx = Mockito.mock(PlanTranslatorContext.class); + ConnectContext connectContext = Mockito.mock(ConnectContext.class); + Mockito.when(connectContext.getSessionVariable()).thenReturn(new SessionVariable()); + Mockito.when(ctx.getConnectContext()).thenReturn(connectContext); + + AggregateInfo aggInfo = Mockito.mock(AggregateInfo.class); + Mockito.when(aggInfo.getOutputTupleId()).thenReturn(new TupleId(NEXT_ID.getAndIncrement())); + Mockito.when(aggInfo.getGroupingExprs()).thenReturn(new ArrayList<>(groupingExprs)); + Mockito.when(aggInfo.getAggregateExprs()).thenReturn(new ArrayList<>(aggExprs)); + Mockito.when(aggInfo.isMerge()).thenReturn(merge); + + TrackingPlanNode child = new TrackingPlanNode(nextPlanNodeId(), childProvided); + AggregationNode agg = new AggregationNode(nextPlanNodeId(), child, aggInfo); + if (distributeExprs != null) { + agg.setChildrenDistributeExprLists(Collections.singletonList(distributeExprs)); + } + if (!needsFinalize) { + agg.unsetNeedsFinalize(); + } + return new AggContext(agg, ctx, child, connectContext); + } + + private static FunctionCallExpr plainAggregateFunction(String functionName) { + FunctionCallExpr fce = Mockito.mock(FunctionCallExpr.class); + FunctionName fnName = Mockito.mock(FunctionName.class); + Mockito.when(fnName.getFunction()).thenReturn(functionName); + Mockito.when(fce.getFnName()).thenReturn(fnName); + return fce; + } + + private static FunctionCallExpr multiDistinctFunction(String functionName) { + FunctionCallExpr fce = Mockito.mock(FunctionCallExpr.class); + FunctionName fnName = Mockito.mock(FunctionName.class); + Mockito.when(fnName.getFunction()).thenReturn(functionName); + Mockito.when(fce.getFnName()).thenReturn(fnName); + return fce; + } + private static PlanNodeId nextPlanNodeId() { return new PlanNodeId(NEXT_ID.getAndIncrement()); } diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java index 275df40cffaa34..d691028666f3c4 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java @@ -22,6 +22,7 @@ import org.apache.doris.common.UserException; import org.apache.doris.nereids.NereidsPlanner; import org.apache.doris.planner.AddLocalExchange; +import org.apache.doris.planner.AggregationNode; import org.apache.doris.planner.LocalExchangeNode; import org.apache.doris.planner.LocalExchangeNode.LocalExchangeType; import org.apache.doris.planner.PlanFragment; @@ -77,6 +78,11 @@ protected void setupLocalShuffleSession(java.util.function.Consumer { + sv.enableBroadcastJoinForcePassthrough = true; + sv.aggPhase = 1; + }); + assertFinalizeDistinctAggChildHashKeyedBy("select count(distinct a.k2) from test.t1 a " + + "left join [shuffle] test.t2 b on a.k2 = b.k2 " + + "left join [broadcast] test.t2 c on b.k1 = c.k1", + "k2"); + + } + + @Test + public void testCountDistinctNoGroupByWithoutForcePassthroughNoRedundantLe() throws Exception { + // Same multi_distinct shape but without broadcast-join force-passthrough: the + // shuffle join's probe output is already hash-partitioned by k2, which satisfies + // the finalize agg's hash demand — so no LOCAL_HASH local exchange may appear. + // The explicit aggPhase=1 + force-passthrough=false (reset in setup) pins the + // exact shape this test means to verify. + setupLocalShuffleSession(sv -> { + sv.enableBroadcastJoinForcePassthrough = false; + sv.aggPhase = 1; + }); + assertNoLocalExchangeOfType("select count(distinct a.k2) from test.t1 a " + + "left join [shuffle] test.t2 b on a.k2 = b.k2 " + + "left join [broadcast] test.t2 c on b.k1 = c.k1", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testDirectMultiDistinctNoKeyHasNoHashLe() throws Exception { + // A directly called scalar multi_distinct_count(k2) has no distribute exprs + // and no group keys: a zero-key LOCAL_HASH exchange would collapse the whole + // input onto one task per BE. The plan must not contain any LOCAL_HASH. + setupLocalShuffleSession(sv -> sv.aggPhase = 1); + assertNoLocalExchangeOfType("select multi_distinct_count(k2) from test.t1", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + @Test + public void testCountStarNoGroupByHasNoHashLe() throws Exception { + // COUNT(*) has no partition requirement: no LOCAL_HASH local exchange may appear + // anywhere in the plan (the two-phase agg only gets the PASSTHROUGH fan-out of + // the pooling scan). + setupLocalShuffleSession(null); + assertNoLocalExchangeOfType("select count(*) from test.t1", + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + + /** + * Assert that every finalize DISTINCT agg (multi_distinct_* output) has a + * LOCAL_EXECUTION_HASH_SHUFFLE local exchange directly beneath it, keyed by + * {@code keyName}. This pins the agg-to-exchange edge and its partition + * expressions — a flatten-to-enum check could not distinguish a keyless or + * wrong-key HASH exchange. + */ + protected void assertFinalizeDistinctAggChildHashKeyedBy(String sql, String keyName) throws Exception { + StmtExecutor executor = executeNereidsSql("explain distributed plan " + sql); + NereidsPlanner planner = (NereidsPlanner) executor.planner(); + List finalizeAggs = new ArrayList<>(); + for (PlanFragment fragment : planner.getFragments()) { + collectFinalizeDistinctAggs(fragment.getPlanRoot(), finalizeAggs); + } + Assertions.assertFalse(finalizeAggs.isEmpty(), "no finalize DISTINCT agg found in plan"); + for (AggregationNode agg : finalizeAggs) { + PlanNode child = agg.getChild(0); + Assertions.assertTrue(child instanceof LocalExchangeNode, + "expected LocalExchangeNode directly below finalize DISTINCT agg, got: " + child); + LocalExchangeNode le = (LocalExchangeNode) child; + Assertions.assertEquals(LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE, le.getExchangeType(), + "hash LE below finalize DISTINCT agg must be LOCAL_EXECUTION_HASH_SHUFFLE"); + Assertions.assertFalse(le.getDistributeExprLists().isEmpty(), + "hash LE below finalize DISTINCT agg must be keyed"); + Assertions.assertTrue(le.getDistributeExprLists().stream() + .anyMatch(e -> e.toString().contains(keyName)), + "hash LE must be keyed by " + keyName + ", actual: " + le.getDistributeExprLists()); + } + } + + private void collectFinalizeDistinctAggs(PlanNode node, List found) { + // "output: multi_distinct_count(...)" pins the merge/finalize DISTINCT agg; + // the sum0(multi_distinct_count(...)) parent above it must not match. + if (node instanceof AggregationNode && node.getNodeExplainString("", TExplainLevel.NORMAL) + .contains("output: multi_distinct_count")) { + found.add((AggregationNode) node); + } + for (PlanNode child : node.getChildren()) { + collectFinalizeDistinctAggs(child, found); + } + } + @Test public void testBroadcastJoinPoolingShapeDsl() throws Exception { // doc rule "HashJoin / BROADCAST / 池化": diff --git a/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out b/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out new file mode 100644 index 00000000000000..507ebcdcc1241c --- /dev/null +++ b/regression-test/data/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.out @@ -0,0 +1,6 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !bug26_be_native -- +1 + +-- !bug26_fe_planned -- +1 diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy index ca3fe027c47fe7..6a9ae2c8fd38e8 100644 --- a/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy +++ b/regression-test/suites/nereids_p0/local_shuffle/test_local_shuffle_rqg_bugs.groovy @@ -1612,5 +1612,54 @@ suite("test_local_shuffle_rqg_bugs") { assertTrue(false, "Bug 25: COLOCATE+NLJ CROSS probe: ${t.message}") } + + // ============================================================ + // Bug 26: scalar count(distinct) over shuffle+broadcast joins returns + // correct-value × task-count when agg_phase=1 + broadcast-join + // force-passthrough with the FE local-shuffle planner. + // Root cause (FE-planned): AggregationNode handed NoRequire to a finalize + // merge agg with no group keys but DISTINCT aggregates; the PASSTHROUGH + // local exchange below the broadcast-join probe scattered same-key rows, + // and sum0(multi_distinct_count(...)) summed the overlapping per-instance + // values. Fixed by keying the hash requirement on the effective partition + // exprs (mirrors BE `_partition_exprs`). + // ============================================================ + try { + logger.info("Bug 26: count(distinct) under agg_phase=1 + broadcast force-passthrough") + sql "DROP TABLE IF EXISTS rqg_local_shuffle_distinct_t1" + sql "DROP TABLE IF EXISTS rqg_local_shuffle_distinct_t2" + sql """CREATE TABLE rqg_local_shuffle_distinct_t1 (pk INT NOT NULL, k2 INT NOT NULL) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE rqg_local_shuffle_distinct_t2 (pk INT NOT NULL, k2 INT NOT NULL, other INT NOT NULL) + ENGINE=OLAP DUPLICATE KEY(pk) DISTRIBUTED BY HASH(pk) BUCKETS 5 + PROPERTIES ("replication_num"="1")""" + // Two rows sharing the same distinct key. batch_size=1 with 4 local tasks + // forces the PASSTHROUGH exchange to send separate blocks to different + // channels, so the pre-fix plan counts the shared key once per task. + sql "INSERT INTO rqg_local_shuffle_distinct_t1 VALUES (1, 5), (2, 5)" + sql "INSERT INTO rqg_local_shuffle_distinct_t2 VALUES (1, 5, 10), (2, 5, 20)" + + def distinctJoinQuery = { vars -> """ + SELECT /*+SET_VAR(${vars})*/ + count(distinct t1.k2) AS cnt_distinct + FROM rqg_local_shuffle_distinct_t1 t1 + LEFT JOIN [shuffle] rqg_local_shuffle_distinct_t2 t2 ON t1.k2 = t2.k2 + LEFT JOIN [broadcast] rqg_local_shuffle_distinct_t2 t3 ON t2.pk = t3.pk + """ } + def distinctJoinVariables = "enable_sql_cache=false, agg_phase=1, " + + "enable_broadcast_join_force_passthrough=true, parallel_pipeline_task_num=4, batch_size=1" + // Pin both implementations to the mathematically correct result (1). Using + // one implementation as the other's oracle would let a shared bug pass. + order_qt_bug26_be_native(distinctJoinQuery( + "${distinctJoinVariables}, enable_local_shuffle_planner=false")) + order_qt_bug26_fe_planned(distinctJoinQuery( + "${distinctJoinVariables}, enable_local_shuffle_planner=true")) + logger.info("Bug 26: PASSED") + } catch (Throwable t) { + logger.error("Bug 26 FAILED: ${t.message}") + assertTrue(false, "Bug 26: ${t.message}") + } + logger.info("=== All RQG bug reproduction tests completed ===") } From 125d0296dc36bc033ef475742bc0d63ff29c3dd5 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Mon, 14 Sep 2026 18:31:30 +0800 Subject: [PATCH 8/9] branch-4.2: [fix](local shuffle) Prevent row loss at parallel-to-serial pipeline boundaries #67177 Cherry-picked from #67177 --- be/src/exec/operator/hashjoin_build_sink.h | 10 +- .../pipeline/pipeline_fragment_context.cpp | 88 ++++++------- .../operator/hashjoin_build_sink_test.cpp | 26 +++- .../exec/pipeline/local_exchanger_test.cpp | 69 ++++++++++- .../doris/planner/AnalyticEvalNode.java | 16 +-- .../apache/doris/planner/HashJoinNode.java | 6 +- .../org/apache/doris/planner/PlanNode.java | 22 +++- .../planner/LocalShuffleNodeCoverageTest.java | 117 +++++++++++++++++- .../doris/qe/LocalExchangePlannerTest.java | 30 +++++ gensrc/thrift/Partitions.thrift | 6 +- ..._serial_aggregation_over_parallel_join.out | 18 +++ ...rial_aggregation_over_parallel_join.groovy | 97 +++++++++++++++ 12 files changed, 433 insertions(+), 72 deletions(-) create mode 100644 regression-test/data/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.out create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.groovy diff --git a/be/src/exec/operator/hashjoin_build_sink.h b/be/src/exec/operator/hashjoin_build_sink.h index ea8e53a872681f..75bdd109998739 100644 --- a/be/src/exec/operator/hashjoin_build_sink.h +++ b/be/src/exec/operator/hashjoin_build_sink.h @@ -133,12 +133,16 @@ class HashJoinBuildSinkOperatorX MOCK_REMOVE(final) ._should_build_hash_table; } - DataDistribution required_data_distribution(RuntimeState* /*state*/) const override { + DataDistribution required_data_distribution(RuntimeState* state) const override { if (_join_op == TJoinOp::NULL_AWARE_LEFT_ANTI_JOIN) { return {TLocalPartitionType::NOOP}; } else if (_is_broadcast_join) { - return _child->is_serial_operator() ? DataDistribution(TLocalPartitionType::PASS_TO_ONE) - : DataDistribution(TLocalPartitionType::NOOP); + if (!_child->is_serial_operator()) { + return {TLocalPartitionType::NOOP}; + } + return state->enable_share_hash_table_for_broadcast_join() + ? DataDistribution(TLocalPartitionType::PASS_TO_ONE) + : DataDistribution(TLocalPartitionType::BROADCAST); } return _join_distribution == TJoinDistributionType::BUCKET_SHUFFLE || _join_distribution == TJoinDistributionType::COLOCATE diff --git a/be/src/exec/pipeline/pipeline_fragment_context.cpp b/be/src/exec/pipeline/pipeline_fragment_context.cpp index bccb92fbf5dddd..14ef0fd572e04f 100644 --- a/be/src/exec/pipeline/pipeline_fragment_context.cpp +++ b/be/src/exec/pipeline/pipeline_fragment_context.cpp @@ -716,6 +716,7 @@ Status PipelineFragmentContext::_build_pipelines(ObjectPool* pool, const Descrip Status PipelineFragmentContext::_create_deferred_local_exchangers() { for (auto& info : _deferred_exchangers) { + const int source_count = cast_set(info.shared_state->source_deps.size()); // DANGER ZONE — do not "fix" this line without reading the history. // // sender_count seeds Exchanger::_running_sink_operators, which the source side @@ -748,34 +749,29 @@ Status PipelineFragmentContext::_create_deferred_local_exchangers() { switch (info.partition_type) { case TLocalPartitionType::LOCAL_EXECUTION_HASH_SHUFFLE: case TLocalPartitionType::GLOBAL_EXECUTION_HASH_SHUFFLE: - info.shared_state->exchanger = ShuffleExchanger::create_unique( - sender_count, _num_instances, info.num_partitions, info.free_blocks_limit, - info.partition_type); + info.shared_state->exchanger = + ShuffleExchanger::create_unique(sender_count, source_count, info.num_partitions, + info.free_blocks_limit, info.partition_type); break; case TLocalPartitionType::BUCKET_HASH_SHUFFLE: info.shared_state->exchanger = BucketShuffleExchanger::create_unique( - sender_count, _num_instances, info.num_partitions, info.free_blocks_limit); + sender_count, source_count, info.num_partitions, info.free_blocks_limit); break; case TLocalPartitionType::PASSTHROUGH: info.shared_state->exchanger = PassthroughExchanger::create_unique( - sender_count, _num_instances, info.free_blocks_limit); + sender_count, source_count, info.free_blocks_limit); break; case TLocalPartitionType::BROADCAST: info.shared_state->exchanger = BroadcastExchanger::create_unique( - sender_count, _num_instances, info.free_blocks_limit); + sender_count, source_count, info.free_blocks_limit); break; case TLocalPartitionType::PASS_TO_ONE: - if (_runtime_state->enable_share_hash_table_for_broadcast_join()) { - info.shared_state->exchanger = PassToOneExchanger::create_unique( - sender_count, _num_instances, info.free_blocks_limit); - } else { - info.shared_state->exchanger = BroadcastExchanger::create_unique( - sender_count, _num_instances, info.free_blocks_limit); - } + info.shared_state->exchanger = PassToOneExchanger::create_unique( + sender_count, source_count, info.free_blocks_limit); break; case TLocalPartitionType::ADAPTIVE_PASSTHROUGH: info.shared_state->exchanger = AdaptivePassthroughExchanger::create_unique( - sender_count, _num_instances, info.free_blocks_limit); + sender_count, source_count, info.free_blocks_limit); break; case TLocalPartitionType::NOOP: case TLocalPartitionType::LOCAL_MERGE_SORT: @@ -852,11 +848,17 @@ void PipelineFragmentContext::_propagate_local_exchange_num_tasks() { if (pit != id_to_pipe.end()) { auto& pipe = pit->second; const auto& ops = pipe->operators(); - const bool le_source = - !ops.empty() && dynamic_cast(ops.front().get()); + auto* le_source = + !ops.empty() ? dynamic_cast(ops.front().get()) + : nullptr; const bool serial_source = !ops.empty() && ops.front()->is_serial_operator(); if (le_source) { - pipe->set_num_tasks(_num_instances); + // PASS_TO_ONE is the explicit N-to-one boundary. Its upstream pipeline + // keeps all active tasks, while only fragment instance 0 creates the + // downstream serial pipeline task. + if (le_source->exchange_type() != TLocalPartitionType::PASS_TO_ONE) { + pipe->set_num_tasks(_num_instances); + } } else if (!serial_source) { int target = pipe->num_tasks(); const auto up_it = _dag.find(id); @@ -1069,22 +1071,12 @@ Status PipelineFragmentContext::_add_local_exchange_impl( : 0); break; case TLocalPartitionType::PASS_TO_ONE: - if (_runtime_state->enable_share_hash_table_for_broadcast_join()) { - // If shared hash table is enabled for BJ, hash table will be built by only one task - shared_state->exchanger = PassToOneExchanger::create_unique( - cur_pipe->num_tasks(), _num_instances, - _runtime_state->query_options().__isset.local_exchange_free_blocks_limit - ? cast_set(_runtime_state->query_options() - .local_exchange_free_blocks_limit) - : 0); - } else { - shared_state->exchanger = BroadcastExchanger::create_unique( - cur_pipe->num_tasks(), _num_instances, - _runtime_state->query_options().__isset.local_exchange_free_blocks_limit - ? cast_set(_runtime_state->query_options() - .local_exchange_free_blocks_limit) - : 0); - } + shared_state->exchanger = PassToOneExchanger::create_unique( + cur_pipe->num_tasks(), _num_instances, + _runtime_state->query_options().__isset.local_exchange_free_blocks_limit + ? cast_set( + _runtime_state->query_options().local_exchange_free_blocks_limit) + : 0); break; case TLocalPartitionType::ADAPTIVE_PASSTHROUGH: shared_state->exchanger = AdaptivePassthroughExchanger::create_unique( @@ -1987,9 +1979,12 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo } case TPlanNodeType::LOCAL_EXCHANGE_NODE: { op = std::make_shared(pool, tnode, next_operator_id(), descs); - // The downstream pipeline (containing LocalExchangeSource) must have - // _num_instances tasks — matching BE-native _inherit_pipeline_properties - // which sets pipe_with_source.set_num_tasks(_num_instances). + const auto partition_type = tnode.local_exchange_node.partition_type; + const bool pass_to_one = partition_type == TLocalPartitionType::PASS_TO_ONE; + // Except at an explicit PASS_TO_ONE boundary, the downstream pipeline + // (containing LocalExchangeSource) must have _num_instances tasks. This + // matches BE-native _inherit_pipeline_properties, which sets + // pipe_with_source.set_num_tasks(_num_instances). // Without this, when the parent pipeline was reduced by a serial operator // (e.g., serial Exchange with use_serial_exchange=true, or UNPARTITIONED // Exchange), the downstream inherits the reduced num_tasks via @@ -1998,14 +1993,23 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo // sink round-robins to all channels and crashes on uninitialized ones. RETURN_IF_ERROR(cur_pipe->add_operator(op, _parallel_instances)); // Restore downstream pipeline's num_tasks (mirroring _inherit_pipeline_properties: - // downstream keeps _num_instances, upstream gets the serial/reduced count) - cur_pipe->set_num_tasks(_num_instances); + // downstream keeps _num_instances, upstream gets the serial/reduced count). + // PASS_TO_ONE is the explicit parallel-to-serial boundary: its downstream + // pipeline must keep the serial parent's single active task, while the upstream + // pipeline is expanded below so every fragment instance keeps an active receiver. + if (!pass_to_one) { + cur_pipe->set_num_tasks(_num_instances); + } + const int downstream_num_tasks = cur_pipe->num_tasks(); const auto downstream_pipeline_id = cur_pipe->id(); if (!_dag.contains(downstream_pipeline_id)) { _dag.insert({downstream_pipeline_id, {}}); } cur_pipe = add_pipeline(cur_pipe); + if (pass_to_one) { + cur_pipe->set_num_tasks(_num_instances); + } // If this local exchange was inserted because of a serial scan (is_serial_operator), // the upstream pipeline (cur_pipe) should have num_tasks=1 (only 1 scan task). // We set this now so the exchanger is created with the correct sender count. @@ -2017,7 +2021,6 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo _dag[downstream_pipeline_id].push_back(cur_pipe->id()); int num_partitions = 0; std::map shuffle_id_to_instance_idx; - auto partition_type = tnode.local_exchange_node.partition_type; switch (partition_type) { case TLocalPartitionType::BUCKET_HASH_SHUFFLE: num_partitions = _params.num_buckets; @@ -2057,9 +2060,10 @@ Status PipelineFragmentContext::_create_operator(ObjectPool* pool, const TPlanNo ? cast_set( _runtime_state->query_options().local_exchange_free_blocks_limit) : 0; - auto shared_state = LocalExchangeSharedState::create_shared(_num_instances); - shared_state->create_source_dependencies(_num_instances, local_exchange_id, - local_exchange_id, "LOCAL_EXCHANGE_OPERATOR"); + const int source_count = downstream_num_tasks; + auto shared_state = LocalExchangeSharedState::create_shared(source_count); + shared_state->create_source_dependencies(source_count, local_exchange_id, local_exchange_id, + "LOCAL_EXCHANGE_OPERATOR"); shared_state->create_sink_dependency(sink_id, local_exchange_id, "LOCAL_EXCHANGE_SINK"); _op_id_to_shared_state.insert({local_exchange_id, {shared_state, shared_state->sink_deps}}); // Defer exchanger creation: sender count depends on final upstream num_tasks diff --git a/be/test/exec/operator/hashjoin_build_sink_test.cpp b/be/test/exec/operator/hashjoin_build_sink_test.cpp index 38de2389352343..b779dd580e53e3 100644 --- a/be/test/exec/operator/hashjoin_build_sink_test.cpp +++ b/be/test/exec/operator/hashjoin_build_sink_test.cpp @@ -266,6 +266,30 @@ TEST_F(HashJoinBuildSinkTest, Sink) { run_test_block(test_block); } +TEST_F(HashJoinBuildSinkTest, BroadcastJoinRequiredDataDistribution) { + auto tnode = _helper.create_test_plan_node(TJoinOp::INNER_JOIN, {TPrimitiveType::INT}, {false}, + {false}); + tnode.hash_join_node.__set_is_broadcast_join(true); + auto [probe_operator, sink_operator] = _helper.create_operators(tnode); + ASSERT_TRUE(probe_operator); + ASSERT_TRUE(sink_operator); + + EXPECT_EQ(sink_operator->required_data_distribution(_helper.runtime_state.get()) + .distribution_type, + TLocalPartitionType::NOOP); + + sink_operator->child()->set_serial_operator(); + _helper.runtime_state->_enable_share_hash_table_for_broadcast_join = true; + EXPECT_EQ(sink_operator->required_data_distribution(_helper.runtime_state.get()) + .distribution_type, + TLocalPartitionType::PASS_TO_ONE); + + _helper.runtime_state->_enable_share_hash_table_for_broadcast_join = false; + EXPECT_EQ(sink_operator->required_data_distribution(_helper.runtime_state.get()) + .distribution_type, + TLocalPartitionType::BROADCAST); +} + TEST_F(HashJoinBuildSinkTest, Terminate) { auto test_block = [&](TJoinOp::type op_type, const std::vector& key_types, const std::vector& left_nullables, @@ -358,4 +382,4 @@ TEST_F(HashJoinBuildSinkTest, Terminate) { run_test_block(test_block); } -} // namespace doris \ No newline at end of file +} // namespace doris diff --git a/be/test/exec/pipeline/local_exchanger_test.cpp b/be/test/exec/pipeline/local_exchanger_test.cpp index 4245d87bf3824d..f54a903e263c35 100644 --- a/be/test/exec/pipeline/local_exchanger_test.cpp +++ b/be/test/exec/pipeline/local_exchanger_test.cpp @@ -29,12 +29,14 @@ #include "exec/exchange/local_exchange_sink_operator.h" #include "exec/exchange/local_exchange_source_operator.h" #include "exec/pipeline/dependency.h" +#include "exec/pipeline/pipeline_fragment_context.h" #include "exec/pipeline/thrift_builder.h" #include "exprs/vslot_ref.h" +#include "runtime/descriptor_helper.h" namespace doris { -class LocalExchangerTest : public testing::Test { +class LocalExchangerTest : public testing::TestWithParam { public: LocalExchangerTest() = default; ~LocalExchangerTest() override = default; @@ -526,9 +528,60 @@ TEST_F(LocalExchangerTest, PassthroughExchanger) { } } -TEST_F(LocalExchangerTest, PassToOneExchanger) { +TEST_F(LocalExchangerTest, FePlannedPassToOneUsesOneDownstreamSource) { + constexpr int num_instances = 4; + _query_options.__set_enable_share_hash_table_for_broadcast_join(false); + TPipelineFragmentParams params; + auto context = std::make_shared( + _query_id, params, _query_ctx, ExecEnv::GetInstance(), [](RuntimeState*, Status*) {}); + context->_num_instances = num_instances; + context->_total_instances = num_instances; + context->_runtime_state = RuntimeState::create_unique(_query_id, _fragment_id, _query_options, + _query_ctx->query_globals, + ExecEnv::GetInstance(), _query_ctx.get()); + + auto downstream_pipe = context->add_pipeline(); + downstream_pipe->set_num_tasks(1); + auto upstream_pipe = downstream_pipe; + + TLocalExchangeNode local_exchange_node; + local_exchange_node.__set_partition_type(TLocalPartitionType::PASS_TO_ONE); + TPlanNode tnode; + tnode.__set_node_type(TPlanNodeType::LOCAL_EXCHANGE_NODE); + tnode.__set_node_id(0); + tnode.__set_num_children(1); + tnode.__set_local_exchange_node(local_exchange_node); + tnode.__set_row_tuples({0}); + + ObjectPool pool; + TDescriptorTableBuilder desc_builder; + TTupleDescriptorBuilder().build(&desc_builder); + DescriptorTbl* descs = nullptr; + ASSERT_TRUE(DescriptorTbl::create(&pool, desc_builder.desc_tbl(), &descs).ok()); + OperatorPtr op; + OperatorPtr cache_op; + ASSERT_TRUE(context->_create_operator(&pool, tnode, *descs, op, upstream_pipe, + /*parent_idx=*/-1, /*child_idx=*/0, + /*followed_by_shuffled_operator=*/false, + /*require_bucket_distribution=*/false, cache_op) + .ok()); + ASSERT_EQ(context->_deferred_exchangers.size(), 1); + EXPECT_EQ(downstream_pipe->num_tasks(), 1); + EXPECT_EQ(upstream_pipe->num_tasks(), num_instances); + + auto shared_state = context->_deferred_exchangers.front().shared_state; + EXPECT_EQ(shared_state->source_deps.size(), 1); + EXPECT_EQ(shared_state->mem_counters.size(), 1); + ASSERT_TRUE(context->_create_deferred_local_exchangers().ok()); + ASSERT_NE(shared_state->exchanger, nullptr); + EXPECT_EQ(shared_state->exchanger->get_type(), TLocalPartitionType::PASS_TO_ONE); + EXPECT_EQ(shared_state->exchanger->_num_senders, num_instances); + EXPECT_EQ(shared_state->exchanger->_num_sources, 1); +} + +TEST_P(LocalExchangerTest, PassToOneExchanger) { int num_sink = 4; - int num_sources = 4; + int num_sources = GetParam(); int free_block_limit = 0; const auto expect_block_bytes = 128; @@ -549,6 +602,8 @@ TEST_F(LocalExchangerTest, PassToOneExchanger) { shared_state->create_source_dependencies(num_sources, 0, 0, "TEST"); auto* exchanger = (PassToOneExchanger*)shared_state->exchanger.get(); + EXPECT_EQ(exchanger->_num_senders, num_sink); + EXPECT_EQ(exchanger->_num_sources, num_sources); for (size_t i = 0; i < num_sink; i++) { auto* compute_hash_value_timer = ADD_TIMER(profile, "ComputeHashValueTime" + std::to_string(i)); @@ -579,10 +634,9 @@ TEST_F(LocalExchangerTest, PassToOneExchanger) { "MemoryUsage" + std::to_string(i), TUnit::BYTES, "", 1); shared_state->mem_counters[i] = _local_states[i]->_memory_used_counter; } - { - // Enqueue `num_blocks` blocks with 10 rows for each data queue. - for (size_t i = 0; i < num_sources; i++) { + // Enqueue `num_blocks` blocks with 10 rows from every sender. + for (size_t i = 0; i < num_sink; i++) { for (size_t j = 0; j < num_blocks; j++) { Block in_block; DataTypePtr int_type = std::make_shared(); @@ -735,6 +789,8 @@ TEST_F(LocalExchangerTest, PassToOneExchanger) { } } +INSTANTIATE_TEST_SUITE_P(SourceCardinality, LocalExchangerTest, testing::Values(4, 1)); + TEST_F(LocalExchangerTest, BroadcastExchanger) { int num_sink = 4; int num_sources = 4; @@ -1386,4 +1442,5 @@ TEST_F(LocalExchangerTest, ShuffleExchangerRestoreOutputBlockOnAddRowsError) { EXPECT_EQ(output_block.rows(), 1); EXPECT_NO_THROW(output_block.check_number_of_rows()); } + } // namespace doris diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java index c16b62010868e2..62c54761e8c4fb 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/AnalyticEvalNode.java @@ -253,17 +253,19 @@ public Pair enforceAndDeriveLocalExchange(PlanTrans LocalExchangeType outputType = null; if (partitionExprs.isEmpty()) { // Serial AnalyticEval (OVER() with no PARTITION BY): - // Must NOT have any LocalExchange between AnalyticEval and its child. - // On BE, AnalyticSink and AnalyticSource share state (source_deps/sink_deps). - // A LocalExchange below would restore the AnalyticSink pipeline to _num_instances - // tasks while the serial AnalyticSource pipeline stays at 1 task. + // Do not keep a redundant LocalExchange between AnalyticEval and an already + // serial child. On BE, AnalyticSink and AnalyticSource share state + // (source_deps/sink_deps), so restoring only the sink pipeline to + // _num_instances tasks would mismatch the serial source pipeline. // - // Use enforceRequire with noRequire to traverse children, then strip any - // LocalExchange the child inserted (e.g., Exchange wrapping itself with PASSTHROUGH). + // PASS_TO_ONE is different: enforceRequire inserts it when the child subtree is + // parallel. It is the explicit N-to-one boundary that keeps every upstream task + // active while leaving the analytic sink/source pair at one task, so retain it. Pair enforceResult = enforceRequire(translatorContext, children.get(0), 0, LocalExchangeTypeRequire.noRequire()); PlanNode newChild = enforceResult.first; - if (newChild instanceof LocalExchangeNode) { + if (newChild instanceof LocalExchangeNode + && ((LocalExchangeNode) newChild).getExchangeType() != LocalExchangeType.PASS_TO_ONE) { newChild = newChild.getChild(0); } children = Lists.newArrayList(newChild); diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java index 4ae1d480b5fb67..b2700a05af9f8e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/HashJoinNode.java @@ -434,7 +434,11 @@ public Pair enforceAndDeriveLocalExchange( ? LocalExchangeTypeRequire.requirePassthrough() : LocalExchangeTypeRequire.noRequire(); buildSideRequire = buildChildSerial - ? LocalExchangeTypeRequire.requirePassToOne() + ? (translatorContext.getConnectContext() == null + || translatorContext.getConnectContext().getSessionVariable() + .enableShareHashTableForBroadcastJoin + ? LocalExchangeTypeRequire.requirePassToOne() + : LocalExchangeTypeRequire.requireBroadcast()) : LocalExchangeTypeRequire.noRequire(); // For serial or force-passthrough probe: output is PASSTHROUGH. // For a non-serial probe without the flag: propagate the probe's distribution. diff --git a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java index 18556071315bd4..c2b4e316139a8f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java +++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PlanNode.java @@ -1216,8 +1216,14 @@ protected Pair enforceRequire( // serial-source mode, BE treats this operator as non-serial regardless of isSerialNode. // Using isSerialNode here would set the child's serial-ancestor flag wider than BE's // view and over-skip required LocalExchanges downstream. - boolean childHasSerialAncestor = inheritedSerial - || isSerialOperatorOnBe(translatorContext.getConnectContext()); + boolean selfSerial = isSerialOperatorOnBe(translatorContext.getConnectContext()); + boolean passToOneAtSerialBoundary = selfSerial + && !child.isSerialOperatorOnBe(translatorContext.getConnectContext()); + // PASS_TO_ONE becomes the pipeline boundary between this serial consumer and the + // parallel child subtree. Do not let either serial marker cross that boundary: the + // child must still plan the local exchanges required by its own parallel pipelines. + boolean childHasSerialAncestor = passToOneAtSerialBoundary + ? false : inheritedSerial || selfSerial; translatorContext.setHasSerialAncestorInPipeline(child, childHasSerialAncestor); // 1b. Propagate shuffle-for-correctness-ancestor flag to child. @@ -1239,6 +1245,18 @@ protected Pair enforceRequire( Pair childOutput = child.enforceAndDeriveLocalExchange(translatorContext, this, require); + // A serial consumer must not implicitly reduce a non-serial subtree to one pipeline + // task. Besides losing parallelism, that can make a remote Exchange expose fewer + // receiver tasks than FE addresses. Keep the subtree parallel and make the N-to-one + // transition explicit. PASS_TO_ONE keeps every upstream receiver task alive and + // funnels their output into the serial downstream pipeline's only task. + if (passToOneAtSerialBoundary && childOutput.second != LocalExchangeType.PASS_TO_ONE) { + childOutput = Pair.of( + createLocalExchange(translatorContext, childOutput.first, + LocalExchangeType.PASS_TO_ONE, null), + LocalExchangeType.PASS_TO_ONE); + } + // Steps 2.5 and 3 both react to a serial child but address different concerns: // - Step 2.5 rewrites the OUTPUT-side view (what we tell satisfy/parent about // the child's actual distribution). A serial pipeline runs with 1 task so diff --git a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java index 59c26166c3f51b..e8bcbb195aa1d3 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/planner/LocalShuffleNodeCoverageTest.java @@ -547,6 +547,26 @@ public void testLayer1SkipUsesIsSerialOperatorOnBeNotIsSerialNode() { + "even if isSerialNode()=true — BE treats the node as non-serial."); } + @Test + public void testPassToOneBoundaryKeepsParallelSubtreeLocalExchange() { + PlanTranslatorContext ctx = new PlanTranslatorContext(); + TrackingPlanNode leaf = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + HashRequiringPlanNode parallelSubtree = new HashRequiringPlanNode(nextPlanNodeId(), leaf); + SerialPipelineBoundaryNode serialParent = new SerialPipelineBoundaryNode( + nextPlanNodeId(), parallelSubtree); + serialParent.fragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialParent.fragment.useSerialSource(Mockito.any())).thenReturn(true); + + Pair output = serialParent.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + + Assertions.assertEquals(LocalExchangeType.PASS_TO_ONE, output.second); + assertChildLocalExchangeType(serialParent, 0, LocalExchangeType.PASS_TO_ONE); + Assertions.assertSame(parallelSubtree, serialParent.getChild(0).getChild(0)); + assertChildLocalExchangeType(parallelSubtree, 0, + LocalExchangeType.LOCAL_EXECUTION_HASH_SHUFFLE); + } + @Test public void testNestedLoopJoinNodeBranches() { PlanTranslatorContext ctx = new PlanTranslatorContext(); @@ -753,8 +773,10 @@ public void testSortNodeBranches() { // Output is still PASSTHROUGH (hardcoded for useSerialSource + ScanNode child). SerialTrackingScanNode serialScan = new SerialTrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); SortNode scanSort = new SortNode(nextPlanNodeId(), serialScan, sortInfo, false); - scanSort.fragment = Mockito.mock(PlanFragment.class); - Mockito.when(scanSort.fragment.useSerialSource(Mockito.any())).thenReturn(true); + PlanFragment serialSortFragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialSortFragment.useSerialSource(Mockito.any())).thenReturn(true); + scanSort.setFragment(serialSortFragment); + serialScan.setFragment(serialSortFragment); Pair scanOutput = scanSort.enforceAndDeriveLocalExchange( ctx, null, LocalExchangeTypeRequire.noRequire()); // Non-merge, non-analytic SortNode: isSerialNode()=true, requireChild=noRequire, @@ -809,6 +831,24 @@ public void testAnalyticEvalNodeBranches() { Assertions.assertEquals(LocalExchangeType.NOOP, noPartitionOutput.second); Assertions.assertSame(noPartitionChild, noPartition.getChild(0)); + // A serial analytic consumer over a parallel subtree needs the generic + // parallel-to-serial boundary inserted by PlanNode.enforceRequire. The analytic + // special case may remove a redundant exchange directly above a serial Exchange, + // but must retain this PASS_TO_ONE gather. + TrackingPlanNode parallelChild = new TrackingPlanNode(nextPlanNodeId(), LocalExchangeType.NOOP); + AnalyticEvalNode serialOverParallel = new AnalyticEvalNode(nextPlanNodeId(), parallelChild, + Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), + null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); + PlanFragment serialAnalyticFragment = Mockito.mock(PlanFragment.class); + Mockito.when(serialAnalyticFragment.useSerialSource(Mockito.any())).thenReturn(true); + serialOverParallel.setFragment(serialAnalyticFragment); + parallelChild.setFragment(serialAnalyticFragment); + Pair serialOverParallelOutput + = serialOverParallel.enforceAndDeriveLocalExchange( + ctx, null, LocalExchangeTypeRequire.noRequire()); + Assertions.assertEquals(LocalExchangeType.NOOP, serialOverParallelOutput.second); + assertChildLocalExchangeType(serialOverParallel, 0, LocalExchangeType.PASS_TO_ONE); + // Analytic with partition but no orderBy, non-colocated → noRequire/NOOP. // (Non-colocated analytic relies on parent SortNode to handle distribution.) TrackingScanNode hashChild = new TrackingScanNode(nextPlanNodeId(), LocalExchangeType.NOOP); @@ -826,12 +866,14 @@ public void testAnalyticEvalNodeBranches() { Collections.emptyList(), Collections.singletonList(Mockito.mock(Expr.class)), Collections.singletonList(new OrderByElement(Mockito.mock(Expr.class), true, true)), null, new TupleDescriptor(new TupleId(NEXT_ID.getAndIncrement()))); - orderedAnalytic.fragment = Mockito.mock(PlanFragment.class); - Mockito.when(orderedAnalytic.fragment.useSerialSource(Mockito.any())).thenReturn(true); + PlanFragment orderedAnalyticFragment = Mockito.mock(PlanFragment.class); + Mockito.when(orderedAnalyticFragment.useSerialSource(Mockito.any())).thenReturn(true); + orderedAnalytic.setFragment(orderedAnalyticFragment); + serialScan.setFragment(orderedAnalyticFragment); Pair orderedOutput = orderedAnalytic.enforceAndDeriveLocalExchange( ctx, null, LocalExchangeTypeRequire.noRequire()); - // Serial AnalyticEval returns NOOP — lets framework serial check handle fan-out - Assertions.assertEquals(LocalExchangeType.NOOP, orderedOutput.second); + Assertions.assertEquals(LocalExchangeType.PASSTHROUGH, orderedOutput.second); + assertChildLocalExchangeType(orderedAnalytic, 0, LocalExchangeType.PASSTHROUGH); } @Test @@ -1210,6 +1252,69 @@ public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { } } + private static class SerialPipelineBoundaryNode extends PlanNode { + SerialPipelineBoundaryNode(PlanNodeId id, PlanNode child) { + super(id, Lists.newArrayList(new TupleId(id.asInt() + 30000)), + "SERIAL_PIPELINE_BOUNDARY"); + children.add(child); + } + + @Override + public boolean isSerialNode() { + return true; + } + + @Override + protected boolean shouldResetSerialFlagForChild(int childIndex) { + return true; + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, + LocalExchangeTypeRequire parentRequire) { + Pair result = enforceRequire(translatorContext, + children.get(0), 0, LocalExchangeTypeRequire.noRequire()); + children = Lists.newArrayList(result.first); + return Pair.of(this, result.second); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + + private static class HashRequiringPlanNode extends PlanNode { + HashRequiringPlanNode(PlanNodeId id, PlanNode child) { + super(id, Lists.newArrayList(new TupleId(id.asInt() + 40000)), "HASH_REQUIRING"); + children.add(child); + } + + @Override + public Pair enforceAndDeriveLocalExchange( + PlanTranslatorContext translatorContext, PlanNode parent, + LocalExchangeTypeRequire parentRequire) { + Pair result = enforceRequire(translatorContext, + children.get(0), 0, LocalExchangeTypeRequire.requireHash()); + children = Lists.newArrayList(result.first); + return Pair.of(this, result.second); + } + + @Override + protected void toThrift(TPlanNode msg) { + } + + @Override + public String getNodeExplainString(String prefix, TExplainLevel detailLevel) { + return ""; + } + } + private static class TrackingPlanNode extends PlanNode { private final LocalExchangeType providedType; private LocalExchangeTypeRequire lastRequire; diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java index d691028666f3c4..7fead508744382 100644 --- a/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java +++ b/fe/fe-core/src/test/java/org/apache/doris/qe/LocalExchangePlannerTest.java @@ -75,6 +75,7 @@ protected void setupLocalShuffleSession(java.util.function.Consumer sv.enableShareHashTableForBroadcastJoin = false); + connectContext.getSessionVariable().setPipelineTaskNum("3"); + assertPlanShape( + "select sum(distinct a.k1) from test.t1 a " + + "left join test.t2 b on a.k1 = b.k1", + anyTree( + agg( + localExchange(PASS_TO_ONE_LE, + hashJoin( + localExchange(PT, olapScan()), + localExchange(BROADCAST_LE, + anyTree(exchange()))))))); + } + @Test public void testCountDistinctNoGroupByRequiresHashBeforeAgg() throws Exception { // count(distinct k2) without group-by: the finalize merge agg emits per-instance @@ -286,6 +304,18 @@ public void testBroadcastJoinPoolingShapeDsl() throws Exception { anyTree(exchange()))))); } + @Test + public void testPrivateBroadcastJoinBuildUsesBroadcastLocalExchange() throws Exception { + setupLocalShuffleSession(sv -> sv.enableShareHashTableForBroadcastJoin = false); + assertPlanShape("select * from test.t1 a join [broadcast] test.t2 b on a.k1=b.k1", + anyTree( + hashJoin( + localExchange(PT, + olapScan()), + localExchange(BROADCAST_LE, + anyTree(exchange()))))); + } + @Test public void testNlJoinPoolingShapeDsl() throws Exception { // doc rule "NL join / 池化": build BROADCAST, probe ADAPTIVE_PASSTHROUGH. diff --git a/gensrc/thrift/Partitions.thrift b/gensrc/thrift/Partitions.thrift index 9b57a7078f76f6..5cff33eaa1e70e 100644 --- a/gensrc/thrift/Partitions.thrift +++ b/gensrc/thrift/Partitions.thrift @@ -129,10 +129,8 @@ enum TLocalPartitionType { // Scan(build side) -> LocalExchangeNode(BROADCAST) -> HashJoin(build) BROADCAST = 6, // PASS_TO_ONE: funnel all rows to a single local instance (channel 0); every other instance gets EOS - // immediately and produces nothing (PassToOneExchanger). used for a broadcast join with a shared - // hash table, where only instance 0 needs the build data and the others share its hash table. - // NOTE: BE only uses PassToOneExchanger when `enable_share_hash_table_for_broadcast_join` is on; - // when it is off the same PASS_TO_ONE type degrades to BROADCAST (each instance keeps its own copy). + // immediately and produces nothing (PassToOneExchanger). Used at parallel-to-serial boundaries and + // for a broadcast join with a shared hash table. A private broadcast hash table uses BROADCAST. PASS_TO_ONE = 7, // LOCAL_MERGE_SORT: k-way merge of several already-sorted local inputs into one globally sorted // stream on a single instance (paired with LocalMergeSortSourceOperator, for a SortNode with diff --git a/regression-test/data/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.out b/regression-test/data/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.out new file mode 100644 index 00000000000000..0ef93e2a60108e --- /dev/null +++ b/regression-test/data/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.out @@ -0,0 +1,18 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !count_distinct_left_join -- +10 + +-- !sum_and_count_distinct_left_join -- +23 5 + +-- !sum_distinct_broad_predicate -- +45 + +-- !sum_distinct_reversed_join -- +45 + +-- !sum_distinct_multi_outer_join -- +35 + +-- !native_private_broadcast_build -- +10 diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.groovy new file mode 100644 index 00000000000000..ebe9c6eed63190 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_serial_aggregation_over_parallel_join.groovy @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_serial_aggregation_over_parallel_join") { + ["serial_agg_join_probe", "serial_agg_join_left", "serial_agg_join_right"].each { table -> + sql "DROP TABLE IF EXISTS ${table}" + } + + sql """CREATE TABLE serial_agg_join_probe ( + col_bigint BIGINT, col_v10 VARCHAR(10), col_v64 VARCHAR(64), pk INT + ) ENGINE=OLAP DISTRIBUTED BY HASH(pk) BUCKETS 10 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE serial_agg_join_left ( + pk INT, col_bigint BIGINT, col_v10 VARCHAR(10), col_v64 VARCHAR(64) + ) ENGINE=OLAP DUPLICATE KEY(pk, col_bigint, col_v10) + DISTRIBUTED BY HASH(pk) BUCKETS 10 PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE serial_agg_join_right ( + pk INT, col_v10 VARCHAR(10), col_bigint BIGINT, col_v64 VARCHAR(64) + ) ENGINE=OLAP DUPLICATE KEY(pk, col_v10) + DISTRIBUTED BY HASH(pk) BUCKETS 10 PROPERTIES ("replication_num"="1")""" + + sql """INSERT INTO serial_agg_join_probe VALUES + (-94,'had','y',0),(672609,'k','h',1),(-3766684,'a','p',2),(5070261,'on','x',3), + (NULL,'u','at',4),(-86,'v','c',5),(21910,'how','m',6),(-63,'that''s','go',7), + (-8276281,'s','a',8),(-101,'w','y',9)""" + sql """INSERT INTO serial_agg_join_left VALUES + (0,NULL,'g','i'),(1,-6138328,'z','do'),(2,-23217,'g','about'),(3,104,'you''re','z'), + (4,NULL,'oh','i'),(5,-54,'want','to'),(6,NULL,'x','c'),(7,NULL,'you''re','come'), + (8,3447,'really','from'),(9,-5459,'i','will')""" + sql """INSERT INTO serial_agg_join_right VALUES + (0,'right',NULL,'g'),(1,'on',-486256,'on'),(2,'I''ll',-1,'at'),(3,'h',29263,'don''t'), + (4,'a',5453,'s'),(5,'j',-119,'can''t'),(6,'one',89,'n'),(7,'s',-7227,'u'), + (8,'time',94,'b'),(9,'yes',1816630,'yes')""" + + def variables = "enable_local_shuffle_planner=true,enable_local_shuffle=true," + + "enable_bucket_shuffle_join=true,ignore_storage_data_distribution=true," + + "bucket_shuffle_downgrade_ratio=0.8,use_serial_exchange=false," + + "parallel_pipeline_task_num=3,enable_sql_cache=false," + + "enable_share_hash_table_for_broadcast_join=false" + + order_qt_count_distinct_left_join """SELECT /*+SET_VAR(${variables})*/ COUNT(DISTINCT t1.pk) + FROM serial_agg_join_left t1 LEFT JOIN serial_agg_join_probe t2 ON t2.pk=t1.pk + WHERE (t1.col_v64>'FVjnKolDTt' AND t1.col_v64<='z') OR t1.col_v64 IS NULL + OR (t1.col_v10>'me' AND t1.col_v10<='zzzz' AND t1.col_bigint BETWEEN 3 AND 7)""" + + order_qt_sum_and_count_distinct_left_join """SELECT /*+SET_VAR(${variables})*/ + SUM(DISTINCT t1.pk), COUNT(DISTINCT t1.pk) + FROM serial_agg_join_right t1 LEFT JOIN serial_agg_join_probe t2 ON t2.pk=t1.pk + WHERE t1.pk IN (2,9) OR t1.col_bigint IN (1,8) + OR (t1.col_v64>='MijtyYyxeA' AND t1.col_v64<'z' + AND t1.col_v64>='on' AND t1.col_v64<'zzzz')""" + + order_qt_sum_distinct_broad_predicate """SELECT /*+SET_VAR(${variables})*/ SUM(DISTINCT t1.pk) + FROM serial_agg_join_right t1 LEFT JOIN serial_agg_join_probe t2 ON t2.pk=t1.pk + WHERE (t1.col_v64>='QXQpaZhWfj' AND t1.col_v64<'z') + OR (t1.col_v64>='fvPsFBZelL' AND t1.col_v64<='well') + OR (t1.pk BETWEEN 0 AND 15 AND t1.col_v10 LIKE 'a%') + OR (t1.pk>=3 AND t1.pk<4) OR t1.pk BETWEEN 0 AND 100 OR (t1.pk>7 AND t1.pk<=9)""" + + order_qt_sum_distinct_reversed_join """SELECT /*+SET_VAR(${variables})*/ SUM(DISTINCT t1.pk) + FROM serial_agg_join_probe t1 LEFT JOIN serial_agg_join_right t2 ON t1.pk=t2.pk + WHERE (t1.pk IS NOT NULL AND t1.pk IN (3,8,2,2) + AND t1.col_v64 IN ('didn''t','when','a','come','AgpEFIOTAN')) + OR (t1.col_v64>'HoatMBMEwP' AND t1.col_v64<='zzzz') OR t1.pk BETWEEN 6 AND 11 + OR (t1.pk IS NULL AND t1.pk IN (5)) OR (t1.pk<=t1.col_bigint AND t1.pk IN (8))""" + + order_qt_sum_distinct_multi_outer_join """SELECT /*+SET_VAR(${variables})*/ SUM(DISTINCT t1.pk) + FROM serial_agg_join_right t1 RIGHT OUTER JOIN serial_agg_join_probe t2 ON t2.pk=t2.pk + LEFT JOIN serial_agg_join_left t3 ON t3.pk=t1.pk + WHERE (t1.col_v10>'jHKKlhlHDn' AND t1.col_v10<'z' + AND t1.col_v10 NOT IN ('him','you''re')) + OR (t1.col_v64>='j' AND t1.col_v64<='y') + OR (t1.col_v10 NOT BETWEEN 'rxpMJWfBRX' AND 'z' AND t1.col_bigint IN (1000) + AND t1.col_bigint IS NULL AND t1.col_bigint BETWEEN 6 AND 15)""" + + def nativeVariables = "enable_local_shuffle_planner=false,enable_local_shuffle=true," + + "parallel_pipeline_task_num=3,enable_sql_cache=false," + + "enable_share_hash_table_for_broadcast_join=false" + + order_qt_native_private_broadcast_build """SELECT /*+SET_VAR(${nativeVariables})*/ COUNT(t2.pk) + FROM serial_agg_join_right t1 INNER JOIN [broadcast] serial_agg_join_probe t2 ON t2.pk=t1.pk + WHERE t1.pk BETWEEN 0 AND 9""" +} From 837e903d1b5c9ce8334692a69a55f2f0aeab5c92 Mon Sep 17 00:00:00 2001 From: 924060929 Date: Wed, 16 Sep 2026 11:13:17 +0800 Subject: [PATCH 9/9] branch-4.2: [fix](local shuffle) Populate distribution keys for select nodes #67941 Cherry-picked from #67941 --- .../translator/PhysicalPlanTranslator.java | 23 ++++--- .../test_select_analytic_shuffle_join.out | 6 ++ .../test_select_analytic_shuffle_join.groovy | 62 +++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 regression-test/data/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.out create mode 100644 regression-test/suites/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.groovy diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java index 0cbd2562c588ee..d959e18bbc35fa 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java @@ -128,6 +128,7 @@ import org.apache.doris.nereids.trees.plans.algebra.Aggregate; import org.apache.doris.nereids.trees.plans.algebra.Relation; import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalJoin; +import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalPlan; import org.apache.doris.nereids.trees.plans.physical.AbstractPhysicalSort; import org.apache.doris.nereids.trees.plans.physical.PhysicalAssertNumRows; import org.apache.doris.nereids.trees.plans.physical.PhysicalBlackholeSink; @@ -1656,9 +1657,7 @@ public PlanFragment visitPhysicalFilter(PhysicalFilter filter, P PlanNode planNode = inputFragment.getPlanRoot(); // the three nodes don't support conjuncts, need create a SelectNode to filter data if (planNode instanceof ExchangeNode || planNode instanceof SortNode || planNode instanceof UnionNode) { - SelectNode selectNode = new SelectNode(context.nextPlanNodeId(), planNode); - selectNode.setNereidsId(filter.getId()); - context.getNereidsIdToPlanNodeIdMap().put(filter.getId(), selectNode.getId()); + SelectNode selectNode = createSelectNode(filter, planNode, context); addConjunctsToPlanNode(filter, selectNode, context); addPlanRoot(inputFragment, selectNode, filter); } else { @@ -1669,12 +1668,10 @@ public PlanFragment visitPhysicalFilter(PhysicalFilter filter, P || CollectionUtils.isNotEmpty(planNode.getProjectList()) // already have limit on this node, filter need execute after limit, so need a new node || planNode.hasLimit()) { - planNode = new SelectNode(context.nextPlanNodeId(), planNode); - planNode.setNereidsId(filter.getId()); + planNode = createSelectNode(filter, planNode, context); // NOTE: can't collect planNode.getId() on filter's child, such as scan node // since if the filter is embedded into scan, the id mapping relation is not correct // i.e, the physical filter's nereids's id will be mapped to final plan's scan node - context.getNereidsIdToPlanNodeIdMap().put(filter.getId(), planNode.getId()); addPlanRoot(inputFragment, planNode, filter); } addConjunctsToPlanNode(filter, planNode, context); @@ -1688,6 +1685,16 @@ public PlanFragment visitPhysicalFilter(PhysicalFilter filter, P return inputFragment; } + private SelectNode createSelectNode(AbstractPhysicalPlan physicalPlan, PlanNode child, + PlanTranslatorContext context) { + SelectNode selectNode = new SelectNode(context.nextPlanNodeId(), child); + selectNode.setNereidsId(physicalPlan.getId()); + context.getNereidsIdToPlanNodeIdMap().put(physicalPlan.getId(), selectNode.getId()); + selectNode.setDistributeExprLists(getDistributeExpr(physicalPlan)); + selectNode.setChildrenDistributeExprLists(getDistributeExprs(physicalPlan.child(0))); + return selectNode; + } + @Override public PlanFragment visitPhysicalGenerate(PhysicalGenerate generate, PlanTranslatorContext context) { @@ -2325,9 +2332,7 @@ public PlanFragment visitPhysicalProject(PhysicalProject project PlanNode inputPlanNode = inputFragment.getPlanRoot(); // this means already have project on this node, filter need execute after project, so need a new node if (CollectionUtils.isNotEmpty(inputPlanNode.getProjectList())) { - SelectNode selectNode = new SelectNode(context.nextPlanNodeId(), inputPlanNode); - selectNode.setNereidsId(project.getId()); - context.getNereidsIdToPlanNodeIdMap().put(project.getId(), selectNode.getId()); + SelectNode selectNode = createSelectNode(project, inputPlanNode, context); addPlanRoot(inputFragment, selectNode, project); inputPlanNode = selectNode; } diff --git a/regression-test/data/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.out b/regression-test/data/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.out new file mode 100644 index 00000000000000..55c70cdf19546f --- /dev/null +++ b/regression-test/data/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.out @@ -0,0 +1,6 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !select_over_analytic_shuffle_join -- +10 45 55 1045 + +-- !select_over_aligned_analytic_shuffle_join -- +10 45 55 1045 diff --git a/regression-test/suites/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.groovy b/regression-test/suites/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.groovy new file mode 100644 index 00000000000000..d295968cd1a387 --- /dev/null +++ b/regression-test/suites/nereids_p0/local_shuffle/test_select_analytic_shuffle_join.groovy @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_select_analytic_shuffle_join") { + sql "DROP TABLE IF EXISTS select_analytic_join_left" + sql "DROP TABLE IF EXISTS select_analytic_join_right" + + sql """CREATE TABLE select_analytic_join_left ( + k INT, v INT + ) ENGINE=OLAP DUPLICATE KEY(k) + DISTRIBUTED BY HASH(k) BUCKETS 10 + PROPERTIES ("replication_num"="1")""" + sql """CREATE TABLE select_analytic_join_right ( + k INT, w INT + ) ENGINE=OLAP DUPLICATE KEY(k) + DISTRIBUTED BY HASH(k) BUCKETS 10 + PROPERTIES ("replication_num"="1")""" + + sql """INSERT INTO select_analytic_join_left VALUES + (0,1),(1,2),(2,3),(3,4),(4,5),(5,6),(6,7),(7,8),(8,9),(9,10)""" + sql """INSERT INTO select_analytic_join_right VALUES + (0,100),(1,101),(2,102),(3,103),(4,104), + (5,105),(6,106),(7,107),(8,108),(9,109)""" + + def variables = "enable_local_shuffle_planner=true,enable_local_shuffle=true," + + "enable_bucket_shuffle_join=false,ignore_storage_data_distribution=true," + + "parallel_pipeline_task_num=3,enable_sql_cache=false" + + order_qt_select_over_analytic_shuffle_join """SELECT /*+SET_VAR(${variables})*/ + COUNT(*), SUM(s.k), SUM(s.running_v), SUM(d.w) + FROM ( + SELECT k, SUM(v) OVER (PARTITION BY v ORDER BY v + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_v, + random(0) AS r + FROM select_analytic_join_left + ) s JOIN [shuffle] select_analytic_join_right d ON s.k=d.k + WHERE s.r < 2.0""" + + order_qt_select_over_aligned_analytic_shuffle_join """SELECT /*+SET_VAR(${variables})*/ + COUNT(*), SUM(s.k), SUM(s.running_v), SUM(d.w) + FROM ( + SELECT k, SUM(v) OVER (PARTITION BY k ORDER BY k + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_v, + random(0) AS r + FROM select_analytic_join_left + ) s JOIN [shuffle] select_analytic_join_right d ON s.k=d.k + WHERE s.r < 2.0""" +}