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 c8a87e9cbd [core] Build source-backed sorted index payloads (#8600)
c8a87e9cbd is described below

commit c8a87e9cbdd948fd555d3ade6f7ae21b58b77c4d
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Jul 13 20:19:01 2026 +0800

    [core] Build source-backed sorted index payloads (#8600)
    
    Provide the reusable core primitives needed to build BTree and Bitmap
    global-index payloads from primary-key table data files.
---
 .../sorted/SortedGlobalIndexBuilder.java           |  24 +-
 .../sorted/SortedSingleColumnIndexWriter.java      |  87 ++++++
 .../index/pksorted/PkSortedDataFileReader.java     | 138 ++++++++++
 .../index/pksorted/PkSortedIndexBuilder.java       | 179 +++++++++++++
 .../paimon/index/pksorted/PkSortedIndexFile.java   | 202 ++++++++++++++
 .../paimon/index/pksorted/PkSortedIndexGroup.java  |  79 ++++++
 .../sorted/SortedGlobalIndexBuilderTest.java       |  36 +++
 .../index/pksorted/PkSortedDataFileReaderTest.java | 161 +++++++++++
 .../index/pksorted/PkSortedIndexBuilderTest.java   | 294 +++++++++++++++++++++
 .../index/pksorted/PkSortedIndexFileTest.java      | 186 +++++++++++++
 10 files changed, 1368 insertions(+), 18 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
index 6ee00b3bb8..6173c070f9 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
@@ -244,31 +244,19 @@ public class SortedGlobalIndexBuilder implements 
Serializable {
 
     public List<CommitMessage> buildForSinglePartition(
             Range rowRange, BinaryRow partition, Iterator<InternalRow> data) 
throws IOException {
-        long counter = 0;
-        GlobalIndexSingleColumnWriter currentWriter = null;
-        List<CommitMessage> commitMessages = new ArrayList<>();
         FieldGetter indexFieldGetter = 
InternalRow.createFieldGetter(indexField.type(), 0);
+        SortedSingleColumnIndexWriter writer =
+                new SortedSingleColumnIndexWriter(recordsPerRange, 
this::createWriter);
 
         while (data.hasNext()) {
             InternalRow row = data.next();
-
-            if (currentWriter != null && counter >= recordsPerRange) {
-                commitMessages.add(flushIndex(rowRange, 
currentWriter.finish(), partition));
-                currentWriter = null;
-                counter = 0;
-            }
-
-            counter++;
-            if (currentWriter == null) {
-                currentWriter = createWriter();
-            }
-
             long localRowId = row.getLong(1) - rowRange.from;
-            currentWriter.write(indexFieldGetter.getFieldOrNull(row), 
localRowId);
+            writer.write(indexFieldGetter.getFieldOrNull(row), localRowId);
         }
 
-        if (counter > 0) {
-            commitMessages.add(flushIndex(rowRange, currentWriter.finish(), 
partition));
+        List<CommitMessage> commitMessages = new ArrayList<>();
+        for (List<ResultEntry> resultEntries : writer.finish()) {
+            commitMessages.add(flushIndex(rowRange, resultEntries, partition));
         }
         return commitMessages;
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedSingleColumnIndexWriter.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedSingleColumnIndexWriter.java
new file mode 100644
index 0000000000..6bc37a0f7d
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedSingleColumnIndexWriter.java
@@ -0,0 +1,87 @@
+/*
+ * 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.globalindex.sorted;
+
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.ResultEntry;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** Rotates single-column index writers after a configured number of logical 
records. */
+public final class SortedSingleColumnIndexWriter {
+
+    private final long recordsPerRange;
+    private final Factory factory;
+    private final List<List<ResultEntry>> resultGroups = new ArrayList<>();
+
+    @Nullable private GlobalIndexSingleColumnWriter currentWriter;
+    private long currentRecordCount;
+    private boolean finished;
+
+    public SortedSingleColumnIndexWriter(long recordsPerRange, Factory 
factory) {
+        checkArgument(recordsPerRange > 0, "Records per range must be 
positive.");
+        this.recordsPerRange = recordsPerRange;
+        this.factory = factory;
+    }
+
+    public void write(@Nullable Object value, long localRowId) throws 
IOException {
+        checkState(!finished, "Cannot write after the sorted index writer is 
finished.");
+        if (currentWriter != null && currentRecordCount >= recordsPerRange) {
+            finishCurrentWriter();
+        }
+        if (currentWriter == null) {
+            currentWriter = factory.create();
+        }
+        currentWriter.write(value, localRowId);
+        currentRecordCount++;
+    }
+
+    public List<List<ResultEntry>> finish() {
+        checkState(!finished, "Sorted index writer is already finished.");
+        if (currentWriter != null) {
+            finishCurrentWriter();
+        }
+        finished = true;
+        List<List<ResultEntry>> copy = new ArrayList<>(resultGroups.size());
+        for (List<ResultEntry> group : resultGroups) {
+            copy.add(Collections.unmodifiableList(new ArrayList<>(group)));
+        }
+        return Collections.unmodifiableList(copy);
+    }
+
+    private void finishCurrentWriter() {
+        resultGroups.add(currentWriter.finish());
+        currentWriter = null;
+        currentRecordCount = 0;
+    }
+
+    /** Creates one underlying algorithm writer. */
+    @FunctionalInterface
+    public interface Factory {
+        GlobalIndexSingleColumnWriter create() throws IOException;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedDataFileReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedDataFileReader.java
new file mode 100644
index 0000000000..2819961ecc
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedDataFileReader.java
@@ -0,0 +1,138 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.reader.RecordReaderIterator;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Reads one scalar value from every physical record in a data file. */
+public final class PkSortedDataFileReader implements Closeable, 
PkSortedIndexBuilder.Reader {
+
+    private final DataFileMeta dataFile;
+    private final RecordReaderIterator<KeyValue> iterator;
+    private final InternalRow.FieldGetter fieldGetter;
+    private long rowsRead;
+
+    public PkSortedDataFileReader(
+            KeyValueFileReaderFactory readerFactory, DataFileMeta dataFile, 
DataField indexField)
+            throws IOException {
+        this.dataFile = dataFile;
+        this.iterator = new 
RecordReaderIterator<>(readerFactory.createRecordReader(dataFile));
+        this.fieldGetter = InternalRow.createFieldGetter(indexField.type(), 0);
+    }
+
+    public long rowCount() {
+        return dataFile.rowCount();
+    }
+
+    @Nullable
+    public Entry readNext() {
+        if (rowsRead == rowCount()) {
+            checkArgument(
+                    !iterator.hasNext(),
+                    "Data file %s contains more rows than declared.",
+                    dataFile.fileName());
+            return null;
+        }
+        checkArgument(
+                iterator.hasNext(),
+                "Data file %s ended before its declared row count.",
+                dataFile.fileName());
+        KeyValue keyValue = iterator.next();
+        long rowPosition = rowsRead++;
+        if (rowsRead == rowCount()) {
+            checkArgument(
+                    !iterator.hasNext(),
+                    "Data file %s contains more rows than declared.",
+                    dataFile.fileName());
+        }
+        return new Entry(fieldGetter.getFieldOrNull(keyValue.value()), 
rowPosition);
+    }
+
+    @Override
+    public void close() throws IOException {
+        try {
+            iterator.close();
+        } catch (IOException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new IOException(
+                    "Failed to close sorted index reader for " + 
dataFile.fileName(), e);
+        }
+    }
+
+    /** One projected value and its zero-based physical position. */
+    public static final class Entry {
+
+        @Nullable private final Object value;
+        private final long rowPosition;
+
+        public Entry(@Nullable Object value, long rowPosition) {
+            this.value = value;
+            this.rowPosition = rowPosition;
+        }
+
+        @Nullable
+        public Object value() {
+            return value;
+        }
+
+        public long rowPosition() {
+            return rowPosition;
+        }
+    }
+
+    /** Bucket-scoped factory with scalar-only projection and no 
deletion-vector filtering. */
+    public static final class Factory {
+
+        private final KeyValueFileReaderFactory readerFactory;
+        private final DataField indexField;
+
+        public Factory(
+                KeyValueFileReaderFactory.Builder readerFactoryBuilder,
+                BinaryRow partition,
+                int bucket,
+                DataField indexField) {
+            this.readerFactory =
+                    readerFactoryBuilder
+                            .copyWithoutProjection()
+                            .withReadKeyType(RowType.of())
+                            .withReadValueType(RowType.of(indexField))
+                            .buildWithoutDeletionVector(partition, bucket);
+            this.indexField = indexField;
+        }
+
+        public PkSortedDataFileReader create(DataFileMeta dataFile) throws 
IOException {
+            return new PkSortedDataFileReader(readerFactory, dataFile, 
indexField);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilder.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilder.java
new file mode 100644
index 0000000000..7a7604abe9
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilder.java
@@ -0,0 +1,179 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.sort.BinaryExternalSortBuffer;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.MutableObjectIteratorAdapter;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Sorts one physical data file and builds its source-backed scalar index 
payloads. */
+public class PkSortedIndexBuilder {
+
+    private static final int LOCAL_ROW_ID_FIELD_ID = Integer.MAX_VALUE;
+
+    private final ReaderFactory readerFactory;
+    private final PkSortedIndexFile indexFile;
+    private final DataField indexField;
+    private final String indexType;
+    private final Options options;
+    @Nullable private final IOManager ioManager;
+
+    public PkSortedIndexBuilder(
+            PkSortedDataFileReader.Factory readerFactory,
+            PkSortedIndexFile indexFile,
+            DataField indexField,
+            String indexType,
+            Options options,
+            @Nullable IOManager ioManager) {
+        this(readerFactory::create, indexFile, indexField, indexType, options, 
ioManager);
+    }
+
+    PkSortedIndexBuilder(
+            ReaderFactory readerFactory,
+            PkSortedIndexFile indexFile,
+            DataField indexField,
+            String indexType,
+            Options options,
+            @Nullable IOManager ioManager) {
+        this.readerFactory = readerFactory;
+        this.indexFile = indexFile;
+        this.indexField = indexField;
+        this.indexType = indexType;
+        this.options = options;
+        this.ioManager = ioManager;
+    }
+
+    public List<org.apache.paimon.index.IndexFileMeta> build(DataFileMeta 
dataFile)
+            throws IOException {
+        IOManager actualIOManager = ioManager;
+        boolean ownsIOManager = false;
+        if (actualIOManager == null) {
+            actualIOManager = createTemporaryIOManager();
+            ownsIOManager = true;
+        }
+
+        BinaryExternalSortBuffer sortBuffer = null;
+        try {
+            CoreOptions coreOptions = new CoreOptions(options);
+            RowType sortRowType =
+                    RowType.of(
+                            indexField,
+                            new DataField(
+                                    LOCAL_ROW_ID_FIELD_ID,
+                                    "_LOCAL_ROW_ID",
+                                    DataTypes.BIGINT().notNull()));
+            sortBuffer =
+                    BinaryExternalSortBuffer.create(
+                            actualIOManager,
+                            sortRowType,
+                            new int[] {0},
+                            coreOptions.writeBufferSize(),
+                            coreOptions.pageSize(),
+                            coreOptions.localSortMaxNumFileHandles(),
+                            coreOptions.spillCompressOptions(),
+                            coreOptions.writeBufferSpillDiskSize());
+
+            try (Reader reader = readerFactory.create(dataFile)) {
+                checkArgument(
+                        reader.rowCount() == dataFile.rowCount(),
+                        "Sorted reader row count %s does not match data file 
%s row count %s.",
+                        reader.rowCount(),
+                        dataFile.fileName(),
+                        dataFile.rowCount());
+                PkSortedDataFileReader.Entry entry;
+                while ((entry = reader.readNext()) != null) {
+                    sortBuffer.write(GenericRow.of(entry.value(), 
entry.rowPosition()));
+                }
+            }
+
+            Iterator<InternalRow> sortedRows =
+                    new MutableObjectIteratorAdapter<>(
+                            sortBuffer.sortedIterator(),
+                            new BinaryRow(sortRowType.getFieldCount()));
+            InternalRow.FieldGetter valueGetter =
+                    InternalRow.createFieldGetter(indexField.type(), 0);
+            Iterator<PkSortedIndexFile.Entry> sortedEntries =
+                    new Iterator<PkSortedIndexFile.Entry>() {
+                        @Override
+                        public boolean hasNext() {
+                            return sortedRows.hasNext();
+                        }
+
+                        @Override
+                        public PkSortedIndexFile.Entry next() {
+                            InternalRow row = sortedRows.next();
+                            return new PkSortedIndexFile.Entry(
+                                    valueGetter.getFieldOrNull(row), 
row.getLong(1));
+                        }
+                    };
+            return indexFile.build(
+                    new PrimaryKeyIndexSourceFile(dataFile.fileName(), 
dataFile.rowCount()),
+                    indexField,
+                    indexType,
+                    options,
+                    sortedEntries);
+        } finally {
+            if (sortBuffer != null) {
+                sortBuffer.clear();
+            }
+            if (ownsIOManager) {
+                IOUtils.closeQuietly(actualIOManager);
+            }
+        }
+    }
+
+    protected IOManager createTemporaryIOManager() {
+        return IOManager.create(System.getProperty("java.io.tmpdir"));
+    }
+
+    /** Physical source reader used by the sorter. */
+    interface Reader extends Closeable {
+
+        long rowCount();
+
+        @Nullable
+        PkSortedDataFileReader.Entry readNext();
+    }
+
+    @FunctionalInterface
+    interface ReaderFactory {
+
+        Reader create(DataFileMeta dataFile) throws IOException;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexFile.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexFile.java
new file mode 100644
index 0000000000..f6ab090e48
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexFile.java
@@ -0,0 +1,202 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.globalindex.sorted.SortedIndexOptions;
+import org.apache.paimon.globalindex.sorted.SortedSingleColumnIndexWriter;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFile;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Builds source-backed BTree or Bitmap payloads for one physical data file. 
*/
+public class PkSortedIndexFile extends IndexFile {
+
+    public PkSortedIndexFile(FileIO fileIO, IndexPathFactory pathFactory) {
+        super(fileIO, pathFactory);
+    }
+
+    public List<IndexFileMeta> build(
+            PrimaryKeyIndexSourceFile sourceFile,
+            DataField indexField,
+            String indexType,
+            Options indexOptions,
+            Iterator<Entry> sortedEntries)
+            throws IOException {
+        checkArgument(
+                sourceFile.rowCount() > 0,
+                "A sorted index group must reference at least one source 
row.");
+
+        TrackingFileWriter fileWriter = new TrackingFileWriter();
+        boolean success = false;
+        try {
+            long recordsPerRange =
+                    
indexOptions.get(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE);
+            SortedSingleColumnIndexWriter writer =
+                    new SortedSingleColumnIndexWriter(
+                            recordsPerRange,
+                            () -> createWriter(indexType, indexField, 
indexOptions, fileWriter));
+
+            long writtenRows = 0;
+            while (sortedEntries.hasNext()) {
+                Entry entry = sortedEntries.next();
+                checkArgument(
+                        entry.localRowId >= 0 && entry.localRowId < 
sourceFile.rowCount(),
+                        "Local row id %s is outside source file %s row range 
[0, %s).",
+                        entry.localRowId,
+                        sourceFile.fileName(),
+                        sourceFile.rowCount());
+                writer.write(entry.value, entry.localRowId);
+                writtenRows++;
+            }
+            checkArgument(
+                    writtenRows == sourceFile.rowCount(),
+                    "Sorted index input row count %s does not match source 
file %s row count %s.",
+                    writtenRows,
+                    sourceFile.fileName(),
+                    sourceFile.rowCount());
+
+            List<List<ResultEntry>> resultGroups = writer.finish();
+            List<IndexFileMeta> payloads = new ArrayList<>();
+            long payloadRows = 0;
+            byte[] sourceMeta = new 
PrimaryKeyIndexSourceMeta(sourceFile).serialize();
+            for (List<ResultEntry> resultGroup : resultGroups) {
+                for (ResultEntry result : resultGroup) {
+                    payloadRows = Math.addExact(payloadRows, 
result.rowCount());
+                    Path payloadPath = fileWriter.path(result.fileName());
+                    payloads.add(
+                            new IndexFileMeta(
+                                    indexType,
+                                    result.fileName(),
+                                    fileIO.getFileSize(payloadPath),
+                                    result.rowCount(),
+                                    new GlobalIndexMeta(
+                                            0,
+                                            sourceFile.rowCount() - 1,
+                                            indexField.id(),
+                                            null,
+                                            result.meta(),
+                                            sourceMeta),
+                                    pathFactory.isExternalPath() ? 
payloadPath.toString() : null));
+                }
+            }
+            checkArgument(
+                    payloadRows == sourceFile.rowCount(),
+                    "Sorted payload row count %s does not match source file %s 
row count %s.",
+                    payloadRows,
+                    sourceFile.fileName(),
+                    sourceFile.rowCount());
+            success = true;
+            return Collections.unmodifiableList(payloads);
+        } finally {
+            if (!success) {
+                fileWriter.deleteCreatedFiles();
+            }
+        }
+    }
+
+    protected GlobalIndexSingleColumnWriter createWriter(
+            String indexType,
+            DataField indexField,
+            Options indexOptions,
+            GlobalIndexFileWriter fileWriter)
+            throws IOException {
+        GlobalIndexer indexer = GlobalIndexer.create(indexType, indexField, 
indexOptions);
+        GlobalIndexWriter writer = indexer.createWriter(fileWriter);
+        checkArgument(
+                writer instanceof GlobalIndexSingleColumnWriter,
+                "Index algorithm %s does not create a single-column writer.",
+                indexType);
+        return (GlobalIndexSingleColumnWriter) writer;
+    }
+
+    /** One sorted scalar value and its zero-based position in the source data 
file. */
+    public static final class Entry {
+
+        @Nullable private final Object value;
+        private final long localRowId;
+
+        public Entry(@Nullable Object value, long localRowId) {
+            this.value = value;
+            this.localRowId = localRowId;
+        }
+
+        @Nullable
+        public Object value() {
+            return value;
+        }
+
+        public long localRowId() {
+            return localRowId;
+        }
+    }
+
+    private final class TrackingFileWriter implements GlobalIndexFileWriter {
+
+        private final Map<String, Path> createdFiles = new LinkedHashMap<>();
+
+        @Override
+        public String newFileName(String prefix) {
+            Path path = pathFactory.newPath();
+            createdFiles.put(path.getName(), path);
+            return path.getName();
+        }
+
+        @Override
+        public PositionOutputStream newOutputStream(String fileName) throws 
IOException {
+            return fileIO.newOutputStream(path(fileName), false);
+        }
+
+        private Path path(String fileName) {
+            Path path = createdFiles.get(fileName);
+            checkArgument(path != null, "Sorted payload file %s was not 
allocated.", fileName);
+            return path;
+        }
+
+        private void deleteCreatedFiles() {
+            for (Path path : createdFiles.values()) {
+                fileIO.deleteQuietly(path);
+            }
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
new file mode 100644
index 0000000000..46f161eadb
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
@@ -0,0 +1,79 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/** All rotated payloads that index one physical source data file. */
+public final class PkSortedIndexGroup {
+
+    private final PrimaryKeyIndexSourceFile sourceFile;
+    private final List<IndexFileMeta> payloads;
+
+    PkSortedIndexGroup(PrimaryKeyIndexSourceFile sourceFile, 
List<IndexFileMeta> payloads) {
+        this.sourceFile = sourceFile;
+        this.payloads = Collections.unmodifiableList(new 
ArrayList<>(payloads));
+    }
+
+    static Optional<PkSortedIndexGroup> create(
+            int fieldId,
+            String indexType,
+            PrimaryKeyIndexSourceFile sourceFile,
+            List<IndexFileMeta> payloads) {
+        long payloadRowCount = 0;
+        Set<String> payloadNames = new HashSet<>();
+        for (IndexFileMeta payload : payloads) {
+            GlobalIndexMeta meta = payload.globalIndexMeta();
+            PrimaryKeyIndexSourceFile payloadSource =
+                    
PrimaryKeyIndexSourceMeta.fromIndexFile(payload).sourceFile();
+            if (!payloadNames.add(payload.fileName())
+                    || !sourceFile.equals(payloadSource)
+                    || !indexType.equals(payload.indexType())
+                    || meta == null
+                    || meta.indexFieldId() != fieldId
+                    || meta.rowRangeStart() != 0
+                    || meta.rowRangeEnd() != sourceFile.rowCount() - 1) {
+                return Optional.empty();
+            }
+            payloadRowCount += payload.rowCount();
+        }
+        if (payloadRowCount != sourceFile.rowCount()) {
+            return Optional.empty();
+        }
+        return Optional.of(new PkSortedIndexGroup(sourceFile, payloads));
+    }
+
+    public PrimaryKeyIndexSourceFile sourceFile() {
+        return sourceFile;
+    }
+
+    public List<IndexFileMeta> payloads() {
+        return payloads;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderTest.java
index 7e0d18c2c9..cb3e55d1a2 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderTest.java
@@ -24,7 +24,9 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.BlobData;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
 import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileHandler;
@@ -50,13 +52,21 @@ import org.apache.paimon.utils.Pair;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Queue;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /** Test class for {@link SortedGlobalIndexBuilder}. */
 public class SortedGlobalIndexBuilderTest extends TableTestBase {
@@ -65,6 +75,32 @@ public class SortedGlobalIndexBuilderTest extends 
TableTestBase {
     private static final KeySerializer KEY_SERIALIZER = 
KeySerializer.create(DataTypes.INT());
     private static final Comparator<Object> COMPARATOR = 
KEY_SERIALIZER.createComparator();
 
+    @Test
+    public void testSingleColumnWriterRotationPreservesResultGroups() throws 
Exception {
+        GlobalIndexSingleColumnWriter first = 
mock(GlobalIndexSingleColumnWriter.class);
+        GlobalIndexSingleColumnWriter second = 
mock(GlobalIndexSingleColumnWriter.class);
+        when(first.finish())
+                .thenReturn(Collections.singletonList(new 
ResultEntry("index-1", 2, null)));
+        when(second.finish())
+                .thenReturn(Collections.singletonList(new 
ResultEntry("index-2", 1, null)));
+        Queue<GlobalIndexSingleColumnWriter> writers =
+                new ArrayDeque<>(Arrays.asList(first, second));
+        SortedSingleColumnIndexWriter rotatingWriter =
+                new SortedSingleColumnIndexWriter(2, writers::remove);
+
+        rotatingWriter.write(10, 0);
+        rotatingWriter.write(20, 1);
+        rotatingWriter.write(30, 2);
+        List<List<ResultEntry>> results = rotatingWriter.finish();
+
+        verify(first).write(10, 0);
+        verify(first).write(20, 1);
+        verify(second).write(30, 2);
+        assertThat(results).hasSize(2);
+        
assertThat(results.get(0)).extracting(ResultEntry::fileName).containsExactly("index-1");
+        
assertThat(results.get(1)).extracting(ResultEntry::fileName).containsExactly("index-2");
+    }
+
     @Override
     public Schema schemaDefault() {
         Schema.Builder schemaBuilder = Schema.newBuilder();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedDataFileReaderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedDataFileReaderTest.java
new file mode 100644
index 0000000000..424689c2e8
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedDataFileReaderTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.KeyValue;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.format.FlushingFileFormat;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.KeyValueFileReaderFactory;
+import org.apache.paimon.io.KeyValueFileWriterFactory;
+import org.apache.paimon.io.RollingFileWriter;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.KeyValueFieldsExtractor;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.options.MemorySize.VALUE_128_MB;
+import static 
org.apache.paimon.utils.FileStorePathFactoryTest.createNonPartFactory;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests reading scalar values with their physical positions. */
+class PkSortedDataFileReaderTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @Test
+    void testReadsNullWithItsPhysicalPosition() throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        DataField keyField = new DataField(0, "id", DataTypes.INT());
+        DataField payloadField = new DataField(1, "payload", 
DataTypes.STRING());
+        RowType keyType = RowType.of(keyField);
+        RowType valueType = RowType.of(payloadField);
+        List<DataField> fields = Arrays.asList(keyField, payloadField);
+        TableSchema schema =
+                new TableSchema(
+                        0,
+                        fields,
+                        1,
+                        Collections.emptyList(),
+                        Collections.singletonList("id"),
+                        Collections.emptyMap(),
+                        "");
+        SchemaManager schemaManager = new SchemaManager(fileIO, tablePath);
+        assertThat(schemaManager.commit(schema)).isTrue();
+        FileStorePathFactory pathFactory = createNonPartFactory(tablePath);
+        FlushingFileFormat format = new FlushingFileFormat("avro");
+        CoreOptions options = new CoreOptions(new Options());
+
+        KeyValueFileWriterFactory writerFactory =
+                KeyValueFileWriterFactory.builder(
+                                fileIO,
+                                schema.id(),
+                                keyType,
+                                valueType,
+                                format,
+                                ignored -> pathFactory,
+                                VALUE_128_MB.getBytes())
+                        .build(BinaryRow.EMPTY_ROW, 0, options);
+        RollingFileWriter<KeyValue, DataFileMeta> writer =
+                writerFactory.createRollingMergeTreeFileWriter(1, 
FileSource.COMPACT);
+        try {
+            writer.write(keyValue(1, "a"));
+            writer.write(keyValue(2, null));
+            writer.write(keyValue(3, "c"));
+        } finally {
+            writer.close();
+        }
+        DataFileMeta dataFile = writer.result().get(0);
+
+        KeyValueFileReaderFactory.Builder readerFactoryBuilder =
+                KeyValueFileReaderFactory.builder(
+                        fileIO,
+                        schemaManager,
+                        schema,
+                        keyType,
+                        valueType,
+                        ignored -> format,
+                        pathFactory,
+                        fieldsExtractor(keyType, valueType),
+                        options);
+        PkSortedDataFileReader reader =
+                new PkSortedDataFileReader.Factory(
+                                readerFactoryBuilder, BinaryRow.EMPTY_ROW, 0, 
payloadField)
+                        .create(dataFile);
+        try {
+            assertEntry(reader.readNext(), 0, "a");
+            assertEntry(reader.readNext(), 1, null);
+            assertEntry(reader.readNext(), 2, "c");
+            assertThat(reader.readNext()).isNull();
+        } finally {
+            reader.close();
+        }
+    }
+
+    private static void assertEntry(
+            PkSortedDataFileReader.Entry entry, long position, String value) {
+        assertThat(entry.rowPosition()).isEqualTo(position);
+        if (value == null) {
+            assertThat(entry.value()).isNull();
+        } else {
+            
assertThat(entry.value()).isEqualTo(BinaryString.fromString(value));
+        }
+    }
+
+    private static KeyValue keyValue(int key, String payload) {
+        return new KeyValue()
+                .replace(
+                        GenericRow.of(key),
+                        RowKind.INSERT,
+                        GenericRow.of(payload == null ? null : 
BinaryString.fromString(payload)));
+    }
+
+    private static KeyValueFieldsExtractor fieldsExtractor(RowType keyType, 
RowType valueType) {
+        return new KeyValueFieldsExtractor() {
+            @Override
+            public List<DataField> keyFields(TableSchema schema) {
+                return keyType.getFields();
+            }
+
+            @Override
+            public List<DataField> valueFields(TableSchema schema) {
+                return valueType.getFields();
+            }
+        };
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilderTest.java
new file mode 100644
index 0000000000..1c14998503
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexBuilderTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.disk.BufferFileWriter;
+import org.apache.paimon.disk.FileIOChannel;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.disk.IOManagerImpl;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.GlobalIndexFileReadWrite;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.sorted.SortedIndexOptions;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.stream.Collectors;
+import java.util.stream.LongStream;
+
+import static 
org.apache.paimon.shade.guava30.com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests physical source sorting and payload construction. */
+class PkSortedIndexBuilderTest {
+
+    @TempDir java.nio.file.Path tempPath;
+
+    @Test
+    void testBuildsQueryableBTreeAndBitmapFromUnsortedPhysicalRows() throws 
Exception {
+        List<PkSortedDataFileReader.Entry> entries =
+                Arrays.asList(entry(20, 0), entry(null, 1), entry(10, 2), 
entry(20, 3));
+        DataFileMeta source = dataFile("data-file", entries.size());
+
+        for (String indexType : Arrays.asList("btree", "bitmap")) {
+            java.nio.file.Path directory = 
FilesHelper.createDirectory(tempPath, indexType);
+            LocalFileIO fileIO = LocalFileIO.create();
+            IndexPathFactory pathFactory = pathFactory(directory);
+            Options options = options();
+            IOManager ioManager = 
IOManager.create(directory.resolve("spill").toString());
+            List<IndexFileMeta> payloads;
+            try {
+                payloads =
+                        new PkSortedIndexBuilder(
+                                        ignored -> new ArrayReader(entries),
+                                        new PkSortedIndexFile(fileIO, 
pathFactory),
+                                        field(),
+                                        indexType,
+                                        options,
+                                        ioManager)
+                                .build(source);
+            } finally {
+                ioManager.close();
+            }
+
+            assertThat(payloads).hasSize(2);
+            assertQuery(fileIO, pathFactory, indexType, options, payloads, 
false, 20, 0L, 3L);
+            assertQuery(fileIO, pathFactory, indexType, options, payloads, 
true, null, 1L);
+        }
+    }
+
+    @Test
+    void testForcedSpillSortsRowsAndClosesTaskOwnedIoManager() throws 
Exception {
+        int rowCount = 20_000;
+        List<PkSortedDataFileReader.Entry> entries = new ArrayList<>(rowCount);
+        for (int position = 0; position < rowCount; position++) {
+            entries.add(entry(rowCount - position - 1, position));
+        }
+        List<Integer> sortedValues = new ArrayList<>(rowCount);
+        TrackingIOManager ioManager =
+                new 
TrackingIOManager(tempPath.resolve("owned-spill").toString());
+        PkSortedIndexFile capturingFile =
+                new PkSortedIndexFile(LocalFileIO.create(), 
pathFactory(tempPath)) {
+                    @Override
+                    public List<IndexFileMeta> build(
+                            PrimaryKeyIndexSourceFile sourceFile,
+                            DataField indexField,
+                            String indexType,
+                            Options indexOptions,
+                            Iterator<Entry> sortedEntries) {
+                        while (sortedEntries.hasNext()) {
+                            sortedValues.add((Integer) 
sortedEntries.next().value());
+                        }
+                        return Collections.emptyList();
+                    }
+                };
+        Options options = options();
+        options.set(
+                CoreOptions.WRITE_BUFFER_SIZE,
+                org.apache.paimon.options.MemorySize.parse("128 kb"));
+        options.set(CoreOptions.PAGE_SIZE, 
org.apache.paimon.options.MemorySize.parse("32 kb"));
+
+        new PkSortedIndexBuilder(
+                ignored -> new ArrayReader(entries),
+                capturingFile,
+                field(),
+                "btree",
+                options,
+                null) {
+            @Override
+            protected IOManager createTemporaryIOManager() {
+                return ioManager;
+            }
+        }.build(dataFile("large-data-file", rowCount));
+
+        assertThat(sortedValues)
+                .containsExactlyElementsOf(
+                        LongStream.range(0, rowCount)
+                                .mapToObj(value -> (int) value)
+                                .collect(Collectors.toList()));
+        assertThat(ioManager.createdSpillWriters).isGreaterThan(0);
+        assertThat(ioManager.closed).isTrue();
+    }
+
+    private static void assertQuery(
+            LocalFileIO fileIO,
+            IndexPathFactory pathFactory,
+            String indexType,
+            Options options,
+            List<IndexFileMeta> payloads,
+            boolean isNull,
+            Object literal,
+            Long... expected)
+            throws Exception {
+        List<GlobalIndexIOMeta> ioMetas = new ArrayList<>();
+        for (IndexFileMeta payload : payloads) {
+            ioMetas.add(
+                    new GlobalIndexIOMeta(
+                            pathFactory.toPath(payload.fileName()),
+                            payload.fileSize(),
+                            payload.globalIndexMeta().indexMeta()));
+        }
+        ExecutorService executor = newDirectExecutorService();
+        try (GlobalIndexReader reader =
+                GlobalIndexer.create(indexType, field(), options)
+                        .createReader(
+                                new GlobalIndexFileReadWrite(fileIO, 
pathFactory),
+                                ioMetas,
+                                executor)) {
+            FieldRef fieldRef = new FieldRef(7, "indexed", DataTypes.INT());
+            GlobalIndexResult result =
+                    (isNull ? reader.visitIsNull(fieldRef) : 
reader.visitEqual(fieldRef, literal))
+                            .join()
+                            .get();
+            assertThat(result.results()).containsExactlyInAnyOrder(expected);
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    private static PkSortedDataFileReader.Entry entry(Object value, long 
position) {
+        return new PkSortedDataFileReader.Entry(value, position);
+    }
+
+    private static DataField field() {
+        return new DataField(7, "indexed", DataTypes.INT());
+    }
+
+    private static Options options() {
+        Options options = new Options();
+        options.set(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE, 2L);
+        return options;
+    }
+
+    private static DataFileMeta dataFile(String fileName, long rowCount) {
+        return DataFileMeta.forAppend(
+                fileName,
+                100,
+                rowCount,
+                SimpleStats.EMPTY_STATS,
+                0,
+                0,
+                1,
+                Collections.emptyList(),
+                null,
+                FileSource.COMPACT,
+                null,
+                null,
+                null,
+                null);
+    }
+
+    private static IndexPathFactory pathFactory(java.nio.file.Path directory) {
+        Path path = new Path(directory.toUri());
+        return new IndexPathFactory() {
+            @Override
+            public Path toPath(String fileName) {
+                return new Path(path, fileName);
+            }
+
+            @Override
+            public Path newPath() {
+                return new Path(path, UUID.randomUUID().toString());
+            }
+
+            @Override
+            public boolean isExternalPath() {
+                return false;
+            }
+        };
+    }
+
+    private static final class ArrayReader implements 
PkSortedIndexBuilder.Reader {
+
+        private final List<PkSortedDataFileReader.Entry> entries;
+        private int position;
+
+        private ArrayReader(List<PkSortedDataFileReader.Entry> entries) {
+            this.entries = entries;
+        }
+
+        @Override
+        public long rowCount() {
+            return entries.size();
+        }
+
+        @Override
+        public PkSortedDataFileReader.Entry readNext() {
+            return position == entries.size() ? null : entries.get(position++);
+        }
+
+        @Override
+        public void close() {}
+    }
+
+    private static final class TrackingIOManager extends IOManagerImpl {
+
+        private int createdSpillWriters;
+        private boolean closed;
+
+        private TrackingIOManager(String tempDir) {
+            super(tempDir);
+        }
+
+        @Override
+        public BufferFileWriter createBufferFileWriter(FileIOChannel.ID 
channelID)
+                throws IOException {
+            createdSpillWriters++;
+            return super.createBufferFileWriter(channelID);
+        }
+
+        @Override
+        public void close() throws Exception {
+            closed = true;
+            super.close();
+        }
+    }
+
+    private static final class FilesHelper {
+
+        private static java.nio.file.Path createDirectory(java.nio.file.Path 
parent, String child)
+                throws IOException {
+            return java.nio.file.Files.createDirectory(parent.resolve(child));
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexFileTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexFileTest.java
new file mode 100644
index 0000000000..adc8ba93d2
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedIndexFileTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.index.pksorted;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.globalindex.sorted.SortedIndexOptions;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourceMeta;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests source-backed sorted index payload creation. */
+class PkSortedIndexFileTest {
+
+    @TempDir java.nio.file.Path tempPath;
+
+    @Test
+    void testBuildsRotatedBTreeAndBitmapPayloadGroups() throws Exception {
+        for (String indexType : Arrays.asList("btree", "bitmap")) {
+            java.nio.file.Path indexDirectory = 
Files.createDirectory(tempPath.resolve(indexType));
+            PkSortedIndexFile indexFile =
+                    new PkSortedIndexFile(LocalFileIO.create(), 
pathFactory(indexDirectory));
+            PrimaryKeyIndexSourceFile source = new 
PrimaryKeyIndexSourceFile("data-file", 3);
+
+            List<IndexFileMeta> payloads =
+                    indexFile.build(
+                            source,
+                            field(),
+                            indexType,
+                            options(),
+                            Arrays.asList(
+                                            new PkSortedIndexFile.Entry(null, 
1),
+                                            new PkSortedIndexFile.Entry(10, 2),
+                                            new PkSortedIndexFile.Entry(20, 0))
+                                    .iterator());
+
+            assertThat(payloads).hasSize(2);
+            
assertThat(payloads).extracting(IndexFileMeta::indexType).containsOnly(indexType);
+            
assertThat(payloads).extracting(IndexFileMeta::rowCount).containsExactly(2L, 
1L);
+            assertThat(payloads)
+                    .allSatisfy(
+                            payload -> {
+                                GlobalIndexMeta meta = 
payload.globalIndexMeta();
+                                assertThat(meta.rowRangeStart()).isZero();
+                                assertThat(meta.rowRangeEnd()).isEqualTo(2);
+                                assertThat(meta.indexFieldId()).isEqualTo(7);
+                                assertThat(meta.indexMeta()).isNotEmpty();
+                                assertThat(
+                                                
PrimaryKeyIndexSourceMeta.fromIndexFile(payload)
+                                                        .sourceFile())
+                                        .isEqualTo(source);
+                                assertThat(indexFile.exists(payload)).isTrue();
+                            });
+        }
+    }
+
+    @Test
+    void testFailedSecondPayloadDeletesWholeGroup() throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        AtomicInteger writerNumber = new AtomicInteger();
+        PkSortedIndexFile indexFile =
+                new PkSortedIndexFile(fileIO, pathFactory(tempPath)) {
+                    @Override
+                    protected GlobalIndexSingleColumnWriter createWriter(
+                            String indexType,
+                            DataField indexField,
+                            Options indexOptions,
+                            GlobalIndexFileWriter fileWriter) {
+                        int currentWriter = writerNumber.incrementAndGet();
+                        return new GlobalIndexSingleColumnWriter() {
+                            private long rowCount;
+
+                            @Override
+                            public void write(Object key, long relativeRowId) {
+                                if (currentWriter == 2) {
+                                    throw new RuntimeException("injected 
second payload failure");
+                                }
+                                rowCount++;
+                            }
+
+                            @Override
+                            public List<ResultEntry> finish() {
+                                String fileName = 
fileWriter.newFileName("test");
+                                try (PositionOutputStream output =
+                                        fileWriter.newOutputStream(fileName)) {
+                                    output.write(new byte[] {1});
+                                } catch (IOException e) {
+                                    throw new RuntimeException(e);
+                                }
+                                return Collections.singletonList(
+                                        new ResultEntry(fileName, rowCount, 
new byte[] {2}));
+                            }
+                        };
+                    }
+                };
+        Options options = options();
+        options.set(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE, 1L);
+
+        assertThatThrownBy(
+                        () ->
+                                indexFile.build(
+                                        new 
PrimaryKeyIndexSourceFile("data-file", 2),
+                                        field(),
+                                        "btree",
+                                        options,
+                                        Arrays.asList(
+                                                        new 
PkSortedIndexFile.Entry(10, 0),
+                                                        new 
PkSortedIndexFile.Entry(20, 1))
+                                                .iterator()))
+                .hasMessageContaining("injected second payload failure");
+
+        try (Stream<java.nio.file.Path> files = Files.list(tempPath)) {
+            assertThat(files).isEmpty();
+        }
+    }
+
+    private static DataField field() {
+        return new DataField(7, "indexed", DataTypes.INT());
+    }
+
+    private static Options options() {
+        Options options = new Options();
+        options.set(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE, 2L);
+        return options;
+    }
+
+    private static IndexPathFactory pathFactory(java.nio.file.Path directory) {
+        Path path = new Path(directory.toUri());
+        return new IndexPathFactory() {
+            @Override
+            public Path toPath(String fileName) {
+                return new Path(path, fileName);
+            }
+
+            @Override
+            public Path newPath() {
+                return new Path(path, UUID.randomUUID().toString());
+            }
+
+            @Override
+            public boolean isExternalPath() {
+                return false;
+            }
+        };
+    }
+}

Reply via email to