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 26f415a1a2 [core][flink][spark] Support PK-vector indexes for postpone 
buckets (#8594)
26f415a1a2 is described below

commit 26f415a1a20c63ceac1cf2d9e829d5516273d81a
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 13 17:48:38 2026 +0800

    [core][flink][spark] Support PK-vector indexes for postpone buckets (#8594)
    
    - Allow primary-key vector indexes in postpone-bucket mode (`bucket =
    -2`) while continuing to reject dynamic buckets.
    - Capture one snapshot for postpone planning and writer restore.
    - Make Flink and Spark background compaction process both bucket `-2`
    rows and real-bucket Level-0 files.
    - Build and publish ANN index changes together with compacted data in
    one strict atomic commit.
    - Exclude postponed data from vector search until compaction and
    document the visibility behavior.
---
 docs/docs/primary-key-table/vector-index.md        |   9 +-
 .../paimon/operation/FileSystemWriteRestore.java   |  27 ++-
 .../org/apache/paimon/postpone/BucketFiles.java    |   2 +
 .../org/apache/paimon/schema/SchemaValidation.java |   5 +-
 .../org/apache/paimon/table/PostponeUtils.java     | 116 ++++++++++-
 .../paimon/table/source/PrimaryKeyVectorScan.java  |   4 +
 .../operation/FileSystemWriteRestoreTest.java      |  39 ++++
 .../apache/paimon/postpone/BucketFilesTest.java    |  72 +++++++
 .../PrimaryKeyVectorIndexValidationTest.java       |  12 +-
 .../org/apache/paimon/table/PostponeUtilsTest.java | 106 ++++++++++
 .../table/source/PrimaryKeyVectorScanTest.java     |  34 +++
 .../apache/paimon/flink/action/CompactAction.java  | 116 ++++++++---
 .../postpone/PostponeBucketCompactOperator.java    | 134 ++++++++++++
 .../postpone/PostponeBucketCompactSplitSource.java |  44 +++-
 .../PostponeBucketCompactSplitSourceTest.java      |  57 +++++
 .../procedure/VectorSearchProcedureITCase.java     |  86 ++++++++
 .../procedure/SparkPostponeCompactProcedure.scala  | 231 ++++++++++++++-------
 .../spark/sql/PrimaryKeyVectorSearchTest.scala     |  69 ++++++
 18 files changed, 1044 insertions(+), 119 deletions(-)

diff --git a/docs/docs/primary-key-table/vector-index.md 
b/docs/docs/primary-key-table/vector-index.md
index e6adaa55f2..899e6f0d32 100644
--- a/docs/docs/primary-key-table/vector-index.md
+++ b/docs/docs/primary-key-table/vector-index.md
@@ -39,7 +39,8 @@ is built separately from writes, see
 
 A table with a primary-key vector index must satisfy all of the following:
 
-- It is a primary-key table in fixed-bucket mode (`bucket > 0`).
+- It is a primary-key table in fixed-bucket mode (`bucket > 0`) or 
postpone-bucket mode
+  (`bucket = -2`).
 - `deletion-vectors.enabled` is `true`, except for `first-row`, where it must 
be `false`.
 - Its merge engine is `deduplicate`, `partial-update`, `aggregation`, or 
`first-row`.
 - The indexed column is a `VECTOR` whose element type is `FLOAT`.
@@ -115,6 +116,12 @@ The index segment records the source data files and maps 
ANN ordinals back to th
 positions. Compact-output data-file and index-file changes are committed 
atomically, so a reader
 never observes an index from a different compact-output snapshot.
 
+For a postpone-bucket table, foreground writes remain write-only. Fixed-bucket 
batch writes produce
+Level-0 files in real buckets, while postponed writes produce files in bucket 
`-2`. These rows do not
+become visible to normal reads or vector search until a batch compact runs. 
The background compact
+processes both kinds of pending files, builds their ANN indexes, and publishes 
the data and index
+changes in one atomic commit.
+
 ANN compaction is configured independently from data compaction. It does not 
inherit
 `vector.target-file-size`, `num-sorted-run.compaction-trigger`, or
 `compaction.delete-ratio-threshold`.
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileSystemWriteRestore.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileSystemWriteRestore.java
index 46e174cc21..3a2017ef56 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileSystemWriteRestore.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileSystemWriteRestore.java
@@ -27,6 +27,8 @@ import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.utils.SnapshotManager;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -38,15 +40,35 @@ public class FileSystemWriteRestore implements WriteRestore 
{
     private final SnapshotManager snapshotManager;
     private final FileStoreScan scan;
     private final IndexFileHandler indexFileHandler;
+    private final @Nullable Long snapshotId;
 
     public FileSystemWriteRestore(
             CoreOptions options,
             SnapshotManager snapshotManager,
             FileStoreScan scan,
             IndexFileHandler indexFileHandler) {
+        this(options, snapshotManager, scan, indexFileHandler, null);
+    }
+
+    public FileSystemWriteRestore(
+            CoreOptions options,
+            SnapshotManager snapshotManager,
+            FileStoreScan scan,
+            IndexFileHandler indexFileHandler,
+            long snapshotId) {
+        this(options, snapshotManager, scan, indexFileHandler, 
Long.valueOf(snapshotId));
+    }
+
+    private FileSystemWriteRestore(
+            CoreOptions options,
+            SnapshotManager snapshotManager,
+            FileStoreScan scan,
+            IndexFileHandler indexFileHandler,
+            @Nullable Long snapshotId) {
         this.snapshotManager = snapshotManager;
         this.scan = scan;
         this.indexFileHandler = indexFileHandler;
+        this.snapshotId = snapshotId;
         if (options.manifestDeleteFileDropStats()) {
             if (this.scan != null) {
                 this.scan.dropStats();
@@ -71,7 +93,10 @@ public class FileSystemWriteRestore implements WriteRestore {
             boolean scanVectorIndexPayloads) {
         // NOTE: don't use snapshotManager.latestSnapshot() here,
         // because we don't want to flood the catalog with high concurrency
-        Snapshot snapshot = snapshotManager.latestSnapshotFromFileSystem();
+        Snapshot snapshot =
+                snapshotId == null
+                        ? snapshotManager.latestSnapshotFromFileSystem()
+                        : snapshotManager.snapshot(snapshotId);
         if (snapshot == null) {
             return RestoreFiles.empty();
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/postpone/BucketFiles.java 
b/paimon-core/src/main/java/org/apache/paimon/postpone/BucketFiles.java
index 51212f708e..d019de4421 100644
--- a/paimon-core/src/main/java/org/apache/paimon/postpone/BucketFiles.java
+++ b/paimon-core/src/main/java/org/apache/paimon/postpone/BucketFiles.java
@@ -89,7 +89,9 @@ public class BucketFiles {
         changelogFiles.addAll(message.newFilesIncrement().changelogFiles());
         changelogFiles.addAll(message.compactIncrement().changelogFiles());
 
+        newIndexFiles.addAll(message.newFilesIncrement().newIndexFiles());
         newIndexFiles.addAll(message.compactIncrement().newIndexFiles());
+        
deletedIndexFiles.addAll(message.newFilesIncrement().deletedIndexFiles());
         
deletedIndexFiles.addAll(message.compactIncrement().deletedIndexFiles());
 
         toDelete.forEach((fileName, path) -> fileIO.deleteQuietly(path));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index 4f68c0b996..746338314d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -929,8 +929,9 @@ public class SchemaValidation {
                 "Primary-key vector index with merge-engine = %s requires 
deletion-vectors.merge-on-read = false.",
                 options.mergeEngine());
         checkArgument(
-                options.bucket() > 0,
-                "Primary-key vector index requires fixed bucket mode (bucket > 
0), but bucket is %s.",
+                options.bucket() > 0 || options.bucket() == 
BucketMode.POSTPONE_BUCKET,
+                "Primary-key vector index requires fixed or postpone bucket 
mode "
+                        + "(bucket > 0 or bucket = -2), but bucket is %s.",
                 options.bucket());
         checkArgument(
                 !options.pkClusteringOverride(),
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 62fb41a9e9..8a600eee9e 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
@@ -24,14 +24,20 @@ import org.apache.paimon.manifest.SimpleFileEntry;
 
 import javax.annotation.Nullable;
 
+import java.io.Serializable;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 
 import static org.apache.paimon.CoreOptions.BUCKET;
+import static 
org.apache.paimon.CoreOptions.COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT;
 import static org.apache.paimon.CoreOptions.WRITE_ONLY;
 
 /** Utils for postpone table. */
@@ -88,9 +94,23 @@ public class PostponeUtils {
     }
 
     public static Map<BinaryRow, Integer> getKnownNumBuckets(FileStoreTable 
table) {
+        return getKnownNumBuckets(
+                
table.store().newScan().onlyReadRealBuckets().readSimpleEntries());
+    }
+
+    public static Map<BinaryRow, Integer> getKnownNumBuckets(
+            FileStoreTable table, long snapshotId) {
+        return getKnownNumBuckets(
+                table.store()
+                        .newScan()
+                        .withSnapshot(snapshotId)
+                        .onlyReadRealBuckets()
+                        .readSimpleEntries());
+    }
+
+    private static Map<BinaryRow, Integer> getKnownNumBuckets(
+            List<SimpleFileEntry> simpleFileEntries) {
         Map<BinaryRow, Integer> knownNumBuckets = new HashMap<>();
-        List<SimpleFileEntry> simpleFileEntries =
-                
table.store().newScan().onlyReadRealBuckets().readSimpleEntries();
         for (SimpleFileEntry entry : simpleFileEntries) {
             if (entry.totalBuckets() >= 0) {
                 Integer oldTotalBuckets =
@@ -109,11 +129,43 @@ public class PostponeUtils {
         return knownNumBuckets;
     }
 
+    /** Returns real buckets containing active Level-0 files in the specified 
snapshot. */
+    public static List<CompactBucket> getLevel0Buckets(FileStoreTable table, 
long snapshotId) {
+        List<SimpleFileEntry> entries =
+                table.store()
+                        .newScan()
+                        .withSnapshot(snapshotId)
+                        .onlyReadRealBuckets()
+                        .readSimpleEntries();
+        Set<CompactBucket> buckets = new LinkedHashSet<>();
+        for (SimpleFileEntry entry : entries) {
+            if (entry.bucket() >= 0 && entry.totalBuckets() > 0 && 
entry.level() == 0) {
+                buckets.add(
+                        new CompactBucket(entry.partition(), entry.bucket(), 
entry.totalBuckets()));
+            }
+        }
+        return new ArrayList<>(buckets);
+    }
+
     /** Returns row counts of current active files in the postpone bucket. */
     public static Map<BinaryRow, Long> getPostponeRowCounts(FileStoreTable 
table) {
+        return getPostponeRowCounts(
+                table.newSnapshotReader()
+                        .withBucket(BucketMode.POSTPONE_BUCKET)
+                        .readFileIterator());
+    }
+
+    /** Returns row counts of active postpone files in the specified snapshot. 
*/
+    public static Map<BinaryRow, Long> getPostponeRowCounts(FileStoreTable 
table, long snapshotId) {
+        return getPostponeRowCounts(
+                table.newSnapshotReader()
+                        .withSnapshot(snapshotId)
+                        .withBucket(BucketMode.POSTPONE_BUCKET)
+                        .readFileIterator());
+    }
+
+    private static Map<BinaryRow, Long> 
getPostponeRowCounts(Iterator<ManifestEntry> iterator) {
         Map<BinaryRow, Long> rowCounts = new HashMap<>();
-        Iterator<ManifestEntry> iterator =
-                
table.newSnapshotReader().withBucket(BucketMode.POSTPONE_BUCKET).readFileIterator();
         while (iterator.hasNext()) {
             ManifestEntry entry = iterator.next();
             rowCounts.merge(entry.partition(), entry.file().rowCount(), 
Long::sum);
@@ -130,8 +182,64 @@ public class PostponeUtils {
         return table.copy(batchWriteOptions);
     }
 
+    public static FileStoreTable tableForPostponeCompact(
+            FileStoreTable table, int numBuckets, long snapshotId) {
+        Map<String, String> compactOptions = new HashMap<>();
+        compactOptions.put(BUCKET.key(), String.valueOf(numBuckets));
+        compactOptions.put(WRITE_ONLY.key(), "false");
+        compactOptions.put(COMMIT_STRICT_MODE_LAST_SAFE_SNAPSHOT.key(), 
String.valueOf(snapshotId));
+        return table.copy(compactOptions);
+    }
+
     public static FileStoreTable tableForCommit(FileStoreTable table) {
         return table.copy(
                 Collections.singletonMap(BUCKET.key(), 
String.valueOf(BucketMode.POSTPONE_BUCKET)));
     }
+
+    /** A real bucket which requires background compaction. */
+    public static final class CompactBucket implements Serializable {
+
+        private static final long serialVersionUID = 1L;
+
+        private final BinaryRow partition;
+        private final int bucket;
+        private final int totalBuckets;
+
+        public CompactBucket(BinaryRow partition, int bucket, int 
totalBuckets) {
+            this.partition = partition.copy();
+            this.bucket = bucket;
+            this.totalBuckets = totalBuckets;
+        }
+
+        public BinaryRow partition() {
+            return partition;
+        }
+
+        public int bucket() {
+            return bucket;
+        }
+
+        public int totalBuckets() {
+            return totalBuckets;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (!(o instanceof CompactBucket)) {
+                return false;
+            }
+            CompactBucket that = (CompactBucket) o;
+            return bucket == that.bucket
+                    && totalBuckets == that.totalBuckets
+                    && Objects.equals(partition, that.partition);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(partition, bucket, totalBuckets);
+        }
+    }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
index bb88799617..221d4afaaf 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorScan.java
@@ -26,6 +26,7 @@ import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
@@ -74,6 +75,9 @@ public class PrimaryKeyVectorScan implements VectorScan {
 
         SnapshotReader snapshotReader =
                 
table.newSnapshotReader().withSnapshot(snapshot).withMode(ScanMode.ALL).keepStats();
+        if (table.coreOptions().bucket() == BucketMode.POSTPONE_BUCKET) {
+            snapshotReader.onlyReadRealBuckets();
+        }
         if (partitionFilter != null) {
             snapshotReader.withPartitionFilter(partitionFilter);
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileSystemWriteRestoreTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileSystemWriteRestoreTest.java
index 401439be91..830fa5ded0 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileSystemWriteRestoreTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileSystemWriteRestoreTest.java
@@ -32,11 +32,50 @@ import java.util.HashMap;
 import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /** Tests for {@link FileSystemWriteRestore}. */
 class FileSystemWriteRestoreTest {
 
+    @Test
+    void testRestoreFromPinnedSnapshot() {
+        Snapshot pinned = mock(Snapshot.class);
+        Snapshot latest = mock(Snapshot.class);
+        SnapshotManager snapshotManager = mock(SnapshotManager.class);
+        when(snapshotManager.snapshot(5L)).thenReturn(pinned);
+        
when(snapshotManager.latestSnapshotFromFileSystem()).thenReturn(latest);
+
+        FileStoreScan scan = mock(FileStoreScan.class);
+        FileStoreScan.Plan plan = mock(FileStoreScan.Plan.class);
+        when(scan.withSnapshot(pinned)).thenReturn(scan);
+        when(scan.withPartitionBucket(EMPTY_ROW, 0)).thenReturn(scan);
+        when(scan.plan()).thenReturn(plan);
+        when(plan.files()).thenReturn(Collections.emptyList());
+
+        IndexFileMeta ann = new IndexFileMeta("test-vector-ann", "ann", 1, 1, 
null, null, null);
+        IndexFileHandler indexFileHandler = mock(IndexFileHandler.class);
+        when(indexFileHandler.scanSourceIndexes(pinned, EMPTY_ROW, 0))
+                .thenReturn(Collections.singletonList(ann));
+
+        FileSystemWriteRestore restore =
+                new FileSystemWriteRestore(
+                        new CoreOptions(new HashMap<>()),
+                        snapshotManager,
+                        scan,
+                        indexFileHandler,
+                        5L);
+
+        RestoreFiles restored = restore.restoreFiles(EMPTY_ROW, 0, false, 
false, true);
+
+        assertThat(restored.snapshot()).isSameAs(pinned);
+        assertThat(restored.vectorIndexPayloads()).containsExactly(ann);
+        verify(scan).withSnapshot(pinned);
+        verify(indexFileHandler).scanSourceIndexes(pinned, EMPTY_ROW, 0);
+        verify(snapshotManager, never()).latestSnapshotFromFileSystem();
+    }
+
     @Test
     void testRestoreVectorIndexPayloadsWithoutDirectory() {
         Snapshot snapshot = mock(Snapshot.class);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/postpone/BucketFilesTest.java 
b/paimon-core/src/test/java/org/apache/paimon/postpone/BucketFilesTest.java
new file mode 100644
index 0000000000..6c60075850
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/postpone/BucketFilesTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.postpone;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.apache.paimon.data.BinaryRow.EMPTY_ROW;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/** Tests for {@link BucketFiles}. */
+class BucketFilesTest {
+
+    @Test
+    void testPreservesIndexChangesFromDataAndCompactIncrements() {
+        IndexFileMeta dataAdd = index("data-add");
+        IndexFileMeta dataDelete = index("data-delete");
+        IndexFileMeta compactAdd = index("compact-add");
+        IndexFileMeta compactDelete = index("compact-delete");
+        DataIncrement dataIncrement =
+                new DataIncrement(
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonList(dataAdd),
+                        Collections.singletonList(dataDelete));
+        CompactIncrement compactIncrement =
+                new CompactIncrement(
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        Collections.singletonList(compactAdd),
+                        Collections.singletonList(compactDelete));
+
+        BucketFiles bucketFiles =
+                new BucketFiles(mock(DataFilePathFactory.class), 
mock(FileIO.class));
+        bucketFiles.update(new CommitMessageImpl(EMPTY_ROW, 0, 1, 
dataIncrement, compactIncrement));
+
+        CompactIncrement result = bucketFiles.makeMessage(EMPTY_ROW, 
0).compactIncrement();
+        assertThat(result.newIndexFiles()).containsExactly(dataAdd, 
compactAdd);
+        assertThat(result.deletedIndexFiles()).containsExactly(dataDelete, 
compactDelete);
+    }
+
+    private static IndexFileMeta index(String name) {
+        return new IndexFileMeta(name, "ann", 1, 1, null, null, null);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
index 8341d6bc4e..af6155411b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/schema/PrimaryKeyVectorIndexValidationTest.java
@@ -171,12 +171,20 @@ class PrimaryKeyVectorIndexValidationTest {
     }
 
     @Test
-    void testRequiresFixedBucket() {
+    void testSupportsPostponeBucket() {
+        Map<String, String> options = enabledOptions();
+        options.put(CoreOptions.BUCKET.key(), "-2");
+
+        assertThatCode(() -> 
validateTableSchema(schema(options))).doesNotThrowAnyException();
+    }
+
+    @Test
+    void testRejectsDynamicBucket() {
         Map<String, String> options = enabledOptions();
         options.put(CoreOptions.BUCKET.key(), "-1");
 
         assertThatThrownBy(() -> validateTableSchema(schema(options)))
-                .hasMessageContaining("requires fixed bucket mode");
+                .hasMessageContaining("requires fixed or postpone bucket 
mode");
     }
 
     @Test
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 50d9bc2077..251f06c5f6 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,20 +18,116 @@
 
 package org.apache.paimon.table;
 
+import org.apache.paimon.FileStore;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryRowWriter;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.SimpleFileEntry;
+import org.apache.paimon.operation.FileStoreScan;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
 
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Answers.RETURNS_SELF;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /** Tests for {@link PostponeUtils}. */
 public class PostponeUtilsTest {
 
+    @Test
+    public void testGetKnownNumBucketsFromSnapshot() {
+        BinaryRow partition = partition(1);
+        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)).containsEntry(partition, 4);
+        verify(scan).withSnapshot(5L);
+        verify(scan).onlyReadRealBuckets();
+    }
+
+    @Test
+    public void testGetPostponeRowCountsFromSnapshot() {
+        BinaryRow partition = partition(1);
+        DataFileMeta file = mock(DataFileMeta.class);
+        when(file.rowCount()).thenReturn(10L);
+        ManifestEntry entry = mock(ManifestEntry.class);
+        when(entry.partition()).thenReturn(partition);
+        when(entry.file()).thenReturn(file);
+
+        SnapshotReader reader = mock(SnapshotReader.class, RETURNS_SELF);
+        
when(reader.readFileIterator()).thenReturn(Collections.singletonList(entry).iterator());
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.newSnapshotReader()).thenReturn(reader);
+
+        assertThat(PostponeUtils.getPostponeRowCounts(table, 
5L)).containsEntry(partition, 10L);
+        verify(reader).withSnapshot(5L);
+        verify(reader).withBucket(BucketMode.POSTPONE_BUCKET);
+    }
+
+    @Test
+    public void testGetLevel0BucketsFromSnapshot() {
+        BinaryRow partition = partition(1);
+        SimpleFileEntry level0 = fileEntry(partition, 0, 2, 0);
+        SimpleFileEntry duplicate = fileEntry(partition, 0, 2, 0);
+        SimpleFileEntry compacted = fileEntry(partition, 1, 2, 1);
+        SimpleFileEntry postpone = fileEntry(partition, -2, -2, 0);
+
+        FileStoreScan scan = mock(FileStoreScan.class, RETURNS_SELF);
+        when(scan.readSimpleEntries())
+                .thenReturn(Arrays.asList(level0, duplicate, compacted, 
postpone));
+        FileStore store = mock(FileStore.class);
+        when(store.newScan()).thenReturn(scan);
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.store()).thenReturn(store);
+
+        List<PostponeUtils.CompactBucket> buckets = 
PostponeUtils.getLevel0Buckets(table, 5L);
+
+        assertThat(buckets).hasSize(1);
+        assertThat(buckets.get(0).partition()).isEqualTo(partition);
+        assertThat(buckets.get(0).bucket()).isEqualTo(0);
+        assertThat(buckets.get(0).totalBuckets()).isEqualTo(2);
+        verify(scan).withSnapshot(5L);
+        verify(scan).onlyReadRealBuckets();
+    }
+
+    @Test
+    public void testTableForPostponeCompact() {
+        FileStoreTable table = mock(FileStoreTable.class);
+        FileStoreTable copied = mock(FileStoreTable.class);
+        when(table.copy(anyMap())).thenReturn(copied);
+
+        assertThat(PostponeUtils.tableForPostponeCompact(table, 4, 
5L)).isSameAs(copied);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, String>> options = 
ArgumentCaptor.forClass(Map.class);
+        verify(table).copy(options.capture());
+        assertThat(options.getValue())
+                .containsEntry("bucket", "4")
+                .containsEntry("write-only", "false")
+                .containsEntry("commit.strict-mode.last-safe-snapshot", "5");
+    }
+
     @Test
     public void testComputeBucketNumByRowCount() {
         assertThat(PostponeUtils.computeBucketNumByRowCount(0, 
100)).isEqualTo(1);
@@ -96,4 +192,14 @@ public class PostponeUtilsTest {
         writer.complete();
         return row;
     }
+
+    private static SimpleFileEntry fileEntry(
+            BinaryRow partition, int bucket, int totalBuckets, int level) {
+        SimpleFileEntry entry = mock(SimpleFileEntry.class);
+        when(entry.partition()).thenReturn(partition);
+        when(entry.bucket()).thenReturn(bucket);
+        when(entry.totalBuckets()).thenReturn(totalBuckets);
+        when(entry.level()).thenReturn(level);
+        return entry;
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
index d3db372da3..25b823139c 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorScanTest.java
@@ -33,6 +33,7 @@ import org.apache.paimon.manifest.FileSource;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.utils.Filter;
@@ -55,11 +56,44 @@ import static org.mockito.Answers.RETURNS_SELF;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /** Tests snapshot-consistent planning for bucket-local primary-key vector 
search. */
 class PrimaryKeyVectorScanTest {
 
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void testPostponeTableOnlyScansRealBuckets() {
+        Options options = new Options();
+        options.set(CoreOptions.BUCKET, BucketMode.POSTPONE_BUCKET);
+        options.set(CoreOptions.PK_VECTOR_INDEX_COLUMNS, "embedding");
+        options.setString("fields.embedding.pk-vector.index.type", "ivf-pq");
+
+        FileStoreTable table = mock(FileStoreTable.class);
+        Snapshot snapshot = mock(Snapshot.class);
+        when(snapshot.id()).thenReturn(11L);
+        when(table.coreOptions()).thenReturn(new CoreOptions(options));
+        when(table.latestSnapshot()).thenReturn(Optional.of(snapshot));
+
+        SnapshotReader reader = mock(SnapshotReader.class, RETURNS_SELF);
+        SnapshotReader.Plan snapshotPlan = mock(SnapshotReader.Plan.class, 
CALLS_REAL_METHODS);
+        when(snapshotPlan.splits()).thenReturn(Collections.emptyList());
+        when(reader.read()).thenReturn(snapshotPlan);
+        when(table.newSnapshotReader()).thenReturn(reader);
+
+        IndexFileHandler indexFileHandler = mock(IndexFileHandler.class);
+        when(indexFileHandler.scan(eq(snapshot), any(Filter.class)))
+                .thenReturn(Collections.emptyList());
+        FileStore store = mock(FileStore.class);
+        when(store.newIndexFileHandler()).thenReturn(indexFileHandler);
+        when(table.store()).thenReturn(store);
+
+        new PrimaryKeyVectorScan(table, 7, "ivf-pq", null).scan();
+
+        verify(reader).onlyReadRealBuckets();
+    }
+
     @Test
     @SuppressWarnings({"unchecked", "rawtypes"})
     void testScansOneSnapshotAndFiltersVectorIdentity() {
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
index cbdcf825f2..146c7b7a62 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/CompactAction.java
@@ -19,12 +19,14 @@
 package org.apache.paimon.flink.action;
 
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.flink.FlinkConnectorOptions;
 import org.apache.paimon.flink.compact.AppendTableCompact;
 import org.apache.paimon.flink.compact.DataEvolutionTableCompact;
 import org.apache.paimon.flink.compact.IncrementalClusterCompact;
+import org.apache.paimon.flink.postpone.PostponeBucketCompactOperator;
 import org.apache.paimon.flink.postpone.PostponeBucketCompactSplitSource;
 import 
org.apache.paimon.flink.postpone.RewritePostponeBucketCommittableOperator;
 import org.apache.paimon.flink.predicate.SimpleSqlPredicateConvertor;
@@ -36,6 +38,7 @@ import org.apache.paimon.flink.sink.FlinkSinkBuilder;
 import org.apache.paimon.flink.sink.FlinkStreamPartitioner;
 import org.apache.paimon.flink.sink.RowDataChannelComputer;
 import org.apache.paimon.flink.source.CompactorSourceBuilder;
+import org.apache.paimon.flink.utils.JavaTypeInfo;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.predicate.PartitionPredicateVisitor;
@@ -45,6 +48,8 @@ import 
org.apache.paimon.predicate.PredicateProjectionConverter;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.PostponeUtils;
+import org.apache.paimon.table.PostponeUtils.CompactBucket;
+import org.apache.paimon.table.sink.ChannelComputer;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.InternalRowPartitionComputer;
 import org.apache.paimon.utils.Pair;
@@ -67,9 +72,11 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 
 import static 
org.apache.paimon.partition.PartitionPredicate.createBinaryPartitions;
 import static 
org.apache.paimon.partition.PartitionPredicate.createPartitionPredicate;
@@ -293,42 +300,45 @@ public class CompactAction extends TableActionBase {
         int defaultBucketNum = 
options.get(CoreOptions.POSTPONE_DEFAULT_BUCKET_NUM);
         Optional<Long> targetRowNumPerBucket =
                 
options.getOptional(CoreOptions.POSTPONE_TARGET_ROW_NUM_PER_BUCKET);
-        Map<BinaryRow, Integer> knownNumBuckets = 
PostponeUtils.getKnownNumBuckets(table);
+        Optional<Snapshot> optionalSnapshot = table.latestSnapshot();
+        if (!optionalSnapshot.isPresent()) {
+            return buildNothingToCompact(env);
+        }
+        long snapshotId = optionalSnapshot.get().id();
+        Map<BinaryRow, Integer> knownNumBuckets =
+                PostponeUtils.getKnownNumBuckets(table, snapshotId);
         Map<BinaryRow, Long> postponeRowCounts =
                 targetRowNumPerBucket.isPresent()
-                        ? PostponeUtils.getPostponeRowCounts(table)
+                        ? PostponeUtils.getPostponeRowCounts(table, snapshotId)
                         : Collections.emptyMap();
 
-        // change bucket to a positive value, so we can scan files from the 
bucket = -2 directory
-        Map<String, String> bucketOptions = new HashMap<>(table.options());
-        bucketOptions.put(CoreOptions.BUCKET.key(), 
String.valueOf(defaultBucketNum));
-        FileStoreTable fileStoreTable = 
table.copy(table.schema().copy(bucketOptions));
-
-        List<BinaryRow> partitions =
-                fileStoreTable
-                        .newSnapshotReader()
+        List<BinaryRow> postponePartitions =
+                table.newSnapshotReader()
+                        .withSnapshot(snapshotId)
                         .withBucket(BucketMode.POSTPONE_BUCKET)
                         .partitions();
-        if (partitions.isEmpty()) {
-            if (this.forceStartFlinkJob) {
-                env.fromSequence(0, 0)
-                        .name("Nothing to Compact Source")
-                        .sinkTo(new DiscardingSink<>());
-                return true;
-            } else {
-                return false;
-            }
+
+        Map<BinaryRow, List<CompactBucket>> compactBucketsByPartition = new 
LinkedHashMap<>();
+        for (CompactBucket bucket : PostponeUtils.getLevel0Buckets(table, 
snapshotId)) {
+            compactBucketsByPartition
+                    .computeIfAbsent(bucket.partition(), ignored -> new 
ArrayList<>())
+                    .add(bucket);
+        }
+        Set<BinaryRow> affectedPartitions = new 
LinkedHashSet<>(postponePartitions);
+        affectedPartitions.addAll(compactBucketsByPartition.keySet());
+        if (affectedPartitions.isEmpty()) {
+            return buildNothingToCompact(env);
         }
 
         InternalRowPartitionComputer partitionComputer =
                 new InternalRowPartitionComputer(
-                        fileStoreTable.coreOptions().partitionDefaultName(),
-                        fileStoreTable.store().partitionType(),
-                        fileStoreTable.partitionKeys().toArray(new String[0]),
-                        fileStoreTable.coreOptions().legacyPartitionName());
+                        table.coreOptions().partitionDefaultName(),
+                        table.store().partitionType(),
+                        table.partitionKeys().toArray(new String[0]),
+                        table.coreOptions().legacyPartitionName());
         String commitUser = CoreOptions.createCommitUser(options);
         List<DataStream<Committable>> dataStreams = new ArrayList<>();
-        for (BinaryRow partition : partitions) {
+        for (BinaryRow partition : affectedPartitions) {
             int bucketNum =
                     PostponeUtils.determineBucketNum(
                             partition,
@@ -336,10 +346,8 @@ public class CompactAction extends TableActionBase {
                             targetRowNumPerBucket,
                             postponeRowCounts,
                             defaultBucketNum);
-
-            bucketOptions = new HashMap<>(table.options());
-            bucketOptions.put(CoreOptions.BUCKET.key(), 
String.valueOf(bucketNum));
-            FileStoreTable realTable = 
table.copy(table.schema().copy(bucketOptions));
+            FileStoreTable realTable =
+                    PostponeUtils.tableForPostponeCompact(table, bucketNum, 
snapshotId);
 
             LinkedHashMap<String, String> partitionSpec =
                     partitionComputer.generatePartValues(partition);
@@ -348,6 +356,7 @@ public class CompactAction extends TableActionBase {
                             env,
                             realTable,
                             partitionSpec,
+                            snapshotId,
                             
options.get(FlinkConnectorOptions.SCAN_PARALLELISM));
 
             DataStream<InternalRow> partitioned =
@@ -358,9 +367,29 @@ public class CompactAction extends TableActionBase {
                                     
table.catalogEnvironment().catalogContext()),
                             new RowDataChannelComputer(realTable.schema()),
                             null);
-            FixedBucketSink sink = new FixedBucketSink(realTable, null);
+            List<CompactBucket> compactBuckets =
+                    compactBucketsByPartition.getOrDefault(partition, 
Collections.emptyList());
+            DataStream<CompactBucket> partitionedCompactBuckets =
+                    FlinkStreamPartitioner.partition(
+                            env.fromCollection(
+                                            compactBuckets, new 
JavaTypeInfo<>(CompactBucket.class))
+                                    .name(
+                                            String.format(
+                                                    "Level-0 compact buckets: 
%s - %s",
+                                                    table.fullName(), 
partitionSpec))
+                                    .forceNonParallel(),
+                            new CompactBucketChannelComputer(),
+                            partitioned.getParallelism());
             DataStream<Committable> written =
-                    sink.doWrite(partitioned, commitUser, 
partitioned.getParallelism())
+                    partitioned
+                            .connect(partitionedCompactBuckets)
+                            .transform(
+                                    String.format(
+                                            "Write and compact postpone 
buckets: %s - %s",
+                                            table.fullName(), partitionSpec),
+                                    new CommittableTypeInfo(),
+                                    new PostponeBucketCompactOperator(
+                                            realTable, commitUser, snapshotId))
                             .forward()
                             .transform(
                                     "Rewrite compact committable",
@@ -370,6 +399,8 @@ public class CompactAction extends TableActionBase {
             dataStreams.add(sourcePair.getRight());
         }
 
+        FileStoreTable fileStoreTable =
+                PostponeUtils.tableForPostponeCompact(table, defaultBucketNum, 
snapshotId);
         FixedBucketSink sink = new FixedBucketSink(fileStoreTable, null);
         DataStream<Committable> dataStream = dataStreams.get(0);
         for (int i = 1; i < dataStreams.size(); i++) {
@@ -379,6 +410,31 @@ public class CompactAction extends TableActionBase {
         return true;
     }
 
+    private boolean buildNothingToCompact(StreamExecutionEnvironment env) {
+        if (this.forceStartFlinkJob) {
+            env.fromSequence(0, 0).name("Nothing to Compact 
Source").sinkTo(new DiscardingSink<>());
+            return true;
+        }
+        return false;
+    }
+
+    private static class CompactBucketChannelComputer implements 
ChannelComputer<CompactBucket> {
+
+        private static final long serialVersionUID = 1L;
+
+        private transient int numChannels;
+
+        @Override
+        public void setup(int numChannels) {
+            this.numChannels = numChannels;
+        }
+
+        @Override
+        public int channel(CompactBucket bucket) {
+            return ChannelComputer.select(bucket.partition(), bucket.bucket(), 
numChannels);
+        }
+    }
+
     @Override
     public void run() throws Exception {
         if (buildImpl()) {
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactOperator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactOperator.java
new file mode 100644
index 0000000000..8055908fe3
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactOperator.java
@@ -0,0 +1,134 @@
+/*
+ * 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.flink.postpone;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManagerImpl;
+import org.apache.paimon.flink.sink.Committable;
+import org.apache.paimon.flink.utils.BoundedTwoInputOperator;
+import org.apache.paimon.flink.utils.RuntimeContextUtils;
+import org.apache.paimon.operation.FileSystemWriteRestore;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.PostponeUtils.CompactBucket;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.FixedBucketRowKeyExtractor;
+import org.apache.paimon.table.sink.TableWriteImpl;
+import org.apache.paimon.utils.Pair;
+
+import org.apache.flink.streaming.api.operators.InputSelection;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/** Writes postponed rows and compacts existing Level-0 buckets with the same 
table writer. */
+public class PostponeBucketCompactOperator
+        extends BoundedTwoInputOperator<InternalRow, CompactBucket, 
Committable> {
+
+    private static final long serialVersionUID = 1L;
+
+    private final FileStoreTable table;
+    private final String commitUser;
+    private final long snapshotId;
+
+    private transient IOManagerImpl ioManager;
+    private transient TableWriteImpl<?> write;
+    private transient FixedBucketRowKeyExtractor rowKeyExtractor;
+    private transient Set<Pair<BinaryRow, Integer>> buckets;
+    private transient boolean[] endedInputs;
+
+    public PostponeBucketCompactOperator(FileStoreTable table, String 
commitUser, long snapshotId) {
+        this.table = table;
+        this.commitUser = commitUser;
+        this.snapshotId = snapshotId;
+    }
+
+    @Override
+    public void open() throws Exception {
+        super.open();
+        ioManager =
+                new IOManagerImpl(
+                        getContainingTask()
+                                .getEnvironment()
+                                .getIOManager()
+                                .getSpillingDirectoriesPaths());
+        write =
+                table.newWrite(
+                                commitUser,
+                                
RuntimeContextUtils.getIndexOfThisSubtask(getRuntimeContext()))
+                        .withIOManager(ioManager);
+        write.withWriteRestore(
+                new FileSystemWriteRestore(
+                        table.coreOptions(),
+                        table.snapshotManager(),
+                        table.store().newScan(),
+                        table.store().newIndexFileHandler(),
+                        snapshotId));
+        rowKeyExtractor = new FixedBucketRowKeyExtractor(table.schema());
+        buckets = new LinkedHashSet<>();
+        endedInputs = new boolean[2];
+    }
+
+    @Override
+    public InputSelection nextSelection() {
+        return InputSelection.ALL;
+    }
+
+    @Override
+    public void processElement1(StreamRecord<InternalRow> element) throws 
Exception {
+        InternalRow row = element.getValue();
+        rowKeyExtractor.setRecord(row);
+        buckets.add(Pair.of(rowKeyExtractor.partition().copy(), 
rowKeyExtractor.bucket()));
+        write.write(row);
+    }
+
+    @Override
+    public void processElement2(StreamRecord<CompactBucket> element) {
+        CompactBucket bucket = element.getValue();
+        buckets.add(Pair.of(bucket.partition().copy(), bucket.bucket()));
+    }
+
+    @Override
+    public void endInput(int inputId) throws Exception {
+        endedInputs[inputId - 1] = true;
+        if (endedInputs[0] && endedInputs[1]) {
+            for (Pair<BinaryRow, Integer> bucket : buckets) {
+                write.compact(bucket.getLeft(), bucket.getRight(), false);
+            }
+            for (CommitMessage message : write.prepareCommit(true, 
Long.MAX_VALUE)) {
+                output.collect(new StreamRecord<>(new 
Committable(Long.MAX_VALUE, message)));
+            }
+        }
+    }
+
+    @Override
+    public void close() throws Exception {
+        try {
+            if (write != null) {
+                write.close();
+            }
+        } finally {
+            if (ioManager != null) {
+                ioManager.close();
+            }
+            super.close();
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSource.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSource.java
index 3e0cd7cf23..5331fd0e9b 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSource.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSource.java
@@ -34,6 +34,7 @@ import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.sink.ChannelComputer;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.utils.Pair;
 
 import org.apache.flink.api.common.eventtime.WatermarkStrategy;
@@ -66,11 +67,23 @@ public class PostponeBucketCompactSplitSource extends 
AbstractNonCoordinatedSour
 
     private final FileStoreTable table;
     private final Map<String, String> partitionSpec;
+    @Nullable private final Long snapshotId;
 
     public PostponeBucketCompactSplitSource(
             FileStoreTable table, Map<String, String> partitionSpec) {
+        this(table, partitionSpec, null);
+    }
+
+    public PostponeBucketCompactSplitSource(
+            FileStoreTable table, Map<String, String> partitionSpec, long 
snapshotId) {
+        this(table, partitionSpec, Long.valueOf(snapshotId));
+    }
+
+    private PostponeBucketCompactSplitSource(
+            FileStoreTable table, Map<String, String> partitionSpec, @Nullable 
Long snapshotId) {
         this.table = table;
         this.partitionSpec = partitionSpec;
+        this.snapshotId = snapshotId;
     }
 
     @Override
@@ -88,8 +101,12 @@ public class PostponeBucketCompactSplitSource extends 
AbstractNonCoordinatedSour
 
         @Override
         public InputStatus pollNext(ReaderOutput<Split> output) throws 
Exception {
+            SnapshotReader snapshotReader = table.newSnapshotReader();
+            if (snapshotId != null) {
+                snapshotReader.withSnapshot(snapshotId);
+            }
             List<Split> splits =
-                    table.newSnapshotReader()
+                    snapshotReader
                             .withPartitionFilter(partitionSpec)
                             .withBucket(BucketMode.POSTPONE_BUCKET)
                             .read()
@@ -123,9 +140,32 @@ public class PostponeBucketCompactSplitSource extends 
AbstractNonCoordinatedSour
             FileStoreTable table,
             Map<String, String> partitionSpec,
             @Nullable Integer parallelism) {
+        return buildSource(env, table, partitionSpec, null, parallelism);
+    }
+
+    public static Pair<DataStream<RowData>, DataStream<Committable>> 
buildSource(
+            StreamExecutionEnvironment env,
+            FileStoreTable table,
+            Map<String, String> partitionSpec,
+            long snapshotId,
+            @Nullable Integer parallelism) {
+        return buildSource(env, table, partitionSpec, 
Long.valueOf(snapshotId), parallelism);
+    }
+
+    private static Pair<DataStream<RowData>, DataStream<Committable>> 
buildSource(
+            StreamExecutionEnvironment env,
+            FileStoreTable table,
+            Map<String, String> partitionSpec,
+            @Nullable Long snapshotId,
+            @Nullable Integer parallelism) {
+        PostponeBucketCompactSplitSource splitSource =
+                snapshotId == null
+                        ? new PostponeBucketCompactSplitSource(table, 
partitionSpec)
+                        : new PostponeBucketCompactSplitSource(
+                                table, partitionSpec, snapshotId.longValue());
         DataStream<Split> source =
                 env.fromSource(
-                                new PostponeBucketCompactSplitSource(table, 
partitionSpec),
+                                splitSource,
                                 WatermarkStrategy.noWatermarks(),
                                 String.format(
                                         "Compact split generator: %s - %s",
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSourceTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSourceTest.java
new file mode 100644
index 0000000000..24973e3706
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/postpone/PostponeBucketCompactSplitSourceTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.flink.postpone;
+
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+
+import org.apache.flink.api.connector.source.ReaderOutput;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.api.connector.source.SourceReaderContext;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.mockito.Answers.CALLS_REAL_METHODS;
+import static org.mockito.Answers.RETURNS_SELF;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link PostponeBucketCompactSplitSource}. */
+class PostponeBucketCompactSplitSourceTest {
+
+    @Test
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    void testReadsPinnedSnapshot() throws Exception {
+        SnapshotReader reader = mock(SnapshotReader.class, RETURNS_SELF);
+        SnapshotReader.Plan plan = mock(SnapshotReader.Plan.class, 
CALLS_REAL_METHODS);
+        when(plan.splits()).thenReturn(Collections.emptyList());
+        when(reader.read()).thenReturn(plan);
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.newSnapshotReader()).thenReturn(reader);
+
+        PostponeBucketCompactSplitSource source =
+                new PostponeBucketCompactSplitSource(table, 
Collections.emptyMap(), 5L);
+        SourceReader sourceReader = 
source.createReader(mock(SourceReaderContext.class));
+        sourceReader.pollNext(mock(ReaderOutput.class));
+
+        verify(reader).withSnapshot(5L);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
index bc29716356..74ec37d14b 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/VectorSearchProcedureITCase.java
@@ -32,6 +32,7 @@ import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.PostponeUtils;
 import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
@@ -40,6 +41,7 @@ import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.utils.Range;
 
+import org.apache.flink.table.api.config.TableConfigOptions;
 import org.apache.flink.types.Row;
 import org.junit.jupiter.api.Test;
 
@@ -71,6 +73,65 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                 .containsExactlyInAnyOrder("{\"id\":\"2\"}", "{\"id\":\"3\"}");
     }
 
+    @Test
+    public void testPostponeBucketBuildsVectorIndexDuringCompact() throws 
Exception {
+        createPostponePrimaryKeyVectorTable("POSTPONE_PK_T");
+
+        sql(
+                "INSERT INTO POSTPONE_PK_T VALUES "
+                        + "(1, ARRAY[CAST(3.0 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(2, ARRAY[CAST(1.0 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(3, ARRAY[CAST(2.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+
+        assertThat(sql("SELECT * FROM POSTPONE_PK_T")).isEmpty();
+        assertThat(sql("SELECT bucket FROM `POSTPONE_PK_T$buckets`"))
+                .allMatch(row -> !Integer.valueOf(-2).equals(row.getField(0)));
+        assertThat(sql("SELECT file_path FROM `POSTPONE_PK_T$files` WHERE 
level = 0")).isNotEmpty();
+        FileStoreTable table = paimonTable("POSTPONE_PK_T");
+        long snapshotId = table.latestSnapshot().get().id();
+        assertThat(PostponeUtils.getLevel0Buckets(table, 
snapshotId)).isNotEmpty();
+
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        sql("CALL sys.compact(`table` => 'default.POSTPONE_PK_T')");
+
+        assertThat(paimonTable("POSTPONE_PK_T").latestSnapshot().get().id())
+                .isGreaterThan(snapshotId);
+        assertThat(sql("SELECT id FROM POSTPONE_PK_T"))
+                .extracting(row -> row.getField(0))
+                .containsExactlyInAnyOrder(1, 2, 3);
+        assertThat(searchPrimaryKeyVectorTable("POSTPONE_PK_T", 2, "id"))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactlyInAnyOrder("{\"id\":\"2\"}", "{\"id\":\"3\"}");
+
+        sql(
+                "INSERT INTO POSTPONE_PK_T VALUES "
+                        + "(4, ARRAY[CAST(0.5 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(6, ARRAY[CAST(6.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+        sql(
+                "INSERT INTO POSTPONE_PK_T /*+ 
OPTIONS('postpone.batch-write-fixed-bucket' = 'false') */ VALUES "
+                        + "(5, ARRAY[CAST(1.5 AS FLOAT), CAST(0.0 AS FLOAT)]), 
"
+                        + "(7, ARRAY[CAST(7.0 AS FLOAT), CAST(0.0 AS 
FLOAT)])");
+        assertThat(sql("SELECT id FROM POSTPONE_PK_T"))
+                .extracting(row -> row.getField(0))
+                .containsExactlyInAnyOrder(1, 2, 3);
+        assertThat(sql("SELECT bucket FROM `POSTPONE_PK_T$buckets`"))
+                .extracting(row -> row.getField(0))
+                .contains(-2);
+        assertThat(sql("SELECT file_path FROM `POSTPONE_PK_T$files` WHERE 
level = 0")).isNotEmpty();
+
+        sql("CALL sys.compact(`table` => 'default.POSTPONE_PK_T')");
+
+        assertThat(sql("SELECT id FROM POSTPONE_PK_T"))
+                .extracting(row -> row.getField(0))
+                .containsExactlyInAnyOrder(1, 2, 3, 4, 5, 6, 7);
+        assertThat(sql("SELECT bucket FROM `POSTPONE_PK_T$buckets`"))
+                .allMatch(row -> !Integer.valueOf(-2).equals(row.getField(0)));
+        assertThat(sql("SELECT file_path FROM `POSTPONE_PK_T$files` WHERE 
level = 0")).isEmpty();
+        assertThat(searchPrimaryKeyVectorTable("POSTPONE_PK_T", 2, "id"))
+                .extracting(row -> row.getField(0).toString())
+                .containsExactlyInAnyOrder("{\"id\":\"4\"}", "{\"id\":\"2\"}");
+    }
+
     @Test
     public void testPrimaryKeyVectorSearchAfterUpdateAndDelete() throws 
Exception {
         createPrimaryKeyVectorTable("PK_UPDATE_T");
@@ -285,6 +346,31 @@ public class VectorSearchProcedureITCase extends 
CatalogITCaseBase {
                 tableName, DIMENSION, 
TestVectorGlobalIndexerFactory.IDENTIFIER, DIMENSION);
     }
 
+    private void createPostponePrimaryKeyVectorTable(String tableName) {
+        sql(
+                "CREATE TABLE %s ("
+                        + "id INT, "
+                        + "vec ARRAY<FLOAT>, "
+                        + "PRIMARY KEY (id) NOT ENFORCED"
+                        + ") WITH ("
+                        + "'bucket' = '-2', "
+                        + "'write-only' = 'true', "
+                        + "'postpone.batch-write-fixed-bucket' = 'true', "
+                        + "'file.format' = 'json', "
+                        + "'file.compression' = 'deflate', "
+                        + "'deletion-vectors.enabled' = 'true', "
+                        + "'deletion-vectors.merge-on-read' = 'false', "
+                        + "'vector-field' = 'vec', "
+                        + "'field.vec.vector-dim' = '%d', "
+                        + "'pk-vector.index.columns' = 'vec', "
+                        + "'fields.vec.pk-vector.index.type' = '%s', "
+                        + "'fields.vec.pk-vector.distance.metric' = 'l2', "
+                        + "'test.vector.dimension' = '%d', "
+                        + "'test.vector.metric' = 'l2'"
+                        + ")",
+                tableName, DIMENSION, 
TestVectorGlobalIndexerFactory.IDENTIFIER, DIMENSION);
+    }
+
     private List<Row> searchPrimaryKeyVectorTable(String tableName, int topK, 
String projection) {
         return sql(
                 "CALL sys.vector_search("
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 4725ce09f9..199702e088 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
@@ -19,9 +19,9 @@
 package org.apache.paimon.spark.procedure
 
 import org.apache.paimon.CoreOptions
-import org.apache.paimon.CoreOptions.BUCKET
 import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.io.{CompactIncrement, DataFileMeta, DataIncrement}
+import org.apache.paimon.operation.FileSystemWriteRestore
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.postpone.BucketFiles
 import org.apache.paimon.spark.PaimonImplicits._
@@ -31,12 +31,13 @@ import org.apache.paimon.spark.util.{ScanPlanHelper, 
SparkRowUtils}
 import org.apache.paimon.spark.write.{PaimonDataWrite, WriteTaskResult}
 import org.apache.paimon.table.{BucketMode, FileStoreTable, PostponeUtils}
 import org.apache.paimon.table.sink.{CommitMessage, CommitMessageImpl}
-import org.apache.paimon.utils.BlobDescriptorUtils
+import org.apache.paimon.utils.{BlobDescriptorUtils, SerializationUtils}
 
+import org.apache.spark.HashPartitioner
+import org.apache.spark.rdd.RDD
 import org.apache.spark.sql._
 import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
-import org.apache.spark.sql.functions.{col, lit}
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.functions.lit
 import org.slf4j.LoggerFactory
 
 import javax.annotation.Nullable
@@ -57,18 +58,13 @@ case class SparkPostponeCompactProcedure(
     @transient relation: DataSourceV2Relation) {
   private val LOG = LoggerFactory.getLogger(getClass)
 
-  // Unlike `PostponeUtils.tableForFixBucketWrite`, here explicitly set bucket 
to 1 without
-  // WRITE_ONLY to enable proper compaction logic.
-  private lazy val realTable = table.copy(Map(BUCKET.key -> "1").asJava)
-
-  // Create bucket computer to determine bucket count for each partition
-  private lazy val postponePartitionBucketComputer = {
-    val knownNumBuckets = PostponeUtils.getKnownNumBuckets(table)
+  private def createPostponePartitionBucketComputer(snapshotId: Long) = {
+    val knownNumBuckets = PostponeUtils.getKnownNumBuckets(table, snapshotId)
     val targetRowNumPerBucket: Option[java.lang.Long] =
       table.coreOptions.postponeTargetRowNumPerBucket
     val postponeRowCounts =
       if (targetRowNumPerBucket.isDefined) {
-        PostponeUtils.getPostponeRowCounts(table)
+        PostponeUtils.getPostponeRowCounts(table, snapshotId)
       } else {
         Collections.emptyMap[BinaryRow, java.lang.Long]()
       }
@@ -86,29 +82,11 @@ case class SparkPostponeCompactProcedure(
       defaultBucketNum)
   }
 
-  private def partitionCols(df: DataFrame): Seq[Column] = {
-    val inputSchema = df.schema
-    val tableSchema = table.schema
-    tableSchema
-      .partitionKeys()
-      .asScala
-      .map(tableSchema.fieldNames().indexOf(_))
-      .map(x => col(inputSchema.fieldNames(x)))
-      .toSeq
-  }
-
-  private def repartitionByPartitionsAndBucket(df: DataFrame): DataFrame = {
-    df.repartition(partitionCols(df) ++ Seq(col(BUCKET_COL)): _*)
-  }
-
-  // write data with the bucket processor
-  private def writeWithBucketProcessor(
-      repartitioned: DataFrame,
-      bucketColIdx: Int,
-      schema: StructType) = {
-    import spark.implicits._
-    val rowKindColIdx = SparkRowUtils.getFieldIndex(schema, ROW_KIND_COL)
-    val writeBuilder = realTable.newBatchWriteBuilder
+  private def newDataWrite(
+      realTable: FileStoreTable,
+      rowKindColIdx: Int,
+      postponePartitionBucketComputer: 
SparkPostponeCompactProcedure.PostponePartitionBucketComputer)
+      : PaimonDataWrite = {
     val rowType = table.rowType()
     val coreOptions = table.coreOptions()
     val catalogContextForBlobDescriptor =
@@ -116,8 +94,8 @@ case class SparkPostponeCompactProcedure(
         table.catalogEnvironment().catalogContext(),
         coreOptions.toConfiguration)
 
-    def newWrite() = PaimonDataWrite(
-      writeBuilder,
+    val dataWrite = PaimonDataWrite(
+      realTable.newBatchWriteBuilder,
       rowType,
       rowKindColIdx,
       writeRowTracking = coreOptions.dataEvolutionEnabled(),
@@ -126,19 +104,7 @@ case class SparkPostponeCompactProcedure(
       catalogContextForBlobDescriptor,
       Some(postponePartitionBucketComputer)
     )
-
-    repartitioned.mapPartitions {
-      iter =>
-        {
-          val write = newWrite()
-          try {
-            iter.foreach(row => write.write(row, row.getInt(bucketColIdx)))
-            Iterator.apply(write.commit)
-          } finally {
-            write.close()
-          }
-        }
-    }
+    dataWrite
   }
 
   /** Creates a new BucketFiles instance for tracking file changes */
@@ -156,47 +122,148 @@ case class SparkPostponeCompactProcedure(
       partitionPredicate == null,
       "Postpone bucket compaction currently does not support specifying 
partitions")
 
+    val snapshot = table.latestSnapshot().orElse(null)
+    if (snapshot == null) {
+      LOG.info("Table has no snapshot, no compact job to execute.")
+      return
+    }
+    val snapshotId = snapshot.id()
+    val postponePartitionBucketComputer =
+      createPostponePartitionBucketComputer(snapshotId)
+    val realTable = PostponeUtils.tableForPostponeCompact(table, 1, snapshotId)
+
     // Read data splits from the POSTPONE_BUCKET (-2)
     val splits =
       table.newSnapshotReader
+        .withSnapshot(snapshotId)
         .withBucket(BucketMode.POSTPONE_BUCKET)
         .read
         .dataSplits
         .asScala
+    val compactBuckets = PostponeUtils.getLevel0Buckets(table, 
snapshotId).asScala
 
-    if (splits.isEmpty) {
-      LOG.info("Partition bucket is empty, no compact job to execute.")
+    if (splits.isEmpty && compactBuckets.isEmpty) {
+      LOG.info("Postpone bucket and real Level-0 buckets are empty, no compact 
job to execute.")
       return
     }
 
-    // Prepare dataset for writing by combining all partitions
-    val datasetForWrite: Dataset[Row] = splits
-      .groupBy(_.partition)
-      .values
-      .map(
-        split => {
-          PaimonUtils
-            .createDataset(spark, 
ScanPlanHelper.createNewScanPlan(split.toArray, relation))
-        })
-      .reduce((a, b) => a.union(b))
+    val rowWorkAndKind: 
(RDD[SparkPostponeCompactProcedure.PostponeCompactWork], Int) =
+      if (splits.isEmpty) {
+        (spark.sparkContext.emptyRDD, -1)
+      } else {
+        val datasetForWrite: Dataset[Row] = splits
+          .groupBy(_.partition)
+          .values
+          .map(
+            split => {
+              PaimonUtils
+                .createDataset(spark, 
ScanPlanHelper.createNewScanPlan(split.toArray, relation))
+            })
+          .reduce((a, b) => a.union(b))
 
-    val withInitBucketCol = datasetForWrite.withColumn(BUCKET_COL, lit(-1))
-    val bucketColIdx = SparkRowUtils.getFieldIndex(withInitBucketCol.schema, 
BUCKET_COL)
-    val encoderGroupWithBucketCol = EncoderSerDeGroup(withInitBucketCol.schema)
+        val withInitBucketCol = datasetForWrite.withColumn(BUCKET_COL, lit(-1))
+        val bucketColIdx = 
SparkRowUtils.getFieldIndex(withInitBucketCol.schema, BUCKET_COL)
+        val encoderGroupWithBucketCol = 
EncoderSerDeGroup(withInitBucketCol.schema)
+        val processor = PostponeFixBucketProcessor(
+          table,
+          bucketColIdx,
+          encoderGroupWithBucketCol,
+          postponePartitionBucketComputer
+        )
+        val dataFrame = withInitBucketCol
+          
.mapPartitions(processor.processPartition)(encoderGroupWithBucketCol.encoder)
+          .toDF()
+        val rowKindColIdx = 
SparkRowUtils.getFieldIndex(withInitBucketCol.schema, ROW_KIND_COL)
+        val rowType = table.rowType()
+        val catalogContext = BlobDescriptorUtils.getCatalogContext(
+          table.catalogEnvironment().catalogContext(),
+          table.coreOptions().toConfiguration)
+        val rowWorks = dataFrame.rdd.mapPartitions {
+          rows =>
+            val extractor = realTable.createRowKeyExtractor()
+            val toPaimonRow = SparkRowUtils.toPaimonRow(rowType, 
rowKindColIdx, catalogContext)
+            rows.map {
+              row =>
+                extractor.setRecord(toPaimonRow(row))
+                val partition = extractor.partition().copy()
+                SparkPostponeCompactProcedure.PostponeCompactWork(
+                  row.copy(),
+                  SerializationUtils.serializeBinaryRow(partition),
+                  row.getInt(bucketColIdx),
+                  compactMarker = false)
+            }
+        }
+        (rowWorks, rowKindColIdx)
+      }
 
-    // Create processor to handle the actual bucket assignment
-    val processor = PostponeFixBucketProcessor(
-      table,
-      bucketColIdx,
-      encoderGroupWithBucketCol,
-      postponePartitionBucketComputer
-    )
+    val markerWorks = compactBuckets.map {
+      bucket =>
+        SparkPostponeCompactProcedure.PostponeCompactWork(
+          null,
+          SerializationUtils.serializeBinaryRow(bucket.partition()),
+          bucket.bucket(),
+          compactMarker = true)
+    }
+    val markerRdd =
+      if (markerWorks.isEmpty) {
+        
spark.sparkContext.emptyRDD[SparkPostponeCompactProcedure.PostponeCompactWork]
+      } else {
+        spark.sparkContext.parallelize(
+          markerWorks.toSeq,
+          Math.min(markerWorks.size, Math.max(1, 
spark.sparkContext.defaultParallelism)))
+      }
+
+    val partitionedWorks = rowWorkAndKind._1
+      .union(markerRdd)
+      .map(
+        work =>
+          (
+            SparkPostponeCompactProcedure
+              .PostponeCompactKey(work.partition.toIndexedSeq, work.bucket),
+            work))
+      .partitionBy(new HashPartitioner(Math.max(1, 
spark.sparkContext.defaultParallelism)))
 
-    val dataFrame = withInitBucketCol
-      
.mapPartitions(processor.processPartition)(encoderGroupWithBucketCol.encoder)
-      .toDF()
-    val repartition = repartitionByPartitionsAndBucket(dataFrame)
-    val written = writeWithBucketProcessor(repartition, bucketColIdx, 
withInitBucketCol.schema)
+    val written = partitionedWorks.mapPartitions {
+      works =>
+        if (!works.hasNext) {
+          Iterator.empty
+        } else {
+          val dataWrite =
+            newDataWrite(realTable, rowWorkAndKind._2, 
postponePartitionBucketComputer)
+          dataWrite.write.withWriteRestore(
+            new FileSystemWriteRestore(
+              realTable.coreOptions(),
+              realTable.snapshotManager(),
+              realTable.store().newScan(),
+              realTable.store().newIndexFileHandler(),
+              snapshotId))
+          var commitInvoked = false
+          try {
+            val pendingBuckets = mutable.LinkedHashMap
+              .empty[SparkPostponeCompactProcedure.PostponeCompactKey, 
Array[Byte]]
+            works.foreach {
+              case (key, work) =>
+                pendingBuckets.put(key, work.partition)
+                if (!work.compactMarker) {
+                  dataWrite.write(work.row, work.bucket)
+                }
+            }
+            pendingBuckets.foreach {
+              case (key, partition) =>
+                dataWrite.write.compact(
+                  SerializationUtils.deserializeBinaryRow(partition),
+                  key.bucket,
+                  false)
+            }
+            commitInvoked = true
+            Iterator.single(dataWrite.commit)
+          } finally {
+            if (!commitInvoked) {
+              dataWrite.close()
+            }
+          }
+        }
+    }
 
     // Create commit messages for removing old postpone bucket files
     val removeMessages = splits.map {
@@ -254,6 +321,16 @@ case class SparkPostponeCompactProcedure(
 
 object SparkPostponeCompactProcedure {
 
+  private[procedure] case class PostponeCompactWork(
+      row: Row,
+      partition: Array[Byte],
+      bucket: Int,
+      compactMarker: Boolean)
+    extends Serializable
+
+  private[procedure] case class PostponeCompactKey(partition: 
IndexedSeq[Byte], bucket: Int)
+    extends Serializable
+
   private[procedure] case class PostponePartitionBucketComputer(
       knownNumBuckets: java.util.Map[BinaryRow, Integer],
       targetRowNumPerBucket: Option[java.lang.Long],
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
index 155f314eba..061fc5238e 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PrimaryKeyVectorSearchTest.scala
@@ -105,6 +105,75 @@ class PrimaryKeyVectorSearchTest extends 
PaimonSparkTestBase {
     }
   }
 
+  test("postpone bucket builds primary-key vector index during compact") {
+    withTable("T") {
+      createVectorTable(
+        bucket = -2,
+        extraOptions = Seq(
+          "write-only" -> "true",
+          "postpone.batch-write-fixed-bucket" -> "true",
+          "deletion-vectors.merge-on-read" -> "false"))
+
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (1, array(3.0f, 0.0f)),
+                  |  (2, array(1.0f, 0.0f)),
+                  |  (3, array(2.0f, 0.0f))
+                  |""".stripMargin)
+
+      assert(spark.sql("SELECT id FROM T").collect().isEmpty)
+      assert(spark.sql("SELECT file_path FROM `T$files` WHERE level = 
0").collect().nonEmpty)
+      assert(!spark.sql("SELECT bucket FROM 
`T$buckets`").collect().exists(_.getInt(0) == -2))
+
+      spark.sql("CALL sys.compact(table => 'T')")
+
+      assert(spark.sql("SELECT id FROM T").collect().map(_.getInt(0)).toSet == 
Set(1, 2, 3))
+      val ids = spark
+        .sql("""
+               |SELECT id
+               |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 2)
+               |""".stripMargin)
+        .collect()
+        .map(_.getInt(0))
+        .toSet
+      assert(ids == Set(2, 3))
+
+      spark.sql("""
+                  |INSERT INTO T VALUES
+                  |  (4, array(0.5f, 0.0f)),
+                  |  (6, array(6.0f, 0.0f))
+                  |""".stripMargin)
+      withSparkSQLConf("spark.paimon.postpone.batch-write-fixed-bucket" -> 
"false") {
+        spark.sql("""
+                    |INSERT INTO T VALUES
+                    |  (5, array(1.5f, 0.0f)),
+                    |  (7, array(7.0f, 0.0f))
+                    |""".stripMargin)
+      }
+
+      assert(spark.sql("SELECT id FROM T").collect().map(_.getInt(0)).toSet == 
Set(1, 2, 3))
+      assert(spark.sql("SELECT bucket FROM 
`T$buckets`").collect().exists(_.getInt(0) == -2))
+      assert(spark.sql("SELECT file_path FROM `T$files` WHERE level = 
0").collect().nonEmpty)
+
+      spark.sql("CALL sys.compact(table => 'T')")
+
+      assert(
+        spark.sql("SELECT id FROM T").collect().map(_.getInt(0)).toSet ==
+          Set(1, 2, 3, 4, 5, 6, 7))
+      assert(!spark.sql("SELECT bucket FROM 
`T$buckets`").collect().exists(_.getInt(0) == -2))
+      assert(spark.sql("SELECT file_path FROM `T$files` WHERE level = 
0").collect().isEmpty)
+      val mixedIds = spark
+        .sql("""
+               |SELECT id
+               |FROM vector_search('T', 'embedding', array(0.0f, 0.0f), 2)
+               |""".stripMargin)
+        .collect()
+        .map(_.getInt(0))
+        .toSet
+      assert(mixedIds == Set(4, 2))
+    }
+  }
+
   test("primary-key vector search merges top k across buckets") {
     withTable("T") {
       createVectorTable(bucket = 4, extraOptions = 
Seq("global-index.thread-num" -> "2"))

Reply via email to