github-actions[bot] commented on code in PR #65381:
URL: https://github.com/apache/doris/pull/65381#discussion_r3601702151


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertIntoTableCommand.java:
##########
@@ -565,6 +567,19 @@ ExecutorFactory selectInsertExecutorFactory(
                                 emptyInsert, jobId
                         )
                 );
+            } else if (physicalSink instanceof PhysicalPaimonTableSink) {
+                boolean emptyInsert = childIsEmptyRelation(physicalSink);

Review Comment:
   [P1] Keep empty Paimon overwrites executable
   
   For `INSERT OVERWRITE ... SELECT ... WHERE 1 = 0`, this sets 
`emptyInsert=true`. `initPlan()` then skips `beginTransaction()`/sink binding 
and `runInternal()` returns, so `PaimonTransaction.withOverwrite()` never runs 
and the old snapshot remains visible. FT-013 in this PR expects the count to 
become zero, but this path cannot publish that empty snapshot. Compute 
`paimonCtx` first and only take the empty-input fast path when 
`!paimonCtx.isOverwrite()`.



##########
be/src/exec/pipeline/pipeline_fragment_context.cpp:
##########
@@ -2515,6 +2524,20 @@ void 
PipelineFragmentContext::_coordinator_callback(const ReportStatusRequest& r
         }
     }
 
