Skip to content

branch-4.1: [feature](external) support partition- and bucket-aware concurrent writes - #66685

Draft
suxiaogang223 wants to merge 13 commits into
apache:branch-4.1from
suxiaogang223:codex/paimon-write-distribution
Draft

branch-4.1: [feature](external) support partition- and bucket-aware concurrent writes#66685
suxiaogang223 wants to merge 13 commits into
apache:branch-4.1from
suxiaogang223:codex/paimon-write-distribution

Conversation

@suxiaogang223

@suxiaogang223 suxiaogang223 commented Aug 12, 2026

Copy link
Copy Markdown
Member

What

This change introduces a connector-independent write-distribution framework for external table sinks, with Iceberg and Paimon as its first consumers.

  • let each connector produce a write-distribution plan containing:
    • the routing kind: random, single writer, stateless hash, or adaptive hash
    • hidden routing expressions
    • optional routing-cardinality bounds
    • a safe single-writer fallback reason
  • materialize routing expressions through a normal Project and reuse standard Doris HASH/GATHER exchanges
  • plan table-writer parallelism in FE from fragment capacity, input statistics, and estimated routing-key cardinality
  • keep writer planning in FE without introducing connector-specific Exchange or Thrift protocols

For Iceberg:

  • evaluate Iceberg partition transforms in Doris, including:
    • identity
    • year, month, day, and hour
    • bucket
    • truncate
  • use transformed partition values instead of raw source columns as shuffle keys
  • preserve random concurrent writing for unpartitioned tables
  • use adaptive hash distribution for partitioned tables, colocating normal partition traffic while retaining the existing ability to fan out hot partitions

For Paimon:

  • support concurrent writes for HASH_FIXED tables using Paimon-compatible native routing
  • calculate the partition hash and fixed bucket directly from Doris Blocks
  • route each (partition, bucket) to one writer within a write job
  • avoid converting Blocks to Arrow and invoking the Java SDK merely to calculate routing results
  • retain the safe single-writer behavior for:
    • HASH_DYNAMIC
    • KEY_DYNAMIC
    • bucket-unaware and postpone modes
    • custom bucket functions
    • partition or bucket key types not supported by native routing

The change also bounds Paimon JNI writer memory:

  • apply one process-wide limit based on JVM -Xmx * paimon_jni_memory_limit_ratio
  • use 0.5 as the default ratio
  • share the limit across all Paimon writers on the BE
  • account for Doris-managed Paimon native pages and Java Arrow direct memory
  • fail the current Paimon write when the hard limit is exceeded instead of allowing an uncontrolled process OOM
  • expose current, peak, limit, and rejected-allocation counters in the writer profile

Why

External table writers previously lacked a common way to express the ownership unit that should be used for writer distribution.

For Iceberg, routing by raw source columns does not reproduce transforms such as bucket, truncate, or time transforms. Rows belonging to the same physical Iceberg partition could therefore be distributed inconsistently, reducing the effectiveness of concurrent writing and producing unnecessary small files.

For Paimon fixed-bucket tables, concurrent writing must preserve (partition, bucket) ownership. Sending the same bucket to multiple writers may create conflicting writer state and additional files. The previous safe fallback serialized more Paimon writes than necessary.

This change separates connector-specific ownership calculation from the common Doris scheduling path:

  1. the connector describes its routing expressions;
  2. Doris evaluates them as hidden columns;
  3. a standard exchange distributes the rows;
  4. FE chooses an appropriate writer count;
  5. unsupported Paimon modes continue to use one writer.

This provides a reusable foundation for other external table formats without adding format-specific exchange protocols.

Scope

This change only manages writer distribution and writer parallelism. It does not change connector file-rolling policies or target file-size settings.

Stateful Paimon dynamic-bucket assignment and global-index assignment are intentionally not implemented in this change. Those modes continue to use the existing single-writer fallback.

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@suxiaogang223 suxiaogang223 changed the title [feature](external) support partition-aware concurrent writes branch-4.1: [feature](external) support partition-aware concurrent writes Aug 12, 2026
@suxiaogang223
suxiaogang223 force-pushed the codex/paimon-write-distribution branch from 2857f25 to 174fc9d Compare August 12, 2026 11:01
@suxiaogang223 suxiaogang223 changed the title branch-4.1: [feature](external) support partition-aware concurrent writes branch-4.1: [feature](external) support partition- and bucket-aware concurrent writes Aug 12, 2026
@suxiaogang223

Copy link
Copy Markdown
Member Author

run buildall

@suxiaogang223

Copy link
Copy Markdown
Member Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for five substantiated issues: one fixed-bucket execution correctness bug, two hard-limit/configuration bugs, and two writer-parallelism bugs.

