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 3624f03dff [core] Avoid eager manifest reads for append-only LIMIT 
scans (#10153)
3624f03dff is described below

commit 3624f03dfff06a1f489e65671d539b922f3c7df3
Author: Beinan <[email protected]>
AuthorDate: Wed Sep 23 19:48:19 2026 -0700

    [core] Avoid eager manifest reads for append-only LIMIT scans (#10153)
---
 .../paimon/operation/AppendOnlyFileStoreScan.java  |   7 +-
 .../table/AppendOnlyLimitManifestReadTest.java     | 225 +++++++++++++++++++++
 2 files changed, 230 insertions(+), 2 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/AppendOnlyFileStoreScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/AppendOnlyFileStoreScan.java
index 7a0cdec312..af38f1d6c9 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/AppendOnlyFileStoreScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/AppendOnlyFileStoreScan.java
@@ -98,15 +98,18 @@ public class AppendOnlyFileStoreScan extends 
AbstractFileStoreScan {
     @Override
     public Iterator<ManifestEntry> readManifestEntries(
             List<ManifestFileMeta> manifestFiles, boolean useSequential) {
-        Iterator<ManifestEntry> result = 
super.readManifestEntries(manifestFiles, useSequential);
         if (limit == null
                 || limit <= 0
                 || deletionVectorsEnabled
                 || dataEvolutionEnabled
                 || inputFilter != null) {
-            return result;
+            return super.readManifestEntries(manifestFiles, useSequential);
         }
 
+        // A LIMIT scan may stop before consuming all manifest entries. Submit 
reads in bounded
+        // batches instead of scheduling every manifest before the first entry 
is consumed.
+        // Keep the normal merge path so later DELETE entries still invalidate 
earlier ADDs.
+        Iterator<ManifestEntry> result = 
super.readManifestEntries(manifestFiles, true);
         List<ManifestEntry> filtered = new ArrayList<>();
         long accumulatedRowCount = 0;
         while (result.hasNext()) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlyLimitManifestReadTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlyLimitManifestReadTest.java
new file mode 100644
index 0000000000..119d410e37
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/AppendOnlyLimitManifestReadTest.java
@@ -0,0 +1,225 @@
+/*
+ * 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.table;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.CloseableIterator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Verifies actual manifest I/O, as well as result rows, for append-only 
LIMIT scans. */
+class AppendOnlyLimitManifestReadTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    private final CountingFileIO fileIO = new CountingFileIO();
+
+    @ParameterizedTest
+    @ValueSource(ints = {1, 10, 100})
+    void testLimitStopsManifestReads(int limit) throws Exception {
+        FileStoreTable table = createTable();
+        writeManifests(table, 20);
+
+        fileIO.manifestReads.set(0);
+        ReadBuilder read = table.newReadBuilder().withLimit(limit);
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(fileIO.manifestReads.get()).isEqualTo(Math.min(20, (limit + 
3) / 4));
+        List<Integer> rows = readIds(read, plan, limit);
+        assertThat(rows).hasSize(Math.min(limit, 80)).doesNotHaveDuplicates();
+        assertThat(rows).allMatch(id -> id >= 0 && id < 80);
+    }
+
+    @Test
+    void testUnpartitionedLimit() throws Exception {
+        FileStoreTable table = createTable(false);
+        writeManifests(table, 20);
+
+        fileIO.manifestReads.set(0);
+        ReadBuilder read = table.newReadBuilder().withLimit(10);
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(fileIO.manifestReads.get()).isEqualTo(3);
+        assertThat(readIds(read, plan, 
10)).hasSize(10).doesNotHaveDuplicates();
+    }
+
+    @Test
+    void testNoLimitReadsAllManifests() throws Exception {
+        FileStoreTable table = createTable();
+        writeManifests(table, 20);
+
+        fileIO.manifestReads.set(0);
+        ReadBuilder read = table.newReadBuilder();
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(fileIO.manifestReads.get()).isEqualTo(20);
+        assertThat(readIds(read, plan)).hasSize(80).doesNotHaveDuplicates();
+    }
+
+    @Test
+    void testDataFilterFindsRowsInLaterManifests() throws Exception {
+        FileStoreTable table = createTable();
+        writeManifests(table, 20);
+
+        fileIO.manifestReads.set(0);
+        ReadBuilder read =
+                table.newReadBuilder()
+                        .withFilter(new 
PredicateBuilder(table.rowType()).greaterOrEqual(0, 76))
+                        .withLimit(10);
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(fileIO.manifestReads.get()).isEqualTo(20);
+        assertThat(readIds(read, plan)).containsExactlyInAnyOrder(76, 77, 78, 
79);
+    }
+
+    @Test
+    void testPartitionFilterCanStopEarly() throws Exception {
+        FileStoreTable table = createTable();
+        writeManifests(table, 20);
+
+        fileIO.manifestReads.set(0);
+        ReadBuilder read =
+                table.newReadBuilder()
+                        .withFilter(new 
PredicateBuilder(table.rowType()).equal(1, 1))
+                        .withLimit(10);
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(fileIO.manifestReads.get()).isEqualTo(3);
+        assertThat(readIds(read, plan, 10))
+                .hasSize(10)
+                .doesNotHaveDuplicates()
+                .allMatch(id -> (id / 4) % 2 == 1);
+    }
+
+    @Test
+    void testLaterDeletesAreAppliedBeforeLimit() throws Exception {
+        FileStoreTable table = createTable();
+        writeManifests(table, 4);
+        // The oldest ADD entries belong to partition 0. Their DELETE entries 
occur later.
+        try (BatchTableCommit commit = 
table.newBatchWriteBuilder().newCommit()) {
+            commit.truncatePartitions(
+                    Collections.singletonList(Collections.singletonMap("p", 
"0")));
+        }
+
+        ReadBuilder read = table.newReadBuilder().withLimit(10);
+        TableScan.Plan plan = read.newScan().plan();
+        assertThat(readIds(read, plan)).containsExactlyInAnyOrder(4, 5, 6, 7, 
12, 13, 14, 15);
+    }
+
+    @Test
+    void testEmptyTable() throws Exception {
+        FileStoreTable table = createTable();
+        ReadBuilder read = table.newReadBuilder().withLimit(1);
+        assertThat(read.newScan().plan().splits()).isEmpty();
+        assertThat(fileIO.manifestReads.get()).isZero();
+    }
+
+    private FileStoreTable createTable() throws Exception {
+        return createTable(true);
+    }
+
+    private FileStoreTable createTable(boolean partitioned) throws Exception {
+        Path path = new Path(tempDir.toUri());
+        Schema.Builder schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("p", DataTypes.INT())
+                        .option("bucket", "-1")
+                        .option("file.format", "avro")
+                        .option("write-only", "true")
+                        .option("manifest.merge-min-count", "1000")
+                        .option("scan.manifest.parallelism", "1");
+        if (partitioned) {
+            schema.partitionKeys("p");
+        }
+        new FileSystemSchemaManager(fileIO, path).createTable(schema.build());
+        return FileStoreTableFactory.create(fileIO, path);
+    }
+
+    private void writeManifests(FileStoreTable table, int count) throws 
Exception {
+        for (int i = 0; i < count; i++) {
+            BatchWriteBuilder builder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = builder.newWrite();
+                    BatchTableCommit commit = builder.newCommit()) {
+                for (int j = 0; j < 4; j++) {
+                    write.write(GenericRow.of(i * 4 + j, i % 2));
+                }
+                commit.commit(write.prepareCommit());
+            }
+        }
+        assertThat(
+                        table.store()
+                                .manifestListFactory()
+                                .create()
+                                
.readDataManifests(table.latestSnapshot().get()))
+                .hasSize(count);
+    }
+
+    private List<Integer> readIds(ReadBuilder read, TableScan.Plan plan) 
throws Exception {
+        return readIds(read, plan, Integer.MAX_VALUE);
+    }
+
+    private List<Integer> readIds(ReadBuilder read, TableScan.Plan plan, int 
limit)
+            throws Exception {
+        List<Integer> result = new ArrayList<>();
+        RecordReader<InternalRow> reader = 
read.newRead().executeFilter().createReader(plan);
+        // Scan pruning retains whole files; the consuming engine enforces the 
global row limit.
+        try (CloseableIterator<InternalRow> rows = 
reader.toCloseableIterator()) {
+            while (result.size() < limit && rows.hasNext()) {
+                result.add(rows.next().getInt(0));
+            }
+        }
+        return result;
+    }
+
+    private static class CountingFileIO extends LocalFileIO {
+        private static final long serialVersionUID = 1L;
+
+        private final AtomicInteger manifestReads = new AtomicInteger();
+
+        @Override
+        public SeekableInputStream newInputStream(Path path) throws 
IOException {
+            String name = path.getName();
+            if (name.startsWith("manifest-") && 
!name.startsWith("manifest-list-")) {
+                manifestReads.incrementAndGet();
+            }
+            return super.newInputStream(path);
+        }
+    }
+}

Reply via email to