diff --git a/docs/docs/multimodal-table/global-index.mdx b/docs/docs/multimodal-table/global-index.mdx
index 26ca42cc14b7..f68af4af3daa 100644
--- a/docs/docs/multimodal-table/global-index.mdx
+++ b/docs/docs/multimodal-table/global-index.mdx
@@ -399,7 +399,8 @@ These table options affect global index build and read behavior:
| `vector-index.search-mode` | `fast` | Search mode for vector queries. |
| `full-text-index.search-mode` | `fast` | Search mode for full-text queries. |
| `global-index.external-path` | Not set | Root directory for global index files. If not set, files are stored under the table index directory. |
-| `global-index.column-update-action` | `THROW_ERROR` | Action for updates to indexed columns. `THROW_ERROR` rejects the update, `DROP_PARTITION_INDEX` drops affected partition indexes, and `IGNORE` keeps existing index files unchanged. Use `IGNORE` only when a stale index is acceptable; for example, a vector updated from `NULL` to a value remains invisible until the index is rebuilt. |
+| `global-index.column-update-action` | `THROW_ERROR` | Action for updates to indexed columns. `THROW_ERROR` rejects the update, `DROP_PARTITION_INDEX` drops affected partition indexes, and `IGNORE` keeps existing index files unchanged until the index is rebuilt. Set this to `IGNORE` together with `global-index.detect-datafile-change=true` to refresh affected row ranges during the next incremental index build. |
+| `global-index.detect-datafile-change` | `false` | Whether incremental global index builds scan active data manifests to detect changes in already indexed row ranges. Enable this together with `global-index.column-update-action=IGNORE` to rebuild affected ranges. Index files created before this metadata was introduced are not refreshed automatically. |
| `sorted-index.records-per-range` | `10000000` | Expected number of records per sorted global index file for BTree and Bitmap builds. |
| `sorted-index.build.max-parallelism` | `4096` | Maximum Flink or Spark parallelism for building sorted global indexes. |
| `global-index.row-count-per-shard` | `100000` | Target row count per shard for non-sorted global index builds such as vector and full-text indexes. |
diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 5670cc65c858..5a0da88d709e 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -794,6 +794,12 @@
Enum
Defines the action to take when an update modifies columns that are covered by a global index. IGNORE leaves existing index files unchanged and may make the index stale.
Possible values:
"THROW_ERROR"
"DROP_PARTITION_INDEX"
"IGNORE"
+
+
global-index.detect-datafile-change
+
false
+
Boolean
+
Whether incremental global index builds inspect active data files to detect changes in already indexed row ranges.
+
global-index.enabled
true
@@ -806,6 +812,12 @@
String
Global index root directory, if not set, the global index files will be stored under the <table-root-directory>/index.
+
+
global-index.ignore-missing-delete
+
false
+
Boolean
+
Whether to ignore deleting a global index file which does not exist in the previous index manifest.
+
global-index.row-count-per-shard
100000
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 7449142e8f75..45e2f3c15b57 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2599,6 +2599,20 @@ public String toString() {
"Defines the action to take when an update modifies columns that are covered by a global index. "
+ "IGNORE leaves existing index files unchanged and may make the index stale.");
+ public static final ConfigOption GLOBAL_INDEX_DETECT_DATA_FILE_CHANGE =
+ key("global-index.detect-datafile-change")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether incremental global index builds inspect active data files to detect changes in already indexed row ranges.");
+
+ public static final ConfigOption GLOBAL_INDEX_IGNORE_MISSING_DELETE =
+ key("global-index.ignore-missing-delete")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to ignore deleting a global index file which does not exist in the previous index manifest.");
+
public static final ConfigOption LOOKUP_MERGE_BUFFER_SIZE =
key("lookup.merge-buffer-size")
.memoryType()
@@ -3699,6 +3713,14 @@ public GlobalIndexColumnUpdateAction globalIndexColumnUpdateAction() {
return options.get(GLOBAL_INDEX_COLUMN_UPDATE_ACTION);
}
+ public boolean globalIndexDetectDataFileChange() {
+ return options.get(GLOBAL_INDEX_DETECT_DATA_FILE_CHANGE);
+ }
+
+ public boolean globalIndexIgnoreMissingDelete() {
+ return options.get(GLOBAL_INDEX_IGNORE_MISSING_DELETE);
+ }
+
public LookupStrategy lookupStrategy() {
return LookupStrategy.from(
mergeEngine().equals(MergeEngine.FIRST_ROW),
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
index 3a731143f80f..05d173ed0aa8 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
@@ -670,7 +670,8 @@ private RewrittenIndexManifest rewriteIndexManifest(Assignment assignment) {
rewrittenRange.to,
globalIndex.indexFieldId(),
globalIndex.extraFieldIds(),
- globalIndex.indexMeta());
+ globalIndex.indexMeta(),
+ globalIndex.sourceMeta());
IndexFileMeta newIndexFile =
new IndexFileMeta(
indexFile.indexType(),
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
new file mode 100644
index 000000000000..44ed39685d24
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
@@ -0,0 +1,230 @@
+/*
+ * 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;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.TreeMap;
+
+import static org.apache.paimon.utils.DataEvolutionUtils.fileFieldIds;
+
+/** Plans existing global index files which need refresh after data-evolution updates. */
+public final class DataEvolutionGlobalIndexRefreshPlanner {
+
+ private DataEvolutionGlobalIndexRefreshPlanner() {}
+
+ public static List findIndexesToRefresh(
+ SchemaManager schemaManager,
+ List dataEntries,
+ List indexEntries,
+ List indexedFields) {
+ Set indexedFieldIds = new HashSet<>();
+ for (DataField field : indexedFields) {
+ indexedFieldIds.add(field.id());
+ }
+
+ Map, RefreshGroup> groups = new HashMap<>();
+ for (int i = 0; i < indexEntries.size(); i++) {
+ IndexManifestEntry indexEntry = indexEntries.get(i);
+ GlobalIndexMeta indexMeta = indexEntry.indexFile().globalIndexMeta();
+ if (indexEntry.kind() != FileKind.ADD
+ || indexMeta == null
+ || !matchesFields(indexMeta, indexedFields)) {
+ continue;
+ }
+
+ byte[] sourceMeta = indexMeta.sourceMeta();
+ if (!DataEvolutionIndexSourceMeta.isDataEvolutionMeta(sourceMeta)) {
+ // Legacy indexes have no trustworthy scan baseline and require an explicit rebuild.
+ continue;
+ }
+ long scanSnapshotId =
+ DataEvolutionIndexSourceMeta.deserialize(sourceMeta).scanSnapshotId();
+ groups.computeIfAbsent(
+ Pair.of(indexEntry.partition(), indexEntry.bucket()),
+ key -> new RefreshGroup())
+ .addIndex(i, indexMeta.rowRange(), scanSnapshotId);
+ }
+
+ Map>, Set> 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;
+ }
+
+ Set physicalFieldIds =
+ fileFieldIdsCache.computeIfAbsent(
+ Pair.of(file.schemaId(), file.writeCols()),
+ key -> fileFieldIds(schemaManager::schema, file));
+ if (!disjoint(indexedFieldIds, physicalFieldIds)) {
+ group.addDataFile(file);
+ }
+ }
+
+ boolean[] indexesToRefresh = new boolean[indexEntries.size()];
+ for (RefreshGroup group : groups.values()) {
+ group.markIndexesToRefresh(indexesToRefresh);
+ }
+
+ List result = new ArrayList<>();
+ for (int i = 0; i < indexEntries.size(); i++) {
+ if (indexesToRefresh[i]) {
+ result.add(indexEntries.get(i));
+ }
+ }
+ return result;
+ }
+
+ private static final class RefreshGroup {
+
+ private final List indexes = new ArrayList<>();
+ private final List dataFiles = new ArrayList<>();
+ private final MergedRanges indexedRanges = new MergedRanges();
+ private long minScanSnapshotId = Long.MAX_VALUE;
+
+ 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 boolean mayContainUpdate(DataFileMeta file) {
+ return file.maxSequenceNumber() > minScanSnapshotId
+ && indexedRanges.intersects(file.nonNullRowIdRange());
+ }
+
+ private void addDataFile(DataFileMeta file) {
+ dataFiles.add(file);
+ }
+
+ 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));
+
+ MergedRanges updatedRanges = new MergedRanges();
+ int nextFile = 0;
+ for (IndexQuery index : indexes) {
+ while (nextFile < dataFiles.size()
+ && dataFiles.get(nextFile).maxSequenceNumber() > index.scanSnapshotId) {
+ updatedRanges.add(dataFiles.get(nextFile).nonNullRowIdRange());
+ nextFile++;
+ }
+ if (updatedRanges.intersects(index.rowRange)) {
+ result[index.ordinal] = true;
+ }
+ }
+ }
+ }
+
+ private static final class IndexQuery {
+
+ private final int ordinal;
+ private final Range rowRange;
+ private final long scanSnapshotId;
+
+ private IndexQuery(int ordinal, Range rowRange, long scanSnapshotId) {
+ this.ordinal = ordinal;
+ this.rowRange = rowRange;
+ this.scanSnapshotId = scanSnapshotId;
+ }
+ }
+
+ /** Dynamically merged inclusive ranges supporting logarithmic intersection checks. */
+ private static final class MergedRanges {
+
+ private final NavigableMap ranges = new TreeMap<>();
+
+ private void add(Range range) {
+ long from = range.from;
+ long to = range.to;
+
+ Map.Entry floor = ranges.floorEntry(from);
+ if (floor != null && floor.getValue() >= from) {
+ from = floor.getKey();
+ to = Math.max(to, floor.getValue());
+ ranges.remove(floor.getKey());
+ }
+
+ Map.Entry next = ranges.ceilingEntry(from);
+ while (next != null && next.getKey() <= to) {
+ to = Math.max(to, next.getValue());
+ ranges.remove(next.getKey());
+ next = ranges.ceilingEntry(from);
+ }
+ ranges.put(from, to);
+ }
+
+ private boolean intersects(Range range) {
+ Map.Entry floor = ranges.floorEntry(range.to);
+ return floor != null && floor.getValue() >= range.from;
+ }
+ }
+
+ private static boolean matchesFields(GlobalIndexMeta meta, List fields) {
+ if (fields.isEmpty() || meta.indexFieldId() != fields.get(0).id()) {
+ return false;
+ }
+ int[] expectedExtraFields =
+ fields.size() == 1
+ ? null
+ : fields.subList(1, fields.size()).stream()
+ .mapToInt(DataField::id)
+ .toArray();
+ int[] actualExtraFields = meta.extraFieldIds();
+ if (actualExtraFields == null || actualExtraFields.length == 0) {
+ return expectedExtraFields == null || expectedExtraFields.length == 0;
+ }
+ return expectedExtraFields != null && Arrays.equals(actualExtraFields, expectedExtraFields);
+ }
+
+ private static boolean disjoint(Set left, Set right) {
+ for (Integer value : left) {
+ if (right.contains(value)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java
index 0537e50114e8..5e838e204b21 100644
--- a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java
@@ -29,13 +29,17 @@
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.operation.FileStoreScan;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.PartitionPredicate;
-import org.apache.paimon.schema.SchemaManager;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RangeHelper;
+import org.apache.paimon.utils.RowRangeIndex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -52,6 +56,7 @@
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
+import java.util.function.Supplier;
import static org.apache.paimon.utils.Preconditions.checkArgument;
@@ -70,7 +75,15 @@ public static List toIndexFileMetas(
List entries)
throws IOException {
return toIndexFileMetas(
- fileIO, indexPathFactory, options, range, indexFieldId, null, indexType, entries);
+ fileIO,
+ indexPathFactory,
+ options,
+ range,
+ indexFieldId,
+ null,
+ indexType,
+ entries,
+ null);
}
/**
@@ -86,52 +99,52 @@ public static List toIndexFileMetas(
Range range,
List fields,
String indexType,
- List entries)
+ List entries,
+ @Nullable byte[] sourceMeta)
throws IOException {
- // The first column is the primary index column and is stored as indexFieldId; the
- // remaining columns (if any) go into extraFieldIds.
- int indexFieldId = fields.get(0).id();
- int[] extraFieldIds = extraFieldIds(fields);
return toIndexFileMetas(
fileIO,
indexPathFactory,
options,
range,
- indexFieldId,
- extraFieldIds,
+ fields.get(0).id(),
+ extraFieldIds(fields),
indexType,
- entries);
+ entries,
+ sourceMeta);
}
public static List unindexedRowRanges(
- FileStoreTable table,
- @Nullable Snapshot snapshot,
- String indexType,
- List fields,
- @Nullable PartitionPredicate partitionPredicate) {
+ @Nullable Snapshot snapshot, List currentIndexes) {
if (snapshot == null || snapshot.nextRowId() == null || snapshot.nextRowId() <= 0) {
return Collections.emptyList();
}
Range dataRange = new Range(0, snapshot.nextRowId() - 1);
- List indexedRanges =
- indexedRowRanges(table, snapshot, indexType, fields, partitionPredicate);
+ List indexedRanges = new ArrayList<>(currentIndexes.size());
+ for (IndexManifestEntry entry : currentIndexes) {
+ GlobalIndexMeta meta = entry.indexFile().globalIndexMeta();
+ if (meta != null) {
+ indexedRanges.add(meta.rowRange());
+ }
+ }
+ indexedRanges = Range.sortAndMergeOverlap(indexedRanges, true);
return Range.sortAndMergeOverlap(dataRange.exclude(indexedRanges), true);
}
- public static List indexedRowRanges(
+ public static List currentIndexEntries(
FileStoreTable table,
- @Nullable Snapshot snapshot,
+ Snapshot snapshot,
String indexType,
List fields,
@Nullable PartitionPredicate partitionPredicate) {
- if (snapshot == null || fields.isEmpty()) {
+ if (fields.isEmpty()) {
return Collections.emptyList();
}
int indexFieldId = fields.get(0).id();
int[] extraFieldIds = extraFieldIds(fields);
- List ranges = new ArrayList<>();
+ List entries = new ArrayList<>();
for (IndexManifestEntry entry :
table.store().newIndexFileHandler().scan(snapshot, indexType)) {
if (partitionPredicate != null && !partitionPredicate.test(entry.partition())) {
@@ -147,20 +160,183 @@ public static List indexedRowRanges(
if (!sameExtraFieldIds(meta.extraFieldIds(), extraFieldIds)) {
continue;
}
- ranges.add(meta.rowRange());
+ entries.add(entry);
}
- return Range.sortAndMergeOverlap(ranges, true);
+ return entries;
+ }
+
+ public static List> splitByRowRangeIndex(
+ RowRangeIndex rowRangeIndex, DataSplit dataSplit) {
+ if (rowRangeIndex == null) {
+ Range range = calcRowRange(dataSplit);
+ return range == null
+ ? Collections.emptyList()
+ : Collections.singletonList(Pair.of(range, dataSplit));
+ }
+
+ List> result = new ArrayList<>();
+ for (Split split :
+ DataEvolutionBatchScan.wrapToIndexSplits(
+ Collections.singletonList(dataSplit), rowRangeIndex, null)
+ .splits()) {
+ IndexedSplit indexedSplit = (IndexedSplit) split;
+ for (Range rowRange : indexedSplit.rowRanges()) {
+ result.add(
+ Pair.of(
+ rowRange,
+ new IndexedSplit(
+ indexedSplit.dataSplit(),
+ Collections.singletonList(rowRange),
+ null)));
+ }
+ }
+ return result;
}
@Nullable
- public static List rowRangesAfter(long maxIndexedRowId) {
- if (maxIndexedRowId < 0) {
+ public static Range calcRowRange(DataSplit dataSplit) {
+ List ranges = calcRowRanges(Collections.singletonList(dataSplit));
+ if (ranges.isEmpty()) {
return null;
}
- if (maxIndexedRowId == Long.MAX_VALUE) {
- return Collections.emptyList();
+ return new Range(ranges.get(0).from, ranges.get(ranges.size() - 1).to);
+ }
+
+ public static List calcRowRanges(List dataSplits) {
+ List ranges = new ArrayList<>();
+ for (DataSplit dataSplit : dataSplits) {
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ ranges.add(file.nonNullRowIdRange());
+ }
+ }
+ return Range.sortAndMergeOverlap(ranges, true);
+ }
+
+ public static List splitByContiguousRowRange(List splits) {
+ List result = new ArrayList<>();
+ for (DataSplit split : splits) {
+ result.addAll(splitByContiguousRowRange(split));
}
- return Collections.singletonList(new Range(maxIndexedRowId + 1, Long.MAX_VALUE));
+ return result;
+ }
+
+ public static Map>> groupSplitsByRange(
+ RowRangeIndex rowRangeIndex, List splits) {
+ Map>> partitionSplitRanges = new HashMap<>();
+ for (DataSplit split : splits) {
+ for (Pair keyPair : splitByRowRangeIndex(rowRangeIndex, split)) {
+ Range splitRange = keyPair.getKey();
+ Split splitWithRange = keyPair.getValue();
+ if (splitRange == null) {
+ continue;
+ }
+ BinaryRow partition = split.partition();
+ partitionSplitRanges
+ .computeIfAbsent(partition, p -> new ArrayList<>())
+ .add(Pair.of(splitRange, splitWithRange));
+ }
+ }
+
+ Map>> result = new HashMap<>();
+ for (Map.Entry>> partitionEntry :
+ partitionSplitRanges.entrySet()) {
+ List> splitRanges = partitionEntry.getValue();
+ splitRanges.sort(
+ Comparator.comparingLong((Pair e) -> e.getKey().from)
+ .thenComparingLong(e -> e.getKey().to));
+
+ Map> partitionRanges = new LinkedHashMap<>();
+ Range current = null;
+ List currentSplits = new ArrayList<>();
+ for (Map.Entry entry : splitRanges) {
+ Range splitRange = entry.getKey();
+ if (current == null) {
+ current = splitRange;
+ currentSplits.add(entry.getValue());
+ continue;
+ }
+ Range merged = Range.union(current, splitRange);
+ if (merged != null) {
+ current = merged;
+ currentSplits.add(entry.getValue());
+ } else {
+ partitionRanges.put(current, currentSplits);
+ current = splitRange;
+ currentSplits = new ArrayList<>();
+ currentSplits.add(entry.getValue());
+ }
+ }
+ if (current != null) {
+ partitionRanges.put(current, currentSplits);
+ }
+ result.put(partitionEntry.getKey(), partitionRanges);
+ }
+
+ return result;
+ }
+
+ private static List splitByContiguousRowRange(DataSplit split) {
+ List input = split.dataFiles();
+ RangeHelper rangeHelper = new RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+ List> ranges = rangeHelper.mergeOverlappingRanges(input);
+
+ Supplier builderSupplier =
+ () ->
+ DataSplit.builder()
+ .withSnapshot(split.snapshotId())
+ .withPartition(split.partition())
+ .withBucket(split.bucket())
+ .withBucketPath(split.bucketPath())
+ .withTotalBuckets(split.totalBuckets())
+ .isStreaming(split.isStreaming())
+ .rawConvertible(split.rawConvertible());
+ return packByContiguousRanges(builderSupplier, ranges);
+ }
+
+ private static List packByContiguousRanges(
+ Supplier builderFactory, List> ranges) {
+ if (ranges.isEmpty()) {
+ return new ArrayList<>();
+ }
+
+ List result = new ArrayList<>();
+ List currentSegment = new ArrayList<>();
+ long currentMaxRowId = Long.MIN_VALUE;
+
+ for (List rangeFiles : ranges) {
+ long minRowId = minRowId(rangeFiles);
+ long maxRowId = maxRowId(rangeFiles);
+ if (currentSegment.isEmpty() || areContiguous(currentMaxRowId, minRowId)) {
+ currentSegment.addAll(rangeFiles);
+ currentMaxRowId = maxRowId;
+ } else {
+ DataSplit.Builder builder = builderFactory.get();
+ builder.withDataFiles(currentSegment);
+ result.add(builder.build());
+ currentSegment = new ArrayList<>(rangeFiles);
+ currentMaxRowId = maxRowId;
+ }
+ }
+
+ DataSplit.Builder builder = builderFactory.get();
+ builder.withDataFiles(currentSegment);
+ result.add(builder.build());
+ return result;
+ }
+
+ private static long minRowId(List files) {
+ return files.stream()
+ .mapToLong(f -> f.nonNullRowIdRange().from)
+ .min()
+ .orElse(Long.MAX_VALUE);
+ }
+
+ private static long maxRowId(List files) {
+ return files.stream().mapToLong(f -> f.nonNullRowIdRange().to).max().orElse(Long.MIN_VALUE);
+ }
+
+ private static boolean areContiguous(long previousMaxRowId, long currentMinRowId) {
+ return previousMaxRowId >= currentMinRowId - 1;
}
public static List createShardIndexedSplits(
@@ -197,13 +373,7 @@ public static List createShardIndexedSplits(
}
Map>> entriesByPartitionAndBucket =
- new LinkedHashMap<>();
- for (ManifestEntry entry : entries) {
- entriesByPartitionAndBucket
- .computeIfAbsent(entry.partition(), key -> new LinkedHashMap<>())
- .computeIfAbsent(entry.bucket(), key -> new ArrayList<>())
- .add(entry);
- }
+ FileStoreScan.Plan.groupByPartFiles(entries);
List result = new ArrayList<>();
for (Map.Entry>> partitionEntry :
@@ -349,7 +519,8 @@ private static List toIndexFileMetas(
int indexFieldId,
@Nullable int[] extraFieldIds,
String indexType,
- List entries)
+ List entries,
+ @Nullable byte[] sourceMeta)
throws IOException {
List results = new ArrayList<>();
for (ResultEntry entry : entries) {
@@ -357,7 +528,12 @@ private static List toIndexFileMetas(
long fileSize = fileIO.getFileSize(indexPathFactory.toPath(fileName));
GlobalIndexMeta globalIndexMeta =
new GlobalIndexMeta(
- range.from, range.to, indexFieldId, extraFieldIds, entry.meta());
+ range.from,
+ range.to,
+ indexFieldId,
+ extraFieldIds,
+ entry.meta(),
+ sourceMeta);
Path externalPathDir = options.globalIndexExternalPath();
String externalPathString = null;
@@ -397,70 +573,6 @@ public static GlobalIndexWriter createIndexWriter(
return globalIndexer.createWriter(createGlobalIndexFileReadWrite(table));
}
- /**
- * Find the minimum firstRowId among files whose schema does not contain all index columns.
- * Files at or beyond this rowId cannot be indexed because the column was added later via ALTER
- * TABLE.
- *
- * @return the boundary rowId, or {@link Long#MAX_VALUE} if all files contain the columns
- */
- public static long findMinNonIndexableRowId(
- SchemaManager schemaManager, List entries, List indexColumns) {
- Map schemaContainsColumns = new HashMap<>();
- long minRowId = Long.MAX_VALUE;
- long minSchemaId = -1;
- for (ManifestEntry entry : entries) {
- long sid = entry.file().schemaId();
- boolean contains =
- schemaContainsColumns.computeIfAbsent(
- sid,
- id -> schemaManager.schema(id).fieldNames().containsAll(indexColumns));
- if (!contains && entry.file().firstRowId() != null) {
- long rowId = entry.file().nonNullFirstRowId();
- if (rowId < minRowId) {
- minRowId = rowId;
- minSchemaId = sid;
- }
- }
- }
- if (minRowId != Long.MAX_VALUE) {
- List schemaFields = schemaManager.schema(minSchemaId).fieldNames();
- List missingColumns = new ArrayList<>();
- for (String col : indexColumns) {
- if (!schemaFields.contains(col)) {
- missingColumns.add(col);
- }
- }
- LOG.info(
- "Found non-indexable files: schemaId={} missing columns {}, boundaryRowId={}.",
- minSchemaId,
- missingColumns,
- minRowId);
- }
- return minRowId;
- }
-
- /** Keep only entries whose firstRowId is strictly less than the given boundary. */
- public static List filterEntriesBefore(
- List entries, long boundaryRowId) {
- if (boundaryRowId == Long.MAX_VALUE) {
- return entries;
- }
- List result = new ArrayList<>();
- for (ManifestEntry entry : entries) {
- if (entry.file().firstRowId() != null
- && entry.file().nonNullFirstRowId() < boundaryRowId) {
- result.add(entry);
- }
- }
- LOG.info(
- "Filtered {} files to {} indexable files (boundaryRowId={}).",
- entries.size(),
- result.size(),
- boundaryRowId);
- return result;
- }
-
private static GlobalIndexFileReadWrite createGlobalIndexFileReadWrite(FileStoreTable table) {
IndexPathFactory indexPathFactory = table.store().pathFactory().globalIndexFileFactory();
return new GlobalIndexFileReadWrite(table.fileIO(), indexPathFactory);
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/ScanResult.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/ScanResult.java
new file mode 100644
index 000000000000..619f24b9f263
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/ScanResult.java
@@ -0,0 +1,70 @@
+/*
+ * 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;
+
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.utils.RowRangeIndex;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Result of scanning data files for a global index build. */
+public final class ScanResult {
+
+ private final long scanSnapshotId;
+ private final RowRangeIndex rowRangeIndex;
+ private final List entries;
+ private final List deletedIndexEntries;
+
+ public ScanResult(
+ long scanSnapshotId,
+ RowRangeIndex rowRangeIndex,
+ List entries,
+ List deletedIndexEntries) {
+ checkArgument(scanSnapshotId > 0, "Scan snapshot id must be positive.");
+ this.scanSnapshotId = scanSnapshotId;
+ this.rowRangeIndex = rowRangeIndex;
+ this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
+ this.deletedIndexEntries =
+ Collections.unmodifiableList(new ArrayList<>(deletedIndexEntries));
+ }
+
+ public long scanSnapshotId() {
+ return scanSnapshotId;
+ }
+
+ public RowRangeIndex rowRangeIndex() {
+ return rowRangeIndex;
+ }
+
+ public List entries() {
+ return entries;
+ }
+
+ public List deletedIndexEntries() {
+ return deletedIndexEntries;
+ }
+
+ public ScanResult withEntries(List entries) {
+ return new ScanResult<>(scanSnapshotId, rowRangeIndex, entries, deletedIndexEntries);
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScanner.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScanner.java
new file mode 100644
index 000000000000..1c2ff8da6d51
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScanner.java
@@ -0,0 +1,176 @@
+/*
+ * 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.generic;
+
+import org.apache.paimon.CoreOptions;
+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;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RowRangeIndex;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.currentIndexEntries;
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.unindexedRowRanges;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Scanner for generic (non-btree) global index. */
+public class GenericGlobalIndexScanner implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ protected final FileStoreTable table;
+
+ @Nullable protected PartitionPredicate partitionPredicate;
+ @Nullable private String indexType;
+ private List indexFields = Collections.emptyList();
+ private Options options = new Options();
+
+ public GenericGlobalIndexScanner(FileStoreTable table) {
+ this.table = table;
+ }
+
+ public GenericGlobalIndexScanner withPartitionPredicate(PartitionPredicate partitionPredicate) {
+ this.partitionPredicate = partitionPredicate;
+ return this;
+ }
+
+ public GenericGlobalIndexScanner withIndex(
+ String indexType, List indexColumns, Options options) {
+ checkArgument(!indexColumns.isEmpty(), "Index columns must not be empty.");
+ List indexFields = new ArrayList<>(indexColumns.size());
+ for (String indexColumn : indexColumns) {
+ checkArgument(
+ table.rowType().containsField(indexColumn),
+ "Column '%s' does not exist in table '%s'.",
+ indexColumn,
+ table.fullName());
+ indexFields.add(table.rowType().getField(indexColumn));
+ }
+ this.indexType = indexType;
+ this.indexFields = Collections.unmodifiableList(indexFields);
+ this.options = options;
+ return this;
+ }
+
+ public FileStoreTable table() {
+ return table;
+ }
+
+ /**
+ * Scans manifest entries to determine which files need to be indexed.
+ *
+ * @return scan result containing manifest entries to build index from
+ */
+ public Optional> scan() {
+ checkArgument(
+ table.coreOptions().bucket() == -1,
+ "Generic global index only supports unaware-bucket tables (bucket = -1), "
+ + "but table '%s' has bucket = %d.",
+ table.name(),
+ table.coreOptions().bucket());
+ checkArgument(
+ !table.coreOptions().deletionVectorsEnabled(),
+ "Generic global index does not support tables with deletion vectors enabled. "
+ + "Table '%s' has 'deletion-vectors.enabled' = true, which may cause "
+ + "deleted rows to be indexed.",
+ table.name());
+
+ Snapshot scanSnapshot = table.snapshotManager().latestSnapshot();
+ if (scanSnapshot == null) {
+ return Optional.empty();
+ }
+
+ Long nextRowId = scanSnapshot.nextRowId();
+ if (nextRowId == null || nextRowId <= 0) {
+ return Optional.empty();
+ }
+ Range dataRange = new Range(0, nextRowId - 1);
+
+ List entries =
+ table.store()
+ .newScan()
+ .withSnapshot(scanSnapshot)
+ .withPartitionFilter(partitionPredicate)
+ .dropStats()
+ .plan()
+ .files();
+ return Optional.of(
+ new ScanResult<>(
+ scanSnapshot.id(),
+ RowRangeIndex.create(Collections.singletonList(dataRange)),
+ entries,
+ Collections.emptyList()));
+ }
+
+ public Optional> incrementalScan() {
+ Optional> optionalScanResult = scan();
+ if (!optionalScanResult.isPresent()) {
+ return Optional.empty();
+ }
+
+ checkArgument(indexType != null, "Index type must be set before incremental scan.");
+ checkArgument(!indexFields.isEmpty(), "Index fields must be set before incremental scan.");
+
+ ScanResult scanResult = optionalScanResult.get();
+ Snapshot scanSnapshot = table.snapshotManager().snapshot(scanResult.scanSnapshotId());
+ List currentIndexes =
+ currentIndexEntries(
+ table, scanSnapshot, indexType, indexFields, partitionPredicate);
+ List rangesToBuild =
+ new ArrayList<>(unindexedRowRanges(scanSnapshot, currentIndexes));
+ List deletedIndexEntries = Collections.emptyList();
+ Options mergedOptions = new Options(table.options(), options.toMap());
+ if (mergedOptions.get(CoreOptions.GLOBAL_INDEX_DETECT_DATA_FILE_CHANGE)) {
+ deletedIndexEntries =
+ DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ table.schemaManager(),
+ scanResult.entries(),
+ currentIndexes,
+ indexFields);
+ for (IndexManifestEntry entry : deletedIndexEntries) {
+ rangesToBuild.add(entry.indexFile().globalIndexMeta().rowRange());
+ }
+ }
+
+ rangesToBuild = Range.sortAndMergeOverlap(rangesToBuild, true);
+ if (rangesToBuild.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new ScanResult<>(
+ scanResult.scanSnapshotId(),
+ RowRangeIndex.create(rangesToBuild),
+ scanResult.entries(),
+ deletedIndexEntries));
+ }
+}
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
deleted file mode 100644
index f97e80fca3cb..000000000000
--- a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
+++ /dev/null
@@ -1,469 +0,0 @@
-/*
- * 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.CoreOptions;
-import org.apache.paimon.Snapshot;
-import org.apache.paimon.annotation.VisibleForTesting;
-import org.apache.paimon.data.BinaryRow;
-import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.data.InternalRow.FieldGetter;
-import org.apache.paimon.disk.IOManager;
-import org.apache.paimon.globalindex.DataEvolutionBatchScan;
-import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
-import org.apache.paimon.globalindex.GlobalIndexWriter;
-import org.apache.paimon.globalindex.IndexedSplit;
-import org.apache.paimon.globalindex.ResultEntry;
-import org.apache.paimon.index.IndexFileMeta;
-import org.apache.paimon.io.CompactIncrement;
-import org.apache.paimon.io.DataFileMeta;
-import org.apache.paimon.io.DataIncrement;
-import org.apache.paimon.options.Options;
-import org.apache.paimon.partition.PartitionPredicate;
-import org.apache.paimon.reader.RecordReader;
-import org.apache.paimon.sort.BinaryExternalSortBuffer;
-import org.apache.paimon.table.FileStoreTable;
-import org.apache.paimon.table.SpecialFields;
-import org.apache.paimon.table.Table;
-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.Split;
-import org.apache.paimon.table.source.snapshot.SnapshotReader;
-import org.apache.paimon.types.DataField;
-import org.apache.paimon.types.RowType;
-import org.apache.paimon.utils.CloseableIterator;
-import org.apache.paimon.utils.MutableObjectIteratorAdapter;
-import org.apache.paimon.utils.Pair;
-import org.apache.paimon.utils.Preconditions;
-import org.apache.paimon.utils.Range;
-import org.apache.paimon.utils.RangeHelper;
-import org.apache.paimon.utils.RowRangeIndex;
-
-import javax.annotation.Nullable;
-
-import java.io.IOException;
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.Supplier;
-import java.util.stream.IntStream;
-
-import static java.util.Collections.singletonList;
-import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
-import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.createIndexWriter;
-import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.indexedRowRanges;
-import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.toIndexFileMetas;
-import static org.apache.paimon.types.VectorType.isVectorStoreFile;
-import static org.apache.paimon.utils.Preconditions.checkArgument;
-
-/** Builder to build sorted global index. */
-public class SortedGlobalIndexBuilder implements Serializable {
-
- private static final long serialVersionUID = 1L;
- private static final double FLOATING = 1.2;
- private final String indexType;
- private final FileStoreTable table;
- private final RowType rowType;
- private final Options options;
- private final long recordsPerRange;
-
- private DataField indexField;
-
- // readRowType is composed by partition fields, indexed field and _ROW_ID field
- private RowType readRowType;
- @Nullable private Snapshot snapshot;
-
- @Nullable private PartitionPredicate partitionPredicate;
-
- public SortedGlobalIndexBuilder(Table table, String indexType) {
- this(table, indexType, ((FileStoreTable) table).coreOptions().toConfiguration());
- }
-
- public SortedGlobalIndexBuilder(Table table, String indexType, Options options) {
- this.indexType = indexType;
- this.table = (FileStoreTable) table;
- this.rowType = this.table.rowType();
- this.options = options;
- this.recordsPerRange =
- (long) (options.get(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE) * FLOATING);
- }
-
- public SortedGlobalIndexBuilder withIndexField(String indexField) {
- checkArgument(
- rowType.containsField(indexField),
- "Column '%s' does not exist in table '%s'.",
- indexField,
- table.fullName());
- this.indexField = rowType.getField(indexField);
- List readColumns = new ArrayList<>();
- readColumns.add(this.indexField.name());
- readColumns.add(SpecialFields.ROW_ID.name());
-
- this.readRowType = SpecialFields.rowTypeWithRowId(table.rowType()).project(readColumns);
- return this;
- }
-
- public SortedGlobalIndexBuilder withPartitionPredicate(PartitionPredicate partitionPredicate) {
- this.partitionPredicate = partitionPredicate;
- return this;
- }
-
- public SortedGlobalIndexBuilder withSnapshot(Snapshot snapshot) {
- this.snapshot = snapshot;
- return this;
- }
-
- public Optional>> scan() {
- SnapshotReader snapshotReader = table.newSnapshotReader();
- if (partitionPredicate != null) {
- snapshotReader = snapshotReader.withPartitionFilter(partitionPredicate);
- }
- Snapshot snapshot =
- this.snapshot != null
- ? this.snapshot
- : snapshotReader.snapshotManager().latestSnapshot();
- if (snapshot == null) {
- return Optional.empty();
- }
- snapshotReader = withManifestEntryFilter(snapshotReader.withSnapshot(snapshot));
- Range dataRange = new Range(0, snapshot.nextRowId() - 1);
-
- return Optional.of(
- Pair.of(
- RowRangeIndex.create(Collections.singletonList(dataRange)),
- snapshotReader.read().dataSplits()));
- }
-
- public Optional>> incrementalScan() {
- SnapshotReader snapshotReader = table.newSnapshotReader();
- if (partitionPredicate != null) {
- snapshotReader = snapshotReader.withPartitionFilter(partitionPredicate);
- }
- Snapshot snapshot =
- this.snapshot != null
- ? this.snapshot
- : snapshotReader.snapshotManager().latestSnapshot();
- if (snapshot == null) {
- return Optional.empty();
- }
- snapshotReader = withManifestEntryFilter(snapshotReader.withSnapshot(snapshot));
-
- Preconditions.checkArgument(indexField != null, "indexField must be set before scan.");
- Range dataRange = new Range(0, snapshot.nextRowId() - 1);
- List indexedRanges =
- indexedRowRanges(
- table,
- snapshot,
- indexType,
- Collections.singletonList(indexField),
- partitionPredicate);
- List nonIndexedRanges = dataRange.exclude(indexedRanges);
- if (nonIndexedRanges.isEmpty()) {
- return Optional.empty();
- }
- snapshotReader = snapshotReader.withRowRanges(nonIndexedRanges);
- return Optional.of(
- Pair.of(
- RowRangeIndex.create(nonIndexedRanges),
- snapshotReader.read().dataSplits()));
- }
-
- private SnapshotReader withManifestEntryFilter(SnapshotReader snapshotReader) {
- return snapshotReader.withManifestEntryFilter(
- entry ->
- !isBlobFile(entry.file().fileName())
- && !isVectorStoreFile(entry.file().fileName()));
- }
-
- @VisibleForTesting
- public List build(DataSplit split, IOManager ioManager) throws IOException {
- BinaryRow partition = split.partition();
- Range rowRange = calcRowRange(split);
-
- CoreOptions options = new CoreOptions(this.options);
- BinaryExternalSortBuffer buffer =
- BinaryExternalSortBuffer.create(
- ioManager,
- readRowType,
- // sort by
- IntStream.range(0, readRowType.getFieldCount() - 1).toArray(),
- options.writeBufferSize(),
- options.pageSize(),
- options.localSortMaxNumFileHandles(),
- options.spillCompressOptions(),
- options.writeBufferSpillDiskSize());
-
- List splitList = Collections.singletonList(split);
- RecordReader reader =
- table.newReadBuilder().withReadType(readRowType).newRead().createReader(splitList);
- try (CloseableIterator iterator = reader.toCloseableIterator()) {
- while (iterator.hasNext()) {
- InternalRow row = iterator.next();
- buffer.write(row);
- }
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
-
- Iterator iterator =
- new MutableObjectIteratorAdapter<>(
- buffer.sortedIterator(), new BinaryRow(readRowType.getFieldCount()));
- List result = buildForSinglePartition(rowRange, partition, iterator);
- buffer.clear();
-
- return result;
- }
-
- public long recordsPerRange() {
- return recordsPerRange;
- }
-
- public List buildForSinglePartition(
- Range rowRange, BinaryRow partition, Iterator data) throws IOException {
- FieldGetter indexFieldGetter = InternalRow.createFieldGetter(indexField.type(), 0);
- try (SortedSingleColumnIndexWriter writer =
- new SortedSingleColumnIndexWriter(recordsPerRange, this::createWriter)) {
- while (data.hasNext()) {
- InternalRow row = data.next();
- long localRowId = row.getLong(1) - rowRange.from;
- writer.write(indexFieldGetter.getFieldOrNull(row), localRowId);
- }
-
- List commitMessages = new ArrayList<>();
- for (List resultEntries : writer.finish()) {
- commitMessages.add(flushIndex(rowRange, resultEntries, partition));
- }
- return commitMessages;
- }
- }
-
- public GlobalIndexSingleColumnWriter createWriter() throws IOException {
- GlobalIndexSingleColumnWriter currentWriter;
- GlobalIndexWriter indexWriter = createIndexWriter(table, indexType, indexField, options);
- if (!(indexWriter instanceof GlobalIndexSingleColumnWriter)) {
- throw new RuntimeException(
- "Unexpected implementation, the index writer of "
- + indexType
- + " should be an instance of GlobalIndexSingleColumnWriter, but found: "
- + indexWriter.getClass().getName());
- }
- currentWriter = (GlobalIndexSingleColumnWriter) indexWriter;
- return currentWriter;
- }
-
- public CommitMessage flushIndex(
- Range rowRange, List resultEntries, BinaryRow partition)
- throws IOException {
- List indexFileMetas =
- toIndexFileMetas(
- table.fileIO(),
- table.store().pathFactory().globalIndexFileFactory(),
- table.coreOptions(),
- rowRange,
- indexField.id(),
- indexType,
- resultEntries);
- DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFileMetas);
- return new CommitMessageImpl(
- partition, 0, null, dataIncrement, CompactIncrement.emptyIncrement());
- }
-
- public static List> splitByRowRangeIndex(
- RowRangeIndex rowRangeIndex, DataSplit dataSplit) {
- if (rowRangeIndex == null) {
- Range range = calcRowRange(dataSplit);
- return range == null
- ? Collections.emptyList()
- : Collections.singletonList(Pair.of(range, dataSplit));
- }
-
- List> result = new ArrayList<>();
- for (Split split :
- DataEvolutionBatchScan.wrapToIndexSplits(
- Collections.singletonList(dataSplit), rowRangeIndex, null)
- .splits()) {
- IndexedSplit indexedSplit = (IndexedSplit) split;
- for (Range rowRange : indexedSplit.rowRanges()) {
- result.add(
- Pair.of(
- rowRange,
- new IndexedSplit(
- indexedSplit.dataSplit(),
- Collections.singletonList(rowRange),
- null)));
- }
- }
- return result;
- }
-
- public static Range calcRowRange(DataSplit dataSplit) {
- List ranges = calcRowRanges(singletonList(dataSplit));
- if (ranges.isEmpty()) {
- return null;
- }
- return new Range(ranges.get(0).from, ranges.get(ranges.size() - 1).to);
- }
-
- public static List calcRowRanges(List dataSplits) {
- List ranges = new ArrayList<>();
- for (DataSplit dataSplit : dataSplits) {
- for (DataFileMeta file : dataSplit.dataFiles()) {
- ranges.add(file.nonNullRowIdRange());
- }
- }
- return Range.sortAndMergeOverlap(ranges, true);
- }
-
- public static List splitByContiguousRowRange(List splits) {
- List result = new ArrayList<>();
- for (DataSplit split : splits) {
- result.addAll(splitByContiguousRowRange(split));
- }
- return result;
- }
-
- public static Map>> groupSplitsByRange(
- RowRangeIndex rowRangeIndex, List splits) {
- Map>> partitionSplitRanges = new HashMap<>();
- for (DataSplit split : splits) {
- for (Pair keyPair : splitByRowRangeIndex(rowRangeIndex, split)) {
- Range splitRange = keyPair.getKey();
- Split splitWithRange = keyPair.getValue();
- if (splitRange == null) {
- continue;
- }
- BinaryRow partition = split.partition();
- partitionSplitRanges
- .computeIfAbsent(partition, p -> new ArrayList<>())
- .add(Pair.of(splitRange, splitWithRange));
- }
- }
-
- Map>> result = new HashMap<>();
- for (Map.Entry>> partitionEntry :
- partitionSplitRanges.entrySet()) {
- List> splitRanges = partitionEntry.getValue();
- splitRanges.sort(
- Comparator.comparingLong((Pair e) -> e.getKey().from)
- .thenComparingLong(e -> e.getKey().to));
-
- Map> partitionRanges = new LinkedHashMap<>();
- Range current = null;
- List currentSplits = new ArrayList<>();
- for (Map.Entry entry : splitRanges) {
- Range splitRange = entry.getKey();
- if (current == null) {
- current = splitRange;
- currentSplits.add(entry.getValue());
- continue;
- }
- Range merged = Range.union(current, splitRange);
- if (merged != null) {
- current = merged;
- currentSplits.add(entry.getValue());
- } else {
- partitionRanges.put(current, currentSplits);
- current = splitRange;
- currentSplits = new ArrayList<>();
- currentSplits.add(entry.getValue());
- }
- }
- if (current != null) {
- partitionRanges.put(current, currentSplits);
- }
- result.put(partitionEntry.getKey(), partitionRanges);
- }
-
- return result;
- }
-
- private static List splitByContiguousRowRange(DataSplit split) {
- List input = split.dataFiles();
- RangeHelper rangeHelper = new RangeHelper<>(DataFileMeta::nonNullRowIdRange);
- List> ranges = rangeHelper.mergeOverlappingRanges(input);
-
- Supplier builderSupplier =
- () ->
- DataSplit.builder()
- .withSnapshot(split.snapshotId())
- .withPartition(split.partition())
- .withBucket(split.bucket())
- .withBucketPath(split.bucketPath())
- .withTotalBuckets(split.totalBuckets())
- .isStreaming(split.isStreaming())
- .rawConvertible(split.rawConvertible());
- return packByContiguousRanges(builderSupplier, ranges);
- }
-
- private static List packByContiguousRanges(
- Supplier builderFactory, List> ranges) {
- if (ranges.isEmpty()) {
- return new ArrayList<>();
- }
-
- List result = new ArrayList<>();
- List currentSegment = new ArrayList<>();
- long currentMaxRowId = Long.MIN_VALUE;
-
- for (List rangeFiles : ranges) {
- long minRowId = minRowId(rangeFiles);
- long maxRowId = maxRowId(rangeFiles);
- if (currentSegment.isEmpty() || areContiguous(currentMaxRowId, minRowId)) {
- currentSegment.addAll(rangeFiles);
- currentMaxRowId = maxRowId;
- } else {
- DataSplit.Builder builder = builderFactory.get();
- builder.withDataFiles(currentSegment);
- result.add(builder.build());
- currentSegment = new ArrayList<>(rangeFiles);
- currentMaxRowId = maxRowId;
- }
- }
-
- DataSplit.Builder builder = builderFactory.get();
- builder.withDataFiles(currentSegment);
- result.add(builder.build());
- return result;
- }
-
- private static long minRowId(List files) {
- return files.stream()
- .mapToLong(f -> f.nonNullRowIdRange().from)
- .min()
- .orElse(Long.MAX_VALUE);
- }
-
- private static long maxRowId(List files) {
- return files.stream().mapToLong(f -> f.nonNullRowIdRange().to).max().orElse(Long.MIN_VALUE);
- }
-
- private static boolean areContiguous(long previousMaxRowId, long currentMinRowId) {
- // Contiguous means no gap between adjacent ranges.
- // e.g. previous max == current min (as requested) or previous max + 1 == current min.
- return previousMaxRowId >= currentMinRowId - 1;
- }
-}
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
new file mode 100644
index 000000000000..39177cb1d5f0
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScanner.java
@@ -0,0 +1,190 @@
+/*
+ * 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.CoreOptions;
+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;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.snapshot.SnapshotReader;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RowRangeIndex;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.currentIndexEntries;
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.unindexedRowRanges;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Scanner for sorted global indexes. */
+public class SortedGlobalIndexScanner implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ private final String indexType;
+ private final FileStoreTable table;
+ private final RowType rowType;
+ private final Options options;
+
+ private DataField indexField;
+
+ @Nullable private Snapshot snapshot;
+
+ @Nullable private PartitionPredicate partitionPredicate;
+
+ public SortedGlobalIndexScanner(Table table, String indexType) {
+ this(table, indexType, ((FileStoreTable) table).coreOptions().toConfiguration());
+ }
+
+ public SortedGlobalIndexScanner(Table table, String indexType, Options options) {
+ this.indexType = indexType;
+ this.table = (FileStoreTable) table;
+ this.rowType = this.table.rowType();
+ this.options = options;
+ }
+
+ public SortedGlobalIndexScanner withIndexField(String indexField) {
+ checkArgument(
+ rowType.containsField(indexField),
+ "Column '%s' does not exist in table '%s'.",
+ indexField,
+ table.fullName());
+ this.indexField = rowType.getField(indexField);
+ return this;
+ }
+
+ public SortedGlobalIndexScanner withPartitionPredicate(PartitionPredicate partitionPredicate) {
+ this.partitionPredicate = partitionPredicate;
+ return this;
+ }
+
+ public SortedGlobalIndexScanner withSnapshot(Snapshot snapshot) {
+ this.snapshot = snapshot;
+ return this;
+ }
+
+ public Optional> scan() {
+ SnapshotReader snapshotReader = table.newSnapshotReader();
+ if (partitionPredicate != null) {
+ snapshotReader = snapshotReader.withPartitionFilter(partitionPredicate);
+ }
+ Snapshot snapshot =
+ this.snapshot != null
+ ? this.snapshot
+ : snapshotReader.snapshotManager().latestSnapshot();
+ if (snapshot == null) {
+ return Optional.empty();
+ }
+ snapshotReader = withManifestEntryFilter(snapshotReader.withSnapshot(snapshot));
+ Range dataRange = new Range(0, snapshot.nextRowId() - 1);
+
+ return Optional.of(
+ new ScanResult<>(
+ snapshot.id(),
+ RowRangeIndex.create(Collections.singletonList(dataRange)),
+ snapshotReader.read().dataSplits(),
+ Collections.emptyList()));
+ }
+
+ public Optional> incrementalScan() {
+ SnapshotReader snapshotReader = table.newSnapshotReader();
+ if (partitionPredicate != null) {
+ snapshotReader = snapshotReader.withPartitionFilter(partitionPredicate);
+ }
+ Snapshot snapshot =
+ this.snapshot != null
+ ? this.snapshot
+ : snapshotReader.snapshotManager().latestSnapshot();
+ if (snapshot == null) {
+ return Optional.empty();
+ }
+ snapshotReader = withManifestEntryFilter(snapshotReader.withSnapshot(snapshot));
+
+ Preconditions.checkArgument(indexField != null, "indexField must be set before scan.");
+ List currentIndexes =
+ currentIndexEntries(
+ table,
+ snapshot,
+ indexType,
+ Collections.singletonList(indexField),
+ partitionPredicate);
+ List rangesToBuild = new ArrayList<>(unindexedRowRanges(snapshot, currentIndexes));
+ List deletedIndexEntries = Collections.emptyList();
+ if (detectDataFileChange()) {
+ List dataEntries =
+ table.store()
+ .newScan()
+ .withSnapshot(snapshot)
+ .withPartitionFilter(partitionPredicate)
+ .dropStats()
+ .plan()
+ .files();
+ deletedIndexEntries =
+ DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ table.schemaManager(),
+ dataEntries,
+ currentIndexes,
+ Collections.singletonList(indexField));
+ for (IndexManifestEntry entry : deletedIndexEntries) {
+ rangesToBuild.add(entry.indexFile().globalIndexMeta().rowRange());
+ }
+ }
+
+ rangesToBuild = Range.sortAndMergeOverlap(rangesToBuild, true);
+ if (rangesToBuild.isEmpty()) {
+ return Optional.empty();
+ }
+ snapshotReader = snapshotReader.withRowRanges(rangesToBuild);
+ return Optional.of(
+ new ScanResult<>(
+ snapshot.id(),
+ RowRangeIndex.create(rangesToBuild),
+ snapshotReader.read().dataSplits(),
+ deletedIndexEntries));
+ }
+
+ private boolean detectDataFileChange() {
+ return new Options(table.options(), options.toMap())
+ .get(CoreOptions.GLOBAL_INDEX_DETECT_DATA_FILE_CHANGE);
+ }
+
+ private SnapshotReader withManifestEntryFilter(SnapshotReader snapshotReader) {
+ return snapshotReader.withManifestEntryFilter(
+ entry ->
+ !isBlobFile(entry.file().fileName())
+ && !isVectorStoreFile(entry.file().fileName()));
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexWriter.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexWriter.java
new file mode 100644
index 000000000000..52e6d12ee893
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexWriter.java
@@ -0,0 +1,145 @@
+/*
+ * 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.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.InternalRow.FieldGetter;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.createIndexWriter;
+import static org.apache.paimon.globalindex.GlobalIndexBuilderUtils.toIndexFileMetas;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Writer for sorted global indexes. */
+public class SortedGlobalIndexWriter implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ private static final double FLOATING = 1.2;
+
+ private final String indexType;
+ private final FileStoreTable table;
+ private final RowType rowType;
+ private final Options options;
+ private final long recordsPerRange;
+
+ private DataField indexField;
+
+ public SortedGlobalIndexWriter(Table table, String indexType) {
+ this(table, indexType, ((FileStoreTable) table).coreOptions().toConfiguration());
+ }
+
+ public SortedGlobalIndexWriter(Table table, String indexType, Options options) {
+ this.indexType = indexType;
+ this.table = (FileStoreTable) table;
+ this.rowType = this.table.rowType();
+ this.options = options;
+ this.recordsPerRange =
+ (long) (options.get(SortedIndexOptions.SORTED_INDEX_RECORDS_PER_RANGE) * FLOATING);
+ }
+
+ public SortedGlobalIndexWriter withIndexField(String indexField) {
+ checkArgument(
+ rowType.containsField(indexField),
+ "Column '%s' does not exist in table '%s'.",
+ indexField,
+ table.fullName());
+ this.indexField = rowType.getField(indexField);
+ return this;
+ }
+
+ public long recordsPerRange() {
+ return recordsPerRange;
+ }
+
+ public List buildForSinglePartition(
+ Range rowRange, BinaryRow partition, Iterator data, long scanSnapshotId)
+ throws IOException {
+ FieldGetter indexFieldGetter = InternalRow.createFieldGetter(indexField.type(), 0);
+ try (SortedSingleColumnIndexWriter writer =
+ new SortedSingleColumnIndexWriter(recordsPerRange, this::createWriter)) {
+ while (data.hasNext()) {
+ InternalRow row = data.next();
+ long localRowId = row.getLong(1) - rowRange.from;
+ writer.write(indexFieldGetter.getFieldOrNull(row), localRowId);
+ }
+
+ List commitMessages = new ArrayList<>();
+ for (List resultEntries : writer.finish()) {
+ commitMessages.add(flushIndex(rowRange, resultEntries, partition, scanSnapshotId));
+ }
+ return commitMessages;
+ }
+ }
+
+ public GlobalIndexSingleColumnWriter createWriter() throws IOException {
+ GlobalIndexWriter indexWriter = createIndexWriter(table, indexType, indexField, options);
+ if (!(indexWriter instanceof GlobalIndexSingleColumnWriter)) {
+ throw new RuntimeException(
+ "Unexpected implementation, the index writer of "
+ + indexType
+ + " should be an instance of GlobalIndexSingleColumnWriter, but found: "
+ + indexWriter.getClass().getName());
+ }
+ return (GlobalIndexSingleColumnWriter) indexWriter;
+ }
+
+ public CommitMessage flushIndex(
+ Range rowRange,
+ List resultEntries,
+ BinaryRow partition,
+ long scanSnapshotId)
+ throws IOException {
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ List indexFileMetas =
+ toIndexFileMetas(
+ table.fileIO(),
+ table.store().pathFactory().globalIndexFileFactory(),
+ table.coreOptions(),
+ rowRange,
+ Collections.singletonList(indexField),
+ indexType,
+ resultEntries,
+ sourceMeta);
+ DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFileMetas);
+ return new CommitMessageImpl(
+ partition, 0, null, dataIncrement, CompactIncrement.emptyIncrement());
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java b/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java
new file mode 100644
index 000000000000..fb39bc2f8ea1
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java
@@ -0,0 +1,101 @@
+/*
+ * 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;
+
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Data snapshot scanned to build a global index for a data-evolution table. */
+public final class DataEvolutionIndexSourceMeta {
+
+ // "DEIX". The marker distinguishes this metadata from primary-key index source metadata.
+ private static final int MAGIC = 0x44454958;
+ private static final int VERSION = 1;
+
+ private final long scanSnapshotId;
+
+ public DataEvolutionIndexSourceMeta(long scanSnapshotId) {
+ checkArgument(scanSnapshotId > 0, "Scan snapshot id must be positive.");
+ this.scanSnapshotId = scanSnapshotId;
+ }
+
+ public long scanSnapshotId() {
+ return scanSnapshotId;
+ }
+
+ public byte[] serialize() {
+ try {
+ DataOutputSerializer output = new DataOutputSerializer(16);
+ output.writeInt(MAGIC);
+ output.writeInt(VERSION);
+ output.writeLong(scanSnapshotId);
+ return output.getCopyOfBuffer();
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Failed to serialize data-evolution index source metadata.", e);
+ }
+ }
+
+ public static boolean isDataEvolutionMeta(@Nullable byte[] bytes) {
+ if (bytes == null || bytes.length < Integer.BYTES) {
+ return false;
+ }
+ try {
+ return new DataInputDeserializer(bytes).readInt() == MAGIC;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ public static DataEvolutionIndexSourceMeta deserialize(byte[] bytes) {
+ try {
+ DataInputDeserializer input = new DataInputDeserializer(bytes);
+ int magic = input.readInt();
+ checkArgument(magic == MAGIC, "Not data-evolution index source metadata.");
+ int version = input.readInt();
+ checkArgument(
+ version == VERSION,
+ "Unsupported data-evolution index source version: %s.",
+ version);
+ long scanSnapshotId = input.readLong();
+ checkArgument(
+ input.available() == 0,
+ "Unexpected trailing bytes in data-evolution index source metadata.");
+ return new DataEvolutionIndexSourceMeta(scanSnapshotId);
+ } catch (IOException e) {
+ throw new IllegalArgumentException(
+ "Failed to deserialize data-evolution index source metadata.", e);
+ }
+ }
+
+ public static DataEvolutionIndexSourceMeta fromIndexFile(IndexFileMeta indexFile) {
+ GlobalIndexMeta globalIndexMeta = indexFile.globalIndexMeta();
+ checkArgument(
+ globalIndexMeta != null && isDataEvolutionMeta(globalIndexMeta.sourceMeta()),
+ "Index file %s has no data-evolution source metadata.",
+ indexFile.fileName());
+ return deserialize(globalIndexMeta.sourceMeta());
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
index a46762c1af77..484ae957d9b1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
@@ -62,16 +62,23 @@ public Path indexManifestFilePath(String fileName) {
return pathFactory.toPath(fileName);
}
- /** Write new index files to index manifest. */
+ /**
+ * Write new index files to index manifest.
+ *
+ * @param ignoreMissingGlobalIndexDelete whether to ignore deleting a global index file which
+ * does not exist in the previous index manifest
+ */
@Nullable
public String writeIndexFiles(
@Nullable String previousIndexManifest,
List newIndexFiles,
- BucketMode bucketMode) {
+ BucketMode bucketMode,
+ boolean ignoreMissingGlobalIndexDelete) {
if (newIndexFiles.isEmpty()) {
return previousIndexManifest;
}
- IndexManifestFileHandler handler = new IndexManifestFileHandler(this, bucketMode);
+ IndexManifestFileHandler handler =
+ new IndexManifestFileHandler(this, bucketMode, ignoreMissingGlobalIndexDelete);
return handler.write(previousIndexManifest, newIndexFiles);
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
index 486f39fef4da..45a8705f33d4 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
@@ -19,6 +19,7 @@
package org.apache.paimon.manifest;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.DeletionVectorMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
@@ -51,9 +52,19 @@ public class IndexManifestFileHandler {
private final BucketMode bucketMode;
+ private final boolean ignoreMissingGlobalIndexDelete;
+
IndexManifestFileHandler(IndexManifestFile indexManifestFile, BucketMode bucketMode) {
+ this(indexManifestFile, bucketMode, false);
+ }
+
+ IndexManifestFileHandler(
+ IndexManifestFile indexManifestFile,
+ BucketMode bucketMode,
+ boolean ignoreMissingGlobalIndexDelete) {
this.indexManifestFile = indexManifestFile;
this.bucketMode = bucketMode;
+ this.ignoreMissingGlobalIndexDelete = ignoreMissingGlobalIndexDelete;
}
String write(@Nullable String previousIndexManifest, List newIndexFiles) {
@@ -96,7 +107,7 @@ private Map> separateIndexEntries(
private IndexManifestFileCombiner getIndexManifestFileCombine(String indexType) {
if (!DELETION_VECTORS_INDEX.equals(indexType) && !HASH_INDEX.equals(indexType)) {
- return new GlobalIndexCombiner();
+ return new GlobalIndexCombiner(ignoreMissingGlobalIndexDelete);
}
if (DELETION_VECTORS_INDEX.equals(indexType) && BucketMode.BUCKET_UNAWARE == bucketMode) {
@@ -202,6 +213,12 @@ public List combine(
/** We combine the previous and new index files by file name. */
static class GlobalIndexCombiner implements IndexManifestFileCombiner {
+ private final boolean ignoreMissingDelete;
+
+ GlobalIndexCombiner(boolean ignoreMissingDelete) {
+ this.ignoreMissingDelete = ignoreMissingDelete;
+ }
+
@Override
public List combine(
List prevIndexFiles, List newIndexFiles) {
@@ -220,7 +237,12 @@ public List combine(
.filter(f -> f.kind() == FileKind.ADD)
.collect(Collectors.toList());
for (IndexManifestEntry entry : removed) {
- indexEntries.remove(entry.indexFile().fileName());
+ String fileName = entry.indexFile().fileName();
+ checkState(
+ ignoreMissingDelete || indexEntries.containsKey(fileName),
+ "Trying to delete global index file %s which does not exist.",
+ fileName);
+ indexEntries.remove(fileName);
}
validateRetainedIndexFiles(indexEntries.values(), added);
for (IndexManifestEntry entry : added) {
@@ -241,7 +263,12 @@ private void validateRetainedIndexFiles(
for (IndexManifestEntry added : addedIndexFiles) {
GlobalIndexMeta addedMeta = added.indexFile().globalIndexMeta();
if (addedMeta == null
- || (retainedMeta.sourceMeta() != null && addedMeta.sourceMeta() != null)
+ || (retainedMeta.sourceMeta() != null
+ && addedMeta.sourceMeta() != null
+ && !DataEvolutionIndexSourceMeta.isDataEvolutionMeta(
+ retainedMeta.sourceMeta())
+ && !DataEvolutionIndexSourceMeta.isDataEvolutionMeta(
+ addedMeta.sourceMeta()))
|| retainedMeta.indexFieldId() != addedMeta.indexFieldId()
|| (Arrays.equals(
retainedMeta.extraFieldIds(), addedMeta.extraFieldIds())
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
index 06cde74f1f2a..40a775739907 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
@@ -62,6 +62,7 @@
import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
import static org.apache.paimon.manifest.ManifestFileMeta.allContainsRowId;
import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.DataEvolutionUtils.fileFieldIds;
import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
/** {@link FileStoreScan} for data-evolution enabled table. */
@@ -251,24 +252,7 @@ private List pruneByReadType(List group) {
private Set fileFieldIdsForEntry(ManifestEntry entry) {
return fileFieldIdsCache.computeIfAbsent(
Pair.of(entry.file().schemaId(), entry.file().writeCols()),
- pair -> computeFileFieldIds(this::scanTableSchema, entry.file()));
- }
-
- /**
- * Field ids of the columns physically present in {@code file}, resolved through the file's own
- * schema (i.e. the schema the file was written under). Field id, not field name, is the stable
- * identity across schemas — necessary so a renamed column matches an old file written under the
- * pre-rename name.
- */
- @VisibleForTesting
- static Set computeFileFieldIds(
- Function scanTableSchema, DataFileMeta file) {
- Set ids = new HashSet<>();
- for (DataField f :
- scanTableSchema.apply(file.schemaId()).project(file.writeCols()).fields()) {
- ids.add(f.id());
- }
- return ids;
+ pair -> fileFieldIds(this::scanTableSchema, entry.file()));
}
/** TODO: Optimize implementation of this method. */
@@ -283,9 +267,7 @@ static EvolutionStats evolutionStats(
entry ->
isBlobFile(entry.file().fileName())
|| isVectorStoreFile(entry.file().fileName()))
- .flatMap(
- entry ->
- computeFileFieldIds(scanTableSchema, entry.file()).stream())
+ .flatMap(entry -> fileFieldIds(scanTableSchema, entry.file()).stream())
.collect(Collectors.toSet());
// exclude blob and vector-store files, useless for predicate eval
metas =
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 1eacfa71f78d..7de0c6b76e4c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -1121,7 +1121,11 @@ CommitResult tryCommitOnce(
}
indexManifest =
- indexManifestFile.writeIndexFiles(oldIndexManifest, indexFiles, bucketMode);
+ indexManifestFile.writeIndexFiles(
+ oldIndexManifest,
+ indexFiles,
+ bucketMode,
+ options.globalIndexIgnoreMissingDelete());
long latestSchemaId =
schemaManager
diff --git a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
index 57ba93ae199d..c1294f39462f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
@@ -19,10 +19,14 @@
package org.apache.paimon.utils;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
import java.util.Collection;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -34,6 +38,24 @@
/** Util class for data evolution. */
public class DataEvolutionUtils {
+ /**
+ * Table field ids physically present in a file, resolved through the schema used to write it.
+ */
+ public static Set fileFieldIds(
+ Function scanTableSchema, DataFileMeta file) {
+ TableSchema schema = scanTableSchema.apply(file.schemaId());
+ List writeCols = file.writeCols();
+ Set writeColNames = writeCols == null ? null : new HashSet<>(writeCols);
+ Set ids = new HashSet<>();
+ for (DataField field : schema.fields()) {
+ // writeCols may also contain physical row-tracking fields outside the table schema.
+ if (writeColNames == null || writeColNames.contains(field.name())) {
+ ids.add(field.id());
+ }
+ }
+ return ids;
+ }
+
/**
* Retrieve the anchor file of a row range group. Always the oldest normal file. Files are
* compared by (max_seq, fileName) pairs.
diff --git a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
index 1eefc0377c51..be683e1b7b85 100644
--- a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
@@ -140,6 +140,24 @@ public void testIndexSearchModes() {
.isEqualTo(CoreOptions.GlobalIndexSearchMode.FULL);
}
+ @Test
+ public void testGlobalIndexIgnoreMissingDelete() {
+ Options conf = new Options();
+ assertThat(new CoreOptions(conf).globalIndexIgnoreMissingDelete()).isFalse();
+
+ conf.set(CoreOptions.GLOBAL_INDEX_IGNORE_MISSING_DELETE, true);
+ assertThat(new CoreOptions(conf).globalIndexIgnoreMissingDelete()).isTrue();
+ }
+
+ @Test
+ public void testGlobalIndexDetectDataFileChange() {
+ Options conf = new Options();
+ assertThat(new CoreOptions(conf).globalIndexDetectDataFileChange()).isFalse();
+
+ conf.set(CoreOptions.GLOBAL_INDEX_DETECT_DATA_FILE_CHANGE, true);
+ assertThat(new CoreOptions(conf).globalIndexDetectDataFileChange()).isTrue();
+ }
+
@Test
public void testBlobSplitByFileSizeDefault() {
Options conf = new Options();
diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index bdc963e678b3..6078259b761d 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -44,7 +44,9 @@
import org.apache.paimon.fs.FileIOFinder;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
-import org.apache.paimon.globalindex.sorted.SortedGlobalIndexBuilder;
+import org.apache.paimon.globalindex.ScanResult;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexScanner;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexTestUtils;
import org.apache.paimon.index.HashBucketAssigner;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
@@ -74,6 +76,7 @@
import org.apache.paimon.table.sink.InnerTableCommit;
import org.apache.paimon.table.sink.StreamTableCommit;
import org.apache.paimon.table.sink.StreamTableWrite;
+import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.EndOfScanException;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.Split;
@@ -656,19 +659,8 @@ public void testBtreeRawFallbackWrite() throws Exception {
commit.commit(write.prepareCommit());
}
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "btree").withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, "btree", "k"));
}
try (BatchTableWrite write = writeBuilder.newWrite();
@@ -733,19 +725,8 @@ public void testBitmapIndexWrite() throws Exception {
}
// build index
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "bitmap").withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, "bitmap", "k"));
}
// assert index
@@ -836,19 +817,8 @@ private void testBtreeIndexWriteGeneric(
}
// build index
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "btree").withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, "btree", "k"));
}
// assert index
@@ -917,19 +887,8 @@ private void writeCompressedGlobalIndexTable(
commit.commit(write.prepareCommit());
}
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, indexType).withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, indexType, "k"));
}
List indexEntries =
@@ -997,19 +956,8 @@ private void testBtreeIndexWriteLarge() throws Exception {
}
// build index
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "btree").withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, "btree", "k"));
}
// assert index
@@ -1076,19 +1024,8 @@ private void testBtreeIndexWriteNull() throws Exception {
}
// build index
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "btree").withIndexField("k");
try (BatchTableCommit commit = writeBuilder.newCommit()) {
- commit.commit(
- builder.build(
- builder.scan()
- .map(org.apache.paimon.utils.Pair::getValue)
- .orElseThrow(
- () ->
- new IllegalStateException(
- "Expected scan result when building index."))
- .get(0),
- IOManager.create(warehouse.toString())));
+ commit.commit(buildSortedIndex(table, "btree", "k"));
}
// assert index
@@ -2057,6 +1994,29 @@ private Map anchorFilesByRange(FileStoreTable table) {
return result;
}
+ private List buildSortedIndex(
+ FileStoreTable table, String indexType, String indexFieldName) throws Exception {
+ SortedGlobalIndexScanner scanner =
+ new SortedGlobalIndexScanner(table, indexType).withIndexField(indexFieldName);
+ ScanResult scanResult =
+ scanner.scan()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "Expected scan result when building index."));
+ List commitMessages = new ArrayList<>();
+ for (DataSplit dataSplit : scanResult.entries()) {
+ commitMessages.addAll(
+ SortedGlobalIndexTestUtils.buildIndex(
+ table,
+ indexType,
+ indexFieldName,
+ dataSplit,
+ scanResult.scanSnapshotId()));
+ }
+ return commitMessages;
+ }
+
private static class E2eDvSpec {
private final Range range;
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index 5e5b2ad923ad..760671113d01 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -27,8 +27,12 @@
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.ScanResult;
import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
-import org.apache.paimon.globalindex.sorted.SortedGlobalIndexBuilder;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexScanner;
+import org.apache.paimon.globalindex.sorted.SortedGlobalIndexTestUtils;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
@@ -1626,6 +1630,27 @@ public void testReassignGlobalIndexRowRanges() throws Exception {
assertThat(readPayloads(table, predicate)).containsExactly("v4");
}
+ @Test
+ public void testReassignPreservesGlobalIndexSourceMeta() throws Exception {
+ FileStoreTable table = createTableWithInterleavedPartitions();
+ createBTreeIndex(table);
+ long scanSnapshotId = table.snapshotManager().latestSnapshot().id();
+ setGlobalIndexSourceMeta(table, scanSnapshotId);
+
+ new DataEvolutionRowIdReassigner(table).reassign("test-preserve-index-source-meta");
+
+ List entries = table.store().newIndexFileHandler().scanEntries();
+ assertThat(entries).isNotEmpty();
+ assertThat(entries)
+ .allSatisfy(
+ entry ->
+ assertThat(
+ DataEvolutionIndexSourceMeta.fromIndexFile(
+ entry.indexFile())
+ .scanSnapshotId())
+ .isEqualTo(scanSnapshotId));
+ }
+
@Test
public void testReassignKeepsConcurrentDisjointGlobalIndexRange() throws Exception {
FileStoreTable table = createTableWithInterleavedPartitions();
@@ -2762,24 +2787,60 @@ private List dataManifestFileNames(FileStoreTable table) {
}
private void createBTreeIndex(FileStoreTable table) throws Exception {
- SortedGlobalIndexBuilder builder =
- new SortedGlobalIndexBuilder(table, "btree").withIndexField("id");
- List dataSplits =
+ SortedGlobalIndexScanner builder =
+ new SortedGlobalIndexScanner(table, "btree").withIndexField("id");
+ ScanResult scanResult =
builder.scan()
- .map(Pair::getRight)
.orElseThrow(
() ->
new IllegalStateException(
"Expected scan result when building index."));
List commitMessages = new ArrayList<>();
- for (DataSplit dataSplit : SortedGlobalIndexBuilder.splitByContiguousRowRange(dataSplits)) {
- commitMessages.addAll(builder.build(dataSplit, ioManager));
+ for (DataSplit dataSplit :
+ GlobalIndexBuilderUtils.splitByContiguousRowRange(scanResult.entries())) {
+ commitMessages.addAll(
+ SortedGlobalIndexTestUtils.buildIndex(
+ table, "btree", "id", dataSplit, scanResult.scanSnapshotId()));
}
try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) {
commit.commit(commitMessages);
}
}
+ private void setGlobalIndexSourceMeta(FileStoreTable table, long scanSnapshotId)
+ throws Exception {
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ IndexManifestFile indexManifestFile = table.store().indexManifestFileFactory().create();
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ List rewritten = new ArrayList<>();
+ for (IndexManifestEntry entry : indexManifestFile.read(latest.indexManifest())) {
+ IndexFileMeta indexFile = entry.indexFile();
+ GlobalIndexMeta globalIndex = indexFile.globalIndexMeta();
+ assertThat(globalIndex).isNotNull();
+ rewritten.add(
+ new IndexManifestEntry(
+ entry.kind(),
+ entry.partition(),
+ entry.bucket(),
+ new IndexFileMeta(
+ indexFile.indexType(),
+ indexFile.fileName(),
+ indexFile.fileSize(),
+ indexFile.rowCount(),
+ indexFile.dvRanges(),
+ indexFile.externalPath(),
+ new GlobalIndexMeta(
+ globalIndex.rowRangeStart(),
+ globalIndex.rowRangeEnd(),
+ globalIndex.indexFieldId(),
+ globalIndex.extraFieldIds(),
+ globalIndex.indexMeta(),
+ sourceMeta))));
+ }
+ replaceLatestSnapshotIndexManifest(
+ table, latest, indexManifestFile.writeWithoutRolling(rewritten));
+ }
+
private void replaceGlobalIndexRangesWithPartitionSpanningRanges(FileStoreTable table)
throws Exception {
Snapshot latest = table.snapshotManager().latestSnapshot();
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
new file mode 100644
index 000000000000..68d1a26218de
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
@@ -0,0 +1,494 @@
+/*
+ * 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;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.IntType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Random;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link DataEvolutionGlobalIndexRefreshPlanner}. */
+class DataEvolutionGlobalIndexRefreshPlannerTest {
+
+ private static final DataField VECTOR_FIELD =
+ new DataField(1, "vector", new ArrayType(new FloatType()));
+ private static final DataField OTHER_FIELD = new DataField(2, "other", new IntType());
+ private static final DataField UNRELATED_FIELD = new DataField(3, "unrelated", new IntType());
+
+ private SchemaManager schemaManager;
+
+ @BeforeEach
+ void beforeEach() {
+ schemaManager = mock(SchemaManager.class);
+ when(schemaManager.schema(0L))
+ .thenReturn(
+ new TableSchema(
+ 0L,
+ Collections.singletonList(OTHER_FIELD),
+ 1,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ when(schemaManager.schema(1L))
+ .thenReturn(
+ new TableSchema(
+ 1L,
+ Arrays.asList(VECTOR_FIELD, OTHER_FIELD, UNRELATED_FIELD),
+ 3,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ when(schemaManager.schema(2L))
+ .thenReturn(
+ new TableSchema(
+ 2L,
+ Arrays.asList(
+ new DataField(
+ 1,
+ "renamed_vector",
+ new ArrayType(new FloatType())),
+ OTHER_FIELD,
+ UNRELATED_FIELD),
+ 3,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ }
+
+ @Test
+ void testRefreshesOnlyForNewPhysicalIndexColumnFile() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("vector-update", 0, 100, 6, 1, "vector")),
+ index))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("old-vector", 0, 100, 5, 1, "vector")),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("other-update", 0, 100, 6, 1, "other")),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("outside", 100, 100, 6, 1, "vector")),
+ index))
+ .isEmpty();
+ }
+
+ @Test
+ void testLegacyIndexIsNotRefreshedAutomatically() {
+ IndexManifestEntry legacy = index("legacy", 0, 99, null, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(plan(Collections.singletonList(data("base", 0, 100, 0, 1, "vector")), legacy))
+ .isEmpty();
+ }
+
+ @Test
+ void testUsesStableFieldIdAcrossRenameAndFullWrites() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("renamed", 0, 100, 6, 2, "renamed_vector")),
+ index))
+ .containsExactly(index);
+ assertThat(plan(Collections.singletonList(data("full", 0, 100, 6, 1)), index))
+ .containsExactly(index);
+ }
+
+ @Test
+ void testRefreshesFromUpdateLayerOverBaseSchemaWithoutIndexColumn() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+ ManifestEntry baseWithoutVector = data("base", 0, 100, 1, 0);
+ ManifestEntry vectorUpdate = data("vector-update", 0, 100, 6, 1, "vector");
+
+ assertThat(plan(Arrays.asList(baseWithoutVector, vectorUpdate), index))
+ .containsExactly(index);
+ }
+
+ @Test
+ void testHandlesSystemAndEmptyPhysicalColumns() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data(
+ "vector-with-system-fields",
+ 0,
+ 100,
+ 6,
+ 1,
+ SpecialFields.ROW_ID.name(),
+ "vector",
+ SpecialFields.SEQUENCE_NUMBER.name())),
+ index))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data(
+ "system-only",
+ 0,
+ 100,
+ 6,
+ 1,
+ SpecialFields.ROW_ID.name(),
+ SpecialFields.SEQUENCE_NUMBER.name())),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ dataWithWriteCols(
+ "empty", 0, 100, 6, 1, Collections.emptyList())),
+ index))
+ .isEmpty();
+ }
+
+ @Test
+ void testMultiColumnIndexRefreshesForEitherIndexedField() {
+ List indexedFields = Arrays.asList(VECTOR_FIELD, OTHER_FIELD);
+ IndexManifestEntry index =
+ index(
+ "multi-column",
+ 0,
+ 99,
+ 5L,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ new int[] {OTHER_FIELD.id()});
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("vector-update", 0, 100, 6, 1, "vector")),
+ Collections.singletonList(index),
+ indexedFields))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("extra-field-update", 0, 100, 6, 1, "other")),
+ Collections.singletonList(index),
+ indexedFields))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("unrelated-update", 0, 100, 6, 1, "unrelated")),
+ Collections.singletonList(index),
+ indexedFields))
+ .isEmpty();
+ }
+
+ @Test
+ void testIsolatesPartitionAndBucket() {
+ BinaryRow partition = BinaryRow.singleColumn(1);
+ IndexManifestEntry index = index("index", 0, 99, 5L, partition, 2);
+
+ ManifestEntry wrongPartition =
+ data("wrong-partition", 0, 100, 6, 1, BinaryRow.singleColumn(2), 2, "vector");
+ ManifestEntry wrongBucket = data("wrong-bucket", 0, 100, 6, 1, partition, 1, "vector");
+ assertThat(plan(Arrays.asList(wrongPartition, wrongBucket), index)).isEmpty();
+
+ ManifestEntry matching = data("matching", 0, 100, 6, 1, partition, 2, "vector");
+ assertThat(plan(Collections.singletonList(matching), index)).containsExactly(index);
+ }
+
+ @Test
+ void testSequenceSweepHandlesOverlappingRangesAndPreservesOrder() {
+ IndexManifestEntry equalSequence = index("equal", 20, 29, 5L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry boundary = index("boundary", 10, 19, 6L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry first = index("first", 0, 9, 8L, BinaryRow.EMPTY_ROW, 0);
+
+ List dataEntries =
+ Arrays.asList(
+ data("wide-equal", 0, 30, 5, 1, "vector"),
+ data("high-outside", 30, 10, 100, 1, "vector"),
+ data("boundary-update", 9, 2, 7, 1, "vector"),
+ data("first-update", 0, 10, 9, 1, "vector"));
+
+ assertThat(plan(dataEntries, Arrays.asList(equalSequence, boundary, first)))
+ .containsExactly(boundary, first);
+ }
+
+ @Test
+ void testRefreshesAllBTreeKeyShardsForOneRowRange() {
+ IndexManifestEntry first =
+ index("btree-first", "btree", OTHER_FIELD, 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry second =
+ index("btree-second", "btree", OTHER_FIELD, 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry nextRange =
+ index("btree-next", "btree", OTHER_FIELD, 100, 199, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(data("key-update", 10, 1, 6, 1, "other")),
+ Arrays.asList(first, second, nextRange),
+ Collections.singletonList(OTHER_FIELD)))
+ .containsExactly(first, second);
+ }
+
+ @Test
+ void testSequenceSweepMatchesBruteForceForOverlappingRanges() {
+ Random random = new Random(123456L);
+ for (int round = 0; round < 10; round++) {
+ List dataEntries = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ dataEntries.add(
+ data(
+ "data-" + round + "-" + i,
+ random.nextInt(1000),
+ random.nextInt(100) + 1,
+ random.nextInt(25),
+ 1,
+ "vector"));
+ }
+
+ List indexEntries = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ long from = random.nextInt(1000);
+ indexEntries.add(
+ index(
+ "index-" + round + "-" + i,
+ from,
+ from + random.nextInt(100),
+ (long) random.nextInt(24) + 1,
+ BinaryRow.EMPTY_ROW,
+ 0));
+ }
+ Collections.shuffle(dataEntries, random);
+ Collections.shuffle(indexEntries, random);
+
+ assertThat(plan(dataEntries, indexEntries))
+ .containsExactlyElementsOf(bruteForcePlan(dataEntries, indexEntries));
+ }
+ }
+
+ private List plan(
+ List dataEntries, IndexManifestEntry indexEntry) {
+ return plan(dataEntries, Collections.singletonList(indexEntry));
+ }
+
+ private List plan(
+ List dataEntries, List indexEntries) {
+ return plan(dataEntries, indexEntries, Collections.singletonList(VECTOR_FIELD));
+ }
+
+ private List plan(
+ List dataEntries,
+ List indexEntries,
+ List indexedFields) {
+ return DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ schemaManager, dataEntries, indexEntries, indexedFields);
+ }
+
+ private List bruteForcePlan(
+ List dataEntries, List indexEntries) {
+ List result = new ArrayList<>();
+ for (IndexManifestEntry indexEntry : indexEntries) {
+ GlobalIndexMeta indexMeta = indexEntry.indexFile().globalIndexMeta();
+ long scanSnapshotId =
+ DataEvolutionIndexSourceMeta.deserialize(indexMeta.sourceMeta())
+ .scanSnapshotId();
+ for (ManifestEntry dataEntry : dataEntries) {
+ DataFileMeta file = dataEntry.file();
+ if (file.maxSequenceNumber() > scanSnapshotId
+ && file.nonNullRowIdRange().hasIntersection(indexMeta.rowRange())) {
+ result.add(indexEntry);
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private IndexManifestEntry index(
+ String fileName,
+ long from,
+ long to,
+ Long scanSnapshotId,
+ BinaryRow partition,
+ int bucket) {
+ return index(fileName, from, to, scanSnapshotId, partition, bucket, null);
+ }
+
+ private IndexManifestEntry index(
+ String fileName,
+ long from,
+ long to,
+ Long scanSnapshotId,
+ BinaryRow partition,
+ int bucket,
+ int[] extraFieldIds) {
+ byte[] sourceMeta =
+ scanSnapshotId == null
+ ? null
+ : new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ return new IndexManifestEntry(
+ FileKind.ADD,
+ partition,
+ bucket,
+ new IndexFileMeta(
+ "lumina",
+ fileName,
+ 1L,
+ to - from + 1,
+ new GlobalIndexMeta(
+ from, to, VECTOR_FIELD.id(), extraFieldIds, null, sourceMeta),
+ null));
+ }
+
+ private IndexManifestEntry index(
+ String fileName,
+ String indexType,
+ DataField indexField,
+ long from,
+ long to,
+ long scanSnapshotId,
+ BinaryRow partition,
+ int bucket) {
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ return new IndexManifestEntry(
+ FileKind.ADD,
+ partition,
+ bucket,
+ new IndexFileMeta(
+ indexType,
+ fileName,
+ 1L,
+ to - from + 1,
+ new GlobalIndexMeta(from, to, indexField.id(), null, null, sourceMeta),
+ null));
+ }
+
+ private ManifestEntry data(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ String... writeCols) {
+ return data(
+ fileName,
+ firstRowId,
+ rowCount,
+ maxSequenceNumber,
+ schemaId,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ writeCols);
+ }
+
+ private ManifestEntry data(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ BinaryRow partition,
+ int bucket,
+ String... writeCols) {
+ List physicalColumns = writeCols.length == 0 ? null : Arrays.asList(writeCols);
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ fileName,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ maxSequenceNumber,
+ maxSequenceNumber,
+ schemaId,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ physicalColumns);
+ return ManifestEntry.create(FileKind.ADD, partition, bucket, 1, file);
+ }
+
+ private ManifestEntry dataWithWriteCols(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ List writeCols) {
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ fileName,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ maxSequenceNumber,
+ maxSequenceNumber,
+ schemaId,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ writeCols);
+ return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1, file);
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
index 11c8ddb64c3f..cf0e6820ad16 100644
--- a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
@@ -23,19 +23,24 @@
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.io.PojoDataFileMeta;
import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.options.Options;
import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.FloatType;
import org.apache.paimon.types.IntType;
import org.apache.paimon.types.VarCharType;
import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RowRangeIndex;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -45,6 +50,7 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -94,7 +100,14 @@ void testToIndexFileMetasMultiColumn() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
@@ -115,13 +128,39 @@ void testToIndexFileMetasSingleColumn() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
assertThat(metas.get(0).globalIndexMeta().extraFieldIds()).isNull();
}
+ @Test
+ void testToIndexFileMetasWithSourceMeta() throws IOException {
+ DataField field = new DataField(1, "vec", new ArrayType(new FloatType()));
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(7L).serialize();
+
+ List metas =
+ GlobalIndexBuilderUtils.toIndexFileMetas(
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ new Range(0, 9),
+ Collections.singletonList(field),
+ "lumina",
+ createDummyResultEntries(),
+ sourceMeta);
+
+ assertThat(metas.get(0).globalIndexMeta().sourceMeta()).containsExactly(sourceMeta);
+ }
+
// Test: 3 columns (title + vec + id), primary column title is indexFieldId, rest in
// extraFieldIds
@Test
@@ -136,7 +175,14 @@ void testToIndexFileMetasThreeColumns() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
@@ -176,6 +222,63 @@ void testCreateShardIndexedSplitsCanSplitOneShardIntoMultipleRanges() {
assertThat(splits.get(0).dataSplit()).isEqualTo(splits.get(1).dataSplit());
}
+ @Test
+ void testSplitByContiguousRowRangeFromDataFiles() {
+ DataFileMeta file1 = createDataFileMeta(0L, 100L);
+ DataFileMeta file2 = createDataFileMeta(300L, 100L);
+ DataFileMeta file3 = createDataFileMeta(100L, 100L);
+ DataSplit split =
+ DataSplit.builder()
+ .withSnapshot(1L)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Arrays.asList(file1, file2, file3))
+ .isStreaming(false)
+ .rawConvertible(false)
+ .build();
+
+ List rebuilt =
+ GlobalIndexBuilderUtils.splitByContiguousRowRange(Collections.singletonList(split));
+
+ assertThat(rebuilt).hasSize(2);
+ assertThat(rebuilt.get(0).dataFiles()).containsExactly(file1, file3);
+ assertThat(rebuilt.get(1).dataFiles()).containsExactly(file2);
+ assertThat(GlobalIndexBuilderUtils.calcRowRange(rebuilt.get(0)))
+ .isEqualTo(new Range(0, 199));
+ assertThat(GlobalIndexBuilderUtils.calcRowRange(rebuilt.get(1)))
+ .isEqualTo(new Range(300, 399));
+ }
+
+ @Test
+ void testGroupSplitsByDiscontiguousRowRangeIndex() {
+ DataFileMeta file1 = createDataFileMeta(4750L, 151L);
+ DataFileMeta file2 = createDataFileMeta(4901L, 1037L);
+ DataFileMeta file3 = createDataFileMeta(5938L, 1662L);
+ DataSplit split =
+ DataSplit.builder()
+ .withSnapshot(1L)
+ .withPartition(BinaryRow.EMPTY_ROW)
+ .withBucket(0)
+ .withBucketPath("bucket-0")
+ .withDataFiles(Arrays.asList(file1, file2, file3))
+ .isStreaming(false)
+ .rawConvertible(false)
+ .build();
+
+ Map>> result =
+ GlobalIndexBuilderUtils.groupSplitsByRange(
+ RowRangeIndex.create(
+ Arrays.asList(new Range(4750, 4900), new Range(5938, 7599))),
+ Collections.singletonList(split));
+
+ assertThat(result).containsOnlyKeys(BinaryRow.EMPTY_ROW);
+ Map> ranges = result.get(BinaryRow.EMPTY_ROW);
+ assertThat(ranges).containsOnlyKeys(new Range(4750, 4900), new Range(5938, 7599));
+ assertIndexedSplitRowRanges(ranges.get(new Range(4750, 4900)), new Range(4750, 4900));
+ assertIndexedSplitRowRanges(ranges.get(new Range(5938, 7599)), new Range(5938, 7599));
+ }
+
private List createDummyResultEntries() throws IOException {
String fileName = "test-index-" + UUID.randomUUID();
Path filePath = indexPathFactory.toPath(fileName);
@@ -208,4 +311,34 @@ private ManifestEntry createEntry(Long firstRowId, long rowCount) {
null);
return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1, file);
}
+
+ private static void assertIndexedSplitRowRanges(List splits, Range rowRange) {
+ assertThat(splits).hasSize(1);
+ assertThat(splits.get(0)).isInstanceOf(IndexedSplit.class);
+ assertThat(((IndexedSplit) splits.get(0)).rowRanges()).containsExactly(rowRange);
+ }
+
+ private static DataFileMeta createDataFileMeta(long firstRowId, long rowCount) {
+ return new PojoDataFileMeta(
+ "test-file-" + UUID.randomUUID(),
+ 1024L,
+ rowCount,
+ BinaryRow.EMPTY_ROW,
+ BinaryRow.EMPTY_ROW,
+ SimpleStats.EMPTY_STATS,
+ SimpleStats.EMPTY_STATS,
+ 0L,
+ 0L,
+ 0L,
+ 0,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ null);
+ }
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScannerTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScannerTest.java
new file mode 100644
index 000000000000..9b3b00c4d22d
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/globalindex/generic/GenericGlobalIndexScannerTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.generic;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.globalindex.ScanResult;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.TableTestBase;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link GenericGlobalIndexScanner}. */
+public class GenericGlobalIndexScannerTest extends TableTestBase {
+
+ @Override
+ public Schema schemaDefault() {
+ return Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("v", DataTypes.STRING())
+ .option(CoreOptions.BUCKET.key(), "-1")
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true")
+ .build();
+ }
+
+ @Test
+ public void testScan() throws Exception {
+ FileStoreTable table = writeRows();
+ long snapshotId = table.snapshotManager().latestSnapshot().id();
+
+ ScanResult scanResult =
+ new GenericGlobalIndexScanner(table)
+ .scan()
+ .orElseThrow(() -> new IllegalStateException("Expected scan result."));
+
+ assertThat(scanResult.scanSnapshotId()).isEqualTo(snapshotId);
+ assertThat(scanResult.entries()).isNotEmpty();
+ assertThat(scanResult.rowRangeIndex().ranges()).containsExactly(new Range(0, 9));
+ assertThat(scanResult.deletedIndexEntries()).isEmpty();
+ }
+
+ @Test
+ public void testIncrementalScanWithoutExistingIndex() throws Exception {
+ FileStoreTable table = writeRows();
+
+ ScanResult scanResult =
+ new GenericGlobalIndexScanner(table)
+ .withIndex("test-index", Collections.singletonList("v"), new Options())
+ .incrementalScan()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "Expected incremental scan result."));
+
+ assertThat(scanResult.entries()).isNotEmpty();
+ assertThat(scanResult.rowRangeIndex().ranges()).containsExactly(new Range(0, 9));
+ assertThat(scanResult.deletedIndexEntries()).isEmpty();
+ }
+
+ @Test
+ public void testScanEmptyTable() throws Exception {
+ createTableDefault();
+
+ assertThat(new GenericGlobalIndexScanner(getTableDefault()).scan()).isEmpty();
+ }
+
+ private FileStoreTable writeRows() throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite()) {
+ for (int i = 0; i < 10; i++) {
+ write.write(GenericRow.of(i, BinaryString.fromString("v-" + i)));
+ }
+ try (BatchTableCommit commit = builder.newCommit()) {
+ commit.commit(write.prepareCommit());
+ }
+ }
+ return table;
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderSplitTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderSplitTest.java
deleted file mode 100644
index 2d64ce9db541..000000000000
--- a/paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderSplitTest.java
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * 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.data.BinaryRow;
-import org.apache.paimon.globalindex.IndexedSplit;
-import org.apache.paimon.io.DataFileMeta;
-import org.apache.paimon.io.PojoDataFileMeta;
-import org.apache.paimon.stats.SimpleStats;
-import org.apache.paimon.table.source.DataSplit;
-import org.apache.paimon.table.source.Split;
-import org.apache.paimon.utils.Range;
-import org.apache.paimon.utils.RowRangeIndex;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/** Tests for split regrouping in {@link SortedGlobalIndexBuilder}. */
-public class SortedGlobalIndexBuilderSplitTest {
-
- @Test
- public void testSplitByContiguousRowRangeFromDataFiles() {
- DataFileMeta file1 = createDataFileMeta(0L, 100L);
- DataFileMeta file2 = createDataFileMeta(300L, 100L);
- DataFileMeta file3 = createDataFileMeta(100L, 100L);
- DataSplit split =
- DataSplit.builder()
- .withSnapshot(1L)
- .withPartition(BinaryRow.EMPTY_ROW)
- .withBucket(0)
- .withBucketPath("bucket-0")
- .withDataFiles(Arrays.asList(file1, file2, file3))
- .isStreaming(false)
- .rawConvertible(false)
- .build();
-
- List rebuilt =
- SortedGlobalIndexBuilder.splitByContiguousRowRange(
- Collections.singletonList(split));
-
- assertThat(rebuilt).hasSize(2);
- assertThat(rebuilt.get(0).dataFiles()).containsExactly(file1, file3);
- assertThat(rebuilt.get(1).dataFiles()).containsExactly(file2);
- assertThat(SortedGlobalIndexBuilder.calcRowRange(rebuilt.get(0)))
- .isEqualTo(new Range(0, 199));
- assertThat(SortedGlobalIndexBuilder.calcRowRange(rebuilt.get(1)))
- .isEqualTo(new Range(300, 399));
- }
-
- @Test
- public void testGroupSplitsByDiscontiguousRowRangeIndex() {
- DataFileMeta file1 = createDataFileMeta(4750L, 151L);
- DataFileMeta file2 = createDataFileMeta(4901L, 1037L);
- DataFileMeta file3 = createDataFileMeta(5938L, 1662L);
- DataSplit split =
- DataSplit.builder()
- .withSnapshot(1L)
- .withPartition(BinaryRow.EMPTY_ROW)
- .withBucket(0)
- .withBucketPath("bucket-0")
- .withDataFiles(Arrays.asList(file1, file2, file3))
- .isStreaming(false)
- .rawConvertible(false)
- .build();
-
- Map>> result =
- SortedGlobalIndexBuilder.groupSplitsByRange(
- RowRangeIndex.create(
- Arrays.asList(new Range(4750, 4900), new Range(5938, 7599))),
- Collections.singletonList(split));
-
- assertThat(result).containsOnlyKeys(BinaryRow.EMPTY_ROW);
- Map> ranges = result.get(BinaryRow.EMPTY_ROW);
- assertThat(ranges).containsOnlyKeys(new Range(4750, 4900), new Range(5938, 7599));
- assertIndexedSplitRowRanges(ranges.get(new Range(4750, 4900)), new Range(4750, 4900));
- assertIndexedSplitRowRanges(ranges.get(new Range(5938, 7599)), new Range(5938, 7599));
- }
-
- private static void assertIndexedSplitRowRanges(List splits, Range rowRange) {
- assertThat(splits).hasSize(1);
- assertThat(splits.get(0)).isInstanceOf(IndexedSplit.class);
- assertThat(((IndexedSplit) splits.get(0)).rowRanges()).containsExactly(rowRange);
- }
-
- private static DataFileMeta createDataFileMeta(long firstRowId, long rowCount) {
- return new PojoDataFileMeta(
- "test-file-" + UUID.randomUUID(),
- 1024L,
- rowCount,
- BinaryRow.EMPTY_ROW,
- BinaryRow.EMPTY_ROW,
- SimpleStats.EMPTY_STATS,
- SimpleStats.EMPTY_STATS,
- 0L,
- 0L,
- 0L,
- 0,
- Collections.emptyList(),
- null,
- null,
- null,
- null,
- null,
- null,
- firstRowId,
- null);
- }
-}
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/SortedGlobalIndexScannerTest.java
similarity index 78%
rename from paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilderTest.java
rename to paimon-core/src/test/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexScannerTest.java
index 84aca44201ba..f918b1728981 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/SortedGlobalIndexScannerTest.java
@@ -24,11 +24,10 @@
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.BlobData;
import org.apache.paimon.data.GenericRow;
-import org.apache.paimon.data.InternalRow;
-import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
import org.apache.paimon.globalindex.KeySerializer;
-import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.ScanResult;
import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileHandler;
import org.apache.paimon.index.IndexFileMeta;
@@ -53,105 +52,23 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
-import java.io.Closeable;
-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 {
+/** Test class for {@link SortedGlobalIndexScanner}. */
+public class SortedGlobalIndexScannerTest extends TableTestBase {
private static final long PART_ROW_NUM = 1000L;
private static final KeySerializer KEY_SERIALIZER = KeySerializer.create(DataTypes.INT());
private static final Comparator