github-actions[bot] commented on code in PR #68320:
URL: https://github.com/apache/doris/pull/68320#discussion_r4085891418
##########
be/src/exec/operator/exchange_sink_operator.cpp:
##########
@@ -535,9 +567,11 @@ Status ExchangeSinkOperatorX::sink_impl(RuntimeState*
state, Block* block, bool
_part_type == TPartitionType::BUCKET_SHFFULE_HASH_PARTITIONED ||
_part_type == TPartitionType::OLAP_TABLE_SINK_HASH_PARTITIONED
||
_part_type == TPartitionType::HIVE_TABLE_SINK_HASH_PARTITIONED
||
+ _part_type ==
TPartitionType::EXTERNAL_TABLE_SINK_HASH_PARTITIONED ||
_part_type == TPartitionType::MERGE_PARTITIONED) {
RETURN_IF_ERROR(local_state._writer->write(state, block, eos));
- } else if (_part_type == TPartitionType::HIVE_TABLE_SINK_UNPARTITIONED) {
+ } else if (_part_type == TPartitionType::HIVE_TABLE_SINK_UNPARTITIONED ||
+ _part_type ==
TPartitionType::EXTERNAL_TABLE_SINK_UNPARTITIONED) {
Review Comment:
[P1] Do not admit the new mode to these unsynchronized shared counters.
Parallel `PipelineTask`s all retain the same pipeline `sink_shared_pointer`,
but each task can enter this branch and perform plain `+=`, comparisons, and
`++` on the parent `_data_processed`/`_writer_count`. That is a C++ data race
(and can lose scaling updates) for external-unpartitioned writes. Move the
decision into task-local state or protect an explicit shared state
atomically/with a lock, and cover multiple concurrent local states.
##########
fe/fe-core/src/main/java/org/apache/doris/datasource/plugin/PluginDrivenExternalTable.java:
##########
@@ -423,6 +507,29 @@ public boolean requirePartitionHashOnWrite() {
.orElse(false);
}
+ /** Returns this table's connector-owned write distribution, or empty for
generic planning. */
+ public Optional<ConnectorWriteDistribution>
getConnectorWriteDistribution() {
+ if (!(catalog instanceof PluginDrivenExternalCatalog)) {
+ return Optional.empty();
+ }
+ PluginDrivenExternalCatalog pluginCatalog =
(PluginDrivenExternalCatalog) catalog;
+ Connector connector = pluginCatalog.getConnector();
+ if (connector == null) {
+ return Optional.empty();
+ }
+ ConnectorSession session = pluginCatalog.buildConnectorSession();
+ ConnectorMetadata metadata = PluginDrivenMetadata.get(session,
connector);
+ Optional<ConnectorTableHandle> handle =
resolveConnectorTableHandle(session, metadata);
+ if (!handle.isPresent()) {
+ return Optional.empty();
+ }
+ ConnectorWritePlanProvider provider =
connector.getWritePlanProvider(handle.get());
+ if (provider == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(provider.getWriteDistribution(session,
handle.get()));
Review Comment:
[P1] Pin the plugin TCCL around this new provider boundary. Directory
plugins load child-first, so a valid `getWriteDistribution` implementation
using `ServiceLoader` or by-name reflection will run under the optimizer
thread's FE app loader here and can miss or split-brain its plugin classes. The
new row-level callbacks above have the same gap, while `resolveWriteColumns` in
this class already switches/restores the provider loader. Route provider
resolution and these callbacks through one restoring TCCL helper and test the
observed loader.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java:
##########
@@ -290,6 +357,12 @@ public PhysicalProperties getRequirePhysicalProperties() {
}
PluginDrivenExternalTable table = (PluginDrivenExternalTable)
targetTable;
+ Optional<ConnectorWriteDistribution> connectorDistribution
+ = table.getConnectorWriteDistribution();
+ if (connectorDistribution.isPresent()) {
+ return toPhysicalProperties(connectorDistribution.get());
Review Comment:
[P1] Compose connector distribution with the independently required local
order. A provider may legally return HASH/EXTERNAL_HASH here and also declare
`requiresPartitionLocalSort()`; this early return preserves the hash but skips
the `MustLocalSortOrderSpec` below, so dynamic-partition rows can remain
interleaved and a streaming writer may revisit an already closed partition.
Either attach the same partition-column local order to connector-owned
distribution, or reject these traits as mutually exclusive in
`ConnectorContractValidator`, and test the combined contract.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ConnectorChangelogPlanBuilder.java:
##########
@@ -0,0 +1,547 @@
+// 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.rules.analysis;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.connector.spi.write.ConnectorChangelogMode;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.analyzer.Scope;
+import org.apache.doris.nereids.analyzer.UnboundAlias;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.Cast;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.LessThanEqual;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.WindowExpression;
+import org.apache.doris.nereids.trees.expressions.functions.agg.AnyValue;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.AssertTrue;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.ShortCircuitIf;
+import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
+import
org.apache.doris.nereids.trees.plans.commands.info.ConnectorChangelogRowChangeSpec;
+import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause;
+import
org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause;
+import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
+import org.apache.doris.nereids.types.BigIntType;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Builds the operation-column plus full-row projection used by
changelog-oriented connectors. */
+public final class ConnectorChangelogPlanBuilder {
+ private static final String BRANCH_LABEL = "__DORIS_CHANGELOG_BRANCH__";
+
+ private ConnectorChangelogPlanBuilder() {
+ }
+
+ /** Builds a changelog plan for the requested connector row-level
operation. */
+ public static LogicalPlan build(List<Column> schema, List<String>
primaryKeys,
+ ConnectorChangelogMode mode, ConnectorChangelogRowChangeSpec spec,
+ LogicalPlan child, CascadesContext context) {
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Update) {
+ return buildUpdate(schema, mode,
(ConnectorChangelogRowChangeSpec.Update) spec,
+ child, context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Delete) {
+ return buildDelete(schema, primaryKeys, mode,
+ (ConnectorChangelogRowChangeSpec.Delete) spec, child,
context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Merge) {
+ return new MergeBuilder(schema, primaryKeys, mode,
+ (ConnectorChangelogRowChangeSpec.Merge) spec, child,
context).build();
+ }
+ throw new AnalysisException("Unsupported connector changelog
specification: "
+ + spec.getClass().getSimpleName());
+ }
+
+ private static LogicalPlan buildUpdate(List<Column> schema,
ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Update update, LogicalPlan child,
+ CascadesContext context) {
+ Map<String, Expression> changes =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (EqualTo assignment : update.getAssignments()) {
+ List<String> parts = ((UnboundSlot)
assignment.left()).getNameParts();
+ String name = parts.get(parts.size() - 1);
+ if (changes.put(name, assignment.right()) != null) {
+ throw new AnalysisException("Duplicate column name in
connector UPDATE: " + name);
+ }
+ }
+ ExpressionAnalyzer analyzer = analyzer(child, context);
+ List<NamedExpression> projects = new ArrayList<>();
+ projects.add(operation(mode.getOperationColumnName(),
mode.getUpdateValue()));
+ for (Column column : schema) {
+ Expression value = changes.remove(column.getName());
+ if (value == null) {
+ value = targetSlot(update.getTargetNameInPlan(),
column.getName());
+ }
+ projects.add(bindColumn(analyzer, value, column));
+ }
+ if (!changes.isEmpty()) {
+ throw new AnalysisException("Unknown column in connector UPDATE: "
+ + String.join(", ", changes.keySet()));
+ }
+ return new LogicalProject<>(projects, child);
+ }
+
+ private static LogicalPlan buildDelete(List<Column> schema, List<String>
primaryKeys,
+ ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Delete delete, LogicalPlan child,
+ CascadesContext context) {
+ ExpressionAnalyzer analyzer = analyzer(child, context);
+ List<NamedExpression> projects = new ArrayList<>();
+ projects.add(operation(mode.getOperationColumnName(),
mode.getDeleteValue()));
+ for (Column column : schema) {
+ projects.add(bindColumn(analyzer,
+ targetSlot(delete.getTargetNameInPlan(),
column.getName()), column));
+ }
+ LogicalProject<LogicalPlan> project = new LogicalProject<>(projects,
child);
+ if (!delete.shouldDeduplicateTargetRows()) {
+ return project;
+ }
+ if (primaryKeys.isEmpty()) {
+ throw new AnalysisException("Connector DELETE USING requires a
primary-key table");
+ }
+ Set<String> keys = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ keys.addAll(primaryKeys);
+ List<Expression> groupBy = new ArrayList<>();
+ List<NamedExpression> outputs = new ArrayList<>();
+ Slot operation = project.getOutput().get(0);
+ groupBy.add(operation);
+ outputs.add(operation);
+ for (int i = 0; i < schema.size(); i++) {
+ Column column = schema.get(i);
+ Slot value = project.getOutput().get(i + 1);
+ if (keys.remove(column.getName())) {
+ groupBy.add(value);
+ outputs.add(value);
+ } else {
+ outputs.add(new Alias(new AnyValue(value), column.getName()));
+ }
+ }
+ if (!keys.isEmpty()) {
+ throw new AnalysisException("Unknown connector primary-key column:
"
+ + String.join(", ", keys));
+ }
+ return new LogicalAggregate<>(groupBy, outputs, project);
+ }
+
+ private static Alias operation(String columnName, byte value) {
+ return new Alias(new TinyIntLiteral(value), columnName);
+ }
+
+ private static UnboundSlot targetSlot(List<String> qualifier, String
column) {
+ List<String> parts = new ArrayList<>(qualifier);
+ parts.add(column);
+ return new UnboundSlot(parts);
+ }
+
+ private static ExpressionAnalyzer analyzer(LogicalPlan plan,
CascadesContext context) {
+ return new ExpressionAnalyzer(plan, new Scope(plan.getOutput()),
context, true, false);
+ }
+
+ private static Alias bindColumn(ExpressionAnalyzer analyzer, Expression
expression, Column column) {
+ Expression value = analyzer.analyze(expression);
+ value = TypeCoercionUtils.castIfNotSameType(value,
DataType.fromCatalogType(column.getType()));
+ return new Alias(value, column.getName());
+ }
+
+ private static final class MergeBuilder {
+ private final List<Column> schema;
+ private final List<String> primaryKeys;
+ private final ConnectorChangelogMode mode;
+ private final ConnectorChangelogRowChangeSpec.Merge merge;
+ private final LogicalPlan child;
+ private final ExpressionAnalyzer analyzer;
+
+ private MergeBuilder(List<Column> schema, List<String> primaryKeys,
+ ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Merge merge, LogicalPlan child,
+ CascadesContext context) {
+ this.schema = schema;
+ this.primaryKeys = primaryKeys;
+ this.mode = mode;
+ this.merge = merge;
+ this.child = child;
+ this.analyzer = analyzer(child, context);
+ }
+
+ private LogicalPlan build() {
+ if (primaryKeys.isEmpty()) {
+ throw new AnalysisException("Connector MERGE requires a
primary-key table");
+ }
+ Alias branch = bindBranchLabel();
+ Slot branchSlot = branch.toSlot();
+ List<NamedExpression> branchOutputs = new
ArrayList<>(child.getOutput());
+ branchOutputs.add(branch);
+ LogicalPlan selected = new LogicalProject<>(branchOutputs, child);
+ selected = new LogicalFilter<>(
+ ImmutableSet.of(new Not(new
org.apache.doris.nereids.trees.expressions.IsNull(branchSlot))),
+ selected);
+ List<List<Expression>> branches = buildBranchProjections();
+ if (!merge.getNotMatchedClauses().isEmpty()) {
+ validateNotMatchedPrimaryKeys(branches);
+ }
+ List<NamedExpression> output = new ArrayList<>();
+ for (int column = 0; column <= schema.size(); column++) {
+ DataType type = column == 0
+ ? org.apache.doris.nereids.types.TinyIntType.INSTANCE
+ : DataType.fromCatalogType(schema.get(column -
1).getType());
+ String name = column == 0
+ ? mode.getOperationColumnName() : schema.get(column -
1).getName();
+ Expression value = new NullLiteral(type);
+ for (int index = branches.size() - 1; index >= 0; index--) {
+ Expression branchValue =
TypeCoercionUtils.castIfNotSameType(
+ branches.get(index).get(column), type);
+ value = new ShortCircuitIf(new EqualTo(branchSlot, new
IntegerLiteral(index)),
+ branchValue, value);
+ }
+ output.add(new Alias(value, name));
+ }
+ return addCardinalityChecks(new LogicalProject<>(output,
selected));
+ }
+
+ private void validateNotMatchedPrimaryKeys(List<List<Expression>>
branches) {
+ Map<String, Slot> targetKeys =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (String key : primaryKeys) {
+ targetKeys.put(key, findTargetSlot(key));
+ }
+ Set<Slot> targetSlots = child.getOutput().stream()
+ .filter(slot -> qualifierEndsWith(slot.getQualifier(),
merge.getTargetNameInPlan()))
+ .collect(ImmutableSet.toImmutableSet());
+ if (!(child instanceof LogicalJoin)) {
+ throw new AnalysisException("Connector MERGE input must be a
logical join");
+ }
+ Expression onClause = ((LogicalJoin<?, ?>)
child).getOnClauseCondition()
+ .orElseThrow(() -> new AnalysisException("Connector MERGE
requires an ON condition"));
+ Map<String, Expression> sourceKeys =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (Expression conjunct :
ExpressionUtils.extractConjunction(onClause)) {
+ if (!(conjunct instanceof EqualTo)) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ EqualTo equality = (EqualTo) conjunct;
+ String leftKey = targetPrimaryKeyName(equality.left(),
targetKeys);
+ String rightKey = targetPrimaryKeyName(equality.right(),
targetKeys);
+ if ((leftKey == null) == (rightKey == null)) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ String key = leftKey != null ? leftKey : rightKey;
+ Expression source = leftKey != null ? equality.right() :
equality.left();
+ if (source.getInputSlots().isEmpty()
+ ||
source.getInputSlots().stream().anyMatch(targetSlots::contains)
+ || source.containsNondeterministic()
+ || sourceKeys.put(key, source) != null) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ }
+ if (sourceKeys.size() != targetKeys.size()) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ int firstInsert = merge.getMatchedClauses().size();
+ for (int branch = firstInsert; branch < branches.size(); branch++)
{
+ for (Map.Entry<String, Expression> sourceKey :
sourceKeys.entrySet()) {
+ int column = schemaIndex(sourceKey.getKey()) + 1;
+ DataType type = DataType.fromCatalogType(schema.get(column
- 1).getType());
+ if
(!TypeCoercionUtils.castIfNotSameType(branches.get(branch).get(column), type)
+
.equals(TypeCoercionUtils.castIfNotSameType(sourceKey.getValue(), type))) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ }
+ }
+ }
+
+ private LogicalPlan addCardinalityChecks(LogicalProject<?> rowChanges)
{
+ List<Slot> outputs = rowChanges.getOutput();
+ Slot operation = outputs.get(0);
+ List<Expression> partitionKeys = new ArrayList<>();
+ for (String key : primaryKeys) {
+ partitionKeys.add(outputs.get(schemaIndex(key) + 1));
Review Comment:
[P1] Partition the matched-row check by the original target identity. This
list is built from `rowChanges` after branch projection, so a legal changelog
provider that allows key updates can let two source rows match target `id=1`,
assign `id=2` and `id=3`, and get two windows of count 1 instead of the
required duplicate-match error (the SPI validator defaults to a no-op). Carry
the target's original key slots through selection for the matched count; keep
emitted keys only for the not-matched insert check.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ConnectorChangelogPlanBuilder.java:
##########
@@ -0,0 +1,547 @@
+// 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.rules.analysis;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.connector.spi.write.ConnectorChangelogMode;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.analyzer.Scope;
+import org.apache.doris.nereids.analyzer.UnboundAlias;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.Cast;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.LessThanEqual;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.WindowExpression;
+import org.apache.doris.nereids.trees.expressions.functions.agg.AnyValue;
+import org.apache.doris.nereids.trees.expressions.functions.agg.Count;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.AssertTrue;
+import
org.apache.doris.nereids.trees.expressions.functions.scalar.ShortCircuitIf;
+import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.TinyIntLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
+import
org.apache.doris.nereids.trees.plans.commands.info.ConnectorChangelogRowChangeSpec;
+import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause;
+import
org.apache.doris.nereids.trees.plans.commands.merge.MergeNotMatchedClause;
+import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
+import org.apache.doris.nereids.types.BigIntType;
+import org.apache.doris.nereids.types.DataType;
+import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.nereids.util.TypeCoercionUtils;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Builds the operation-column plus full-row projection used by
changelog-oriented connectors. */
+public final class ConnectorChangelogPlanBuilder {
+ private static final String BRANCH_LABEL = "__DORIS_CHANGELOG_BRANCH__";
+
+ private ConnectorChangelogPlanBuilder() {
+ }
+
+ /** Builds a changelog plan for the requested connector row-level
operation. */
+ public static LogicalPlan build(List<Column> schema, List<String>
primaryKeys,
+ ConnectorChangelogMode mode, ConnectorChangelogRowChangeSpec spec,
+ LogicalPlan child, CascadesContext context) {
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Update) {
+ return buildUpdate(schema, mode,
(ConnectorChangelogRowChangeSpec.Update) spec,
+ child, context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Delete) {
+ return buildDelete(schema, primaryKeys, mode,
+ (ConnectorChangelogRowChangeSpec.Delete) spec, child,
context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Merge) {
+ return new MergeBuilder(schema, primaryKeys, mode,
+ (ConnectorChangelogRowChangeSpec.Merge) spec, child,
context).build();
+ }
+ throw new AnalysisException("Unsupported connector changelog
specification: "
+ + spec.getClass().getSimpleName());
+ }
+
+ private static LogicalPlan buildUpdate(List<Column> schema,
ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Update update, LogicalPlan child,
+ CascadesContext context) {
+ Map<String, Expression> changes =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (EqualTo assignment : update.getAssignments()) {
+ List<String> parts = ((UnboundSlot)
assignment.left()).getNameParts();
+ String name = parts.get(parts.size() - 1);
+ if (changes.put(name, assignment.right()) != null) {
+ throw new AnalysisException("Duplicate column name in
connector UPDATE: " + name);
+ }
+ }
+ ExpressionAnalyzer analyzer = analyzer(child, context);
+ List<NamedExpression> projects = new ArrayList<>();
+ projects.add(operation(mode.getOperationColumnName(),
mode.getUpdateValue()));
+ for (Column column : schema) {
+ Expression value = changes.remove(column.getName());
+ if (value == null) {
+ value = targetSlot(update.getTargetNameInPlan(),
column.getName());
+ }
+ projects.add(bindColumn(analyzer, value, column));
+ }
+ if (!changes.isEmpty()) {
+ throw new AnalysisException("Unknown column in connector UPDATE: "
+ + String.join(", ", changes.keySet()));
+ }
+ return new LogicalProject<>(projects, child);
+ }
+
+ private static LogicalPlan buildDelete(List<Column> schema, List<String>
primaryKeys,
+ ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Delete delete, LogicalPlan child,
+ CascadesContext context) {
+ ExpressionAnalyzer analyzer = analyzer(child, context);
+ List<NamedExpression> projects = new ArrayList<>();
+ projects.add(operation(mode.getOperationColumnName(),
mode.getDeleteValue()));
+ for (Column column : schema) {
+ projects.add(bindColumn(analyzer,
+ targetSlot(delete.getTargetNameInPlan(),
column.getName()), column));
+ }
+ LogicalProject<LogicalPlan> project = new LogicalProject<>(projects,
child);
+ if (!delete.shouldDeduplicateTargetRows()) {
+ return project;
+ }
+ if (primaryKeys.isEmpty()) {
+ throw new AnalysisException("Connector DELETE USING requires a
primary-key table");
+ }
+ Set<String> keys = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+ keys.addAll(primaryKeys);
+ List<Expression> groupBy = new ArrayList<>();
+ List<NamedExpression> outputs = new ArrayList<>();
+ Slot operation = project.getOutput().get(0);
+ groupBy.add(operation);
+ outputs.add(operation);
+ for (int i = 0; i < schema.size(); i++) {
+ Column column = schema.get(i);
+ Slot value = project.getOutput().get(i + 1);
+ if (keys.remove(column.getName())) {
+ groupBy.add(value);
+ outputs.add(value);
+ } else {
+ outputs.add(new Alias(new AnyValue(value), column.getName()));
+ }
+ }
+ if (!keys.isEmpty()) {
+ throw new AnalysisException("Unknown connector primary-key column:
"
+ + String.join(", ", keys));
+ }
+ return new LogicalAggregate<>(groupBy, outputs, project);
+ }
+
+ private static Alias operation(String columnName, byte value) {
+ return new Alias(new TinyIntLiteral(value), columnName);
+ }
+
+ private static UnboundSlot targetSlot(List<String> qualifier, String
column) {
+ List<String> parts = new ArrayList<>(qualifier);
+ parts.add(column);
+ return new UnboundSlot(parts);
+ }
+
+ private static ExpressionAnalyzer analyzer(LogicalPlan plan,
CascadesContext context) {
+ return new ExpressionAnalyzer(plan, new Scope(plan.getOutput()),
context, true, false);
+ }
+
+ private static Alias bindColumn(ExpressionAnalyzer analyzer, Expression
expression, Column column) {
+ Expression value = analyzer.analyze(expression);
+ value = TypeCoercionUtils.castIfNotSameType(value,
DataType.fromCatalogType(column.getType()));
+ return new Alias(value, column.getName());
+ }
+
+ private static final class MergeBuilder {
+ private final List<Column> schema;
+ private final List<String> primaryKeys;
+ private final ConnectorChangelogMode mode;
+ private final ConnectorChangelogRowChangeSpec.Merge merge;
+ private final LogicalPlan child;
+ private final ExpressionAnalyzer analyzer;
+
+ private MergeBuilder(List<Column> schema, List<String> primaryKeys,
+ ConnectorChangelogMode mode,
+ ConnectorChangelogRowChangeSpec.Merge merge, LogicalPlan child,
+ CascadesContext context) {
+ this.schema = schema;
+ this.primaryKeys = primaryKeys;
+ this.mode = mode;
+ this.merge = merge;
+ this.child = child;
+ this.analyzer = analyzer(child, context);
+ }
+
+ private LogicalPlan build() {
+ if (primaryKeys.isEmpty()) {
+ throw new AnalysisException("Connector MERGE requires a
primary-key table");
+ }
+ Alias branch = bindBranchLabel();
+ Slot branchSlot = branch.toSlot();
+ List<NamedExpression> branchOutputs = new
ArrayList<>(child.getOutput());
+ branchOutputs.add(branch);
+ LogicalPlan selected = new LogicalProject<>(branchOutputs, child);
+ selected = new LogicalFilter<>(
+ ImmutableSet.of(new Not(new
org.apache.doris.nereids.trees.expressions.IsNull(branchSlot))),
+ selected);
+ List<List<Expression>> branches = buildBranchProjections();
+ if (!merge.getNotMatchedClauses().isEmpty()) {
+ validateNotMatchedPrimaryKeys(branches);
+ }
+ List<NamedExpression> output = new ArrayList<>();
+ for (int column = 0; column <= schema.size(); column++) {
+ DataType type = column == 0
+ ? org.apache.doris.nereids.types.TinyIntType.INSTANCE
+ : DataType.fromCatalogType(schema.get(column -
1).getType());
+ String name = column == 0
+ ? mode.getOperationColumnName() : schema.get(column -
1).getName();
+ Expression value = new NullLiteral(type);
+ for (int index = branches.size() - 1; index >= 0; index--) {
+ Expression branchValue =
TypeCoercionUtils.castIfNotSameType(
+ branches.get(index).get(column), type);
+ value = new ShortCircuitIf(new EqualTo(branchSlot, new
IntegerLiteral(index)),
+ branchValue, value);
+ }
+ output.add(new Alias(value, name));
+ }
+ return addCardinalityChecks(new LogicalProject<>(output,
selected));
+ }
+
+ private void validateNotMatchedPrimaryKeys(List<List<Expression>>
branches) {
+ Map<String, Slot> targetKeys =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (String key : primaryKeys) {
+ targetKeys.put(key, findTargetSlot(key));
+ }
+ Set<Slot> targetSlots = child.getOutput().stream()
+ .filter(slot -> qualifierEndsWith(slot.getQualifier(),
merge.getTargetNameInPlan()))
+ .collect(ImmutableSet.toImmutableSet());
+ if (!(child instanceof LogicalJoin)) {
+ throw new AnalysisException("Connector MERGE input must be a
logical join");
+ }
+ Expression onClause = ((LogicalJoin<?, ?>)
child).getOnClauseCondition()
+ .orElseThrow(() -> new AnalysisException("Connector MERGE
requires an ON condition"));
+ Map<String, Expression> sourceKeys =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (Expression conjunct :
ExpressionUtils.extractConjunction(onClause)) {
+ if (!(conjunct instanceof EqualTo)) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ EqualTo equality = (EqualTo) conjunct;
+ String leftKey = targetPrimaryKeyName(equality.left(),
targetKeys);
+ String rightKey = targetPrimaryKeyName(equality.right(),
targetKeys);
+ if ((leftKey == null) == (rightKey == null)) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ String key = leftKey != null ? leftKey : rightKey;
+ Expression source = leftKey != null ? equality.right() :
equality.left();
+ if (source.getInputSlots().isEmpty()
+ ||
source.getInputSlots().stream().anyMatch(targetSlots::contains)
+ || source.containsNondeterministic()
+ || sourceKeys.put(key, source) != null) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ }
+ if (sourceKeys.size() != targetKeys.size()) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ int firstInsert = merge.getMatchedClauses().size();
+ for (int branch = firstInsert; branch < branches.size(); branch++)
{
+ for (Map.Entry<String, Expression> sourceKey :
sourceKeys.entrySet()) {
+ int column = schemaIndex(sourceKey.getKey()) + 1;
+ DataType type = DataType.fromCatalogType(schema.get(column
- 1).getType());
+ if
(!TypeCoercionUtils.castIfNotSameType(branches.get(branch).get(column), type)
+
.equals(TypeCoercionUtils.castIfNotSameType(sourceKey.getValue(), type))) {
+ throw invalidNotMatchedKeyCondition();
+ }
+ }
+ }
+ }
+
+ private LogicalPlan addCardinalityChecks(LogicalProject<?> rowChanges)
{
+ List<Slot> outputs = rowChanges.getOutput();
+ Slot operation = outputs.get(0);
+ List<Expression> partitionKeys = new ArrayList<>();
+ for (String key : primaryKeys) {
+ partitionKeys.add(outputs.get(schemaIndex(key) + 1));
+ }
+ Expression isInsert = new EqualTo(operation, new
TinyIntLiteral(mode.getInsertValue()));
+ List<CardinalityCheck> checks = new ArrayList<>();
+ if (!merge.getMatchedClauses().isEmpty()) {
+ checks.add(CardinalityCheck.matched(isInsert));
+ }
+ if (!merge.getNotMatchedClauses().isEmpty()) {
+ checks.add(CardinalityCheck.inserted(isInsert));
+ }
+ List<NamedExpression> markerOutputs = new ArrayList<>(outputs);
+ for (CardinalityCheck check : checks) {
+ markerOutputs.add(check.marker);
+ }
+ LogicalPlan plan = new LogicalProject<>(markerOutputs, rowChanges);
+ List<Alias> counts = new ArrayList<>();
+ for (CardinalityCheck check : checks) {
+ counts.add(check.count(partitionKeys));
+ }
+ plan = new LogicalWindow<>(new ArrayList<>(counts), plan);
+ ImmutableSet.Builder<Expression> assertions =
ImmutableSet.builder();
+ for (int i = 0; i < checks.size(); i++) {
+ assertions.add(checks.get(i).assertion(counts.get(i)));
+ }
+ plan = new LogicalFilter<>(assertions.build(), plan);
+ return new LogicalProject<>(new ArrayList<>(outputs), plan);
+ }
+
+ private int schemaIndex(String name) {
+ for (int i = 0; i < schema.size(); i++) {
+ if (schema.get(i).getName().equalsIgnoreCase(name)) {
+ return i;
+ }
+ }
+ throw new AnalysisException("Unable to resolve connector
row-change column '" + name + "'");
+ }
+
+ private String targetPrimaryKeyName(Expression expression, Map<String,
Slot> targetKeys) {
+ Expression unwrapped = expression;
+ while (unwrapped instanceof Cast) {
+ if (((Cast) unwrapped).isExplicitType()) {
+ return null;
+ }
+ unwrapped = unwrapped.child(0);
+ }
+ if (!(unwrapped instanceof Slot)) {
+ return null;
+ }
+ Slot slot = (Slot) unwrapped;
+ for (Map.Entry<String, Slot> key : targetKeys.entrySet()) {
+ if (slot.getExprId().equals(key.getValue().getExprId())
+ &&
expression.getDataType().equals(key.getValue().getDataType())) {
+ return key.getKey();
+ }
+ }
+ return null;
+ }
+
+ private AnalysisException invalidNotMatchedKeyCondition() {
+ return new AnalysisException("Connector MERGE with NOT MATCHED
INSERT requires ON to contain "
+ + "only equality predicates for every target primary-key
column and each INSERT "
+ + "to use the corresponding deterministic source
expression");
+ }
+
+ private Alias bindBranchLabel() {
+ Expression targetPresent = new Not(new
org.apache.doris.nereids.trees.expressions.IsNull(
+ findTargetSlot(primaryKeys.get(0))));
+ Expression matched = new NullLiteral(IntegerType.INSTANCE);
+ for (int i = merge.getMatchedClauses().size() - 1; i >= 0; i--) {
+ MergeMatchedClause clause = merge.getMatchedClauses().get(i);
+ if (i != merge.getMatchedClauses().size() - 1 &&
!clause.getCasePredicate().isPresent()) {
+ throw new AnalysisException("Only the last matched clause
may omit its condition");
+ }
+ Expression label = new IntegerLiteral(i);
+ matched = clause.getCasePredicate().isPresent()
+ ? new ShortCircuitIf(clause.getCasePredicate().get(),
label, matched) : label;
+ }
+ Expression notMatched = new NullLiteral(IntegerType.INSTANCE);
+ for (int i = merge.getNotMatchedClauses().size() - 1; i >= 0; i--)
{
+ MergeNotMatchedClause clause =
merge.getNotMatchedClauses().get(i);
+ if (i != merge.getNotMatchedClauses().size() - 1
+ && !clause.getCasePredicate().isPresent()) {
+ throw new AnalysisException("Only the last not matched
clause may omit its condition");
+ }
+ Expression label = new IntegerLiteral(i +
merge.getMatchedClauses().size());
+ notMatched = clause.getCasePredicate().isPresent()
+ ? new ShortCircuitIf(clause.getCasePredicate().get(),
label, notMatched) : label;
+ }
+ return new Alias(analyzer.analyze(
+ new ShortCircuitIf(targetPresent, matched, notMatched)),
BRANCH_LABEL);
+ }
+
+ private List<List<Expression>> buildBranchProjections() {
+ List<List<Expression>> branches = new ArrayList<>();
+ for (MergeMatchedClause clause : merge.getMatchedClauses()) {
+ branches.add(clause.isDelete() ? deleteProjection() :
updateProjection(clause));
+ }
+ for (MergeNotMatchedClause clause : merge.getNotMatchedClauses()) {
+ branches.add(insertProjection(clause));
+ }
+ if (branches.isEmpty()) {
+ throw new AnalysisException("Connector MERGE requires at least
one WHEN clause");
+ }
+ for (List<Expression> branch : branches) {
+ for (int i = 0; i < branch.size(); i++) {
+ branch.set(i, analyzer.analyze(branch.get(i)));
+ }
+ }
+ return branches;
+ }
+
+ private List<Expression> deleteProjection() {
+ List<Expression> output = new ArrayList<>();
+ output.add(new TinyIntLiteral(mode.getDeleteValue()));
+ for (Column column : schema) {
+ output.add(targetSlot(column.getName()));
+ }
+ return output;
+ }
+
+ private List<Expression> updateProjection(MergeMatchedClause clause) {
+ Map<String, Expression> changes =
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+ for (EqualTo assignment : clause.getAssignments()) {
+ List<String> parts = ((UnboundSlot)
assignment.left()).getNameParts();
+ String name = parts.get(parts.size() - 1);
+ if (changes.put(name, assignment.right()) != null) {
+ throw new AnalysisException("Duplicate column name in
connector MERGE UPDATE: " + name);
+ }
+ }
+ List<Expression> output = new ArrayList<>();
+ output.add(new TinyIntLiteral(mode.getUpdateValue()));
+ for (Column column : schema) {
+ output.add(changes.containsKey(column.getName())
+ ? changes.remove(column.getName()) :
targetSlot(column.getName()));
+ }
+ if (!changes.isEmpty()) {
+ throw new AnalysisException("Unknown column in connector MERGE
UPDATE: "
+ + String.join(", ", changes.keySet()));
+ }
+ return output;
+ }
+
+ private List<Expression> insertProjection(MergeNotMatchedClause
clause) {
+ if (clause.getRow().size() != schema.size()) {
Review Comment:
[P1] Preserve normal MERGE INSERT default expansion for changelog
connectors. For a target `(id, value DEFAULT 7)`, `WHEN NOT MATCHED THEN INSERT
(id) VALUES (s.id)` reaches this check with one value and two schema columns
and is rejected, although the sink only needs the engine to produce the final
full row. The sibling position-delete MERGE path maps the supplied column list
and fills omitted fields via `ConnectorWriteSchemaUtils.resolveDefault` (also
handling explicit DEFAULT references). Reuse that expansion and add
partial-column/default tests.
##########
fe/fe-core/src/main/java/org/apache/doris/transaction/CommitDataSerializer.java:
##########
@@ -67,6 +92,20 @@ public static void feed(Transaction txn, List<? extends
TBase<?, ?>> fragments)
}
}
+ /**
+ * Delivers opaque commit fragments without interpreting connector-owned
bytes in FE core.
+ * Thrift exposes binary values as {@link ByteBuffer}; copy each remaining
slice before
+ * passing it to a transaction, which may keep the byte array after the
RPC is released.
+ */
+ public static void feedRaw(Transaction txn, List<ByteBuffer> fragments) {
+ for (ByteBuffer fragment : fragments) {
+ ByteBuffer source = fragment.duplicate();
+ byte[] bytes = new byte[source.remaining()];
+ source.get(bytes);
+ txn.addCommitData(bytes);
Review Comment:
[P1] Serialize opaque fragment delivery and lifecycle transitions per
connector transaction. Final reports from different backends have only
per-report locks, so both can reach this call concurrently; an error report can
also cancel, zero the coordinator latch, and let rollback/close overlap another
backend already inside `addCommitData`. The public SPI has no threading
contract (the built-ins happen to synchronize their appends), so a compliant
connector can corrupt state or race teardown. Serialize `addCommitData`,
commit, rollback, and close in the engine wrapper, and test both overlaps.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ChangelogRowLevelDmlTransform.java:
##########
@@ -0,0 +1,233 @@
+// 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.commands;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.connector.spi.DorisConnectorException;
+import org.apache.doris.connector.spi.handle.WriteOperation;
+import org.apache.doris.connector.spi.pushdown.ConnectorPredicate;
+import org.apache.doris.connector.spi.write.ConnectorRowChangeStyle;
+import org.apache.doris.connector.spi.write.ConnectorRowLevelDmlRequest;
+import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
+import org.apache.doris.mysql.privilege.AccessControllerManager;
+import org.apache.doris.nereids.NereidsPlanner;
+import org.apache.doris.nereids.analyzer.UnboundConnectorTableSink;
+import org.apache.doris.nereids.analyzer.UnboundRelation;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+import org.apache.doris.nereids.parser.LogicalPlanBuilderAssistant;
+import org.apache.doris.nereids.rules.exploration.join.JoinReorderContext;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
+import org.apache.doris.nereids.trees.plans.JoinType;
+import org.apache.doris.nereids.trees.plans.Plan;
+import
org.apache.doris.nereids.trees.plans.commands.info.ConnectorChangelogRowChangeSpec;
+import
org.apache.doris.nereids.trees.plans.commands.insert.BaseExternalTableInsertExecutor;
+import
org.apache.doris.nereids.trees.plans.commands.insert.PluginDrivenInsertExecutor;
+import org.apache.doris.nereids.trees.plans.commands.merge.MergeMatchedClause;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
+import
org.apache.doris.nereids.trees.plans.physical.PhysicalConnectorTableSink;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalSink;
+import org.apache.doris.nereids.util.RelationUtil;
+import org.apache.doris.planner.DataSink;
+import org.apache.doris.planner.PlanFragment;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Plans row-level changes as an operation column followed by a complete
table row. */
+public class ChangelogRowLevelDmlTransform implements RowLevelDmlTransform {
+
+ @Override
+ public boolean handles(TableIf table) {
+ if (!(table instanceof PluginDrivenExternalTable)) {
+ return false;
+ }
+ PluginDrivenExternalTable connectorTable = (PluginDrivenExternalTable)
table;
+ if (connectorTable.getConnectorRowChangeStyle() !=
ConnectorRowChangeStyle.CHANGELOG) {
+ return false;
+ }
+ return RowLevelDmlRegistry.supportsAnyRowLevelDml(
+ connectorTable.connectorSupportedWriteOperations());
+ }
+
+ @Override
+ public void checkMode(TableIf table, RowLevelDmlOp op) {
+ WriteOperation operation = op.toWriteOperation();
+ if (!((PluginDrivenExternalTable)
table).connectorSupportedWriteOperations().contains(operation)) {
+ throw new AnalysisException("Connector does not support " +
operation + " operations");
+ }
+ // Statement-specific validation runs in synthesize, where assignments
and MERGE clauses are available.
+ }
+
+ @Override
+ public LogicalPlan synthesize(ConnectContext ctx, RowLevelDmlArgs args,
RowLevelDmlOp op) {
+ PluginDrivenExternalTable table = (PluginDrivenExternalTable)
args.getTable();
+ if (op == RowLevelDmlOp.DELETE && (args.isTempPart() ||
!args.getPartitions().isEmpty())) {
+ throw new AnalysisException(
+ "Connector changelog DELETE does not support partition
name lists; use a WHERE predicate");
+ }
+ validate(ctx, table, args, op);
+ switch (op) {
+ case DELETE:
+ return deletePlan(ctx, args);
+ case UPDATE:
+ return updatePlan(ctx, args);
+ default:
+ return mergePlan(ctx, args);
+ }
+ }
+
+ private LogicalPlan deletePlan(ConnectContext ctx, RowLevelDmlArgs args) {
+ List<String> target = args.getTableAlias() != null
+ ? ImmutableList.of(args.getTableAlias())
+ : RelationUtil.getQualifierName(ctx, args.getNameParts());
+ return new UnboundConnectorTableSink<>(args.getNameParts(),
args.getLogicalQuery(),
+ new ConnectorChangelogRowChangeSpec.Delete(target,
args.shouldDeduplicateTargetRows()));
+ }
+
+ private LogicalPlan updatePlan(ConnectContext ctx, RowLevelDmlArgs args) {
+ for (EqualTo assignment : args.getAssignments()) {
+ UpdateCommand.checkAssignmentColumn(ctx,
+ ((UnboundSlot) assignment.left()).getNameParts(),
+ args.getNameParts(), args.getTableAlias());
+ }
+ List<String> target = args.getTableAlias() != null
+ ? ImmutableList.of(args.getTableAlias())
+ : RelationUtil.getQualifierName(ctx, args.getNameParts());
+ LogicalPlan sink = new
UnboundConnectorTableSink<>(args.getNameParts(), args.getLogicalQuery(),
+ new ConnectorChangelogRowChangeSpec.Update(target,
args.getAssignments()));
+ return args.getCte().isPresent() ? (LogicalPlan)
args.getCte().get().withChildren(sink) : sink;
+ }
+
+ private LogicalPlan mergePlan(ConnectContext ctx, RowLevelDmlArgs args) {
+ for (MergeMatchedClause clause : args.getMatchedClauses()) {
+ for (EqualTo assignment : clause.getAssignments()) {
+ UpdateCommand.checkAssignmentColumn(ctx,
+ ((UnboundSlot) assignment.left()).getNameParts(),
+ args.getTargetNameParts(),
args.getTargetAlias().orElse(null));
+ }
+ }
+ List<String> targetName = args.getTargetAlias().isPresent()
+ ? ImmutableList.of(args.getTargetAlias().get())
+ : RelationUtil.getQualifierName(ctx,
args.getTargetNameParts());
+ ConnectorChangelogRowChangeSpec.Merge spec = new
ConnectorChangelogRowChangeSpec.Merge(
+ targetName, args.getMatchedClauses(),
args.getNotMatchedClauses());
+ LogicalPlan target = LogicalPlanBuilderAssistant.withCheckPolicy(
+ new UnboundRelation(StatementScopeIdGenerator.newRelationId(),
args.getTargetNameParts()));
+ if (args.getTargetAlias().isPresent()) {
+ target = new LogicalSubQueryAlias<>(args.getTargetAlias().get(),
target);
+ }
+ JoinType joinType = args.getNotMatchedClauses().isEmpty()
Review Comment:
[P1] Reuse `MergeUtils.buildMergeJoin` here. With this fixed `source LEFT
OUTER JOIN target` tree, Doris builds the entire external target on the right
and LEFT OUTER is in the runtime-filter deny list, so a small change source
against a large table loses target pruning and may exhaust the hash-build
memory. `target RIGHT OUTER JOIN source` preserves the same unmatched-source
rows while keeping the source on the build side; the existing MERGE paths
already centralize that shape.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java:
##########
@@ -370,4 +446,53 @@ public PhysicalProperties getRequirePhysicalProperties() {
}
return PhysicalProperties.GATHER;
}
+
+ private PhysicalProperties toPhysicalProperties(ConnectorWriteDistribution
distribution) {
+ switch (distribution.getMode()) {
+ case EXECUTION_ANY:
+ return PhysicalProperties.EXECUTION_ANY;
+ case GATHER:
+ return PhysicalProperties.GATHER;
+ case HASH:
+ return PhysicalProperties.createHash(
+ routeExprIds(distribution.getRouteColumns()),
ShuffleType.REQUIRE);
+ case EXTERNAL_UNPARTITIONED:
+ requireExternalWriterRoutingSupport();
+ return PhysicalProperties.EXTERNAL_TABLE_SINK_UNPARTITIONED;
+ case EXTERNAL_HASH:
+ requireExternalWriterRoutingSupport();
+ return new PhysicalProperties(new
DistributionSpecExternalTableSinkHashPartitioned(
+ routeExprIds(distribution.getRouteColumns()),
+ distribution.getPartitionFunction(),
+ distribution.getPartitionFunctionOptions(),
+ distribution.getWriterAssignment()));
+ default:
+ throw new IllegalStateException("Unsupported connector write
distribution: "
+ + distribution.getMode());
+ }
+ }
+
+ private void requireExternalWriterRoutingSupport() {
+ Preconditions.checkState(Config.be_exec_version
+ >=
DistributionSpecExternalTableSinkHashPartitioned.MIN_BE_EXEC_VERSION,
+ "External table sink distribution requires BE execution
version %s or newer",
+
DistributionSpecExternalTableSinkHashPartitioned.MIN_BE_EXEC_VERSION);
+ }
+
+ private List<ExprId> routeExprIds(List<String> routeColumns) {
+ List<Slot> output = child().getOutput();
+ int offset = hasRowOperationColumn() ? 1 : 0;
+ Preconditions.checkState(boundTargetSchema.size() + offset ==
output.size(),
Review Comment:
[P1] Resolve route slots against the schema actually aligned with the child.
Name-mapped connectors may return HASH/EXTERNAL_HASH without opting into
positional full-schema order: `INSERT INTO t(id) SELECT 1` fails this assertion
because `boundTargetSchema` still has `(id,value)`, while full reordered
`INSERT INTO t(value,id) ...` passes the size check but maps route name `id` to
the `value` slot and silently hashes the wrong data. Map against `cols` for
name-mapped output and use `boundTargetSchema` only for a known positional
projection (including the operation-column offset).
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalConnectorTableSink.java:
##########
@@ -370,4 +446,53 @@ public PhysicalProperties getRequirePhysicalProperties() {
}
return PhysicalProperties.GATHER;
}
+
+ private PhysicalProperties toPhysicalProperties(ConnectorWriteDistribution
distribution) {
+ switch (distribution.getMode()) {
+ case EXECUTION_ANY:
+ return PhysicalProperties.EXECUTION_ANY;
+ case GATHER:
+ return PhysicalProperties.GATHER;
+ case HASH:
+ return PhysicalProperties.createHash(
+ routeExprIds(distribution.getRouteColumns()),
ShuffleType.REQUIRE);
+ case EXTERNAL_UNPARTITIONED:
+ requireExternalWriterRoutingSupport();
+ return PhysicalProperties.EXTERNAL_TABLE_SINK_UNPARTITIONED;
+ case EXTERNAL_HASH:
+ requireExternalWriterRoutingSupport();
+ return new PhysicalProperties(new
DistributionSpecExternalTableSinkHashPartitioned(
Review Comment:
[P1] Preserve connector-owned routing independently of
`enable_strict_consistency_dml`. `visitPhysicalConnectorTableSink` obtains this
requirement and then replaces every non-GATHER property with `ANY` when that
setting is false; the getter is always false in cloud mode. An
`EXTERNAL_HASH`/`IDENTITY` write therefore emits no external-hash exchange,
letting the same ownership key reach multiple writers and bypassing the BE
checks entirely. Treat this distribution as a correctness contract and add
strict=false/cloud property-derivation coverage.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]