github-actions[bot] commented on code in PR #67395:
URL: https://github.com/apache/doris/pull/67395#discussion_r4055878062
##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java:
##########
@@ -222,11 +222,11 @@ private static FileStoreTable
copyWithPinnedFallback(FileStoreTable table, Map<S
Map<String, String> fallbackOptions = new
HashMap<>(dynamicOptions);
// Keep branch policy, but never retranslate the main fence
against a later fallback history.
fallbackOptions.remove(CoreOptions.BUCKET.key());
- fallbackOptions.put(CoreOptions.BRANCH.key(),
pair.fallback().coreOptions().branch());
+ fallbackOptions.put(CoreOptions.BRANCH.key(),
pair.other().coreOptions().branch());
fallbackOptions.put(CoreOptions.SCAN_SNAPSHOT_ID.key(),
snapshotId);
return new FallbackReadFileStoreTable(
copyWithPinnedFallback(pair.wrapped(), dynamicOptions,
coordinates, path),
- copyWithPinnedFallback(pair.fallback(),
fallbackOptions, coordinates, fallbackPath));
+ copyWithPinnedFallback(pair.other(), fallbackOptions,
coordinates, fallbackPath), true);
Review Comment:
[P1] Preserve primary-branch precedence when rebuilding the fallback pair —
for a table configured with `scan.primary-branch`,
`PaimonReaderOptions.isWrappedFirst(pair)` is false, but this constructor
hard-codes true; `restoreBoundSchema()` does the same at line 249. The
statement-pin path reaches these rebuilds, so it can read the current/wrapped
branch before the configured primary branch and return the wrong rows. Preserve
`PaimonReaderOptions.isWrappedFirst(pair)` at both sites and cover the
false-order case through option application/schema restoration.
##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java:
##########
@@ -0,0 +1,129 @@
+// 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.connector.paimon;
+
+import org.apache.doris.connector.spi.DorisConnectorException;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.InstantiationUtil;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+/** Statement-scoped Paimon write target shared by sink planning and
transaction commit. */
+final class PaimonWriteBinding {
+
+ private final String tableName;
+ private final FileStoreTable table;
+ private final String serializedTable;
+ private final Map<String, String> hadoopConfig;
+ private final boolean overwrite;
+ private final Map<String, String> staticPartition;
+
+ private PaimonWriteBinding(String tableName, FileStoreTable table,
+ Map<String, String> hadoopConfig, boolean overwrite,
+ Map<String, String> staticPartition) {
+ this.tableName = tableName;
+ this.table = table;
+ this.serializedTable = serialize(table);
Review Comment:
[P1] Strip the metastore loader from the table sent to the BE — HMS/DLF
`FileStoreTable`s carry a `HiveCatalogLoader`, and the existing scan handoff
deliberately calls `dropCatalogLoader()` because the BE plugin excludes the
Hive runtime classes; its POM explicitly notes that serialized tables reaching
this loader fail. This binding serializes the write table unchanged, and
`PaimonJniWriter.open()` deserializes it before `newWrite()`, so
metastore-backed writes can fail on the BE. Keep the catalog-bearing table for
the FE committer, but serialize a loader-free backend copy and add an HMS
writer-handoff test.
##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonWriteBinding.java:
##########
@@ -0,0 +1,129 @@
+// 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.connector.paimon;
+
+import org.apache.doris.connector.spi.DorisConnectorException;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.InstantiationUtil;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+/** Statement-scoped Paimon write target shared by sink planning and
transaction commit. */
+final class PaimonWriteBinding {
+
+ private final String tableName;
+ private final FileStoreTable table;
+ private final String serializedTable;
+ private final Map<String, String> hadoopConfig;
+ private final boolean overwrite;
+ private final Map<String, String> staticPartition;
+
+ private PaimonWriteBinding(String tableName, FileStoreTable table,
+ Map<String, String> hadoopConfig, boolean overwrite,
+ Map<String, String> staticPartition) {
+ this.tableName = tableName;
+ this.table = table;
+ this.serializedTable = serialize(table);
+ this.hadoopConfig = Collections.unmodifiableMap(new
LinkedHashMap<>(hadoopConfig));
+ this.overwrite = overwrite;
+ this.staticPartition = Collections.unmodifiableMap(new
LinkedHashMap<>(staticPartition));
+ }
+
+ static PaimonWriteBinding create(PaimonTableHandle handle, FileStoreTable
table,
+ Map<String, String> hadoopConfig, boolean overwrite,
+ Map<String, String> requestedStaticPartition) {
+ Map<String, String> staticPartition = resolveStaticPartition(table,
requestedStaticPartition);
+ FileStoreTable writeTable = configureTableForWrite(table, overwrite,
staticPartition);
+ return new PaimonWriteBinding(handle.getDatabaseName() + "." +
handle.getTableName(),
+ writeTable, hadoopConfig, overwrite, staticPartition);
+ }
+
+ static FileStoreTable configureTableForWrite(FileStoreTable table, boolean
overwrite,
+ Map<String, String> staticPartition) {
+ if (!overwrite) {
+ return table;
+ }
+ String dynamicOverwriteKey =
CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key();
+ boolean explicitlyDynamic = staticPartition.isEmpty()
+ &&
Boolean.parseBoolean(table.options().get(dynamicOverwriteKey));
+ if (explicitlyDynamic) {
+ return table;
+ }
+ return table.copy(Collections.singletonMap(dynamicOverwriteKey,
Boolean.FALSE.toString()));
+ }
+
+ private static Map<String, String> resolveStaticPartition(FileStoreTable
table,
+ Map<String, String> requested) {
+ Map<String, String> canonicalNames = new
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ for (String partitionKey : table.partitionKeys()) {
+ canonicalNames.put(partitionKey, partitionKey);
+ }
+ String defaultPartitionName =
CoreOptions.fromMap(table.options()).partitionDefaultName();
+ Map<String, String> result = new LinkedHashMap<>();
+ for (Map.Entry<String, String> entry : requested.entrySet()) {
+ String canonicalName = canonicalNames.get(entry.getKey());
+ if (canonicalName == null) {
+ throw new DorisConnectorException("Column '" + entry.getKey()
+ + "' is not a partition column of Paimon table");
+ }
+ String value = entry.getValue();
+ result.put(canonicalName,
+ value == null || "NULL".equalsIgnoreCase(value) ?
defaultPartitionName : value);
Review Comment:
[P1] Preserve typed NULL when building the overwrite partition spec — both a
genuine SQL NULL and the literal strings `'null'`/`'NULL'` arrive here as
strings because the insert commands call `Literal.getStringValue()`, and this
comparison maps all of them to Paimon's null-partition sentinel. The data row
still contains the typed literal, so `INSERT OVERWRITE ... PARTITION (region =
'null')` writes new files into the string partition while `withOverwrite()`
removes the genuine-null partition and leaves the old string files in place.
Carry an explicit typed/null representation into this binding, and add
overwrite coverage for literal `'null'` and `'NULL'` alongside SQL NULL.
##########
be/src/exec/sink/writer/paimon/paimon_table_writer.cpp:
##########
@@ -0,0 +1,161 @@
+// 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_table_writer.h"
+
+#include "common/check.h"
+#include "common/logging.h"
+#include "core/block/block.h"
+#include "core/block/materialize_block.h"
+#include "exprs/vexpr_context.h"
+#include "runtime/runtime_state.h"
+
+namespace doris {
+
+PaimonTableWriter::PaimonTableWriter(TDataSink t_sink, const
VExprContextSPtrs& output_exprs)
+ : _t_sink(std::move(t_sink)), _output_expr_ctxs(output_exprs) {
+ DCHECK(_t_sink.__isset.paimon_table_sink);
+}
+
+Status PaimonTableWriter::open(RuntimeState* state, RuntimeProfile* profile) {
+ _state = state;
+
+ // Register profile counters
+ _written_rows_counter = ADD_COUNTER(profile, "WrittenRows", TUnit::UNIT);
+ _written_bytes_counter = ADD_COUNTER(profile, "WrittenBytes",
TUnit::BYTES);
+ _send_data_timer = ADD_TIMER(profile, "SendDataTime");
+ _project_timer = ADD_CHILD_TIMER(profile, "ProjectTime", "SendDataTime");
+ _file_store_write_timer = ADD_CHILD_TIMER(profile, "FileStoreWriteTime",
"SendDataTime");
+ _open_timer = ADD_TIMER(profile, "OpenTime");
+ _close_timer = ADD_TIMER(profile, "CloseTime");
+ _prepare_commit_timer = ADD_TIMER(profile, "PrepareCommitTime");
+ _commit_payload_count = ADD_COUNTER(profile, "CommitPayloadCount",
TUnit::UNIT);
+ _commit_payload_bytes_counter = ADD_COUNTER(profile, "CommitPayloadBytes",
TUnit::BYTES);
+
+ SCOPED_TIMER(_open_timer);
+
+ // Step 1: Create the backend (JNI or FFI) based on the sink configuration.
+
RETURN_IF_ERROR(PaimonWriteBackendFactory::create(_t_sink.paimon_table_sink,
&_backend));
+ DCHECK(_backend);
+ // Step 2: Open the backend — for JNI this loads the Java class and calls
PaimonJniWriter.open().
+ RETURN_IF_ERROR(_backend->open(_t_sink.paimon_table_sink, state, profile));
+ // Step 3: Create a lightweight writer adapter that delegates to the
opened backend.
+ RETURN_IF_ERROR(_backend->create_writer(&_writer));
+ DCHECK(_writer);
+
+ LOG(INFO) << "PaimonTableWriter opened: backend=" <<
static_cast<int>(_backend->type())
+ << ", writer_scope=local_state";
+ return Status::OK();
+}
+
+Status PaimonTableWriter::write(RuntimeState* state, Block& block) {
+ if (block.rows() == 0) {
+ return Status::OK();
+ }
+
+ SCOPED_TIMER(_send_data_timer);
+
+ // Step 1: Apply output expressions to produce the columns selected by FE.
+ Block output_block;
+ {
+ SCOPED_TIMER(_project_timer);
+
RETURN_IF_ERROR(VExprContext::get_output_block_after_execute_exprs(_output_expr_ctxs,
block,
+
&output_block));
+ materialize_block_inplace(output_block);
+ }
+
+ 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());
+
+ // Step 2: Delegate to the backend writer (JNI or FFI). For the JNI path
+ // this converts Block → Arrow RecordBatch → Arrow C Data → Java
PaimonJniWriter.
+ DCHECK(_writer);
+ {
+ SCOPED_TIMER(_file_store_write_timer);
+ RETURN_IF_ERROR(_writer->write(state, output_block));
+ }
+ _written_rows += block.rows();
+ return Status::OK();
+}
+
+Status PaimonTableWriter::close(Status status) {
+ SCOPED_TIMER(_close_timer);
+
+ // Prepare messages first, but do not publish them until the backend
confirms
+ // that every SDK user has stopped and its native backing memory is safe
to release.
+ std::vector<TPaimonCommitMessage> messages;
+ if (status.ok()) {
+ DCHECK(_writer);
+ {
+ SCOPED_TIMER(_prepare_commit_timer);
+ Status prep_st = _writer->prepare_commit(messages);
+ if (!prep_st.ok()) {
+ status = prep_st;
+ }
+ }
+ }
+
+ // If prepare_commit failed or the incoming status was already an error,
+ // abort the writer to clean up uncommitted data files.
+ if (!status.ok()) {
+ LOG(WARNING) << "Paimon writer closing with error: " <<
status.to_string();
+ if (_writer) {
+ Status abort_st = _writer->abort();
+ if (!abort_st.ok()) {
+ LOG(WARNING) << "Paimon writer abort failed: " <<
abort_st.to_string();
+ }
+ }
+ }
+
+ // The adapter only owns Arrow conversion resources. Release it before
closing
+ // the backend, whose Java close is the authoritative SDK shutdown
boundary.
+ _writer.reset();
+
+ if (_backend) {
+ Status close_st = _backend->close();
+ if (!close_st.ok()) {
Review Comment:
[P2] Abort prepared files when backend close fails before publication —
`prepare_commit()` has already returned the staged-file messages, but a
subsequent backend-close error changes `status` and skips the only block that
publishes them to `RuntimeState`. The earlier abort block is not re-entered,
`_writer` is already destroyed, and the local messages then vanish, so FE
rollback sees no payload to abort. Keep prepared payloads under an
abort-capable owner through close; abort them on a confirmed completed close
failure and retain/reconcile them only for a genuinely ambiguous live-task
close.
##########
be/src/exec/sink/writer/paimon/jni_paimon_write_backend.cpp:
##########
@@ -0,0 +1,543 @@
+// 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/c/bridge.h>
+#include <arrow/io/memory.h>
+#include <arrow/ipc/reader.h>
+#include <arrow/record_batch.h>
+#include <fmt/format.h>
+
+#include <algorithm>
+#include <atomic>
+#include <map>
+#include <mutex>
+#include <string_view>
+#include <vector>
+
+#include "common/check.h"
+#include "common/logging.h"
+#include "exec/sink/writer/paimon/paimon_jni_memory_manager.h"
+#include "exec/spill/spill_file_manager.h"
+#include "format/arrow/arrow_block_convertor.h"
+#include "runtime/exec_env.h"
+#include "runtime/query_context.h"
+#include "runtime/runtime_state.h"
+#include "util/defer_op.h"
+#include "util/jni-util.h"
+#include "util/jni_plugin_registry.h"
+#include "util/pretty_printer.h"
+
+namespace doris {
+
+namespace {
+constexpr std::string_view PAIMON_JNI_WRITER_IO_TMP_DIR =
"paimon_jni_writer_io_tmp";
+
+void throw_java_io_exception(JNIEnv* env, const std::string& message) {
+ jclass exception_class = env->FindClass("java/io/IOException");
+ env->ThrowNew(exception_class, message.c_str());
+ env->DeleteLocalRef(exception_class);
+}
+
+jobjectArray get_paimon_spill_directories(JNIEnv* env, jclass, jlong
spill_session_handle) {
+ auto* spill_session =
reinterpret_cast<ExternalSpillSession*>(spill_session_handle);
+ if (spill_session == nullptr) {
+ throw_java_io_exception(env, "Paimon external spill session is null");
+ return nullptr;
+ }
+
+ std::vector<std::string> paths;
+ Status st = spill_session->get_paths(&paths);
+ if (!st.ok()) {
+ throw_java_io_exception(env, st.to_string());
+ return nullptr;
+ }
+ jclass string_class = env->FindClass("java/lang/String");
+ if (string_class == nullptr) {
+ return nullptr;
+ }
+ jobjectArray result =
+ env->NewObjectArray(static_cast<jsize>(paths.size()),
string_class, nullptr);
+ env->DeleteLocalRef(string_class);
+ if (result == nullptr) {
+ return nullptr;
+ }
+ for (jsize i = 0; i < static_cast<jsize>(paths.size()); ++i) {
+ jstring path = env->NewStringUTF(paths[i].c_str());
+ if (path == nullptr) {
+ return nullptr;
+ }
+ env->SetObjectArrayElement(result, i, path);
+ env->DeleteLocalRef(path);
+ if (env->ExceptionCheck()) {
+ return nullptr;
+ }
+ }
+ return result;
+}
+
+void reserve_paimon_spill(JNIEnv* env, jclass, jlong spill_session_handle,
jstring path,
+ jlong bytes) {
+ auto* spill_session =
reinterpret_cast<ExternalSpillSession*>(spill_session_handle);
+ if (spill_session == nullptr || path == nullptr) {
+ throw_java_io_exception(env, "Paimon external spill session or path is
null");
+ return;
+ }
+ const char* path_chars = env->GetStringUTFChars(path, nullptr);
+ if (path_chars == nullptr) {
+ return;
+ }
+ std::string native_path(path_chars);
+ env->ReleaseStringUTFChars(path, path_chars);
+ Status st = spill_session->reserve(native_path, bytes);
+ if (!st.ok()) {
+ throw_java_io_exception(env, st.to_string());
+ }
+}
+
+void update_paimon_spill_accounting(JNIEnv* env, jclass, jlong
spill_session_handle, jstring path,
+ jlong current_bytes_delta, jlong
write_bytes,
+ jlong read_bytes) {
+ auto* spill_session =
reinterpret_cast<ExternalSpillSession*>(spill_session_handle);
+ if (spill_session == nullptr || path == nullptr) {
+ return;
+ }
+ const char* path_chars = env->GetStringUTFChars(path, nullptr);
+ if (path_chars == nullptr) {
+ return;
+ }
+ std::string native_path(path_chars);
+ env->ReleaseStringUTFChars(path, path_chars);
+ spill_session->update_accounting(native_path, current_bytes_delta,
write_bytes, read_bytes);
+}
+
+Status register_paimon_spill_natives(JNIEnv* env, jclass writer_class) {
+ static char get_spill_directories_name[] = "getPaimonSpillDirectories";
+ static char get_spill_directories_signature[] = "(J)[Ljava/lang/String;";
+ static char reserve_spill_name[] = "reservePaimonSpill";
+ static char reserve_spill_signature[] = "(JLjava/lang/String;J)V";
+ static char update_spill_name[] = "updatePaimonSpillAccounting";
+ static char update_spill_signature[] = "(JLjava/lang/String;JJJ)V";
+ static ::JNINativeMethod methods[] = {
+ {get_spill_directories_name, get_spill_directories_signature,
+ reinterpret_cast<void*>(&get_paimon_spill_directories)},
+ {reserve_spill_name, reserve_spill_signature,
+ reinterpret_cast<void*>(&reserve_paimon_spill)},
+ {update_spill_name, update_spill_signature,
+ reinterpret_cast<void*>(&update_paimon_spill_accounting)},
+ };
+ if (env->RegisterNatives(writer_class, methods,
+ static_cast<jint>(sizeof(methods) /
sizeof(methods[0]))) != JNI_OK) {
+ RETURN_IF_ERROR(Jni::Env::GetJniExceptionMsg(
+ env, true, "JNI exception registering Paimon spill native
methods: "));
+ return Status::JniError("Failed to register Paimon spill native
methods");
+ }
+ return Status::OK();
+}
+
+std::atomic<bool>& paimon_jni_close_failed() {
+ static std::atomic<bool> failed {false};
+ return failed;
+}
+
+struct RetainedPaimonResources {
+ std::unique_ptr<PaimonJniMemoryManager> memory_manager;
+ std::unique_ptr<ExternalSpillSession> spill_session;
+};
+
+std::mutex& retained_resources_mutex() {
+ static auto* mutex = new std::mutex();
+ return *mutex;
+}
+
+std::vector<RetainedPaimonResources>& retained_resources() {
+ static auto* resources = new std::vector<RetainedPaimonResources>();
+ return *resources;
+}
+
+void
retain_resources_after_failed_close(std::unique_ptr<PaimonJniMemoryManager>
memory_manager,
+ std::unique_ptr<ExternalSpillSession>
spill_session) {
+ // An unconfirmed Java close means a background Paimon task may still
reference this manager's
+ // native pages or spill callbacks. Quarantine both resources and stop
admitting new writers so
+ // repeated failures cannot accumulate process-lifetime resources without
a bound.
+ paimon_jni_close_failed().store(true, std::memory_order_release);
Review Comment:
[P2] Do not permanently fence the BE after a completed SDK close error —
this one-way process-global flag is set for every Java close exception. In the
ordinary `writer.close()` failure path, `PaimonJniWriter` still terminates the
compaction executor, closes the index and IO manager, clears all writer state,
and only then rethrows; nevertheless every unrelated Paimon write on this BE is
rejected until restart. Distinguish a timed-out/live-callback close from a
completed SDK error, quarantine the affected handles, and bound/observe
retained resources without permanently disabling new writers.
##########
fe/be-java-extensions/paimon-scanner/src/main/java/org/apache/doris/paimon/GlobalIndexAssigner.java:
##########
@@ -0,0 +1,177 @@
+// 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.paimon;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.crosspartition.BucketAssigner;
+import org.apache.paimon.crosspartition.ExistingProcessor;
+import org.apache.paimon.crosspartition.IndexBootstrap;
+import org.apache.paimon.crosspartition.KeyPartPartitionKeyExtractor;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.PartitionKeyExtractor;
+import org.apache.paimon.table.sink.RowPartitionAllPrimaryKeyExtractor;
+import org.apache.paimon.utils.IDMapping;
+import org.apache.paimon.utils.PositiveIntInt;
+import org.apache.paimon.utils.ProjectToRowFunction;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.BiConsumer;
+
+/**
+ * Assigns buckets for a key-dynamic table with a process-local global-key
index.
+ *
+ * <p>Doris gathers key-dynamic writes into one writer, so a single in-memory
index can preserve
+ * Paimon's cross-partition merge semantics without a native state backend.
+ */
+final class GlobalIndexAssigner implements AutoCloseable {
+ private final FileStoreTable table;
+ // TODO: After resolving rocksdbjni allocator compatibility with the Doris
BE jemalloc hook,
+ // use a RocksDB-backed on-disk index to bound Java heap usage for large
tables.
+ private final Map<BinaryRow, PositiveIntInt> keyIndex = new HashMap<>();
Review Comment:
[P1] Keep the key-dynamic index within the query memory budget —
`openKeyDynamicBucketAssigner()` bootstraps the whole table, so
`bootstrapKey()` copies every existing primary key into this map, and
`processNewRecord()` retains every new key as well. KEY_DYNAMIC is gathered to
one writer, while `PaimonJniMemoryManager` charges only native pages, so
`exec_mem_limit` cannot stop this Java-heap growth before the shared embedded
JVM is exhausted. The added 4M-key test explicitly labels this a known bug and
is skipped unless `enablePaimonKnownBugTest` is enabled. Please use a
bounded/spillable index or reject at an accounted query limit before enabling
this path.
##########
be/src/exec/pipeline/pipeline_fragment_context.cpp:
##########
@@ -2529,6 +2538,20 @@ void
PipelineFragmentContext::_coordinator_callback(const ReportStatusRequest& r
}
_append_external_file_commit_data(req, ¶ms);
+ if (auto pcm = req.runtime_state->paimon_commit_messages(); !pcm.empty()) {
Review Comment:
[P2] Retain rollback ownership until FE accepts these payloads — after
`prepareCommit()` the Java writer is closed and these `RuntimeState` messages
are the only abort metadata left. Each chunk is limited to 8 MiB, but their
aggregate is not, so `validate_report_exec_status_size()` can reject a valid
high-file-count final report locally before FE receives anything. Those
rejection paths only run the Iceberg external-file cleanup, and FE rollback
returns with an empty Paimon payload list, leaving the staged files orphaned.
Give Paimon an acknowledgement-backed cleanup owner and abort on definite
pre-delivery rejection while preserving files for ambiguous delivery.
##########
gensrc/thrift/DataSinks.thrift:
##########
@@ -46,6 +46,7 @@ enum TDataSinkType {
MAXCOMPUTE_TABLE_SINK = 18,
ICEBERG_DELETE_SINK = 19,
ICEBERG_MERGE_SINK = 20,
+ PAIMON_TABLE_SINK = 21,
Review Comment:
[P1] Gate the new sink type with a newly bumped BE capability — an
immediately preceding BE has no `_create_data_sink` case for value 21, but
`PaimonWritePlanProvider` emits this sink unconditionally and this PR leaves
the maximum execution version at the already-advertised value 15. During an
FE-first rolling upgrade, even gather or dynamic/bucket-unaware Paimon writes
that avoid the value-7 exchange protocol are dispatched to old BEs and fail as
an unsupported sink. Allocate a new capability and reject/gate planning until
all executing BEs support it, with a mixed-version gather-write test.
##########
gensrc/thrift/Partitions.thrift:
##########
@@ -45,11 +45,12 @@ enum TPartitionType {
// used for shuffle data by parititon and tablet
OLAP_TABLE_SINK_HASH_PARTITIONED = 6,
- // used for shuffle data by hive parititon
- HIVE_TABLE_SINK_HASH_PARTITIONED = 7,
+ // used for shuffle data by external table sink ownership key. BE execution
+ // versions before 12 reject this type because value 7 had different
semantics.
+ EXTERNAL_TABLE_SINK_HASH_PARTITIONED = 7,
Review Comment:
[P1] Allocate a new wire value and capability for this protocol — value 7
was already `HIVE_TABLE_SINK_HASH_PARTITIONED`, and the version-13 gate cannot
distinguish this feature because the immediately preceding FE/BE already
advertise version 15. During a BE-first upgrade, an old FE sends value 7
without field 5 and the new BE rejects every partitioned Hive write as missing
metadata. In the other direction, an old BE decodes a new Paimon fixed-bucket
request as the legacy Hive ScaleWriter protocol, ignores field 5, and loses the
required identity/one-writer-per-bucket routing. Please preserve value 7 for
legacy payloads and use a fresh enum plus a newly bumped capability version (or
gather until every BE supports it).
##########
fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/write/ConnectorWriteDistribution.java:
##########
@@ -0,0 +1,89 @@
+// 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.connector.spi.write;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Connector-neutral description of the required distribution for a table
write. */
+public final class ConnectorWriteDistribution {
Review Comment:
[P2] Freeze the public members of the new connector SPI types — the API is
bumped to 10, but `ConnectorPluginSurfaceTest` only walks its hand-written
frozen sets, which omit this class, `ConnectorRowLevelDmlRequest`,
`ConnectorRowChangeStyle`, and `ConnectorWriteDistribution.Mode`. The refreshed
baseline therefore records their names only inside provider signatures, not the
factories/getters or enum constants that plugin bytecode links, so removing or
re-signing those members later can pass without a major bump. Add the new
classes/enums to the frozen sets and baseline, or discover public SPI types
automatically.
##########
fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ConnectorChangelogPlanBuilder.java:
##########
@@ -0,0 +1,536 @@
+// 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.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 {
+ public static final String OPERATION_COLUMN = "__DORIS_PAIMON_ROW_KIND__";
+ public static final byte INSERT = 0;
+ public static final byte UPDATE = 1;
+ public static final byte DELETE = 2;
+ 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,
+ ConnectorChangelogRowChangeSpec spec, LogicalPlan child,
CascadesContext context) {
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Update) {
+ return buildUpdate(schema,
(ConnectorChangelogRowChangeSpec.Update) spec, child, context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Delete) {
+ return buildDelete(schema, primaryKeys,
(ConnectorChangelogRowChangeSpec.Delete) spec,
+ child, context);
+ }
+ if (spec instanceof ConnectorChangelogRowChangeSpec.Merge) {
+ return new MergeBuilder(schema, primaryKeys,
+ (ConnectorChangelogRowChangeSpec.Merge) spec, child,
context).build();
+ }
+ throw new AnalysisException("Unsupported connector changelog
specification: "
+ + spec.getClass().getSimpleName());
+ }
+
+ private static LogicalPlan buildUpdate(List<Column> schema,
+ 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(UPDATE));
+ 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,
+ ConnectorChangelogRowChangeSpec.Delete delete, LogicalPlan child,
+ CascadesContext context) {
+ ExpressionAnalyzer analyzer = analyzer(child, context);
+ List<NamedExpression> projects = new ArrayList<>();
+ projects.add(operation(DELETE));
+ 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;
+ }
+ 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.contains(column.getName())) {
+ groupBy.add(value);
+ outputs.add(value);
+ } else {
+ outputs.add(new Alias(new AnyValue(value), column.getName()));
+ }
+ }
+ return new LogicalAggregate<>(groupBy, outputs, project);
+ }
+
+ private static Alias operation(byte value) {
+ return new Alias(new TinyIntLiteral(value), OPERATION_COLUMN);
+ }
+
+ 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 ConnectorChangelogRowChangeSpec.Merge merge;
+ private final LogicalPlan child;
+ private final ExpressionAnalyzer analyzer;
+
+ private MergeBuilder(List<Column> schema, List<String> primaryKeys,
+ ConnectorChangelogRowChangeSpec.Merge merge, LogicalPlan child,
+ CascadesContext context) {
+ this.schema = schema;
+ this.primaryKeys = primaryKeys;
+ 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 ? OPERATION_COLUMN :
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)),
Review Comment:
[P1] Keep forced-short-circuit IFs out of generic boolean rewrites — this
also wraps boolean target columns, but `CaseWhenToCompoundPredicate` matches
`If.class` with `Class.isInstance`, so it matches `ShortCircuitIf` too. For a
literal-valued branch it rewrites the expression to ordinary `AND`/`OR` and
drops `AlwaysShortCircuit`; with the default `short_circuit_evaluation=false`,
an inactive later expression such as `assert_true(false, ...)` can still
execute and fail the MERGE. Exclude `AlwaysShortCircuit` expressions from that
rewrite (or preserve the marker) and add a full-plan boolean-branch test.
##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java:
##########
@@ -1153,6 +1190,223 @@ public void dropTable(ConnectorSession session,
ConnectorTableHandle handle) {
LOG.info("dropped Paimon table {}", id);
}
+ // ==================== DDL: Column evolution ====================
+
+ @Override
+ public void addColumn(ConnectorSession session, ConnectorTableHandle
handle,
+ ConnectorColumn column, ConnectorColumnPosition position) {
+ PaimonTableHandle paimonHandle = (PaimonTableHandle) handle;
+ List<DataField> fields = loadRemoteFields(paimonHandle);
+ Map<String, DataField> fieldsByName = indexFieldsByName(fields);
+ rejectDuplicateColumn(fieldsByName.keySet(), column.getName());
Review Comment:
[P1] Reject reserved metadata names before applying column evolution —
CREATE already blocks `__paimon_file_path` and `__paimon_row_index`, but the
new ADD/ADD COLUMNS/RENAME paths pass those names to Paimon. After the remote
ALTER succeeds, `afterExternalDdl()` refreshes the table and both
`buildTableSchema()` and `buildColumnHandles()` reject the physical collision,
so the altered table can no longer be loaded for normal reads. Reuse the
case-insensitive reserved-name check for every newly introduced column name and
cover both names and case variants in column-evolution tests.
--
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]