Review checkpoints:

  • Routing and transforms: Paimon BinaryRow/hash parity and Iceberg shared-transform/pinned-spec semantics are sound outside the commented constant-column boundary.
  • Planning and scheduling: hidden routing slots survive pruning and translation; SINGLE_WRITER remains one instance in both schedulers. The partitioned legacy cap and partial-statistics issues remain.
  • Memory and lifecycle: Arrow/native-page reservation and release ownership, open/abort/close, failed-close retention, and concurrent-writer accounting are balanced. The JVM-limit derivation and live ratio validation are not.
  • Variant and compatibility: positional Variant V2 validation, static/reordered inserts, and supported BE-before-FE upgrade sequencing were rechecked without additional findings.
  • Tests: the changed tests do not reach the five commented boundaries. Per runner constraints, no builds were run.
  • User focus: no additional focus was supplied.

Three complete review rounds converged with no unresolved candidates or duplicate live inline threads.


bool use_default_implementation_for_nulls() const override { return false; }

ColumnNumbers get_arguments_that_are_always_constant() const override {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Avoid the all-constant fixed-bucket out-of-bounds path

When all fixed-bucket key expressions are constant on a multi-row input (for example, INSERT ... SELECT 1, payload FROM source), the default constant-argument wrapper retains argument 0 as an N-row ColumnConst but unwraps every key to a one-row nested column. The temporary block therefore still reports N rows, and this loop indexes those one-row key columns at rows 1..N-1. Nullable keys first read past the one-byte null map; fixed/string keys likewise index past their nested column, so a valid insert can crash or misroute rows. Please disable the generic constant fast path here (the implementation already handles ColumnConst) or otherwise make the temporary block cardinalities consistent, and cover multi-row constant and constant-NULL keys.

public:
int64_t limit() const {
const long double limit =
static_cast<long double>(Jni::Util::get_max_jni_heap_memory_size()) *

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Use the real JVM heap on USE_LIBHDFS3 builds

get_max_jni_heap_memory_size() returns SIZE_MAX whenever USE_LIBHDFS3 is defined, so the default ratio makes this expression clamp to INT64_MAX. Those builds still create a finite JVM (-Xmx1g by default, or JAVA_OPTS), and this value is passed to both the process limiter and the Arrow allocator; concurrent Paimon writes therefore have no useful hard cap and can exhaust that JVM. The ordinary parser also misreads valid unitless -Xmx values. Please derive the limit from the created VM/runtime and cover both a finite USE_LIBHDFS3 heap and unitless heap options.

Comment thread be/src/common/config.cpp
[](int64_t bytes) -> bool { return bytes > 0; });
// Process-wide hard limit as a fraction of the BE JVM -Xmx.
DEFINE_mDouble(paimon_jni_memory_limit_ratio, "0.5");
DEFINE_Validator(paimon_jni_memory_limit_ratio,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate the requested mutable ratio

This is a mutable config, but UPDATE_FIELD calls the registered validator before assigning new_value; the generated zero-argument validator therefore reads the old global ratio. Starting from 0.5, set_config(..., "2") (and likewise a negative value or NaN) succeeds and only then stores the invalid value, defeating the (0, 1] hard-limit invariant. Please either make this setting immutable or validate the proposed value before committing it, with a live-update regression case.

if (connectContext != null && connectContext.getSessionVariable() != null) {
expectInstanceNum = connectContext.getSessionVariable().getExchangeInstanceParallel();
}
int writerInstanceLimit = fragment.getSink() == null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Enforce writer caps in the legacy scheduler too

This is the only scheduling consumer of getWriterInstanceLimit(). When enable_nereids_distribute_planner=false, the legacy Coordinator instead copies every instance of the largest child fragment (or uses exchange_instance_parallel) for partitioned exchange-root sinks without consulting this cap. A four-bucket Paimon plan that reports planned writers: 4 can therefore still open, for example, 64 JNI writers, defeating the resource/small-writer control on a supported session path. Please apply the same upper bound in the legacy partitioned branch and cover that scheduler mode.

boolean foundKnownInput = false;
for (Slot input : expression.getInputSlots()) {
ColumnStatistic columnStatistic = statistics.findColumnStatistics(input);
if (columnStatistic == null || columnStatistic.isUnKnown

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep capacity when any route input is unknown

For a composite routing expression, this skips unknown input statistics but returns the product of the remaining known NDVs as soon as one is available. Thus a fixed-bucket route with an NDV-1 key plus an unknown high-cardinality key is estimated as one ownership unit and capped to one writer, even though the unknown key can fill every bucket. That silently defeats this PR's scaling goal on common incomplete statistics. Please treat the expression cardinality as unknown when any relevant input is unknown (then retain the connector cap/capacity), and add a mixed-known/unknown test.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 42.29% (203/480) 🎉
Increment coverage report
Complete coverage report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants