Skip to content

Commit e9e4d60

Browse files
committed
[Refactor] Localize MERGE duplicate-match check above a shuffle join, keyed by slot id
Follow-up to the MERGE INTO planner/executor commits. Rework the duplicate-match (cardinality) check so its correctness rests on the merge join rather than on a bespoke distribution, mirroring how Spark/Trino keep the check local to the join. * Analyzer pins HINT_JOIN_SHUFFLE on the source-target merge join (same mechanism as IcebergEqualityDeleteRewriteRule), so the target side is never broadcast/replicated: each target row is owned by one instance and all source rows matching it meet on the same node, and the hash join's probe side is locally shuffled by join key so they meet on the same driver. * The planner places EnforceUniqueRowLocatorNode directly above the merge join, in the join's fragment, before any write-distribution exchange. The required property is now only the Iceberg write-clustering shuffle (partition columns for partitioned targets); it no longer carries any duplicate-check responsibility. This decouples the cardinality check from the write distribution and lets non-partitioned inserts spread by join key instead of funneling onto one node. * validateMergeJoinKeepsTargetUnreplicated rejects plans that would let a target row be matched by source rows on different drivers: a nestloop join is accepted only when its source is a constant relation of at most one row (no scan, exact cardinality <= 1); a scanned or multi-row-constant nestloop (no source-target equality in ON) is rejected with a clear error. * The unique-key columns are identified by SLOT ID; the BE resolves them through the chunk's slot-id map, removing any FE-side prediction of the BE chunk column order. * Remove the now-unused custom local column-hash exchange (ColumnHashPartitioner / ColumnHashPartitionExchanger / interpolate_local_column_hash_partition_exchange) and its NULL-key round-robin handling; the merge join's own distribution supersedes them. * Rename EnforceUnique{Node,Operator} to EnforceUniqueRowLocator{Node,Operator} (and the TEnforceUniqueRowLocatorNode thrift) to reflect that it is an Iceberg row-locator-specific check, not a general uniqueness operator. Tests: FE plan tests assert the merge join is a shuffle join with the check in its fragment, the constant single-row nestloop is accepted, and the non-equi / multi-row-constant nestloop is rejected; BE tests cover slot-id resolution and missing-slot rejection. Signed-off-by: GavinMar <yangguansuo@starrocks.com>
1 parent 054c378 commit e9e4d60

27 files changed

Lines changed: 949 additions & 796 deletions

