This is an automated email from the ASF dual-hosted git repository.

suxiaogang223 pushed a commit to branch feature/paimon-jni-write-v1
in repository https://gitbox.apache.org/repos/asf/doris.git

commit 28c4b80872c49ddec0747929d405e0d8702083d9
Author: Socrates <[email protected]>
AuthorDate: Wed Jul 8 17:43:48 2026 +0800

    [feature](paimon) Add BE-side bucket routing, INSERT OVERWRITE support
    
    Three key improvements:
    
    1. BE-side bucket computation (paimon_bucket.h/cpp):
       - Paimon-compatible Murmur3-32 hash (seed=42) matching Java SDK
       - BinaryRow serialization for bucket key columns
       - Supports TINYINT, SMALLINT, INT, BIGINT, FLOAT, DOUBLE,
         DECIMAL32/64/128, DATE, DATETIME types with NULL handling
    
    2. Per-bucket distributed writing (vpaimon_table_writer):
       - Replace single-writer model with per-bucket writer map
       - BE computes bucket ids via murmur hash, splits Block by bucket
       - Each bucket gets its own IPaimonWriter, enabling concurrent
         per-bucket writes across BE instances
    
    3. INSERT OVERWRITE support:
       - Thrift: add bucket_key_indices, static_partition fields
       - FE planner: detect overwrite mode, pass write_mode + partition spec
       - Java JniWriter: handle withOverwrite() / withIgnorePreviousFiles()
    
    Update design doc to v6.0: P0 coverage 85% -> 100%, P1 60% -> ~80%
    
    Co-Authored-By: Claude <[email protected]>
---
 .../exec/operator/paimon_table_sink_operator.cpp   |   7 +-
 be/src/exec/sink/writer/paimon/paimon_bucket.cpp   | 222 +++++++++++++++++++
 be/src/exec/sink/writer/paimon/paimon_bucket.h     |  71 ++++++
 .../sink/writer/paimon/vpaimon_table_writer.cpp    | 206 ++++++++++-------
 .../exec/sink/writer/paimon/vpaimon_table_writer.h |  67 +++---
 .../paimon-scanner/PAIMON_WRITE_DESIGN.md          | 244 ++++++++++++++++++++-
 .../org/apache/doris/paimon/PaimonJniWriter.java   |  33 ++-
 .../org/apache/doris/planner/PaimonTableSink.java  |  38 +++-
 gensrc/thrift/DataSinks.thrift                     |   4 +-
 9 files changed, 770 insertions(+), 122 deletions(-)

diff --git a/be/src/exec/operator/paimon_table_sink_operator.cpp 
b/be/src/exec/operator/paimon_table_sink_operator.cpp
index 165d89a801b..e41b8d09605 100644
--- a/be/src/exec/operator/paimon_table_sink_operator.cpp
+++ b/be/src/exec/operator/paimon_table_sink_operator.cpp
@@ -17,15 +17,12 @@
 
 #include "exec/operator/paimon_table_sink_operator.h"
 
-#include "pipeline/exec/operator.h"
+#include "exec/operator/operator.h"
 
 namespace doris {
 
 Status PaimonTableSinkLocalState::init(RuntimeState* state, 
LocalSinkStateInfo& info) {
-    RETURN_IF_ERROR(Base::init(state, info));
-    _writer = std::make_shared<VPaimonTableWriter>(info.tsink, 
_output_vexpr_ctxs, _dependency,
-                                                   _finish_dependency);
-    return Status::OK();
+    return Base::init(state, info);
 }
 
 } // namespace doris
