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 d55fabffcf [core] Reduce memory for update index (#9092)
d55fabffcf is described below

commit d55fabffcfd7038ca33e1f2b9eda912e79da175b
Author: YeJunHao <[email protected]>
AuthorDate: Fri Aug 7 19:20:17 2026 +0800

    [core] Reduce memory for update index (#9092)
---
 .../DataEvolutionGlobalIndexRefreshPlanner.java    | 325 ++++++++++++++++++---
 .../sorted/SortedGlobalIndexScanner.java           |  15 +-
 .../sorted/SortedGlobalIndexScannerTest.java       | 124 ++++++++
 3 files changed, 419 insertions(+), 45 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
index 44ed39685d..36e11b38f4 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
@@ -18,21 +18,34 @@
 
 package org.apache.paimon.globalindex;
 
+import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
 import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.io.BinaryDataFileMeta;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.BinaryManifestEntry;
+import org.apache.paimon.manifest.DeletedIdentifierSet;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
 import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.manifest.ManifestFile;
+import org.apache.paimon.manifest.ManifestFileMeta;
+import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.ScanMode;
 import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.CloseableIterator;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.Range;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Comparator;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -48,16 +61,91 @@ public final class DataEvolutionGlobalIndexRefreshPlanner {
 
     private DataEvolutionGlobalIndexRefreshPlanner() {}
 
+    /**
+     * Consumes data entries one by one. Entries are never retained; only 
merged row ranges bucketed
+     * by distinct scan sequence numbers are kept in memory.
+     */
     public static List<IndexManifestEntry> findIndexesToRefresh(
             SchemaManager schemaManager,
-            List<ManifestEntry> dataEntries,
+            Iterable<ManifestEntry> dataEntries,
             List<IndexManifestEntry> indexEntries,
             List<DataField> indexedFields) {
-        Set<Integer> indexedFieldIds = new HashSet<>();
-        for (DataField field : indexedFields) {
-            indexedFieldIds.add(field.id());
+        Map<Pair<BinaryRow, Integer>, RefreshGroup> groups =
+                collectRefreshGroups(indexEntries, indexedFields);
+        if (groups.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Set<Integer> indexedFieldIds = indexedFieldIds(indexedFields);
+        Map<Pair<Long, List<String>>, Set<Integer>> fileFieldIdsCache = new 
HashMap<>();
+        for (ManifestEntry dataEntry : dataEntries) {
+            DataFileMeta file = dataEntry.file();
+            if (dataEntry.kind() != FileKind.ADD || file.firstRowId() == null) 
{
+                continue;
+            }
+
+            RefreshGroup group = groups.get(Pair.of(dataEntry.partition(), 
dataEntry.bucket()));
+            if (group == null || !group.mayContainUpdate(file)) {
+                continue;
+            }
+
+            addIfUpdatesIndexedFields(
+                    schemaManager, fileFieldIdsCache, indexedFieldIds, group, 
file);
+        }
+
+        return collectMarkedIndexes(groups, indexEntries);
+    }
+
+    /**
+     * Scans data manifests through reusable {@link BinaryManifestEntry} views 
and plans indexes to
+     * refresh. Neither {@link ManifestEntry} nor {@link DataFileMeta} POJOs 
are materialized: one
+     * narrow DELETE pass tracks removed files in a primitive identifier set, 
then one projected ADD
+     * pass merges updated row ranges directly into the refresh groups.
+     */
+    public static List<IndexManifestEntry> findIndexesToRefresh(
+            FileStoreTable table,
+            Snapshot snapshot,
+            @Nullable PartitionPredicate partitionPredicate,
+            List<IndexManifestEntry> indexEntries,
+            List<DataField> indexedFields) {
+        Map<Pair<BinaryRow, Integer>, RefreshGroup> groups =
+                collectRefreshGroups(indexEntries, indexedFields);
+        if (groups.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        List<ManifestFileMeta> manifests =
+                table.store()
+                        .newScan()
+                        .withPartitionFilter(partitionPredicate)
+                        .manifestsReader()
+                        .read(snapshot, ScanMode.ALL)
+                        .filteredManifests;
+        ManifestFile manifestFile = 
table.store().manifestFileFactory().create();
+        Set<BinaryRow> groupPartitions = new HashSet<>();
+        for (Pair<BinaryRow, Integer> key : groups.keySet()) {
+            groupPartitions.add(key.getLeft());
         }
 
+        DeletedIdentifierSet deleted = new DeletedIdentifierSet();
+        try {
+            collectDeletedIdentifiers(manifestFile, manifests, 
groupPartitions, deleted);
+            collectUpdatedRanges(
+                    table.schemaManager(),
+                    manifestFile,
+                    manifests,
+                    deleted,
+                    groups,
+                    indexedFieldIds(indexedFields));
+        } finally {
+            deleted.release();
+        }
+
+        return collectMarkedIndexes(groups, indexEntries);
+    }
+
+    private static Map<Pair<BinaryRow, Integer>, RefreshGroup> 
collectRefreshGroups(
+            List<IndexManifestEntry> indexEntries, List<DataField> 
indexedFields) {
         Map<Pair<BinaryRow, Integer>, RefreshGroup> groups = new HashMap<>();
         for (int i = 0; i < indexEntries.size(); i++) {
             IndexManifestEntry indexEntry = indexEntries.get(i);
@@ -80,28 +168,135 @@ public final class DataEvolutionGlobalIndexRefreshPlanner 
{
                             key -> new RefreshGroup())
                     .addIndex(i, indexMeta.rowRange(), scanSnapshotId);
         }
+        for (RefreshGroup group : groups.values()) {
+            group.finishAddingIndexes();
+        }
+        return groups;
+    }
 
-        Map<Pair<Long, List<String>>, Set<Integer>> fileFieldIdsCache = new 
HashMap<>();
-        for (ManifestEntry dataEntry : dataEntries) {
-            DataFileMeta file = dataEntry.file();
-            if (dataEntry.kind() != FileKind.ADD || file.firstRowId() == null) 
{
+    private static void collectDeletedIdentifiers(
+            ManifestFile manifestFile,
+            List<ManifestFileMeta> manifests,
+            Set<BinaryRow> groupPartitions,
+            DeletedIdentifierSet deleted) {
+        for (ManifestFileMeta manifest : manifests) {
+            if (manifest.numDeletedFiles() <= 0) {
                 continue;
             }
+            try (CloseableIterator<BinaryManifestEntry> entries =
+                    manifestFile.scan(
+                            manifest.fileName(),
+                            manifest.fileSize(),
+                            BinaryManifestEntry.DELETE_ENTRY_PROJECTION)) {
+                while (entries.hasNext()) {
+                    BinaryManifestEntry entry = entries.next();
+                    if (entry.isDelete() && 
groupPartitions.contains(entry.partition())) {
+                        deleted.add(entry);
+                    }
+                }
+            } catch (Exception e) {
+                throw manifestScanException(manifest, e);
+            }
+        }
+    }
 
-            RefreshGroup group = groups.get(Pair.of(dataEntry.partition(), 
dataEntry.bucket()));
-            if (group == null || !group.mayContainUpdate(file)) {
+    private static void collectUpdatedRanges(
+            SchemaManager schemaManager,
+            ManifestFile manifestFile,
+            List<ManifestFileMeta> manifests,
+            DeletedIdentifierSet deleted,
+            Map<Pair<BinaryRow, Integer>, RefreshGroup> groups,
+            Set<Integer> indexedFieldIds) {
+        Map<Pair<Long, List<String>>, Set<Integer>> fileFieldIdsCache = new 
HashMap<>();
+        BinaryManifestEntry.Projection projection = 
addedEntryProjection(!deleted.isEmpty());
+        for (ManifestFileMeta manifest : manifests) {
+            if (manifest.numAddedFiles() <= 0) {
                 continue;
             }
-
-            Set<Integer> physicalFieldIds =
-                    fileFieldIdsCache.computeIfAbsent(
-                            Pair.of(file.schemaId(), file.writeCols()),
-                            key -> fileFieldIds(schemaManager::schema, file));
-            if (!disjoint(indexedFieldIds, physicalFieldIds)) {
-                group.addDataFile(file);
+            try (CloseableIterator<BinaryManifestEntry> entries =
+                    manifestFile.scan(manifest.fileName(), 
manifest.fileSize(), projection)) {
+                while (entries.hasNext()) {
+                    BinaryManifestEntry entry = entries.next();
+                    if (!entry.isAdd()) {
+                        continue;
+                    }
+                    BinaryDataFileMeta file = entry.file();
+                    if (!file.hasFirstRowId()) {
+                        continue;
+                    }
+                    RefreshGroup group = groups.get(Pair.of(entry.partition(), 
entry.bucket()));
+                    if (group == null || !group.mayContainUpdate(file)) {
+                        continue;
+                    }
+                    if (!deleted.isEmpty() && deleted.contains(entry)) {
+                        continue;
+                    }
+                    addIfUpdatesIndexedFields(
+                            schemaManager, fileFieldIdsCache, indexedFieldIds, 
group, file);
+                }
+            } catch (Exception e) {
+                throw manifestScanException(manifest, e);
             }
         }
+    }
+
+    private static void addIfUpdatesIndexedFields(
+            SchemaManager schemaManager,
+            Map<Pair<Long, List<String>>, Set<Integer>> fileFieldIdsCache,
+            Set<Integer> indexedFieldIds,
+            RefreshGroup group,
+            DataFileMeta file) {
+        Set<Integer> physicalFieldIds =
+                fileFieldIdsCache.computeIfAbsent(
+                        Pair.of(file.schemaId(), file.writeCols()),
+                        key -> fileFieldIds(schemaManager::schema, file));
+        if (!disjoint(indexedFieldIds, physicalFieldIds)) {
+            group.addUpdatedFile(file.maxSequenceNumber(), 
file.nonNullRowIdRange());
+        }
+    }
+
+    /**
+     * Projects only the fields the refresh planner consumes; identifier 
fields are included only
+     * when deleted files must be recognized.
+     */
+    private static BinaryManifestEntry.Projection addedEntryProjection(
+            boolean includeIdentifierFields) {
+        List<DataField> fileFields = new ArrayList<>();
+        fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.ROW_COUNT));
+        
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.MAX_SEQUENCE_NUMBER));
+        fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.SCHEMA_ID));
+        
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.FIRST_ROW_ID));
+        fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.WRITE_COLS));
+        if (includeIdentifierFields) {
+            
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.FILE_NAME));
+            fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.LEVEL));
+            
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.EXTRA_FILES));
+            
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.EMBEDDED_FILE_INDEX));
+            
fileFields.add(DataFileMeta.SCHEMA.getField(DataFileMeta.EXTERNAL_PATH));
+        }
+
+        List<DataField> fields = new ArrayList<>();
+        
fields.add(ManifestEntry.MANIFEST_ROW_TYPE.getField(ManifestEntry.KIND));
+        
fields.add(ManifestEntry.MANIFEST_ROW_TYPE.getField(ManifestEntry.PARTITION));
+        
fields.add(ManifestEntry.MANIFEST_ROW_TYPE.getField(ManifestEntry.BUCKET));
+        fields.add(
+                ManifestEntry.MANIFEST_ROW_TYPE
+                        .getField(ManifestEntry.FILE)
+                        .newType(new RowType(false, fileFields)));
+        return BinaryManifestEntry.Projection.create(new RowType(false, 
fields));
+    }
+
+    private static Set<Integer> indexedFieldIds(List<DataField> indexedFields) 
{
+        Set<Integer> indexedFieldIds = new HashSet<>();
+        for (DataField field : indexedFields) {
+            indexedFieldIds.add(field.id());
+        }
+        return indexedFieldIds;
+    }
 
