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 822c5b29c5 [core] Prune FM index partitions by query row ranges 
(#10201)
822c5b29c5 is described below

commit 822c5b29c5713641ff6ee565bbaa7f9594d66bd2
Author: QuakeWang <[email protected]>
AuthorDate: Sat Oct 3 17:06:49 2026 +0800

    [core] Prune FM index partitions by query row ranges (#10201)
---
 .../globalindex/fmindex/FMGlobalIndexer.java       |  16 ++
 .../globalindex/fmindex/FMGlobalIndexTest.java     | 165 +++++++++++++++++++++
 .../apache/paimon/table/IndexQuerySplitTest.java   | 100 +++++++++++++
 3 files changed, 281 insertions(+)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexer.java
 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexer.java
index 6a8969abc3..45fe62248a 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexer.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexer.java
@@ -119,6 +119,7 @@ public class FMGlobalIndexer implements GlobalIndexer {
         FMGlobalIndexReader.FileSetRowCountValidator validator =
                 new FMGlobalIndexReader.FileSetRowCountValidator(files.size(), 
totalRowCount);
         FMIndexFile.IndexMeta[] indexMetas = new 
FMIndexFile.IndexMeta[files.size()];
+        // Validate the complete file set before pruning partitions.
         for (int i = 0; i < files.size(); i++) {
             byte[] metadata = files.get(i).metadata();
             checkArgument(
@@ -134,6 +135,17 @@ public class FMGlobalIndexer implements GlobalIndexer {
             FMGlobalIndexReader.ContainerMetadataLoader container =
                     new 
FMGlobalIndexReader.ContainerMetadataLoader(files.get(i), indexMetas[i]);
             for (FMIndexFile.PartitionMeta partition : 
indexMetas[i].partitions) {
+                if (rowRanges != null
+                        && rowRanges.stream()
+                                .noneMatch(
+                                        range ->
+                                                Range.intersect(
+                                                        range.from,
+                                                        range.to,
+                                                        partition.firstRowId,
+                                                        
partition.lastRowId()))) {
+                    continue;
+                }
                 readers.add(
                         new FMGlobalIndexReader(
                                 fileReader,
@@ -146,6 +158,10 @@ public class FMGlobalIndexer implements GlobalIndexer {
                                 locateCostRatio));
             }
         }
+        if (readers.isEmpty()) {
+            return FMGlobalIndexReader.empty(
+                    executor, readContext, demandPageSize, locateCostRatio);
+        }
         return readers.size() == 1 ? readers.get(0) : new 
UnionGlobalIndexReader(readers);
     }
 }
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
index 8e7befeb4e..af6a9e1512 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/globalindex/fmindex/FMGlobalIndexTest.java
@@ -39,10 +39,14 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.FieldRef;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.utils.Range;
 
 import org.junit.jupiter.api.BeforeEach;
 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.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.IOException;
 import java.io.RandomAccessFile;
@@ -62,6 +66,7 @@ import java.util.concurrent.CompletionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Stream;
 
 import static 
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -109,6 +114,166 @@ public class FMGlobalIndexTest {
         indexer = new FMGlobalIndexer(dataField, options);
     }
 
+    @ParameterizedTest
+    @MethodSource("partitionRowRanges")
+    public void testPartitionPruning(
+            List<Range> ranges, int expectedPartitions, long[] expectedRows) 
throws Exception {
+        List<GlobalIndexIOMeta> files =
+                writeData(
+                        Arrays.asList(
+                                str("needle-0"),
+                                str("other-1"),
+                                str("other-2"),
+                                str("needle-3"),
+                                str("other-4"),
+                                str("other-5"),
+                                str("needle-6")),
+                        0);
+        assertThat(files).hasSize(1);
+        
assertThat(FMIndexFile.readIndexMeta(files.get(0).metadata()).partitions).hasSize(3);
+        AtomicInteger opens = new AtomicInteger();
+        GlobalIndexFileReader countingReader =
+                meta -> {
+                    opens.incrementAndGet();
+                    return fileIO.newInputStream(meta.filePath());
+                };
+        CountingDirectExecutor executor = new CountingDirectExecutor();
+        try (GlobalIndexReader reader =
+                indexer.createReader(countingReader, files, 7, ranges, 
executor)) {
+            assertThat(opens).hasValue(0);
+            assertThat(executor.submittedTasks).hasValue(0);
+            assertRows(reader.visitContains(fieldRef, str("needle")).join(), 
expectedRows);
+            if (expectedPartitions == 0) {
+                assertRows(reader.visitIsNull(fieldRef).join());
+                assertRows(reader.visitIsNotNull(fieldRef).join());
+            }
+        }
+        assertThat(executor.submittedTasks).hasValue(expectedPartitions);
+        assertThat(opens).hasValue(expectedPartitions);
+    }
+
+    private static Stream<Arguments> partitionRowRanges() {
+        return Stream.of(
+                Arguments.of(null, 3, new long[] {0, 3, 6}),
+                Arguments.of(Collections.emptyList(), 0, new long[0]),
+                Arguments.of(Collections.singletonList(new Range(0, 1)), 1, 
new long[] {0}),
+                Arguments.of(Collections.singletonList(new Range(7, 9)), 0, 
new long[0]),
+                Arguments.of(Collections.singletonList(new Range(2, 3)), 2, 
new long[] {0, 3}),
+                Arguments.of(Arrays.asList(new Range(2, 2), new Range(0, 0)), 
1, new long[] {0}),
+                Arguments.of(Arrays.asList(new Range(0, 1), new Range(1, 2)), 
1, new long[] {0}),
+                Arguments.of(Collections.singletonList(new Range(6, 6)), 1, 
new long[] {6}),
+                // Boundary partitions retain matching rows outside the 
requested range.
+                Arguments.of(Collections.singletonList(new Range(2, 2)), 1, 
new long[] {0}),
+                Arguments.of(Collections.singletonList(new Range(3, 3)), 1, 
new long[] {3}),
+                Arguments.of(
+                        Arrays.asList(new Range(6, 6), new Range(0, 0)), 2, 
new long[] {0, 6}));
+    }
+
+    @Test
+    public void testPartitionPruningAcrossFilesPreservesRowIds() throws 
Exception {
+        List<GlobalIndexIOMeta> files = new ArrayList<>();
+        files.addAll(writeData(Arrays.asList(str("needle-0"), null, 
str("other-2")), 0));
+        files.addAll(writeData(Arrays.asList(str("needle-3"), null, 
str("needle-5")), 3));
+        assertThat(files).hasSize(2);
+        AtomicInteger opens = new AtomicInteger();
+        GlobalIndexFileReader countingReader =
+                meta -> {
+                    
assertThat(meta.filePath()).isEqualTo(files.get(1).filePath());
+                    opens.incrementAndGet();
+                    return fileIO.newInputStream(meta.filePath());
+                };
+        CountingDirectExecutor executor = new CountingDirectExecutor();
+        try (GlobalIndexReader reader =
+                indexer.createReader(
+                        countingReader,
+                        files,
+                        6,
+                        Collections.singletonList(new Range(4, 4)),
+                        executor)) {
+            assertRows(reader.visitContains(fieldRef, str("needle")).join(), 
3L, 5L);
+            assertRows(reader.visitIsNull(fieldRef).join(), 4L);
+        }
+        assertThat(executor.submittedTasks).hasValue(2);
+        assertThat(opens).hasValue(2);
+    }
+
+    @Test
+    public void testPrunedFileMetadataIsStillValidated() throws Exception {
+        GlobalIndexIOMeta first = writeData(Arrays.asList(str("first"), null), 
0).get(0);
+        GlobalIndexIOMeta second = writeData(Arrays.asList(str("second"), 
null), 2).get(0);
+        GlobalIndexIOMeta overlapping = 
writeData(Arrays.asList(str("overlap"), null), 1).get(0);
+        byte[] metadata = Arrays.copyOf(second.metadata(), 
second.metadata().length);
+        metadata[metadata.length - 1] ^= 1;
+        GlobalIndexIOMeta corrupted =
+                new GlobalIndexIOMeta(second.filePath(), second.fileSize(), 
metadata);
+        GlobalIndexFileReader noIO =
+                meta -> {
+                    throw new AssertionError("Unexpected I/O");
+                };
+        for (List<Range> ranges :
+                Arrays.asList(
+                        Collections.<Range>emptyList(),
+                        Collections.singletonList(new Range(0, 0)),
+                        Collections.singletonList(new Range(4, 4)))) {
+            assertThatThrownBy(
+                            () ->
+                                    indexer.createReader(
+                                            noIO,
+                                            Arrays.asList(first, corrupted),
+                                            4,
+                                            ranges,
+                                            newDirectExecutorService()))
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("metadata checksum mismatch");
+            assertThatThrownBy(
+                            () ->
+                                    indexer.createReader(
+                                            noIO,
+                                            Arrays.asList(first, overlapping),
+                                            4,
+                                            ranges,
+                                            newDirectExecutorService()))
+                    .isInstanceOf(IllegalStateException.class)
+                    .hasMessageContaining("do not exactly cover source rows");
+        }
+    }
+
+    @Test
+    public void testMetadataValidationWithEmptyRanges() throws Exception {
+        List<GlobalIndexIOMeta> files = 
writeData(Collections.singletonList(str("needle")), 0);
+        GlobalIndexIOMeta file = files.get(0);
+        byte[] metadata = Arrays.copyOf(file.metadata(), 
file.metadata().length);
+        metadata[metadata.length - 1] ^= 1;
+        GlobalIndexFileReader noIO =
+                meta -> {
+                    throw new AssertionError("Unexpected I/O");
+                };
+        assertThatThrownBy(
+                        () ->
+                                indexer.createReader(
+                                        noIO,
+                                        Collections.singletonList(
+                                                new GlobalIndexIOMeta(
+                                                        file.filePath(),
+                                                        file.fileSize(),
+                                                        metadata)),
+                                        1,
+                                        Collections.emptyList(),
+                                        newDirectExecutorService()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("metadata checksum mismatch");
+        assertThatThrownBy(
+                        () ->
+                                indexer.createReader(
+                                        noIO,
+                                        files,
+                                        2,
+                                        Collections.emptyList(),
+                                        newDirectExecutorService()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("row count mismatch");
+    }
+
     @Test
     public void testExactContainsForShortUtf8AndRepeatedOccurrences() throws 
Exception {
         List<GlobalIndexIOMeta> files =
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/IndexQuerySplitTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/IndexQuerySplitTest.java
index a7ca6b9eb2..040519cfe9 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/IndexQuerySplitTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/IndexQuerySplitTest.java
@@ -903,6 +903,106 @@ public class IndexQuerySplitTest extends 
DataEvolutionTestBase {
         return table.copy(options);
     }
 
+    @Test
+    public void testFMPartitionPruningAfterSplitRestore() throws Exception {
+        write(100);
+        appendRows(100, 101);
+        appendRows(101, 104);
+        appendRows(104, 106);
+        FileStoreTable original = smallSplits(getTableDefault());
+        GlobalIndexFileReadWrite io =
+                new GlobalIndexFileReadWrite(
+                        original.fileIO(), 
original.store().pathFactory().globalIndexFileFactory());
+        Options indexOptions = new Options();
+        indexOptions.set(FMGlobalIndexOptions.PARTITION_ROW_COUNT, 2);
+        indexOptions.set(FMGlobalIndexOptions.SA_SAMPLE_RATE, 1);
+        GlobalIndexSingleColumnWriter writer =
+                (GlobalIndexSingleColumnWriter)
+                        GlobalIndexer.create("fm", 
original.rowType().getField("f1"), indexOptions)
+                                .createWriter(io);
+        for (int i = 0; i < 6; i++) {
+            writer.write(str("a" + (100 + i)), i);
+        }
+        List<IndexFileMeta> indexes = new ArrayList<>();
+        for (ResultEntry entry : writer.finish()) {
+            indexes.add(
+                    new IndexFileMeta(
+                            "fm",
+                            entry.fileName(),
+                            io.fileSize(entry.fileName()),
+                            entry.rowCount(),
+                            new GlobalIndexMeta(
+                                    100,
+                                    105,
+                                    original.rowType().getField("f1").id(),
+                                    null,
+                                    entry.meta()),
+                            null));
+        }
+        assertThat(indexes).hasSize(1);
+        try (BatchTableCommit commit = 
original.newBatchWriteBuilder().newCommit()) {
+            commit.commit(
+                    Collections.singletonList(
+                            new CommitMessageImpl(
+                                    BinaryRow.EMPTY_ROW,
+                                    BucketMode.UNAWARE_BUCKET,
+                                    null,
+                                    DataIncrement.indexIncrement(indexes),
+                                    CompactIncrement.emptyIncrement())));
+        }
+        Path indexPath =
+                
original.store().pathFactory().globalIndexFileFactory().toPath(indexes.get(0));
+        AtomicInteger opens = new AtomicInteger();
+        FileIO fileIO = spy(original.fileIO());
+        doAnswer(
+                        invocation -> {
+                            if (indexPath.equals(invocation.getArgument(0))) {
+                                opens.incrementAndGet();
+                            }
+                            return invocation.callRealMethod();
+                        })
+                .when(fileIO)
+                .newInputStream(any(Path.class));
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(), "fast");
+        options.put(FMGlobalIndexOptions.LOCATE_COST_RATIO.key(), "1");
+        FileStoreTable table =
+                distributedTable(
+                        smallSplits(
+                                        new AppendOnlyFileStoreTable(
+                                                fileIO, original.location(), 
original.schema()))
+                                .copy(options));
+        ReadBuilder read =
+                table.newReadBuilder()
+                        .withFilter(new 
PredicateBuilder(table.rowType()).contains(1, str("a10")));
+        List<Split> splits = read.newScan().plan().splits();
+        
assertThat(splits).hasSize(3).allMatch(IndexQuerySplit.class::isInstance);
+        assertThat(opens).hasValue(0);
+        List<Split> restoredSplits = new ArrayList<>();
+        for (Split split : splits) {
+            IndexQuerySplit restored =
+                    (IndexQuerySplit) 
SplitSerializer.deserialize(SplitSerializer.serialize(split));
+            assertThat(restored).isEqualTo(split);
+            IndexQuerySplit javaRestored =
+                    InstantiationUtil.deserializeObject(
+                            InstantiationUtil.serializeObject(split), 
getClass().getClassLoader());
+            assertThat(javaRestored).isEqualTo(split);
+            long firstRowId = 
restored.dataSplit().dataFiles().get(0).nonNullRowIdRange().from;
+            long lastRowId = 
restored.dataSplit().dataFiles().get(0).nonNullRowIdRange().to;
+            for (IndexQuerySplit transported : Arrays.asList(restored, 
javaRestored)) {
+                opens.set(0);
+                IndexedSplit evaluated = transported.evaluate(fileIO);
+                assertThat(evaluated.rowRanges()).containsExactly(new 
Range(firstRowId, lastRowId));
+                assertThat(opens).hasValue(firstRowId == 101 ? 2 : 1);
+            }
+            restoredSplits.add(restored);
+        }
+        assertThat(read(read, restoredSplits)).containsExactly(100, 101, 102, 
103, 104, 105);
+        original.fileIO().delete(indexPath, false);
+        assertThatThrownBy(() -> ((IndexQuerySplit) 
restoredSplits.get(0)).evaluate(fileIO))
+                .isInstanceOf(IOException.class);
+    }
+
     @Test
     public void testFMFallbackAndMixedDistributedIndex() throws Exception {
         write(100);

Reply via email to