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 50bfb69cc6 [core][flink][spark] Support default bucket number for 
postpone writes (#9067)
50bfb69cc6 is described below

commit 50bfb69cc695e757ed4c5d949512610d755361a0
Author: Zouxxyy <[email protected]>
AuthorDate: Thu Aug 6 22:14:58 2026 +0800

    [core][flink][spark] Support default bucket number for postpone writes 
(#9067)
---
 docs/docs/primary-key-table/data-distribution.md   |  37 +--
 docs/generated/core_configuration.html             |   8 +-
 .../main/java/org/apache/paimon/CoreOptions.java   |  19 +-
 .../org/apache/paimon/table/PostponeUtils.java     | 274 +++++++++++----------
 .../paimon/table/source/PostponeMergePlan.java     |  10 -
 .../table/source/PostponeMergeReadBuilder.java     |  45 ++--
 .../org/apache/paimon/table/PostponeUtilsTest.java |  83 +++++--
 .../apache/paimon/table/source/TableScanTest.java  |  41 +--
 .../apache/paimon/flink/action/CompactAction.java  |  25 +-
 .../paimon/flink/source/FlinkSourceBuilder.java    |   6 +-
 .../paimon/flink/PostponeBucketTableITCase.java    |  16 ++
 .../org/apache/paimon/spark/PaimonBaseScan.scala   |   6 +-
 .../apache/paimon/spark/PostponeMergeOnRead.scala  |   3 +-
 .../spark/SparkPostponeStagedCommitter.scala       |   4 +-
 .../paimon/spark/commands/PaimonSparkWriter.scala  |  71 +++++-
 .../paimon/spark/execution/PaimonStrategy.scala    |   2 +-
 .../procedure/SparkPostponeCompactProcedure.scala  |  21 +-
 .../paimon/spark/sql/PostponeBucketTableTest.scala | 156 +++++++++++-
 18 files changed, 552 insertions(+), 275 deletions(-)

diff --git a/docs/docs/primary-key-table/data-distribution.md 
b/docs/docs/primary-key-table/data-distribution.md
index b7a0fecb7c..360febf904 100644
--- a/docs/docs/primary-key-table/data-distribution.md
+++ b/docs/docs/primary-key-table/data-distribution.md
@@ -70,16 +70,18 @@ Postpone bucket mode is configured by `'bucket' = '-2'`.
 This mode aims to solve the difficulty to determine a fixed number of buckets
 and support different buckets for different partitions.
 
-By default, `postpone.batch-write-fixed-bucket` is `true`. This staged 
fixed-bucket flow uses
-Spark's DataSource V1 write path, even when `spark.paimon.write.use-v2-write` 
is enabled. Spark
-completes each batch in three steps:
+By default, `postpone.batch-write-fixed-bucket` is `true`. The fixed-bucket 
flow uses Spark's
+DataSource V1 write path, even when `spark.paimon.write.use-v2-write` is 
enabled. Unless direct
+writing applies, Spark completes each batch in three steps:
 
 1. Write the current batch to uncommitted bucket `-2` files. Spark derives 
each partition's row
    count and file size directly from the staged file metadata; there is no 
extra input scan, cache,
    or per-row statistics pass.
-2. Calculate the required bucket number per touched partition. 
`postpone.target-row-num-per-bucket`,
-   when configured, takes precedence over `postpone.target-size-per-bucket` 
(default `1 GB`). The
-   result is at least `1`, rounded up to a power of two, and capped by
+2. Calculate the required bucket number per touched partition. For a partition 
without real
+   buckets, an explicitly configured `postpone.default-bucket-num` is used 
exactly. Otherwise,
+   `postpone.target-row-num-per-bucket`, when configured, takes precedence over
+   `postpone.target-size-per-bucket` (default `1 GB`). An inferred result is 
at least `1`, rounded
+   up to a power of two, and capped by
    `postpone.batch-write-fixed-bucket.max-parallelism`.
 3. Route the staged records to real buckets and commit them. The current batch 
becomes visible only
    in this commit.
@@ -91,6 +93,14 @@ larger than the existing layout. Different partitions may 
have different target
 The rescale is a separate overwrite commit which changes real buckets only; 
the current batch is
 appended in the following commit.
 
+`postpone.default-bucket-num` has no default value. When it is explicitly 
configured, Spark can
+skip the staged bucket `-2` files and write directly to real buckets for 
`INSERT OVERWRITE`, or
+when the base snapshot contains no real buckets. An overwrite always uses the 
configured number
+exactly and does not rescale the replaced layout. An append to an existing 
real-bucket partition
+ignores this option and still uses the staged batch to decide whether 
rescaling is required. If a
+batch mixes existing and new real-bucket partitions, the whole batch remains 
staged; only the new
+partitions use the configured default.
+
 Previously committed bucket `-2` files are not included in the calculation, 
read, rewritten, or
 deleted by an append or rescale. They remain available to merge-on-read and 
regular postpone
 compaction. `INSERT OVERWRITE` still follows its normal replacement semantics.
@@ -100,14 +110,13 @@ records are first stored in the `bucket-postpone` 
directory of each partition
 and are not available to readers.
 To move these records into the correct bucket and make them readable, run a 
compaction job.
 See `compact` [procedure](../flink/procedures).
-The bucket number for the partitions compacted for the first time
-is configured by the option `postpone.default-bucket-num`, whose default value 
is `1`.
-You can also configure `postpone.target-row-num-per-bucket` to calculate the 
bucket number
-from the row count of the files in the postpone bucket directory.
-The calculated bucket number is `ceil(row_count / 
postpone.target-row-num-per-bucket)`,
-and is at least `1`.
-When this option is configured, it takes precedence over 
`postpone.default-bucket-num`
-for partitions compacted for the first time.
+The bucket number for partitions compacted for the first time can be 
configured by the option
+`postpone.default-bucket-num`. Its value is used exactly and takes precedence 
over automatic
+estimation. Otherwise, `postpone.target-row-num-per-bucket`, when configured, 
calculates the
+bucket number as `ceil(row_count / target_row_count)`. If it is not 
configured, Paimon calculates
+the bucket number as `ceil(postpone_file_size / 
postpone.target-size-per-bucket)`; the target size
+defaults to `1 GB`. Both estimates are at least `1`. Execution parallelism 
does not determine the
+logical bucket number.
 Partitions that already have real bucket files keep their existing bucket 
number.
 
 Finally, when you feel that the bucket number of some partition is too small,
diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 0c0c1a0a8d..a7c8114dd8 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1316,9 +1316,9 @@ For an internal format table in a REST catalog, it also 
makes the catalog own th
         </tr>
         <tr>
             <td><h5>postpone.default-bucket-num</h5></td>
-            <td style="word-wrap: break-word;">1</td>
+            <td style="word-wrap: break-word;">(none)</td>
             <td>Integer</td>
-            <td>Bucket number for the partitions compacted for the first time 
in postpone bucket tables.</td>
+            <td>Optional bucket number for partitions receiving real buckets 
for the first time and for fixed-bucket overwrite writes. The configured value 
is used exactly and takes precedence over automatic bucket estimation. When 
unset, Paimon estimates the bucket number from the target row count or target 
file size.</td>
         </tr>
         <tr>
             <td><h5>postpone.merge-on-read</h5></td>
@@ -1330,13 +1330,13 @@ For an internal format table in a REST catalog, it also 
makes the catalog own th
             <td><h5>postpone.target-row-num-per-bucket</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
             <td>Long</td>
-            <td>Target row number per bucket when estimating the required 
bucket number from the current staged batch or compacting postpone bucket 
files.</td>
+            <td>Target postpone row count per bucket when estimating the 
required bucket number from staged or committed postpone files. When 
configured, this option takes precedence over 
'postpone.target-size-per-bucket'.</td>
         </tr>
         <tr>
             <td><h5>postpone.target-size-per-bucket</h5></td>
             <td style="word-wrap: break-word;">1 gb</td>
             <td>MemorySize</td>
-            <td>Target staged file size per bucket when Spark estimates the 
required bucket number from the current staged batch. This option is ignored 
when 'postpone.target-row-num-per-bucket' is configured.</td>
+            <td>Target postpone file size per bucket when estimating the 
required bucket number from staged or committed postpone files. This option is 
ignored when 'postpone.target-row-num-per-bucket' is configured.</td>
         </tr>
         <tr>
             <td><h5>primary-key</h5></td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 96fdbf731b..3be4d072e1 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2766,23 +2766,23 @@ public class CoreOptions implements Serializable {
     public static final ConfigOption<Integer> POSTPONE_DEFAULT_BUCKET_NUM =
             key("postpone.default-bucket-num")
                     .intType()
-                    .defaultValue(1)
+                    .noDefaultValue()
                     .withDescription(
-                            "Bucket number for the partitions compacted for 
the first time in postpone bucket tables.");
+                            "Optional bucket number for partitions receiving 
real buckets for the first time and for fixed-bucket overwrite writes. The 
configured value is used exactly and takes precedence over automatic bucket 
estimation. When unset, Paimon estimates the bucket number from the target row 
count or target file size.");
 
     public static final ConfigOption<Long> POSTPONE_TARGET_ROW_NUM_PER_BUCKET =
             key("postpone.target-row-num-per-bucket")
                     .longType()
                     .noDefaultValue()
                     .withDescription(
-                            "Target row number per bucket when estimating the 
required bucket number from the current staged batch or compacting postpone 
bucket files.");
+                            "Target postpone row count per bucket when 
estimating the required bucket number from staged or committed postpone files. 
When configured, this option takes precedence over 
'postpone.target-size-per-bucket'.");
 
     public static final ConfigOption<MemorySize> 
POSTPONE_TARGET_SIZE_PER_BUCKET =
             key("postpone.target-size-per-bucket")
                     .memoryType()
                     .defaultValue(MemorySize.parse("1 gb"))
                     .withDescription(
-                            "Target staged file size per bucket when Spark 
estimates the required bucket number from the current staged batch. "
+                            "Target postpone file size per bucket when 
estimating the required bucket number from staged or committed postpone files. "
                                     + "This option is ignored when 
'postpone.target-row-num-per-bucket' is configured.");
 
     public static final ConfigOption<Long> GLOBAL_INDEX_ROW_COUNT_PER_SHARD =
@@ -4465,8 +4465,15 @@ public class CoreOptions implements Serializable {
         return 
options.get(POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR);
     }
 
-    public int postponeDefaultBucketNum() {
-        return options.get(POSTPONE_DEFAULT_BUCKET_NUM);
+    public Optional<Integer> postponeDefaultBucketNum() {
+        Optional<Integer> bucketNum = 
options.getOptional(POSTPONE_DEFAULT_BUCKET_NUM);
+        bucketNum.ifPresent(
+                value ->
+                        checkArgument(
+                                value > 0,
+                                "Option '%s' must be greater than 0.",
+                                POSTPONE_DEFAULT_BUCKET_NUM.key()));
+        return bucketNum;
     }
 
     public Optional<Long> postponeTargetRowNumPerBucket() {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/PostponeUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/table/PostponeUtils.java
index 0d2a77ad0b..7ea59f78a9 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/PostponeUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/PostponeUtils.java
@@ -127,51 +127,59 @@ public class PostponeUtils {
         return result;
     }
 
-    public static PostponeBucketAssigner createPostponeBucketAssigner(
-            FileStoreTable table, long snapshotId, int defaultParallelism) {
-        return loadPostponeBucketAssigner(table, snapshotId, 
defaultParallelism, null);
-    }
-
-    private static PostponeBucketAssigner loadPostponeBucketAssigner(
-            FileStoreTable table,
-            long snapshotId,
-            int defaultParallelism,
-            @Nullable PartitionPredicate partitionFilter) {
-        Map<BinaryRow, Integer> knownNumBuckets =
-                getKnownNumBuckets(table, snapshotId, partitionFilter);
-        Map<BinaryRow, Long> postponeRowCounts =
-                
!table.coreOptions().postponeTargetRowNumPerBucket().isPresent()
-                        ? Collections.emptyMap()
-                        : getPostponeRowCounts(table, snapshotId, 
partitionFilter);
-        Long targetRowNumPerBucket =
-                
table.coreOptions().postponeTargetRowNumPerBucket().orElse(null);
-        int defaultBucketNum =
-                table.coreOptions()
-                                .toConfiguration()
-                                
.contains(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM)
-                        ? table.coreOptions().postponeDefaultBucketNum()
-                        : defaultParallelism;
-        return new PostponeBucketAssigner(
-                knownNumBuckets, targetRowNumPerBucket, postponeRowCounts, 
defaultBucketNum);
-    }
-
-    /** Creates snapshot-bound routing metadata. */
+    public static PostponeBucketNumResolver createPostponeBucketNumResolver(
+            FileStoreTable table, long snapshotId) {
+        return loadPostponeBucketNumResolver(table, snapshotId, null);
+    }
+
+    private static PostponeBucketNumResolver loadPostponeBucketNumResolver(
+            FileStoreTable table, long snapshotId, @Nullable List<BinaryRow> 
postponePartitions) {
+        CoreOptions options = table.coreOptions();
+        Map<BinaryRow, Integer> numBucketsByPartition =
+                postponePartitions == null
+                        ? getKnownNumBuckets(table, snapshotId)
+                        : getKnownNumBuckets(table, snapshotId, 
postponePartitions);
+        Integer configuredDefaultBucketNum = 
options.postponeDefaultBucketNum().orElse(null);
+        if (configuredDefaultBucketNum == null) {
+            Optional<Long> targetRowNumPerBucket = 
options.postponeTargetRowNumPerBucket();
+            if (targetRowNumPerBucket.isPresent()) {
+                checkArgument(
+                        targetRowNumPerBucket.get() > 0,
+                        "Option '%s' must be greater than 0.",
+                        CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key());
+                addEstimatedBucketNums(
+                        numBucketsByPartition,
+                        getPostponeRowCounts(
+                                postponeFileIterator(table, snapshotId, 
postponePartitions)),
+                        targetRowNumPerBucket.get(),
+                        CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key());
+            } else {
+                long targetSizePerBucket = 
options.postponeTargetSizePerBucket();
+                checkArgument(
+                        targetSizePerBucket > 0,
+                        "Option '%s' must be greater than 0.",
+                        CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET.key());
+                addEstimatedBucketNums(
+                        numBucketsByPartition,
+                        getPostponeFileSizes(
+                                postponeFileIterator(table, snapshotId, 
postponePartitions)),
+                        targetSizePerBucket,
+                        CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET.key());
+            }
+        }
+        return new PostponeBucketNumResolver(numBucketsByPartition, 
configuredDefaultBucketNum);
+    }
+
+    /** Creates snapshot-bound routing metadata for partitions containing 
postpone files. */
     public static PostponeBucketRouter createPostponeBucketRouter(
-            FileStoreTable table,
-            long snapshotId,
-            int defaultParallelism,
-            @Nullable PartitionPredicate partitionFilter) {
+            FileStoreTable table, long snapshotId, List<BinaryRow> 
postponePartitions) {
         return newPostponeBucketRouter(
-                table,
-                loadPostponeBucketAssigner(table, snapshotId, 
defaultParallelism, partitionFilter));
+                table, loadPostponeBucketNumResolver(table, snapshotId, 
postponePartitions));
     }
 
     /** Creates routing metadata from bucket numbers decided by an execution 
engine. */
     public static PostponeBucketRouter createPostponeBucketRouter(
-            FileStoreTable table,
-            Map<BinaryRow, Integer> numBucketsByPartition,
-            int defaultBucketNum) {
-        checkArgument(defaultBucketNum > 0, "Default postpone bucket number 
must be positive.");
+            FileStoreTable table, Map<BinaryRow, Integer> 
numBucketsByPartition) {
         Map<BinaryRow, Integer> copied = new HashMap<>();
         for (Map.Entry<BinaryRow, Integer> entry : 
numBucketsByPartition.entrySet()) {
             checkArgument(
@@ -179,13 +187,11 @@ public class PostponeUtils {
                     "Postpone bucket number must be positive.");
             copied.put(entry.getKey().copy(), entry.getValue());
         }
-        return newPostponeBucketRouter(
-                table,
-                new PostponeBucketAssigner(copied, null, 
Collections.emptyMap(), defaultBucketNum));
+        return newPostponeBucketRouter(table, new 
PostponeBucketNumResolver(copied, null));
     }
 
     private static PostponeBucketRouter newPostponeBucketRouter(
-            FileStoreTable table, PostponeBucketAssigner bucketAssigner) {
+            FileStoreTable table, PostponeBucketNumResolver bucketNumResolver) 
{
         List<String> trimmedPrimaryKeys = table.schema().trimmedPrimaryKeys();
         int[] bucketKeyMapping =
                 table.schema().bucketKeys().stream()
@@ -202,7 +208,7 @@ public class PostponeUtils {
                         
PrimaryKeyTableUtils.PrimaryKeyFieldsExtractor.EXTRACTOR.keyFields(
                                 table.schema()));
         return new PostponeBucketRouter(
-                bucketAssigner,
+                bucketNumResolver,
                 keyType,
                 table.schema().logicalBucketKeyType(),
                 bucketKeyMapping,
@@ -210,52 +216,57 @@ public class PostponeUtils {
     }
 
     public static int computeBucketNumByRowCount(long rowCount, long 
targetRowNumPerBucket) {
-        if (targetRowNumPerBucket <= 0) {
-            throw new IllegalArgumentException(
-                    "Option 'postpone.target-row-num-per-bucket' must be 
greater than 0.");
-        }
+        return computeBucketNum(
+                rowCount,
+                targetRowNumPerBucket,
+                CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key());
+    }
 
-        long bucketNum = rowCount <= 0 ? 1 : (rowCount - 1) / 
targetRowNumPerBucket + 1;
+    private static int computeBucketNum(
+            long value, long targetValuePerBucket, String targetOptionKey) {
+        checkArgument(
+                targetValuePerBucket > 0, "Option '%s' must be greater than 
0.", targetOptionKey);
+        long bucketNum = value <= 0 ? 1 : (value - 1) / targetValuePerBucket + 
1;
         if (bucketNum > Integer.MAX_VALUE) {
             throw new IllegalArgumentException(
                     "Computed postpone bucket number "
                             + bucketNum
                             + " exceeds the maximum integer value 
(Integer.MAX_VALUE = "
                             + Integer.MAX_VALUE
-                            + "). Consider increasing 
'postpone.target-row-num-per-bucket' "
+                            + "). Consider increasing '"
+                            + targetOptionKey
+                            + "' "
                             + "to reduce the bucket count.");
         }
         return (int) bucketNum;
     }
 
-    public static int determineBucketNum(
-            BinaryRow partition,
-            Map<BinaryRow, Integer> knownNumBuckets,
-            Optional<Long> targetRowNumPerBucket,
-            Map<BinaryRow, Long> postponeRowCounts,
-            int defaultBucketNum) {
-        return determineBucketNum(
-                partition,
-                knownNumBuckets,
-                targetRowNumPerBucket.orElse(null),
-                postponeRowCounts,
-                defaultBucketNum);
-    }
-
-    public static int determineBucketNum(
+    private static void addEstimatedBucketNums(
+            Map<BinaryRow, Integer> numBucketsByPartition,
+            Map<BinaryRow, Long> valuesByPartition,
+            long targetValuePerBucket,
+            String targetOptionKey) {
+        for (Map.Entry<BinaryRow, Long> entry : valuesByPartition.entrySet()) {
+            if (!numBucketsByPartition.containsKey(entry.getKey())) {
+                numBucketsByPartition.put(
+                        entry.getKey(),
+                        computeBucketNum(entry.getValue(), 
targetValuePerBucket, targetOptionKey));
+            }
+        }
+    }
+
+    static int determineBucketNum(
             BinaryRow partition,
-            Map<BinaryRow, Integer> knownNumBuckets,
-            @Nullable Long targetRowNumPerBucket,
-            Map<BinaryRow, Long> postponeRowCounts,
-            int defaultBucketNum) {
-        Integer knownBucketNum = knownNumBuckets.get(partition);
-        if (knownBucketNum != null) {
-            return knownBucketNum;
-        } else if (targetRowNumPerBucket != null) {
-            return computeBucketNumByRowCount(
-                    postponeRowCounts.getOrDefault(partition, 0L), 
targetRowNumPerBucket);
+            Map<BinaryRow, Integer> numBucketsByPartition,
+            @Nullable Integer configuredDefaultBucketNum) {
+        Integer numBuckets = numBucketsByPartition.get(partition);
+        if (numBuckets != null) {
+            return numBuckets;
+        } else if (configuredDefaultBucketNum != null) {
+            return configuredDefaultBucketNum;
         } else {
-            return defaultBucketNum;
+            throw new IllegalArgumentException(
+                    "Missing postpone bucket number for partition " + 
partition + ".");
         }
     }
 
@@ -278,6 +289,11 @@ public class PostponeUtils {
                 existingBucketNum == null || existingBucketNum > 0,
                 "Existing bucket number must be positive.");
 
+        Optional<Integer> configuredDefaultBucketNum = 
options.postponeDefaultBucketNum();
+        if (existingBucketNum == null && 
configuredDefaultBucketNum.isPresent()) {
+            return new FixedBucketDecision(configuredDefaultBucketNum.get(), 
false);
+        }
+
         int maxBucketNum = 
options.postponeBatchWriteFixedBucketMaxParallelism();
         checkArgument(
                 maxBucketNum > 0,
@@ -453,48 +469,69 @@ public class PostponeUtils {
         return rowCounts;
     }
 
-    public static FileStoreTable tableForPostponeCompact(
+    private static Iterator<ManifestEntry> postponeFileIterator(
+            FileStoreTable table, long snapshotId, @Nullable List<BinaryRow> 
postponePartitions) {
+        SnapshotReader reader =
+                table.newSnapshotReader()
+                        .withSnapshot(snapshotId)
+                        .withBucket(BucketMode.POSTPONE_BUCKET);
+        if (postponePartitions != null) {
+            reader.withPartitionFilter(postponePartitions);
+        }
+        return reader.readFileIterator();
+    }
+
+    static Map<BinaryRow, Long> getPostponeFileSizes(
+            FileStoreTable table, long snapshotId, @Nullable 
PartitionPredicate partitionFilter) {
+        SnapshotReader reader =
+                table.newSnapshotReader()
+                        .withSnapshot(snapshotId)
+                        .withBucket(BucketMode.POSTPONE_BUCKET);
+        if (partitionFilter != null) {
+            reader.withPartitionFilter(partitionFilter);
+        }
+
+        return getPostponeFileSizes(reader.readFileIterator());
+    }
+
+    private static Map<BinaryRow, Long> 
getPostponeFileSizes(Iterator<ManifestEntry> iterator) {
+        Map<BinaryRow, Long> fileSizes = new HashMap<>();
+        while (iterator.hasNext()) {
+            ManifestEntry entry = iterator.next();
+            fileSizes.merge(
+                    entry.partition(),
+                    entry.file().fileSize(),
+                    (left, right) -> Math.addExact(left, right));
+        }
+        return fileSizes;
+    }
+
+    public static FileStoreTable tableForPostponeRewrite(
             FileStoreTable table, int numBuckets, long snapshotId) {
-        Map<String, String> compactOptions = new HashMap<>();
-        compactOptions.put(BUCKET.key(), String.valueOf(numBuckets));
-        compactOptions.put(WRITE_ONLY.key(), "false");
-        compactOptions.put(COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(), 
String.valueOf(snapshotId));
-        return table.copy(compactOptions);
+        Map<String, String> rewriteOptions = new HashMap<>();
+        rewriteOptions.put(BUCKET.key(), String.valueOf(numBuckets));
+        rewriteOptions.put(WRITE_ONLY.key(), "false");
+        rewriteOptions.put(COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(), 
String.valueOf(snapshotId));
+        return table.copy(rewriteOptions);
     }
 
-    /** Snapshot-bound bucket-count assignment. */
-    public static final class PostponeBucketAssigner implements Serializable {
+    /** Resolves the snapshot-bound bucket count of a partition. */
+    public static final class PostponeBucketNumResolver implements 
Serializable {
 
         private static final long serialVersionUID = 1L;
 
-        private final Map<BinaryRow, Integer> knownNumBuckets;
-        @Nullable private final Long targetRowNumPerBucket;
-        private final Map<BinaryRow, Long> postponeRowCounts;
-        private final int defaultBucketNum;
+        private final Map<BinaryRow, Integer> numBucketsByPartition;
+        @Nullable private final Integer configuredDefaultBucketNum;
 
-        private PostponeBucketAssigner(
-                Map<BinaryRow, Integer> knownNumBuckets,
-                @Nullable Long targetRowNumPerBucket,
-                Map<BinaryRow, Long> postponeRowCounts,
-                int defaultBucketNum) {
-            this.knownNumBuckets = knownNumBuckets;
-            this.targetRowNumPerBucket = targetRowNumPerBucket;
-            this.postponeRowCounts = postponeRowCounts;
-            this.defaultBucketNum = defaultBucketNum;
+        private PostponeBucketNumResolver(
+                Map<BinaryRow, Integer> numBucketsByPartition,
+                @Nullable Integer configuredDefaultBucketNum) {
+            this.numBucketsByPartition = numBucketsByPartition;
+            this.configuredDefaultBucketNum = configuredDefaultBucketNum;
         }
 
-        public int assign(BinaryRow partition) {
-            return determineBucketNum(
-                    partition,
-                    knownNumBuckets,
-                    targetRowNumPerBucket,
-                    postponeRowCounts,
-                    defaultBucketNum);
-        }
-
-        private PostponeBucketAssigner withDefaultBucketNum(int 
newDefaultBucketNum) {
-            return new PostponeBucketAssigner(
-                    knownNumBuckets, targetRowNumPerBucket, postponeRowCounts, 
newDefaultBucketNum);
+        public int numBuckets(BinaryRow partition) {
+            return determineBucketNum(partition, numBucketsByPartition, 
configuredDefaultBucketNum);
         }
     }
 
@@ -523,7 +560,7 @@ public class PostponeUtils {
 
         private static final long serialVersionUID = 1L;
 
-        private final PostponeBucketAssigner bucketAssigner;
+        private final PostponeBucketNumResolver bucketNumResolver;
         private final RowType keyType;
         private final RowType bucketKeyType;
         private final int[] bucketKeyMapping;
@@ -532,12 +569,12 @@ public class PostponeUtils {
         @Nullable private transient BucketFunction bucketFunction;
 
         private PostponeBucketRouter(
-                PostponeBucketAssigner bucketAssigner,
+                PostponeBucketNumResolver bucketNumResolver,
                 RowType keyType,
                 RowType bucketKeyType,
                 int[] bucketKeyMapping,
                 CoreOptions.BucketFunctionType bucketFunctionType) {
-            this.bucketAssigner = bucketAssigner;
+            this.bucketNumResolver = bucketNumResolver;
             this.keyType = keyType;
             this.bucketKeyType = bucketKeyType;
             this.bucketKeyMapping = bucketKeyMapping;
@@ -556,18 +593,7 @@ public class PostponeUtils {
         }
 
         public int numBuckets(BinaryRow partition) {
-            return bucketAssigner.assign(partition);
-        }
-
-        public PostponeBucketRouter withDefaultBucketNum(int 
newDefaultBucketNum) {
-            checkArgument(
-                    newDefaultBucketNum > 0, "Default postpone bucket number 
must be positive.");
-            return new PostponeBucketRouter(
-                    bucketAssigner.withDefaultBucketNum(newDefaultBucketNum),
-                    keyType,
-                    bucketKeyType,
-                    bucketKeyMapping,
-                    bucketFunctionType);
+            return bucketNumResolver.numBuckets(partition);
         }
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergePlan.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergePlan.java
index a65e415ee0..b2e6bc460e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergePlan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergePlan.java
@@ -92,16 +92,6 @@ public final class PostponeMergePlan implements 
TableScan.Plan {
         return numPotentialBuckets;
     }
 
-    PostponeMergePlan withDefaultBucketNum(int newDefaultBucketNum) {
-        return new PostponeMergePlan(
-                realSplits,
-                postponeSplits,
-                bucketRouter.withDefaultBucketNum(newDefaultBucketNum),
-                keyType,
-                resultReadType,
-                mergeReadType);
-    }
-
     private static long numPotentialBuckets(
             List<DataSplit> realSplits,
             List<DataSplit> postponeSplits,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergeReadBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergeReadBuilder.java
index c0561c09db..147d12e05a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergeReadBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PostponeMergeReadBuilder.java
@@ -21,6 +21,7 @@ package org.apache.paimon.table.source;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.KeyValueFileStore;
 import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.metrics.MetricRegistry;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.Predicate;
@@ -40,6 +41,7 @@ import javax.annotation.Nullable;
 
 import java.io.Serializable;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -63,7 +65,6 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
     @Nullable private RowType readType;
     @Nullable private transient MetricRegistry metricRegistry;
     @Nullable private transient String readProtectionTagName;
-    private int defaultBucketNum = 1;
 
     private PostponeMergeReadBuilder(FileStoreTable table, @Nullable Snapshot 
snapshot) {
         this.table = table;
@@ -180,12 +181,6 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
         return this;
     }
 
-    public PostponeMergeReadBuilder withDefaultBucketNum(int defaultBucketNum) 
{
-        checkArgument(defaultBucketNum > 0, "Default postpone bucket number 
must be positive.");
-        this.defaultBucketNum = defaultBucketNum;
-        return this;
-    }
-
     public PostponeMergePlan plan() {
         checkArgument(snapshot != null, "Snapshot-bound postpone merge plan 
requires a snapshot.");
         RowType resultReadType = resultReadType();
@@ -218,12 +213,28 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
             postponeReader.withPartitionFilter(partitionFilter);
         }
 
+        List<DataSplit> realSplits = realReader.read().dataSplits();
+        List<DataSplit> postponeSplits =
+                
PostponeUtils.groupPostponeFiles(postponeReader.read().dataSplits());
+        PostponeUtils.PostponeBucketRouter bucketRouter;
+        if (postponeSplits.isEmpty()) {
+            bucketRouter = PostponeUtils.createPostponeBucketRouter(table, 
Collections.emptyMap());
+        } else {
+            List<BinaryRow> postponePartitions =
+                    postponeSplits.stream()
+                            .map(DataSplit::partition)
+                            .distinct()
+                            .collect(Collectors.toList());
+            bucketRouter =
+                    PostponeUtils.createPostponeBucketRouter(
+                            table, snapshot.id(), postponePartitions);
+        }
+
         PostponeMergePlan plan =
                 new PostponeMergePlan(
-                        realReader.read().dataSplits(),
-                        
PostponeUtils.groupPostponeFiles(postponeReader.read().dataSplits()),
-                        PostponeUtils.createPostponeBucketRouter(
-                                table, snapshot.id(), defaultBucketNum, 
partitionFilter),
+                        realSplits,
+                        postponeSplits,
+                        bucketRouter,
                         keyType(),
                         resultReadType,
                         mergeReadType);
@@ -246,18 +257,6 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
                 mergeReadType(resultReadType));
     }
 
-    /** Rebuilds only the routing metadata of an existing plan with a new 
default bucket number. */
-    public PostponeMergePlan reroute(PostponeMergePlan plan, int 
newDefaultBucketNum) {
-        checkArgument(newDefaultBucketNum > 0, "Default postpone bucket number 
must be positive.");
-        if (table.coreOptions()
-                .toConfiguration()
-                .contains(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM)) {
-            return plan;
-        }
-        defaultBucketNum = newDefaultBucketNum;
-        return plan.withDefaultBucketNum(defaultBucketNum);
-    }
-
     @Nullable
     public String readProtectionTagName() {
         return readProtectionTagName;
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/PostponeUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/PostponeUtilsTest.java
index 1d07359b6d..d6c6ddc5d3 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/PostponeUtilsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/PostponeUtilsTest.java
@@ -117,6 +117,28 @@ public class PostponeUtilsTest {
         verify(reader).withPartitionFilter(partitionFilter);
     }
 
+    @Test
+    public void testGetPostponeFileSizesFromSnapshot() {
+        BinaryRow partition = partition(1);
+        PartitionPredicate partitionFilter = mock(PartitionPredicate.class);
+        DataFileMeta file = mock(DataFileMeta.class);
+        when(file.fileSize()).thenReturn(1024L);
+        ManifestEntry entry = mock(ManifestEntry.class);
+        when(entry.partition()).thenReturn(partition);
+        when(entry.file()).thenReturn(file);
+
+        SnapshotReader reader = mock(SnapshotReader.class, RETURNS_SELF);
+        
when(reader.readFileIterator()).thenReturn(Collections.singletonList(entry).iterator());
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.newSnapshotReader()).thenReturn(reader);
+
+        assertThat(PostponeUtils.getPostponeFileSizes(table, 5L, 
partitionFilter))
+                .containsEntry(partition, 1024L);
+        verify(reader).withSnapshot(5L);
+        verify(reader).withBucket(BucketMode.POSTPONE_BUCKET);
+        verify(reader).withPartitionFilter(partitionFilter);
+    }
+
     @Test
     public void testGetLevel0BucketsFromSnapshot() {
         BinaryRow partition = partition(1);
@@ -223,7 +245,7 @@ public class PostponeUtilsTest {
         FileStoreTable copied = mock(FileStoreTable.class);
         when(table.copy(anyMap())).thenReturn(copied);
 
-        assertThat(PostponeUtils.tableForPostponeCompact(table, 4, 
5L)).isSameAs(copied);
+        assertThat(PostponeUtils.tableForPostponeRewrite(table, 4, 
5L)).isSameAs(copied);
 
         @SuppressWarnings("unchecked")
         ArgumentCaptor<Map<String, String>> options = 
ArgumentCaptor.forClass(Map.class);
@@ -262,33 +284,24 @@ public class PostponeUtilsTest {
 
     @Test
     public void testDetermineBucketNum() {
-        Map<BinaryRow, Integer> knownNumBuckets = new HashMap<>();
-        Map<BinaryRow, Long> postponeRowCounts = new HashMap<>();
+        Map<BinaryRow, Integer> numBucketsByPartition = new HashMap<>();
 
         BinaryRow knownPartition = partition(1);
-        BinaryRow targetPartition = partition(2);
-        BinaryRow defaultPartition = partition(3);
+        BinaryRow configuredPartition = partition(2);
+        BinaryRow missingPartition = partition(3);
 
-        knownNumBuckets.put(knownPartition, 4);
-        postponeRowCounts.put(knownPartition, 1000L);
-        postponeRowCounts.put(targetPartition, 450L);
+        numBucketsByPartition.put(knownPartition, 4);
 
-        assertThat(
-                        PostponeUtils.determineBucketNum(
-                                knownPartition, knownNumBuckets, 200L, 
postponeRowCounts, 1))
+        assertThat(PostponeUtils.determineBucketNum(knownPartition, 
numBucketsByPartition, 7))
                 .isEqualTo(4);
-        assertThat(
-                        PostponeUtils.determineBucketNum(
-                                targetPartition, knownNumBuckets, 200L, 
postponeRowCounts, 1))
-                .isEqualTo(3);
-        assertThat(
-                        PostponeUtils.determineBucketNum(
-                                defaultPartition,
-                                knownNumBuckets,
-                                (Long) null,
-                                postponeRowCounts,
-                                7))
+        assertThat(PostponeUtils.determineBucketNum(configuredPartition, 
numBucketsByPartition, 7))
                 .isEqualTo(7);
+        assertThatThrownBy(
+                        () ->
+                                PostponeUtils.determineBucketNum(
+                                        missingPartition, 
numBucketsByPartition, null))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Missing postpone bucket number");
     }
 
     @Test
@@ -341,6 +354,32 @@ public class PostponeUtilsTest {
                         29, 0, 7, CoreOptions.fromMap(lowerLoadFactorOptions));
         assertThat(lowerLoadFactor.targetBucketNum()).isEqualTo(16);
         assertThat(lowerLoadFactor.requiresRescale()).isTrue();
+
+        Map<String, String> configuredDefaultOptions = new 
HashMap<>(optionMap);
+        
configuredDefaultOptions.put(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM.key(), 
"3");
+        CoreOptions optionsWithDefault = 
CoreOptions.fromMap(configuredDefaultOptions);
+        PostponeUtils.FixedBucketDecision configuredDefault =
+                PostponeUtils.decideFixedBucketNum(100, 0, null, 
optionsWithDefault);
+        assertThat(configuredDefault.targetBucketNum()).isEqualTo(3);
+        assertThat(configuredDefault.requiresRescale()).isFalse();
+
+        PostponeUtils.FixedBucketDecision existingIgnoresDefault =
+                PostponeUtils.decideFixedBucketNum(225, 0, 7, 
optionsWithDefault);
+        assertThat(existingIgnoresDefault.targetBucketNum()).isEqualTo(16);
+        assertThat(existingIgnoresDefault.requiresRescale()).isTrue();
+    }
+
+    @Test
+    public void testDecideFixedBucketNumRejectsInvalidConfiguredDefault() {
+        Map<String, String> optionMap = new HashMap<>();
+        optionMap.put(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM.key(), "0");
+
+        assertThatThrownBy(
+                        () ->
+                                PostponeUtils.decideFixedBucketNum(
+                                        1, 0, null, 
CoreOptions.fromMap(optionMap)))
+                .isInstanceOf(IllegalArgumentException.class)
+                
.hasMessageContaining(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM.key());
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
index e7dd850d63..2f8f8959ed 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/TableScanTest.java
@@ -419,6 +419,8 @@ public class TableScanTest extends ScannerTestBase {
         // A snapshot-bound builder must not pick up postpone files committed 
after its selection.
         PostponeMergePlan realOnlyPlan = realOnlyBuilder.plan();
         assertThat(realOnlyPlan.postponeSplits()).isEmpty();
+        assertThatThrownBy(() -> 
realOnlyPlan.bucketRouter().numBuckets(BinaryRow.singleColumn(1)))
+                .hasMessageContaining("Missing postpone bucket number");
         assertThat(realOnlyPlan.realSplits())
                 .allSatisfy(split -> 
assertThat(split.snapshotId()).isEqualTo(realOnlySnapshotId));
 
@@ -436,7 +438,6 @@ public class TableScanTest extends ScannerTestBase {
                         .get()
                         .withFilter(valueFilter)
                         .withReadType(postponeTable.rowType().project("b"))
-                        .withDefaultBucketNum(1)
                         .plan();
         assertThat(mergePlan.realSplits()).hasSize(3);
         assertThat(mergePlan.postponeSplits()).hasSize(1);
@@ -523,8 +524,7 @@ public class TableScanTest extends ScannerTestBase {
                 PostponeMergeReadBuilder.create(postponeTable, null)
                         .get()
                         .withFilter(partitionFilter)
-                        .withReadType(postponeTable.rowType().project("b"))
-                        .withDefaultBucketNum(1);
+                        .withReadType(postponeTable.rowType().project("b"));
         PostponeMergePlan plan = readBuilder.plan();
 
         assertThat(plan.realSplits()).hasSize(1);
@@ -574,26 +574,37 @@ public class TableScanTest extends ScannerTestBase {
         postponeCommit.close();
 
         PostponeMergeReadBuilder readBuilder =
-                PostponeMergeReadBuilder.create(postponeTable, 
null).get().withDefaultBucketNum(1);
+                PostponeMergeReadBuilder.create(postponeTable, null).get();
         PostponeMergePlan initialPlan = readBuilder.plan();
         assertThat(initialPlan.numPotentialBuckets()).isEqualTo(3);
-
-        PostponeMergePlan plan = readBuilder.reroute(initialPlan, 4);
-
-        // Partition 1 uses its known bucket, partition 2 is real-only, and 
the new partition 3
-        // may route to any of the four default buckets.
-        assertThat(plan.numPotentialBuckets()).isEqualTo(6);
-
-        FileStoreTable explicitDefaultTable =
+        
assertThat(initialPlan.bucketRouter().numBuckets(BinaryRow.singleColumn(1))).isEqualTo(1);
+        assertThatThrownBy(() -> 
initialPlan.bucketRouter().numBuckets(BinaryRow.singleColumn(2)))
+                .hasMessageContaining("Missing postpone bucket number");
+
+        BinaryRow newPartition = BinaryRow.singleColumn(3);
+        long postponeFileSize =
+                initialPlan.postponeSplits().stream()
+                        .filter(split -> 
split.partition().equals(newPartition))
+                        .flatMap(split -> split.dataFiles().stream())
+                        .mapToLong(DataFileMeta::fileSize)
+                        .sum();
+        assertThat(postponeFileSize).isPositive();
+        FileStoreTable sizeEstimatedTable =
                 postponeTable.copy(
                         Collections.singletonMap(
-                                CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM.key(), 
"2"));
+                                
CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET.key(), "1 b"));
+        PostponeMergePlan sizeEstimatedPlan =
+                PostponeMergeReadBuilder.create(sizeEstimatedTable, 
null).get().plan();
+        assertThat(sizeEstimatedPlan.numPotentialBuckets()).isEqualTo(2L + 
postponeFileSize);
+
+        Map<String, String> explicitDefaultOptions = new HashMap<>();
+        
explicitDefaultOptions.put(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM.key(), "2");
+        
explicitDefaultOptions.put(CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key(),
 "100");
+        FileStoreTable explicitDefaultTable = 
postponeTable.copy(explicitDefaultOptions);
         PostponeMergeReadBuilder explicitDefaultBuilder =
                 PostponeMergeReadBuilder.create(explicitDefaultTable, 
null).get();
         PostponeMergePlan explicitDefaultPlan = explicitDefaultBuilder.plan();
         assertThat(explicitDefaultPlan.numPotentialBuckets()).isEqualTo(4);
-        assertThat(explicitDefaultBuilder.reroute(explicitDefaultPlan, 
8).numPotentialBuckets())
-                .isEqualTo(4);
     }
 
     @Test
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
index 501b6aed16..479468c42f 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
@@ -50,6 +50,7 @@ import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.PostponeUtils;
 import org.apache.paimon.table.PostponeUtils.CompactBucket;
+import org.apache.paimon.table.PostponeUtils.PostponeBucketNumResolver;
 import org.apache.paimon.table.sink.ChannelComputer;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.InternalRowPartitionComputer;
@@ -317,20 +318,13 @@ public class CompactAction extends TableActionBase {
                 "Postpone bucket compaction currently does not support 
predicates");
 
         Options options = new Options(table.options());
-        int defaultBucketNum = 
options.get(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM);
-        Optional<Long> targetRowNumPerBucket =
-                
options.getOptional(CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET);
         Optional<Snapshot> optionalSnapshot = table.latestSnapshot();
         if (!optionalSnapshot.isPresent()) {
             return buildNothingToCompact(env);
         }
         long snapshotId = optionalSnapshot.get().id();
-        Map<BinaryRow, Integer> knownNumBuckets =
-                PostponeUtils.getKnownNumBuckets(table, snapshotId);
-        Map<BinaryRow, Long> postponeRowCounts =
-                targetRowNumPerBucket.isPresent()
-                        ? PostponeUtils.getPostponeRowCounts(table, snapshotId)
-                        : Collections.emptyMap();
+        PostponeBucketNumResolver bucketNumResolver =
+                PostponeUtils.createPostponeBucketNumResolver(table, 
snapshotId);
 
         List<BinaryRow> postponePartitions =
                 table.newSnapshotReader()
@@ -359,15 +353,9 @@ public class CompactAction extends TableActionBase {
         String commitUser = CoreOptions.createCommitUser(options);
         List<DataStream<Committable>> dataStreams = new ArrayList<>();
         for (BinaryRow partition : affectedPartitions) {
-            int bucketNum =
-                    PostponeUtils.determineBucketNum(
-                            partition,
-                            knownNumBuckets,
-                            targetRowNumPerBucket,
-                            postponeRowCounts,
-                            defaultBucketNum);
+            int bucketNum = bucketNumResolver.numBuckets(partition);
             FileStoreTable realTable =
-                    PostponeUtils.tableForPostponeCompact(table, bucketNum, 
snapshotId);
+                    PostponeUtils.tableForPostponeRewrite(table, bucketNum, 
snapshotId);
 
             LinkedHashMap<String, String> partitionSpec =
                     partitionComputer.generatePartValues(partition);
@@ -419,8 +407,9 @@ public class CompactAction extends TableActionBase {
             dataStreams.add(sourcePair.getRight());
         }
 
+        int commitBucketNum = 
bucketNumResolver.numBuckets(affectedPartitions.iterator().next());
         FileStoreTable fileStoreTable =
-                PostponeUtils.tableForPostponeCompact(table, defaultBucketNum, 
snapshotId);
+                PostponeUtils.tableForPostponeRewrite(table, commitBucketNum, 
snapshotId);
         FixedBucketSink sink = new FixedBucketSink(fileStoreTable, null);
         DataStream<Committable> dataStream = dataStreams.get(0);
         for (int i = 1; i < dataStreams.size(); i++) {
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
index dca0f3b119..eb3da3e163 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
@@ -246,8 +246,7 @@ public class FlinkSourceBuilder {
         }
 
         int baseParallelism = basePostponeMergeParallelism();
-        PostponeMergeReadBuilder readBuilder =
-                optionalBuilder.get().withDefaultBucketNum(baseParallelism);
+        PostponeMergeReadBuilder readBuilder = optionalBuilder.get();
         org.apache.paimon.types.RowType readType = projectedRowType();
         if (readType != null) {
             readBuilder.withReadType(readType);
@@ -257,9 +256,6 @@ public class FlinkSourceBuilder {
         }
         PostponeMergePlan plan = readBuilder.plan();
         int mergeParallelism = inferPostponeMergeParallelism(plan, 
baseParallelism);
-        if (mergeParallelism != baseParallelism) {
-            plan = readBuilder.reroute(plan, mergeParallelism);
-        }
         return PostponeMergeOnRead.build(
                 env,
                 sourceName,
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PostponeBucketTableITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PostponeBucketTableITCase.java
index c3a020cbd3..6420bda348 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PostponeBucketTableITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/PostponeBucketTableITCase.java
@@ -560,6 +560,22 @@ public class PostponeBucketTableITCase extends 
AbstractTestBase {
                                         "SELECT `partition`, COUNT(DISTINCT 
bucket) FROM `T$files` "
                                                 + "GROUP BY `partition`")))
                 .containsExactlyInAnyOrder("+I[{0}, 1]", "+I[{1}, 3]");
+
+        tEnv.executeSql("ALTER TABLE T SET ('postpone.default-bucket-num' = 
'2')").await();
+        values.clear();
+        for (int j = 0; j < 450; j++) {
+            values.add(String.format("(2, %d, %d)", j, j));
+        }
+        tEnv.executeSql("INSERT INTO T VALUES " + String.join(", ", 
values)).await();
+        tEnv.executeSql("CALL sys.compact(`table` => 'default.T')").await();
+
+        // An explicitly configured default takes precedence over the 
row-count estimate of 3.
+        assertThat(
+                        collect(
+                                tEnv.executeSql(
+                                        "SELECT COUNT(DISTINCT bucket) FROM 
`T$files` "
+                                                + "WHERE `partition` = 
'{2}'")))
+                .containsExactly("+I[2]");
     }
 
     @Timeout(TIMEOUT)
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
index e3f70b8172..dc71a9cfbf 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonBaseScan.scala
@@ -159,7 +159,7 @@ abstract class PaimonBaseScan(table: InnerTable)
   override def estimateStatistics: Statistics = {
     if (postponeMergeOnRead.enabled) {
       val splits =
-        planPostponeMerge(SparkSession.active.sparkContext.defaultParallelism)
+        planPostponeMerge()
           .map(_.corePlan.splits().asScala.toArray)
           .getOrElse(Array.empty[Split])
       PaimonStatistics(splits, readTableRowType, table.rowType(), 
table.statistics())
@@ -168,8 +168,8 @@ abstract class PaimonBaseScan(table: InnerTable)
     }
   }
 
-  final private[spark] def planPostponeMerge(defaultBucketNum: Int): 
Option[MergePlan] = {
-    postponeMergeOnRead.plan(defaultBucketNum)
+  final private[spark] def planPostponeMerge(): Option[MergePlan] = {
+    postponeMergeOnRead.plan()
   }
 
   override def supportedCustomMetrics: Array[CustomMetric] = {
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeOnRead.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeOnRead.scala
index 5d7ad0eb4a..e340be2882 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeOnRead.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeOnRead.scala
@@ -48,7 +48,7 @@ final private[spark] class PostponeMergeOnRead(scan: 
PaimonBaseScan) {
 
   def enabled: Boolean = PostponeMergeOnRead.usesCustomSource(scan.table)
 
-  def plan(defaultBucketNum: Int): Option[MergePlan] = synchronized {
+  def plan(): Option[MergePlan] = synchronized {
     if (!enabled) {
       return None
     }
@@ -64,7 +64,6 @@ final private[spark] class PostponeMergeOnRead(scan: 
PaimonBaseScan) {
 
           builder
             .withReadType(scan.readTableRowType)
-            .withDefaultBucketNum(defaultBucketNum)
             .withMetricRegistry(scan.paimonMetricsRegistry)
           if (scan.pushedDataFilters.nonEmpty) {
             
builder.withFilter(PredicateBuilder.and(scan.pushedDataFilters.toList.asJava))
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkPostponeStagedCommitter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkPostponeStagedCommitter.scala
index 883a797ebe..a4c5babc0d 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkPostponeStagedCommitter.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkPostponeStagedCommitter.scala
@@ -518,7 +518,7 @@ private[spark] class SparkPostponeStagedCommitter(
     // per-partition totalBuckets values, so the copied table's uniform bucket 
number is only a
     // compatibility fallback for the existing commit API.
     val commitTable =
-      PostponeUtils.tableForPostponeCompact(table, 
rescaleBucketNums.values.max, snapshotId)
+      PostponeUtils.tableForPostponeRewrite(table, 
rescaleBucketNums.values.max, snapshotId)
     val commit = commitTable
       .newCommit(fixedWriteCommitUser)
       .appendCommitCheckConflict(true)
@@ -579,7 +579,7 @@ private[spark] class SparkPostponeStagedCommitter(
     bucketNums.foreach {
       case (partition, buckets) => javaBucketNums.put(partition, 
Integer.valueOf(buckets))
     }
-    PostponeUtils.createPostponeBucketRouter(table, javaBucketNums, 1)
+    PostponeUtils.createPostponeBucketRouter(table, javaBucketNums)
   }
 
   private def shuffleParallelism(bucketNums: Map[BinaryRow, Int]): Int = {
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
index ef49aab883..b201b88110 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
@@ -19,9 +19,10 @@
 package org.apache.paimon.spark.commands
 
 import org.apache.paimon.{CoreOptions, Snapshot}
-import org.apache.paimon.CoreOptions.{PartitionSinkStrategy, WRITE_ONLY}
+import org.apache.paimon.CoreOptions.{COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT, 
PartitionSinkStrategy, WRITE_ONLY}
 import org.apache.paimon.codegen.CodeGenUtils
 import org.apache.paimon.crosspartition.{IndexBootstrap, KeyPartOrRow}
+import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.data.serializer.InternalSerializers
 import org.apache.paimon.deletionvectors.DeletionVector
 import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer
@@ -69,6 +70,7 @@ case class PaimonSparkWriter(
   @transient private lazy val serializer = new CommitMessageSerializer
 
   @transient private var stagedSparkSession: SparkSession = _
+  @transient private var directPostponeWriteBuilder: 
PostponeFixedBucketWriteBuilder = _
   private var overwritePartitionSpec: Option[Map[String, String]] = None
 
   private val writeType = {
@@ -92,6 +94,11 @@ case class PaimonSparkWriter(
       Option(table.snapshotManager().latestSnapshot()).map(_.id())
     else None
 
+  private val configuredPostponeDefaultBucketNum: Option[Int] = {
+    val bucketNum = coreOptions.postponeDefaultBucketNum()
+    if (bucketNum.isPresent) Some(bucketNum.get().intValue()) else None
+  }
+
   val writeBuilder: BatchWriteBuilder = table.newBatchWriteBuilder()
 
   def withOverwrite(): PaimonSparkWriter = 
withOverwrite(java.util.Collections.emptyMap())
@@ -121,6 +128,31 @@ case class PaimonSparkWriter(
     val uriReaderFactory = uriReaderFactoryForBlobDescriptor
     import sparkSession.implicits._
 
+    val directPostponeBucketNum =
+      if (
+        postponeBatchWriteFixedBucket && 
configuredPostponeDefaultBucketNum.isDefined &&
+        (overwritePartitionSpec.isDefined || baseSnapshotHasNoRealBuckets)
+      ) {
+        configuredPostponeDefaultBucketNum
+      } else {
+        None
+      }
+    val activeWriteBuilder: BatchWriteBuilder = directPostponeBucketNum match {
+      case Some(_) =>
+        val directWriteOptions = new java.util.HashMap[String, String]()
+        directWriteOptions.put(
+          COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(),
+          postponeBaseSnapshotId.getOrElse(0L).toString)
+        val builder = 
table.copy(directWriteOptions).newPostponeFixedBucketWriteBuilder()
+        overwritePartitionSpec.foreach(spec => 
builder.withOverwrite(spec.asJava))
+        directPostponeWriteBuilder = builder
+        builder
+      case None =>
+        directPostponeWriteBuilder = null
+        writeBuilder
+    }
+    stagedSparkSession = null
+
     val withInitBucketCol = bucketMode match {
       case BUCKET_UNAWARE => data
       case KEY_DYNAMIC if !data.schema.fieldNames.contains(ROW_KIND_COL) =>
@@ -132,16 +164,18 @@ case class PaimonSparkWriter(
     val rowKindColIdx = SparkRowUtils.getFieldIndex(withInitBucketCol.schema, 
ROW_KIND_COL)
     val bucketColIdx = SparkRowUtils.getFieldIndex(withInitBucketCol.schema, 
BUCKET_COL)
     val encoderGroupWithBucketCol = EncoderSerDeGroup(withInitBucketCol.schema)
+    val postponePartitionBucketComputer: Option[BinaryRow => Integer] =
+      directPostponeBucketNum.map(bucketNum => (_: BinaryRow) => 
Integer.valueOf(bucketNum))
     def newWrite() =
       PaimonDataWrite(
-        writeBuilder,
+        activeWriteBuilder,
         writeType,
         rowKindColIdx,
         writeRowTracking,
         fullCompactionDeltaCommits,
         batchId,
         uriReaderFactory,
-        None
+        postponePartitionBucketComputer
       )
 
     def sparkParallelism = {
@@ -291,6 +325,17 @@ case class PaimonSparkWriter(
           )
         }
 
+      case POSTPONE_MODE if directPostponeBucketNum.isDefined =>
+        // The configured bucket number is final for overwrite and for a table 
without real
+        // buckets, so route the input directly without first materializing 
bucket -2 files.
+        writeWithBucketProcessor(
+          withInitBucketCol,
+          PostponeFixBucketProcessor(
+            table,
+            bucketColIdx,
+            encoderGroupWithBucketCol,
+            postponePartitionBucketComputer.get))
+
       case BUCKET_UNAWARE | POSTPONE_MODE =>
         var input = data
         if (tableSchema.partitionKeys().size() > 0) {
@@ -341,7 +386,7 @@ case class PaimonSparkWriter(
     }
 
     val taskResults = written.collect().toSeq
-    if (postponeBatchWriteFixedBucket) {
+    if (postponeBatchWriteFixedBucket && directPostponeWriteBuilder == null) {
       stagedSparkSession = sparkSession
     }
     WriteTaskResult.merge(taskResults)
@@ -419,7 +464,7 @@ case class PaimonSparkWriter(
   }
 
   def commit(commitMessages: Seq[CommitMessage], operation: 
Snapshot.Operation): Unit = {
-    if (postponeBatchWriteFixedBucket) {
+    if (postponeBatchWriteFixedBucket && directPostponeWriteBuilder == null) {
       if (stagedSparkSession == null) {
         throw new IllegalStateException("Postpone staged write has no 
SparkSession.")
       }
@@ -432,7 +477,9 @@ case class PaimonSparkWriter(
       postCommit(finalMessages)
       return
     }
-    val tableCommit = writeBuilder.newCommit()
+    val activeWriteBuilder =
+      Option(directPostponeWriteBuilder).getOrElse(writeBuilder)
+    val tableCommit = activeWriteBuilder.newCommit()
     if (operation != null) {
       tableCommit.withOperation(operation)
     }
@@ -446,6 +493,18 @@ case class PaimonSparkWriter(
     postCommit(commitMessages)
   }
 
+  private def baseSnapshotHasNoRealBuckets: Boolean = {
+    postponeBaseSnapshotId.forall {
+      snapshotId =>
+        !table
+          .newSnapshotReader()
+          .withSnapshot(snapshotId)
+          .onlyReadRealBuckets()
+          .readFileIterator()
+          .hasNext
+    }
+  }
+
   /** Bootstrap and repartition for cross partition mode. */
   private def bootstrapAndRepartitionByKeyHash(
       data: DataFrame,
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
index 53a10b0287..770b9eec66 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/execution/PaimonStrategy.scala
@@ -86,7 +86,7 @@ case class PaimonStrategy(spark: SparkSession)
     case PhysicalOperation(projects, filters, relation: 
DataSourceV2ScanRelation) =>
       relation.scan match {
         case scan: PaimonScan if 
PostponeMergeOnRead.usesCustomSource(scan.table) =>
-          scan.planPostponeMerge(spark.sparkContext.defaultParallelism) match {
+          scan.planPostponeMerge() match {
             case Some(mergePlan) =>
               val inputScan = PostponeMergeInputScan(mergePlan)
               val inputOutput =
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/procedure/SparkPostponeCompactProcedure.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/procedure/SparkPostponeCompactProcedure.scala
index 25d4f8a91d..a39636e9c1 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/procedure/SparkPostponeCompactProcedure.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/procedure/SparkPostponeCompactProcedure.scala
@@ -29,7 +29,7 @@ import 
org.apache.paimon.spark.schema.SparkSystemColumns.{BUCKET_COL, ROW_KIND_C
 import org.apache.paimon.spark.util.{ScanPlanHelper, SparkRowUtils}
 import org.apache.paimon.spark.write.{PaimonDataWrite, WriteTaskResult}
 import org.apache.paimon.table.{BlobDescriptorReaderFactory, BucketMode, 
FileStoreTable, PostponeUtils}
-import org.apache.paimon.table.PostponeUtils.PostponeBucketAssigner
+import org.apache.paimon.table.PostponeUtils.PostponeBucketNumResolver
 import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl}
 import org.apache.paimon.utils.{SerializationUtils, UriReaderFactory}
 
@@ -58,17 +58,10 @@ case class SparkPostponeCompactProcedure(
     @transient relation: DataSourceV2Relation) {
   private val LOG = LoggerFactory.getLogger(getClass)
 
-  private def createPostponeBucketAssigner(snapshotId: Long) = {
-    PostponeUtils.createPostponeBucketAssigner(
-      table,
-      snapshotId,
-      spark.sparkContext.defaultParallelism)
-  }
-
   private def newDataWrite(
       realTable: FileStoreTable,
       rowKindColIdx: Int,
-      bucketAssigner: PostponeBucketAssigner,
+      bucketNumResolver: PostponeBucketNumResolver,
       uriReaderFactoryForBlobDescriptor: UriReaderFactory): PaimonDataWrite = {
     val rowType = table.rowType()
     val coreOptions = table.coreOptions()
@@ -81,7 +74,7 @@ case class SparkPostponeCompactProcedure(
       Option.apply(coreOptions.fullCompactionDeltaCommits()),
       None,
       uriReaderFactoryForBlobDescriptor,
-      Some(partition => bucketAssigner.assign(partition))
+      Some(partition => bucketNumResolver.numBuckets(partition))
     )
     dataWrite
   }
@@ -107,8 +100,8 @@ case class SparkPostponeCompactProcedure(
       return
     }
     val snapshotId = snapshot.id()
-    val bucketAssigner = createPostponeBucketAssigner(snapshotId)
-    val realTable = PostponeUtils.tableForPostponeCompact(table, 1, snapshotId)
+    val bucketNumResolver = 
PostponeUtils.createPostponeBucketNumResolver(table, snapshotId)
+    val realTable = PostponeUtils.tableForPostponeRewrite(table, 1, snapshotId)
 
     // Read data splits from the POSTPONE_BUCKET (-2)
     val splits =
@@ -147,7 +140,7 @@ case class SparkPostponeCompactProcedure(
           table,
           bucketColIdx,
           encoderGroupWithBucketCol,
-          partition => bucketAssigner.assign(partition)
+          partition => bucketNumResolver.numBuckets(partition)
         )
         val dataFrame = withInitBucketCol
           
.mapPartitions(processor.processPartition)(encoderGroupWithBucketCol.encoder)
@@ -205,7 +198,7 @@ case class SparkPostponeCompactProcedure(
           Iterator.empty
         } else {
           val dataWrite =
-            newDataWrite(realTable, rowWorkAndKind._2, bucketAssigner, 
uriReaderFactory)
+            newDataWrite(realTable, rowWorkAndKind._2, bucketNumResolver, 
uriReaderFactory)
           dataWrite.write.withWriteRestore(
             new FileSystemWriteRestore(
               realTable.coreOptions(),
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PostponeBucketTableTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PostponeBucketTableTest.scala
index 2f59dd9e0c..5ad96cba0e 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PostponeBucketTableTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PostponeBucketTableTest.scala
@@ -102,6 +102,139 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
     }
   }
 
+  test("Postpone bucket table: configured default directly writes overwrite") {
+    Seq(
+      (
+        "static",
+        """
+          |INSERT OVERWRITE t SELECT
+          |CAST(id AS INT) AS k,
+          |CAST(id AS STRING) AS v,
+          |0 AS pt
+          |FROM range(100, 200)
+          |""".stripMargin,
+        false),
+      (
+        "static",
+        """
+          |INSERT OVERWRITE t PARTITION (pt = 0) SELECT
+          |CAST(id AS INT) AS k,
+          |CAST(id AS STRING) AS v
+          |FROM range(100, 200)
+          |""".stripMargin,
+        true),
+      (
+        "dynamic",
+        """
+          |INSERT OVERWRITE t SELECT
+          |CAST(id AS INT) AS k,
+          |CAST(id AS STRING) AS v,
+          |0 AS pt
+          |FROM range(100, 200)
+          |""".stripMargin,
+        true)
+    ).foreach {
+      case (partitionOverwriteMode, overwriteSql, preservesPartitionOne) =>
+        withTable("t") {
+          sql("""
+                |CREATE TABLE t (
+                |  k INT,
+                |  v STRING,
+                |  pt INT
+                |) PARTITIONED BY (pt)
+                |TBLPROPERTIES (
+                |  'primary-key' = 'k, pt',
+                |  'bucket' = '-2',
+                |  'postpone.batch-write-fixed-bucket' = 'true',
+                |  'postpone.target-row-num-per-bucket' = '1'
+                |)
+                |""".stripMargin)
+
+          sql("""
+                |INSERT INTO t
+                |SELECT CAST(id AS INT), CAST(id AS STRING), 0 AS pt FROM 
range(0, 16)
+                |UNION ALL SELECT 1000, 'untouched-real', 1
+                |""".stripMargin)
+          val initialBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+          assert(initialBuckets.get(BinaryRow.singleColumn(0)) == 16)
+          assert(initialBuckets.get(BinaryRow.singleColumn(1)) == 1)
+
+          withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+            sql("INSERT INTO t VALUES (2000, 'overwritten-postpone', 0), 
(2001, 'untouched-postpone', 1)")
+          }
+          sql("ALTER TABLE t SET TBLPROPERTIES ('postpone.default-bucket-num' 
= '3')")
+
+          withSparkSQLConf(
+            "spark.sql.adaptive.enabled" -> "false",
+            "spark.sql.sources.partitionOverwriteMode" -> 
partitionOverwriteMode) {
+            val jobs = countSparkJobs("postpone-default-overwrite") {
+              sql(overwriteSql)
+            }
+            assert(jobs == 1, s"Direct overwrite should use one Spark job, but 
found $jobs.")
+          }
+
+          val resultBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+          assert(resultBuckets.get(BinaryRow.singleColumn(0)) == 3)
+          assert(resultBuckets.containsKey(BinaryRow.singleColumn(1)) == 
preservesPartitionOne)
+          val retainedPartitionCount = if (preservesPartitionOne) 1L else 0L
+          checkAnswer(sql("SELECT count(*) FROM t WHERE pt = 1"), 
Seq(Row(retainedPartitionCount)))
+          checkAnswer(
+            sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"),
+            Seq(Row(retainedPartitionCount)))
+        }
+    }
+  }
+
+  test("Postpone bucket table: configured default directly writes without real 
buckets") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING,
+            |  pt INT
+            |) PARTITIONED BY (pt)
+            |TBLPROPERTIES (
+            |  'primary-key' = 'k, pt',
+            |  'bucket' = '-2',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.default-bucket-num' = '3',
+            |  'postpone.target-row-num-per-bucket' = '1'
+            |)
+            |""".stripMargin)
+
+      withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+        sql("INSERT INTO t VALUES (0, 'historical-postpone', 0)")
+      }
+      assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).isEmpty)
+
+      withSparkSQLConf("spark.sql.adaptive.enabled" -> "false") {
+        val jobs = countSparkJobs("postpone-default-new-layout") {
+          sql("INSERT INTO t VALUES (1, 'p0-real', 0), (2, 'p1-real', 1)")
+        }
+        assert(jobs == 1, s"Direct new-layout write should use one Spark job, 
but found $jobs.")
+      }
+
+      val initialBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+      assert(initialBuckets.get(BinaryRow.singleColumn(0)) == 3)
+      assert(initialBuckets.get(BinaryRow.singleColumn(1)) == 3)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+
+      sql("ALTER TABLE t SET TBLPROPERTIES ('postpone.default-bucket-num' = 
'5')")
+      withSparkSQLConf("spark.sql.adaptive.enabled" -> "false") {
+        val jobs = countSparkJobs("postpone-default-mixed-layout") {
+          sql("INSERT INTO t VALUES (3, 'p0-append', 0), (4, 'p2-new', 2)")
+        }
+        assert(jobs == 2, s"Mixed existing/new write should stay staged, but 
found $jobs jobs.")
+      }
+
+      val resultBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+      assert(resultBuckets.get(BinaryRow.singleColumn(0)) == 3)
+      assert(resultBuckets.get(BinaryRow.singleColumn(1)) == 3)
+      assert(resultBuckets.get(BinaryRow.singleColumn(2)) == 5)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+    }
+  }
+
   test("Postpone bucket table: staged rescale supports per-partition layouts") 
{
     withTable("t") {
       sql("""
@@ -899,7 +1032,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
           case relation: DataSourceV2ScanRelation if 
relation.scan.isInstanceOf[PaimonScan] =>
             relation.scan.asInstanceOf[PaimonScan]
         }.get
-        
assert(postponeScan.planPostponeMerge(spark.sparkContext.defaultParallelism).isDefined)
+        assert(postponeScan.planPostponeMerge().isDefined)
         assert(postponeScan.filterAttributes().isEmpty)
         assert(
           
intercept[UnsupportedOperationException](postponeScan.toBatch).getMessage
@@ -925,7 +1058,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
             relation.scan.asInstanceOf[PaimonScan]
         }.get
         val realSplits = mergeScan
-          .planPostponeMerge(spark.sparkContext.defaultParallelism)
+          .planPostponeMerge()
           .get
           .corePlan
           .realSplits()
@@ -1294,7 +1427,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
         }.get
         assert(
           pinnedRealScan
-            .planPostponeMerge(spark.sparkContext.defaultParallelism)
+            .planPostponeMerge()
             .isDefined)
 
         withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
@@ -1310,7 +1443,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
         }.get
         assert(
           pinnedEmptyScan
-            .planPostponeMerge(spark.sparkContext.defaultParallelism)
+            .planPostponeMerge()
             .isEmpty)
 
         sql("INSERT INTO empty_t VALUES (1, 'committed-later')")
@@ -1468,14 +1601,15 @@ class PostponeBucketTableTest extends 
PaimonSparkTestBase {
       )
 
       sql("SET spark.default.parallelism = 2")
-      // compact
+      // Compact estimates one logical bucket from the default target size; 
Spark parallelism is
+      // only an execution setting.
       sql("CALL sys.compact(table => 't')")
 
       checkAnswer(sql("SELECT count(*) FROM t"), Seq(Row(1000)))
       checkAnswer(sql("SELECT sum(k) FROM t"), Seq(Row((0 until 1000).sum)))
       checkAnswer(
         sql("SELECT distinct(bucket) FROM `t$buckets` ORDER BY bucket"),
-        Seq(Row(0), Row(1))
+        Seq(Row(0))
       )
     }
   }
@@ -1687,6 +1821,16 @@ class PostponeBucketTableTest extends 
PaimonSparkTestBase {
       .sum
   }
 
+  private def countSparkJobs(groupPrefix: String)(action: => Unit): Int = {
+    val jobGroup = s"$groupPrefix-${System.nanoTime()}"
+    spark.sparkContext.setJobGroup(jobGroup, jobGroup)
+    try {
+      action
+    } finally {
+      spark.sparkContext.clearJobGroup()
+    }
+    spark.sparkContext.statusTracker.getJobIdsForGroup(jobGroup).length
+  }
 }
 
 object PostponeBucketTableTest {

Reply via email to