+    private static List<IndexManifestEntry> collectMarkedIndexes(
+            Map<Pair<BinaryRow, Integer>, RefreshGroup> groups,
+            List<IndexManifestEntry> indexEntries) {
         boolean[] indexesToRefresh = new boolean[indexEntries.size()];
         for (RefreshGroup group : groups.values()) {
             group.markIndexesToRefresh(indexesToRefresh);
@@ -116,42 +311,88 @@ public final class DataEvolutionGlobalIndexRefreshPlanner 
{
         return result;
     }
 
+    private static RuntimeException manifestScanException(
+            ManifestFileMeta manifest, Exception cause) {
+        return new RuntimeException("Failed to scan manifest " + 
manifest.fileName(), cause);
+    }
+
     private static final class RefreshGroup {
 
         private final List<IndexQuery> indexes = new ArrayList<>();
-        private final List<DataFileMeta> dataFiles = new ArrayList<>();
         private final MergedRanges indexedRanges = new MergedRanges();
         private long minScanSnapshotId = Long.MAX_VALUE;
 
+        // Distinct scan sequence numbers in descending order. Bucket i merges 
row ranges of updated
+        // data files whose max sequence number lies in
+        // (sequenceNumbers[i], sequenceNumbers[i - 1]].
+        private long[] sequenceNumbers;
+        private MergedRanges[] updatedRangesPerSequenceNumber;
+
         private void addIndex(int ordinal, Range rowRange, long 
scanSnapshotId) {
             indexes.add(new IndexQuery(ordinal, rowRange, scanSnapshotId));
             indexedRanges.add(rowRange);
             minScanSnapshotId = Math.min(minScanSnapshotId, scanSnapshotId);
         }
 
+        private void finishAddingIndexes() {
+            indexes.sort((left, right) -> Long.compare(right.scanSnapshotId, 
left.scanSnapshotId));
+            long[] distinct = new long[indexes.size()];
+            int size = 0;
+            for (IndexQuery index : indexes) {
+                if (size == 0 || distinct[size - 1] != index.scanSnapshotId) {
+                    distinct[size++] = index.scanSnapshotId;
+                }
+            }
+            this.sequenceNumbers = Arrays.copyOf(distinct, size);
+            this.updatedRangesPerSequenceNumber = new MergedRanges[size];
+        }
+
         private boolean mayContainUpdate(DataFileMeta file) {
             return file.maxSequenceNumber() > minScanSnapshotId
                     && indexedRanges.intersects(file.nonNullRowIdRange());
         }
 
-        private void addDataFile(DataFileMeta file) {
-            dataFiles.add(file);
+        private void addUpdatedFile(long maxSequenceNumber, Range rowRange) {
+            // Merge the range eagerly instead of retaining the file metadata.
+            int sequenceNumberIndex = 
firstIndexWithSequenceNumberBelow(maxSequenceNumber);
+            if (updatedRangesPerSequenceNumber[sequenceNumberIndex] == null) {
+                updatedRangesPerSequenceNumber[sequenceNumberIndex] = new 
MergedRanges();
+            }
+            updatedRangesPerSequenceNumber[sequenceNumberIndex].add(rowRange);
         }
 
-        private void markIndexesToRefresh(boolean[] result) {
-            // As scan watermarks decrease, eligible data files only grow.
-            
dataFiles.sort(Comparator.comparingLong(DataFileMeta::maxSequenceNumber).reversed());
-            indexes.sort((left, right) -> Long.compare(right.scanSnapshotId, 
left.scanSnapshotId));
+        /** Returns the first position whose scan sequence number is below the 
maximum sequence. */
+        private int firstIndexWithSequenceNumberBelow(long maxSequenceNumber) {
+            // mayContainUpdate guarantees the last sequence number qualifies.
+            int low = 0;
+            int high = sequenceNumbers.length - 1;
+            while (low < high) {
+                int mid = (low + high) >>> 1;
+                if (sequenceNumbers[mid] < maxSequenceNumber) {
+                    high = mid;
+                } else {
+                    low = mid + 1;
+                }
+            }
+            return low;
+        }
 
-            MergedRanges updatedRanges = new MergedRanges();
-            int nextFile = 0;
+        private void markIndexesToRefresh(boolean[] result) {
+            // As scan sequence numbers decrease, eligible updated ranges only 
grow.
+            MergedRanges updatedRanges = null;
+            int nextSequenceNumberIndex = 0;
             for (IndexQuery index : indexes) {
-                while (nextFile < dataFiles.size()
-                        && dataFiles.get(nextFile).maxSequenceNumber() > 
index.scanSnapshotId) {
-                    
updatedRanges.add(dataFiles.get(nextFile).nonNullRowIdRange());
-                    nextFile++;
+                while (nextSequenceNumberIndex < sequenceNumbers.length
+                        && sequenceNumbers[nextSequenceNumberIndex] >= 
index.scanSnapshotId) {
+                    MergedRanges ranges = 
updatedRangesPerSequenceNumber[nextSequenceNumberIndex];
+                    if (ranges != null) {
+                        updatedRanges =
+                                updatedRanges == null ? ranges : 
updatedRanges.merge(ranges);
+                        
updatedRangesPerSequenceNumber[nextSequenceNumberIndex] = null;
+                    }
+                    nextSequenceNumberIndex++;
                 }
-                if (updatedRanges.intersects(index.rowRange)) {
+                if (updatedRanges != null && 
updatedRanges.intersects(index.rowRange)) {
                     result[index.ordinal] = true;
                 }
             }
@@ -177,9 +418,10 @@ public final class DataEvolutionGlobalIndexRefreshPlanner {
         private final NavigableMap<Long, Long> ranges = new TreeMap<>();
 
         private void add(Range range) {
-            long from = range.from;
-            long to = range.to;
+            add(range.from, range.to);
+        }
 
+        private void add(long from, long to) {
             Map.Entry<Long, Long> floor = ranges.floorEntry(from);
             if (floor != null && floor.getValue() >= from) {
                 from = floor.getKey();
@@ -196,6 +438,21 @@ public final class DataEvolutionGlobalIndexRefreshPlanner {
             ranges.put(from, to);
         }
 
+        /** Merges the smaller range set into the larger one and clears the 
source. */
+        private MergedRanges merge(MergedRanges other) {
+            MergedRanges target = this;
+            MergedRanges source = other;
+            if (target.ranges.size() < source.ranges.size()) {
+                target = other;
+                source = this;
+            }
+            for (Map.Entry<Long, Long> range : source.ranges.entrySet()) {
+                target.add(range.getKey(), range.getValue());
+            }
+            source.ranges.clear();
+            return target;
+        }
+
         private boolean intersects(Range range) {
             Map.Entry<Long, Long> floor = ranges.floorEntry(range.to);
             return floor != null && floor.getValue() >= range.from;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScanner.java
index e1417dd90c..7f44a33451 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScanner.java
@@ -23,7 +23,6 @@ import org.apache.paimon.Snapshot;
 import org.apache.paimon.globalindex.DataEvolutionGlobalIndexRefreshPlanner;
 import org.apache.paimon.globalindex.ScanResult;
 import org.apache.paimon.manifest.IndexManifestEntry;
-import org.apache.paimon.manifest.ManifestEntry;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.table.FileStoreTable;
@@ -144,18 +143,12 @@ public class SortedGlobalIndexScanner implements 
Serializable {
         List<Range> rangesToBuild = new 
ArrayList<>(unindexedRowRanges(snapshot, currentIndexes));
         List<IndexManifestEntry> deletedIndexEntries = Collections.emptyList();
         if (detectDataFileChange()) {
-            List<ManifestEntry> dataEntries =
-                    table.store()
-                            .newScan()
-                            .withSnapshot(snapshot)
-                            .withPartitionFilter(partitionPredicate)
-                            .dropStats()
-                            .plan()
-                            .files();
+            // Scans data manifests through reusable binary views without 
materializing entries.
             deletedIndexEntries =
                     
DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
-                            table.schemaManager(),
-                            dataEntries,
+                            table,
+                            snapshot,
+                            partitionPredicate,
                             currentIndexes,
                             Collections.singletonList(indexField));
             for (IndexManifestEntry entry : deletedIndexEntries) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScannerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScannerTest.java
index f918b17289..ab2b87d93b 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScannerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScannerTest.java
@@ -20,10 +20,13 @@ package org.apache.paimon.globalindex.sorted;
 
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
 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.DataEvolutionGlobalIndexRefreshPlanner;
 import org.apache.paimon.globalindex.KeySerializer;
 import org.apache.paimon.globalindex.ScanResult;
 import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
@@ -44,7 +47,10 @@ import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.EndOfScanException;
+import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Pair;
@@ -53,6 +59,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -344,6 +351,123 @@ public class SortedGlobalIndexScannerTest extends 
TableTestBase {
                 "incrementalScan should only return the new rows in partition 
p0");
     }
 
+    @Test
+    public void testIncrementalScanRefreshesIndexAfterColumnUpdate() throws 
Exception {
+        write();
+        createIndex(null);
+
+        FileStoreTable table =
+                getTableDefault()
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(),
+                                        "IGNORE"));
+        updateColumn(table, 0L, 5);
+
+        SortedGlobalIndexScanner scanner =
+                new SortedGlobalIndexScanner(table, 
"btree").withIndexField("f0");
+        ScanResult<DataSplit> scanResult =
+                scanner.incrementalScan()
+                        .orElseThrow(
+                                () ->
+                                        new IllegalStateException(
+                                                "Expected incremental scan 
result after column update."));
+
+        assertThat(scanResult.deletedIndexEntries()).isNotEmpty();
+        assertThat(scanResult.deletedIndexEntries())
+                .allSatisfy(
+                        entry ->
+                                
assertThat(entry.indexFile().globalIndexMeta().rowRange().from)
+                                        .isEqualTo(0L));
+    }
+
+    @Test
+    public void testFindIndexesToRefreshBinaryScanMatchesEntryScan() throws 
Exception {
+        write();
+        createIndex(null);
+        FileStoreTable table = getTableDefault();
+
+        // Column update overlapping indexed rows must be planned identically 
by both paths.
+        // Cover the whole base file so the follow-up compaction can merge the 
two files.
+        updateColumn(table, 0L, (int) PART_ROW_NUM);
+        assertThat(refreshPlanParity(table)).isNotEmpty();
+
+        // Compaction produces DELETE manifest entries which the binary scan 
must recognize.
+        doDataEvolutionCompact(table);
+        updateColumn(table, 100L, 5);
+        refreshPlanParity(table);
+    }
+
+    private List<IndexManifestEntry> refreshPlanParity(FileStoreTable table) {
+        Snapshot snapshot = table.snapshotManager().latestSnapshot();
+        List<IndexManifestEntry> currentIndexes =
+                table.store().newIndexFileHandler().scan(snapshot, "btree");
+        List<DataField> indexedFields = 
Collections.singletonList(table.rowType().getField("f0"));
+
+        List<IndexManifestEntry> viaEntries =
+                DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+                        table.schemaManager(),
+                        
table.store().newScan().withSnapshot(snapshot).plan().files(),
+                        currentIndexes,
+                        indexedFields);
+        List<IndexManifestEntry> viaBinaryScan =
+                DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+                        table, snapshot, null, currentIndexes, indexedFields);
+
+        assertThat(viaBinaryScan).containsExactlyElementsOf(viaEntries);
+        return viaBinaryScan;
+    }
+
+    private void updateColumn(FileStoreTable table, long firstRowId, int 
rowCount)
+            throws Exception {
+        RowType writeType = table.rowType().project(Arrays.asList("dt", "f0"));
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(writeType)) {
+            for (int i = 0; i < rowCount; i++) {
+                write.write(GenericRow.of(BinaryString.fromString("p0"), 
-(int) (firstRowId + i)));
+            }
+            List<CommitMessage> messages = write.prepareCommit();
+            setFirstRowId(messages, firstRowId);
+            try (BatchTableCommit commit = builder.newCommit()) {
+                commit.commit(messages);
+            }
+        }
+    }
+
+    private void setFirstRowId(List<CommitMessage> messages, long firstRowId) {
+        for (CommitMessage message : messages) {
+            CommitMessageImpl commitMessage = (CommitMessageImpl) message;
+            List<DataFileMeta> newFiles =
+                    new 
ArrayList<>(commitMessage.newFilesIncrement().newFiles());
+            commitMessage.newFilesIncrement().newFiles().clear();
+            for (DataFileMeta newFile : newFiles) {
+                commitMessage
+                        .newFilesIncrement()
+                        .newFiles()
+                        .add(newFile.assignFirstRowId(firstRowId));
+            }
+        }
+    }
+
+    private void doDataEvolutionCompact(FileStoreTable table) throws Exception 
{
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(
+                        table, false, false, table.latestSnapshot().get());
+        List<CommitMessage> messages = new ArrayList<>();
+        try {
+            List<DataEvolutionCompactTask> tasks;
+            while (!(tasks = coordinator.plan()).isEmpty()) {
+                for (DataEvolutionCompactTask task : tasks) {
+                    messages.add(task.doCompact(table, "test-compact"));
+                }
+            }
+        } catch (EndOfScanException ignore) {
+        }
+        if (!messages.isEmpty()) {
+            table.newBatchWriteBuilder().newCommit().commit(messages);
+        }
+    }
+
     @Test
     public void testScanFiltersBlobFilesByManifestEntryFilter() throws 
Exception {
         Schema.Builder schemaBuilder = Schema.newBuilder();

Reply via email to