This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new f8a4eba21b8 [improvement](iceberg) Route partitioned Iceberg writes by
the transformed partition value (#68921)
f8a4eba21b8 is described below
commit f8a4eba21b8b63e33ddf632ef9127889e994c881
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 15 14:50:32 2026 +0800
[improvement](iceberg) Route partitioned Iceberg writes by the transformed
partition value (#68921)
### What problem does this PR solve?
Issue Number: None
Related PR: #66685
Problem Summary:
**In short.** This ports the Iceberg part of branch-4.x #66685 to
master: an INSERT into a partitioned Iceberg table is now
hash-distributed by the transformed partition value, so each partition
is written by one writer. On master today the rows are spread randomly,
and a single INSERT that touches more than 128 partitions fails.
**Background.** Every writer of an Iceberg sink keeps one open file per
partition it has seen, capped by the BE config
`table_sink_partition_write_max_partition_nums_per_writer` (default
128). branch-4.x #66685 routes partitioned Iceberg writes through an
Iceberg-transform exchange. The FE computes the active partition spec's
transforms, the BE applies them before hashing, and all rows of one
partition reach one writer. Master's Iceberg connector only declares
`requiresParallelWrite`, so the engine falls back to random
distribution. The external sink exchange that master already has
(connector-defined partition functions, used by Paimon's fixed buckets)
has no Iceberg function.
**The problem, and what it cost.** With random distribution every writer
receives rows of every partition:
| | master today | with this PR |
|---|---|---|
| `INSERT ... SELECT number, number % 200 FROM numbers("number" =
"10000")` into a table partitioned by `p` | fails: `Too many open
partitions 128` | succeeds: 10,000 rows in 200 partitions |
| Data files per partition after one INSERT | up to one per writer | one
per partition (200 identity partitions: 200 files; 300 `day(ts)`
partitions: 300 files) |
| Sink exchange in `EXPLAIN SHAPE PLAN` |
`DistributionSpecHiveTableSinkUnPartitioned` (random) |
`DistributionSpecExternalTableSinkHashPartitioned` |
**How this PR fixes it.**
- **Iceberg connector.** `IcebergWritePlanProvider.getWriteDistribution`
returns an external hash distribution. The route columns are the
partition fields' source columns, the partition function is
`iceberg_transform` with the transforms in spec order (for example
`day,identity,bucket[4]`), and the writer assignment is `SKEWED`, so a
large partition can still scale out. It reads the statement's pinned
write schema when one is active. It returns `null` (today's random
parallel write) for an unpartitioned table, a partition field on a
nested source (route columns must be top-level sink outputs), a
partition field whose transformed value is BINARY or FIXED (`identity`
or `truncate` of a binary or fixed column: the BE holds that value in a
`ColumnVarbinary`, which has no shuffle hash; `bucket` of one yields an
INT and is routed), or a backend older than execution version 18.
- **BE.** The external partition function factory registers
`iceberg_transform`. It reuses `IcebergInsertPartitionFunction`, the
MERGE insert-side router, which applies the same
`PartitionColumnTransforms` as the Iceberg writer. A transform the
writer cannot apply fails the partitioner the same way it fails the
writer; it never falls back to hashing the raw value.
- **Mixed versions.** Older backends reject the new function name, so BE
execution version 18 (17 is taken on master by the `map_agg_v2` frame
change) marks the capability and FE `max_be_exec_version` moves to 18.
The connector reads the version at optimize time through a new
`ConnectorSession#getBeExecVersion()` (default `Integer.MAX_VALUE`). The
engine session returns the version the planner pinned for the statement
(#68837), which is also what the backends receive in the query options
and what `ConnectorWriteHandle#getBeExecVersion()` carries into
`planWrite`, so changing `be_exec_version` while a statement is planned
cannot put the new function into a plan sent at an older version.
Outside a planned statement it returns `Config.be_exec_version`. With
`be_exec_version` below 18 the plan stays as today: the sink keeps the
random distribution, and the INSERT above still fails with `Too many
open partitions 128`. branch-4.x falls back to a single gathered writer
instead.
- **The cap is now per writer's share.** Each writer receives the
partitions whose hash lands on it, so
`table_sink_partition_write_max_partition_nums_per_writer` limits that
share rather than the whole INSERT. One INSERT can span roughly writers
× 128 partitions, less the hash imbalance; a writer whose share goes
over the cap still fails. branch-4.x routes the same way. On a local
single-BE cluster the 200-partition INSERT fails with one writer and the
300-partition `day(ts)` INSERT fails with one or two, and both pass with
three or more (`parallel_pipeline_task_num`).
- The SPI addition is a default method; the plugin-surface baseline is
refreshed. The SPI is unreleased, so `connector.plugin.api.version` is
unchanged. No Thrift change: the function name and its options travel in
the existing `TExternalTableSinkHashPartitionInfo` fields.
Original author of the branch-4.x change: @suxiaogang223
**Results.** On a local single-BE cluster with the Iceberg REST docker
environment, the 200-partition INSERT that fails on master succeeds, a
300-partition `day(ts)` INSERT succeeds too, and every partition holds
exactly one data file. Setting `be_exec_version = 16` brings back the
random distribution and the original error, which is the mixed-version
fallback.
Co-authored-by: Socrates <[email protected]>
---
be/src/agent/be_exec_version_manager.cpp | 4 +-
be/src/agent/be_exec_version_manager.h | 1 +
.../external_partition_function_factory.cpp | 57 +++++++++
.../external_partition_function_factory_test.cpp | 26 ++++
.../external_table_sink_hash_partitioner_test.cpp | 37 ++++++
.../main/java/org/apache/doris/common/Config.java | 4 +-
.../iceberg/IcebergWritePlanProvider.java | 65 ++++++++++
.../iceberg/IcebergWritePlanProviderTest.java | 135 +++++++++++++++++++++
.../doris/connector/spi/ConnectorSession.java | 11 ++
.../test/resources/connector-plugin-surface.txt | 1 +
.../doris/connector/ConnectorSessionBuilder.java | 20 ++-
.../doris/connector/ConnectorSessionImpl.java | 17 ++-
.../doris/connector/ConnectorSessionImplTest.java | 42 +++++++
.../write/test_iceberg_write_binary_partitions.out | 24 ++++
.../test_iceberg_write_partition_distribution.out | 25 ++++
.../test_iceberg_write_binary_partitions.groovy | 20 +++
...est_iceberg_write_partition_distribution.groovy | 128 +++++++++++++++++++
17 files changed, 612 insertions(+), 5 deletions(-)
diff --git a/be/src/agent/be_exec_version_manager.cpp
b/be/src/agent/be_exec_version_manager.cpp
index 1ccdf978688..960159e8d15 100644
--- a/be/src/agent/be_exec_version_manager.cpp
+++ b/be/src/agent/be_exec_version_manager.cpp
@@ -156,8 +156,10 @@ void
BeExecVersionManager::check_function_compatibility(int current_be_exec_vers
// a. support Paimon table writes.
// 17: start from master
// a. use exact logical frames for map_agg_v2 aggregate state serialization.
+// 18: start from master
+// a. support Iceberg partition transform routing in the external sink
exchange.
-const int BeExecVersionManager::max_be_exec_version =
SUPPORT_MAP_AGG_V2_EXACT_FRAME_VERSION;
+const int BeExecVersionManager::max_be_exec_version =
SUPPORT_ICEBERG_TRANSFORM_ROUTING_VERSION;
const int BeExecVersionManager::min_be_exec_version = 0;
std::map<std::string, std::set<int>>
BeExecVersionManager::_function_change_map {};
std::map<std::string, std::set<int>>
BeExecVersionManager::_function_alternative_map {};
diff --git a/be/src/agent/be_exec_version_manager.h
b/be/src/agent/be_exec_version_manager.h
index 01e5f442bad..53564005f0e 100644
--- a/be/src/agent/be_exec_version_manager.h
+++ b/be/src/agent/be_exec_version_manager.h
@@ -39,6 +39,7 @@ constexpr inline int SUPPORT_TIMESTAMP_NS_VERSION = 14;
constexpr inline int SUPPORT_HIVE_OPEN_CSV_VERSION = 15;
constexpr inline int SUPPORT_PAIMON_WRITE_VERSION = 16;
constexpr inline int SUPPORT_MAP_AGG_V2_EXACT_FRAME_VERSION = 17;
+constexpr inline int SUPPORT_ICEBERG_TRANSFORM_ROUTING_VERSION = 18;
class BeExecVersionManager {
public:
diff --git
a/be/src/exec/partitioner/external/external_partition_function_factory.cpp
b/be/src/exec/partitioner/external/external_partition_function_factory.cpp
index 5b312900762..9cd19ae6c33 100644
--- a/be/src/exec/partitioner/external/external_partition_function_factory.cpp
+++ b/be/src/exec/partitioner/external/external_partition_function_factory.cpp
@@ -17,6 +17,7 @@
#include "exec/partitioner/external/external_partition_function_factory.h"
#include <charconv>
+#include <string>
#include <string_view>
#include "common/status.h"
@@ -89,6 +90,58 @@ Status create_paimon_fixed_bucket_function(
return Status::OK();
}
+Status parse_transforms(std::string_view value, std::vector<std::string>*
const transforms) {
+ while (true) {
+ size_t separator = value.find(',');
+ std::string_view item = value.substr(0, separator);
+ if (item.empty()) {
+ return Status::InvalidArgument("Empty Iceberg partition transform
in '{}'", value);
+ }
+ transforms->emplace_back(item);
+ if (separator == std::string_view::npos) {
+ return Status::OK();
+ }
+ value.remove_prefix(separator + 1);
+ }
+}
+
+// Routes each row by its Iceberg partition value: the hash covers the
transformed source columns,
+// so rows of one partition reach one logical partition no matter how the raw
values differ.
+Status create_iceberg_transform_function(
+ const TExternalTableSinkHashPartitionInfo& partition_info,
+ PartitionerBase::HashValType logical_partition_count,
ShuffleHashMethod hash_method,
+ const std::vector<TExpr>& partition_exprs,
+ std::unique_ptr<PartitionFunction>* const partition_function) {
+ if (!partition_info.__isset.partition_function_options) {
+ return Status::InvalidArgument("Iceberg transform partition function
requires options");
+ }
+ const auto& options = partition_info.partition_function_options;
+ if (options.size() != 1 || !options.contains("transforms")) {
+ return Status::InvalidArgument(
+ "Iceberg transform partition function requires exactly the
transforms option");
+ }
+ std::vector<std::string> transforms;
+ RETURN_IF_ERROR(parse_transforms(options.at("transforms"), &transforms));
+ if (transforms.size() != partition_exprs.size()) {
+ return Status::InvalidArgument(
+ "Iceberg partition transform count {} does not match partition
expression count {}",
+ transforms.size(), partition_exprs.size());
+ }
+ std::vector<TIcebergPartitionField> fields;
+ fields.reserve(partition_exprs.size());
+ for (size_t index = 0; index < partition_exprs.size(); ++index) {
+ TIcebergPartitionField field;
+ field.__set_transform(transforms[index]);
+ field.__set_source_expr(partition_exprs[index]);
+ fields.emplace_back(std::move(field));
+ }
+ auto function = std::make_unique<IcebergInsertPartitionFunction>(
+ logical_partition_count, hash_method, std::vector<TExpr> {},
std::move(fields));
+ RETURN_IF_ERROR(function->init({}));
+ *partition_function = std::move(function);
+ return Status::OK();
+}
+
Status create_legacy_external_partition_function(
const TExternalTableSinkHashPartitionInfo& partition_info,
PartitionerBase::HashValType logical_partition_count,
ShuffleHashMethod hash_method,
@@ -173,6 +226,10 @@ Status create_external_partition_function(
return create_paimon_fixed_bucket_function(partition_info,
logical_partition_count,
partition_exprs,
partition_function);
}
+ if (partition_info.partition_function == "iceberg_transform") {
+ return create_iceberg_transform_function(partition_info,
logical_partition_count,
+ hash_method, partition_exprs,
partition_function);
+ }
if (partition_info.partition_function != "direct_hash") {
return Status::NotSupported("Unsupported external sink partition
function '{}'",
partition_info.partition_function);
diff --git
a/be/test/exec/partitioner/external_partition_function_factory_test.cpp
b/be/test/exec/partitioner/external_partition_function_factory_test.cpp
index e03265f6830..de0bd75b92b 100644
--- a/be/test/exec/partitioner/external_partition_function_factory_test.cpp
+++ b/be/test/exec/partitioner/external_partition_function_factory_test.cpp
@@ -129,4 +129,30 @@ TEST(ExternalPartitionFunctionFactoryTest,
ReadsBranch41PaimonFixedBucketEncodin
EXPECT_EQ(function->partition_count(), 3);
}
+TEST(ExternalPartitionFunctionFactoryTest, IcebergTransformValidatesOptions) {
+ auto create = [](const std::map<std::string, std::string>* options) {
+ TExternalTableSinkHashPartitionInfo info;
+ info.__set_partition_function("iceberg_transform");
+ if (options != nullptr) {
+ info.__set_partition_function_options(*options);
+ }
+
info.__set_writer_assignment(TExternalTableSinkWriterAssignment::SKEWED);
+ std::unique_ptr<PartitionFunction> function;
+ return create_external_partition_function(info, 3,
ShuffleHashMethod::CRC32, {}, &function);
+ };
+
+ EXPECT_NE(create(nullptr).to_string().find("requires options"),
std::string::npos);
+ const std::map<std::string, std::string> extra_option {{"transforms",
"identity"},
+ {"unexpected",
"value"}};
+ EXPECT_NE(create(&extra_option).to_string().find("requires exactly the
transforms option"),
+ std::string::npos);
+ const std::map<std::string, std::string> empty_transform {{"transforms",
"identity,,day"}};
+ EXPECT_NE(create(&empty_transform).to_string().find("Empty Iceberg
partition transform"),
+ std::string::npos);
+ // One transform per route expression; this call routes no expressions.
+ const std::map<std::string, std::string> one_transform {{"transforms",
"bucket[16]"}};
+ EXPECT_NE(create(&one_transform).to_string().find("does not match
partition expression count"),
+ std::string::npos);
+}
+
} // namespace doris
diff --git
a/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
b/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
index 6b53ab4dd16..c28e9a31eda 100644
--- a/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
+++ b/be/test/exec/partitioner/external_table_sink_hash_partitioner_test.cpp
@@ -199,6 +199,43 @@ TEST_F(ExternalTableSinkHashPartitionerTest,
PaimonFixedBucketIncludesPartitionH
ASSERT_TRUE(partitioner.close(&_state).ok());
}
+TEST_F(ExternalTableSinkHashPartitionerTest,
IcebergTransformHashesTransformedValue) {
+ TExternalTableSinkHashPartitionInfo info;
+ info.__set_partition_function("iceberg_transform");
+ info.__set_partition_function_options({{"transforms", "truncate[10]"}});
+ info.__set_writer_assignment(TExternalTableSinkWriterAssignment::SKEWED);
+ ExternalTableSinkHashPartitioner partitioner(64, ShuffleHashMethod::CRC32,
info);
+ ASSERT_TRUE(partitioner.init({slot_ref()}).ok());
+ ASSERT_TRUE(partitioner.prepare(&_state, *_row_descriptor).ok());
+ ASSERT_TRUE(partitioner.open(&_state).ok());
+
+ // truncate[10] maps 11 and 19 to partition 10, and 20 and 29 to partition
20.
+ Block input = block({11, 19, 20, 29});
+ ASSERT_TRUE(partitioner.do_partitioning(&_state, &input).ok());
+ const auto& channels = partitioner.get_channel_ids();
+ ASSERT_EQ(4, channels.size());
+ EXPECT_EQ(channels[0], channels[1]);
+ EXPECT_EQ(channels[2], channels[3]);
+ EXPECT_EQ(1, input.columns());
+
+ ASSERT_TRUE(partitioner.close(&_state).ok());
+}
+
+TEST_F(ExternalTableSinkHashPartitionerTest,
IcebergUnsupportedTransformFailsClosed) {
+ TExternalTableSinkHashPartitionInfo info;
+ info.__set_partition_function("iceberg_transform");
+ info.__set_partition_function_options({{"transforms", "unsupported"}});
+ info.__set_writer_assignment(TExternalTableSinkWriterAssignment::IDENTITY);
+ ExternalTableSinkHashPartitioner partitioner(4, ShuffleHashMethod::CRC32,
info);
+ ASSERT_TRUE(partitioner.init({slot_ref()}).ok());
+ ASSERT_TRUE(partitioner.prepare(&_state, *_row_descriptor).ok());
+
+ Status status = partitioner.open(&_state);
+ ASSERT_FALSE(status.ok());
+ EXPECT_NE(status.to_string().find("Unsupported Iceberg partition
transform"),
+ std::string::npos);
+}
+
TEST_F(ExternalTableSinkHashPartitionerTest,
PaimonFixedBucketRejectsMissingMetadata) {
TExternalTableSinkHashPartitionInfo info;
info.__set_partition_function("paimon_fixed_bucket");
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 076723aa8ac..7a2df0152c4 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -2028,9 +2028,11 @@ public class Config extends ConfigBase {
= ALIGNED_EXTERNAL_TABLE_SINK_THRIFT_MIN_BE_EXEC_VERSION;
// Older backends serialize map_agg_v2 keys with the legacy padded frame
format.
public static final int MAP_AGG_V2_EXACT_FRAME_MIN_BE_EXEC_VERSION = 17;
+ // Older backends reject the iceberg_transform external sink partition
function.
+ public static final int ICEBERG_TRANSFORM_ROUTING_MIN_BE_EXEC_VERSION = 18;
@ConfField(mutable = false)
- public static int max_be_exec_version =
MAP_AGG_V2_EXACT_FRAME_MIN_BE_EXEC_VERSION;
+ public static int max_be_exec_version =
ICEBERG_TRANSFORM_ROUTING_MIN_BE_EXEC_VERSION;
/**
* Min data version of backends serialize block.
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
index 63945f50e35..5a06993830c 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergWritePlanProvider.java
@@ -31,6 +31,7 @@ import
org.apache.doris.connector.spi.handle.ConnectorWriteHandle;
import org.apache.doris.connector.spi.handle.WriteOperation;
import org.apache.doris.connector.spi.write.ConnectorRowChangeStyle;
import org.apache.doris.connector.spi.write.ConnectorSinkPlan;
+import org.apache.doris.connector.spi.write.ConnectorWriteDistribution;
import org.apache.doris.connector.spi.write.ConnectorWritePartitionField;
import org.apache.doris.connector.spi.write.ConnectorWritePartitionSpec;
import org.apache.doris.connector.spi.write.ConnectorWritePlanProvider;
@@ -111,6 +112,9 @@ public class IcebergWritePlanProvider implements
ConnectorWritePlanProvider {
}
private static final int SUPPORT_NESTED_PARTITION_WRITE_EXEC_VERSION = 12;
+ // Older backends reject the iceberg_transform external sink partition
function.
+ private static final int SUPPORT_TRANSFORM_ROUTING_EXEC_VERSION = 18;
+ private static final String TRANSFORM_PARTITION_FUNCTION =
"iceberg_transform";
// Legacy IcebergUtils compression-codec property keys (connector-local
copies; iceberg SDK has no
// constant for the doris/spark-sql forms).
@@ -614,6 +618,67 @@ public class IcebergWritePlanProvider implements
ConnectorWritePlanProvider {
return new ConnectorWritePartitionSpec(spec.specId(), fields);
}
+ /**
+ * Routes a partitioned INSERT / INSERT OVERWRITE by the Iceberg partition
value: the BE
+ * {@code iceberg_transform} partition function hashes each partition
field's transformed source column, so the
+ * rows of one partition reach one writer (SKEWED lets a large partition
scale out). Without it every writer
+ * receives every partition, opens a file per partition it sees, and fails
past
+ * {@code table_sink_partition_write_max_partition_nums_per_writer} open
partitions.
+ *
+ * <p>{@code null} keeps the engine's random parallel write: an
unpartitioned table, a partition field on a nested
+ * source (the route columns are top-level sink outputs), a partition
value the backend cannot hash (see the
+ * loop), or a backend older than the routing function.</p>
+ */
+ @Override
+ public ConnectorWriteDistribution getWriteDistribution(ConnectorSession
session,
+ ConnectorTableHandle tableHandle) {
+ if (session.getBeExecVersion() <
SUPPORT_TRANSFORM_ROUTING_EXEC_VERSION) {
+ return null;
+ }
+ IcebergTableHandle handle = (IcebergTableHandle) tableHandle;
+ Optional<IcebergWriteSchemaContext> active =
+ IcebergStatementScope.activeWriteSchema(
+ session, handle.getDbName(), handle.getTableName());
+ Table table = active.isPresent() ? null : resolveTable(session,
handle);
+ PartitionSpec spec = active.isPresent()
+ ? active.get().getPartitionSpec() : table.spec();
+ if (spec == null || !spec.isPartitioned()) {
+ return null;
+ }
+ Schema schema = active.isPresent()
+ ? active.get().getSchema() : table.schema();
+ List<String> routeColumns = new ArrayList<>();
+ List<String> transforms = new ArrayList<>();
+ for (PartitionField field : spec.fields()) {
+ String sourceColumnName = findTopLevelColumnName(schema,
field.sourceId());
+ if (sourceColumnName == null) {
+ return null;
+ }
+ // The exchange hashes the transformed value, and the BE column of
a BINARY or FIXED value
+ // (ColumnVarbinary) has no shuffle hash: identity(binary_key) or
truncate(1, binary_key) would fail the
+ // INSERT with "update_crc32c_batch is not supported for
ColumnVarbinary" as soon as it has two writers
+ // to route to. bucket(16, binary_key) yields an INT and is routed.
+ Type.TypeID transformedType =
field.transform().getResultType(schema.findType(field.sourceId())).typeId();
+ if (transformedType == Type.TypeID.BINARY || transformedType ==
Type.TypeID.FIXED) {
+ return null;
+ }
+ routeColumns.add(sourceColumnName);
+ transforms.add(field.transform().toString());
+ }
+ return ConnectorWriteDistribution.externalHash(routeColumns,
TRANSFORM_PARTITION_FUNCTION,
+ Collections.singletonMap("transforms", String.join(",",
transforms)),
+ ConnectorWriteDistribution.WriterAssignment.SKEWED);
+ }
+
+ private static String findTopLevelColumnName(Schema schema, int sourceId) {
+ for (NestedField column : schema.columns()) {
+ if (column.fieldId() == sourceId) {
+ return column.name();
+ }
+ }
+ return null;
+ }
+
private static String findPartitionSourceColumnName(Schema schema, int
sourceId) {
List<NestedField> columns = schema.columns();
for (NestedField column : columns) {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
index 37e5734bf6f..b265391bb17 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergWritePlanProviderTest.java
@@ -30,6 +30,7 @@ import org.apache.doris.connector.spi.handle.WriteOperation;
import org.apache.doris.connector.spi.mvcc.ConnectorMvccSnapshot;
import org.apache.doris.connector.spi.write.ConnectorRowChangeStyle;
import org.apache.doris.connector.spi.write.ConnectorSinkPlan;
+import org.apache.doris.connector.spi.write.ConnectorWriteDistribution;
import org.apache.doris.connector.spi.write.ConnectorWritePartitionField;
import org.apache.doris.connector.spi.write.ConnectorWritePartitionSpec;
import org.apache.doris.connector.spi.write.ConnectorWriteSortColumn;
@@ -85,6 +86,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -1491,6 +1493,128 @@ public class IcebergWritePlanProviderTest {
Assertions.assertEquals(Integer.valueOf(3), field.getSourceId());
}
+ // ───────────────────────────── getWriteDistribution (partition-hash
INSERT routing) ─────────────────────────────
+ //
+ // WHY: without a declared distribution a partitioned INSERT is spread
randomly, so every writer opens a file for
+ // every partition it sees and the write fails once one writer holds more
than
+ // table_sink_partition_write_max_partition_nums_per_writer (128) open
partitions. The BE iceberg_transform
+ // function hashes the transformed partition value, so one partition
reaches one writer.
+
+ @Test
+ public void getWriteDistributionRoutesByTheIcebergTransform() {
+ Table table = bucketPartitionedTable(freshCatalog());
+ ConnectorWriteDistribution distribution = providerFor(table,
contextWithStorage())
+ .getWriteDistribution(sessionFor(table, contextWithStorage()),
new IcebergTableHandle("db1", "tb"));
+
+ Assertions.assertNotNull(distribution, "a partitioned INSERT must be
routed by its partition value");
+ Assertions.assertEquals(ConnectorWriteDistribution.Mode.EXTERNAL_HASH,
distribution.getMode());
+ // The route column is the source column ("id"), not the partition
field ("id_bucket"): fe-core binds it to
+ // the sink output, and the BE applies the transform.
+ Assertions.assertEquals(Collections.singletonList("id"),
distribution.getRouteColumns());
+ Assertions.assertEquals("iceberg_transform",
distribution.getPartitionFunction());
+ Assertions.assertEquals(Collections.singletonMap("transforms",
"bucket[16]"),
+ distribution.getPartitionFunctionOptions());
+
Assertions.assertEquals(ConnectorWriteDistribution.WriterAssignment.SKEWED,
distribution.getWriterAssignment());
+ }
+
+ @Test
+ public void getWriteDistributionCarriesEveryPartitionFieldInSpecOrder() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "name", Types.StringType.get()),
+ Types.NestedField.optional(3, "ts",
Types.TimestampType.withoutZone()));
+ Table table = freshCatalog().createTable(TableIdentifier.of("db1",
"multi_field"), schema,
+
PartitionSpec.builderFor(schema).day("ts").identity("name").bucket("id",
4).build());
+
+ ConnectorWriteDistribution distribution = providerFor(table,
contextWithStorage())
+ .getWriteDistribution(sessionFor(table, contextWithStorage()),
+ new IcebergTableHandle("db1", "multi_field"));
+
+ // The BE pairs the i-th route expression with the i-th transform, so
both lists follow the spec order.
+ Assertions.assertEquals(Arrays.asList("ts", "name", "id"),
distribution.getRouteColumns());
+ Assertions.assertEquals(Collections.singletonMap("transforms",
"day,identity,bucket[4]"),
+ distribution.getPartitionFunctionOptions());
+ }
+
+ @Test
+ public void
getWriteDistributionKeepsRandomWritesWhenRoutingIsUnavailable() {
+ InMemoryCatalog catalog = freshCatalog();
+ partitionedSortedTable(catalog);
+ Table unpartitioned = unpartitionedUnsortedTable(catalog);
+ Assertions.assertNull(providerFor(unpartitioned,
contextWithStorage()).getWriteDistribution(
+ sessionFor(unpartitioned, contextWithStorage()), new
IcebergTableHandle("db1", "t2")));
+
+ // The route columns are top-level sink outputs; a nested source has
none, so keep the random write.
+ // MUTATION: resolving the nested source to its top-level struct
column -> routes by "payload" -> red.
+ Schema nestedSchema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "payload", Types.StructType.of(
+ Types.NestedField.optional(3, "part",
Types.IntegerType.get()))));
+ Table nested = catalog.createTable(TableIdentifier.of("db1",
"nested_partition"), nestedSchema,
+ PartitionSpec.builderFor(nestedSchema).bucket("payload.part",
8).build());
+ Assertions.assertNull(providerFor(nested,
contextWithStorage()).getWriteDistribution(
+ sessionFor(nested, contextWithStorage()), new
IcebergTableHandle("db1", "nested_partition")));
+
+ // A backend older than exec version 18 rejects iceberg_transform, so
the INSERT keeps the random write it
+ // already runs there. MUTATION: dropping the version gate -> red.
+ Table partitioned = bucketPartitionedTable(catalog);
+ IcebergTableHandle handle = new IcebergTableHandle("db1", "tb");
+ Assertions.assertNull(providerFor(partitioned,
contextWithStorage()).getWriteDistribution(
+ sessionFor(partitioned,
contextWithStorage()).beExecVersion(17), handle));
+ Assertions.assertNotNull(providerFor(partitioned,
contextWithStorage()).getWriteDistribution(
+ sessionFor(partitioned,
contextWithStorage()).beExecVersion(18), handle));
+ }
+
+ @Test
+ public void getWriteDistributionUsesTheStatementPinnedSchema() {
+ Table table = bucketPartitionedTable(freshCatalog());
+ IcebergWritePlanProvider provider = providerFor(table,
contextWithStorage());
+ WriteSession session = sessionFor(table, contextWithStorage());
+ IcebergTableHandle handle = new IcebergTableHandle("db1", "tb");
+ // Binding the INSERT pins the write schema for the statement; the
sink output carries its column names.
+ provider.getWriteColumns(session, handle, Optional.empty());
+ table.updateSchema().renameColumn("id", "renamed_id").commit();
+
+ // MUTATION: reading the live table instead of the pinned schema ->
routes by "renamed_id", which the bound
+ // sink output does not have -> red.
+ Assertions.assertEquals(Collections.singletonList("id"),
+ provider.getWriteDistribution(session,
handle).getRouteColumns());
+ }
+
+ @Test
+ public void
getWriteDistributionKeepsRandomWritesForBinaryPartitionValues() {
+ // The exchange hashes the transformed value, and the BE column of a
BINARY or FIXED value has no shuffle
+ // hash: routing identity(binary_key) fails the INSERT once it has two
writers. bucket() turns the bytes
+ // into an INT, so that one is routed. MUTATION: dropping the
transformed-type check -> the first four
+ // tables route -> red.
+ InMemoryCatalog catalog = freshCatalog();
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "binary_key",
Types.BinaryType.get()),
+ Types.NestedField.optional(3, "fixed_key",
Types.FixedType.ofLength(4)));
+ Map<String, PartitionSpec> unroutable = new LinkedHashMap<>();
+ unroutable.put("identity_binary",
PartitionSpec.builderFor(schema).identity("binary_key").build());
+ unroutable.put("truncate_binary",
PartitionSpec.builderFor(schema).truncate("binary_key", 1).build());
+ unroutable.put("identity_fixed",
PartitionSpec.builderFor(schema).identity("fixed_key").build());
+ // One unhashable field is enough: the route hashes every field of the
spec together.
+ unroutable.put("int_and_binary",
+
PartitionSpec.builderFor(schema).identity("id").identity("binary_key").build());
+ for (Map.Entry<String, PartitionSpec> entry : unroutable.entrySet()) {
+ Table table = catalog.createTable(TableIdentifier.of("db1",
entry.getKey()), schema, entry.getValue());
+ Assertions.assertNull(providerFor(table,
contextWithStorage()).getWriteDistribution(
+ sessionFor(table, contextWithStorage()), new
IcebergTableHandle("db1", entry.getKey())),
+ entry.getKey());
+ }
+
+ Table bucketed = catalog.createTable(TableIdentifier.of("db1",
"bucket_binary"), schema,
+ PartitionSpec.builderFor(schema).bucket("binary_key",
16).build());
+ ConnectorWriteDistribution distribution = providerFor(bucketed,
contextWithStorage()).getWriteDistribution(
+ sessionFor(bucketed, contextWithStorage()), new
IcebergTableHandle("db1", "bucket_binary"));
+ Assertions.assertEquals(Collections.singletonList("binary_key"),
distribution.getRouteColumns());
+ Assertions.assertEquals(Collections.singletonMap("transforms",
"bucket[16]"),
+ distribution.getPartitionFunctionOptions());
+ }
+
@Test
public void oldBeRejectsDataWritesUsingNestedPartitionSources() {
InMemoryCatalog catalog = freshCatalog();
@@ -2194,6 +2318,7 @@ public class IcebergWritePlanProviderTest {
// Default to a live memoizing scope so a v3 row-level DML does not
trip planWrite's NONE fail-loud; the
// dedicated fail-loud test forces NONE via noScope().
private ConnectorStatementScope statementScope = new
TestStatementScope();
+ private int beExecVersion = Integer.MAX_VALUE;
WriteSession(ConnectorTransaction txn) {
this(txn, "q");
@@ -2204,6 +2329,16 @@ public class IcebergWritePlanProviderTest {
this.queryId = queryId;
}
+ WriteSession beExecVersion(int version) {
+ this.beExecVersion = version;
+ return this;
+ }
+
+ @Override
+ public int getBeExecVersion() {
+ return beExecVersion;
+ }
+
/** Shares an explicit scope (so a test can pre-fill the
rewritable-delete supply the write then reads). */
WriteSession withScope(ConnectorStatementScope scope) {
this.statementScope = scope;
diff --git
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSession.java
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSession.java
index 111e46ac7ff..e305338266e 100644
---
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSession.java
+++
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/ConnectorSession.java
@@ -114,6 +114,17 @@ public interface ConnectorSession {
getSessionProperties().get("enable_external_scan_task_reuse"));
}
+ /**
+ * Query-wide backend execution version selected by the engine, for
planning decisions that depend on a
+ * backend capability (for example a write distribution whose partition
function older backends reject).
+ * Mirrors {@link
org.apache.doris.connector.spi.handle.ConnectorWriteHandle#getBeExecVersion()},
which
+ * carries the same value at {@code planWrite}. Offline and third-party
sessions default to the
+ * newest-capability side of any fence.
+ */
+ default int getBeExecVersion() {
+ return Integer.MAX_VALUE;
+ }
+
/**
* Returns the transaction this session is currently bound to, if any.
*
diff --git
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index 134d018f5c2..35ee4c05eb6 100644
---
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -87,6 +87,7 @@
org.apache.doris.connector.spi.ConnectorProvider#validateCreateTable(java.util.M
org.apache.doris.connector.spi.ConnectorProvider#validateProperties(java.util.Map):void
org.apache.doris.connector.spi.ConnectorProvider#validatePropertiesForUpdate(java.util.Map,java.util.Map):void
org.apache.doris.connector.spi.ConnectorSession#allocateTransactionId():long
+org.apache.doris.connector.spi.ConnectorSession#getBeExecVersion():int
org.apache.doris.connector.spi.ConnectorSession#getCatalogId():long
org.apache.doris.connector.spi.ConnectorSession#getCatalogName():java.lang.String
org.apache.doris.connector.spi.ConnectorSession#getCatalogProperties():java.util.Map
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java
index bdaeae2f4e9..d5989123614 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionBuilder.java
@@ -176,7 +176,25 @@ public final class ConnectorSessionBuilder {
}
return new ConnectorSessionImpl(queryId, user, timeZone, locale,
catalogId, catalogName, catalogProperties, sessionProperties,
sid, cred,
- captureStatementScope());
+ captureStatementScope(), captureBeExecVersion());
+ }
+
+ /**
+ * Captures the backend execution version that NereidsPlanner pinned for
the current statement, which the
+ * query options sent to the backends and the write handle carry too. It
is read off the same statement
+ * context as {@link #captureStatementScope()}. Without a statement
context, or before the planner pins the
+ * version (offline planning, commands that plan no query, tests), it is
{@code Config.be_exec_version}.
+ */
+ private int captureBeExecVersion() {
+ ConnectContext ctx = connectContext != null ? connectContext :
ConnectContext.get();
+ if (ctx == null) {
+ return Config.be_exec_version;
+ }
+ StatementContext sc = ctx.getStatementContext();
+ if (sc == null || !sc.hasBeExecVersion()) {
+ return Config.be_exec_version;
+ }
+ return sc.getBeExecVersion();
}
/**
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java
index 9a2624c5e81..ffd66d2b0cd 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/connector/ConnectorSessionImpl.java
@@ -18,6 +18,7 @@
package org.apache.doris.connector;
import org.apache.doris.catalog.Env;
+import org.apache.doris.common.Config;
import org.apache.doris.connector.spi.ConnectorDelegatedCredential;
import org.apache.doris.connector.spi.ConnectorSession;
import org.apache.doris.connector.spi.ConnectorStatementScope;
@@ -53,6 +54,9 @@ public class ConnectorSessionImpl implements ConnectorSession
{
// reachable) so off-thread scan pumps that reuse this one session still
reach it. NONE when there is no
// live statement context (offline planning, tests) -- then
getStatementScope() memoizes nothing.
private final ConnectorStatementScope statementScope;
+ // The backend execution version the planner pinned for the statement,
captured at construction like the
+ // statement scope; Config.be_exec_version when the session is built
outside a planned statement.
+ private final int beExecVersion;
// Otherwise-immutable session; this is bound once by the insert executor
at write time
// for connectors using the SPI transaction model (e.g. maxcompute), and
read back by the
// connector's planWrite via getCurrentTransaction(). volatile for
cross-thread visibility.
@@ -62,13 +66,14 @@ public class ConnectorSessionImpl implements
ConnectorSession {
long catalogId, String catalogName, Map<String, String>
catalogProperties,
Map<String, String> sessionProperties) {
this(queryId, user, timeZone, locale, catalogId, catalogName,
catalogProperties, sessionProperties,
- null, null, ConnectorStatementScope.NONE);
+ null, null, ConnectorStatementScope.NONE,
Config.be_exec_version);
}
ConnectorSessionImpl(String queryId, String user, String timeZone, String
locale,
long catalogId, String catalogName, Map<String, String>
catalogProperties,
Map<String, String> sessionProperties, String sessionId,
- ConnectorDelegatedCredential delegatedCredential,
ConnectorStatementScope statementScope) {
+ ConnectorDelegatedCredential delegatedCredential,
ConnectorStatementScope statementScope,
+ int beExecVersion) {
this.queryId = queryId != null ? queryId : "";
this.user = user != null ? user : "";
this.timeZone = timeZone != null ? timeZone : "UTC";
@@ -82,6 +87,7 @@ public class ConnectorSessionImpl implements ConnectorSession
{
this.sessionId = sessionId;
this.delegatedCredential = delegatedCredential;
this.statementScope = statementScope != null ? statementScope :
ConnectorStatementScope.NONE;
+ this.beExecVersion = beExecVersion;
}
@Override
@@ -165,6 +171,13 @@ public class ConnectorSessionImpl implements
ConnectorSession {
return sessionProperties;
}
+ @Override
+ public int getBeExecVersion() {
+ // The statement's pinned version, which the write handle reports and
the backends receive too, so
+ // optimizer-time choices match planWrite and the dispatched plan.
+ return beExecVersion;
+ }
+
@Override
public long allocateTransactionId() {
return Env.getCurrentEnv().getNextId();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
index eb2b2b46ce9..d59e5edfc21 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/connector/ConnectorSessionImplTest.java
@@ -17,6 +17,7 @@
package org.apache.doris.connector;
+import org.apache.doris.common.Config;
import org.apache.doris.connector.spi.ConnectorDelegatedCredential;
import org.apache.doris.connector.spi.ConnectorSession;
import org.apache.doris.connector.spi.ConnectorStatementScope;
@@ -99,6 +100,47 @@ public class ConnectorSessionImplTest {
}
}
+ @Test
+ public void testBeExecVersionIsTheVersionPinnedForTheStatement() {
+ // Connectors gate optimizer-time choices on it (an Iceberg
partition-hash write needs exec version 18). The
+ // planner pins one version per statement, and the query options the
backends receive and the write handle
+ // carry it, so the session must report it too: an ADMIN change of
be_exec_version while the statement is
+ // planned must not put a version-18 partition function into a plan
sent at version 17. MUTATION: reading
+ // Config.be_exec_version in getBeExecVersion() -> red.
+ int original = Config.be_exec_version;
+ ConnectContext ctx = new ConnectContext();
+ ctx.setThreadLocalInfo();
+ try {
+ StatementContext stmtCtx = new StatementContext();
+ ctx.setStatementContext(stmtCtx);
+ stmtCtx.initializeBeExecVersion(17);
+ Config.be_exec_version = 18;
+ Assertions.assertEquals(17,
ConnectorSessionBuilder.from(ctx).build().getBeExecVersion());
+ Assertions.assertEquals(17,
ConnectorSessionBuilder.create().build().getBeExecVersion(),
+ "a session built on the statement's thread reads the
thread-local statement context");
+ } finally {
+ Config.be_exec_version = original;
+ ConnectContext.remove();
+ }
+ }
+
+ @Test
+ public void
testBeExecVersionFallsBackToTheEngineConfigOutsideAPlannedStatement() {
+ // Sessions built where no statement version is pinned (background
loaders, commands that plan no query,
+ // tests) report the configured version.
+ int original = Config.be_exec_version;
+ try {
+ Config.be_exec_version = 16;
+ Assertions.assertEquals(16,
ConnectorSessionBuilder.create().build().getBeExecVersion());
+ ConnectContext ctx = new ConnectContext();
+ ctx.setStatementContext(new StatementContext());
+ Assertions.assertEquals(16,
ConnectorSessionBuilder.from(ctx).build().getBeExecVersion(),
+ "a statement whose version is not pinned yet");
+ } finally {
+ Config.be_exec_version = original;
+ }
+ }
+
@Test
public void testSessionPropertyOverridesCatalogProperty() {
Map<String, String> catalogProps = new HashMap<>();
diff --git
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
index 3789d7e436a..e60f362fd80 100644
---
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
+++
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.out
@@ -275,3 +275,27 @@
-- !binary_orc_truncate_INSERT_OVERWRITE_TABLE_null --
7
+-- !binary_multi_writer_identity_INSERT_INTO --
+3130 2500 0 9996
+3131 2500 1 9997
+3132 2500 2 9998
+3133 2500 3 9999
+
+-- !binary_multi_writer_identity_INSERT_OVERWRITE_TABLE --
+3130 2500 0 9996
+3131 2500 1 9997
+3132 2500 2 9998
+3133 2500 3 9999
+
+-- !binary_multi_writer_truncate_INSERT_INTO --
+3130 2500 0 9996
+3131 2500 1 9997
+3132 2500 2 9998
+3133 2500 3 9999
+
+-- !binary_multi_writer_truncate_INSERT_OVERWRITE_TABLE --
+3130 2500 0 9996
+3131 2500 1 9997
+3132 2500 2 9998
+3133 2500 3 9999
+
diff --git
a/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.out
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.out
new file mode 100644
index 00000000000..23aa7647ba6
--- /dev/null
+++
b/regression-test/data/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.out
@@ -0,0 +1,25 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !identity_plan --
+PhysicalConnectorTableSink
+--PhysicalDistribute[DistributionSpecExternalTableSinkHashPartitioned]
+----PhysicalProject
+------PhysicalTVFRelation
+
+-- !identity_rows --
+10000 200 0 9999
+
+-- !identity_files --
+200 1
+
+-- !day_plan --
+PhysicalConnectorTableSink
+--PhysicalDistribute[DistributionSpecExternalTableSinkHashPartitioned]
+----PhysicalProject
+------PhysicalTVFRelation
+
+-- !day_rows --
+10000 300 2024-01-01T00:00 2024-10-26T23:00
+
+-- !day_files --
+300 1
+
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
index 31a592610c8..79995932ee4 100644
---
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_binary_partitions.groovy
@@ -92,6 +92,26 @@ suite("test_iceberg_write_binary_partitions",
"p0,external,iceberg,external_dock
}
}
}
+
+ // The VALUES writes above have a single-instance source, so their
sink has one writer and its exchange
+ // does not hash. A multi-instance source reaches several writers
through a hashing exchange, which must
+ // not route by a binary partition value: the BE cannot hash it
("update_crc32c_batch is not supported
+ // for ColumnVarbinary"). Pin the writers, since a fuzzy session can
pick one.
+ sql "set parallel_pipeline_task_num = 8"
+ for (String transform : ["identity", "truncate"]) {
+ String table = "binary_multi_writer_${transform}"
+ String expression = transform == "identity" ? "binary_key" :
"truncate(1, binary_key)"
+ sql "DROP TABLE IF EXISTS ${table}"
+ spark_iceberg """CREATE TABLE demo.${dbName}.${table} (id BIGINT,
binary_key BINARY)
+ USING iceberg PARTITIONED BY (${expression})
+ TBLPROPERTIES ('format-version'='2',
'write.format.default'='parquet')"""
+ for (String operation : ["INSERT INTO", "INSERT OVERWRITE TABLE"])
{
+ sql """${operation} ${table}
+ SELECT number, CAST(CAST(number % 4 + 10 AS STRING) AS
VARBINARY) FROM numbers("number" = "10000")"""
+ "order_qt_${table}_${operation.replace(' ', '_')}" """
+ SELECT HEX(binary_key), count(*), min(id), max(id) FROM
${table} GROUP BY HEX(binary_key) ORDER BY 1"""
+ }
+ }
} finally {
sql "SWITCH internal"
}
diff --git
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.groovy
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.groovy
new file mode 100644
index 00000000000..6e355f33605
--- /dev/null
+++
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_partition_distribution.groovy
@@ -0,0 +1,128 @@
+// 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.
+
+// A partitioned INSERT is routed by the Iceberg partition value, so one
partition reaches one writer. A write
+// touching more than table_sink_partition_write_max_partition_nums_per_writer
(128) partitions then succeeds,
+// and each partition gets one data file instead of one per writer.
+suite("test_iceberg_write_partition_distribution",
+ "p0,external,iceberg,external_docker,external_docker_iceberg") {
+ String enabled = context.config.otherConfigs.get("enableIcebergTest")
+ if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+ logger.info("disable iceberg test")
+ return
+ }
+
+ String restPort = context.config.otherConfigs.get("iceberg_rest_uri_port")
+ String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+ String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+ String catalogName = "test_iceberg_write_partition_distribution"
+ String dbName = "iceberg_write_partition_distribution_db"
+
+ sql """drop catalog if exists ${catalogName}"""
+ sql """
+ create catalog ${catalogName} properties (
+ "type" = "iceberg",
+ "iceberg.catalog.type" = "rest",
+ "uri" = "http://${externalEnvIp}:${restPort}",
+ "s3.access_key" = "admin",
+ "s3.secret_key" = "password",
+ "s3.endpoint" = "http://${externalEnvIp}:${minioPort}",
+ "s3.region" = "us-east-1",
+ "meta.cache.iceberg.table.ttl-second" = "0",
+ "meta.cache.iceberg.schema.ttl-second" = "0"
+ )
+ """
+ sql """switch ${catalogName}"""
+ sql """drop database if exists ${dbName} force"""
+ sql """create database ${dbName}"""
+ sql """use ${dbName}"""
+ // The writers follow the source fragment's parallelism, and the
128-partition cap applies to the partitions
+ // routed to each writer. Pin enough writers for 300 partitions: a fuzzy
session can pick one or two.
+ sql """set parallel_pipeline_task_num = 8"""
+
+ // Identity partitions: 200 partitions in one INSERT.
+ sql """drop table if exists identity_partitions"""
+ sql """
+ create table identity_partitions (
+ id int,
+ p int
+ )
+ partition by list (p) ()
+ properties (
+ "format-version" = "2",
+ "write.format.default" = "parquet"
+ )
+ """
+ qt_identity_plan """
+ explain shape plan
+ insert into identity_partitions
+ select number, number % 200 from numbers("number" = "10000")
+ """
+ sql """
+ insert into identity_partitions
+ select number, number % 200 from numbers("number" = "10000")
+ """
+ qt_identity_rows """
+ select count(*), count(distinct p), min(id), max(id) from
identity_partitions
+ """
+ // One writer owns each partition, so every partition holds exactly one
data file.
+ qt_identity_files """
+ select count(*), max(file_count)
+ from (
+ select `partition`, count(*) as file_count
+ from identity_partitions\$files
+ group by `partition`
+ ) partition_file_counts
+ """
+
+ // Transform partitions: rows of one day carry different timestamps, so
routing by the raw source value would
+ // split a day across writers. The hash must cover day(ts).
+ sql """drop table if exists day_partitions"""
+ sql """
+ create table day_partitions (
+ id int,
+ ts datetime
+ )
+ partition by list (day(ts)) ()
+ properties (
+ "format-version" = "2",
+ "write.format.default" = "parquet"
+ )
+ """
+ qt_day_plan """
+ explain shape plan
+ insert into day_partitions
+ select number, hours_add(cast('2024-01-01 00:00:00' as datetime),
number % 7200)
+ from numbers("number" = "10000")
+ """
+ sql """
+ insert into day_partitions
+ select number, hours_add(cast('2024-01-01 00:00:00' as datetime),
number % 7200)
+ from numbers("number" = "10000")
+ """
+ qt_day_rows """
+ select count(*), count(distinct date(ts)), min(ts), max(ts) from
day_partitions
+ """
+ qt_day_files """
+ select count(*), max(file_count)
+ from (
+ select `partition`, count(*) as file_count
+ from day_partitions\$files
+ group by `partition`
+ ) partition_file_counts
+ """
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]