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 a8cea0922b [spark] Stage postpone fixed-bucket writes before rescale 
(#9017)
a8cea0922b is described below

commit a8cea0922bee3679f03c4d6e7de6da56c36d9b8b
Author: Zouxxyy <[email protected]>
AuthorDate: Wed Aug 5 15:39:11 2026 +0800

    [spark] Stage postpone fixed-bucket writes before rescale (#9017)
---
 docs/docs/primary-key-table/data-distribution.md   |  40 +-
 docs/generated/core_configuration.html             |  12 +-
 .../main/java/org/apache/paimon/CoreOptions.java   |  19 +-
 .../org/apache/paimon/table/PostponeUtils.java     | 164 ++++-
 .../table/source/PostponeMergeReadBuilder.java     |  28 +-
 .../org/apache/paimon/table/PostponeUtilsTest.java | 101 +++
 .../paimon/flink/PostponeBucketTableITCase.java    |  37 ++
 .../procedure/CompactChainTableProcedure.java      |   2 +-
 .../paimon/spark/procedure/CompactProcedure.java   |   2 +-
 .../paimon/spark/procedure/RescaleProcedure.java   |   2 +-
 .../paimon/spark/PostponeMergeInputScan.scala      |   2 +-
 .../spark/SparkPostponeStagedCommitter.scala       | 717 +++++++++++++++++++++
 .../paimon/spark/commands/PaimonSparkWriter.scala  | 265 ++------
 .../spark/commands/WriteIntoPaimonTable.scala      |   7 +-
 .../procedure/SparkPostponeCompactProcedure.scala  |   1 +
 .../spark/procedure/RescaleProcedureTest.scala     |  51 ++
 .../paimon/spark/sql/PostponeBucketTableTest.scala | 642 ++++++++++++++++--
 17 files changed, 1762 insertions(+), 330 deletions(-)

diff --git a/docs/docs/primary-key-table/data-distribution.md 
b/docs/docs/primary-key-table/data-distribution.md
index a7de95c838..b7a0fecb7c 100644
--- a/docs/docs/primary-key-table/data-distribution.md
+++ b/docs/docs/primary-key-table/data-distribution.md
@@ -70,22 +70,30 @@ 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, batch writes set `postpone.batch-write-fixed-bucket` to `true`
-and write records directly to real buckets.
-For a Spark batch write to a partition without real bucket files,
-Spark calculates the bucket number from the uncompressed Paimon `BinaryRow` 
serialized size.
-The target size is configured by `postpone.target-size-per-bucket` and 
defaults to `1 GB`.
-Existing postpone files do not record their uncompressed serialized size, so 
Spark estimates their
-size using their row count and the average serialized size of incoming rows in 
the same partition.
-You can instead configure `postpone.target-row-num-per-bucket` to calculate 
the bucket number
-from row counts; this option takes precedence over the target size.
-The calculated bucket number is at least `1` and is limited by
-`postpone.batch-write-fixed-bucket.max-parallelism`.
-The serialized size is measured before file encoding and compression, so it is 
not the exact
-ORC or Parquet size on storage.
-Inferring the data amount adds a statistics stage; Spark caches the batch so 
that inference and
-writing use the same input rows. Spark skips this stage for an unpartitioned 
table that already
-has real bucket files, or when 
`postpone.batch-write-fixed-bucket.max-parallelism` is `1`.
+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:
+
+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
+   `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.
+
+An existing partition normally keeps its bucket number. Spark first rescales 
its real buckets when
+the uncapped required bucket number is greater than the existing bucket number 
multiplied by
+`postpone.batch-write-fixed-bucket.rescale-load-factor` (default `32`), and 
the capped result is
+larger than the existing layout. Different partitions may have different 
target bucket numbers.
+The rescale is a separate overwrite commit which changes real buckets only; 
the current batch is
+appended in the following commit.
+
+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.
 
 When `postpone.batch-write-fixed-bucket` is `false`,
 records are first stored in the `bucket-postpone` directory of each partition
diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 78d3e5a168..bb5fba90d3 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1306,7 +1306,13 @@ For an internal format table in a REST catalog, it also 
makes the catalog own th
             <td><h5>postpone.batch-write-fixed-bucket.max-parallelism</h5></td>
             <td style="word-wrap: break-word;">2048</td>
             <td>Integer</td>
-            <td>The number of partitions for global index.</td>
+            <td>Maximum bucket number inferred for a partition by a 
fixed-bucket batch write. The inferred number is rounded up to a power of two 
before applying this limit.</td>
+        </tr>
+        <tr>
+            
<td><h5>postpone.batch-write-fixed-bucket.rescale-load-factor</h5></td>
+            <td style="word-wrap: break-word;">32</td>
+            <td>Integer</td>
+            <td>Maximum tolerated ratio between the required bucket number and 
the existing bucket number before a fixed-bucket batch write enlarges the 
existing layout. Rescaling also requires the configured maximum parallelism to 
permit a larger bucket number.</td>
         </tr>
         <tr>
             <td><h5>postpone.default-bucket-num</h5></td>
@@ -1324,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 batch writing fixed buckets 
or compacting postpone bucket files for a partition without real bucket 
data.</td>
+            <td>Target row number per bucket when estimating the required 
bucket number from the current staged batch or compacting postpone bucket 
files.</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 uncompressed serialized data size per bucket when Spark 
batch writes fixed buckets for a partition without real bucket data. This 
option is ignored when 'postpone.target-row-num-per-bucket' is configured.</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>
         </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 a405d1318e..2454b84c80 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2752,7 +2752,16 @@ public class CoreOptions implements Serializable {
             key("postpone.batch-write-fixed-bucket.max-parallelism")
                     .intType()
                     .defaultValue(2048)
-                    .withDescription("The number of partitions for global 
index.");
+                    .withDescription(
+                            "Maximum bucket number inferred for a partition by 
a fixed-bucket batch write. The inferred number is rounded up to a power of two 
before applying this limit.");
+
+    public static final ConfigOption<Integer>
+            POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR =
+                    
key("postpone.batch-write-fixed-bucket.rescale-load-factor")
+                            .intType()
+                            .defaultValue(32)
+                            .withDescription(
+                                    "Maximum tolerated ratio between the 
required bucket number and the existing bucket number before a fixed-bucket 
batch write enlarges the existing layout. Rescaling also requires the 
configured maximum parallelism to permit a larger bucket number.");
 
     public static final ConfigOption<Integer> POSTPONE_DEFAULT_BUCKET_NUM =
             key("postpone.default-bucket-num")
@@ -2766,14 +2775,14 @@ public class CoreOptions implements Serializable {
                     .longType()
                     .noDefaultValue()
                     .withDescription(
-                            "Target row number per bucket when batch writing 
fixed buckets or compacting postpone bucket files for a partition without real 
bucket data.");
+                            "Target row number per bucket when estimating the 
required bucket number from the current staged batch or compacting postpone 
bucket files.");
 
     public static final ConfigOption<MemorySize> 
POSTPONE_TARGET_SIZE_PER_BUCKET =
             key("postpone.target-size-per-bucket")
                     .memoryType()
                     .defaultValue(MemorySize.parse("1 gb"))
                     .withDescription(
-                            "Target uncompressed serialized data size per 
bucket when Spark batch writes fixed buckets for a partition without real 
bucket data. "
+                            "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.");
 
     public static final ConfigOption<Long> GLOBAL_INDEX_ROW_COUNT_PER_SHARD =
@@ -4452,6 +4461,10 @@ public class CoreOptions implements Serializable {
         return options.get(POSTPONE_BATCH_WRITE_FIXED_BUCKET_MAX_PARALLELISM);
     }
 
+    public int postponeBatchWriteFixedBucketRescaleLoadFactor() {
+        return 
options.get(POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR);
+    }
+
     public int postponeDefaultBucketNum() {
         return options.get(POSTPONE_DEFAULT_BUCKET_NUM);
     }
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 60b464fd15..0d2a77ad0b 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
@@ -39,6 +39,7 @@ import org.apache.paimon.utils.Pair;
 import javax.annotation.Nullable;
 
 import java.io.Serializable;
+import java.math.BigInteger;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -128,10 +129,10 @@ public class PostponeUtils {
 
     public static PostponeBucketAssigner createPostponeBucketAssigner(
             FileStoreTable table, long snapshotId, int defaultParallelism) {
-        return createPostponeBucketAssigner(table, snapshotId, 
defaultParallelism, null);
+        return loadPostponeBucketAssigner(table, snapshotId, 
defaultParallelism, null);
     }
 
-    private static PostponeBucketAssigner createPostponeBucketAssigner(
+    private static PostponeBucketAssigner loadPostponeBucketAssigner(
             FileStoreTable table,
             long snapshotId,
             int defaultParallelism,
@@ -142,15 +143,6 @@ public class PostponeUtils {
                 
!table.coreOptions().postponeTargetRowNumPerBucket().isPresent()
                         ? Collections.emptyMap()
                         : getPostponeRowCounts(table, snapshotId, 
partitionFilter);
-        return createPostponeBucketAssigner(
-                table, knownNumBuckets, postponeRowCounts, defaultParallelism);
-    }
-
-    private static PostponeBucketAssigner createPostponeBucketAssigner(
-            FileStoreTable table,
-            Map<BinaryRow, Integer> knownNumBuckets,
-            Map<BinaryRow, Long> postponeRowCounts,
-            int defaultParallelism) {
         Long targetRowNumPerBucket =
                 
table.coreOptions().postponeTargetRowNumPerBucket().orElse(null);
         int defaultBucketNum =
@@ -163,18 +155,36 @@ public class PostponeUtils {
                 knownNumBuckets, targetRowNumPerBucket, postponeRowCounts, 
defaultBucketNum);
     }
 
+    /** Creates snapshot-bound routing metadata. */
     public static PostponeBucketRouter createPostponeBucketRouter(
             FileStoreTable table,
             long snapshotId,
             int defaultParallelism,
             @Nullable PartitionPredicate partitionFilter) {
-        return createPostponeBucketRouter(
+        return newPostponeBucketRouter(
+                table,
+                loadPostponeBucketAssigner(table, snapshotId, 
defaultParallelism, partitionFilter));
+    }
+
+    /** 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.");
+        Map<BinaryRow, Integer> copied = new HashMap<>();
+        for (Map.Entry<BinaryRow, Integer> entry : 
numBucketsByPartition.entrySet()) {
+            checkArgument(
+                    entry.getValue() != null && entry.getValue() > 0,
+                    "Postpone bucket number must be positive.");
+            copied.put(entry.getKey().copy(), entry.getValue());
+        }
+        return newPostponeBucketRouter(
                 table,
-                createPostponeBucketAssigner(
-                        table, snapshotId, defaultParallelism, 
partitionFilter));
+                new PostponeBucketAssigner(copied, null, 
Collections.emptyMap(), defaultBucketNum));
     }
 
-    private static PostponeBucketRouter createPostponeBucketRouter(
+    private static PostponeBucketRouter newPostponeBucketRouter(
             FileStoreTable table, PostponeBucketAssigner bucketAssigner) {
         List<String> trimmedPrimaryKeys = table.schema().trimmedPrimaryKeys();
         int[] bucketKeyMapping =
@@ -249,6 +259,93 @@ public class PostponeUtils {
         }
     }
 
+    /**
+     * Decides the target bucket number from an exactly measured staged batch.
+     *
+     * <p>Previously committed postpone files are intentionally excluded. An 
existing layout is
+     * retained while the staged batch's required bucket number stays within 
the configured rescale
+     * load factor. This avoids paying for a layout rewrite for ordinary size 
differences while
+     * protecting a large batch from being funneled into too few writers.
+     */
+    public static FixedBucketDecision decideFixedBucketNum(
+            long stagedRowCount,
+            long stagedFileSize,
+            @Nullable Integer existingBucketNum,
+            CoreOptions options) {
+        checkArgument(stagedRowCount >= 0, "Staged row count cannot be 
negative.");
+        checkArgument(stagedFileSize >= 0, "Staged file size cannot be 
negative.");
+        checkArgument(
+                existingBucketNum == null || existingBucketNum > 0,
+                "Existing bucket number must be positive.");
+
+        int maxBucketNum = 
options.postponeBatchWriteFixedBucketMaxParallelism();
+        checkArgument(
+                maxBucketNum > 0,
+                "Option '%s' must be greater than 0.",
+                
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_MAX_PARALLELISM.key());
+        int rescaleLoadFactor = 
options.postponeBatchWriteFixedBucketRescaleLoadFactor();
+        checkArgument(
+                rescaleLoadFactor > 0,
+                "Option '%s' must be greater than 0.",
+                
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR.key());
+
+        BigInteger requiredBucketNum;
+        if (options.postponeTargetRowNumPerBucket().isPresent()) {
+            long targetRowCount = 
options.postponeTargetRowNumPerBucket().get();
+            checkArgument(
+                    targetRowCount > 0,
+                    "Option '%s' must be greater than 0.",
+                    CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key());
+            requiredBucketNum =
+                    ceilDiv(BigInteger.valueOf(stagedRowCount), 
BigInteger.valueOf(targetRowCount));
+        } else {
+            long targetSize = options.postponeTargetSizePerBucket();
+            checkArgument(
+                    targetSize > 0,
+                    "Option '%s' must be greater than 0.",
+                    CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET.key());
+            requiredBucketNum =
+                    ceilDiv(BigInteger.valueOf(stagedFileSize), 
BigInteger.valueOf(targetSize));
+        }
+
+        requiredBucketNum = requiredBucketNum.max(BigInteger.ONE);
+        int suggestedBucketNum = roundUpToPowerOfTwo(requiredBucketNum, 
maxBucketNum);
+        boolean requiresRescale =
+                existingBucketNum != null
+                        && requiredBucketNum.compareTo(
+                                        BigInteger.valueOf(existingBucketNum)
+                                                
.multiply(BigInteger.valueOf(rescaleLoadFactor)))
+                                > 0
+                        && suggestedBucketNum > existingBucketNum;
+        int targetBucketNum;
+        if (existingBucketNum == null) {
+            targetBucketNum = suggestedBucketNum;
+        } else if (requiresRescale) {
+            targetBucketNum = suggestedBucketNum;
+        } else {
+            targetBucketNum = existingBucketNum;
+        }
+        return new FixedBucketDecision(targetBucketNum, requiresRescale);
+    }
+
+    private static BigInteger ceilDiv(BigInteger value, BigInteger divisor) {
+        checkArgument(divisor.signum() > 0, "Bucket target must be positive.");
+        if (value.signum() <= 0) {
+            return BigInteger.ZERO;
+        }
+        return 
value.subtract(BigInteger.ONE).divide(divisor).add(BigInteger.ONE);
+    }
+
+    private static int roundUpToPowerOfTwo(BigInteger value, int upperBound) {
+        int cappedValue = 
value.min(BigInteger.valueOf(upperBound)).intValueExact();
+        if (cappedValue <= 1) {
+            return 1;
+        }
+
+        long roundedValue = (long) Integer.highestOneBit(cappedValue - 1) << 1;
+        return (int) Math.min(roundedValue, upperBound);
+    }
+
     public static Map<BinaryRow, Integer> getKnownNumBuckets(FileStoreTable 
table) {
         return getKnownNumBuckets(
                 
table.store().newScan().onlyReadRealBuckets().readSimpleEntries());
@@ -256,7 +353,22 @@ public class PostponeUtils {
 
     public static Map<BinaryRow, Integer> getKnownNumBuckets(
             FileStoreTable table, long snapshotId) {
-        return getKnownNumBuckets(table, snapshotId, null);
+        return getKnownNumBuckets(table, snapshotId, (PartitionPredicate) 
null);
+    }
+
+    /** Returns known real-bucket counts only for the specified partitions. */
+    public static Map<BinaryRow, Integer> getKnownNumBuckets(
+            FileStoreTable table, long snapshotId, List<BinaryRow> partitions) 
{
+        if (partitions.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return getKnownNumBuckets(
+                table.store()
+                        .newScan()
+                        .withSnapshot(snapshotId)
+                        .withPartitionFilter(partitions)
+                        .onlyReadRealBuckets()
+                        .readSimpleEntries());
     }
 
     static Map<BinaryRow, Integer> getKnownNumBuckets(
@@ -386,6 +498,26 @@ public class PostponeUtils {
         }
     }
 
+    /** Bucket decision for an exactly measured staged batch. */
+    public static final class FixedBucketDecision {
+
+        private final int targetBucketNum;
+        private final boolean requiresRescale;
+
+        private FixedBucketDecision(int targetBucketNum, boolean 
requiresRescale) {
+            this.targetBucketNum = targetBucketNum;
+            this.requiresRescale = requiresRescale;
+        }
+
+        public int targetBucketNum() {
+            return targetBucketNum;
+        }
+
+        public boolean requiresRescale() {
+            return requiresRescale;
+        }
+    }
+
     /** Snapshot-bound routing metadata for postpone records. */
     public static final class PostponeBucketRouter implements Serializable {
 
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 75eda5624e..c0561c09db 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
@@ -56,7 +56,7 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
     private static final long serialVersionUID = 1L;
 
     private final FileStoreTable table;
-    private final Snapshot snapshot;
+    private final @Nullable Snapshot snapshot;
 
     @Nullable private Predicate filter;
     @Nullable private PartitionPredicate partitionFilter;
@@ -65,11 +65,19 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
     @Nullable private transient String readProtectionTagName;
     private int defaultBucketNum = 1;
 
-    private PostponeMergeReadBuilder(FileStoreTable table, Snapshot snapshot) {
+    private PostponeMergeReadBuilder(FileStoreTable table, @Nullable Snapshot 
snapshot) {
         this.table = table;
         this.snapshot = snapshot;
     }
 
+    /** Creates a builder for execution-engine supplied postpone and 
real-bucket splits. */
+    public static PostponeMergeReadBuilder createForSplits(FileStoreTable 
table) {
+        checkArgument(
+                table.bucketMode() == BucketMode.POSTPONE_MODE && 
!table.primaryKeys().isEmpty(),
+                "Postpone merge read requires a primary-key postpone bucket 
table.");
+        return new PostponeMergeReadBuilder(table, null);
+    }
+
     /** Creates a snapshot-bound builder when the selected partitions contain 
postpone files. */
     public static Optional<PostponeMergeReadBuilder> create(
             FileStoreTable table, @Nullable PartitionPredicate 
partitionFilter) {
@@ -179,6 +187,7 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
     }
 
     public PostponeMergePlan plan() {
+        checkArgument(snapshot != null, "Snapshot-bound postpone merge plan 
requires a snapshot.");
         RowType resultReadType = resultReadType();
         RowType mergeReadType = mergeReadType(resultReadType);
 
@@ -222,6 +231,21 @@ public final class PostponeMergeReadBuilder implements 
Serializable {
         return plan;
     }
 
+    /** Builds a plan from splits and routing metadata supplied by an 
execution engine. */
+    public PostponeMergePlan plan(
+            List<DataSplit> realSplits,
+            List<DataSplit> postponeSplits,
+            PostponeUtils.PostponeBucketRouter bucketRouter) {
+        RowType resultReadType = resultReadType();
+        return new PostponeMergePlan(
+                realSplits,
+                PostponeUtils.groupPostponeFiles(postponeSplits),
+                bucketRouter,
+                keyType(),
+                resultReadType,
+                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.");
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 40b36bfb96..1d07359b6d 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
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.table;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.FileStore;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryRowWriter;
@@ -72,6 +73,28 @@ public class PostponeUtilsTest {
         verify(scan).withPartitionFilter(partitionFilter);
     }
 
+    @Test
+    public void testGetKnownNumBucketsByPartitions() {
+        BinaryRow partition = partition(1);
+        List<BinaryRow> partitions = Collections.singletonList(partition);
+        SimpleFileEntry entry = mock(SimpleFileEntry.class);
+        when(entry.partition()).thenReturn(partition);
+        when(entry.totalBuckets()).thenReturn(4);
+
+        FileStoreScan scan = mock(FileStoreScan.class, RETURNS_SELF);
+        
when(scan.readSimpleEntries()).thenReturn(Collections.singletonList(entry));
+        FileStore store = mock(FileStore.class);
+        when(store.newScan()).thenReturn(scan);
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.store()).thenReturn(store);
+
+        assertThat(PostponeUtils.getKnownNumBuckets(table, 5L, partitions))
+                .containsEntry(partition, 4);
+        verify(scan).withSnapshot(5L);
+        verify(scan).onlyReadRealBuckets();
+        verify(scan).withPartitionFilter(partitions);
+    }
+
     @Test
     public void testGetPostponeRowCountsFromSnapshot() {
         BinaryRow partition = partition(1);
@@ -268,6 +291,84 @@ public class PostponeUtilsTest {
                 .isEqualTo(7);
     }
 
+    @Test
+    public void testDecideFixedBucketNum() {
+        Map<String, String> optionMap = new HashMap<>();
+        optionMap.put(CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key(), 
"1");
+        
optionMap.put(CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_MAX_PARALLELISM.key(),
 "16");
+        optionMap.put(
+                
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR.key(), "32");
+        CoreOptions options = CoreOptions.fromMap(optionMap);
+
+        PostponeUtils.FixedBucketDecision rounded =
+                PostponeUtils.decideFixedBucketNum(3, 0, null, options);
+        assertThat(rounded.targetBucketNum()).isEqualTo(4);
+        assertThat(rounded.requiresRescale()).isFalse();
+
+        PostponeUtils.FixedBucketDecision capped =
+                PostponeUtils.decideFixedBucketNum((long) Integer.MAX_VALUE + 
1, 0, null, options);
+        assertThat(capped.targetBucketNum()).isEqualTo(16);
+        assertThat(capped.requiresRescale()).isFalse();
+
+        PostponeUtils.FixedBucketDecision atLoadFactor =
+                PostponeUtils.decideFixedBucketNum(224, 0, 7, options);
+        assertThat(atLoadFactor.targetBucketNum()).isEqualTo(7);
+        assertThat(atLoadFactor.requiresRescale()).isFalse();
+
+        PostponeUtils.FixedBucketDecision aboveLoadFactor =
+                PostponeUtils.decideFixedBucketNum(225, 0, 7, options);
+        assertThat(aboveLoadFactor.targetBucketNum()).isEqualTo(16);
+        assertThat(aboveLoadFactor.requiresRescale()).isTrue();
+
+        Map<String, String> cappedOptionMap = new HashMap<>(optionMap);
+        cappedOptionMap.put(
+                
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_MAX_PARALLELISM.key(), "4");
+        PostponeUtils.FixedBucketDecision cappedBelowExisting =
+                PostponeUtils.decideFixedBucketNum(225, 0, 7, 
CoreOptions.fromMap(cappedOptionMap));
+        assertThat(cappedBelowExisting.targetBucketNum()).isEqualTo(7);
+        assertThat(cappedBelowExisting.requiresRescale()).isFalse();
+
+        PostponeUtils.FixedBucketDecision largerExisting =
+                PostponeUtils.decideFixedBucketNum(257, 0, 8, options);
+        assertThat(largerExisting.targetBucketNum()).isEqualTo(16);
+        assertThat(largerExisting.requiresRescale()).isTrue();
+
+        Map<String, String> lowerLoadFactorOptions = new HashMap<>(optionMap);
+        lowerLoadFactorOptions.put(
+                
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR.key(), "4");
+        PostponeUtils.FixedBucketDecision lowerLoadFactor =
+                PostponeUtils.decideFixedBucketNum(
+                        29, 0, 7, CoreOptions.fromMap(lowerLoadFactorOptions));
+        assertThat(lowerLoadFactor.targetBucketNum()).isEqualTo(16);
+        assertThat(lowerLoadFactor.requiresRescale()).isTrue();
+    }
+
+    @Test
+    public void testDecideFixedBucketNumRejectsInvalidRescaleLoadFactor() {
+        Map<String, String> optionMap = new HashMap<>();
+        optionMap.put(CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET.key(), 
"1");
+        
optionMap.put(CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR.key(),
 "0");
+
+        assertThatThrownBy(
+                        () ->
+                                PostponeUtils.decideFixedBucketNum(
+                                        1, 0, 1, 
CoreOptions.fromMap(optionMap)))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(
+                        
CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_RESCALE_LOAD_FACTOR.key());
+    }
+
+    @Test
+    public void testDecideFixedBucketNumByStagedFileSize() {
+        Map<String, String> optionMap = new HashMap<>();
+        optionMap.put(CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET.key(), "100 
b");
+        
optionMap.put(CoreOptions.POSTPONE_BATCH_WRITE_FIXED_BUCKET_MAX_PARALLELISM.key(),
 "32");
+
+        PostponeUtils.FixedBucketDecision decision =
+                PostponeUtils.decideFixedBucketNum(10, 1000, null, 
CoreOptions.fromMap(optionMap));
+        assertThat(decision.targetBucketNum()).isEqualTo(16);
+    }
+
     private static BinaryRow partition(int value) {
         BinaryRow row = new BinaryRow(1);
         BinaryRowWriter writer = new BinaryRowWriter(row);
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 d27f6dd615..c3a020cbd3 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
@@ -1007,6 +1007,43 @@ public class PostponeBucketTableITCase extends 
AbstractTestBase {
                         "+I[5, 53]");
     }
 
+    @Test
+    public void testFixedBucketWriteDoesNotCompact() throws Exception {
+        String warehouse = getTempDirPath();
+        TableEnvironment tEnv =
+                tableEnvironmentBuilder()
+                        .batchMode()
+                        .setConf(TableConfigOptions.TABLE_DML_SYNC, true)
+                        .build();
+
+        tEnv.executeSql(
+                "CREATE CATALOG mycat WITH (\n"
+                        + "  'type' = 'paimon',\n"
+                        + "  'warehouse' = '"
+                        + warehouse
+                        + "'\n"
+                        + ")");
+        tEnv.executeSql("USE CATALOG mycat");
+        tEnv.executeSql(
+                "CREATE TABLE T (\n"
+                        + "  k INT,\n"
+                        + "  v STRING,\n"
+                        + "  PRIMARY KEY (k) NOT ENFORCED\n"
+                        + ") WITH (\n"
+                        + "  'bucket' = '-2',\n"
+                        + "  
'postpone.batch-write-fixed-bucket.max-parallelism' = '1',\n"
+                        + "  'num-sorted-run.compaction-trigger' = '2'\n"
+                        + ")");
+
+        tEnv.executeSql("INSERT INTO T VALUES (1, 'a')").await();
+        tEnv.executeSql("INSERT INTO T VALUES (2, 'b')").await();
+
+        assertThat(collect(tEnv.executeSql("SELECT * FROM T")))
+                .containsExactlyInAnyOrder("+I[1, a]", "+I[2, b]");
+        assertThat(collect(tEnv.executeSql("SELECT * FROM `T$files` WHERE 
level = 0"))).hasSize(2);
+        assertThat(collect(tEnv.executeSql("SELECT * FROM `T$files` WHERE 
level > 0"))).isEmpty();
+    }
+
     @Test
     public void testWriteFixedBucketWithDifferentBucketNumber() throws 
Exception {
         String warehouse = getTempDirPath();
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
index 66f7f31615..36ab181aec 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactChainTableProcedure.java
@@ -187,7 +187,7 @@ public class CompactChainTableProcedure extends 
BaseProcedure {
         if (partitionExists) {
             Map<String, String> staticPartition =
                     SparkProcedureUtils.parseStaticPartition(spark(), 
targetPartition);
-            writer.writeBuilder().withOverwrite(staticPartition);
+            writer.withOverwrite(staticPartition);
         }
         writer.commit(writer.write(datasetForWrite));
         LOG.info("Successfully compacted partition {} to snapshot branch.", 
partitionStr);
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
index 27dd435aba..1e156ca42a 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/CompactProcedure.java
@@ -671,7 +671,7 @@ public class CompactProcedure extends BaseProcedure {
         if (datasetForWrite != null) {
             PaimonSparkWriter writer = PaimonSparkWriter.apply(table);
             // Use dynamic partition overwrite
-            writer.writeBuilder().withOverwrite();
+            writer.withOverwrite();
             writer.commit(writer.write(datasetForWrite));
         }
     }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RescaleProcedure.java
 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RescaleProcedure.java
index 404a78db78..232f4370b5 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RescaleProcedure.java
+++ 
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/procedure/RescaleProcedure.java
@@ -186,7 +186,7 @@ public class RescaleProcedure extends BaseProcedure {
         FileStoreTable rescaledTable = 
table.copy(table.schema().copy(bucketOptions));
 
         PaimonSparkWriter writer = PaimonSparkWriter.apply(rescaledTable);
-        writer.writeBuilder().withOverwrite();
+        writer.withOverwrite();
         writer.commit(writer.write(datasetForRead));
     }
 
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeInputScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeInputScan.scala
index f0d5cdb1fe..9721320c6d 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeInputScan.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PostponeMergeInputScan.scala
@@ -255,7 +255,7 @@ private[spark] object PostponeMergeInputScan {
 
   private def bucketKey(split: DataSplit) = (split.partition(), split.bucket())
 
-  private def mergeRealSplits(splits: Seq[DataSplit]): DataSplit = {
+  private[spark] def mergeRealSplits(splits: Seq[DataSplit]): DataSplit = {
     if (splits.size == 1) {
       splits.head
     } else {
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
new file mode 100644
index 0000000000..1ba596b4fe
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/SparkPostponeStagedCommitter.scala
@@ -0,0 +1,717 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark
+
+import org.apache.paimon.{CoreOptions, KeyValue, Snapshot}
+import org.apache.paimon.data.{BinaryRow, InternalRow}
+import org.apache.paimon.data.serializer.InternalRowSerializer
+import org.apache.paimon.operation.FileSystemWriteRestore
+import org.apache.paimon.options.Options
+import org.apache.paimon.postpone.BucketFiles
+import org.apache.paimon.reader.RecordReaderIterator
+import org.apache.paimon.table.{BucketMode, FileStoreTable, PostponeUtils}
+import org.apache.paimon.table.PostponeUtils.PostponeBucketRouter
+import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl, 
CommitMessageSerializer, PostponeFixedBucketWriteBuilder, TableWriteImpl}
+import org.apache.paimon.table.source.{DataSplit, PostponeMergePlan, 
PostponeMergeReadBuilder, SplitSerializer}
+import org.apache.paimon.types.RowKind
+import org.apache.paimon.utils.{IteratorRecordReader, SerializationUtils}
+
+import org.apache.spark.{Partitioner, TaskContext}
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.{PaimonUtils, SparkSession}
+
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
+/**
+ * Completes a fixed-bucket batch write from uncommitted postpone files.
+ *
+ * Job 1 has already materialized the source rows into bucket -2. This 
coordinator derives the
+ * actual target partitions and bucket counts from those files. When necessary 
it first rescales
+ * existing real-bucket data, then writes and commits the current batch to 
real buckets. Committed
+ * postpone files are left to the regular postpone compaction path.
+ */
+private[spark] class SparkPostponeStagedCommitter(
+    table: FileStoreTable,
+    @transient spark: SparkSession,
+    baseSnapshotId: Option[Long],
+    overwritePartitionSpec: Option[Map[String, String]])
+  extends Serializable {
+
+  import SparkPostponeStagedCommitter._
+
+  private val coreOptions = table.coreOptions()
+  private val fixedWriteCommitUser = coreOptions.createCommitUser()
+  private val fixedWriteTable = {
+    val options = new java.util.HashMap[String, String]()
+    options.put(
+      CoreOptions.COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(),
+      baseSnapshotId.getOrElse(0L).toString)
+    // Every overwrite in this coordinator supplies its exact BinaryRow 
partitions. Disable the
+    // table-level dynamic rewrite so an empty rewritten partition is still 
removed, and implement
+    // INSERT OVERWRITE dynamic-partition semantics explicitly in 
commitCurrentBatch.
+    options.put(CoreOptions.DYNAMIC_PARTITION_OVERWRITE.key(), "false")
+    table.copy(options)
+  }
+  private val fixedWriteBuilder = 
fixedWriteTable.newPostponeFixedBucketWriteBuilder()
+  private val rescaleWriteBuilder = {
+    val options = new java.util.HashMap[String, String]()
+    options.put(CoreOptions.CHANGELOG_PRODUCER.key(), 
CoreOptions.ChangelogProducer.NONE.toString)
+    fixedWriteTable.copy(options).newPostponeFixedBucketWriteBuilder()
+  }
+  private val ignoreEmptyCommit = new Options(table.options())
+    .getOptional(CoreOptions.SNAPSHOT_IGNORE_EMPTY_COMMIT)
+    .orElse(true)
+
+  def commit(
+      stagedMessages: Seq[CommitMessage],
+      operation: Snapshot.Operation): Seq[CommitMessage] = {
+    val (stageMessages, passThroughMessages) = 
stagedMessages.partition(isStageMessage)
+    var cleanupStageMessages = stageMessages
+    var pendingRescaleMessages = Seq.empty[CommitMessage]
+    var pendingFinalMessages = passThroughMessages
+    var rescaleCommitStarted = false
+    var finalCommitStarted = false
+
+    try {
+      val normalizedStageMessages = mergeBucketMessages(stageMessages)
+      cleanupStageMessages = normalizedStageMessages
+      val stagedSplits = splitsFromMessages(normalizedStageMessages)
+      val stats = aggregateStats(stagedSplits)
+      val touchedPartitions = stats.keysIterator.map(_.copy()).toSeq
+      if (touchedPartitions.isEmpty) {
+        if (
+          overwritePartitionSpec.isDefined || passThroughMessages.nonEmpty || 
!ignoreEmptyCommit
+        ) {
+          finalCommitStarted = true
+          commitCurrentBatch(passThroughMessages, Seq.empty, operation)
+        }
+        return passThroughMessages
+      }
+
+      val existingBuckets: Map[BinaryRow, Int] = baseSnapshotId
+        .map(
+          id =>
+            PostponeUtils
+              .getKnownNumBuckets(table, id, touchedPartitions.asJava)
+              .asScala
+              .iterator
+              .map { case (partition, buckets) => partition -> 
buckets.intValue() }
+              .toMap)
+        .getOrElse(Map.empty[BinaryRow, Int])
+      val decisions = touchedPartitions.map {
+        partition =>
+          val stage = stats(partition)
+          val decision = PostponeUtils.decideFixedBucketNum(
+            stage.rowCount,
+            stage.fileSize,
+            existingBuckets.get(partition).map(Int.box).orNull,
+            coreOptions
+          )
+          partition -> decision
+      }.toMap
+
+      val targetBuckets = decisions.map {
+        case (partition, decision) => partition -> decision.targetBucketNum()
+      }
+
+      if (overwritePartitionSpec.isDefined) {
+        val rewrittenMessages =
+          writePostponeRecords(
+            stagedSplits,
+            targetBuckets,
+            replacePreviousFiles = true,
+            restoreSnapshotId = None)
+        pendingFinalMessages = rewrittenMessages ++ passThroughMessages
+        finalCommitStarted = true
+        commitCurrentBatch(pendingFinalMessages, touchedPartitions, operation)
+        return pendingFinalMessages
+      }
+
+      val rescaleBucketNums = decisions.collect {
+        case (partition, decision) if decision.requiresRescale() =>
+          partition -> decision.targetBucketNum()
+      }
+      if (rescaleBucketNums.nonEmpty) {
+        pendingRescaleMessages =
+          rewriteRealBuckets(readRealSplits(rescaleBucketNums.keys.toSeq), 
rescaleBucketNums)
+        rescaleCommitStarted = true
+        commitRescale(pendingRescaleMessages, rescaleBucketNums)
+        pendingRescaleMessages = Seq.empty
+      }
+
+      // After rescale, the current batch must restore from the new snapshot. 
Otherwise it restores
+      // from the snapshot captured before the staging job.
+      val currentWriteSnapshotId =
+        if (rescaleBucketNums.nonEmpty) {
+          Option(table.snapshotManager().latestSnapshot()).map(_.id())
+        } else {
+          baseSnapshotId
+        }
+      val currentMessages =
+        writePostponeRecords(
+          stagedSplits,
+          targetBuckets,
+          replacePreviousFiles = false,
+          restoreSnapshotId = currentWriteSnapshotId)
+      pendingFinalMessages = currentMessages ++ passThroughMessages
+      finalCommitStarted = true
+      commitCurrentBatch(pendingFinalMessages, touchedPartitions, operation)
+      pendingFinalMessages
+    } catch {
+      case error: Throwable =>
+        if (!rescaleCommitStarted && pendingRescaleMessages.nonEmpty) {
+          abortMessages(pendingRescaleMessages)
+        }
+        if (!finalCommitStarted && pendingFinalMessages.nonEmpty) {
+          abortMessages(pendingFinalMessages)
+        }
+        throw error
+    } finally {
+      // Staged files were never visible, so they are always safe to delete, 
including when the
+      // final commit returned an unknown result.
+      abortMessages(cleanupStageMessages)
+    }
+  }
+
+  private def aggregateStats(stagedSplits: Seq[DataSplit]): Map[BinaryRow, 
PartitionStats] = {
+    val result = mutable.HashMap.empty[BinaryRow, PartitionStats]
+    stagedSplits.foreach {
+      split =>
+        val previous = result.getOrElse(split.partition(), PartitionStats(0L, 
0L))
+        val splitFileSize = split
+          .dataFiles()
+          .asScala
+          .iterator
+          .map(_.fileSize())
+          .foldLeft(0L)(Math.addExact)
+        result.put(
+          split.partition().copy(),
+          PartitionStats(
+            Math.addExact(previous.rowCount, split.rowCount()),
+            Math.addExact(previous.fileSize, splitFileSize)))
+    }
+    result.toMap
+  }
+
+  private def splitsFromMessages(messages: Seq[CommitMessage]): Seq[DataSplit] 
= {
+    messages.collect {
+      case message: CommitMessageImpl if message.bucket() == 
BucketMode.POSTPONE_BUCKET =>
+        val files = message.newFilesIncrement().newFiles().asScala ++
+          message.compactIncrement().compactAfter().asScala
+        if (files.isEmpty) {
+          None
+        } else {
+          Some(
+            DataSplit
+              .builder()
+              .withSnapshot(baseSnapshotId.getOrElse(0L))
+              .withPartition(message.partition())
+              .withBucket(BucketMode.POSTPONE_BUCKET)
+              .withBucketPath(
+                table
+                  .store()
+                  .pathFactory()
+                  .bucketPath(message.partition(), BucketMode.POSTPONE_BUCKET)
+                  .toString)
+              .withTotalBuckets(message.totalBuckets())
+              .withDataFiles(files.asJava)
+              .isStreaming(false)
+              .rawConvertible(false)
+              .build())
+        }
+    }.flatten
+  }
+
+  private def isStageMessage(message: CommitMessage): Boolean = message match {
+    case commit: CommitMessageImpl => commit.bucket() == 
BucketMode.POSTPONE_BUCKET
+    case _ => false
+  }
+
+  private def readRealSplits(partitions: Seq[BinaryRow]): Seq[DataSplit] = {
+    baseSnapshotId.toSeq.flatMap {
+      snapshotId =>
+        table
+          .newSnapshotReader()
+          .withSnapshot(snapshotId)
+          .withPartitionFilter(partitions.asJava)
+          .onlyReadRealBuckets()
+          .read()
+          .dataSplits()
+          .asScala
+    }
+  }
+
+  private def writePostponeRecords(
+      postponeSplits: Seq[DataSplit],
+      bucketNums: Map[BinaryRow, Int],
+      replacePreviousFiles: Boolean,
+      restoreSnapshotId: Option[Long]): Seq[CommitMessage] = {
+    val router = createRouter(bucketNums)
+    val readBuilder = PostponeMergeReadBuilder.createForSplits(table)
+    val corePlan = readBuilder.plan(Seq.empty[DataSplit].asJava, 
postponeSplits.asJava, router)
+    val records = routePostponeRecords(readBuilder, corePlan)
+    val partitioned = records.repartitionAndSortWithinPartitions(
+      new BucketGroupPartitioner(shuffleParallelism(bucketNums)))
+    writeRoutedRecords(
+      partitioned,
+      bucketNums,
+      replacePreviousFiles,
+      restoreSnapshotId = restoreSnapshotId,
+      writeBuilder = fixedWriteBuilder)
+  }
+
+  private def rewriteRealBuckets(
+      realSplits: Seq[DataSplit],
+      targetBucketNums: Map[BinaryRow, Int]): Seq[CommitMessage] = {
+    val targetRouter = createRouter(targetBucketNums)
+    val readBuilder = PostponeMergeReadBuilder.createForSplits(table)
+    val corePlan = readBuilder.plan(realSplits.asJava, 
Seq.empty[DataSplit].asJava, targetRouter)
+    val routedRecords = routeRealRecords(readBuilder, corePlan)
+      .repartitionAndSortWithinPartitions(
+        new BucketGroupPartitioner(shuffleParallelism(targetBucketNums)))
+    writeRoutedRecords(
+      routedRecords,
+      targetBucketNums,
+      replacePreviousFiles = true,
+      restoreSnapshotId = None,
+      writeBuilder = rescaleWriteBuilder)
+  }
+
+  private def writeRoutedRecords(
+      records: RDD[(BucketOrderKey, PostponeRecord)],
+      bucketNums: Map[BinaryRow, Int],
+      replacePreviousFiles: Boolean,
+      restoreSnapshotId: Option[Long],
+      writeBuilder: PostponeFixedBucketWriteBuilder): Seq[CommitMessage] = {
+    val written = records.mapPartitions {
+      input =>
+        if (!input.hasNext) {
+          Iterator.empty
+        } else {
+          val ioManager = SparkUtils.createIOManager()
+          val write = writeBuilder
+            .newWrite(fixedWriteCommitUser, null)
+            .withIOManager(ioManager)
+            .asInstanceOf[TableWriteImpl[InternalRow]]
+          if (replacePreviousFiles) {
+            write.withIgnorePreviousFiles(true)
+          } else {
+            restoreSnapshotId.foreach {
+              id =>
+                write.withWriteRestore(
+                  new FileSystemWriteRestore(
+                    table.coreOptions(),
+                    table.snapshotManager(),
+                    table.store().newScan(),
+                    table.store().newIndexFileHandler(),
+                    id))
+            }
+          }
+          try {
+            val buffered = input.buffered
+            while (buffered.hasNext) {
+              val first = buffered.head._1
+              val partition = deserializePartition(first.partition)
+              val bucket = first.bucket
+              while (buffered.hasNext && buffered.head._1.sameBucket(first)) {
+                val record = buffered.next()._2
+                val row = SerializationUtils.deserializeBinaryRow(record.value)
+                row.setRowKind(RowKind.fromByteValue(record.rowKind))
+                write.writeAndReturn(row, bucket, bucketNums(partition))
+              }
+            }
+            val serializer = new CommitMessageSerializer()
+            val commitMessages = write.prepareCommit().asScala.toVector
+            reportOutputMetrics(commitMessages)
+            Iterator.single(
+              commitMessages
+                .map(serializer.serialize))
+          } finally {
+            try {
+              write.close()
+            } finally {
+              ioManager.close()
+            }
+          }
+        }
+    }
+    deserializeCommitMessages(written.collect().iterator.flatten.toVector)
+  }
+
+  private def deserializeCommitMessages(
+      serializedMessages: Iterable[Array[Byte]]): Seq[CommitMessage] = {
+    val serializer = new CommitMessageSerializer()
+    serializedMessages.iterator
+      .map(serializer.deserialize(serializer.getVersion, _))
+      .toVector
+  }
+
+  private def routeRealRecords(
+      readBuilder: PostponeMergeReadBuilder,
+      plan: PostponeMergePlan): RDD[(BucketOrderKey, PostponeRecord)] = {
+    val splits = plan.realSplits().asScala.toSeq
+    if (splits.isEmpty) {
+      return spark.sparkContext.emptyRDD
+    }
+    val keyType = plan.keyType()
+    val resultReadType = plan.resultReadType()
+    val router = plan.bucketRouter()
+    val indexedSplits = splits
+      .groupBy(split => (split.partition(), split.bucket()))
+      .values
+      .map(bucketSplits => 
PostponeMergeInputScan.mergeRealSplits(bucketSplits.toSeq))
+      .toSeq
+      .map(serializeSplit)
+      .zipWithIndex
+    spark.sparkContext
+      .parallelize(
+        indexedSplits,
+        Math.max(1, Math.min(indexedSplits.size, 
spark.sparkContext.defaultParallelism)))
+      .mapPartitions {
+        inputs =>
+          val ioManager = SparkUtils.createIOManager()
+          val read = readBuilder.newRead().withIOManager(ioManager)
+          val openReaders = 
mutable.HashSet.empty[RecordReaderIterator[InternalRow]]
+          Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit] {
+            _ =>
+              try {
+                openReaders.foreach(_.close())
+              } finally {
+                ioManager.close()
+              }
+          })
+          val keyExtractor = table.createRowKeyExtractor()
+          val keySerializer = new InternalRowSerializer(keyType)
+          val valueSerializer = new InternalRowSerializer(resultReadType)
+          inputs.flatMap {
+            case (serializedSplit, splitOrder) =>
+              val split = deserializeSplit(serializedSplit)
+              val splitPartition = split.partition()
+              val partitionBytes = 
serializePartition(splitPartition).toIndexedSeq
+              val emptyRecords =
+                new IteratorRecordReader[KeyValue](
+                  java.util.Collections.emptyList[KeyValue]().iterator())
+              val reader = new RecordReaderIterator[InternalRow](
+                read.createBucketMergeReader(split, emptyRecords))
+              mapReader(reader, openReaders) {
+                (row, localOrder) =>
+                  keyExtractor.setRecord(row)
+                  val key = 
keySerializer.toBinaryRow(keyExtractor.trimmedPrimaryKey())
+                  val bucket = router.bucket(splitPartition, key)
+                  BucketOrderKey(
+                    partitionBytes,
+                    bucket,
+                    splitOrder.toLong,
+                    localOrder) -> PostponeRecord(
+                    row.getRowKind.toByteValue,
+                    
SerializationUtils.serializeBinaryRow(valueSerializer.toBinaryRow(row))
+                  )
+              }
+          }
+      }
+  }
+
+  private def routePostponeRecords(
+      readBuilder: PostponeMergeReadBuilder,
+      plan: PostponeMergePlan): RDD[(BucketOrderKey, PostponeRecord)] = {
+    val splits = plan.postponeSplits().asScala.toSeq
+    if (splits.isEmpty) {
+      return spark.sparkContext.emptyRDD
+    }
+    val keyType = plan.keyType()
+    val mergeReadType = plan.mergeReadType()
+    val router = plan.bucketRouter()
+    val indexedSplits = splits.map(serializeSplit).zipWithIndex
+    spark.sparkContext
+      .parallelize(
+        indexedSplits,
+        Math.max(1, Math.min(indexedSplits.size, 
spark.sparkContext.defaultParallelism)))
+      .mapPartitions {
+        inputs =>
+          val ioManager = SparkUtils.createIOManager()
+          val read = readBuilder.newRead().withIOManager(ioManager)
+          val openReaders = 
mutable.HashSet.empty[RecordReaderIterator[KeyValue]]
+          Option(TaskContext.get()).foreach(_.addTaskCompletionListener[Unit] {
+            _ =>
+              try {
+                openReaders.foreach(_.close())
+              } finally {
+                ioManager.close()
+              }
+          })
+          val keySerializer = new InternalRowSerializer(keyType)
+          val valueSerializer = new InternalRowSerializer(mergeReadType)
+          inputs.flatMap {
+            case (serializedSplit, writerOrder) =>
+              val split = deserializeSplit(serializedSplit)
+              val splitPartition = split.partition()
+              val partitionBytes = 
serializePartition(splitPartition).toIndexedSeq
+              val reader = new 
RecordReaderIterator[KeyValue](read.createPostponeReader(split))
+              mapReader(reader, openReaders) {
+                (keyValue, localOrder) =>
+                  val key = keySerializer.toBinaryRow(keyValue.key())
+                  val bucket = router.bucket(splitPartition, key)
+                  BucketOrderKey(
+                    partitionBytes,
+                    bucket,
+                    writerOrder.toLong,
+                    localOrder) -> PostponeRecord(
+                    keyValue.valueKind().toByteValue,
+                    SerializationUtils.serializeBinaryRow(
+                      valueSerializer.toBinaryRow(keyValue.value()))
+                  )
+              }
+          }
+      }
+  }
+
+  private def mergeBucketMessages(messages: Seq[CommitMessage]): 
Seq[CommitMessageImpl] = {
+    val buckets = mutable.LinkedHashMap.empty[(BinaryRow, Int), BucketFiles]
+    messages.foreach {
+      case message: CommitMessageImpl =>
+        val key = (message.partition(), message.bucket())
+        val files = buckets.getOrElseUpdate(
+          key,
+          new BucketFiles(
+            table.store().pathFactory().createDataFilePathFactory(key._1, 
key._2),
+            table.fileIO()))
+        files.update(message)
+      case other => throw new IllegalArgumentException(s"Unsupported commit 
message $other")
+    }
+    buckets.map {
+      case ((partition, bucket), files) =>
+        files.makeMessage(partition, bucket)
+    }.toSeq
+  }
+
+  private def commitRescale(
+      messages: Seq[CommitMessage],
+      rescaleBucketNums: Map[BinaryRow, Int]): Unit = {
+    val snapshotId = baseSnapshotId.getOrElse {
+      throw new IllegalStateException("Cannot rescale real buckets without a 
base snapshot.")
+    }
+    // A positive table bucket makes overwrite delete only real buckets. 
Commit messages carry the
+    // 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)
+    val commit = commitTable
+      .newCommit(fixedWriteCommitUser)
+      .appendCommitCheckConflict(true)
+      .ignoreEmptyCommit(ignoreEmptyCommit)
+      .withOperation(Snapshot.Operation.OVERWRITE)
+      .withOverwriteStaticPartitions(rescaleBucketNums.keys.toSeq.asJava)
+    try {
+      commit.commit(messages.asJava)
+    } finally {
+      commit.close()
+    }
+  }
+
+  private def commitCurrentBatch(
+      messages: Seq[CommitMessage],
+      touchedPartitions: Seq[BinaryRow],
+      operation: Snapshot.Operation): Unit = {
+    val dynamicPartitionOverwrite =
+      overwritePartitionSpec.exists(_.isEmpty) && 
coreOptions.dynamicPartitionOverwrite()
+    if (!dynamicPartitionOverwrite) {
+      overwritePartitionSpec.foreach(spec => 
fixedWriteBuilder.withOverwrite(spec.asJava))
+    }
+    val commit = fixedWriteBuilder
+      .newCommit(fixedWriteCommitUser, ignoreEmptyCommit)
+      .withOperation(operation)
+    if (dynamicPartitionOverwrite && touchedPartitions.nonEmpty) {
+      commit.withOverwriteStaticPartitions(touchedPartitions.asJava)
+    }
+    try {
+      commit.commit(messages.asJava)
+    } finally {
+      commit.close()
+    }
+  }
+
+  private def abortMessages(messages: Seq[CommitMessage]): Unit = {
+    if (messages.nonEmpty) {
+      try {
+        val commit = table.newBatchWriteBuilder().newCommit()
+        try {
+          commit.abort(messages.asJava)
+        } finally {
+          commit.close()
+        }
+      } catch {
+        case error: Throwable =>
+          // Cleanup failure must not hide the write or commit failure. 
Orphan-file cleanup remains
+          // the final safety net for these never-committed files.
+          SparkPostponeStagedCommitter.LOG.warn(
+            s"Failed to clean uncommitted files for table ${table.name()}.",
+            error)
+      }
+    }
+  }
+
+  private def createRouter(bucketNums: Map[BinaryRow, Int]): 
PostponeBucketRouter = {
+    val javaBucketNums = new java.util.HashMap[BinaryRow, 
Integer](bucketNums.size)
+    bucketNums.foreach {
+      case (partition, buckets) => javaBucketNums.put(partition, 
Integer.valueOf(buckets))
+    }
+    PostponeUtils.createPostponeBucketRouter(table, javaBucketNums, 1)
+  }
+
+  private def shuffleParallelism(bucketNums: Map[BinaryRow, Int]): Int = {
+    val useful = bucketNums.values.foldLeft(BigInt(0))(_ + _).max(BigInt(1))
+    useful.min(BigInt(spark.sessionState.conf.numShufflePartitions)).toInt
+  }
+}
+
+private[spark] object SparkPostponeStagedCommitter {
+
+  private val LOG = 
org.slf4j.LoggerFactory.getLogger(classOf[SparkPostponeStagedCommitter])
+
+  private case class PartitionStats(rowCount: Long, fileSize: Long)
+
+  private def mapReader[T, R](
+      reader: RecordReaderIterator[T],
+      openReaders: mutable.Set[RecordReaderIterator[T]])(transform: (T, Long) 
=> R): Iterator[R] = {
+    openReaders += reader
+    new Iterator[R] {
+      private var nextLocalOrder = 0L
+      private var readerClosed = false
+
+      private def closeReader(): Unit = {
+        if (!readerClosed) {
+          readerClosed = true
+          openReaders -= reader
+          reader.close()
+        }
+      }
+
+      override def hasNext: Boolean = {
+        try {
+          val hasNext = reader.hasNext
+          if (!hasNext) {
+            closeReader()
+          }
+          hasNext
+        } catch {
+          case error: Throwable =>
+            closeReader()
+            throw error
+        }
+      }
+
+      override def next(): R = {
+        if (!hasNext) {
+          throw new NoSuchElementException
+        }
+        try {
+          val result = transform(reader.next(), nextLocalOrder)
+          nextLocalOrder = Math.addExact(nextLocalOrder, 1L)
+          result
+        } catch {
+          case error: Throwable =>
+            closeReader()
+            throw error
+        }
+      }
+    }
+  }
+
+  private case class BucketOrderKey(
+      partition: IndexedSeq[Byte],
+      bucket: Int,
+      writerOrder: Long,
+      localOrder: Long) {
+    def sameBucket(other: BucketOrderKey): Boolean =
+      partition == other.partition && bucket == other.bucket
+  }
+
+  implicit private val bucketOrder: Ordering[BucketOrderKey] = new 
Ordering[BucketOrderKey] {
+    override def compare(left: BucketOrderKey, right: BucketOrderKey): Int = {
+      val partitionComparison = compareBytes(left.partition, right.partition)
+      if (partitionComparison != 0) {
+        partitionComparison
+      } else {
+        val bucketComparison = Integer.compare(left.bucket, right.bucket)
+        if (bucketComparison != 0) {
+          bucketComparison
+        } else {
+          val writerComparison = java.lang.Long.compare(left.writerOrder, 
right.writerOrder)
+          if (writerComparison != 0) {
+            writerComparison
+          } else {
+            java.lang.Long.compare(left.localOrder, right.localOrder)
+          }
+        }
+      }
+    }
+  }
+
+  private def compareBytes(left: IndexedSeq[Byte], right: IndexedSeq[Byte]): 
Int = {
+    val limit = Math.min(left.length, right.length)
+    var index = 0
+    while (index < limit) {
+      val comparison = java.lang.Byte.compare(left(index), right(index))
+      if (comparison != 0) {
+        return comparison
+      }
+      index += 1
+    }
+    Integer.compare(left.length, right.length)
+  }
+
+  private case class BucketGroupPartitioner(override val numPartitions: Int) 
extends Partitioner {
+    require(numPartitions > 0, "Shuffle partition number must be positive.")
+
+    override def getPartition(key: Any): Int = {
+      val bucketKey = key.asInstanceOf[BucketOrderKey]
+      Math.floorMod(31 * bucketKey.partition.hashCode() + bucketKey.bucket, 
numPartitions)
+    }
+  }
+
+  private case class PostponeRecord(rowKind: Byte, value: Array[Byte])
+
+  private def reportOutputMetrics(messages: Seq[CommitMessage]): Unit = {
+    Option(TaskContext.get()).foreach {
+      taskContext =>
+        val files = messages
+          .collect { case message: CommitMessageImpl => message }
+          .flatMap(_.newFilesIncrement().newFiles().asScala)
+        val bytesWritten = 
files.iterator.map(_.fileSize()).foldLeft(0L)(Math.addExact)
+        val recordsWritten = 
files.iterator.map(_.rowCount()).foldLeft(0L)(Math.addExact)
+        PaimonUtils.updateOutputMetrics(
+          taskContext.taskMetrics().outputMetrics,
+          bytesWritten,
+          recordsWritten)
+    }
+  }
+
+  private def serializePartition(partition: BinaryRow): Array[Byte] =
+    SerializationUtils.serializeBinaryRow(partition)
+
+  private def deserializePartition(partition: IndexedSeq[Byte]): BinaryRow =
+    SerializationUtils.deserializeBinaryRow(partition.toArray)
+
+  private def serializeSplit(split: DataSplit): Array[Byte] = 
SplitSerializer.serialize(split)
+
+  private def deserializeSplit(split: Array[Byte]): DataSplit =
+    SplitSerializer.deserialize(split).asInstanceOf[DataSplit]
+
+}
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 e1301703da..ef49aab883 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
@@ -22,7 +22,6 @@ import org.apache.paimon.{CoreOptions, Snapshot}
 import org.apache.paimon.CoreOptions.{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
@@ -30,14 +29,14 @@ import org.apache.paimon.fs.Path
 import org.apache.paimon.index.{BucketAssigner, SimpleHashBucketAssigner}
 import org.apache.paimon.io.{CompactIncrement, DataIncrement}
 import org.apache.paimon.manifest.FileKind
-import org.apache.paimon.spark.{SparkRow, SparkTypeUtils}
+import org.apache.paimon.spark.{SparkPostponeStagedCommitter, SparkRow, 
SparkTypeUtils}
 import org.apache.paimon.spark.catalog.functions.BucketFunction
 import org.apache.paimon.spark.schema.SparkSystemColumns.{BUCKET_COL, 
ROW_KIND_COL}
 import org.apache.paimon.spark.sort.TableSorter
 import org.apache.paimon.spark.util.OptionUtils.paimonExtensionEnabled
 import org.apache.paimon.spark.util.SparkRowUtils
 import org.apache.paimon.spark.write.{PaimonDataWrite, WriteHelper, 
WriteTaskResult}
-import org.apache.paimon.table.{FileStoreTable, PostponeUtils, SpecialFields}
+import org.apache.paimon.table.{FileStoreTable, SpecialFields}
 import org.apache.paimon.table.BucketMode._
 import org.apache.paimon.table.sink._
 import org.apache.paimon.types.{RowKind, RowType}
@@ -49,10 +48,10 @@ import org.apache.spark.sql._
 import org.apache.spark.sql.functions._
 
 import java.io.IOException
+import java.util.{Map => JMap}
 import java.util.Collections.singletonMap
 
 import scala.collection.JavaConverters._
-import scala.collection.mutable
 
 case class PaimonSparkWriter(
     table: FileStoreTable,
@@ -60,8 +59,6 @@ case class PaimonSparkWriter(
     batchId: Option[Long] = None)
   extends WriteHelper {
 
-  import PaimonSparkWriter._
-
   private lazy val tableSchema = table.schema
 
   private lazy val bucketMode = table.bucketMode
@@ -71,6 +68,9 @@ case class PaimonSparkWriter(
 
   @transient private lazy val serializer = new CommitMessageSerializer
 
+  @transient private var stagedSparkSession: SparkSession = _
+  private var overwritePartitionSpec: Option[Map[String, String]] = None
+
   private val writeType = {
     if (writeRowTracking) {
       // The historical data and new data are processed separately.
@@ -87,12 +87,21 @@ case class PaimonSparkWriter(
   val postponeBatchWriteFixedBucket: Boolean =
     table.bucketMode() == POSTPONE_MODE && 
coreOptions.postponeBatchWriteFixedBucket()
 
-  val writeBuilder: BatchWriteBuilder = {
-    if (postponeBatchWriteFixedBucket) {
-      table.newPostponeFixedBucketWriteBuilder()
-    } else {
-      table.newBatchWriteBuilder()
+  private val postponeBaseSnapshotId =
+    if (postponeBatchWriteFixedBucket)
+      Option(table.snapshotManager().latestSnapshot()).map(_.id())
+    else None
+
+  val writeBuilder: BatchWriteBuilder = table.newBatchWriteBuilder()
+
+  def withOverwrite(): PaimonSparkWriter = 
withOverwrite(java.util.Collections.emptyMap())
+
+  def withOverwrite(partition: JMap[String, String]): PaimonSparkWriter = {
+    overwritePartitionSpec = Some(partition.asScala.toMap)
+    if (!postponeBatchWriteFixedBucket) {
+      writeBuilder.withOverwrite(partition)
     }
+    this
   }
 
   def writeOnly(): PaimonSparkWriter = {
@@ -108,12 +117,6 @@ case class PaimonSparkWriter(
   }
 
   def write(data: DataFrame): Seq[CommitMessage] = {
-    write(data, overwriteExistingData = false)
-  }
-
-  private[commands] def write(
-      data: DataFrame,
-      overwriteExistingData: Boolean): Seq[CommitMessage] = {
     val sparkSession = data.sparkSession
     val uriReaderFactory = uriReaderFactoryForBlobDescriptor
     import sparkSession.implicits._
@@ -126,29 +129,20 @@ case class PaimonSparkWriter(
           .withColumn(BUCKET_COL, lit(-1))
       case _ => data.withColumn(BUCKET_COL, lit(-1))
     }
-    val postponeBucketAssignment =
-      if (postponeBatchWriteFixedBucket) {
-        Some(preparePostponeBucketAssignment(withInitBucketCol, 
overwriteExistingData))
-      } else {
-        None
-      }
-
     val rowKindColIdx = SparkRowUtils.getFieldIndex(withInitBucketCol.schema, 
ROW_KIND_COL)
     val bucketColIdx = SparkRowUtils.getFieldIndex(withInitBucketCol.schema, 
BUCKET_COL)
     val encoderGroupWithBucketCol = EncoderSerDeGroup(withInitBucketCol.schema)
-    val postponePartitionBucketComputer =
-      postponeBucketAssignment.map(_.partitionBucketComputer)
-
-    def newWrite() = PaimonDataWrite(
-      writeBuilder,
-      writeType,
-      rowKindColIdx,
-      writeRowTracking,
-      fullCompactionDeltaCommits,
-      batchId,
-      uriReaderFactory,
-      postponePartitionBucketComputer
-    )
+    def newWrite() =
+      PaimonDataWrite(
+        writeBuilder,
+        writeType,
+        rowKindColIdx,
+        writeRowTracking,
+        fullCompactionDeltaCommits,
+        batchId,
+        uriReaderFactory,
+        None
+      )
 
     def sparkParallelism = {
       val defaultParallelism = sparkSession.sparkContext.defaultParallelism
@@ -210,7 +204,7 @@ case class PaimonSparkWriter(
       }
     }
 
-    val written = bucketMode match {
+    val written: Dataset[_ <: WriteTaskResult] = bucketMode match {
       case KEY_DYNAMIC =>
         // Topology: input -> bootstrap -> shuffle by key hash -> 
bucket-assigner -> shuffle by partition & bucket
         val rowType = 
SparkTypeUtils.toPaimonType(withInitBucketCol.schema).asInstanceOf[RowType]
@@ -297,16 +291,6 @@ case class PaimonSparkWriter(
           )
         }
 
-      case POSTPONE_MODE if coreOptions.postponeBatchWriteFixedBucket() =>
-        // Topology: input -> bucket-assigner -> shuffle by partition & bucket
-        writeWithBucketProcessor(
-          withInitBucketCol,
-          PostponeFixBucketProcessor(
-            table,
-            bucketColIdx,
-            encoderGroupWithBucketCol,
-            postponePartitionBucketComputer.get))
-
       case BUCKET_UNAWARE | POSTPONE_MODE =>
         var input = data
         if (tableSchema.partitionKeys().size() > 0) {
@@ -356,13 +340,11 @@ case class PaimonSparkWriter(
         throw new UnsupportedOperationException(s"Spark doesn't support 
$bucketMode mode.")
     }
 
-    try {
-      WriteTaskResult.merge(written.collect())
-    } finally {
-      if (postponeBucketAssignment.exists(_.dataPersisted)) {
-        withInitBucketCol.unpersist()
-      }
+    val taskResults = written.collect().toSeq
+    if (postponeBatchWriteFixedBucket) {
+      stagedSparkSession = sparkSession
     }
+    WriteTaskResult.merge(taskResults)
   }
 
   /**
@@ -437,6 +419,19 @@ case class PaimonSparkWriter(
   }
 
   def commit(commitMessages: Seq[CommitMessage], operation: 
Snapshot.Operation): Unit = {
+    if (postponeBatchWriteFixedBucket) {
+      if (stagedSparkSession == null) {
+        throw new IllegalStateException("Postpone staged write has no 
SparkSession.")
+      }
+      val finalOperation = 
Option(operation).getOrElse(Snapshot.Operation.WRITE)
+      val finalMessages = new SparkPostponeStagedCommitter(
+        table,
+        stagedSparkSession,
+        postponeBaseSnapshotId,
+        overwritePartitionSpec).commit(commitMessages, finalOperation)
+      postCommit(finalMessages)
+      return
+    }
     val tableCommit = writeBuilder.newCommit()
     if (operation != null) {
       tableCommit.withOperation(operation)
@@ -540,136 +535,6 @@ case class PaimonSparkWriter(
       .toSeq
   }
 
-  private def preparePostponeBucketAssignment(
-      df: DataFrame,
-      overwriteExistingData: Boolean): PostponeBucketAssignment = {
-    val knownNumBuckets = PostponeUtils.getKnownNumBuckets(table)
-    val maxNumBuckets = 
coreOptions.postponeBatchWriteFixedBucketMaxParallelism()
-    val unpartitionedTableHasKnownNumBuckets =
-      tableSchema.partitionKeys().isEmpty &&
-        knownNumBuckets.containsKey(BinaryRow.EMPTY_ROW)
-    val inferBucketNumFromData =
-      maxNumBuckets != 1 && !unpartitionedTableHasKnownNumBuckets
-    if (inferBucketNumFromData) {
-      df.persist()
-    }
-
-    try {
-      val defaultNumBuckets = Math.min(df.rdd.getNumPartitions, maxNumBuckets)
-      val inferredNumBuckets: Map[BinaryRow, Int] =
-        if (inferBucketNumFromData) {
-          val targetRowNum = coreOptions.postponeTargetRowNumPerBucket()
-          val postponeRowCounts =
-            if (overwriteExistingData) {
-              java.util.Collections.emptyMap[BinaryRow, java.lang.Long]()
-            } else {
-              PostponeUtils.getPostponeRowCounts(table)
-            }
-          val dataStats =
-            collectDataStatsByPartition(df, collectSize = 
!targetRowNum.isPresent)
-          dataStats.map {
-            case (partition, stats) =>
-              val postponeRowCount = postponeRowCounts.getOrDefault(partition, 
0L)
-              val numBuckets =
-                if (targetRowNum.isPresent) {
-                  computeBucketNumByRowCount(
-                    Math.addExact(stats.rowCount, postponeRowCount),
-                    targetRowNum.get(),
-                    maxNumBuckets)
-                } else {
-                  computeBucketNumBySize(
-                    stats,
-                    postponeRowCount,
-                    coreOptions.postponeTargetSizePerBucket(),
-                    maxNumBuckets)
-                }
-              partition -> numBuckets
-          }
-        } else {
-          Map.empty
-        }
-      val partitionBucketComputer = (partition: BinaryRow) =>
-        knownNumBuckets.getOrDefault(
-          partition,
-          Integer.valueOf(inferredNumBuckets.getOrElse(partition, 
defaultNumBuckets)))
-      PostponeBucketAssignment(partitionBucketComputer, inferBucketNumFromData)
-    } catch {
-      case e: Throwable =>
-        if (inferBucketNumFromData) {
-          df.unpersist()
-        }
-        throw e
-    }
-  }
-
-  private def collectDataStatsByPartition(
-      df: DataFrame,
-      collectSize: Boolean): Map[BinaryRow, PartitionDataStats] = {
-    val schema = tableSchema
-    val rowType = writeType
-    val toPaimonRow = SparkRowUtils.toPaimonRow(
-      rowType,
-      SparkRowUtils.getFieldIndex(df.schema, ROW_KIND_COL),
-      uriReaderFactoryForBlobDescriptor)
-    df.rdd
-      .mapPartitions {
-        rows =>
-          val partitionKeyExtractor = new RowPartitionKeyExtractor(schema)
-          val rowSerializer = InternalSerializers.create(rowType)
-          val stats = mutable.HashMap.empty[SerializedPartition, 
PartitionDataStats]
-          rows.foreach {
-            row =>
-              val paimonRow = toPaimonRow(row)
-              val partition = SerializedPartition(
-                
SerializationUtils.serializeBinaryRow(partitionKeyExtractor.partition(paimonRow)))
-              val rowSize =
-                if (collectSize) 
rowSerializer.toBinaryRow(paimonRow).getSizeInBytes.toLong else 0L
-              val previous = stats.getOrElse(partition, PartitionDataStats(0L, 
0L))
-              stats.put(
-                partition,
-                PartitionDataStats(
-                  Math.addExact(previous.rowCount, 1L),
-                  Math.addExact(previous.serializedSize, rowSize)))
-          }
-          stats.iterator
-      }
-      .reduceByKey {
-        (left, right) =>
-          PartitionDataStats(
-            Math.addExact(left.rowCount, right.rowCount),
-            Math.addExact(left.serializedSize, right.serializedSize))
-      }
-      .collect()
-      .map {
-        case (partition, stats) =>
-          SerializationUtils.deserializeBinaryRow(partition.bytes) -> stats
-      }
-      .toMap
-  }
-
-  private def computeBucketNumBySize(
-      dataStats: PartitionDataStats,
-      postponeRowCount: Long,
-      targetSizePerBucket: Long,
-      maxNumBuckets: Int): Int = {
-    if (targetSizePerBucket <= 0) {
-      throw new IllegalArgumentException(
-        "Option 'postpone.target-size-per-bucket' must be greater than 0.")
-    }
-
-    // Previous postpone files do not record their uncompressed serialized 
size. Estimate it with
-    // the average serialized size of incoming rows from the same partition.
-    val estimatedTotalSizeNumerator =
-      BigInt(dataStats.serializedSize) *
-        (BigInt(dataStats.rowCount) + BigInt(postponeRowCount))
-    val rowCount = BigInt(dataStats.rowCount)
-    val estimatedTotalSize = (estimatedTotalSizeNumerator + rowCount - 1) / 
rowCount
-    val bucketNum =
-      if (estimatedTotalSize == 0) BigInt(1)
-      else (estimatedTotalSize - 1) / BigInt(targetSizePerBucket) + 1
-    bucketNum.min(BigInt(maxNumBuckets)).toInt
-  }
-
   private def deserializeCommitMessage(
       serializer: CommitMessageSerializer,
       bytes: Array[Byte]): CommitMessage = {
@@ -689,38 +554,6 @@ case class PaimonSparkWriter(
 }
 
 object PaimonSparkWriter {
-
-  private[spark] def computeBucketNumByRowCount(
-      rowCount: Long,
-      targetRowNumPerBucket: Long,
-      maxNumBuckets: Int): Int = {
-    if (targetRowNumPerBucket <= 0) {
-      throw new IllegalArgumentException(
-        "Option 'postpone.target-row-num-per-bucket' must be greater than 0.")
-    }
-
-    val bucketNum =
-      if (rowCount <= 0) 1L else (rowCount - 1) / targetRowNumPerBucket + 1
-    Math.min(bucketNum, maxNumBuckets.toLong).toInt
-  }
-
-  private case class PostponeBucketAssignment(
-      partitionBucketComputer: BinaryRow => Integer,
-      dataPersisted: Boolean)
-
-  private case class PartitionDataStats(rowCount: Long, serializedSize: Long)
-
-  private case class SerializedPartition(bytes: Array[Byte]) {
-    override def equals(other: Any): Boolean = {
-      other match {
-        case that: SerializedPartition => java.util.Arrays.equals(bytes, 
that.bytes)
-        case _ => false
-      }
-    }
-
-    override def hashCode(): Int = java.util.Arrays.hashCode(bytes)
-  }
-
   def apply(table: FileStoreTable): PaimonSparkWriter = {
     new PaimonSparkWriter(table)
   }
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
index 346aa0fe3a..937a47c526 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/WriteIntoPaimonTable.scala
@@ -60,7 +60,7 @@ case class WriteIntoPaimonTable(
 
     val writer = PaimonSparkWriter(table, batchId = batchId)
     if (overwritePartition != null) {
-      writer.writeBuilder.withOverwrite(overwritePartition.asJava)
+      writer.withOverwrite(overwritePartition.asJava)
     }
     val operation = Option(options.get(PaimonWriteOptions.OPERATION_OPTION))
       .map(Snapshot.Operation.valueOf)
@@ -71,10 +71,7 @@ case class WriteIntoPaimonTable(
           Snapshot.Operation.WRITE
         }
       }
-    val commitMessages =
-      writer.write(
-        replacedData,
-        overwriteExistingData = overwritePartition != null || 
dynamicPartitionOverwriteMode)
+    val commitMessages = writer.write(replacedData)
     writer.commit(commitMessages, operation)
 
     Seq.empty
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 a3141f97b7..25d4f8a91d 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
@@ -213,6 +213,7 @@ case class SparkPostponeCompactProcedure(
               realTable.store().newScan(),
               realTable.store().newIndexFileHandler(),
               snapshotId))
+          dataWrite.write.getWrite().withIgnoreNumBucketCheck(true)
           var commitInvoked = false
           try {
             val pendingBuckets = mutable.LinkedHashMap
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RescaleProcedureTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RescaleProcedureTest.scala
index b4a6525116..7d792ca27a 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RescaleProcedureTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/RescaleProcedureTest.scala
@@ -84,6 +84,57 @@ class RescaleProcedureTest extends PaimonSparkTestBase {
     }
   }
 
+  test("Paimon Procedure: rescale reads deletion-vector level-0 files") {
+    withTable("T") {
+      spark.sql(s"""
+                   |CREATE TABLE T (id INT, value STRING)
+                   |TBLPROPERTIES (
+                   |  'primary-key' = 'id',
+                   |  'bucket' = '1',
+                   |  'deletion-vectors.enabled' = 'true',
+                   |  'deletion-vectors.merge-on-read' = 'false',
+                   |  'deletion-vectors.bitmap64' = 'true'
+                   |)
+                   |""".stripMargin)
+
+      spark.sql("INSERT INTO T VALUES (1, 'one'), (2, 'two'), (3, 'three')")
+      spark.sql("INSERT INTO T VALUES (2, 'updated-two')")
+      val tableWithDv = loadTable("T")
+      val splitsWithDv = 
tableWithDv.newSnapshotReader().read().dataSplits().asScala
+      assert(
+        splitsWithDv.exists(
+          _.deletionFiles().orElse(Collections.emptyList()).asScala.exists(_ 
!= null)))
+
+      // Ordinary non-MOR scans skip level 0. Keep a real level-0 file to 
verify that rescale's
+      // explicit split scan is not subject to the ordinary batch-scan pruning.
+      spark.sql("ALTER TABLE T SET TBLPROPERTIES ('write-only' = 'true')")
+      spark.sql("INSERT INTO T VALUES (4, 'level-zero')")
+      assert(
+        loadTable("T")
+          .newSnapshotReader()
+          .read()
+          .dataSplits()
+          .asScala
+          .flatMap(_.dataFiles().asScala)
+          .exists(_.level() == 0))
+      checkAnswer(spark.sql("SELECT * FROM T WHERE id = 4"), Seq.empty)
+
+      spark.sql("ALTER TABLE T SET TBLPROPERTIES ('bucket' = '2', 'write-only' 
= 'false')")
+      checkAnswer(spark.sql("CALL sys.rescale(table => 'T', bucket_num => 
2)"), Row(true) :: Nil)
+
+      val rescaledTable = loadTable("T")
+      val rescaledSplits = 
rescaledTable.newSnapshotReader().read().dataSplits().asScala
+      assert(rescaledSplits.flatMap(_.dataFiles().asScala).forall(_.level() > 
0))
+      assert(
+        rescaledSplits
+          .flatMap(_.deletionFiles().orElse(Collections.emptyList()).asScala)
+          .forall(_ == null))
+      checkAnswer(
+        spark.sql("SELECT * FROM T ORDER BY id"),
+        Seq(Row(1, "one"), Row(2, "updated-two"), Row(3, "three"), Row(4, 
"level-zero")))
+    }
+  }
+
   test("Paimon Procedure: rescale partitioned tables") {
     withTable("T") {
       spark.sql(s"""
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 b25c03aec5..a96e0c21e9 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
@@ -18,15 +18,17 @@
 
 package org.apache.paimon.spark.sql
 
+import org.apache.paimon.Snapshot.CommitKind
 import org.apache.paimon.catalog.{Catalog, CatalogLoader, DelegateCatalog, 
Identifier}
+import org.apache.paimon.data.BinaryRow
 import 
org.apache.paimon.deletionvectors.DeletionVectorsIndexFile.DELETION_VECTORS_INDEX
 import org.apache.paimon.fs.Path
-import org.apache.paimon.spark.{PaimonScan, PaimonSparkTestBase, 
PostponeMergeInputScan}
+import org.apache.paimon.spark.{PaimonScan, PaimonSparkTestBase, 
PostponeMergeInputScan, SparkTable}
 import org.apache.paimon.spark.PaimonMetrics._
-import org.apache.paimon.spark.commands.PaimonSparkWriter
 import org.apache.paimon.spark.execution.PostponeMergeOnReadExec
 import org.apache.paimon.spark.procedure.SparkPostponeCompactProcedure
-import org.apache.paimon.table.{BucketMode, CatalogEnvironment, 
FileStoreTableFactory}
+import org.apache.paimon.table.{BucketMode, CatalogEnvironment, 
FileStoreTableFactory, PostponeUtils}
+import org.apache.paimon.table.source.ScanMode
 
 import org.apache.spark.TaskContext
 import org.apache.spark.sql.Row
@@ -36,12 +38,474 @@ import scala.collection.JavaConverters._
 
 class PostponeBucketTableTest extends PaimonSparkTestBase {
 
-  test("Postpone bucket table: cap inferred row-count buckets before Int 
conversion") {
-    assert(
-      PaimonSparkWriter.computeBucketNumByRowCount(
-        Integer.MAX_VALUE.toLong + 1L,
-        targetRowNumPerBucket = 1L,
-        maxNumBuckets = 2048) == 2048)
+  test("Postpone bucket table: staged fixed write respects non-ignored empty 
commit") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING
+            |) TBLPROPERTIES (
+            |  'primary-key' = 'k',
+            |  'bucket' = '-2',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '10',
+            |  'snapshot.ignore-empty-commit' = 'false'
+            |)
+            |""".stripMargin)
+
+      sql("INSERT INTO t SELECT CAST(id AS INT), CAST(id AS STRING) FROM 
range(0)")
+
+      val latestSnapshot = loadTable("t").latestSnapshot()
+      assert(latestSnapshot.isPresent)
+      assert(latestSnapshot.get().id() == 1L)
+      assert(latestSnapshot.get().deltaRecordCount() == 0L)
+    }
+  }
+
+  test("Postpone bucket table: staged fixed write uses V1 and supports dynamic 
partitions") {
+    withTable("t") {
+      withSparkSQLConf("spark.paimon.write.use-v2-write" -> "true") {
+        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' = '10',
+              |  'postpone.batch-write-fixed-bucket.max-parallelism' = '32'
+              |)
+              |""".stripMargin)
+        assert(!SparkTable(loadTable("t")).useV2Write)
+
+        sql("""
+              |INSERT INTO t SELECT
+              |CAST(id AS INT) AS k,
+              |CAST(id AS STRING) AS v,
+              |CASE WHEN id < 5 THEN 0 WHEN id < 26 THEN 1 ELSE 2 END AS pt
+              |FROM range(0, 68)
+              |""".stripMargin)
+
+        checkAnswer(
+          sql("SELECT pt, count(*) FROM t GROUP BY pt ORDER BY pt"),
+          Seq(Row(0, 5L), Row(1, 21L), Row(2, 42L)))
+        val knownBuckets = 
PostponeUtils.getKnownNumBuckets(loadTable("t")).asScala
+        assert(knownBuckets(BinaryRow.singleColumn(0)) == 1)
+        assert(knownBuckets(BinaryRow.singleColumn(1)) == 4)
+        assert(knownBuckets(BinaryRow.singleColumn(2)) == 8)
+        checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(0L)))
+        assert(loadTable("t").latestSnapshot().get().id() == 1L)
+      }
+    }
+  }
+
+  test("Postpone bucket table: staged rescale supports per-partition layouts") 
{
+    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',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '64'
+            |)
+            |""".stripMargin)
+
+      sql("""
+            |INSERT INTO t
+            |SELECT 0 AS k, 'p0-initial' AS v, 0 AS pt
+            |UNION ALL
+            |SELECT CAST(id AS INT), CAST(id AS STRING), 1 AS pt FROM range(0, 
8)
+            |UNION ALL
+            |SELECT 0 AS k, 'p3-initial' AS v, 3 AS pt
+            |""".stripMargin)
+
+      withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+        sql("""INSERT INTO t VALUES
+              |(0, 'historical-update', 0),
+              |(9999, 'historical-only', 0),
+              |(8888, 'untouched-postpone', 2)
+              |""".stripMargin)
+      }
+      checkAnswer(
+        sql("SELECT count(*) FROM `t$buckets` WHERE partition = '{0}' AND 
bucket = -2"),
+        Seq(Row(1L)))
+      checkAnswer(
+        sql("SELECT count(*) FROM `t$buckets` WHERE partition = '{2}' AND 
bucket = -2"),
+        Seq(Row(1L)))
+
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CONCAT('current-', CAST(id AS STRING)), 0 
AS pt
+            |FROM range(0, 130)
+            |UNION ALL
+            |SELECT 100 AS k, 'p1-new' AS v, 1 AS pt
+            |UNION ALL
+            |SELECT CAST(id AS INT), CONCAT('p3-', CAST(id AS STRING)), 3 AS pt
+            |FROM range(0, 33)
+            |""".stripMargin)
+
+      checkAnswer(
+        sql("SELECT pt, count(*) FROM t GROUP BY pt ORDER BY pt"),
+        Seq(Row(0, 130L), Row(1, 9L), Row(3, 33L)))
+      checkAnswer(
+        sql("SELECT k, v FROM t WHERE pt = 0 AND k IN (0, 9999) ORDER BY k"),
+        Seq(Row(0, "current-0")))
+      val knownBuckets = 
PostponeUtils.getKnownNumBuckets(loadTable("t")).asScala
+      assert(knownBuckets(BinaryRow.singleColumn(0)) == 64)
+      assert(knownBuckets(BinaryRow.singleColumn(1)) == 8)
+      assert(knownBuckets(BinaryRow.singleColumn(3)) == 64)
+      checkAnswer(
+        sql("SELECT count(*) FROM `t$buckets` WHERE partition = '{0}' AND 
bucket = -2"),
+        Seq(Row(1L)))
+      checkAnswer(
+        sql("SELECT count(*) FROM `t$buckets` WHERE partition = '{2}' AND 
bucket = -2"),
+        Seq(Row(1L)))
+      assert(loadTable("t").latestSnapshot().get().id() == 4L)
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(
+          sql("SELECT k, v, pt FROM t WHERE k IN (8888, 9999) ORDER BY k"),
+          Seq(Row(8888, "untouched-postpone", 2), Row(9999, "historical-only", 
0)))
+      }
+    }
+  }
+
+  test("Postpone bucket table: load-factor rescale") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING
+            |) TBLPROPERTIES (
+            |  'primary-key' = 'k',
+            |  'bucket' = '-2',
+            |  'changelog-producer' = 'input',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '1',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '16',
+            |  'postpone.batch-write-fixed-bucket.rescale-load-factor' = '32'
+            |)
+            |""".stripMargin)
+
+      sql("INSERT INTO t VALUES (0, 'initial')")
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 1)
+
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CAST(id AS STRING) FROM range(1, 33)
+            |""".stripMargin)
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 1)
+
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CAST(id AS STRING) FROM range(33, 66)
+            |""".stripMargin)
+
+      checkAnswer(sql("SELECT count(*), sum(k) FROM t"), Seq(Row(66L, 2145L)))
+      val resultTable = loadTable("t")
+      
assert(PostponeUtils.getKnownNumBuckets(resultTable).get(BinaryRow.EMPTY_ROW) 
== 16)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(0L)))
+      assert(resultTable.latestSnapshot().get().id() == 4L)
+      assert(resultTable.snapshotManager().snapshot(3L).commitKind() == 
CommitKind.OVERWRITE)
+      
assert(resultTable.snapshotManager().snapshot(3L).changelogManifestList() == 
null)
+      assert(resultTable.snapshotManager().snapshot(4L).commitKind() == 
CommitKind.APPEND)
+      assert(
+        !resultTable
+          .newSnapshotReader()
+          .withMode(ScanMode.CHANGELOG)
+          .read()
+          .dataSplits()
+          .isEmpty)
+    }
+  }
+
+  test("Postpone bucket table: staged fixed write does not compact") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING
+            |) TBLPROPERTIES (
+            |  'primary-key' = 'k',
+            |  'bucket' = '1',
+            |  'deletion-vectors.enabled' = 'true',
+            |  'deletion-vectors.merge-on-read' = 'true',
+            |  'deletion-vectors.bitmap64' = 'false'
+            |)
+            |""".stripMargin)
+
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CONCAT('base-', CAST(id AS STRING)) FROM 
range(0, 4)
+            |""".stripMargin)
+      sql("INSERT INTO t VALUES (3, 'updated-3')")
+      assert(deletionVectorCardinality("t") == 1L)
+      checkAnswer(
+        sql("SELECT * FROM t ORDER BY k"),
+        Seq(Row(0, "base-0"), Row(1, "base-1"), Row(2, "base-2"), Row(3, 
"updated-3")))
+
+      sql("""
+            |ALTER TABLE t SET TBLPROPERTIES (
+            |  'bucket' = '-2',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '4',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '8',
+            |  'postpone.batch-write-fixed-bucket.rescale-load-factor' = '2'
+            |)
+            |""".stripMargin)
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 1)
+
+      // The fixed writer restores the old bucket but remains write-only. 
Existing deletion
+      // vectors and new level-0 files are left for background compaction.
+      sql("""
+            |INSERT INTO t
+            |SELECT 2 AS k, 'updated-2' AS v
+            |UNION ALL
+            |SELECT CAST(id AS INT), CONCAT('new-', CAST(id AS STRING)) FROM 
range(4, 8)
+            |""".stripMargin)
+
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 1)
+      assert(deletionVectorCardinality("t") == 1L)
+      assert(
+        loadTable("t")
+          .newSnapshotReader()
+          .onlyReadRealBuckets()
+          .read()
+          .dataSplits()
+          .asScala
+          .flatMap(_.dataFiles().asScala)
+          .exists(_.level() == 0))
+      checkAnswer(
+        sql("SELECT * FROM t ORDER BY k"),
+        Seq(
+          Row(0, "base-0"),
+          Row(1, "base-1"),
+          Row(2, "updated-2"),
+          Row(3, "updated-3"),
+          Row(4, "new-4"),
+          Row(5, "new-5"),
+          Row(6, "new-6"),
+          Row(7, "new-7"))
+      )
+      checkAnswer(sql("SELECT count(*) FROM t"), Seq(Row(8L)))
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(0L)))
+    }
+  }
+
+  test("Postpone bucket table: staged rescale reads level 0 and applies 
deletion vectors") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING,
+            |  pt INT
+            |) PARTITIONED BY (pt)
+            |TBLPROPERTIES (
+            |  'primary-key' = 'k, pt',
+            |  'bucket' = '1',
+            |  'deletion-vectors.enabled' = 'true',
+            |  'deletion-vectors.merge-on-read' = 'false',
+            |  'deletion-vectors.bitmap64' = 'true'
+            |)
+            |""".stripMargin)
+
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CONCAT('base-', CAST(id AS STRING)), 0 AS 
pt
+            |FROM range(0, 4)
+            |UNION ALL
+            |SELECT CAST(id AS INT), CONCAT('base-', CAST(id AS STRING)), 1 AS 
pt
+            |FROM range(0, 4)
+            |""".stripMargin)
+      sql("INSERT INTO t VALUES (2, 'updated-2', 0), (2, 'updated-2', 1)")
+      assert(deletionVectorCardinality("t") == 2L)
+      assert(deletionVectorCardinality("t", BinaryRow.singleColumn(0)) == 1L)
+      assert(deletionVectorCardinality("t", BinaryRow.singleColumn(1)) == 1L)
+      checkAnswer(
+        sql("SELECT k, v, pt FROM t ORDER BY pt, k"),
+        Seq(
+          Row(0, "base-0", 0),
+          Row(1, "base-1", 0),
+          Row(2, "updated-2", 0),
+          Row(3, "base-3", 0),
+          Row(0, "base-0", 1),
+          Row(1, "base-1", 1),
+          Row(2, "updated-2", 1),
+          Row(3, "base-3", 1)
+        )
+      )
+
+      // Ordinary batch scans skip DV level-0 files when merge-on-read is 
false. Keep one such
+      // file in partition 0 to verify that rescale still reads the complete 
logical snapshot.
+      sql("ALTER TABLE t SET TBLPROPERTIES ('write-only' = 'true')")
+      sql("INSERT INTO t VALUES (50, 'level-0', 0)")
+      val partition0 = BinaryRow.singleColumn(0)
+      assert(
+        loadTable("t")
+          .newSnapshotReader()
+          .onlyReadRealBuckets()
+          .read()
+          .dataSplits()
+          .asScala
+          .filter(_.partition() == partition0)
+          .flatMap(_.dataFiles().asScala)
+          .exists(_.level() == 0))
+      checkAnswer(sql("SELECT * FROM t WHERE k = 50 AND pt = 0"), Seq.empty)
+
+      sql("""
+            |ALTER TABLE t SET TBLPROPERTIES (
+            |  'bucket' = '-2',
+            |  'write-only' = 'false',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '4',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '8',
+            |  'postpone.batch-write-fixed-bucket.rescale-load-factor' = '2'
+            |)
+            |""".stripMargin)
+      val initialBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+      assert(initialBuckets.get(partition0) == 1)
+      assert(initialBuckets.get(BinaryRow.singleColumn(1)) == 1)
+      assert(!loadTable("t").coreOptions().writeOnly())
+      assert(loadTable("t").coreOptions().needLookup())
+
+      // Only partition 0 exceeds the load factor. Its old file and deletion 
vectors are
+      // materialized into the new layout, while partition 1 and its deletion 
vectors stay intact.
+      sql("""
+            |INSERT INTO t
+            |SELECT 1 AS k, 'new-1' AS v, 0 AS pt
+            |UNION ALL
+            |SELECT CAST(id AS INT), CONCAT('new-', CAST(id AS STRING)), 0 AS 
pt
+            |FROM range(100, 108)
+            |""".stripMargin)
+
+      val resultBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+      assert(resultBuckets.get(partition0) == 4)
+      assert(resultBuckets.get(BinaryRow.singleColumn(1)) == 1)
+      assert(deletionVectorCardinality("t") == 1L)
+      assert(deletionVectorCardinality("t", partition0) == 0L)
+      assert(deletionVectorCardinality("t", BinaryRow.singleColumn(1)) == 1L)
+      val resultFiles = loadTable("t")
+        .newSnapshotReader()
+        .onlyReadRealBuckets()
+        .read()
+        .dataSplits()
+        .asScala
+        .filter(_.partition() == partition0)
+        .flatMap(_.dataFiles().asScala)
+      assert(
+        resultFiles.exists(_.level() == 0),
+        resultFiles.map(file => 
s"${file.fileName()}:L${file.level()}").mkString(", "))
+      val expected =
+        Seq(
+          Row(0, "base-0", 0),
+          Row(1, "new-1", 0),
+          Row(2, "updated-2", 0),
+          Row(3, "base-3", 0),
+          Row(50, "level-0", 0)) ++
+          (100 until 108).map(id => Row(id, s"new-$id", 0)) ++
+          Seq(Row(0, "base-0", 1), Row(1, "base-1", 1), Row(2, "updated-2", 
1), Row(3, "base-3", 1))
+      withSparkSQLConf("spark.paimon.deletion-vectors.merge-on-read" -> 
"true") {
+        checkAnswer(sql("SELECT k, v, pt FROM t ORDER BY pt, k"), expected)
+        checkAnswer(
+          sql("SELECT pt, count(*) FROM t GROUP BY pt ORDER BY pt"),
+          Seq(Row(0, 13L), Row(1, 4L)))
+      }
+
+      // Background compaction materializes the level-0 files and deletion 
vectors.
+      sql("CALL sys.compact(table => 't')")
+      val compactedFiles = loadTable("t")
+        .newSnapshotReader()
+        .onlyReadRealBuckets()
+        .read()
+        .dataSplits()
+        .asScala
+        .flatMap(_.dataFiles().asScala)
+      assert(
+        compactedFiles.forall(_.level() > 0),
+        compactedFiles.map(file => 
s"${file.fileName()}:L${file.level()}").mkString(", "))
+      checkAnswer(sql("SELECT k, v, pt FROM t ORDER BY pt, k"), expected)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(0L)))
+    }
+  }
+
+  test("Postpone bucket table: staged write preserves historical postpone 
without rescale") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING
+            |) TBLPROPERTIES (
+            |  'primary-key' = 'k',
+            |  'bucket' = '-2',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '1',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '16'
+            |)
+            |""".stripMargin)
+
+      sql("INSERT INTO t SELECT CAST(id AS INT), CAST(id AS STRING) FROM 
range(0, 8)")
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 8)
+
+      withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+        sql("INSERT INTO t VALUES (0, 'historical-update'), (100, 
'historical-only')")
+      }
+      sql("INSERT INTO t VALUES (0, 'current-update')")
+
+      checkAnswer(sql("SELECT count(*) FROM t"), Seq(Row(8L)))
+      checkAnswer(
+        sql("SELECT * FROM t WHERE k IN (0, 100) ORDER BY k"),
+        Seq(Row(0, "current-update")))
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.EMPTY_ROW)
 == 8)
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(sql("SELECT * FROM t WHERE k = 100"), Seq(Row(100, 
"historical-only")))
+      }
+    }
+  }
+
+  test("Postpone bucket table: real-bucket rescale preserves historical 
postpone data") {
+    withTable("t") {
+      sql("""
+            |CREATE TABLE t (
+            |  k INT,
+            |  v STRING,
+            |  pt INT
+            |) PARTITIONED BY (pt)
+            |TBLPROPERTIES (
+            |  'primary-key' = 'k, pt',
+            |  'bucket' = '-2',
+            |  'dynamic-partition-overwrite' = 'true',
+            |  'postpone.batch-write-fixed-bucket' = 'true',
+            |  'postpone.target-row-num-per-bucket' = '1',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '16'
+            |)
+            |""".stripMargin)
+
+      sql("INSERT INTO t VALUES (1, 'base', 0)")
+      withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+        sql("DELETE FROM t WHERE k = 1 AND pt = 0")
+      }
+      sql("""
+            |INSERT INTO t
+            |SELECT CAST(id AS INT), CAST(id AS STRING), 0 AS pt FROM range(2, 
35)
+            |""".stripMargin)
+
+      checkAnswer(sql("SELECT count(*) FROM t"), Seq(Row(34L)))
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.singleColumn(0))
 == 16)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(sql("SELECT count(*) FROM t"), Seq(Row(33L)))
+        checkAnswer(sql("SELECT * FROM t WHERE k = 1"), Seq.empty)
+      }
+    }
   }
 
   test("Postpone bucket table: write with different bucket number") {
@@ -99,7 +563,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
             |""".stripMargin)
       checkAnswer(
         sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{5}' 
ORDER BY bucket"),
-        Seq(Row(0), Row(1), Row(2))
+        Seq(Row(0), Row(1))
       )
     }
   }
@@ -134,7 +598,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
       )
       checkAnswer(
         sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{1}' 
ORDER BY bucket"),
-        Seq(Row(0), Row(1), Row(2))
+        Seq(Row(0), Row(1), Row(2), Row(3))
       )
 
       // Existing partitions keep their bucket number even when the new data 
volume changes.
@@ -150,7 +614,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
         Seq(Row(0))
       )
 
-      // Postpone rows are included when a partition gets real buckets for the 
first time.
+      // Historical postpone rows neither participate in inference nor enter 
the fixed write.
       withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
         sql("""
               |INSERT INTO t SELECT
@@ -169,8 +633,13 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
             |""".stripMargin)
       checkAnswer(
         sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{2}' 
ORDER BY bucket"),
-        Seq(Row(-2), Row(0), Row(1))
+        Seq(Row(-2), Row(0))
       )
+      checkAnswer(sql("SELECT count(*) FROM t WHERE pt = 2"), Seq(Row(100L)))
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.singleColumn(2))
 == 1)
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(sql("SELECT count(*) FROM t WHERE pt = 2"), Seq(Row(250L)))
+      }
     }
   }
 
@@ -205,43 +674,47 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
     ).foreach {
       case (partitionOverwriteMode, overwriteSql) =>
         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' = 'false',
-                |  'postpone.target-row-num-per-bucket' = '100'
-                |)
-                |""".stripMargin)
-
-          sql("""
-                |INSERT INTO t SELECT
-                |id AS k,
-                |CAST(id AS STRING) AS v,
-                |0 AS pt
-                |FROM range (0, 1000)
-                |""".stripMargin)
-
-          withSparkSQLConf(
-            "spark.paimon.postpone.batch-write-fixed-bucket" -> "true",
-            "spark.sql.sources.partitionOverwriteMode" -> 
partitionOverwriteMode) {
-            sql(overwriteSql)
+          withSparkSQLConf("spark.paimon.write.use-v2-write" -> "true") {
+            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' = 'false',
+                  |  'postpone.target-row-num-per-bucket' = '100'
+                  |)
+                  |""".stripMargin)
+            assert(SparkTable(loadTable("t")).useV2Write)
+
+            sql("""
+                  |INSERT INTO t SELECT
+                  |id AS k,
+                  |CAST(id AS STRING) AS v,
+                  |0 AS pt
+                  |FROM range (0, 1000)
+                  |""".stripMargin)
+
+            withSparkSQLConf(
+              "spark.paimon.postpone.batch-write-fixed-bucket" -> "true",
+              "spark.sql.sources.partitionOverwriteMode" -> 
partitionOverwriteMode) {
+              sql(overwriteSql)
+            }
+
+            checkAnswer(sql("SELECT count(*), sum(k) FROM t"), Seq(Row(100L, 
104950L)))
+            checkAnswer(
+              sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = 
'{0}'"),
+              Seq(Row(0)))
+            checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = 
-2"), Seq(Row(0L)))
           }
-
-          checkAnswer(
-            sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = 
'{0}'"),
-            Seq(Row(0))
-          )
         }
     }
   }
 
-  test("Postpone bucket table: infer bucket number from serialized data size") 
{
+  test("Postpone bucket table: infer bucket number from staged file size") {
     withTable("t") {
       sql("""
             |CREATE TABLE t (
@@ -258,43 +731,45 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
             |""".stripMargin)
 
       sql("""
-            |INSERT INTO t SELECT /*+ REPARTITION(20) */
+            |INSERT INTO t SELECT /*+ REPARTITION(1) */
             |id AS k,
-            |CASE WHEN id < 100 THEN repeat('x', 100) ELSE repeat('x', 1000) 
END AS v,
-            |CASE WHEN id < 100 THEN 0 ELSE 1 END AS pt
-            |FROM range (0, 200)
+            |CASE WHEN id < 10 THEN sha2(CAST(id AS STRING), 256)
+            |     ELSE array_join(transform(sequence(0, 63),
+            |       x -> sha2(concat(CAST(id AS STRING), '-', CAST(x AS 
STRING)), 256)), '')
+            |END AS v,
+            |CASE WHEN id < 10 THEN 0 ELSE 1 END AS pt
+            |FROM range (0, 110)
             |""".stripMargin)
 
-      checkAnswer(
-        sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{0}' 
ORDER BY bucket"),
-        Seq(Row(0))
-      )
-      checkAnswer(
-        sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{1}' 
ORDER BY bucket"),
-        Seq(Row(0), Row(1), Row(2), Row(3))
-      )
+      val initialBuckets = PostponeUtils.getKnownNumBuckets(loadTable("t"))
+      assert(initialBuckets.get(BinaryRow.singleColumn(0)) == 1)
+      assert(initialBuckets.get(BinaryRow.singleColumn(1)) > 1)
 
-      // Estimate existing postpone data with the average serialized size of 
incoming rows.
+      // Historical postpone data is ignored; only the exactly measured 
current batch is inferred.
       withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
         sql("""
               |INSERT INTO t SELECT
               |id AS k,
-              |repeat('x', 1000) AS v,
+              |array_join(transform(sequence(0, 63),
+              |  x -> sha2(concat(CAST(id AS STRING), '-', CAST(x AS STRING)), 
256)), '') AS v,
               |2 AS pt
               |FROM range (1000, 1100)
               |""".stripMargin)
       }
       sql("""
-            |INSERT INTO t SELECT /*+ REPARTITION(20) */
+            |INSERT INTO t SELECT /*+ REPARTITION(1) */
             |id AS k,
-            |repeat('x', 1000) AS v,
+            |array_join(transform(sequence(0, 63),
+            |  x -> sha2(concat(CAST(id AS STRING), '-', CAST(x AS STRING)), 
256)), '') AS v,
             |2 AS pt
             |FROM range (2000, 2100)
             |""".stripMargin)
-      checkAnswer(
-        sql("SELECT distinct(bucket) FROM `t$buckets` WHERE partition = '{2}' 
ORDER BY bucket"),
-        Seq(Row(-2), Row(0), Row(1), Row(2), Row(3), Row(4), Row(5), Row(6))
-      )
+      
assert(PostponeUtils.getKnownNumBuckets(loadTable("t")).get(BinaryRow.singleColumn(2))
 > 1)
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+      checkAnswer(sql("SELECT count(*) FROM t WHERE pt = 2"), Seq(Row(100L)))
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(sql("SELECT count(*) FROM t WHERE pt = 2"), Seq(Row(200L)))
+      }
     }
   }
 
@@ -357,6 +832,7 @@ class PostponeBucketTableTest extends PaimonSparkTestBase {
             |  'deletion-vectors.enabled' = 'true',
             |  'deletion-vectors.merge-on-read' = 'true',
             |  'postpone.default-bucket-num' = '1',
+            |  'postpone.batch-write-fixed-bucket.max-parallelism' = '1',
             |  'source.split.target-size' = '1 B'
             |)
             |""".stripMargin)
@@ -722,6 +1198,19 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
         // Core must retain seq after Spark projects v.
         checkAnswer(sql("SELECT v FROM t ORDER BY v"), Seq(Row("base-1"), 
Row("newer")))
       }
+
+      // The fixed write ignores historical postpone rows. They still 
participate when merge-on-read
+      // is enabled, where the user sequence field takes precedence.
+      sql("INSERT INTO t VALUES (1, 'current', 15), (2, 'current-older', 15)")
+      checkAnswer(
+        sql("SELECT * FROM t ORDER BY k"),
+        Seq(Row(1, "current", 15), Row(2, "current-older", 15)))
+      checkAnswer(sql("SELECT count(*) FROM `t$buckets` WHERE bucket = -2"), 
Seq(Row(1L)))
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(
+          sql("SELECT * FROM t ORDER BY k"),
+          Seq(Row(1, "current", 15), Row(2, "newer", 20)))
+      }
     }
   }
 
@@ -863,6 +1352,19 @@ class PostponeBucketTableTest extends PaimonSparkTestBase 
{
         checkAnswer(sql("SELECT * FROM partial_t WHERE v2 = 'b'"), Seq(Row(1, 
"a", "b")))
         checkAnswer(sql("SELECT * FROM aggregation_t WHERE total = 13"), 
Seq(Row(1, 13L)))
       }
+
+      sql("INSERT INTO partial_t VALUES (1, 'c', CAST(NULL AS STRING))")
+      sql("INSERT INTO aggregation_t VALUES (1, 7L)")
+      checkAnswer(sql("SELECT * FROM partial_t"), Seq(Row(1, "c", null)))
+      checkAnswer(sql("SELECT * FROM aggregation_t"), Seq(Row(1, 17L)))
+      checkAnswer(sql("SELECT count(*) FROM `partial_t$buckets` WHERE bucket = 
-2"), Seq(Row(1L)))
+      checkAnswer(
+        sql("SELECT count(*) FROM `aggregation_t$buckets` WHERE bucket = -2"),
+        Seq(Row(1L)))
+      withSparkSQLConf("spark.paimon.postpone.merge-on-read" -> "true") {
+        checkAnswer(sql("SELECT * FROM partial_t"), Seq(Row(1, "c", "b")))
+        checkAnswer(sql("SELECT * FROM aggregation_t"), Seq(Row(1, 20L)))
+      }
     }
   }
 
@@ -1153,17 +1655,27 @@ class PostponeBucketTableTest extends 
PaimonSparkTestBase {
   }
 
   private def deletionVectorCardinality(tableName: String): Long = {
+    deletionVectorCardinality(tableName, None)
+  }
+
+  private def deletionVectorCardinality(tableName: String, partition: 
BinaryRow): Long = {
+    deletionVectorCardinality(tableName, Some(partition))
+  }
+
+  private def deletionVectorCardinality(tableName: String, partition: 
Option[BinaryRow]): Long = {
     val table = loadTable(tableName)
     table
       .store()
       .newIndexFileHandler()
       .scan(table.latestSnapshot().get(), DELETION_VECTORS_INDEX)
       .asScala
+      .filter(entry => partition.forall(_ == entry.partition()))
       .flatMap(entry => Option(entry.indexFile().dvRanges()).toSeq)
       .flatMap(_.values().asScala)
       .flatMap(meta => Option(meta.cardinality()).map(_.longValue()))
       .sum
   }
+
 }
 
 object PostponeBucketTableTest {

Reply via email to