This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 934f7c6166 [core] Report reachable dynamic-bucket max-buckets
misconfiguration clearly (#10104)
934f7c6166 is described below
commit 934f7c6166e8578b3b37a1706b2490f404c46727
Author: YangJie <[email protected]>
AuthorDate: Thu Sep 24 02:37:44 2026 -0400
[core] Report reachable dynamic-bucket max-buckets misconfiguration clearly
(#10104)
---
.../org/apache/paimon/index/PartitionIndex.java | 11 +++++++
.../paimon/index/SimpleHashBucketAssigner.java | 15 ++++++++++
.../paimon/index/HashBucketAssignerTest.java | 35 ++++++++++++++++++++++
.../paimon/index/SimpleHashBucketAssignerTest.java | 21 +++++++++++++
4 files changed, 82 insertions(+)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/PartitionIndex.java
b/paimon-core/src/main/java/org/apache/paimon/index/PartitionIndex.java
index 7a0a0802eb..aa239f4be8 100644
--- a/paimon-core/src/main/java/org/apache/paimon/index/PartitionIndex.java
+++ b/paimon-core/src/main/java/org/apache/paimon/index/PartitionIndex.java
@@ -118,6 +118,17 @@ public class PartitionIndex {
}
// 4. exceed buckets upper bound
+ if (totalBucketArray.isEmpty()) {
+ // this assigner owns no bucket at all: the bucket filter rejected
every bucket id
+ // below the upper bound, which happens when the upper bound is
smaller than the
+ // number of assigners
+ throw new RuntimeException(
+ String.format(
+ "Cannot assign a bucket: the bucket filter
rejected all buckets under "
+ + "the max buckets number %s. Check '%s'
is not smaller than "
+ + "the writer parallelism.",
+ maxBucketsNum, DYNAMIC_BUCKET_MAX_BUCKETS.key()));
+ }
int bucket = ListUtils.pickRandomly(totalBucketArray);
hash2Bucket.put(hash, toBucketShort(bucket));
return bucket;
diff --git
a/paimon-core/src/main/java/org/apache/paimon/index/SimpleHashBucketAssigner.java
b/paimon-core/src/main/java/org/apache/paimon/index/SimpleHashBucketAssigner.java
index e0f0b56e85..a14cefd819 100644
---
a/paimon-core/src/main/java/org/apache/paimon/index/SimpleHashBucketAssigner.java
+++
b/paimon-core/src/main/java/org/apache/paimon/index/SimpleHashBucketAssigner.java
@@ -30,7 +30,9 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
+import static org.apache.paimon.CoreOptions.DYNAMIC_BUCKET_MAX_BUCKETS;
import static org.apache.paimon.CoreOptions.MAX_DYNAMIC_BUCKETS;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
/** When we need to overwrite the table, we should use this to avoid loading
index. */
public class SimpleHashBucketAssigner implements BucketAssigner {
@@ -45,6 +47,19 @@ public class SimpleHashBucketAssigner implements
BucketAssigner {
public SimpleHashBucketAssigner(
int numAssigners, int assignId, long targetBucketRowNumber, int
maxBucketsNum) {
PartitionIndex.validateMaxBuckets(maxBucketsNum);
+ // buckets are owned by 'bucket % numAssigners == assignId %
numAssigners', so the
+ // smallest bucket this assigner owns is assignId % numAssigners. With
a cap that does
+ // not admit it, this assigner owns no bucket and would silently
default to bucket 0,
+ // which belongs to another assigner.
+ checkArgument(
+ maxBucketsNum == -1 || maxBucketsNum > assignId % numAssigners,
+ "Dynamic bucket max buckets number %s must be greater than the
assigner id %s: "
+ + "buckets are owned by 'bucket modulo assigners', so
this assigner "
+ + "would own no bucket and its records could not be
placed. "
+ + "Increase '%s' or reduce the assigner parallelism.",
+ maxBucketsNum,
+ assignId % numAssigners,
+ DYNAMIC_BUCKET_MAX_BUCKETS.key());
this.numAssigners = numAssigners;
this.assignId = assignId;
this.targetBucketRowNumber = targetBucketRowNumber;
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/HashBucketAssignerTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/HashBucketAssignerTest.java
index d750dc83f3..aeff1be8bd 100644
---
a/paimon-core/src/test/java/org/apache/paimon/index/HashBucketAssignerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/index/HashBucketAssignerTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.io.DataIncrement;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.sink.CommitMessageImpl;
import org.apache.paimon.table.sink.StreamTableCommit;
+import org.apache.paimon.utils.Int2ShortHashMap;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -85,6 +86,40 @@ public class HashBucketAssignerTest extends
PrimaryKeyTableTestBase {
maxBucketsNum);
}
+ @Test
+ public void testAssignFailsDescriptivelyWhenAssignerOwnsNoBucket() {
+ // a bucket filter rejecting every id below the cap leaves the random
pick with an
+ // empty bucket list; the error must name the cause instead of "list
is empty"
+ PartitionIndex index = new PartitionIndex(new Int2ShortHashMap(), new
HashMap<>(), 100);
+ assertThatThrownBy(() -> index.assign(42, bucket -> false, 2))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("dynamic-bucket.max-buckets");
+ }
+
+ @Test
+ public void testFreshPartitionFailsDescriptivelyWhenAssignerOwnsNoBucket()
{
+ // with a cap smaller than the assigner number, the assigner whose id
reaches the cap
+ // owns no bucket on a fresh partition: it used to die with an
unrelated "list is
+ // empty" from the random pick over its empty bucket list
+ HashBucketAssigner assigner2 = createAssigner(3, 3, 2, 1);
+ assertThatThrownBy(() -> assigner2.assign(row(9), 123))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("dynamic-bucket.max-buckets");
+ }
+
+ @Test
+ public void testAssignReusesLoadedBucketBeyondCap() {
+ // an assigner that already owns a loaded bucket beyond the cap (the
table's
+ // max-buckets was lowered after the bucket was created) must keep
serving that
+ // bucket, not throw: only the genuinely bucketless case is rejected
+ Map<Integer, Long> loaded = new HashMap<>();
+ loaded.put(5, 1L); // bucket 5 is owned by assigner 2 of 3 and already
at the row cap
+ PartitionIndex index = new PartitionIndex(new Int2ShortHashMap(),
loaded, 1);
+ // cap 2 admits only buckets 0 and 1; bucket 5 is beyond it but
already loaded, so the
+ // assign falls through to the random pick over the loaded bucket
rather than failing
+ assertThat(index.assign(42, bucket -> bucket % 3 == 2,
2)).isEqualTo(5);
+ }
+
@Test
public void testAssign() {
HashBucketAssigner assigner = createAssigner(2, 2, 0);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/index/SimpleHashBucketAssignerTest.java
b/paimon-core/src/test/java/org/apache/paimon/index/SimpleHashBucketAssignerTest.java
index c354eda5f5..8e18a570a1 100644
---
a/paimon-core/src/test/java/org/apache/paimon/index/SimpleHashBucketAssignerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/index/SimpleHashBucketAssignerTest.java
@@ -29,11 +29,32 @@ import java.util.Map;
import static org.apache.paimon.io.DataFileTestUtils.row;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.entry;
/** Tests for {@link SimpleHashBucketAssigner}. */
public class SimpleHashBucketAssignerTest {
+ @Test
+ public void testRejectsMaxBucketsSmallerThanAssignerNumber() {
+ // buckets are owned by 'bucket % assigners', so with a cap of 2 and 4
assigners the
+ // assigner with id 3 owns no bucket: it used to default to bucket 0
(owned by
+ // another assigner) and write all its records there
+ assertThatThrownBy(() -> new SimpleHashBucketAssigner(4, 3, 100, 2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dynamic-bucket.max-buckets");
+
+ // the exact boundary also leaves this assigner bucketless: it owns
buckets 3, 7, ...
+ // but a cap of 3 only admits buckets 0-2
+ assertThatThrownBy(() -> new SimpleHashBucketAssigner(4, 3, 100, 3))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dynamic-bucket.max-buckets");
+
+ // unlimited and the tightest legal cap (assigner id + 1) are both fine
+ new SimpleHashBucketAssigner(4, 3, 100, -1);
+ new SimpleHashBucketAssigner(4, 3, 100, 4);
+ }
+
@Test
public void testOverflowIsSpreadAcrossAllBuckets() {
// A single assigner owns every bucket id, so a cap of 4 means buckets
0..3.