+    if (auto pcm = req.runtime_state->paimon_commit_messages(); !pcm.empty()) {
+        params.__isset.paimon_commit_messages = true;
+        
params.paimon_commit_messages.insert(params.paimon_commit_messages.end(), 
pcm.begin(),
+                                             pcm.end());
+    } else if (!req.runtime_states.empty()) {
+        for (auto* rs : req.runtime_states) {
+            if (auto rs_pcm = rs->paimon_commit_messages(); !rs_pcm.empty()) {
+                params.__isset.paimon_commit_messages = true;
+                
params.paimon_commit_messages.insert(params.paimon_commit_messages.end(),

Review Comment:
   [P2] Send commit metadata in bounded RPCs
   
   `PaimonCommitCodec` emits nominal 8 MiB chunks, but this loop concatenates 
every chunk from every task RuntimeState into one `TReportExecStatusParams` and 
sends it once. The default Thrift message limit is 100 MiB, so enough 
files/partitions still make the final report fail after writers have drained 
staged files; FE can abort only payloads it received. Send acknowledged, 
idempotent batches as separate bounded RPCs instead of reassembling them here.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/physical/PhysicalPaimonTableSink.java:
##########
@@ -0,0 +1,111 @@
+// 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.physical;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.datasource.paimon.PaimonExternalDatabase;
+import org.apache.doris.datasource.paimon.PaimonExternalTable;
+import org.apache.doris.nereids.memo.GroupExpression;
+import org.apache.doris.nereids.properties.LogicalProperties;
+import org.apache.doris.nereids.properties.PhysicalProperties;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.plans.AbstractPlan;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.PlanType;
+import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
+import org.apache.doris.statistics.Statistics;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Physical Paimon table sink.
+ */
+public class PhysicalPaimonTableSink<CHILD_TYPE extends Plan>
+        extends PhysicalBaseExternalTableSink<CHILD_TYPE> {
+
+    public PhysicalPaimonTableSink(PaimonExternalDatabase database,
+                                    PaimonExternalTable targetTable,
+                                    List<Column> cols,
+                                    List<NamedExpression> outputExprs,
+                                    Optional<GroupExpression> groupExpression,
+                                    LogicalProperties logicalProperties,
+                                    CHILD_TYPE child) {
+        this(database, targetTable, cols, outputExprs, groupExpression, 
logicalProperties,
+                PhysicalProperties.SINK_RANDOM_PARTITIONED, null, child);
+    }
+
+    public PhysicalPaimonTableSink(PaimonExternalDatabase database,
+                                    PaimonExternalTable targetTable,
+                                    List<Column> cols,
+                                    List<NamedExpression> outputExprs,
+                                    Optional<GroupExpression> groupExpression,
+                                    LogicalProperties logicalProperties,
+                                    PhysicalProperties physicalProperties,
+                                    Statistics statistics,
+                                    CHILD_TYPE child) {
+        super(PlanType.PHYSICAL_PAIMON_TABLE_SINK, database, targetTable, 
cols, outputExprs,
+                groupExpression, logicalProperties, physicalProperties, 
statistics, child);
+    }
+
+    @Override
+    public Plan withChildren(List<Plan> children) {
+        return AbstractPlan.copyWithSameId(this, () -> new 
PhysicalPaimonTableSink<>(
+                (PaimonExternalDatabase) database, (PaimonExternalTable) 
targetTable,
+                cols, outputExprs, groupExpression,
+                getLogicalProperties(), physicalProperties, statistics, 
children.get(0)));
+    }
+
+    @Override
+    public Plan withGroupExpression(Optional<GroupExpression> groupExpression) 
{
+        return AbstractPlan.copyWithSameId(this, () -> new 
PhysicalPaimonTableSink<>(
+                (PaimonExternalDatabase) database, (PaimonExternalTable) 
targetTable,
+                cols, outputExprs, groupExpression,
+                getLogicalProperties(), physicalProperties, statistics, 
child()));
+    }
+
+    @Override
+    public Plan withGroupExprLogicalPropChildren(Optional<GroupExpression> 
groupExpression,
+            Optional<LogicalProperties> logicalProperties, List<Plan> 
children) {
+        return AbstractPlan.copyWithSameId(this, () -> new 
PhysicalPaimonTableSink<>(
+                (PaimonExternalDatabase) database, (PaimonExternalTable) 
targetTable,
+                cols, outputExprs, groupExpression,
+                logicalProperties.get(), physicalProperties, statistics, 
children.get(0)));
+    }
+
+    @Override
+    public PhysicalPaimonTableSink<Plan> withPhysicalPropertiesAndStats(
+            PhysicalProperties physicalProperties, Statistics stats) {
+        return AbstractPlan.copyWithSameId(this, () -> new 
PhysicalPaimonTableSink<>(
+                (PaimonExternalDatabase) database, (PaimonExternalTable) 
targetTable,
+                cols, outputExprs, groupExpression,
+                getLogicalProperties(), physicalProperties, stats, child()));
+    }
+
+    @Override
+    public PhysicalProperties getRequirePhysicalProperties() {
+        // Partition and bucket routing is handled by the Paimon SDK 
internally.
+        // Doris does not need to shuffle data by partition/bucket keys.
+        return PhysicalProperties.SINK_RANDOM_PARTITIONED;

Review Comment:
   [P1] Keep each Paimon bucket on one writer
   
   `SINK_RANDOM_PARTITIONED` becomes `HIVE_TABLE_SINK_UNPARTITIONED`, which 
round-robins and opens additional writer channels after 25 MiB. Equal primary 
keys can therefore reach independent `TableWriteImpl`s. Each restores the same 
bucket max sequence and allocates the next sequence locally, so the two 
versions can tie and Paimon's reader order—not Doris input order—decides the 
winner. Hash-distribute the required partition/bucket/primary-key identity to 
one writer (or provide a global sequence domain), and cover duplicate keys 
after crossing the scaling threshold.



##########
be/src/exec/sink/writer/paimon/jni_paimon_write_backend.cpp:
##########
@@ -0,0 +1,363 @@
+// 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.
+
+#include "exec/sink/writer/paimon/jni_paimon_write_backend.h"
+
+#include <arrow/buffer.h>
+#include <arrow/io/memory.h>
+#include <arrow/ipc/writer.h>
+#include <arrow/record_batch.h>
+#include <arrow/type.h>
+
+#include <map>
+
+#include "common/check.h"
+#include "common/logging.h"
+#include "format/arrow/arrow_block_convertor.h"
+#include "format/arrow/arrow_row_batch.h"
+#include "runtime/runtime_state.h"
+#include "util/jni-util.h"
+
+namespace doris {
+
+// ────────────────────────────────────────────────────────────
+// JNI helpers — JVM attachment and class loading
+// ────────────────────────────────────────────────────────────
+
+static constexpr const char* PAIMON_JNI_WRITER_CLASS = 
"org/apache/doris/paimon/PaimonJniWriter";
+static constexpr const char* SCANNER_LOADER_CLASS =
+        "org/apache/doris/common/classloader/ScannerLoader";
+
+/// Attach the current native thread to the JVM if not already attached,
+/// and return a valid JNIEnv pointer.
+static Status _get_jni_env(JNIEnv** env) {
+    JavaVM* jvm = nullptr;
+    jsize n_vms = 0;
+    jint result = JNI_GetCreatedJavaVMs(&jvm, 1, &n_vms);
+    if (result != JNI_OK || n_vms == 0) {
+        return Status::InternalError("Failed to get created JavaVM");
+    }
+    result = jvm->GetEnv(reinterpret_cast<void**>(env), JNI_VERSION_1_8);
+    if (result == JNI_EDETACHED) {
+        result = jvm->AttachCurrentThread(reinterpret_cast<void**>(env), 
nullptr);
+        if (result != JNI_OK) {
+            return Status::InternalError("Failed to attach current thread to 
JVM");
+        }
+    } else if (result != JNI_OK) {
+        return Status::InternalError("Failed to get JNIEnv");
+    }
+    return Status::OK();
+}
+
+JniPaimonWriteBackend::~JniPaimonWriteBackend() {
+    JNIEnv* env = nullptr;
+    if (_get_jni_env(&env).ok()) {
+        if (_jni_writer_obj != nullptr) {
+            DCHECK(_close_id != nullptr);
+            env->CallVoidMethod(_jni_writer_obj, _close_id);
+            Status close_status = _check_jni_exception(env, "close 
PaimonJniWriter");
+            if (!close_status.ok()) {
+                LOG(WARNING) << "Failed to close PaimonJniWriter: " << 
close_status.to_string();
+            }
+            env->DeleteGlobalRef(_jni_writer_obj);
+            _jni_writer_obj = nullptr;
+        }
+        if (_jni_writer_cls != nullptr) {
+            env->DeleteGlobalRef(_jni_writer_cls);
+            _jni_writer_cls = nullptr;
+        }
+    }
+    _opened = false;
+}
+
+Status JniPaimonWriteBackend::_check_jni_exception(JNIEnv* env, const 
std::string& method_name) {
+    if (env->ExceptionCheck()) {
+        Status st =
+                Jni::Env::GetJniExceptionMsg(env, true, "JNI exception in " + 
method_name + ": ");
+        LOG(WARNING) << st.to_string();
+        return st;
+    }
+    return Status::OK();
+}
+
+Status JniPaimonWriteBackend::_load_writer_class(JNIEnv* env, jclass* 
writer_class) {
+    jclass loader_class = env->FindClass(SCANNER_LOADER_CLASS);
+    RETURN_IF_ERROR(_check_jni_exception(env, "find ScannerLoader"));
+
+    jmethodID loader_constructor = env->GetMethodID(loader_class, "<init>", 
"()V");
+    jmethodID get_loaded_class = env->GetMethodID(loader_class, 
"getLoadedClass",
+                                                  
"(Ljava/lang/String;)Ljava/lang/Class;");
+    RETURN_IF_ERROR(_check_jni_exception(env, "resolve ScannerLoader 
methods"));
+
+    jobject loader = env->NewObject(loader_class, loader_constructor);
+    jstring class_name = env->NewStringUTF(PAIMON_JNI_WRITER_CLASS);
+    auto* loaded_class =
+            static_cast<jclass>(env->CallObjectMethod(loader, 
get_loaded_class, class_name));
+    RETURN_IF_ERROR(_check_jni_exception(env, "load PaimonJniWriter"));
+
+    *writer_class = loaded_class;
+    env->DeleteLocalRef(class_name);
+    env->DeleteLocalRef(loader);
+    env->DeleteLocalRef(loader_class);
+    return Status::OK();
+}
+
+static jobject _to_java_options(JNIEnv* env, const std::map<std::string, 
std::string>& options) {
+    jclass map_cls = env->FindClass("java/util/HashMap");
+    jmethodID map_ctor = env->GetMethodID(map_cls, "<init>", "()V");
+    jmethodID put_method = env->GetMethodID(
+            map_cls, "put", 
"(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
+
+    jobject map_obj = env->NewObject(map_cls, map_ctor);
+    for (const auto& kv : options) {
+        jstring key = env->NewStringUTF(kv.first.c_str());
+        jstring val = env->NewStringUTF(kv.second.c_str());
+        env->CallObjectMethod(map_obj, put_method, key, val);
+        env->DeleteLocalRef(key);
+        env->DeleteLocalRef(val);
+    }
+    env->DeleteLocalRef(map_cls);
+    return map_obj;
+}
+
+Status JniPaimonWriteBackend::open(const TPaimonTableSink& sink, RuntimeState* 
state) {
+    _sink = sink;
+    DORIS_CHECK(sink.__isset.column_names);
+    DORIS_CHECK(sink.__isset.write_mode);
+    DORIS_CHECK(sink.__isset.serialized_table);
+    DORIS_CHECK(!sink.serialized_table.empty());
+    DORIS_CHECK(sink.__isset.transaction_id);
+    DORIS_CHECK(sink.transaction_id > 0);
+    DORIS_CHECK(sink.__isset.commit_user);
+    DORIS_CHECK(!sink.commit_user.empty());
+
+    JNIEnv* env = nullptr;
+    RETURN_IF_ERROR(_get_jni_env(&env));
+
+    // Step 1: Load PaimonJniWriter class through ScannerLoader (Paimon jars 
are
+    // not on the default application classpath, so FindClass won't work).
+    jclass local_cls = nullptr;
+    RETURN_IF_ERROR(_load_writer_class(env, &local_cls));
+    _jni_writer_cls = static_cast<jclass>(env->NewGlobalRef(local_cls));
+    env->DeleteLocalRef(local_cls);
+
+    // Step 2: Cache JNI method IDs for write, prepareCommit, abort, close.
+    jmethodID open_id = env->GetMethodID(
+            _jni_writer_cls, "open",
+            
"(Ljava/lang/String;Ljava/util/Map;[Ljava/lang/String;JLjava/lang/String;Z)V");
+    _write_id = env->GetMethodID(_jni_writer_cls, "write", 
"(Ljava/nio/ByteBuffer;)V");
+    _prepare_commit_id = env->GetMethodID(_jni_writer_cls, "prepareCommit", 
"()[[B");
+    _abort_id = env->GetMethodID(_jni_writer_cls, "abort", "()V");
+    _close_id = env->GetMethodID(_jni_writer_cls, "close", "()V");
+    RETURN_IF_ERROR(_check_jni_exception(env, "GetMethodID"));
+
+    // Step 3: Create the Java PaimonJniWriter instance.
+    jmethodID ctor_id = env->GetMethodID(_jni_writer_cls, "<init>", "()V");
+    jobject local_obj = env->NewObject(_jni_writer_cls, ctor_id);
+    RETURN_IF_ERROR(_check_jni_exception(env, "NewObject"));
+    _jni_writer_obj = env->NewGlobalRef(local_obj);
+    env->DeleteLocalRef(local_obj);
+
+    // Step 4: Build Java arguments and call PaimonJniWriter.open().
+    const std::map<std::string, std::string> empty_config;
+    jstring j_serialized_table = 
env->NewStringUTF(sink.serialized_table.c_str());
+    jobject j_hadoop_config =
+            _to_java_options(env, sink.__isset.hadoop_config ? 
sink.hadoop_config : empty_config);
+    jstring j_commit_user = env->NewStringUTF(sink.commit_user.c_str());
+
+    jclass string_cls = env->FindClass("java/lang/String");
+    jobjectArray j_cols =
+            env->NewObjectArray(static_cast<jsize>(sink.column_names.size()), 
string_cls, nullptr);
+    for (size_t i = 0; i < sink.column_names.size(); ++i) {
+        jstring str = env->NewStringUTF(sink.column_names[i].c_str());
+        env->SetObjectArrayElement(j_cols, static_cast<jsize>(i), str);
+        env->DeleteLocalRef(str);
+    }
+
+    env->CallVoidMethod(_jni_writer_obj, open_id, j_serialized_table, 
j_hadoop_config, j_cols,
+                        static_cast<jlong>(sink.transaction_id), j_commit_user,
+                        static_cast<jboolean>(sink.write_mode == 
TPaimonWriteMode::OVERWRITE));
+    Status st = _check_jni_exception(env, "open");
+
+    env->DeleteLocalRef(j_serialized_table);
+    env->DeleteLocalRef(j_hadoop_config);
+    env->DeleteLocalRef(j_commit_user);
+    env->DeleteLocalRef(j_cols);
+    env->DeleteLocalRef(string_cls);
+
+    if (st.ok()) {
+        _opened = true;
+    }
+    return st;
+}
+
+// Writer creation stays non-const because the backend interface also supports 
future stateful FFI
+// implementations.
+Status JniPaimonWriteBackend::create_writer( // 
NOLINT(readability-make-member-function-const)
+        std::unique_ptr<IPaimonWriter>* writer) {
+    DORIS_CHECK(_opened);
+    *writer = std::make_unique<JniPaimonWriter>(_jni_writer_obj, _write_id, 
_prepare_commit_id,
+                                                _abort_id, 
std::make_unique<ArrowMemoryPool<>>(),
+                                                _sink);
+    return Status::OK();
+}
+
+JniPaimonWriter::JniPaimonWriter(jobject jni_writer_obj, jmethodID write_id,
+                                 jmethodID prepare_commit_id, jmethodID 
abort_id,
+                                 std::unique_ptr<ArrowMemoryPool<>> arrow_pool,
+                                 TPaimonTableSink sink)
+        : _jni_writer_obj(jni_writer_obj),
+          _write_id(write_id),
+          _prepare_commit_id(prepare_commit_id),
+          _abort_id(abort_id),
+          _arrow_pool(std::move(arrow_pool)),
+          _sink(std::move(sink)) {}
+
+Status JniPaimonWriter::_write_projected_block(RuntimeState* state, Block& 
block) {
+    if (block.rows() == 0) {
+        return Status::OK();
+    }
+
+    // Use Thrift column_names as the authoritative schema source for both
+    // Arrow schema construction and Java-side write type derivation.
+    DORIS_CHECK(_sink.__isset.column_names);
+    DORIS_CHECK_EQ(_sink.column_names.size(), block.columns());
+    for (size_t i = 0; i < _sink.column_names.size(); ++i) {
+        block.get_by_position(i).name = _sink.column_names[i];
+    }
+
+    // Pipeline: Doris Block → Arrow Schema → Arrow RecordBatch → IPC Stream → 
JNI direct buffer
+    //
+    // Step 1: Build Arrow schema from the projected Block.
+    std::shared_ptr<arrow::Schema> arrow_schema;
+    RETURN_IF_ERROR(get_arrow_schema_from_block(block, &arrow_schema, 
state->timezone()));
+
+    // Step 2: Convert Doris Block columns to an Arrow RecordBatch.
+    std::shared_ptr<arrow::RecordBatch> record_batch;
+    RETURN_IF_ERROR(convert_to_arrow_batch(block, arrow_schema, 
_arrow_pool.get(), &record_batch,
+                                           state->timezone_obj()));
+
+    // Step 3: Serialize the RecordBatch to Arrow IPC Stream format in memory.
+    auto out_stream_res = arrow::io::BufferOutputStream::Create();

Review Comment:
   [P1] Allocate the IPC stream from the tracked pool
   
   The RecordBatch above uses `_arrow_pool`, but this overload allocates the 
IPC stream from Arrow's default pool. It creates another batch-sized native 
buffer while the Doris block and RecordBatch are still live, yet that memory 
bypasses the task/query tracker, so the query limit cannot stop concurrent 
large string/complex batches from pushing the BE into process OOM. Construct 
the stream with the tracked pool (and reserve the estimated size before 
allocation) so the query is rejected cleanly instead.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonTransaction.java:
##########
@@ -0,0 +1,327 @@
+// 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.datasource.paimon;
+
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.mvcc.MvccUtil;
+import 
org.apache.doris.nereids.trees.plans.commands.insert.InsertCommandContext;
+import 
org.apache.doris.nereids.trees.plans.commands.insert.PaimonInsertCommandContext;
+import org.apache.doris.thrift.TPaimonCommitMessage;
+import org.apache.doris.transaction.Transaction;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Lists;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.table.InnerTable;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageSerializer;
+import org.apache.paimon.table.sink.InnerTableCommit;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Paimon transaction.
+ *
+ * Lifecycle:
+ *   1. beginInsert() — record target table
+ *   2. updateCommitMessages() — called multiple times as BE reports commit 
data
+ *   3. commit() — deserialize all CommitMessages, call 
StreamTableCommit.filterAndCommit()
+ *   4. rollback() — abort uncommitted data files
+ *
+ * CommitMessage wire format:
+ *   BE ← JNI ← Java: byte[] with DPCM header (magic + version + length) + 
Paimon
+ *   CommitMessageSerializer payload
+ */
+public class PaimonTransaction implements Transaction {
+    private static final Logger LOG = 
LogManager.getLogger(PaimonTransaction.class);
+
+    private static final int COMMIT_HEADER_SIZE = 12;
+    private static final byte[] COMMIT_MAGIC = new byte[] {'D', 'P', 'C', 'M'};
+
+    private final PaimonMetadataOps ops;
+    private final long transactionId;
+    private final String commitUser;
+    private PaimonExternalTable table;
+    private boolean committed = false;
+    private boolean overwrite = false;
+    private Map<String, String> staticPartition = new HashMap<>();
+
+    private final List<byte[]> commitPayloads = Lists.newArrayList();
+    private final Set<String> commitPayloadSet = new HashSet<>();
+
+    public PaimonTransaction(PaimonMetadataOps ops, long transactionId) {
+        Preconditions.checkArgument(transactionId > 0, "Paimon transaction id 
must be positive");
+        this.ops = ops;
+        this.transactionId = transactionId;
+        this.commitUser = commitUser(transactionId);
+    }
+
+    // ────────────────────────────────────────────────────────────
+    // Transaction lifecycle
+    // ────────────────────────────────────────────────────────────
+
+    public void beginInsert(PaimonExternalTable table, 
Optional<InsertCommandContext> insertCtx) {
+        this.table = table;
+        PaimonInsertCommandContext context = (PaimonInsertCommandContext) 
insertCtx.orElseThrow(
+                () -> new IllegalStateException("Missing Paimon insert command 
context"));
+        this.overwrite = context.isOverwrite();
+        this.staticPartition = new HashMap<>(context.getStaticPartition());
+    }
+
+    public void finishInsert(PaimonExternalTable table, 
Optional<InsertCommandContext> insertCtx) {
+    }
+
+    @Override
+    public void commit() throws UserException {
+        List<byte[]> rawPayloads = snapshotPayloads();
+        if (rawPayloads.isEmpty() && !overwrite) {
+            LOG.info("Skip empty PaimonTransaction commit, txnId={}, table={}",
+                    transactionId, tableName());
+            return;
+        }
+        if (table == null) {
+            throw new UserException("Missing paimon table for transaction");
+        }
+        try {
+            List<CommitMessage> allMessages = deserializePayloads(rawPayloads);
+            LOG.info("Commit PaimonTransaction, txnId={}, table={}, 
payloads={}, messages={}, overwrite={}",
+                    transactionId, tableName(), rawPayloads.size(), 
allMessages.size(), overwrite);
+            if (allMessages.isEmpty() && !overwrite) {
+                throw new RuntimeException(
+                        "Paimon commit messages are empty, payloads=" + 
rawPayloads.size());
+            }
+            doCommit(allMessages);
+            synchronized (this) {
+                committed = true;
+            }
+        } catch (Exception e) {
+            throw new UserException("Failed to commit paimon transaction on 
FE", e);
+        }
+    }
+
+    @Override
+    public void rollback() {
+        if (isCommitted()) {
+            LOG.info("Skip rollback for committed PaimonTransaction, txnId={}, 
table={}",
+                    transactionId, tableName());
+            return;
+        }
+        List<byte[]> rawPayloads = snapshotPayloads();
+        if (rawPayloads.isEmpty()) {
+            LOG.info("Skip empty PaimonTransaction rollback, txnId={}, 
table={}",
+                    transactionId, tableName());
+            return;
+        }
+        if (table == null) {
+            LOG.warn("Skip PaimonTransaction rollback, table missing, 
txnId={}", transactionId);
+            return;
+        }
+        try {
+            List<CommitMessage> allMessages = deserializePayloads(rawPayloads);
+            if (allMessages.isEmpty()) {
+                LOG.info("Skip PaimonTransaction rollback with empty decoded 
messages, "
+                        + "txnId={}, table={}", transactionId, tableName());
+                return;
+            }
+            LOG.info("Rollback PaimonTransaction, txnId={}, table={}, 
payloads={}, messages={}",
+                    transactionId, tableName(), rawPayloads.size(), 
allMessages.size());
+            doAbort(allMessages);

Review Comment:
   [P1] Do not abort an outcome-unknown commit
   
   If `filterAndCommit()` exhausts its retries after an atomic-commit 
exception, `committed` is still false and 
`BaseExternalTableInsertExecutor.onFail()` immediately reaches this abort. 
Paimon explicitly treats that exception as outcome-unknown because the snapshot 
may already be published; `abort()` then deletes the new and compacted files 
referenced by that snapshot. Reconcile the commit user/identifier against the 
published snapshots before aborting, and preserve the files while the outcome 
remains unknown.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/insert/InsertOverwriteTableCommand.java:
##########
@@ -467,6 +482,23 @@ private void 
setStaticPartitionToContext(UnboundIcebergTableSink<?> sink,
         }
     }
 
+    private void setStaticPartitionToContext(UnboundPaimonTableSink<?> sink,
+            PaimonInsertCommandContext insertCtx) {
+        if (sink.hasStaticPartition()) {
+            Map<String, String> staticPartitionValues = Maps.newHashMap();
+            for (Map.Entry<String, Expression> entry
+                    : sink.getStaticPartitionKeyValues().entrySet()) {
+                Expression expr = entry.getValue();
+                if (!(expr instanceof Literal)) {
+                    throw new AnalysisException(
+                            String.format("Static partition value must be a 
literal, but got: %s", expr));
+                }
+                staticPartitionValues.put(entry.getKey(), ((Literal) 
expr).getStringValue());

Review Comment:
   [P2] Canonicalize static partition key spelling
   
   Paimon binding validates static partition names with a case-insensitive map, 
so `PARTITION(region='east')` is accepted for a field named `Region`; this 
stores the original `region` key in the transaction context. `withOverwrite()` 
later passes it to Paimon, whose `RowType.getField()` and 
`fieldNames.indexOf()` lookups are exact-case, so the valid Doris statement 
fails during commit. Resolve every key to the canonical `Column.getName()` 
during binding and reuse that map for row construction and overwrite.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindSink.java:
##########
@@ -878,6 +884,81 @@ private void 
validateStaticPartition(UnboundIcebergTableSink<?> sink, IcebergExt
         }
     }
 
+    private Plan 
bindPaimonTableSink(MatchingContext<UnboundPaimonTableSink<Plan>> ctx) {
+        UnboundPaimonTableSink<?> sink = ctx.root;
+        Pair<PaimonExternalDatabase, PaimonExternalTable> pair = 
bind(ctx.cascadesContext, sink);
+        PaimonExternalDatabase database = pair.first;
+        PaimonExternalTable table = pair.second;
+        LogicalPlan child = ((LogicalPlan) sink.child());
+
+        Map<String, Expression> staticPartitions = 
Maps.newTreeMap(String.CASE_INSENSITIVE_ORDER);
+        staticPartitions.putAll(sink.getStaticPartitionKeyValues());
+        Set<String> staticPartitionColNames = staticPartitions.keySet();
+        if (!staticPartitionColNames.isEmpty()) {
+            Set<String> partitionColumnNames = 
Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
+            
partitionColumnNames.addAll(table.getPartitionColumnNames(Optional.empty()));
+            for (String columnName : staticPartitionColNames) {
+                if (!partitionColumnNames.contains(columnName)) {
+                    throw new AnalysisException(String.format(
+                            "Column '%s' is not a partition column of Paimon 
table '%s'",
+                            columnName, table.getName()));
+                }
+                Expression partitionValue = staticPartitions.get(columnName);
+                if (!(partitionValue instanceof Literal)) {
+                    throw new AnalysisException(String.format(
+                            "Partition value for column '%s' must be a 
literal, but got: %s",
+                            columnName, partitionValue));
+                }
+            }
+        }
+
+        List<Column> bindColumns;
+        if (sink.getColNames().isEmpty()) {
+            bindColumns = table.getBaseSchema(true).stream()
+                    .filter(Column::isVisible)
+                    .filter(column -> 
!staticPartitionColNames.contains(column.getName()))
+                    .collect(ImmutableList.toImmutableList());
+        } else {
+            bindColumns = sink.getColNames().stream().map(cn -> {
+                if (staticPartitionColNames.contains(cn)) {
+                    throw new AnalysisException(String.format(
+                            "Static partition column '%s' must not appear in 
the insert column list", cn));
+                }
+                Column column = table.getColumn(cn);
+                if (column == null) {
+                    throw new AnalysisException(String.format(
+                            "column %s is not found in table %s", cn, 
table.getName()));
+                }
+                return column;
+            }).collect(ImmutableList.toImmutableList());
+        }
+
+        if (bindColumns.size() != child.getOutput().size()) {
+            throw new AnalysisException("insert into cols should be 
corresponding to the query output");
+        }
+        Map<String, NamedExpression> columnToOutput = 
getSpecifiedColumnToOutput(bindColumns, child);

Review Comment:
   [P1] Reject duplicate Paimon target columns
   
   The case-insensitive map overwrites an earlier occurrence, but the coercion 
project iterates `writeColumns` and re-emits the surviving slot for every 
duplicate, so the sink's column/output size check still passes. Java then maps 
both names to one table index, leaves the omitted field null, and treats the 
write as full solely because the list length equals the table field count; on a 
PK table this bypasses the partial-write guard and can commit the wrong row. 
Reject duplicate target names before building this map, and validate unique 
full-schema coverage in `PaimonWriteSchema`.



-- 
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]

Reply via email to