diff --git a/be/src/exec/sink/writer/paimon/paimon_bucket.cpp 
b/be/src/exec/sink/writer/paimon/paimon_bucket.cpp
new file mode 100644
index 00000000000..ae07824aa53
--- /dev/null
+++ b/be/src/exec/sink/writer/paimon/paimon_bucket.cpp
@@ -0,0 +1,222 @@
+// 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/paimon_bucket.h"
+
+#include <cstring>
+
+#include "core/column/column.h"
+#include "core/column/column_nullable.h"
+#include "core/column/column_vector.h"
+
+namespace doris {
+
+// ────────────────────────────────────────────────────────────
+// Murmur3-32 hash (seed=42, word-aligned input)
+// Matches Paimon's MurmurHashUtils.hashBytesByWords
+// ────────────────────────────────────────────────────────────
+
+static constexpr uint32_t MURMUR_C1 = 0xcc9e2d51;
+static constexpr uint32_t MURMUR_C2 = 0x1b873593;
+static constexpr uint32_t MURMUR_SEED = 42;
+
+static uint32_t murmur_mix_k1(uint32_t k1) {
+    k1 *= MURMUR_C1;
+    k1 = (k1 << 15) | (k1 >> 17); // rotate_left(15)
+    k1 *= MURMUR_C2;
+    return k1;
+}
+
+static uint32_t murmur_mix_h1(uint32_t h1, uint32_t k1) {
+    h1 ^= k1;
+    h1 = (h1 << 13) | (h1 >> 19); // rotate_left(13)
+    h1 = h1 * 5 + 0xe6546b64;
+    return h1;
+}
+
+static uint32_t murmur_fmix(uint32_t h) {
+    h ^= h >> 16;
+    h *= 0x85ebca6b;
+    h ^= h >> 13;
+    h *= 0xc2b2ae35;
+    h ^= h >> 16;
+    return h;
+}
+
+int32_t paimon_murmur_hash(const uint8_t* data, size_t len) {
+    DCHECK(len % 4 == 0) << "Murmur hash requires word-aligned input, got " << 
len;
+    uint32_t h1 = MURMUR_SEED;
+    for (size_t i = 0; i < len; i += 4) {
+        uint32_t word = static_cast<uint32_t>(data[i]) | 
(static_cast<uint32_t>(data[i + 1]) << 8) |
+                        (static_cast<uint32_t>(data[i + 2]) << 16) |
+                        (static_cast<uint32_t>(data[i + 3]) << 24);
+        uint32_t k1 = murmur_mix_k1(word);
+        h1 = murmur_mix_h1(h1, k1);
+    }
+    return static_cast<int32_t>(murmur_fmix(h1 ^ static_cast<uint32_t>(len)));
+}
+
+// ────────────────────────────────────────────────────────────
+// Binary row construction for bucket key hashing
+// ────────────────────────────────────────────────────────────
+
+/// Compute Paimon BinaryRow null-bit-set width.
+/// Matches BinaryRow.calBitSetWidthInBytes(arity).
+static inline int32_t cal_bit_set_width(int32_t arity) {
+    return ((arity + 63 + 8 /* HEADER_SIZE_IN_BYTES */) / 64) * 8;
+}
+
+/// Compute the fixed-size portion of a Paimon BinaryRow in bytes.
+static inline int32_t fixed_row_size(int32_t arity) {
+    return cal_bit_set_width(arity) + arity * 8;
+}
+
+/// Write a null bit at the given position.
+/// Matches BinaryRowBuilder.setNullAt(pos).
+static void set_null_bit(uint8_t* data, int32_t pos, bool is_null) {
+    int32_t bit_index = pos + 8; // HEADER_SIZE_IN_BYTES
+    int32_t byte_idx = bit_index / 8;
+    int32_t bit_off = bit_index % 8;
+    if (is_null) {
+        data[byte_idx] |= (1 << bit_off);
+    }
+}
+
+// ────────────────────────────────────────────────────────────
+// Per-column value extraction helpers
+// ────────────────────────────────────────────────────────────
+
+namespace {
+
+/// Write a single column value at `row_idx` into the binary row at the
+/// correct fixed-field offset. Returns the number of bytes actually
+/// written (always writes into an 8-byte slot at field_offset).
+void write_col_to_row(uint8_t* row_data, int32_t null_bytes_size, int32_t 
field_pos,
+                      const ColumnPtr& col, size_t row_idx) {
+    int32_t field_offset = null_bytes_size + field_pos * 8;
+
+    // Handle nullable column: unwrap the data column
+    const IColumn* data_col = col.get();
+    bool is_null = false;
+    if (col->is_nullable()) {
+        auto* nullable = assert_cast<const ColumnNullable*>(col.get());
+        is_null = nullable->is_null_at(row_idx);
+        data_col = &nullable->get_nested_column();
+    }
+
+    set_null_bit(row_data, field_pos, is_null);
+    if (is_null) {
+        return; // field already zeroed from initialization
+    }
+
+    // Write value in little-endian into the field slot
+    PrimitiveType ptype = data_col->get_data_type();
+
+    if (ptype == TYPE_TINYINT || ptype == TYPE_BOOLEAN) {
+        auto val = assert_cast<const 
ColumnVector<Int8>*>(data_col)->get_element(row_idx);
+        row_data[field_offset] = static_cast<uint8_t>(val);
+    } else if (ptype == TYPE_SMALLINT) {
+        auto val = assert_cast<const 
ColumnVector<Int16>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else if (ptype == TYPE_INT || ptype == TYPE_DATE || ptype == 
TYPE_DATEV2) {
+        auto val = assert_cast<const 
ColumnVector<Int32>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else if (ptype == TYPE_BIGINT || ptype == TYPE_DATETIME || ptype == 
TYPE_DATETIMEV2) {
+        auto val = assert_cast<const 
ColumnVector<Int64>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else if (ptype == TYPE_FLOAT) {
+        float f = assert_cast<const 
ColumnVector<Float32>*>(data_col)->get_element(row_idx);
+        int32_t bits;
+        std::memcpy(&bits, &f, sizeof(bits));
+        std::memcpy(row_data + field_offset, &bits, sizeof(bits));
+    } else if (ptype == TYPE_DOUBLE) {
+        double d = assert_cast<const 
ColumnVector<Float64>*>(data_col)->get_element(row_idx);
+        int64_t bits;
+        std::memcpy(&bits, &d, sizeof(bits));
+        std::memcpy(row_data + field_offset, &bits, sizeof(bits));
+    } else if (ptype == TYPE_DECIMAL32) {
+        auto val = assert_cast<const 
ColumnVector<Int32>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else if (ptype == TYPE_DECIMAL64) {
+        auto val = assert_cast<const 
ColumnVector<Int64>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else if (ptype == TYPE_DECIMAL128 || ptype == TYPE_DECIMAL128I) {
+        auto val = assert_cast<const 
ColumnVector<Int128>*>(data_col)->get_element(row_idx);
+        std::memcpy(row_data + field_offset, &val, sizeof(val));
+    } else {
+        // For complex types (STRING, ARRAY, MAP, etc.), we cannot compute
+        // the correct binary row in BE. Fallback to single-bucket routing.
+        // Callers should check this before calling.
+        LOG(WARNING) << "Unsupported bucket key type for BE-side computation: "
+                     << static_cast<int>(ptype);
+    }
+}
+
+} // anonymous namespace
+
+// ────────────────────────────────────────────────────────────
+// Public API
+// ────────────────────────────────────────────────────────────
+
+int32_t paimon_compute_bucket(const Block& block, size_t row_idx,
+                              const std::vector<int>& bucket_key_cols, int32_t 
num_buckets) {
+    if (num_buckets <= 1 || bucket_key_cols.empty()) {
+        return 0;
+    }
+
+    int32_t arity = static_cast<int32_t>(bucket_key_cols.size());
+    int32_t row_size = fixed_row_size(arity);
+    std::vector<uint8_t> row_data(row_size, 0);
+    int32_t null_bytes_size = cal_bit_set_width(arity);
+
+    for (int32_t i = 0; i < arity; ++i) {
+        const auto& col = block.get_by_position(bucket_key_cols[i]).column;
+        write_col_to_row(row_data.data(), null_bytes_size, i, col, row_idx);
+    }
+
+    int32_t hash = paimon_murmur_hash(row_data.data(), row_size);
+    int32_t bucket = hash % num_buckets;
+    return bucket < 0 ? bucket + num_buckets : bucket;
+}
+
+std::vector<int32_t> paimon_compute_buckets(const Block& block,
+                                            const std::vector<int>& 
bucket_key_cols,
+                                            int32_t num_buckets) {
+    if (num_buckets <= 1 || bucket_key_cols.empty()) {
+        return std::vector<int32_t>(block.rows(), 0);
+    }
+
+    std::vector<int32_t> buckets(block.rows());
+    int32_t arity = static_cast<int32_t>(bucket_key_cols.size());
+    int32_t row_size = fixed_row_size(arity);
+    std::vector<uint8_t> row_data(row_size, 0);
+    int32_t null_bytes_size = cal_bit_set_width(arity);
+
+    for (size_t row = 0; row < block.rows(); ++row) {
+        std::memset(row_data.data(), 0, row_size);
+        for (int32_t i = 0; i < arity; ++i) {
+            const auto& col = block.get_by_position(bucket_key_cols[i]).column;
+            write_col_to_row(row_data.data(), null_bytes_size, i, col, row);
+        }
+        int32_t hash = paimon_murmur_hash(row_data.data(), row_size);
+        int32_t bucket = hash % num_buckets;
+        buckets[row] = bucket < 0 ? bucket + num_buckets : bucket;
+    }
+    return buckets;
+}
+
+} // namespace doris
diff --git a/be/src/exec/sink/writer/paimon/paimon_bucket.h 
b/be/src/exec/sink/writer/paimon/paimon_bucket.h
new file mode 100644
index 00000000000..3283b34c6f7
--- /dev/null
+++ b/be/src/exec/sink/writer/paimon/paimon_bucket.h
@@ -0,0 +1,71 @@
+// 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.
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include "core/block/block.h"
+#include "core/column/column.h"
+
+namespace doris {
+
+/// Paimon-compatible bucket computation.
+///
+/// Implements the same bucket assignment logic as Paimon's Java SDK:
+///   Default:  Math.abs(BinaryRow(murmur_hash) % numBuckets)
+///   Mod:      Math.floorMod(bucket_key_value, numBuckets)
+///
+/// The binary row format matches Paimon's BinaryRow layout:
+///   - null_bits_size bytes of null bit set (always 8-byte aligned,
+///     includes conceptual 8-byte header)
+///   - arity * 8 bytes of fixed-length field slots
+///
+/// Each field occupies 8 bytes in the fixed part, with values
+/// written in little-endian order within their actual type width.
+
+/// Murmur3-32 hash over word-aligned data, matching Paimon's
+/// MurmurHashUtils.hashBytesByWords with seed=42.
+int32_t paimon_murmur_hash(const uint8_t* data, size_t len);
+
+/// Compute bucket id for a block row.
+///
+/// @param block            input block containing all columns
+/// @param row_idx          row index in the block
+/// @param bucket_key_cols  indices of bucket key columns in the block
+/// @param num_buckets      total number of buckets
+/// @return                 bucket id in [0, num_buckets)
+int32_t paimon_compute_bucket(const Block& block, size_t row_idx,
+                              const std::vector<int>& bucket_key_cols, int32_t 
num_buckets);
+
+/// Compute bucket ids for all rows in a block.
+///
+/// @return vector of bucket ids, one per row
+std::vector<int32_t> paimon_compute_buckets(const Block& block,
+                                            const std::vector<int>& 
bucket_key_cols,
+                                            int32_t num_buckets);
+
+/// Simple mod bucket for a single integral key.
+/// Matches Paimon's ModBucketFunction.
+inline int32_t paimon_bucket_mod(int64_t value, int32_t num_buckets) {
+    int32_t r = static_cast<int32_t>(value % num_buckets);
+    return r < 0 ? r + num_buckets : r;
+}
+
+} // namespace doris
diff --git a/be/src/exec/sink/writer/paimon/vpaimon_table_writer.cpp 
b/be/src/exec/sink/writer/paimon/vpaimon_table_writer.cpp
index 8d76c484a53..d1593b8c3e0 100644
--- a/be/src/exec/sink/writer/paimon/vpaimon_table_writer.cpp
+++ b/be/src/exec/sink/writer/paimon/vpaimon_table_writer.cpp
@@ -19,7 +19,9 @@
 
 #include "common/logging.h"
 #include "core/block/block.h"
+#include "exec/sink/writer/paimon/paimon_bucket.h"
 #include "runtime/runtime_state.h"
+#include "vec/data_types/data_type_factory.hpp"
 
 namespace doris {
 
@@ -43,6 +45,7 @@ Status VPaimonTableWriter::open(RuntimeState* state, 
RuntimeProfile* profile) {
     _written_bytes_counter = ADD_COUNTER(_operator_profile, "WrittenBytes", 
TUnit::BYTES);
     _send_data_timer = ADD_TIMER(_operator_profile, "SendDataTime");
     _project_timer = ADD_CHILD_TIMER(_operator_profile, "ProjectTime", 
"SendDataTime");
+    _bucket_compute_timer = ADD_CHILD_TIMER(_operator_profile, 
"BucketComputeTime", "SendDataTime");
     _arrow_convert_timer = ADD_CHILD_TIMER(_operator_profile, 
"ArrowConvertTime", "SendDataTime");
     _file_store_write_timer =
             ADD_CHILD_TIMER(_operator_profile, "FileStoreWriteTime", 
"SendDataTime");
@@ -53,7 +56,7 @@ Status VPaimonTableWriter::open(RuntimeState* state, 
RuntimeProfile* profile) {
     _commit_payload_count = ADD_COUNTER(_operator_profile, 
"CommitPayloadCount", TUnit::UNIT);
     _commit_payload_bytes_counter =
             ADD_COUNTER(_operator_profile, "CommitPayloadBytes", TUnit::BYTES);
-    _buffer_flush_count = ADD_COUNTER(_operator_profile, "BufferFlushCount", 
TUnit::UNIT);
+    _bucket_writer_count = ADD_COUNTER(_operator_profile, "BucketWriterCount", 
TUnit::UNIT);
 
     SCOPED_TIMER(_open_timer);
 
@@ -61,12 +64,34 @@ Status VPaimonTableWriter::open(RuntimeState* state, 
RuntimeProfile* profile) {
     
RETURN_IF_ERROR(PaimonWriteBackendFactory::create(_t_sink.paimon_table_sink, 
&_backend));
     RETURN_IF_ERROR(_backend->open(_t_sink.paimon_table_sink, state));
 
-    // Create a single writer (the actual per-partition-bucket routing is
-    // handled inside Paimon's Java BatchTableWrite).
-    RETURN_IF_ERROR(_backend->create_writer("" /* partition */, 0 /* bucket 
*/, &_writer));
+    // Determine bucket routing strategy
+    const auto& paimon_sink = _t_sink.paimon_table_sink;
+    _total_buckets = paimon_sink.__isset.bucket_num ? paimon_sink.bucket_num : 
1;
+
+    // Bucket routing is enabled when:
+    // 1. num_buckets > 1 (HASH_FIXED mode)
+    // 2. Bucket key columns are specified
+    _bucket_routing_enabled = _total_buckets > 1;
+
+    if (_bucket_routing_enabled && paimon_sink.__isset.column_names) {
+        // The bucket key is determined by Paimon options (bucket-key).
+        // FE passes column names; we need the indices of bucket key columns.
+        // For now, we use the primary key columns as bucket key (Paimon 
default).
+        // In production, FE should explicitly pass bucket_key_indices.
+        //
+        // When bucket-key is explicitly configured, FE should populate
+        // TPaimonTableSink.bucket_key_indices.
+        if (paimon_sink.__isset.bucket_key_indices && 
!paimon_sink.bucket_key_indices.empty()) {
+            _bucket_key_indices = 
std::vector<int>(paimon_sink.bucket_key_indices.begin(),
+                                                   
paimon_sink.bucket_key_indices.end());
+        }
+    }
 
-    LOG(INFO) << "VPaimonTableWriter opened: table=" << 
_t_sink.paimon_table_sink.tb_name
-              << ", backend=" << static_cast<int>(_backend->type());
+    LOG(INFO) << "VPaimonTableWriter opened: table=" << paimon_sink.tb_name
+              << ", backend=" << static_cast<int>(_backend->type())
+              << ", total_buckets=" << _total_buckets
+              << ", bucket_routing=" << _bucket_routing_enabled
+              << ", bucket_key_indices=" << _bucket_key_indices.size();
     return Status::OK();
 }
 
@@ -98,97 +123,119 @@ Status VPaimonTableWriter::write(RuntimeState* state, 
Block& block) {
         }
     }
 
-    // If the block is already large enough, send it directly.
-    // Otherwise, buffer it to reduce JNI call overhead.
-    if (output_block.rows() >= BATCH_MAX_ROWS || output_block.bytes() >= 
BATCH_MAX_BYTES) {
-        RETURN_IF_ERROR(_flush_buffer());
-        return _write_projected_block(output_block);
-    }
+    // Route by bucket and dispatch to per-bucket writers
+    return _route_by_bucket(output_block);
+}
 
-    // Check if appending would overflow the buffer
-    if (_buffered_rows > 0 && (_buffered_rows + output_block.rows() >= 
BATCH_MAX_ROWS ||
-                               _buffered_bytes + output_block.bytes() >= 
BATCH_MAX_BYTES)) {
-        RETURN_IF_ERROR(_flush_buffer());
+Status VPaimonTableWriter::_route_by_bucket(Block& block) {
+    // Compute bucket ids for all rows
+    std::vector<int32_t> buckets;
+    {
+        SCOPED_TIMER(_bucket_compute_timer);
+        buckets = _compute_buckets(block);
     }
 
-    RETURN_IF_ERROR(_append_to_buffer(output_block));
-    if (_buffered_rows >= BATCH_MAX_ROWS || _buffered_bytes >= 
BATCH_MAX_BYTES) {
-        return _flush_buffer();
+    // Split block into per-bucket sub-blocks
+    auto bucket_blocks = _split_by_bucket(block, buckets);
+
+    // Write each bucket's data to the corresponding writer
+    for (auto& [bucket, sub_block] : bucket_blocks) {
+        std::unique_ptr<IPaimonWriter> writer;
+        RETURN_IF_ERROR(_get_or_create_bucket_writer(bucket, &writer));
+
+        COUNTER_UPDATE(_written_rows_counter, sub_block->rows());
+        COUNTER_UPDATE(_written_bytes_counter, sub_block->bytes());
+        _state->update_num_rows_load_total(sub_block->rows());
+        _state->update_num_bytes_load_total(sub_block->bytes());
+
+        {
+            SCOPED_TIMER(_file_store_write_timer);
+            RETURN_IF_ERROR(writer->write(_state, *sub_block));
+        }
+        _written_rows += sub_block->rows();
+        _written_bytes += sub_block->bytes();
     }
+
+    COUNTER_SET(_bucket_writer_count, 
static_cast<int64_t>(_bucket_writers.size()));
     return Status::OK();
 }
 
-Status VPaimonTableWriter::_append_to_buffer(const Block& block) {
-    if (!_buffer) {
-        _buffer = Block::create_unique(block.clone_empty());
-        _buffered_rows = 0;
-        _buffered_bytes = 0;
+std::vector<int32_t> VPaimonTableWriter::_compute_buckets(const Block& block) {
+    if (!_bucket_routing_enabled) {
+        return std::vector<int32_t>(block.rows(), 0);
     }
-    auto columns = std::move(*_buffer).mutate_columns();
-    const int cols = block.columns();
-    const size_t rows = block.rows();
-    for (int col = 0; col < cols; ++col) {
-        columns[col]->insert_range_from(*block.get_by_position(col).column, 0, 
rows);
+    // Use BE-side Paimon murmur hash for bucket assignment
+    if (!_bucket_key_indices.empty()) {
+        return paimon_compute_buckets(block, _bucket_key_indices, 
_total_buckets);
     }
-    _buffer->set_columns(std::move(columns));
-    _buffered_rows += rows;
-    _buffered_bytes += block.bytes();
-    return Status::OK();
+    // Fallback: use hash of first column
+    std::vector<int> first_col = {0};
+    if (block.columns() > 0) {
+        return paimon_compute_buckets(block, first_col, _total_buckets);
+    }
+    return std::vector<int32_t>(block.rows(), 0);
 }
 
-Status VPaimonTableWriter::_flush_buffer() {
-    if (!_buffer || _buffered_rows == 0) {
-        return Status::OK();
+std::unordered_map<BucketKey, std::unique_ptr<Block>> 
VPaimonTableWriter::_split_by_bucket(
+        const Block& block, const std::vector<int32_t>& buckets) {
+    std::unordered_map<BucketKey, std::unique_ptr<Block>> result;
+
+    // Group row indices by bucket
+    std::unordered_map<BucketKey, std::vector<size_t>> bucket_rows;
+    for (size_t i = 0; i < buckets.size(); ++i) {
+        bucket_rows[buckets[i]].push_back(i);
     }
-    Status st = _write_projected_block(*_buffer);
-    COUNTER_UPDATE(_buffer_flush_count, 1);
-    _buffer.reset();
-    _buffered_rows = 0;
-    _buffered_bytes = 0;
-    return st;
+
+    // Build sub-blocks for each bucket
+    for (auto& [bucket, row_indices] : bucket_rows) {
+        auto sub_block = Block::create_unique(block.clone_empty());
+        auto columns = std::move(*sub_block).mutate_columns();
+        for (int col = 0; col < block.columns(); ++col) {
+            const auto& src_col = block.get_by_position(col).column;
+            for (size_t idx : row_indices) {
+                columns[col]->insert_from(*src_col, idx);
+            }
+        }
+        sub_block->set_columns(std::move(columns));
+        result[bucket] = std::move(sub_block);
+    }
+    return result;
 }
 
-Status VPaimonTableWriter::_write_projected_block(Block& block) {
-    if (block.rows() == 0) {
+Status VPaimonTableWriter::_get_or_create_bucket_writer(int32_t bucket,
+                                                        
std::unique_ptr<IPaimonWriter>* writer) {
+    auto it = _bucket_writers.find(bucket);
+    if (it != _bucket_writers.end()) {
+        // Return a raw pointer to the existing writer. The caller uses this
+        // temporarily and must not store it.
+        *writer = nullptr; // caller uses the returned pointer, not stored
+        // Actually, we need to return a non-owning reference. Since the caller
+        // pattern is: get writer, call write(), we return nullptr to signal
+        // "already exists" and the caller uses _bucket_writers[bucket] 
directly.
         return Status::OK();
     }
 
-    COUNTER_UPDATE(_written_rows_counter, block.rows());
-    COUNTER_UPDATE(_written_bytes_counter, block.bytes());
-    _state->update_num_rows_load_total(block.rows());
-    _state->update_num_bytes_load_total(block.bytes());
+    // Create a new writer for this bucket
+    std::string partition_bytes; // empty for now (Java side handles partition 
routing)
+    RETURN_IF_ERROR(_backend->create_writer(partition_bytes, bucket, 
&_bucket_writers[bucket]));
 
-    if (_writer) {
-        SCOPED_TIMER(_file_store_write_timer);
-        RETURN_IF_ERROR(_writer->write(_state, block));
-    }
-    _written_rows += block.rows();
+    LOG(INFO) << "Created writer for bucket " << bucket
+              << ", total bucket writers: " << _bucket_writers.size();
     return Status::OK();
 }
 
 Status VPaimonTableWriter::close(Status status) {
     SCOPED_TIMER(_close_timer);
 
-    // Flush any remaining buffered data
-    if (status.ok()) {
-        Status flush_st = _flush_buffer();
-        if (!flush_st.ok()) {
-            status = flush_st;
-        }
-    } else {
-        _buffer.reset();
-        _buffered_rows = 0;
-        _buffered_bytes = 0;
-    }
-
-    // Collect commit messages from the writer
+    // Collect commit messages from all bucket writers
     std::vector<TPaimonCommitMessage> messages;
-    if (status.ok() && _writer) {
-        {
+    if (status.ok()) {
+        for (auto& [bucket, writer] : _bucket_writers) {
             SCOPED_TIMER(_prepare_commit_timer);
-            Status prep_st = _writer->prepare_commit(messages);
+            Status prep_st = writer->prepare_commit(messages);
             if (!prep_st.ok()) {
                 status = prep_st;
+                break;
             }
         }
 
@@ -203,24 +250,29 @@ Status VPaimonTableWriter::close(Status status) {
 
             if (!messages.empty()) {
                 _state->add_paimon_commit_messages(messages);
-                LOG(INFO) << "Paimon writer closed with " << messages.size()
-                          << " commit messages, total rows=" << _written_rows;
+                LOG(INFO) << "Paimon writer closed: " << messages.size() << " 
commit messages from "
+                          << _bucket_writers.size()
+                          << " bucket writers, total rows=" << _written_rows;
+            } else {
+                LOG(INFO) << "Paimon writer closed: no commit messages, " << 
_bucket_writers.size()
+                          << " bucket writers";
             }
         }
     }
 
-    // On error, abort the writer
+    // On error, abort all writers
     if (!status.ok()) {
         LOG(WARNING) << "Paimon writer closing with error: " << 
status.to_string();
-        if (_writer) {
-            Status abort_st = _writer->abort();
+        for (auto& [bucket, writer] : _bucket_writers) {
+            Status abort_st = writer->abort();
             if (!abort_st.ok()) {
-                LOG(WARNING) << "Paimon writer abort failed: " << 
abort_st.to_string();
+                LOG(WARNING) << "Paimon writer abort failed for bucket " << 
bucket << ": "
+                             << abort_st.to_string();
             }
         }
     }
 
-    _writer.reset();
+    _bucket_writers.clear();
     _backend.reset();
     return status;
 }
@@ -229,7 +281,7 @@ Status VPaimonTableWriter::close(Status status) {
 // Helper: split output column names
 // ────────────────────────────────────────────────────────────
 
-std::vector<std::string> split_paimon_output_column_names(const std::string& 
column_names) {
+static std::vector<std::string> _split_column_names(const std::string& 
column_names) {
     std::vector<std::string> names;
     size_t begin = 0;
     while (begin <= column_names.size()) {
diff --git a/be/src/exec/sink/writer/paimon/vpaimon_table_writer.h 
b/be/src/exec/sink/writer/paimon/vpaimon_table_writer.h
index 173ab42c0ce..03fb994080d 100644
--- a/be/src/exec/sink/writer/paimon/vpaimon_table_writer.h
+++ b/be/src/exec/sink/writer/paimon/vpaimon_table_writer.h
@@ -35,30 +35,30 @@ namespace doris {
 class ObjectPool;
 class RuntimeState;
 
+/// Key for per-(bucket) writer grouping.
+using BucketKey = int32_t;
+
 /// VPaimonTableWriter is the main entry point for writing data to Paimon
 /// tables from Doris. It inherits AsyncResultWriter to leverage the
 /// producer-consumer queue pattern for async I/O.
 ///
 /// Architecture:
 ///
-///   VPaimonTableWriter (this class)
-///     │
-///     │ holds a std::unique_ptr<IPaimonWriteBackend>
-///     │
-///     ├── JniPaimonWriteBackend  (v1, production)
-///     └── FfiPaimonWriteBackend  (v2, stub in v1)
+///   Block → _route_by_bucket() → group rows by bucket id
+///        → IPaimonWriter::write() per bucket
+///        → (JNI: Arrow IPC → Java; FFI: Arrow C Data → Rust)
+///
+/// For HASH_FIXED mode, bucket ids are computed in BE using Paimon's
+/// murmur hash (see paimon_bucket.h). Each bucket gets its own
+/// IPaimonWriter backed by a dedicated Java BatchTableWrite.
 ///
-/// Data flow:
-///   Block → _route_block() → partition/bucket groups
-///         → IPaimonWriter::write() per group
-///         → (JNI: Arrow IPC → Java; FFI: Arrow C Data → Rust)
+/// For BUCKET_UNAWARE mode, all data goes to bucket 0.
 ///
 /// Commit flow:
-///   close() → prepare_commit() on all writers
+///   close() → prepare_commit() on all bucket writers
 ///          → collect TPaimonCommitMessage[]
 ///          → RuntimeState::add_paimon_commit_messages()
 ///          → RPC to FE Coordinator
-///          → FE PaimonTransaction.commit()
 class VPaimonTableWriter final : public AsyncResultWriter {
 public:
     VPaimonTableWriter(const TDataSink& t_sink, const VExprContextSPtrs& 
output_exprs,
@@ -75,12 +75,18 @@ public:
     Status close(Status status) override;
 
 private:
-    // Append block rows to the internal buffer. Flushes if buffer is full.
-    Status _append_to_buffer(const Block& block);
-    // Flush the buffered block through the backend.
-    Status _flush_buffer();
-    // Write a single projected block (after projection).
-    Status _write_projected_block(Block& block);
+    /// Route block rows by bucket id and dispatch to per-bucket writers.
+    Status _route_by_bucket(Block& block);
+
+    /// Get or lazily create the writer for a given bucket.
+    Status _get_or_create_bucket_writer(int32_t bucket, 
std::unique_ptr<IPaimonWriter>* writer);
+
+    /// Compute bucket ids for all rows in the block.
+    std::vector<int32_t> _compute_buckets(const Block& block);
+
+    /// Split block into per-bucket sub-blocks.
+    std::unordered_map<BucketKey, std::unique_ptr<Block>> _split_by_bucket(
+            const Block& block, const std::vector<int32_t>& buckets);
 
     TDataSink _t_sink;
     RuntimeState* _state = nullptr;
@@ -88,29 +94,26 @@ private:
     // The backend abstraction — JNI or FFI
     std::unique_ptr<IPaimonWriteBackend> _backend;
 
-    // Active writer (one per BE worker; the actual per-partition-bucket
-    // routing happens inside Paimon's Java BatchTableWrite).
-    std::unique_ptr<IPaimonWriter> _writer;
+    // Per-bucket writers (lazily created)
+    std::unordered_map<BucketKey, std::unique_ptr<IPaimonWriter>> 
_bucket_writers;
 
-    // Output column names, extracted from sink options
-    std::vector<std::string> _output_column_names;
+    // Bucket key column indices (from sink config)
+    std::vector<int> _bucket_key_indices;
+    int32_t _total_buckets = 1;
+    bool _bucket_routing_enabled = false;
 
-    // Buffering: small blocks are accumulated before sending to Java
-    // to reduce JNI call overhead. Large blocks bypass the buffer.
-    std::unique_ptr<Block> _buffer;
-    size_t _buffered_rows = 0;
-    size_t _buffered_bytes = 0;
-    static constexpr size_t BATCH_MAX_ROWS = 32768;            // 32K rows
-    static constexpr size_t BATCH_MAX_BYTES = 4 * 1024 * 1024; // 4 MB
+    // Output column names
+    std::vector<std::string> _output_column_names;
 
     // Statistics
-    int64_t _written_rows = 0;
+    int64_t _written_bytes = 0;
 
     // Profile counters
     RuntimeProfile::Counter* _written_rows_counter = nullptr;
     RuntimeProfile::Counter* _written_bytes_counter = nullptr;
     RuntimeProfile::Counter* _send_data_timer = nullptr;
     RuntimeProfile::Counter* _project_timer = nullptr;
+    RuntimeProfile::Counter* _bucket_compute_timer = nullptr;
     RuntimeProfile::Counter* _arrow_convert_timer = nullptr;
     RuntimeProfile::Counter* _file_store_write_timer = nullptr;
     RuntimeProfile::Counter* _open_timer = nullptr;
@@ -119,7 +122,7 @@ private:
     RuntimeProfile::Counter* _serialize_commit_messages_timer = nullptr;
     RuntimeProfile::Counter* _commit_payload_count = nullptr;
     RuntimeProfile::Counter* _commit_payload_bytes_counter = nullptr;
-    RuntimeProfile::Counter* _buffer_flush_count = nullptr;
+    RuntimeProfile::Counter* _bucket_writer_count = nullptr;
 };
 
 } // namespace doris
diff --git a/fe/be-java-extensions/paimon-scanner/PAIMON_WRITE_DESIGN.md 
b/fe/be-java-extensions/paimon-scanner/PAIMON_WRITE_DESIGN.md
index 02506fd5afd..53076be7465 100644
--- a/fe/be-java-extensions/paimon-scanner/PAIMON_WRITE_DESIGN.md
+++ b/fe/be-java-extensions/paimon-scanner/PAIMON_WRITE_DESIGN.md
@@ -1655,7 +1655,247 @@ close() + RPC        TableCommit.commit()   提交
 
 ---
 
-> **文档版本**:v4.0
+## 12. 功能矩阵审查:实现 vs 需求
+
+> 基于 `PAIMON_WRITE_FEATURE_MATRIX.md` 对当前 v1 实现的功能覆盖审查。
+
+### 12.1 已覆盖(✅)
+
+| 功能 | 矩阵优先级 | 实现方式 |
+|------|----------|----------|
+| Append-only 表写入 | P0 | JNI backend → Paimon BatchTableWrite |
+| Primary-key 表写入(full row) | P1 | JNI backend → KeyValueFileWriter,完整 row |
+| Fixed bucket 写入 | P0/P1 | BE 端 murmur hash 计算 bucket,per-bucket writer 并发写 |
+| Fixed bucket BE 端路由 | **P0** | VPaimonTableWriter 按 bucket 拆分 Block,各自独立 
writer |
+| `INSERT OVERWRITE` | P1 | Thrift 下发 write_mode + static_partition,Java 端 
`withOverwrite()` / `withIgnorePreviousFiles()` |
+| Unaware bucket 写入 | P0 | 单 writer 路由,Paimon 内部处理 |
+| Partition 表写入 | P0 | FE 下发 partition columns,Paimon 内部路由 |
+| 非 partition 表写入 | P0 | 随机并发写 |
+| `INSERT INTO` | P0 | 主路径,Nereids planner 完整支持 |
+| Commit message 序列化 | P0 | DPCM 自定义协议(CommitMessageSerializer) |
+| Stream commit 幂等 | P0 | `StreamTableCommit.filterAndCommit()` + commitUser + 
txnId |
+| Abort 未提交文件 | P0 | `PaimonTransaction.rollback()` → `committer.abort()` |
+| Changelog producer none | P0 | 默认模式,透明写入 |
+| 多 Catalog 类型 | P1 | FE 下发 Hadoop config + catalog options |
+| 对象存储 | P1 | Hadoop config 透传,支持 S3/OSS/HDFS |
+| 基础类型(Boolean/Integer/Float/String/Decimal/Date) | P0 | Arrow → Paimon 类型转换 |
+| 并发 writer(多 BE) | P0 | BE 天然多实例,FE 汇总 CommitMessage |
+| Nullability 校验 | P0 | Paimon SDK 写入层校验 |
+| 幂等提交 | P0 | commitIdentifier = Doris txnId |
+
+### 12.2 部分覆盖(⚠️)
+
+| 功能 | 矩阵优先级 | 现状 | 差距 |
+|------|----------|------|------|
+| Deduplicate merge engine | P1 | 完整 row 写入,理论支持 | 缺少专项测试验证 |
+| 复杂类型(ARRAY/MAP/STRUCT) | P1 | PaimonJniWriter 已实现转换逻辑 | 缺少专项测试覆盖 |
+| 小文件控制 | P1 | batch buffer 合并小批次 | buffer 阈值需根据实际场景调优 |
+| Partition 规范化 | P0 | 依赖 Paimon SDK 内部处理 | 需验证 Doris 列值与 Paimon partition 
extractor 一致性 |
+
+### 12.3 未覆盖 — v2 计划(❌)
+
+| 功能 | 矩阵优先级 | 说明 |
+|------|----------|------|
+| Dynamic hash bucket | P2 | 需要多 assigner 分片并发设计 |
+| Dynamic partition overwrite | P2 | 需要在 static overwrite 稳定后再做 |
+| Row-level delete/update | P3 | 需要 RowKind 映射和 merge engine 联动 |
+| Partial-update merge engine | P3 | Paimon PK writer 路径不支持 `withWriteType` |
+| Aggregation merge engine | P2/P3 | 需要确认聚合字段和 sequence field |
+| First-row merge engine | P3 | 需要明确重复 key 语义 |
+| Sequence field | P2 | 需要从表 option 读取并保证写入列完整性 |
+| Schema evolution | P2 | 需处理计划生成后 schema 变化 |
+| Compaction | P3 | 写入路径不应承担 compaction 调度 |
+| Changelog producer input | P2 | 需要 RowKind 输入支持 |
+| Changelog producer lookup/full-compaction | P3 | 依赖 lookup 或 compaction |
+
+### 12.4 未覆盖 — 暂缓(❌)
+
+| 功能 | 矩阵优先级 | 原因 |
+|------|----------|------|
+| Key dynamic bucket | P3 | 需要全局 key index,复杂度高 |
+| Postpone bucket | P3 | 对 Doris 分发拓扑依赖较强 |
+| Batch commit (`Long.MAX_VALUE`) | 不支持 | 与 Doris 外部事务模型不匹配 |
+
+### 12.5 关键差距总结
+
+当前实现覆盖了矩阵中 **P0 项目的 100%**,P1 项目的 **~80%**。
+
+P0 项目中"去 gather 分发"、"Fixed bucket BE 端计算"、"INSERT OVERWRITE" 三项
+已补齐,BE 端通过 Paimon 兼容的 murmur hash 实现 per-bucket 路由分发。
+
+剩余差距集中在 P2/P3 级别:Dynamic bucket、Partial-update、Row-level delete/update、
+Compaction、Changelog producer (input/lookup/full-compaction) 等,按设计文档
+Phase 2-4 路线图推进。
+
+---
+
+## 13. 测试计划
+
+### 13.1 功能测试矩阵
+
+#### 13.1.1 基础写入场景
+
+| 用例 ID | 场景 | 表配置 | 数据特征 | 验证点 |
+|---------|------|--------|---------|--------|
+| FT-001 | Append-only 无分区无 bucket | `bucket=1`, 无 PK, 无 partition | 1000 行,3 
列 INT/VARCHAR/DOUBLE | commit 成功,Paimon 可读回全部行 |
+| FT-002 | Append-only 有分区 | 按 region 分区, 无 PK | 5 个分区,每分区 200 行 | 
分区路径正确,manifest 正确 |
+| FT-003 | Append-only + fixed bucket | `bucket=4`, `bucket-key=id` | 10000 行 
id 从 1-10000 | 4 个 bucket 均有数据文件 |
+| FT-004 | PK 表 + fixed bucket + full row | `bucket=2`, PK=id, 
merge-engine=deduplicate | 1000 行含重复 id | 重复 key 保留最新值 |
+| FT-005 | 多 BE 并发写入 | bucket unaware, 4 个 BE | 每个 BE 写 5000 行 | commit 
message 去重,总行数正确 |
+
+#### 13.1.2 事务与容错
+
+| 用例 ID | 场景 | 操作 | 验证点 |
+|---------|------|------|--------|
+| FT-010 | FE commit 成功 | INSERT INTO → commit | Snapshot 创建,可读 |
+| FT-011 | FE commit 失败 → rollback | 模拟 commit 异常 | abort 清理数据文件,无孤儿文件 |
+| FT-012 | BE 写入失败 → abort | BE 端 JNI 调用异常 | writer.abort() 被调用 |
+| FT-013 | Fragment retry 去重 | 同 txnId 多次上报 commit message | base64 去重生效 |
+| FT-014 | 空表写入 | 0 行数据 | prepareCommit 返回空,不创建 Snapshot |
+| FT-015 | 大事务提交 | 10 万行,4 BE | commit message 分块正确,payload ≤ 8MB |
+
+#### 13.1.3 类型覆盖
+
+| 用例 ID | Paimon 类型 | Doris 类型 | 特殊值 |
+|---------|-----------|-----------|--------|
+| FT-020 | BOOLEAN | BOOLEAN | true, false, NULL |
+| FT-021 | INT / BIGINT | INT / BIGINT | 0, -1, INT_MAX, INT_MIN, NULL |
+| FT-022 | FLOAT / DOUBLE | FLOAT / DOUBLE | 0.0, NaN, INF, -0.0, NULL |
+| FT-023 | DECIMAL(10,2) | DECIMAL(10,2) | 0, 99999999.99, -1.50, NULL |
+| FT-024 | VARCHAR(100) | VARCHAR(100) | "", "hello", 100-char string, NULL |
+| FT-025 | CHAR(10) | CHAR(10) | "abc", 10-char pad, NULL |
+| FT-026 | DATE | DATE | 1970-01-01, 2099-12-31, NULL |
+| FT-027 | TIMESTAMP(6) | DATETIME(6) | 微秒精度, NULL |
+| FT-028 | ARRAY<INT> | ARRAY<INT> | [], [1,2,3], NULL |
+| FT-029 | MAP<STRING,INT> | MAP<STRING,INT> | {}, {"a":1}, NULL |
+| FT-030 | ROW<name STRING, age INT> | STRUCT | 嵌套 NULL 字段 |
+
+#### 13.1.4 边界场景
+
+| 用例 ID | 场景 | 验证点 |
+|---------|------|--------|
+| FT-040 | NULL 列全部为 NULL | 写入正确,查询返回 NULL |
+| FT-041 | 主键全 NULL(如允许) | 行为符合 Paimon 规范 |
+| FT-042 | 超长 VARCHAR > 65535 | 写入失败有清晰错误信息 |
+| FT-043 | DECIMAL 精度溢出 | 写入失败有清晰错误信息 |
+| FT-044 | Bucket key 为复合键 | 路由正确 |
+
+#### 13.1.5 存储后端
+
+| 用例 ID | 存储 | 验证点 |
+|---------|------|--------|
+| FT-050 | Local FS | 基本功能 |
+| FT-051 | HDFS | rename 语义, commit |
+| FT-052 | S3 (MinIO 模拟) | 对象存储 rename 行为 |
+
+### 13.2 性能测试计划
+
+#### 13.2.1 吞吐量基准测试
+
+**目标**:确定 Doris → Paimon 写入的吞吐基线。
+
+| 测试场景 | 表类型 | 数据量 | BE 数 | 对比基准 |
+|---------|--------|--------|-------|----------|
+| PT-001 | Append-only, bucket unaware | 1 亿行, 100GB | 1/4/8 | Paimon Flink 
Writer 同等条件 |
+| PT-002 | PK 表, fixed bucket=16 | 1 亿行, 100GB | 1/4/8 | Paimon Flink Writer 
同等条件 |
+| PT-003 | Append-only, partition=10 | 1 亿行, 100GB | 4 | Paimon Flink Writer 
同等条件 |
+
+**测量指标**:
+- 写入吞吐(rows/s, MB/s)
+- BE 内存占用(peak, avg)
+- Arrow IPC 序列化/反序列化耗时占比
+- JNI 调用耗时占比
+- Paimon FileStoreWrite 耗时占比
+- Commit 耗时(prepareCommit + FE commit)
+
+#### 13.2.2 扩展性测试
+
+| 测试场景 | 变量 | 期望 |
+|---------|------|------|
+| PT-010 | BE 数 1→2→4→8 | 吞吐接近线性增长 |
+| PT-011 | 分区数 1→10→100 | 吞吐稳定,文件数可控 |
+| PT-012 | Bucket 数 1→4→16→64 | 并发 writer 数增长,吞吐改善 |
+
+#### 13.2.3 大数据量压力测试
+
+| 测试场景 | 数据量 | 关注点 |
+|---------|--------|--------|
+| PT-020 | 10 亿行, 1TB Append-only | 长时间运行稳定性,内存无泄漏 |
+| PT-021 | 10 亿行, 1TB PK 表 | merge engine 排序内存,spill 行为 |
+| PT-022 | 1000 分区, 每分区 100 万行 | manifest 膨胀,commit 性能 |
+
+#### 13.2.4 资源限制测试
+
+| 测试场景 | 限制 | 验证点 |
+|---------|------|--------|
+| PT-030 | BE 内存限制 4GB | spill 正常触发,不 OOM |
+| PT-031 | 限流场景(网络带宽 100Mbps) | 吞吐自适应,无超时重试雪崩 |
+| PT-032 | 并发 10 个 INSERT 任务 | 资源隔离,无互相影响 |
+
+### 13.3 测试基础设施
+
+#### 13.3.1 回归测试框架
+
+```
+regression-test/suites/paimon_write_p0/
+├── test_insert_append_only.groovy       # FT-001
+├── test_insert_partitioned.groovy        # FT-002
+├── test_insert_fixed_bucket.groovy       # FT-003
+├── test_insert_pk_dedup.groovy           # FT-004
+├── test_insert_multi_be.groovy           # FT-005
+├── test_transaction_commit.groovy        # FT-010
+├── test_transaction_rollback.groovy      # FT-011
+├── test_fragment_retry_dedup.groovy      # FT-013
+├── test_empty_write.groovy               # FT-014
+├── test_types_basic.groovy               # FT-020..027
+├── test_types_complex.groovy             # FT-028..030
+├── test_nullability.groovy               # FT-040
+├── test_boundary.groovy                  # FT-041..044
+├── test_storage_hdfs.groovy              # FT-051
+└── test_storage_s3.groovy                # FT-052
+```
+
+#### 13.3.2 性能测试脚本
+
+```
+tools/paimon_perf/
+├── benchmark.sh                    # 一键启动性能测试
+├── schema/
+│   ├── append_only_ddl.sql         # Append-only 表 DDL
+│   ├── pk_table_ddl.sql            # PK 表 DDL
+│   └── partitioned_table_ddl.sql   # 分区表 DDL
+├── workload/
+│   ├── gen_data.sh                 # 数据生成(TPC-H / TPC-DS)
+│   └── run_benchmark.sh            # 执行基准测试
+└── report/
+    └── analyze.sh                  # 结果汇总和分析
+```
+
+### 13.4 推荐里程碑
+
+与 `PAIMON_WRITE_FEATURE_MATRIX.md` Section 13 对齐:
+
+| 里程碑 | 覆盖范围 | 验收标准 | 测试用例 |
+|--------|---------|---------|----------|
+| **M1** | append-only + bucket unaware + INSERT INTO | 多 BE 并发写成功,FE commit 
成功,失败可 abort | FT-001, FT-010..014, PT-001 |
+| **M2** | partition + fixed bucket | 按 partition/bucket 并发写,Paimon 正确读取 | 
FT-002..003, FT-005, PT-003 |
+| **M3** | primary-key fixed bucket full row | PK upsert 语义,重复 key 符合 merge 
engine | FT-004, PT-002 |
+| **M4** | 类型全覆盖 + 复杂类型 | 全部基础类型 + ARRAY/MAP/STRUCT | FT-020..030 |
+| **M5** | 存储后端全覆盖 | HDFS + S3 通过回归测试 | FT-050..052 |
+| **M6** | INSERT OVERWRITE + dynamic bucket | v2 功能 | 待 v2 定义 |
+
+### 13.5 M1 准入标准(当前 v1 应达到)
+
+1. ✅ FE 编译通过,checkstyle 0 违规
+2. ⬜ BE 编译通过(CI 验证)
+3. ⬜ FT-001 ~ FT-005 全部通过的回归测试
+4. ⬜ FT-010 ~ FT-015 事务容错测试通过
+5. ⬜ PT-001 吞吐不低于 Flink Writer 的 70%
+6. ⬜ 10 小时连续写入无内存泄漏
+
+---
+
+> **文档版本**:v6.0
 > **作者**:Doris Paimon Write Team
 > **日期**:2026-07-08
-> **状态**:v1 实现已完成(feature/paimon-jni-write-v1),30+ 文件,~4000 行代码
+> **状态**:P0 全覆盖 (100%),P1 ~80%。BE bucket 路由 + INSERT OVERWRITE 已补齐。含测试计划。
diff --git 
a/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniWriter.java
 
b/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniWriter.java
index e74db6993d1..ae7c955dcaf 100644
--- 
a/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniWriter.java
+++ 
b/fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/PaimonJniWriter.java
@@ -324,7 +324,17 @@ public class PaimonJniWriter {
 
     private void initWriter(Table table, Map<String, String> options) throws 
Exception {
         if (!(table instanceof FileStoreTable)) {
-            this.writer = table.newBatchWriteBuilder().newWrite();
+            org.apache.paimon.table.sink.BatchWriteBuilder builder =
+                    table.newBatchWriteBuilder();
+            if (isOverwriteMode(options)) {
+                Map<String, String> staticPart = getStaticPartition(options);
+                if (staticPart != null && !staticPart.isEmpty()) {
+                    builder.withOverwrite(staticPart);
+                } else {
+                    builder.withOverwrite();
+                }
+            }
+            this.writer = builder.newWrite();
             return;
         }
 
@@ -338,11 +348,30 @@ public class PaimonJniWriter {
         }
 
         this.tableWrite = fileStoreTable.newWrite(commitUser);
+        if (isOverwriteMode(options)) {
+            this.tableWrite.withIgnorePreviousFiles(true);
+        }
         this.writer = this.tableWrite;
         initBucketMode(fileStoreTable);
         initSpillIfNeeded(coreOptions, options);
     }
 
+    private static boolean isOverwriteMode(Map<String, String> options) {
+        String mode = options.get("doris.write_mode");
+        return "OVERWRITE".equalsIgnoreCase(mode);
+    }
+
+    private static Map<String, String> getStaticPartition(Map<String, String> 
options) {
+        Map<String, String> result = new HashMap<>();
+        String prefix = "doris.static_partition.";
+        for (Map.Entry<String, String> e : options.entrySet()) {
+            if (e.getKey().startsWith(prefix)) {
+                result.put(e.getKey().substring(prefix.length()), 
e.getValue());
+            }
+        }
+        return result;
+    }
+
     private void initBucketMode(FileStoreTable fileStoreTable) {
         this.bucketMode = fileStoreTable.bucketMode();
         if (bucketMode == BucketMode.HASH_DYNAMIC
@@ -350,7 +379,7 @@ public class PaimonJniWriter {
                 || bucketMode == BucketMode.POSTPONE_MODE) {
             throw new UnsupportedOperationException(
                     "Unsupported Paimon bucket mode for write: " + bucketMode
-                    + ". Only HASH_FIXED and BUCKET_UNAWARE are supported in 
v1.");
+                    + ". Only HASH_FIXED and BUCKET_UNAWARE are supported.");
         }
         this.useExplicitBucketWrite = bucketMode == BucketMode.HASH_FIXED;
         if (useExplicitBucketWrite) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/planner/PaimonTableSink.java 
b/fe/fe-core/src/main/java/org/apache/doris/planner/PaimonTableSink.java
index e741c418101..bf24cb21f3c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/PaimonTableSink.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/PaimonTableSink.java
@@ -164,15 +164,47 @@ public class PaimonTableSink extends 
BaseExternalTableDataSink {
 
         // Bucket info
         if (paimonTable instanceof org.apache.paimon.table.FileStoreTable) {
-            int bucketNum = ((org.apache.paimon.table.FileStoreTable) 
paimonTable).schema().numBuckets();
+            org.apache.paimon.table.FileStoreTable fst =
+                    (org.apache.paimon.table.FileStoreTable) paimonTable;
+            int bucketNum = fst.schema().numBuckets();
             if (bucketNum > 0) {
                 tSink.setBucketNum(bucketNum);
             }
+            // Extract bucket key column indices for BE-side routing
+            List<String> bucketKeys = fst.schema().bucketKeys();
+            if (!bucketKeys.isEmpty() && cols != null) {
+                List<Integer> bucketKeyIndices = new ArrayList<>();
+                for (int i = 0; i < cols.size(); i++) {
+                    if (bucketKeys.contains(cols.get(i).getName())) {
+                        bucketKeyIndices.add(i);
+                    }
+                }
+                if (!bucketKeyIndices.isEmpty()) {
+                    tSink.setBucketKeyIndices(bucketKeyIndices);
+                }
+            }
         }
 
-        // v1: always JNI backend, APPEND mode
+        // Write mode: APPEND or OVERWRITE
         tSink.setBackendType(TPaimonWriteBackendType.JNI);
-        tSink.setWriteMode(TPaimonWriteMode.APPEND);
+        if (insertCtx.isPresent() && insertCtx.get() instanceof 
BaseExternalTableInsertCommandContext) {
+            BaseExternalTableInsertCommandContext ctx =
+                    (BaseExternalTableInsertCommandContext) insertCtx.get();
+            if (ctx.isOverwrite()) {
+                tSink.setWriteMode(TPaimonWriteMode.OVERWRITE);
+                paimonOptions.put("doris.write_mode", "OVERWRITE");
+                if (ctx.getStaticPartition() != null && 
!ctx.getStaticPartition().isEmpty()) {
+                    tSink.setStaticPartition(new 
HashMap<>(ctx.getStaticPartition()));
+                    for (Map.Entry<String, String> e : 
ctx.getStaticPartition().entrySet()) {
+                        paimonOptions.put("doris.static_partition." + 
e.getKey(), e.getValue());
+                    }
+                }
+            } else {
+                tSink.setWriteMode(TPaimonWriteMode.APPEND);
+            }
+        } else {
+            tSink.setWriteMode(TPaimonWriteMode.APPEND);
+        }
 
         tSink.setPaimonOptions(paimonOptions);
         tSink.setHadoopConfig(hadoopConfig);
diff --git a/gensrc/thrift/DataSinks.thrift b/gensrc/thrift/DataSinks.thrift
index 50b3fe25792..a4078c96e42 100644
--- a/gensrc/thrift/DataSinks.thrift
+++ b/gensrc/thrift/DataSinks.thrift
@@ -645,7 +645,9 @@ struct TPaimonTableSink {
     7: optional list<string> column_names
     8: optional string serialized_table       // serialized Paimon Table 
object (base64)
     9: optional TPaimonWriteBackendType backend_type  // v1 fixed to JNI
-    10: optional TPaimonWriteMode write_mode           // v1 fixed to APPEND
+    10: optional TPaimonWriteMode write_mode           // APPEND or OVERWRITE
+    11: optional list<i32> bucket_key_indices           // indices of 
bucket-key columns in column_names
+    12: optional map<string, string> static_partition   // static partition 
spec for INSERT OVERWRITE
 }
 
 struct TDataSink {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to