be/src/exec/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ set(PIPELINE_OPERATORS_BATCH_SRCS
245245
pipeline/bucket_process_operator.cpp
246246
pipeline/table_function_operator.cpp
247247
pipeline/assert_num_rows_operator.cpp
248-
pipeline/enforce_unique_operator.cpp
248+
pipeline/enforce_unique_row_locator_operator.cpp
249249
pipeline/set/union_passthrough_operator.cpp
250250
pipeline/set/union_const_source_operator.cpp
251251
pipeline/set/raw_values_source_operator.cpp
@@ -355,7 +355,7 @@ set(EXEC_NON_PIPELINE_FILES
355355
parquet_builder.cpp
356356
file_scan_node.cpp
357357
assert_num_rows_node.cpp
358-
enforce_unique_node.cpp
358+
enforce_unique_row_locator_node.cpp
359359
intersect_hash_set.cpp
360360
intersect_node.cpp
361361
json_parser.cpp

be/src/exec/enforce_unique_node.cpp

Lines changed: 0 additions & 86 deletions
This file was deleted.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright 2021-present StarRocks, Inc. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#include "exec/enforce_unique_row_locator_node.h"
16+
17+
#include "exec/pipeline/enforce_unique_row_locator_operator.h"
18+
#include "exec/pipeline/exec_node_pipeline_adapter.h"
19+
#include "exec/pipeline/limit_operator.h"
20+
#include "exec/pipeline/pipeline_builder.h"
21+
#include "gen_cpp/PlanNodes_types.h"
22+
#include "runtime/runtime_state.h"
23+
24+
namespace starrocks {
25+
26+
EnforceUniqueRowLocatorNode::EnforceUniqueRowLocatorNode(ObjectPool* pool, const TPlanNode& tnode,
27+
const DescriptorTbl& descs)
28+
: PipelineNode(pool, tnode, descs) {
29+
if (tnode.__isset.enforce_unique_row_locator_node &&
30+
tnode.enforce_unique_row_locator_node.__isset.unique_key_slot_ids) {
31+
const auto& slot_ids = tnode.enforce_unique_row_locator_node.unique_key_slot_ids;
32+
_unique_key_slot_ids.assign(slot_ids.begin(), slot_ids.end());
33+
}
34+
}
35+
36+
Status EnforceUniqueRowLocatorNode::init(const TPlanNode& tnode, RuntimeState* state) {
37+
RETURN_IF_ERROR(ExecNode::init(tnode, state));
38+
return Status::OK();
39+
}
40+
41+
void EnforceUniqueRowLocatorNode::close(RuntimeState* state) {
42+
if (is_closed()) {
43+
return;
44+
}
45+
ExecNode::close(state);
46+
}
47+
48+
StatusOr<pipeline::OpFactories> EnforceUniqueRowLocatorNode::decompose_to_pipeline(
49+
pipeline::PipelineBuilderContext* context) {
50+
using namespace pipeline;
51+
52+
ASSIGN_OR_RETURN(auto ops, _children[0]->decompose_to_pipeline(context));
53+
// No exchange is interpolated here: each driver keeps a local seen-set, and the
54+
// co-location of all matches of one target row is guaranteed by the MERGE join
55+
// below this node. The FE places this node directly above the merge join and
56+
// forbids broadcasting/replicating the target side (shuffle join hint +
57+
// MergeIntoPlanner::validateMergeJoinKeepsTargetUnreplicated), so cross-BE the
58+
// join key owns each target row on one instance, and within a BE the hash join's
59+
// probe side is locally shuffled by join key across drivers. NULL-key rows
60+
// (NOT-MATCHED insert rows) are exempt from the check and may sit on any driver.
61+
auto factory = std::make_shared<EnforceUniqueRowLocatorOperatorFactory>(context->next_operator_id(), id(),
62+
_unique_key_slot_ids);
63+
ops.emplace_back(std::move(factory));
64+
65+
// Initialize OperatorFactory's fields involving runtime filters.
66+
auto&& rc_rf_probe_collector = std::make_shared<RcRfProbeCollector>(1, std::move(this->runtime_filter_collector()));
67+
pipeline::init_runtime_filter_for_operator(*this, ops.back().get(), context, rc_rf_probe_collector);
68+
69+
if (limit() != -1) {
70+
ops.emplace_back(std::make_shared<LimitOperatorFactory>(context->next_operator_id(), id(), limit()));
71+
}
72+
return ops;
73+
}
74+
75+
} // namespace starrocks

be/src/exec/enforce_unique_node.h renamed to be/src/exec/enforce_unique_row_locator_node.h

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
#include <vector>
1818

19+
#include "common/global_types.h"
1920
#include "common/statusor.h"
2021
#include "exec/pipeline_node.h"
2122
#include "gen_cpp/PlanNodes_types.h"
@@ -25,18 +26,22 @@ namespace starrocks {
2526
// Plan node that enforces uniqueness of (file_path, row_position) keys
2627
// across the data stream. Used by MERGE INTO to guarantee that each target
2728
// row is matched by at most one source row.
28-
class EnforceUniqueNode final : public PipelineNode {
29+
//
30+
// The key columns are identified by SLOT ID and resolved per chunk through the
31+
// chunk's slot-id map, so this node is insensitive to the physical column order
32+
// of the child's output chunk.
33+
class EnforceUniqueRowLocatorNode final : public PipelineNode {
2934
public:
30-
EnforceUniqueNode(ObjectPool* pool, const TPlanNode& tnode, const DescriptorTbl& descs);
31-
~EnforceUniqueNode() override = default;
35+
EnforceUniqueRowLocatorNode(ObjectPool* pool, const TPlanNode& tnode, const DescriptorTbl& descs);
36+
~EnforceUniqueRowLocatorNode() override = default;
3237

3338
Status init(const TPlanNode& tnode, RuntimeState* state = nullptr) override;
3439
void close(RuntimeState* state) override;
3540

3641
StatusOr<pipeline::OpFactories> decompose_to_pipeline(pipeline::PipelineBuilderContext* context) override;
3742

3843
private:
39-
std::vector<int32_t> _unique_key_col_indices;
44+
std::vector<SlotId> _unique_key_slot_ids;
4045
};
4146

4247
} // namespace starrocks

be/src/exec/exec_factory.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
#include "exec/cross_join_node.h"
5454
#include "exec/dict_decode_node.h"
5555
#include "exec/empty_set_node.h"
56-
#include "exec/enforce_unique_node.h"
56+
#include "exec/enforce_unique_row_locator_node.h"
5757
#include "exec/except_node.h"
5858
#include "exec/exchange_node.h"
5959
#include "exec/fetch_node.h"
@@ -368,8 +368,8 @@ Status ExecFactory::create_vectorized_node(RuntimeState* state, ObjectPool* pool
368368
CREATE_NODE(ConnectorScanNode, pool, new_node, descs);
369369
return Status::OK();
370370
}
371-
case TPlanNodeType::ENFORCE_UNIQUE_NODE:
372-
CREATE_NODE(EnforceUniqueNode, pool, tnode, descs);
371+
case TPlanNodeType::ENFORCE_UNIQUE_ROW_LOCATOR_NODE:
372+
CREATE_NODE(EnforceUniqueRowLocatorNode, pool, tnode, descs);
373373
return Status::OK();
374374
default:
375375
return Status::InternalError(strings::Substitute("Vectorized engine not support node: $0", tnode.node_type));

be/src/exec/pipeline/enforce_unique_operator.cpp renamed to be/src/exec/pipeline/enforce_unique_row_locator_operator.cpp

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
#include "exec/pipeline/enforce_unique_operator.h"
15+
#include "exec/pipeline/enforce_unique_row_locator_operator.h"
1616

1717
#include "column/binary_column.h"
1818
#include "column/column_helper.h"
@@ -21,14 +21,14 @@
2121

2222
namespace starrocks::pipeline {
2323

24-
Status EnforceUniqueOperator::prepare(RuntimeState* state) {
24+
Status EnforceUniqueRowLocatorOperator::prepare(RuntimeState* state) {
2525
RETURN_IF_ERROR(Operator::prepare(state));
2626
_seen_rows_counter = ADD_COUNTER(_unique_metrics, "SeenSetRows", TUnit::UNIT);
2727
_seen_memory_counter = ADD_COUNTER(_unique_metrics, "SeenSetMemoryUsage", TUnit::BYTES);
2828
return Status::OK();
2929
}
3030

31-
int64_t EnforceUniqueOperator::_seen_memory_usage() const {
31+
int64_t EnforceUniqueRowLocatorOperator::_seen_memory_usage() const {
3232
// flat_hash_set/map store entries inline in the bucket array; capacity()
3333
// reflects allocated buckets. Path strings are heap-allocated separately.
3434
int64_t bytes = static_cast<int64_t>(_seen.capacity()) * sizeof(std::pair<int32_t, int64_t>);
@@ -39,7 +39,7 @@ int64_t EnforceUniqueOperator::_seen_memory_usage() const {
3939
return bytes;
4040
}
4141

42-
void EnforceUniqueOperator::close(RuntimeState* state) {
42+
void EnforceUniqueRowLocatorOperator::close(RuntimeState* state) {
4343
if (_seen_rows_counter != nullptr) {
4444
COUNTER_SET(_seen_rows_counter, static_cast<int64_t>(_seen.size()));
4545
COUNTER_SET(_seen_memory_counter, _seen_memory_usage());
@@ -51,38 +51,47 @@ void EnforceUniqueOperator::close(RuntimeState* state) {
5151
Operator::close(state);
5252
}
5353

54-
bool EnforceUniqueOperator::has_output() const {
54+
bool EnforceUniqueRowLocatorOperator::has_output() const {
5555
return _output_chunk != nullptr;
5656
}
5757

58-
bool EnforceUniqueOperator::need_input() const {
58+
bool EnforceUniqueRowLocatorOperator::need_input() const {
5959
return !_input_finished && _output_chunk == nullptr;
6060
}
6161

62-
bool EnforceUniqueOperator::is_finished() const {
62+
bool EnforceUniqueRowLocatorOperator::is_finished() const {
6363
return _input_finished && _output_chunk == nullptr;
6464
}
6565

66-
Status EnforceUniqueOperator::set_finishing(RuntimeState* state) {
66+
Status EnforceUniqueRowLocatorOperator::set_finishing(RuntimeState* state) {
6767
_input_finished = true;
6868
return Status::OK();
6969
}
7070

71-
StatusOr<ChunkPtr> EnforceUniqueOperator::pull_chunk(RuntimeState* state) {
71+
StatusOr<ChunkPtr> EnforceUniqueRowLocatorOperator::pull_chunk(RuntimeState* state) {
7272
return std::move(_output_chunk);
7373
}
7474

75-
Status EnforceUniqueOperator::push_chunk(RuntimeState* state, const ChunkPtr& chunk) {
75+
Status EnforceUniqueRowLocatorOperator::push_chunk(RuntimeState* state, const ChunkPtr& chunk) {
7676
size_t num_rows = chunk->num_rows();
7777
if (num_rows == 0) {
7878
return Status::OK();
7979
}
8080

81-
// Key-count validation runs once in EnforceUniqueOperatorFactory::prepare.
82-
DCHECK_EQ(_unique_key_col_indices.size(), 2);
81+
// Key-count validation runs once in EnforceUniqueRowLocatorOperatorFactory::prepare.
82+
DCHECK_EQ(_unique_key_slot_ids.size(), 2);
8383

84-
auto file_path_col = chunk->get_column_by_index(_unique_key_col_indices[0]);
85-
auto row_pos_col = chunk->get_column_by_index(_unique_key_col_indices[1]);
84+
// Resolve the key columns by slot id. Chunk::get_column_by_slot_id is only
85+
// DCHECK-guarded, so verify existence explicitly: in RELEASE builds a missing
86+
// slot would silently default-construct a map entry pointing at column 0.
87+
for (SlotId slot_id : _unique_key_slot_ids) {
88+
if (!chunk->is_slot_exist(slot_id)) {
89+
return Status::InternalError("EnforceUniqueRowLocatorOperator: key slot " + std::to_string(slot_id) +
90+
" does not exist in the input chunk");
91+
}
92+
}
93+
auto file_path_col = chunk->get_column_by_slot_id(_unique_key_slot_ids[0]);
94+
auto row_pos_col = chunk->get_column_by_slot_id(_unique_key_slot_ids[1]);
8695

8796
// Determine if columns are nullable
8897
const NullableColumn* file_path_nullable = nullptr;
@@ -96,24 +105,23 @@ Status EnforceUniqueOperator::push_chunk(RuntimeState* state, const ChunkPtr& ch
96105

97106
// Get the underlying data columns (unwrap nullable/const).
98107
//
99-
// Defense-in-depth type check: the FE resolves unique_key_col_indices by
100-
// slot ID now (MergeIntoPlanner::insertEnforceUniqueNode), so this shouldn't
101-
// fire in practice. But down_cast is unchecked in RELEASE builds, so without
102-
// these guards a bad index — i.e. the historical Bug #1 where indices [0,1]
103-
// pointed at non-binary/non-int64 data columns — would dereference garbage
104-
// and SIGSEGV instead of producing a diagnosable error. Verify the unwrapped
105-
// types first and fail with a clear message if they mismatch.
108+
// Defense-in-depth type check: the keys are resolved by slot id, so a
109+
// mismatch means the FE bound the wrong slots. down_cast is unchecked in
110+
// RELEASE builds — without these guards a wrong slot pointing at a
111+
// non-binary/non-int64 column would dereference garbage and SIGSEGV
112+
// instead of producing a diagnosable error. Verify the unwrapped types
113+
// first and fail with a clear message if they mismatch.
106114
const Column* file_path_inner = ColumnHelper::get_data_column(file_path_col.get());
107115
if (!file_path_inner->is_binary()) {
108-
return Status::InternalError("EnforceUniqueOperator: expected _file key column (index " +
109-
std::to_string(_unique_key_col_indices[0]) + ") to be a binary column, got " +
116+
return Status::InternalError("EnforceUniqueRowLocatorOperator: expected _file key column (slot " +
117+
std::to_string(_unique_key_slot_ids[0]) + ") to be a binary column, got " +
110118
file_path_inner->get_name());
111119
}
112120
const Column* row_pos_data_col = ColumnHelper::get_data_column(row_pos_col.get());
113121
const auto* row_pos_typed = dynamic_cast<const FixedLengthColumn<int64_t>*>(row_pos_data_col);
114122
if (row_pos_typed == nullptr) {
115-
return Status::InternalError("EnforceUniqueOperator: expected _pos key column (index " +
116-
std::to_string(_unique_key_col_indices[1]) +
123+
return Status::InternalError("EnforceUniqueRowLocatorOperator: expected _pos key column (slot " +
124+
std::to_string(_unique_key_slot_ids[1]) +
117125
") to be FixedLengthColumn<int64_t>, got " + row_pos_data_col->get_name());
118126
}
119127
const auto* file_path_data = down_cast<const BinaryColumn*>(file_path_inner);
@@ -154,16 +162,16 @@ Status EnforceUniqueOperator::push_chunk(RuntimeState* state, const ChunkPtr& ch
154162
return Status::OK();
155163
}
156164

157-
Status EnforceUniqueOperatorFactory::prepare(RuntimeState* state) {
165+
Status EnforceUniqueRowLocatorOperatorFactory::prepare(RuntimeState* state) {
158166
RETURN_IF_ERROR(OperatorFactory::prepare(state));
159-
if (_unique_key_col_indices.size() != 2) {
160-
return Status::InternalError("EnforceUniqueOperator expects exactly 2 key columns, got " +
161-
std::to_string(_unique_key_col_indices.size()));
167+
if (_unique_key_slot_ids.size() != 2) {
168+
return Status::InternalError("EnforceUniqueRowLocatorOperator expects exactly 2 key columns, got " +
169+
std::to_string(_unique_key_slot_ids.size()));
162170
}
163171
return Status::OK();
164172
}
165173

166-
void EnforceUniqueOperatorFactory::close(RuntimeState* state) {
174+
void EnforceUniqueRowLocatorOperatorFactory::close(RuntimeState* state) {
167175
OperatorFactory::close(state);
168176
}
169177

0 commit comments

Comments
 (0)