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 6deb55a2a9 [core] Fix cascading re-merge in BinaryExternalSortBuffer 
spill merge (#9303)
6deb55a2a9 is described below

commit 6deb55a2a98e47d35fbe26980432d1929770b6c8
Author: zhoulii <[email protected]>
AuthorDate: Thu Aug 20 09:20:21 2026 +0800

    [core] Fix cascading re-merge in BinaryExternalSortBuffer spill merge 
(#9303)
---
 .../paimon/sort/BinaryExternalSortBuffer.java      | 19 ++++++---
 .../paimon/sort/BinaryExternalSortBufferTest.java  | 45 ++++++++++++++++++++++
 2 files changed, 58 insertions(+), 6 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/sort/BinaryExternalSortBuffer.java
 
b/paimon-core/src/main/java/org/apache/paimon/sort/BinaryExternalSortBuffer.java
index 960965c901..8319f458bf 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/sort/BinaryExternalSortBuffer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/sort/BinaryExternalSortBuffer.java
@@ -196,12 +196,6 @@ public class BinaryExternalSortBuffer implements 
SortBuffer {
                                 + "or increase the table option 
'write-buffer-size'.");
             } else {
                 spill();
-
-                if (spillChannelIDs.size() >= maxNumFileHandles) {
-                    List<ChannelWithMeta> merged = 
merger.mergeChannelList(spillChannelIDs);
-                    spillChannelIDs.clear();
-                    spillChannelIDs.addAll(merged);
-                }
             }
         }
     }
@@ -217,6 +211,19 @@ public class BinaryExternalSortBuffer implements 
SortBuffer {
     private MutableObjectIterator<BinaryRow> spilledIterator() throws 
IOException {
         spill();
 
+        // Merge the spilled sorted runs until the number of file handles fits 
within the
+        // fan-in limit. This is performed once here, after spilling is 
finished, instead of
+        // incrementally during write(). Doing it during write() would re-add 
the merged output
+        // to the spill list and re-merge it together with subsequently 
spilled files, causing
+        // cascading re-merge and degrading the merge IO from O(N*log N) to 
O(N^2).
+        // This mirrors Flink's SpillingThread#mergeOnDisk: while (channels > 
maxFanIn)
+        // channels = mergeChannelList(channels).
+        while (spillChannelIDs.size() > maxNumFileHandles) {
+            List<ChannelWithMeta> merged = 
merger.mergeChannelList(spillChannelIDs);
+            spillChannelIDs.clear();
+            spillChannelIDs.addAll(merged);
+        }
+
         List<FileIOChannel> openChannels = new ArrayList<>();
         BinaryMergeIterator<BinaryRow> iterator =
                 merger.getMergingIterator(spillChannelIDs, openChannels);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/sort/BinaryExternalSortBufferTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/sort/BinaryExternalSortBufferTest.java
index 8aea0a76ce..a23f5104cb 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/sort/BinaryExternalSortBufferTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/sort/BinaryExternalSortBufferTest.java
@@ -224,6 +224,51 @@ public class BinaryExternalSortBufferTest {
         innerTestSpilling(buffer);
     }
 
+    @Test
+    public void testNoCascadingMergeAtFinalIterator() throws Exception {
+        // Use a tiny memory so that writing a moderate number of records 
produces many spill
+        // files, and a very small fan-in (maxNumFileHandles = 2) so that a 
correct final merge
+        // must invoke mergeChannelList more than once.
+        final int maxNumFileHandles = 2;
+        initMemorySegmentPool(128 * 1024); // 4 pages of 32 KB
+
+        BinaryExternalSortBuffer sorter = createBuffer(maxNumFileHandles);
+
+        int size = 50_000;
+        MockBinaryRowReader reader = new MockBinaryRowReader(size);
+        sorter.write(reader);
+        assertThat(sorter.size()).isEqualTo(size);
+
+        // No merge should happen during write(): the number of spilled files 
must exceed
+        // maxNumFileHandles^2. The previous (cascading) implementation merged 
during write()
+        // and re-added the merged output to the spill list, which would leave 
at most
+        // maxNumFileHandles files here and cause O(N^2) re-merge.
+        int spilledFilesAfterWrite = spillChannelCount(sorter);
+        assertThat(spilledFilesAfterWrite).isGreaterThan(maxNumFileHandles * 
maxNumFileHandles);
+
+        MutableObjectIterator<BinaryRow> iterator = sorter.sortedIterator();
+
+        // The final merge must bring the number of opened files within the 
fan-in limit.
+        // When spilledFilesAfterWrite > maxNumFileHandles^2 a single 
mergeChannelList call is
+        // not enough, so this also guards against using a one-shot `if` 
instead of `while`.
+        int finalFileHandles = spillChannelCount(sorter);
+        assertThat(finalFileHandles).isLessThanOrEqualTo(maxNumFileHandles);
+
+        BinaryRow next = serializer.createInstance();
+        for (int i = 0; i < size; i++) {
+            next = iterator.next(next);
+            assertThat(next.getInt(0)).isEqualTo(i);
+            assertThat(next.getString(1).toString()).isEqualTo(getString(i));
+        }
+        sorter.clear();
+    }
+
+    private static int spillChannelCount(BinaryExternalSortBuffer sorter) 
throws Exception {
+        Field field = 
BinaryExternalSortBuffer.class.getDeclaredField("spillChannelIDs");
+        field.setAccessible(true);
+        return ((List<?>) field.get(sorter)).size();
+    }
+
     private void innerTestSpilling(BinaryExternalSortBuffer sorter) throws 
Exception {
         int size = 2000_000;
 

Reply via email to