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 967b242ba9 [core] Make compact_manifest perform a full manifest sort 
(#9802)
967b242ba9 is described below

commit 967b242ba960687c53da0d6a85fe41d67f18361d
Author: jianguotian <[email protected]>
AuthorDate: Mon Sep 14 17:42:10 2026 +0800

    [core] Make compact_manifest perform a full manifest sort (#9802)
---
 docs/docs/flink/procedures/compaction.md           |   4 +
 docs/docs/spark/procedures/maintenance.md          |   3 +
 .../paimon/operation/FileStoreCommitImpl.java      |  20 +-
 .../paimon/operation/ManifestFileMerger.java       |  18 +-
 .../paimon/operation/ManifestFileSorter.java       |  65 ++++-
 .../paimon/manifest/ManifestFileMetaTest.java      | 309 +++++++++++++++++++++
 .../paimon/operation/FileStoreCommitTest.java      |  12 +-
 .../paimon/operation/ManifestFileMergerTest.java   |   4 +-
 .../operation/ManifestFileMergerTestUtils.java     |  40 +++
 .../procedure/CompactManifestProcedureITCase.java  |   2 +-
 10 files changed, 437 insertions(+), 40 deletions(-)

diff --git a/docs/docs/flink/procedures/compaction.md 
b/docs/docs/flink/procedures/compaction.md
index 71c2fd531d..5b26f1dfda 100644
--- a/docs/docs/flink/procedures/compaction.md
+++ b/docs/docs/flink/procedures/compaction.md
@@ -250,6 +250,10 @@ To compact_manifest the manifests. Arguments:
 
 - manifest_sort_max_rewrite_size (String, optional): maximum manifest size 
rewritten by one sort pass.
 
+When manifest sort is enabled, `compact_manifest` performs a full sort using 
the layout selected
+from the table options. The existing `manifest_sort_max_rewrite_size` limit 
still controls the
+amount of manifest data rewritten in one invocation.
+
 **Syntax**
 
 ```sql
diff --git a/docs/docs/spark/procedures/maintenance.md 
b/docs/docs/spark/procedures/maintenance.md
index 1244848028..9952a70cc5 100644
--- a/docs/docs/spark/procedures/maintenance.md
+++ b/docs/docs/spark/procedures/maintenance.md
@@ -126,6 +126,9 @@ Compact manifest files.
 - `manifest_sort_enabled` (`BOOLEAN`, optional): whether to use manifest sort 
rewrite for this invocation.
 - `manifest_sort_partition_field` (`STRING`, optional): partition field used 
to sort manifest entries. Defaults to the first partition field.
 - `manifest_sort_max_rewrite_size` (`STRING`, optional): maximum manifest size 
rewritten by one sort pass.
+When manifest sort is enabled, `compact_manifest` performs a full sort using 
the layout selected
+from the table options. The existing `manifest_sort_max_rewrite_size` limit 
still controls the
+amount of manifest data rewritten in one invocation.
 
 ```sql
 CALL sys.compact_manifest(`table` => 'default.T');
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 2325d85ff7..0f60d6a3cb 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -1615,8 +1615,9 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         mergeBeforeManifests,
                         manifestFile,
                         partitionType,
-                        manifestCompactionOptions(options, 
mergeBeforeManifests, partitionType),
-                        ioManager);
+                        manifestCompactionOptions(options),
+                        ioManager,
+                        true);
 
         if (new HashSet<>(mergeBeforeManifests).equals(new 
HashSet<>(mergeAfterManifests))) {
             // no need to commit this snapshot, because no compact were 
happened
@@ -1655,17 +1656,12 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         return commitSnapshotImpl(latestSnapshot, newSnapshot, emptyList());
     }
 
