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 d41ec7357f [core] Read primary-key vector results by position (#8576)
d41ec7357f is described below

commit d41ec7357f0c4de9f6ee5c83ee35b4be3c72b4b3
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Jul 12 20:43:32 2026 +0800

    [core] Read primary-key vector results by position (#8576)
    
    Adds the physical-row materialization path for primary-key vector search
    results. Bucket-local ANN and exact hits can now reuse `IndexedSplit`
    for distributed transport and be read from their source data files while
    preserving physical positions and scores.
---
 .../apache/paimon/globalindex/IndexedSplit.java    |   8 +-
 .../operation/PrimaryKeyIndexedSplitRead.java      | 121 +++++++++++++
 .../apache/paimon/operation/RawFileSplitRead.java  |  88 ++++++---
 .../paimon/table/source/KeyValueTableRead.java     |   2 +
 .../source/PrimaryKeyVectorPositionReader.java     | 106 +++++++++++
 .../PrimaryKeyIndexedSplitReadProvider.java        |  55 ++++++
 .../operation/PrimaryKeyIndexedSplitReadTest.java  |  98 ++++++++++
 .../source/PrimaryKeyVectorPositionReaderTest.java | 197 +++++++++++++++++++++
 .../PrimaryKeyVectorRawFileSplitReadTest.java      | 108 +++++++++++
 9 files changed, 762 insertions(+), 21 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplit.java 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplit.java
index d9b6fef28c..49802d5d4c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/IndexedSplit.java
@@ -37,7 +37,13 @@ import java.util.List;
 import java.util.Objects;
 import java.util.OptionalLong;
 
-/** Indexed split for global index. */
+/**
+ * Indexed split for global index.
+ *
+ * <p>Row ranges use the coordinate system of the table read path. Data 
Evolution reads them as
+ * stable row IDs, while primary-key raw reads use them as physical positions 
in the split's single
+ * data file. Scores, when present, follow the expanded row-range order.
+ */
 public class IndexedSplit implements Split {
 
     private static final long serialVersionUID = 1L;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
new file mode 100644
index 0000000000..bb8d82e32c
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitRead.java
@@ -0,0 +1,121 @@
+/*
+ * 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.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyVectorPositionReader;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.IntFunction;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Reads an {@link IndexedSplit} as physical positions in one primary-key 
data file. */
+public class PrimaryKeyIndexedSplitRead implements SplitRead<InternalRow> {
+
+    private final RawFileSplitRead rawRead;
+
+    public PrimaryKeyIndexedSplitRead(RawFileSplitRead rawRead) {
+        this.rawRead = rawRead;
+    }
+
+    @Override
+    public SplitRead<InternalRow> forceKeepDelete() {
+        rawRead.forceKeepDelete();
+        return this;
+    }
+
+    @Override
+    public SplitRead<InternalRow> withIOManager(@Nullable IOManager ioManager) 
{
+        rawRead.withIOManager(ioManager);
+        return this;
+    }
+
+    @Override
+    public SplitRead<InternalRow> withReadType(RowType readType) {
+        rawRead.withReadType(readType);
+        return this;
+    }
+
+    @Override
+    public SplitRead<InternalRow> withFilter(@Nullable Predicate predicate) {
+        rawRead.withFilter(predicate);
+        return this;
+    }
+
+    @Override
+    public RecordReader<InternalRow> createReader(Split split) throws 
IOException {
+        IndexedSplit indexedSplit = (IndexedSplit) split;
+        DataSplit dataSplit = indexedSplit.dataSplit();
+        checkArgument(
+                dataSplit.dataFiles().size() == 1,
+                "Indexed split for a primary-key table must contain exactly 
one data file.");
+        DataFileMeta dataFile = dataSplit.dataFiles().get(0);
+        RoaringBitmap32 rowPositions = new RoaringBitmap32();
+        float[] scores = indexedSplit.scores();
+        Map<Integer, Float> scoreByPosition = scores == null ? null : new 
HashMap<>();
+        int scoreIndex = 0;
+        for (Range range : indexedSplit.rowRanges()) {
+            checkArgument(
+                    range.from >= 0 && range.to < dataFile.rowCount(),
+                    "Indexed row range %s is outside data file %s row count 
%s.",
+                    range,
+                    dataFile.fileName(),
+                    dataFile.rowCount());
+            checkArgument(
+                    range.to <= Integer.MAX_VALUE,
+                    "Indexed row range %s exceeds supported physical 
positions.",
+                    range);
+            for (long position = range.from; position <= range.to; position++) 
{
+                rowPositions.add((int) position);
+                if (scoreByPosition != null) {
+                    checkArgument(
+                            scoreIndex < scores.length,
+                            "Scores length does not match row ranges in 
indexed split.");
+                    scoreByPosition.put((int) position, scores[scoreIndex++]);
+                }
+            }
+        }
+        checkArgument(!rowPositions.isEmpty(), "Indexed split must select at 
least one row.");
+        if (scores != null) {
+            checkArgument(
+                    scoreIndex == scores.length,
+                    "Scores length does not match row ranges in indexed 
split.");
+        }
+        IntFunction<Float> scoreGetter =
+                scoreByPosition == null ? position -> Float.NaN : 
scoreByPosition::get;
+        FileRecordReader<InternalRow> reader = 
rawRead.createFileReader(dataSplit, rowPositions);
+        return new PrimaryKeyVectorPositionReader(reader, rowPositions, 
scoreGetter);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
index 464d9ea0e5..56680aeb83 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java
@@ -194,22 +194,7 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
                 pathFactory.createDataFilePathFactory(partition, bucket);
         List<ReaderSupplier<InternalRow>> suppliers = new ArrayList<>();
 
-        Builder formatReaderMappingBuilder =
-                new Builder(
-                        formatDiscover,
-                        readRowType.getFields(),
-                        schema -> {
-                            if (rowTrackingEnabled) {
-                                // maybe file has no row id and sequence 
number, but in manifest
-                                // entry
-                                return 
rowTypeWithRowTracking(schema.logicalRowType(), true, true)
-                                        .getFields();
-                            }
-                            return schema.fields();
-                        },
-                        filters,
-                        topN,
-                        limit);
+        Builder formatReaderMappingBuilder = 
createFormatReaderMappingBuilder();
 
         for (DataFileMeta file : files) {
             suppliers.add(
@@ -218,18 +203,66 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
                             dataFilePathFactory,
                             file,
                             formatReaderMappingBuilder,
-                            dvFactories));
+                            dvFactories,
+                            null));
         }
 
         return ConcatRecordReader.create(suppliers);
     }
 
+    FileRecordReader<InternalRow> createFileReader(
+            DataSplit dataSplit, RoaringBitmap32 selectedPositions) throws 
IOException {
+        DataFileMeta dataFile = dataSplit.dataFiles().get(0);
+        DeletionVector.Factory dvFactory =
+                DeletionVector.factory(
+                        fileIO, dataSplit.dataFiles(), 
dataSplit.deletionFiles().orElse(null));
+        Map<String, IOExceptionSupplier<DeletionVector>> dvFactories = new 
HashMap<>();
+        dvFactories.put(
+                dataFile.fileName(), () -> 
dvFactory.create(dataFile.fileName()).orElse(null));
+        DataFilePathFactory dataFilePathFactory =
+                pathFactory.createDataFilePathFactory(dataSplit.partition(), 
dataSplit.bucket());
+        return (FileRecordReader<InternalRow>)
+                createFileReader(
+                                dataSplit.partition(),
+                                dataFilePathFactory,
+                                dataFile,
+                                // The caller has already selected the rows. 
Applying a regular
+                                // TopN or limit before position filtering can 
drop hits.
+                                createFormatReaderMappingBuilder(null, null),
+                                dvFactories,
+                                selectedPositions)
+                        .get();
+    }
+
+    private Builder createFormatReaderMappingBuilder() {
+        return createFormatReaderMappingBuilder(topN, limit);
+    }
+
+    private Builder createFormatReaderMappingBuilder(
+            @Nullable TopN pushDownTopN, @Nullable Integer pushDownLimit) {
+        return new Builder(
+                formatDiscover,
+                readRowType.getFields(),
+                schema -> {
+                    if (rowTrackingEnabled) {
+                        // maybe file has no row id and sequence number, but 
in manifest entry
+                        return rowTypeWithRowTracking(schema.logicalRowType(), 
true, true)
+                                .getFields();
+                    }
+                    return schema.fields();
+                },
+                filters,
+                pushDownTopN,
+                pushDownLimit);
+    }
+
     private ReaderSupplier<InternalRow> createFileReader(
             BinaryRow partition,
             DataFilePathFactory dataFilePathFactory,
             DataFileMeta file,
             Builder formatBuilder,
-            @Nullable Map<String, IOExceptionSupplier<DeletionVector>> 
dvFactories) {
+            @Nullable Map<String, IOExceptionSupplier<DeletionVector>> 
dvFactories,
+            @Nullable RoaringBitmap32 selectedPositions) {
         String formatIdentifier = 
DataFilePathFactory.formatIdentifier(file.fileName());
         long schemaId = file.schemaId();
 
@@ -248,7 +281,12 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
                 dvFactories == null ? null : dvFactories.get(file.fileName());
         return () ->
                 createFileReader(
-                        partition, file, dataFilePathFactory, 
formatReaderMapping, dvFactory);
+                        partition,
+                        file,
+                        dataFilePathFactory,
+                        formatReaderMapping,
+                        dvFactory,
+                        selectedPositions);
     }
 
     private FileRecordReader<InternalRow> createFileReader(
@@ -256,7 +294,8 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
             DataFileMeta file,
             DataFilePathFactory dataFilePathFactory,
             FormatReaderMapping formatReaderMapping,
-            IOExceptionSupplier<DeletionVector> dvFactory)
+            IOExceptionSupplier<DeletionVector> dvFactory,
+            @Nullable RoaringBitmap32 selectedPositions)
             throws IOException {
         FileIndexResult fileIndexResult = null;
         DeletionVector deletionVector = dvFactory == null ? null : 
dvFactory.get();
@@ -280,6 +319,15 @@ public class RawFileSplitRead implements 
SplitRead<InternalRow> {
         if (fileIndexResult instanceof BitmapIndexResult) {
             selection = ((BitmapIndexResult) fileIndexResult).get();
         }
+        if (selectedPositions != null) {
+            selection =
+                    selection == null
+                            ? selectedPositions.clone()
+                            : RoaringBitmap32.and(selection, 
selectedPositions);
+            if (selection.isEmpty()) {
+                return new EmptyFileRecordReader<>();
+            }
+        }
 
         FormatReaderContext formatReaderContext =
                 new FormatReaderContext(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
index fda7d70ffd..48b08df0c1 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/KeyValueTableRead.java
@@ -33,6 +33,7 @@ import org.apache.paimon.schema.TableSchema;
 import 
org.apache.paimon.table.source.splitread.IncrementalChangelogReadProvider;
 import org.apache.paimon.table.source.splitread.IncrementalDiffReadProvider;
 import org.apache.paimon.table.source.splitread.MergeFileSplitReadProvider;
+import 
org.apache.paimon.table.source.splitread.PrimaryKeyIndexedSplitReadProvider;
 import 
org.apache.paimon.table.source.splitread.PrimaryKeyTableRawFileSplitReadProvider;
 import org.apache.paimon.table.source.splitread.SplitReadProvider;
 import org.apache.paimon.types.RowType;
@@ -67,6 +68,7 @@ public final class KeyValueTableRead extends 
AbstractDataTableRead {
         super(schema);
         this.readProviders =
                 Arrays.asList(
+                        new 
PrimaryKeyIndexedSplitReadProvider(batchRawReadSupplier, this::config),
                         new PrimaryKeyTableRawFileSplitReadProvider(
                                 batchRawReadSupplier, this::config),
                         new MergeFileSplitReadProvider(mergeReadSupplier, 
this::config),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
new file mode 100644
index 0000000000..6470fc26eb
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReader.java
@@ -0,0 +1,106 @@
+/*
+ * 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.source;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.function.IntFunction;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Reads selected physical file positions and exposes their vector-search 
scores. */
+public class PrimaryKeyVectorPositionReader implements 
RecordReader<InternalRow> {
+
+    private final FileRecordReader<InternalRow> reader;
+    private final RoaringBitmap32 rowPositions;
+    private final IntFunction<Float> scoreGetter;
+    private final int lastPosition;
+    private boolean exhausted;
+
+    public PrimaryKeyVectorPositionReader(
+            FileRecordReader<InternalRow> reader,
+            RoaringBitmap32 rowPositions,
+            IntFunction<Float> scoreGetter) {
+        checkArgument(!rowPositions.isEmpty(), "Selected row positions must 
not be empty.");
+        this.reader = reader;
+        this.rowPositions = rowPositions.clone();
+        this.scoreGetter = scoreGetter;
+        this.lastPosition = rowPositions.last();
+    }
+
+    @Nullable
+    @Override
+    public ScoreRecordIterator<InternalRow> readBatch() throws IOException {
+        if (exhausted) {
+            return null;
+        }
+        FileRecordIterator<InternalRow> batch = reader.readBatch();
+        if (batch == null) {
+            return null;
+        }
+        FileRecordIterator<InternalRow> selected = 
batch.selection(rowPositions);
+        return new ScoreRecordIterator<InternalRow>() {
+
+            private long returnedPosition = -1;
+            private float returnedScore = Float.NaN;
+
+            @Override
+            public float returnedScore() {
+                return returnedScore;
+            }
+
+            @Override
+            public long returnedRowId() {
+                return returnedPosition;
+            }
+
+            @Nullable
+            @Override
+            public InternalRow next() throws IOException {
+                InternalRow row = selected.next();
+                if (row != null) {
+                    returnedPosition = selected.returnedPosition();
+                    returnedScore = scoreGetter.apply((int) returnedPosition);
+                    if (returnedPosition >= lastPosition) {
+                        exhausted = true;
+                    }
+                }
+                return row;
+            }
+
+            @Override
+            public void releaseBatch() {
+                selected.releaseBatch();
+            }
+        };
+    }
+
+    @Override
+    public void close() throws IOException {
+        reader.close();
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/splitread/PrimaryKeyIndexedSplitReadProvider.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/splitread/PrimaryKeyIndexedSplitReadProvider.java
new file mode 100644
index 0000000000..c96e995a34
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/splitread/PrimaryKeyIndexedSplitReadProvider.java
@@ -0,0 +1,55 @@
+/*
+ * 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.source.splitread;
+
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.operation.PrimaryKeyIndexedSplitRead;
+import org.apache.paimon.operation.RawFileSplitRead;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.utils.LazyField;
+
+import java.util.function.Supplier;
+
+/** Provides physical-position reads for primary-key {@link IndexedSplit}s. */
+public class PrimaryKeyIndexedSplitReadProvider implements SplitReadProvider {
+
+    private final LazyField<PrimaryKeyIndexedSplitRead> splitRead;
+
+    public PrimaryKeyIndexedSplitReadProvider(
+            Supplier<RawFileSplitRead> supplier, SplitReadConfig 
splitReadConfig) {
+        this.splitRead =
+                new LazyField<>(
+                        () -> {
+                            PrimaryKeyIndexedSplitRead read =
+                                    new 
PrimaryKeyIndexedSplitRead(supplier.get());
+                            splitReadConfig.config(read);
+                            return read;
+                        });
+    }
+
+    @Override
+    public boolean match(Split split, Context context) {
+        return !context.forceKeepDelete() && split instanceof IndexedSplit;
+    }
+
+    @Override
+    public LazyField<PrimaryKeyIndexedSplitRead> get() {
+        return splitRead;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
new file mode 100644
index 0000000000..3d4ef2413f
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/PrimaryKeyIndexedSplitReadTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.PrimaryKeyVectorPositionReader;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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 for {@link PrimaryKeyIndexedSplitRead}. */
+class PrimaryKeyIndexedSplitReadTest {
+
+    @Test
+    void testConvertsRangesToPhysicalPositionsAndDelegates() throws Exception {
+        DataSplit dataSplit = dataSplit();
+        IndexedSplit split =
+                new IndexedSplit(
+                        dataSplit,
+                        Arrays.asList(new Range(1, 1), new Range(3, 3)),
+                        new float[] {0.5F, 0.25F});
+        RawFileSplitRead rawRead = mock(RawFileSplitRead.class);
+        FileRecordReader<InternalRow> fileReader = 
mock(FileRecordReader.class);
+        when(rawRead.createFileReader(eq(dataSplit), 
any(RoaringBitmap32.class)))
+                .thenReturn(fileReader);
+
+        RecordReader<InternalRow> reader =
+                new PrimaryKeyIndexedSplitRead(rawRead).createReader(split);
+
+        assertThat(reader).isInstanceOf(PrimaryKeyVectorPositionReader.class);
+        ArgumentCaptor<RoaringBitmap32> positions = 
ArgumentCaptor.forClass(RoaringBitmap32.class);
+        verify(rawRead).createFileReader(eq(dataSplit), positions.capture());
+        assertThat(positions.getValue()).isEqualTo(RoaringBitmap32.bitmapOf(1, 
3));
+    }
+
+    private static DataSplit dataSplit() {
+        DataFileMeta dataFile =
+                DataFileMeta.forAppend(
+                        "data-file",
+                        100,
+                        5,
+                        SimpleStats.EMPTY_STATS,
+                        0,
+                        0,
+                        1,
+                        Collections.emptyList(),
+                        null,
+                        FileSource.APPEND,
+                        null,
+                        null,
+                        null,
+                        null);
+        return DataSplit.builder()
+                .withSnapshot(3)
+                .withPartition(BinaryRow.EMPTY_ROW)
+                .withBucket(0)
+                .withBucketPath("bucket-0")
+                .withTotalBuckets(1)
+                .withDataFiles(Collections.singletonList(dataFile))
+                .build();
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
new file mode 100644
index 0000000000..c99f1d0c41
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorPositionReaderTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.source;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.operation.MergeFileSplitRead;
+import org.apache.paimon.operation.RawFileSplitRead;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RoaringBitmap32;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Answers.RETURNS_SELF;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/** Tests physical row-position filtering with vector scores. */
+class PrimaryKeyVectorPositionReaderTest {
+
+    @Test
+    void testSelectsPhysicalPositionsAndReturnsScores() throws Exception {
+        PrimaryKeyVectorPositionReader reader =
+                new PrimaryKeyVectorPositionReader(
+                        new TestingFileReader(5),
+                        RoaringBitmap32.bitmapOf(1, 3),
+                        position -> position / 10F);
+
+        ScoreRecordIterator<InternalRow> batch = reader.readBatch();
+        assertThat(batch.next().getInt(0)).isEqualTo(1);
+        assertThat(batch.returnedRowId()).isEqualTo(1);
+        assertThat(batch.returnedScore()).isEqualTo(0.1F);
+        assertThat(batch.next().getInt(0)).isEqualTo(3);
+        assertThat(batch.returnedRowId()).isEqualTo(3);
+        assertThat(batch.returnedScore()).isEqualTo(0.3F);
+        assertThat(batch.next()).isNull();
+        assertThat(reader.readBatch()).isNull();
+    }
+
+    @Test
+    void testSelectedPositionsAcrossBatches() throws Exception {
+        PrimaryKeyVectorPositionReader reader =
+                new PrimaryKeyVectorPositionReader(
+                        new TestingFileReader(5, 2),
+                        RoaringBitmap32.bitmapOf(1, 3),
+                        position -> position / 10F);
+
+        ScoreRecordIterator<InternalRow> firstBatch = reader.readBatch();
+        assertThat(firstBatch.next().getInt(0)).isEqualTo(1);
+        assertThat(firstBatch.returnedRowId()).isEqualTo(1);
+        assertThat(firstBatch.next()).isNull();
+
+        ScoreRecordIterator<InternalRow> secondBatch = reader.readBatch();
+        assertThat(secondBatch.next().getInt(0)).isEqualTo(3);
+        assertThat(secondBatch.returnedScore()).isEqualTo(0.3F);
+        assertThat(secondBatch.next()).isNull();
+        assertThat(reader.readBatch()).isNull();
+    }
+
+    @Test
+    void testKeyValueTableReadRoutesPhysicalSplitToRawReader() throws 
Exception {
+        RawFileSplitRead rawRead = mock(RawFileSplitRead.class, RETURNS_SELF);
+        IndexedSplit split = selectedSplit();
+        KeyValueTableRead tableRead =
+                new KeyValueTableRead(
+                        () -> mock(MergeFileSplitRead.class),
+                        () -> rawRead,
+                        mock(TableSchema.class));
+
+        assertThat(tableRead.createReader(split))
+                .isInstanceOf(PrimaryKeyVectorPositionReader.class);
+        verify(rawRead, never()).createReader(split);
+    }
+
+    private static IndexedSplit selectedSplit() {
+        DataFileMeta dataFile =
+                DataFileMeta.forAppend(
+                        "data-file",
+                        100,
+                        5,
+                        SimpleStats.EMPTY_STATS,
+                        0,
+                        0,
+                        1,
+                        Collections.emptyList(),
+                        null,
+                        FileSource.APPEND,
+                        null,
+                        null,
+                        null,
+                        null);
+        DataSplit dataSplit =
+                DataSplit.builder()
+                        .withSnapshot(3)
+                        .withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(0)
+                        .withBucketPath("bucket-0")
+                        .withTotalBuckets(1)
+                        .withDataFiles(Collections.singletonList(dataFile))
+                        .build();
+        return new IndexedSplit(
+                dataSplit, Collections.singletonList(new Range(1, 1)), new 
float[] {0.5F});
+    }
+
+    private static class TestingFileReader implements 
FileRecordReader<InternalRow> {
+
+        private final int rowCount;
+        private final int batchSize;
+        private int nextPosition;
+
+        private TestingFileReader(int rowCount) {
+            this(rowCount, rowCount);
+        }
+
+        private TestingFileReader(int rowCount, int batchSize) {
+            this.rowCount = rowCount;
+            this.batchSize = batchSize;
+        }
+
+        @Override
+        public FileRecordIterator<InternalRow> readBatch() {
+            if (nextPosition == rowCount) {
+                return null;
+            }
+            int startPosition = nextPosition;
+            nextPosition = Math.min(rowCount, nextPosition + batchSize);
+            return new TestingFileIterator(startPosition, nextPosition);
+        }
+
+        @Override
+        public void close() {}
+    }
+
+    private static class TestingFileIterator implements 
FileRecordIterator<InternalRow> {
+
+        private final int endPosition;
+        private int nextPosition;
+        private int returnedPosition = -1;
+
+        private TestingFileIterator(int startPosition, int endPosition) {
+            this.nextPosition = startPosition;
+            this.endPosition = endPosition;
+        }
+
+        @Override
+        public long returnedPosition() {
+            return returnedPosition;
+        }
+
+        @Override
+        public Path filePath() {
+            return new Path("/test");
+        }
+
+        @Override
+        public InternalRow next() {
+            if (nextPosition == endPosition) {
+                return null;
+            }
+            returnedPosition = nextPosition;
+            return GenericRow.of(nextPosition++);
+        }
+
+        @Override
+        public void releaseBatch() {}
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorRawFileSplitReadTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorRawFileSplitReadTest.java
new file mode 100644
index 0000000000..73b75cfec5
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeyVectorRawFileSplitReadTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.source;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.reader.ScoreRecordIterator;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.SchemaUtils;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+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.types.DataTypes;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests raw-file materialization of primary-key vector results. */
+class PrimaryKeyVectorRawFileSplitReadTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @Test
+    void testRegularLimitDoesNotDropSelectedPhysicalPositions() throws 
Exception {
+        Path tablePath = new Path(tempDir.toUri());
+        Options options = new Options();
+        options.set(CoreOptions.PATH, tablePath.toString());
+        options.set(CoreOptions.BUCKET, 1);
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("value", DataTypes.INT())
+                        .primaryKey("id")
+                        .options(options.toMap())
+                        .build();
+        TableSchema tableSchema =
+                SchemaUtils.forceCommit(new 
SchemaManager(LocalFileIO.create(), tablePath), schema);
+        FileStoreTable table =
+                FileStoreTableFactory.create(LocalFileIO.create(), tablePath, 
tableSchema);
+
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite();
+                BatchTableCommit commit = writeBuilder.newCommit()) {
+            for (int i = 0; i < 5; i++) {
+                write.write(GenericRow.of(i, i * 10));
+            }
+            commit.commit(write.prepareCommit());
+        }
+
+        DataSplit dataSplit = 
table.newSnapshotReader().read().dataSplits().get(0);
+        assertThat(dataSplit.dataFiles()).hasSize(1);
+        IndexedSplit vectorSplit =
+                new IndexedSplit(
+                        dataSplit,
+                        Arrays.asList(new Range(1, 1), new Range(3, 3)),
+                        new float[] {0.5F, 0.25F});
+
+        try (RecordReader<InternalRow> reader =
+                table.newRead().withLimit(2).createReader(vectorSplit)) {
+            ScoreRecordIterator<InternalRow> batch =
+                    (ScoreRecordIterator<InternalRow>) reader.readBatch();
+            InternalRow row = batch.next();
+            assertThat(row.getInt(0)).isEqualTo(1);
+            assertThat(row.getInt(1)).isEqualTo(10);
+            assertThat(batch.returnedRowId()).isEqualTo(1);
+            assertThat(batch.returnedScore()).isEqualTo(0.5F);
+            row = batch.next();
+            assertThat(row.getInt(0)).isEqualTo(3);
+            assertThat(row.getInt(1)).isEqualTo(30);
+            assertThat(batch.returnedRowId()).isEqualTo(3);
+            assertThat(batch.returnedScore()).isEqualTo(0.25F);
+            assertThat(batch.next()).isNull();
+            batch.releaseBatch();
+            assertThat(reader.readBatch()).isNull();
+        }
+    }
+}


Reply via email to