-    static CoreOptions manifestCompactionOptions(
-            CoreOptions options, List<ManifestFileMeta> manifests, RowType 
partitionType) {
-        // Use a copied options with forced full compaction settings for the 
legacy merge path.
-        // Manifest sort has its own full/minor picking strategy and should 
respect its configured
-        // thresholds.
+    static CoreOptions manifestCompactionOptions(CoreOptions options) {
+        // Use copied options so explicit manifest compaction always takes the 
full-compaction path
+        // without changing the table options used by regular commits.
         Options compactOptions = Options.fromMap(options.toMap());
-        if (!ManifestFileMerger.canUseManifestSort(manifests, partitionType, 
options)) {
-            compactOptions.set(CoreOptions.MANIFEST_MERGE_MIN_COUNT, 1);
-            compactOptions.set(
-                    CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE, 
MemorySize.ofBytes(1));
-        }
+        compactOptions.set(CoreOptions.MANIFEST_MERGE_MIN_COUNT, 1);
+        compactOptions.set(CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE, 
MemorySize.ofBytes(1));
         return new CoreOptions(compactOptions);
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileMerger.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileMerger.java
index 688d59f492..79d21e025e 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileMerger.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileMerger.java
@@ -58,6 +58,16 @@ public class ManifestFileMerger {
             RowType partitionType,
             CoreOptions options,
             @Nullable IOManager ioManager) {
+        return merge(input, manifestFile, partitionType, options, ioManager, 
false);
+    }
+
+    static List<ManifestFileMeta> merge(
+            List<ManifestFileMeta> input,
+            ManifestFile manifestFile,
+            RowType partitionType,
+            CoreOptions options,
+            @Nullable IOManager ioManager,
+            boolean fullCompaction) {
         // these are the newly created manifest files, clean them up if 
exception occurs
         List<ManifestFileMeta> newFilesForAbort = new ArrayList<>();
 
@@ -66,7 +76,13 @@ public class ManifestFileMerger {
             // partition fields for manifest sort rewrite.
             if (canUseManifestSort(input, partitionType, options)) {
                 return ManifestFileSorter.trySortCompaction(
-                        input, newFilesForAbort, manifestFile, partitionType, 
options, ioManager);
+                        input,
+                        newFilesForAbort,
+                        manifestFile,
+                        partitionType,
+                        options,
+                        ioManager,
+                        fullCompaction);
             }
 
             if (options.manifestMergeOptimizeEnabled()) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileSorter.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileSorter.java
index 94255953fc..30351f9e79 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileSorter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/ManifestFileSorter.java
@@ -71,6 +71,7 @@ public class ManifestFileSorter {
     /** Context object that carries shared state across compaction methods. */
     static class CompactionContext {
         final boolean fullCompaction;
+        final boolean fullSort;
         final boolean runMergeOptimizeEnabled;
         final ManifestSortKey sortKey;
         final RowType partitionType;
@@ -91,6 +92,7 @@ public class ManifestFileSorter {
 
         CompactionContext(
                 boolean fullCompaction,
+                boolean fullSort,
                 boolean runMergeOptimizeEnabled,
                 ManifestSortKey sortKey,
                 RowType partitionType,
@@ -100,6 +102,7 @@ public class ManifestFileSorter {
                 List<ManifestAdjacentSortedRun> levelRuns,
                 List<ManifestAdjacentSortedRun> pickedRuns) {
             this.fullCompaction = fullCompaction;
+            this.fullSort = fullSort;
             this.runMergeOptimizeEnabled = runMergeOptimizeEnabled;
             this.sortKey = sortKey;
             this.partitionType = partitionType;
@@ -153,7 +156,8 @@ public class ManifestFileSorter {
             ManifestFile manifestFile,
             RowType partitionType,
             CoreOptions options,
-            @Nullable IOManager ioManager)
+            @Nullable IOManager ioManager,
+            boolean fullSort)
             throws Exception {
         String sortPartitionField = options.manifestSortPartitionField();
         boolean bucketed = options.bucket() > 0 || options.bucket() == 
BucketMode.POSTPONE_BUCKET;
@@ -181,6 +185,7 @@ public class ManifestFileSorter {
                         suggestedMetaSize,
                         suggestedMinMetaCount,
                         fullCompactionThreshold,
+                        fullSort,
                         maxRewriteSize,
                         maxSizeAmplificationPercent,
                         sortedRunSizeRatio,
@@ -225,6 +230,7 @@ public class ManifestFileSorter {
             long suggestedMetaSize,
             int suggestedMinMetaCount,
             long fullCompactionThreshold,
+            boolean fullSort,
             long maxRewriteSize,
             int maxSizeAmplificationPercent,
             int sortedRunSizeRatio,
@@ -232,7 +238,9 @@ public class ManifestFileSorter {
             @Nullable Integer manifestReadParallelism)
             throws Exception {
         // Step 1: Check if full compaction threshold is met
-        if (!reachesFullCompactionThreshold(input, suggestedMetaSize, 
fullCompactionThreshold)) {
+        if (!fullSort
+                && !reachesFullCompactionThreshold(
+                        input, suggestedMetaSize, fullCompactionThreshold)) {
             return Optional.empty();
         }
         // Step 2: Prepare compaction context
@@ -240,6 +248,7 @@ public class ManifestFileSorter {
                 prepareCompaction(
                         input,
                         true,
+                        fullSort,
                         manifestFile,
                         partitionType,
                         sortPartitionField,
@@ -253,7 +262,8 @@ public class ManifestFileSorter {
                         manifestReadParallelism);
         try {
             List<ManifestAdjacentSortedRun> levelRuns = ctx.levelRuns;
-            List<ManifestAdjacentSortedRun> pickedRuns = ctx.pickedRuns;
+            List<ManifestAdjacentSortedRun> pickedRuns =
+                    fullSort ? new ArrayList<>(levelRuns) : ctx.pickedRuns;
 
             if (pickedRuns.isEmpty() && ctx.defaultCompactFiles.isEmpty()) {
                 LOG.debug(
@@ -283,9 +293,22 @@ public class ManifestFileSorter {
             }
             pickedFiles.addAll(ctx.defaultCompactFiles.keySet());
 
-            // Step 4: Split into sections and merge small adjacent sections
-            List<Section> sections = splitIntoSections(pickedFiles, ctx);
-            sections = mergeSmallAdjacentSections(sections, suggestedMetaSize);
+            // Step 4: A full sort uses one global section so entries from all 
existing runs can be
+            // clustered using the layout selected from the table options.
+            List<Section> sections;
+            if (fullSort) {
+                long totalSize = 0L;
+                boolean hasDefaultCompactFile = false;
+                for (ManifestFileMeta file : pickedFiles) {
+                    totalSize += file.fileSize();
+                    hasDefaultCompactFile |= 
ctx.isMarkedForDefaultCompaction(file);
+                }
+                sections = new ArrayList<>();
+                sections.add(new Section(pickedFiles, totalSize, 
hasDefaultCompactFile));
+            } else {
+                sections = splitIntoSections(pickedFiles, ctx);
+                sections = mergeSmallAdjacentSections(sections, 
suggestedMetaSize);
+            }
 
             LOG.info(
                     "Manifest sort full compact: pickedFiles={}, sections={}.",
@@ -343,6 +366,7 @@ public class ManifestFileSorter {
                 prepareCompaction(
                         input,
                         false,
+                        false,
                         manifestFile,
                         partitionType,
                         sortPartitionField,
@@ -458,6 +482,7 @@ public class ManifestFileSorter {
     private static CompactionContext prepareCompaction(
             List<ManifestFileMeta> input,
             boolean fullCompaction,
+            boolean fullSort,
             ManifestFile manifestFile,
             RowType partitionType,
             String sortPartitionField,
@@ -500,6 +525,7 @@ public class ManifestFileSorter {
 
         return new CompactionContext(
                 fullCompaction,
+                fullSort,
                 useRunMergeOptimize,
                 sortKey,
                 partitionType,
@@ -862,15 +888,17 @@ public class ManifestFileSorter {
         for (int i = 0; i < sections.size(); i++) {
             Section section = sections.get(i);
 
-            // A single-file section is always handled directly, regardless of 
the budget.
-            if (section.files.size() == 1) {
+            // Preserve the ordinary-compaction shortcut: an unchanged 
singleton must not consume
+            // the sort rewrite limit. Explicit full sort intentionally 
rewrites the singleton.
+            if (!ctx.fullSort && section.files.size() == 1) {
                 rewriteSection(
                         section.files,
                         output,
                         sortNewFiles,
                         ctx,
                         manifestFile,
-                        manifestReadParallelism);
+                        manifestReadParallelism,
+                        false);
                 continue;
             }
 
@@ -886,7 +914,8 @@ public class ManifestFileSorter {
                             sortNewFiles,
                             ctx,
                             manifestFile,
-                            manifestReadParallelism);
+                            manifestReadParallelism,
+                            true);
                 } else {
                     // Phase 1b: first overflow -- split the section at the 
budget boundary,
                     // rewrite the affordable head, and append the remaining 
tail back for later
@@ -966,7 +995,8 @@ public class ManifestFileSorter {
             }
         }
 
-        rewriteSection(headFiles, output, sortNewFiles, ctx, manifestFile, 
manifestReadParallelism);
+        rewriteSection(
+                headFiles, output, sortNewFiles, ctx, manifestFile, 
manifestReadParallelism, true);
 
         if (tailFiles.isEmpty()) {
             return null;
@@ -1044,7 +1074,8 @@ public class ManifestFileSorter {
                         sortNewFiles,
                         ctx,
                         manifestFile,
-                        manifestReadParallelism);
+                        manifestReadParallelism,
+                        false);
                 candidates.clear();
                 candidatesSize = 0;
             }
@@ -1058,7 +1089,8 @@ public class ManifestFileSorter {
                         sortNewFiles,
                         ctx,
                         manifestFile,
-                        manifestReadParallelism);
+                        manifestReadParallelism,
+                        false);
             } else {
                 output.addAllUnchanged(candidates);
             }
@@ -1077,10 +1109,13 @@ public class ManifestFileSorter {
             List<ManifestFileMeta> sortNewFiles,
             CompactionContext ctx,
             ManifestFile manifestFile,
-            @Nullable Integer manifestReadParallelism)
+            @Nullable Integer manifestReadParallelism,
+            boolean allowFullRewrite)
             throws Exception {
         // Skip rewrite for single file not in delete-range.
-        if (section.size() == 1 && 
!ctx.defaultCompactFiles.getOrDefault(section.get(0), false)) {
+        if (section.size() == 1
+                && !(allowFullRewrite && ctx.fullSort)
+                && !ctx.defaultCompactFiles.getOrDefault(section.get(0), 
false)) {
             output.addUnchanged(section.get(0));
             return;
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
index 6d67eddfbd..ad3641b121 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileMetaTest.java
@@ -33,6 +33,7 @@ import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.operation.ManifestCompactDryRun;
 import org.apache.paimon.operation.ManifestFileMerger;
+import org.apache.paimon.operation.ManifestFileMergerTestUtils;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.schema.FileSystemSchemaManager;
@@ -1352,6 +1353,278 @@ public class ManifestFileMetaTest extends 
ManifestFileMetaTestBase {
                 .containsExactly(0, 1, 0, 1);
     }
 
+    @Test
+    public void testManifestSortFullCompactionAlreadyCompactedRuns() {
+        List<ManifestFileMeta> physical =
+                Arrays.asList(
+                        makeManifest(makeBucketEntry("a-3", 0, 3), 
makeBucketEntry("a-1", 0, 1)),
+                        makeManifest(makeBucketEntry("b-2", 1, 2), 
makeBucketEntry("b-0", 1, 0)));
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input =
+                physical.stream()
+                        .map(meta -> copyAsLegacyWithFileSize(meta, 
targetSize))
+                        .collect(Collectors.toList());
+        assertThat(input)
+                .allMatch(
+                        meta ->
+                                meta.minBucket() == null
+                                        && meta.maxBucket() == null
+                                        && meta.totalBuckets() == null);
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"1G");
+        testOptions.set(CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE.key(), 
Long.MAX_VALUE + "B");
+        testOptions.set(CoreOptions.BUCKET, 4);
+
+        List<ManifestFileMeta> unchanged =
+                ManifestFileMerger.merge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+        assertThat(unchanged).containsExactlyInAnyOrderElementsOf(input);
+
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        assertEquivalentEntries(input, rewritten);
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .doesNotContainAnyElementsOf(
+                        input.stream()
+                                .map(ManifestFileMeta::fileName)
+                                .collect(Collectors.toList()));
+        assertThat(readEntries(rewritten))
+                .extracting(ManifestEntry::bucket)
+                .containsExactly(0, 1, 2, 3);
+        assertThat(rewritten).hasSize(1);
+        assertThat(rewritten.get(0).minBucket()).isZero();
+        assertThat(rewritten.get(0).maxBucket()).isEqualTo(3);
+        assertThat(rewritten.get(0).totalBuckets()).isEqualTo(240);
+
+        testOptions.set(CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE.key(), 
Long.MAX_VALUE + "B");
+        List<ManifestFileMeta> afterMigration =
+                ManifestFileMerger.merge(
+                        rewritten,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+        assertThat(afterMigration).containsExactlyElementsOf(rewritten);
+    }
+
+    @Test
+    public void testManifestSortFullCompactionAllLevelRuns() {
+        List<ManifestFileMeta> physical =
+                Arrays.asList(
+                        makeManifest(makeBucketEntry("a-3", 0, 3), 
makeBucketEntry("a-1", 2, 1)),
+                        makeManifest(makeBucketEntry("b-2", 1, 2), 
makeBucketEntry("b-0", 3, 0)));
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input =
+                physical.stream()
+                        .map(meta -> copyAsLegacyWithFileSize(meta, 
targetSize))
+                        .collect(Collectors.toList());
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"1G");
+        testOptions.set(CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE.key(), 
Long.MAX_VALUE + "B");
+        testOptions.set(CoreOptions.BUCKET, 4);
+
+        // Without bucket metadata, manifest sort falls back to the 
overlapping partition ranges
+        // [0, 2] and [1, 3]. The dry run therefore shows that these files 
form two level runs.
+        FileStoreTable table = mock(FileStoreTable.class, RETURNS_DEEP_STUBS);
+        Snapshot snapshot = mock(Snapshot.class);
+        when(table.options()).thenReturn(testOptions.toMap());
+        
when(table.store().snapshotManager().latestSnapshot()).thenReturn(snapshot);
+        
when(table.store().manifestListFactory().create().readDataManifests(snapshot))
+                .thenReturn(input);
+        
when(table.store().manifestFileFactory().create()).thenReturn(manifestFile);
+        
when(table.schema().logicalPartitionType()).thenReturn(getPartitionType());
+        assertThat(ManifestCompactDryRun.execute(table))
+                .endsWith("Manifest sort level files: L0=0, L1=0, L2=0, L3=1, 
L4=1.");
+
+        List<ManifestFileMeta> unchanged =
+                ManifestFileMerger.merge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+        assertThat(unchanged).containsExactlyInAnyOrderElementsOf(input);
+
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        assertEquivalentEntries(input, rewritten);
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .doesNotContainAnyElementsOf(
+                        input.stream()
+                                .map(ManifestFileMeta::fileName)
+                                .collect(Collectors.toList()));
+        assertThat(readEntries(rewritten))
+                .extracting(ManifestEntry::bucket)
+                .containsExactly(0, 1, 2, 3);
+    }
+
+    @Test
+    public void testManifestSortFullCompactionSingleManifest() {
+        ManifestFileMeta physical =
+                makeManifest(makeBucketEntry("file-3", 0, 3), 
makeBucketEntry("file-0", 0, 0));
+        ManifestFileMeta input =
+                copyWithFileSize(
+                        physical, 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes());
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.BUCKET, 4);
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        Collections.singletonList(input),
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        assertEquivalentEntries(Collections.singletonList(input), rewritten);
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .doesNotContain(input.fileName());
+        
assertThat(readEntries(rewritten)).extracting(ManifestEntry::bucket).containsExactly(0,
 3);
+    }
+
+    @Test
+    public void testManifestSortFullCompactionRespectsRewriteLimit() {
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input = new ArrayList<>();
+        for (int i = 0; i < 4; i++) {
+            input.add(
+                    copyWithFileSize(makeManifest(makeBucketEntry("file-" + i, 
0, i)), targetSize));
+        }
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"1B");
+        testOptions.set(CoreOptions.BUCKET, 4);
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        Set<String> inputNames =
+                
input.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet());
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .filteredOn(inputNames::contains)
+                .hasSize(2);
+        assertEquivalentEntries(input, rewritten);
+    }
+
+    @Test
+    public void 
testManifestSortFullCompactionDoesNotExceedLimitForSingletonTail() {
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input = new ArrayList<>();
+        for (int i = 0; i < 3; i++) {
+            input.add(
+                    copyWithFileSize(makeManifest(makeBucketEntry("file-" + i, 
0, i)), targetSize));
+        }
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"1B");
+        testOptions.set(CoreOptions.BUCKET, 4);
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        Set<String> inputNames =
+                
input.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet());
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .filteredOn(inputNames::contains)
+                .hasSize(1);
+        assertEquivalentEntries(input, rewritten);
+    }
+
+    @Test
+    public void testManifestSortFullCompactionDoesNotRewriteTailBeyondLimit() {
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input = new ArrayList<>();
+        for (int i = 0; i < 4; i++) {
+            input.add(
+                    copyWithFileSize(makeManifest(makeBucketEntry("file-" + i, 
0, i)), targetSize));
+        }
+        input.add(
+                copyWithFileSize(
+                        makeManifest(makeBucketEntry("small-file", 0, 4)), 
targetSize - 1));
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"1B");
+        testOptions.set(CoreOptions.BUCKET, 8);
+        List<ManifestFileMeta> rewritten =
+                ManifestFileMergerTestUtils.fullMerge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        Set<String> inputNames =
+                
input.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet());
+        assertThat(rewritten)
+                .extracting(ManifestFileMeta::fileName)
+                .filteredOn(inputNames::contains)
+                .hasSize(3);
+        assertEquivalentEntries(input, rewritten);
+    }
+
+    @Test
+    public void testManifestSortUnchangedSingletonDoesNotConsumeRewriteLimit() 
{
+        long targetSize = 
CoreOptions.MANIFEST_TARGET_FILE_SIZE.defaultValue().getBytes();
+        List<ManifestFileMeta> input = new ArrayList<>();
+        input.add(copyWithFileSize(makeManifest(makeEntry(true, "singleton", 
0)), targetSize));
+        for (int i = 0; i < 5; i++) {
+            input.add(
+                    copyWithFileSize(
+                            makeManifest(
+                                    makeEntry(true, "range-" + i + "-1", 1),
+                                    makeEntry(true, "range-" + i + "-2", 2)),
+                            targetSize));
+        }
+
+        Options testOptions = new Options();
+        testOptions.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
+        testOptions.set(CoreOptions.MANIFEST_SORT_MAX_REWRITE_SIZE.key(), 
"4M");
+        testOptions.set(CoreOptions.BUCKET, -1);
+        List<ManifestFileMeta> merged =
+                ManifestFileMerger.merge(
+                        input,
+                        manifestFile,
+                        getPartitionType(),
+                        CoreOptions.fromMap(testOptions.toMap()));
+
+        Set<String> inputNames =
+                
input.stream().map(ManifestFileMeta::fileName).collect(Collectors.toSet());
+        
assertThat(merged).extracting(ManifestFileMeta::fileName).contains(input.get(0).fileName());
+        assertThat(merged)
+                .extracting(ManifestFileMeta::fileName)
+                .filteredOn(inputNames::contains)
+                .hasSize(4);
+        assertEquivalentEntries(input, merged);
+    }
+
     @ParameterizedTest
     @ValueSource(ints = {-1, 4, -2})
     public void testManifestSortDryRunUsesBucketRangesForBucketedTable(int 
bucket) {
@@ -2830,6 +3103,42 @@ public class ManifestFileMetaTest extends 
ManifestFileMetaTestBase {
         return ManifestEntry.create(entry.kind(), entry.partition(), bucket, 
240, entry.file());
     }
 
+    private ManifestFileMeta copyWithFileSize(ManifestFileMeta meta, long 
fileSize) {
+        return new ManifestFileMeta(
+                meta.fileName(),
+                fileSize,
+                meta.numAddedFiles(),
+                meta.numDeletedFiles(),
+                meta.partitionStats(),
+                meta.schemaId(),
+                meta.minBucket(),
+                meta.maxBucket(),
+                meta.minLevel(),
+                meta.maxLevel(),
+                meta.minRowId(),
+                meta.maxRowId(),
+                meta.totalBuckets(),
+                meta.extraFiles());
+    }
+
+    private ManifestFileMeta copyAsLegacyWithFileSize(ManifestFileMeta meta, 
long fileSize) {
+        return new ManifestFileMeta(
+                meta.fileName(),
+                fileSize,
+                meta.numAddedFiles(),
+                meta.numDeletedFiles(),
+                meta.partitionStats(),
+                meta.schemaId(),
+                null,
+                null,
+                meta.minLevel(),
+                meta.maxLevel(),
+                meta.minRowId(),
+                meta.maxRowId(),
+                null,
+                meta.extraFiles());
+    }
+
     /** Create a ManifestEntry with a 3-field partition row (region, dt, 
hour). */
     private ManifestEntry makeMultiPartEntry(
             boolean isAdd, String fileName, int region, int dt, int hour) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index c96b7b7111..437944fae5 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -1430,21 +1430,17 @@ public class FileStoreCommitTest {
     }
 
     @Test
-    public void testManifestSortCompactManifestRespectsCompactionThresholds() {
+    public void testManifestSortCompactManifestUsesFullCompactionThresholds() {
         Options options = new Options();
         options.set(CoreOptions.MANIFEST_SORT_ENABLED, true);
         options.set(CoreOptions.MANIFEST_MERGE_MIN_COUNT, 100);
         options.set(CoreOptions.MANIFEST_FULL_COMPACTION_FILE_SIZE.key(), 
Long.MAX_VALUE + "B");
 
         CoreOptions compactOptions =
-                FileStoreCommitImpl.manifestCompactionOptions(
-                        new CoreOptions(options),
-                        Collections.emptyList(),
-                        TestKeyValueGenerator.DEFAULT_PART_TYPE);
+                FileStoreCommitImpl.manifestCompactionOptions(new 
CoreOptions(options));
 
-        assertThat(compactOptions.manifestMergeMinCount()).isEqualTo(100);
-        
assertThat(compactOptions.manifestFullCompactionThresholdSize().getBytes())
-                .isEqualTo(Long.MAX_VALUE);
+        assertThat(compactOptions.manifestMergeMinCount()).isEqualTo(1);
+        
assertThat(compactOptions.manifestFullCompactionThresholdSize().getBytes()).isEqualTo(1);
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTest.java
index cfd1cb8fc1..57b4dadec4 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTest.java
@@ -81,9 +81,7 @@ public class ManifestFileMergerTest extends 
ManifestFileMetaTestBase {
         assertThat(ManifestFileMerger.canUseManifestSort(input, 
NO_PARTITION_TYPE, tableOptions))
                 .isFalse();
 
-        CoreOptions compactOptions =
-                FileStoreCommitImpl.manifestCompactionOptions(
-                        tableOptions, input, NO_PARTITION_TYPE);
+        CoreOptions compactOptions = 
FileStoreCommitImpl.manifestCompactionOptions(tableOptions);
         assertThat(compactOptions.manifestMergeMinCount()).isEqualTo(1);
         
assertThat(compactOptions.manifestFullCompactionThresholdSize().getBytes()).isEqualTo(1);
 
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTestUtils.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTestUtils.java
new file mode 100644
index 0000000000..87d93d1285
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/ManifestFileMergerTestUtils.java
@@ -0,0 +1,40 @@
+/*
+ * 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.operation;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.manifest.ManifestFile;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.types.RowType;
+
+import java.util.List;
+
+/** Test access to explicit full manifest compaction. */
+public class ManifestFileMergerTestUtils {
+
+    private ManifestFileMergerTestUtils() {}
+
+    public static List<ManifestFileMeta> fullMerge(
+            List<ManifestFileMeta> input,
+            ManifestFile manifestFile,
+            RowType partitionType,
+            CoreOptions options) {
+        return ManifestFileMerger.merge(input, manifestFile, partitionType, 
options, null, true);
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactManifestProcedureITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactManifestProcedureITCase.java
index a40fa8f905..a2e0f85fce 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactManifestProcedureITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/CompactManifestProcedureITCase.java
@@ -122,7 +122,7 @@ public class CompactManifestProcedureITCase extends 
CatalogITCaseBase {
         long compactSnapshotId = table.snapshotManager().latestSnapshot().id();
         sql(procedure);
         Assertions.assertThat(table.snapshotManager().latestSnapshot().id())
-                .isEqualTo(compactSnapshotId);
+                .isEqualTo(compactSnapshotId + 1);
     }
 
     @Test

Reply via email to