This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ec79aacada [index] Reuse incremental global index planning (#8407)
ec79aacada is described below
commit ec79aacada6c7f885d2fa65cb96114d955f6ee09
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jul 1 15:21:13 2026 +0800
[index] Reuse incremental global index planning (#8407)
This PR makes global index build incremental by default across
Java/Flink/Spark/Python and extracts shared planning logic for reusable
row-range/shard handling.
---
.../globalindex/GlobalIndexBuilderUtils.java | 275 +++++++++++++++-
.../sorted/SortedGlobalIndexBuilder.java | 31 +-
.../globalindex/GlobalIndexBuilderUtilsTest.java | 64 ++++
.../globalindex/GenericGlobalIndexBuilder.java | 12 +-
.../flink/globalindex/GenericIndexTopoBuilder.java | 353 +++++++++------------
.../flink/globalindex/SortedIndexTopoBuilder.java | 2 +-
.../globalindex/GenericIndexTopoBuilderTest.java | 183 ++++++-----
.../globalindex/SortedIndexTopoBuilderTest.java | 4 +-
paimon-python/pypaimon/globalindex/build_plan.py | 331 +++++++++++++++++++
.../pypaimon/globalindex/create_global_index.py | 283 +++--------------
.../pypaimon/tests/global_index_build_test.py | 160 +++++++++-
.../globalindex/DefaultGlobalIndexTopoBuilder.java | 223 ++++---------
.../globalindex/sorted/SortedIndexTopoBuilder.java | 3 +-
.../procedure/CreateGlobalIndexProcedureTest.java | 28 ++
14 files changed, 1225 insertions(+), 727 deletions(-)
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 39f7fb2b0e..0537e50114 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
@@ -19,15 +19,21 @@
package org.apache.paimon.globalindex;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.io.DataFileMeta;
+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.schema.SchemaManager;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.types.DataField;
import org.apache.paimon.utils.Range;
@@ -38,9 +44,16 @@ import javax.annotation.Nullable;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.BiFunction;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
/** Utils for global index build. */
public class GlobalIndexBuilderUtils {
@@ -78,12 +91,7 @@ public class GlobalIndexBuilderUtils {
// 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 =
- fields.size() > 1
- ? fields.subList(1, fields.size()).stream()
- .mapToInt(DataField::id)
- .toArray()
- : null;
+ int[] extraFieldIds = extraFieldIds(fields);
return toIndexFileMetas(
fileIO,
indexPathFactory,
@@ -95,6 +103,244 @@ public class GlobalIndexBuilderUtils {
entries);
}
+ public static List<Range> unindexedRowRanges(
+ FileStoreTable table,
+ @Nullable Snapshot snapshot,
+ String indexType,
+ List<DataField> fields,
+ @Nullable PartitionPredicate partitionPredicate) {
+ if (snapshot == null || snapshot.nextRowId() == null ||
snapshot.nextRowId() <= 0) {
+ return Collections.emptyList();
+ }
+
+ Range dataRange = new Range(0, snapshot.nextRowId() - 1);
+ List<Range> indexedRanges =
+ indexedRowRanges(table, snapshot, indexType, fields,
partitionPredicate);
+ return Range.sortAndMergeOverlap(dataRange.exclude(indexedRanges),
true);
+ }
+
+ public static List<Range> indexedRowRanges(
+ FileStoreTable table,
+ @Nullable Snapshot snapshot,
+ String indexType,
+ List<DataField> fields,
+ @Nullable PartitionPredicate partitionPredicate) {
+ if (snapshot == null || fields.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ int indexFieldId = fields.get(0).id();
+ int[] extraFieldIds = extraFieldIds(fields);
+ List<Range> ranges = new ArrayList<>();
+ for (IndexManifestEntry entry :
+ table.store().newIndexFileHandler().scan(snapshot, indexType))
{
+ if (partitionPredicate != null &&
!partitionPredicate.test(entry.partition())) {
+ continue;
+ }
+ GlobalIndexMeta meta = entry.indexFile().globalIndexMeta();
+ if (meta == null) {
+ continue;
+ }
+ if (meta.indexFieldId() != indexFieldId) {
+ continue;
+ }
+ if (!sameExtraFieldIds(meta.extraFieldIds(), extraFieldIds)) {
+ continue;
+ }
+ ranges.add(meta.rowRange());
+ }
+ return Range.sortAndMergeOverlap(ranges, true);
+ }
+
+ @Nullable
+ public static List<Range> rowRangesAfter(long maxIndexedRowId) {
+ if (maxIndexedRowId < 0) {
+ return null;
+ }
+ if (maxIndexedRowId == Long.MAX_VALUE) {
+ return Collections.emptyList();
+ }
+ return Collections.singletonList(new Range(maxIndexedRowId + 1,
Long.MAX_VALUE));
+ }
+
+ public static List<IndexedSplit> createShardIndexedSplits(
+ FileStoreTable table, List<ManifestEntry> entries, long
rowsPerShard) {
+ return createShardIndexedSplits(table, entries, rowsPerShard, null);
+ }
+
+ public static List<IndexedSplit> createShardIndexedSplits(
+ FileStoreTable table,
+ List<ManifestEntry> entries,
+ long rowsPerShard,
+ @Nullable List<Range> rowRangesToBuild) {
+ return createShardIndexedSplits(
+ entries,
+ rowsPerShard,
+ (partition, bucket) ->
+ table.store().pathFactory().bucketPath(partition,
bucket).toString(),
+ rowRangesToBuild);
+ }
+
+ public static List<IndexedSplit> createShardIndexedSplits(
+ List<ManifestEntry> entries,
+ long rowsPerShard,
+ BiFunction<BinaryRow, Integer, String> bucketPathFactory,
+ @Nullable List<Range> rowRangesToBuild) {
+ checkArgument(
+ rowsPerShard > 0,
+ "Option 'global-index.row-count-per-shard' must be greater
than 0.");
+ if (rowRangesToBuild != null) {
+ rowRangesToBuild = Range.sortAndMergeOverlap(rowRangesToBuild,
true);
+ if (rowRangesToBuild.isEmpty()) {
+ return Collections.emptyList();
+ }
+ }
+
+ Map<BinaryRow, Map<Integer, List<ManifestEntry>>>
entriesByPartitionAndBucket =
+ new LinkedHashMap<>();
+ for (ManifestEntry entry : entries) {
+ entriesByPartitionAndBucket
+ .computeIfAbsent(entry.partition(), key -> new
LinkedHashMap<>())
+ .computeIfAbsent(entry.bucket(), key -> new ArrayList<>())
+ .add(entry);
+ }
+
+ List<IndexedSplit> result = new ArrayList<>();
+ for (Map.Entry<BinaryRow, Map<Integer, List<ManifestEntry>>>
partitionEntry :
+ entriesByPartitionAndBucket.entrySet()) {
+ BinaryRow partition = partitionEntry.getKey();
+ for (Map.Entry<Integer, List<ManifestEntry>> bucketEntry :
+ partitionEntry.getValue().entrySet()) {
+ addShardIndexedSplits(
+ result,
+ partition,
+ bucketEntry.getKey(),
+ bucketEntry.getValue(),
+ rowsPerShard,
+ bucketPathFactory,
+ rowRangesToBuild);
+ }
+ }
+ return result;
+ }
+
+ private static void addShardIndexedSplits(
+ List<IndexedSplit> result,
+ BinaryRow partition,
+ int bucket,
+ List<ManifestEntry> entries,
+ long rowsPerShard,
+ BiFunction<BinaryRow, Integer, String> bucketPathFactory,
+ @Nullable List<Range> rowRangesToBuild) {
+ Map<Long, List<DataFileMeta>> filesByShard = new LinkedHashMap<>();
+ for (ManifestEntry entry : entries) {
+ DataFileMeta file = entry.file();
+ if (file.firstRowId() == null) {
+ LOG.warn(
+ "Skipping file '{}' in partition {} bucket {} because
it has no row ID.",
+ file.fileName(),
+ partition,
+ bucket);
+ continue;
+ }
+ Range fileRange = file.nonNullRowIdRange();
+ long startShardId = fileRange.from / rowsPerShard;
+ long endShardId = fileRange.to / rowsPerShard;
+ for (long shardId = startShardId; shardId <= endShardId;
shardId++) {
+ long shardStartRowId = shardId * rowsPerShard;
+ filesByShard.computeIfAbsent(shardStartRowId, key -> new
ArrayList<>()).add(file);
+ }
+ }
+
+ for (Map.Entry<Long, List<DataFileMeta>> shardEntry :
filesByShard.entrySet()) {
+ long shardStart = shardEntry.getKey();
+ long shardEnd = shardStart + rowsPerShard - 1;
+ List<DataFileMeta> shardFiles = shardEntry.getValue();
+ if (shardFiles.isEmpty()) {
+ continue;
+ }
+
+
shardFiles.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
+ List<DataFileMeta> currentGroup = new ArrayList<>();
+ long currentGroupEnd = -1;
+
+ for (DataFileMeta file : shardFiles) {
+ long fileStart = file.nonNullFirstRowId();
+ long fileEnd = file.nonNullRowIdRange().to;
+ if (currentGroup.isEmpty()) {
+ currentGroup.add(file);
+ currentGroupEnd = fileEnd;
+ } else if (fileStart <= currentGroupEnd + 1) {
+ currentGroup.add(file);
+ currentGroupEnd = Math.max(currentGroupEnd, fileEnd);
+ } else {
+ addIndexedSplitForFileGroup(
+ result,
+ currentGroup,
+ shardStart,
+ shardEnd,
+ partition,
+ bucket,
+ entries.get(0).totalBuckets(),
+ bucketPathFactory.apply(partition, bucket),
+ rowRangesToBuild);
+ currentGroup = new ArrayList<>();
+ currentGroup.add(file);
+ currentGroupEnd = fileEnd;
+ }
+ }
+ if (!currentGroup.isEmpty()) {
+ addIndexedSplitForFileGroup(
+ result,
+ currentGroup,
+ shardStart,
+ shardEnd,
+ partition,
+ bucket,
+ entries.get(0).totalBuckets(),
+ bucketPathFactory.apply(partition, bucket),
+ rowRangesToBuild);
+ }
+ }
+ }
+
+ private static void addIndexedSplitForFileGroup(
+ List<IndexedSplit> result,
+ List<DataFileMeta> files,
+ long shardStart,
+ long shardEnd,
+ BinaryRow partition,
+ int bucket,
+ int totalBuckets,
+ String bucketPath,
+ @Nullable List<Range> rowRangesToBuild) {
+ long groupMinRowId = files.get(0).nonNullFirstRowId();
+ long groupMaxRowId =
+ files.stream().mapToLong(file ->
file.nonNullRowIdRange().to).max().getAsLong();
+ Range groupRange =
+ new Range(Math.max(groupMinRowId, shardStart),
Math.min(groupMaxRowId, shardEnd));
+ List<Range> taskRanges =
+ rowRangesToBuild == null
+ ? Collections.singletonList(groupRange)
+ : Range.and(Collections.singletonList(groupRange),
rowRangesToBuild);
+ if (taskRanges.isEmpty()) {
+ return;
+ }
+
+ DataSplit dataSplit =
+ DataSplit.builder()
+ .withPartition(partition)
+ .withBucket(bucket)
+ .withTotalBuckets(totalBuckets)
+ .withDataFiles(files)
+ .withBucketPath(bucketPath)
+ .rawConvertible(false)
+ .build();
+ for (Range taskRange : taskRanges) {
+ result.add(new IndexedSplit(dataSplit,
Collections.singletonList(taskRange), null));
+ }
+ }
+
private static List<IndexFileMeta> toIndexFileMetas(
FileIO fileIO,
IndexPathFactory indexPathFactory,
@@ -219,4 +465,21 @@ public class GlobalIndexBuilderUtils {
IndexPathFactory indexPathFactory =
table.store().pathFactory().globalIndexFileFactory();
return new GlobalIndexFileReadWrite(table.fileIO(), indexPathFactory);
}
+
+ @Nullable
+ private static int[] extraFieldIds(List<DataField> fields) {
+ return fields.size() > 1
+ ? fields.subList(1,
fields.size()).stream().mapToInt(DataField::id).toArray()
+ : null;
+ }
+
+ private static boolean sameExtraFieldIds(@Nullable int[] left, @Nullable
int[] right) {
+ if (left == null || left.length == 0) {
+ return right == null || right.length == 0;
+ }
+ if (right == null || right.length == 0) {
+ return false;
+ }
+ return Arrays.equals(left, right);
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
index e8ba93ffc8..6ee00b3bb8 100644
---
a/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
+++
b/paimon-core/src/main/java/org/apache/paimon/globalindex/sorted/SortedGlobalIndexBuilder.java
@@ -34,7 +34,6 @@ 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.manifest.IndexManifestEntry;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.reader.RecordReader;
@@ -76,6 +75,7 @@ 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;
@@ -174,7 +174,13 @@ public class SortedGlobalIndexBuilder implements
Serializable {
Preconditions.checkArgument(indexField != null, "indexField must be
set before scan.");
Range dataRange = new Range(0, snapshot.nextRowId() - 1);
- List<Range> indexedRanges = indexedRowRanges(snapshot);
+ List<Range> indexedRanges =
+ indexedRowRanges(
+ table,
+ snapshot,
+ indexType,
+ Collections.singletonList(indexField),
+ partitionPredicate);
List<Range> nonIndexedRanges = dataRange.exclude(indexedRanges);
if (nonIndexedRanges.isEmpty()) {
return Optional.empty();
@@ -193,27 +199,6 @@ public class SortedGlobalIndexBuilder implements
Serializable {
&&
!isVectorStoreFile(entry.file().fileName()));
}
- private List<Range> indexedRowRanges(Snapshot snapshot) {
- List<Range> ranges = new ArrayList<>();
- for (IndexManifestEntry entry :
- table.store().newIndexFileHandler().scan(snapshot, indexType))
{
- if (partitionPredicate != null &&
!partitionPredicate.test(entry.partition())) {
- continue;
- }
- if (entry.indexFile().globalIndexMeta() == null) {
- continue;
- }
- if (entry.indexFile().globalIndexMeta().indexFieldId() !=
indexField.id()) {
- continue;
- }
- ranges.add(
- new Range(
-
entry.indexFile().globalIndexMeta().rowRangeStart(),
-
entry.indexFile().globalIndexMeta().rowRangeEnd()));
- }
- return Range.sortAndMergeOverlap(ranges, true);
- }
-
@VisibleForTesting
public List<CommitMessage> build(DataSplit split, IOManager ioManager)
throws IOException {
BinaryRow partition = split.partition();
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 67852ae925..11c8ddb64c 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
@@ -19,12 +19,17 @@
package org.apache.paimon.globalindex;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexPathFactory;
+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.types.ArrayType;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.FloatType;
@@ -138,10 +143,69 @@ class GlobalIndexBuilderUtilsTest {
assertThat(metas.get(0).globalIndexMeta().extraFieldIds()).isEqualTo(new int[]
{2, 3});
}
+ @Test
+ void testCreateShardIndexedSplitsUsesUnindexedRanges() {
+ List<ManifestEntry> entries = Arrays.asList(createEntry(0L, 100),
createEntry(100L, 100));
+
+ List<IndexedSplit> splits =
+ GlobalIndexBuilderUtils.createShardIndexedSplits(
+ entries,
+ 100,
+ (partition, bucket) -> "/bucket-" + bucket,
+ Collections.singletonList(new Range(0, 99)));
+
+ assertThat(splits).hasSize(1);
+ assertThat(splits.get(0).rowRanges()).containsExactly(new Range(0,
99));
+
assertThat(splits.get(0).dataSplit().dataFiles()).containsExactly(entries.get(0).file());
+ }
+
+ @Test
+ void testCreateShardIndexedSplitsCanSplitOneShardIntoMultipleRanges() {
+ List<ManifestEntry> entries =
Collections.singletonList(createEntry(0L, 100));
+
+ List<IndexedSplit> splits =
+ GlobalIndexBuilderUtils.createShardIndexedSplits(
+ entries,
+ 100,
+ (partition, bucket) -> "/bucket-" + bucket,
+ Arrays.asList(new Range(0, 9), new Range(90, 99)));
+
+ assertThat(splits).hasSize(2);
+ assertThat(splits.get(0).rowRanges()).containsExactly(new Range(0, 9));
+ assertThat(splits.get(1).rowRanges()).containsExactly(new Range(90,
99));
+
assertThat(splits.get(0).dataSplit()).isEqualTo(splits.get(1).dataSplit());
+ }
+
private List<ResultEntry> createDummyResultEntries() throws IOException {
String fileName = "test-index-" + UUID.randomUUID();
Path filePath = indexPathFactory.toPath(fileName);
fileIO.newOutputStream(filePath, false).close();
return Collections.singletonList(new ResultEntry(fileName, 100, null));
}
+
+ private ManifestEntry createEntry(Long firstRowId, long rowCount) {
+ PojoDataFileMeta file =
+ 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);
+ return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1,
file);
+ }
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
index 415c1590a5..b20c513cd0 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericGlobalIndexBuilder.java
@@ -40,6 +40,7 @@ public class GenericGlobalIndexBuilder implements
Serializable {
protected final FileStoreTable table;
@Nullable protected PartitionPredicate partitionPredicate;
+ @Nullable private Snapshot scanSnapshot;
public GenericGlobalIndexBuilder(FileStoreTable table) {
this.table = table;
@@ -73,19 +74,24 @@ public class GenericGlobalIndexBuilder implements
Serializable {
+ "deleted rows to be indexed.",
table.name());
- Snapshot snapshot = table.snapshotManager().latestSnapshot();
- if (snapshot == null) {
+ scanSnapshot = table.snapshotManager().latestSnapshot();
+ if (scanSnapshot == null) {
return Collections.emptyList();
}
return table.store()
.newScan()
- .withSnapshot(snapshot)
+ .withSnapshot(scanSnapshot)
.withPartitionFilter(partitionPredicate)
.plan()
.files();
}
+ @Nullable
+ public Snapshot scanSnapshot() {
+ return scanSnapshot;
+ }
+
/** Returns old index file entries that should be deleted after new
indexes are built. */
public List<IndexManifestEntry> deletedIndexEntries() {
return Collections.emptyList();
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
index 39e6db0cf8..34461d7f75 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
@@ -19,6 +19,7 @@
package org.apache.paimon.flink.globalindex;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.flink.sink.Committable;
@@ -32,9 +33,9 @@ import
org.apache.paimon.flink.utils.StreamExecutionEnvironmentUtils;
import org.apache.paimon.globalindex.GlobalIndexMultiColumnWriter;
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.DataFileMeta;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.options.Options;
@@ -45,7 +46,6 @@ import org.apache.paimon.table.SpecialFields;
import org.apache.paimon.table.sink.BatchWriteBuilder;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.sink.CommitMessageImpl;
-import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.table.source.TableRead;
import org.apache.paimon.types.DataField;
@@ -61,23 +61,24 @@ import
org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Nullable;
+
import java.io.Closeable;
import java.io.IOException;
-import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.Comparator;
-import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.createIndexWriter;
+import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.createShardIndexedSplits;
import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.filterEntriesBefore;
import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.findMinNonIndexableRowId;
+import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.rowRangesAfter;
import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.toIndexFileMetas;
+import static
org.apache.paimon.globalindex.GlobalIndexBuilderUtils.unindexedRowRanges;
import static org.apache.paimon.io.CompactIncrement.emptyIncrement;
import static org.apache.paimon.io.DataIncrement.deleteIndexIncrement;
import static org.apache.paimon.io.DataIncrement.indexIncrement;
@@ -103,7 +104,7 @@ public class GenericIndexTopoBuilder {
PartitionPredicate partitionPredicate,
Options userOptions)
throws Exception {
- buildIndexAndExecute(
+ buildIndexAndExecuteInternal(
env,
table,
indexColumn,
@@ -111,7 +112,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- NO_MAX_INDEXED_ROW_ID);
+ NO_MAX_INDEXED_ROW_ID,
+ true);
}
public static void buildIndexAndExecute(
@@ -123,7 +125,7 @@ public class GenericIndexTopoBuilder {
Options userOptions,
long maxIndexedRowId)
throws Exception {
- buildIndexAndExecute(
+ buildIndexAndExecuteInternal(
env,
table,
indexColumn,
@@ -131,7 +133,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- maxIndexedRowId);
+ maxIndexedRowId,
+ false);
}
public static void buildIndexAndExecute(
@@ -143,7 +146,7 @@ public class GenericIndexTopoBuilder {
PartitionPredicate partitionPredicate,
Options userOptions)
throws Exception {
- buildIndexAndExecute(
+ buildIndexAndExecuteInternal(
env,
table,
indexColumn,
@@ -151,7 +154,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- NO_MAX_INDEXED_ROW_ID);
+ NO_MAX_INDEXED_ROW_ID,
+ true);
}
public static void buildIndexAndExecute(
@@ -164,8 +168,31 @@ public class GenericIndexTopoBuilder {
Options userOptions,
long maxIndexedRowId)
throws Exception {
+ buildIndexAndExecuteInternal(
+ env,
+ table,
+ indexColumn,
+ extraColumns,
+ indexType,
+ partitionPredicate,
+ userOptions,
+ maxIndexedRowId,
+ false);
+ }
+
+ private static void buildIndexAndExecuteInternal(
+ StreamExecutionEnvironment env,
+ FileStoreTable table,
+ String indexColumn,
+ List<String> extraColumns,
+ String indexType,
+ PartitionPredicate partitionPredicate,
+ Options userOptions,
+ long maxIndexedRowId,
+ boolean autoIncremental)
+ throws Exception {
boolean hasIndexToBuild =
- buildIndex(
+ buildIndexInternal(
env,
() -> new GenericGlobalIndexBuilder(table),
table,
@@ -174,7 +201,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- maxIndexedRowId);
+ maxIndexedRowId,
+ autoIncremental);
if (hasIndexToBuild) {
env.execute("Create " + indexType + " global index for table: " +
table.name());
} else {
@@ -191,7 +219,7 @@ public class GenericIndexTopoBuilder {
PartitionPredicate partitionPredicate,
Options userOptions)
throws Exception {
- return buildIndex(
+ return buildIndexInternal(
env,
indexBuilderSupplier,
table,
@@ -200,7 +228,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- NO_MAX_INDEXED_ROW_ID);
+ NO_MAX_INDEXED_ROW_ID,
+ true);
}
public static boolean buildIndex(
@@ -213,7 +242,7 @@ public class GenericIndexTopoBuilder {
Options userOptions,
long maxIndexedRowId)
throws Exception {
- return buildIndex(
+ return buildIndexInternal(
env,
indexBuilderSupplier,
table,
@@ -222,7 +251,8 @@ public class GenericIndexTopoBuilder {
indexType,
partitionPredicate,
userOptions,
- maxIndexedRowId);
+ maxIndexedRowId,
+ false);
}
/**
@@ -242,6 +272,31 @@ public class GenericIndexTopoBuilder {
Options userOptions,
long maxIndexedRowId)
throws Exception {
+ return buildIndexInternal(
+ env,
+ indexBuilderSupplier,
+ table,
+ indexColumn,
+ extraColumns,
+ indexType,
+ partitionPredicate,
+ userOptions,
+ maxIndexedRowId,
+ false);
+ }
+
+ private static boolean buildIndexInternal(
+ StreamExecutionEnvironment env,
+ Supplier<GenericGlobalIndexBuilder> indexBuilderSupplier,
+ FileStoreTable table,
+ String indexColumn,
+ List<String> extraColumns,
+ String indexType,
+ PartitionPredicate partitionPredicate,
+ Options userOptions,
+ long maxIndexedRowId,
+ boolean autoIncremental)
+ throws Exception {
GenericGlobalIndexBuilder indexBuilder = indexBuilderSupplier.get();
if (partitionPredicate != null) {
indexBuilder.withPartitionPredicate(partitionPredicate);
@@ -259,16 +314,16 @@ public class GenericIndexTopoBuilder {
userOptions,
entries,
deletedIndexEntries,
- maxIndexedRowId);
+ partitionPredicate,
+ indexBuilder.scanSnapshot(),
+ maxIndexedRowId,
+ autoIncremental);
}
/**
* Builds the Flink topology for global index creation from pre-scanned
entries. Supports both
- * full builds ({@code maxIndexedRowId = NO_MAX_INDEXED_ROW_ID}) and
incremental builds where
- * rows up to {@code maxIndexedRowId} are skipped.
+ * full builds and incremental builds where already indexed row ranges are
skipped.
*
- * @param maxIndexedRowId the maximum row ID already indexed; use {@link
#NO_MAX_INDEXED_ROW_ID}
- * for a full build
* @return {@code true} if a Flink topology was built, {@code false} if
nothing to index
*/
private static boolean buildTopology(
@@ -280,7 +335,10 @@ public class GenericIndexTopoBuilder {
Options userOptions,
List<ManifestEntry> entries,
List<IndexManifestEntry> deletedIndexEntries,
- long maxIndexedRowId)
+ PartitionPredicate partitionPredicate,
+ @Nullable Snapshot scanSnapshot,
+ long maxIndexedRowId,
+ boolean autoIncremental)
throws Exception {
// The primary column followed by the extra columns, in index order.
List<String> indexColumns = new ArrayList<>(1 + extraColumns.size());
@@ -289,14 +347,12 @@ public class GenericIndexTopoBuilder {
long totalRowCount = entries.stream().mapToLong(e ->
e.file().rowCount()).sum();
LOG.info(
- "Scanned {} files ({} rows) across {} partitions for {} index
on columns '{}'"
- + (maxIndexedRowId >= 0 ? ", maxIndexedRowId={}." :
"."),
+ "Scanned {} files ({} rows) across {} partitions for {} index
on columns '{}'.",
entries.size(),
totalRowCount,
entries.stream().map(ManifestEntry::partition).distinct().count(),
indexType,
- indexColumns,
- maxIndexedRowId);
+ indexColumns);
long minNonIndexableRowId =
findMinNonIndexableRowId(table.schemaManager(), entries,
indexColumns);
@@ -306,6 +362,9 @@ public class GenericIndexTopoBuilder {
DataField indexField = rowType.getField(indexColumn);
List<DataField> extraFields =
extraColumns.stream().map(rowType::getField).collect(Collectors.toList());
+ List<DataField> indexedFields = new ArrayList<>(1 +
extraFields.size());
+ indexedFields.add(indexField);
+ indexedFields.addAll(extraFields);
// Project indexColumns + _ROW_ID so we can read the actual row ID
from data
List<String> readColumns = new ArrayList<>(indexColumns);
readColumns.add(SpecialFields.ROW_ID.name());
@@ -318,9 +377,33 @@ public class GenericIndexTopoBuilder {
rowsPerShard > 0,
"Option 'global-index.row-count-per-shard' must be greater
than 0.");
+ List<Range> rowRangesToBuild = null;
+ if (deletedIndexEntries.isEmpty()) {
+ if (autoIncremental) {
+ Snapshot snapshot =
+ scanSnapshot == null
+ ? table.snapshotManager().latestSnapshot()
+ : scanSnapshot;
+ rowRangesToBuild =
+ unindexedRowRanges(
+ table, snapshot, indexType, indexedFields,
partitionPredicate);
+ LOG.info("Automatically selected unindexed row ranges: {}.",
rowRangesToBuild);
+ } else if (maxIndexedRowId != NO_MAX_INDEXED_ROW_ID) {
+ rowRangesToBuild = rowRangesAfter(maxIndexedRowId);
+ LOG.info(
+ "Selected row ranges after maxIndexedRowId={}: {}.",
+ maxIndexedRowId,
+ rowRangesToBuild);
+ }
+ if (rowRangesToBuild != null && rowRangesToBuild.isEmpty()) {
+ LOG.info("No unindexed row ranges found, nothing to index.");
+ return false;
+ }
+ }
+
// Compute shard tasks at file level from the provided entries
- List<ShardTask> shardTasks =
- computeShardTasks(table, entries, rowsPerShard,
maxIndexedRowId);
+ List<IndexedSplit> shardTasks =
+ computeShardTasks(table, entries, rowsPerShard,
rowRangesToBuild);
if (shardTasks.isEmpty()) {
LOG.info("No shard tasks generated, nothing to index.");
return false;
@@ -339,11 +422,11 @@ public class GenericIndexTopoBuilder {
// Build Flink topology
ReadBuilder readBuilder =
table.newReadBuilder().withReadType(projectedRowType);
- DataStream<ShardTask> source =
+ DataStream<IndexedSplit> source =
StreamExecutionEnvironmentUtils.fromData(
env,
- new JavaTypeInfo<>(ShardTask.class),
- shardTasks.toArray(new ShardTask[0]))
+ new JavaTypeInfo<>(IndexedSplit.class),
+ shardTasks.toArray(new IndexedSplit[0]))
.name("Generic Index Source")
.setParallelism(1);
@@ -377,152 +460,25 @@ public class GenericIndexTopoBuilder {
return true;
}
- /**
- * Compute shard tasks for a full build (no rows to skip).
- *
- * @see #computeShardTasks(FileStoreTable, List, long, long)
- */
- static List<ShardTask> computeShardTasks(
+ static List<IndexedSplit> computeShardTasks(
FileStoreTable table, List<ManifestEntry> entries, long
rowsPerShard) {
- return computeShardTasks(table, entries, rowsPerShard,
NO_MAX_INDEXED_ROW_ID);
+ return createShardIndexedSplits(table, entries, rowsPerShard);
}
- /**
- * Compute shard tasks at file level from the given manifest entries. Each
shard only contains
- * the files whose row ID ranges overlap with its shard range. A file
spanning multiple shard
- * boundaries is included in each overlapping shard.
- *
- * <p>When {@code maxIndexedRowId >= 0}, each shard's effective start is
advanced past {@code
- * maxIndexedRowId}, skipping fully-indexed shards entirely. This enables
incremental index
- * building where only new (un-indexed) rows are processed.
- *
- * @param maxIndexedRowId the maximum row ID already indexed; use {@link
#NO_MAX_INDEXED_ROW_ID}
- * for a full build
- */
- static List<ShardTask> computeShardTasks(
+ static List<IndexedSplit> computeShardTasks(
FileStoreTable table,
List<ManifestEntry> entries,
long rowsPerShard,
long maxIndexedRowId) {
- // Group by partition (bucket is always 0 for unaware-bucket tables)
- Map<BinaryRow, List<ManifestEntry>> entriesByPartition =
-
entries.stream().collect(Collectors.groupingBy(ManifestEntry::partition));
-
- List<ShardTask> tasks = new ArrayList<>();
-
- for (Map.Entry<BinaryRow, List<ManifestEntry>> partitionEntry :
- entriesByPartition.entrySet()) {
- BinaryRow partition = partitionEntry.getKey();
- String partBucketPath =
table.store().pathFactory().bucketPath(partition, 0).toString();
-
- // Assign files to shards by row ID range
- Map<Long, List<DataFileMeta>> filesByShard = new LinkedHashMap<>();
- for (ManifestEntry entry : partitionEntry.getValue()) {
- DataFileMeta file = entry.file();
- if (file.firstRowId() == null) {
- LOG.warn(
- "Skipping file '{}' in partition {} because it has
no row ID. "
- + "This file will NOT be indexed. "
- + "Ensure row tracking is enabled for the
table.",
- file.fileName(),
- partition);
- continue;
- }
- Range fileRange = file.nonNullRowIdRange();
- long startShardId = fileRange.from / rowsPerShard;
- long endShardId = fileRange.to / rowsPerShard;
-
- for (long shardId = startShardId; shardId <= endShardId;
shardId++) {
- long shardStartRowId = shardId * rowsPerShard;
- filesByShard.computeIfAbsent(shardStartRowId, k -> new
ArrayList<>()).add(file);
- }
- }
-
- // Create ShardTask for each shard group
- for (Map.Entry<Long, List<DataFileMeta>> shardEntry :
filesByShard.entrySet()) {
- long shardStart = shardEntry.getKey();
- long shardEnd = shardStart + rowsPerShard - 1;
- List<DataFileMeta> shardFiles = shardEntry.getValue();
- if (shardFiles.isEmpty()) {
- continue;
- }
-
- // For incremental builds, advance past already-indexed rows
- long effectiveStart =
- maxIndexedRowId >= 0
- ? Math.max(shardStart, maxIndexedRowId + 1)
- : shardStart;
- if (effectiveStart > shardEnd) {
- continue; // entire shard already indexed
- }
-
-
shardFiles.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
-
- // Group contiguous files; gaps produce separate tasks
- List<DataFileMeta> currentGroup = new ArrayList<>();
- long currentGroupEnd = -1;
-
- for (DataFileMeta file : shardFiles) {
- long fileStart = file.nonNullFirstRowId();
- long fileEnd = fileStart + file.rowCount() - 1;
-
- if (currentGroup.isEmpty()) {
- currentGroup.add(file);
- currentGroupEnd = fileEnd;
- } else if (fileStart <= currentGroupEnd + 1) {
- currentGroup.add(file);
- currentGroupEnd = Math.max(currentGroupEnd, fileEnd);
- } else {
- tasks.add(
- createShardTask(
- currentGroup,
- effectiveStart,
- shardEnd,
- partition,
- partBucketPath));
- currentGroup = new ArrayList<>();
- currentGroup.add(file);
- currentGroupEnd = fileEnd;
- }
- }
- if (!currentGroup.isEmpty()) {
- tasks.add(
- createShardTask(
- currentGroup,
- effectiveStart,
- shardEnd,
- partition,
- partBucketPath));
- }
- }
- }
- return tasks;
+ return computeShardTasks(table, entries, rowsPerShard,
rowRangesAfter(maxIndexedRowId));
}
- private static ShardTask createShardTask(
- List<DataFileMeta> files,
- long effectiveStart,
- long shardEnd,
- BinaryRow partition,
- String bucketPath) {
- long groupMinRowId = files.get(0).nonNullFirstRowId();
- long groupMaxRowId =
- files.stream().mapToLong(f ->
f.nonNullRowIdRange().to).max().getAsLong();
-
- // Clamp to effective boundaries
- long rangeFrom = Math.max(groupMinRowId, effectiveStart);
- long rangeTo = Math.min(groupMaxRowId, shardEnd);
-
- DataSplit dataSplit =
- DataSplit.builder()
- .withPartition(partition)
- .withBucket(0)
- .withDataFiles(files)
- .withBucketPath(bucketPath)
- .rawConvertible(false)
- .build();
-
- return new ShardTask(dataSplit, new Range(rangeFrom, rangeTo));
+ static List<IndexedSplit> computeShardTasks(
+ FileStoreTable table,
+ List<ManifestEntry> entries,
+ long rowsPerShard,
+ @Nullable List<Range> rowRangesToBuild) {
+ return createShardIndexedSplits(table, entries, rowsPerShard,
rowRangesToBuild);
}
private static List<Committable> createDeleteCommittables(
@@ -558,26 +514,13 @@ public class GenericIndexTopoBuilder {
.setMaxParallelism(1);
}
- /** Serializable descriptor for one shard's work. Each shard has its own
DataSplit and Range. */
- static class ShardTask implements Serializable {
- private static final long serialVersionUID = 1L;
-
- final DataSplit split;
- final Range shardRange;
-
- ShardTask(DataSplit split, Range shardRange) {
- this.split = split;
- this.shardRange = shardRange;
- }
- }
-
/**
- * Operator that receives a {@link ShardTask}, reads data from its split,
builds the index, and
- * emits a {@link Committable}. Each shard's split contains only the files
relevant to this
+ * Operator that receives an {@link IndexedSplit}, reads data from its
split, builds the index,
+ * and emits a {@link Committable}. Each shard's split contains only the
files relevant to this
* shard, so no redundant I/O occurs.
*/
private static class BuildIndexOperator
- extends BoundedOneInputOperator<ShardTask, Committable> {
+ extends BoundedOneInputOperator<IndexedSplit, Committable> {
private static final long serialVersionUID = 1L;
@@ -641,17 +584,21 @@ public class GenericIndexTopoBuilder {
}
@Override
- public void processElement(StreamRecord<ShardTask> element) throws
Exception {
- ShardTask task = element.getValue();
- BinaryRow partition = task.split.partition();
+ public void processElement(StreamRecord<IndexedSplit> element) throws
Exception {
+ IndexedSplit task = element.getValue();
+ checkArgument(
+ task.rowRanges().size() == 1,
+ "Each generic global index task should contain exactly one
row range.");
+ Range shardRange = task.rowRanges().get(0);
+ BinaryRow partition = task.dataSplit().partition();
LOG.info(
"Building {} index for partition={}, shardRange=[{}, {}],
files={}.",
indexType,
partition,
- task.shardRange.from,
- task.shardRange.to,
- task.split.dataFiles().size());
+ shardRange.from,
+ shardRange.to,
+ task.dataSplit().dataFiles().size());
long startTime = System.currentTimeMillis();
GlobalIndexWriter indexWriter =
@@ -660,7 +607,7 @@ public class GenericIndexTopoBuilder {
try {
long rowsSeen = 0;
long lastRowId = Long.MIN_VALUE;
- try (RecordReader<InternalRow> reader =
tableRead.createReader(task.split);
+ try (RecordReader<InternalRow> reader =
tableRead.createReader(task.dataSplit());
CloseableIterator<InternalRow> iter =
reader.toCloseableIterator()) {
while (iter.hasNext()) {
InternalRow row = iter.next();
@@ -674,17 +621,17 @@ public class GenericIndexTopoBuilder {
+ "This may indicate
corrupted data files.",
lastRowId,
currentRowId,
- task.shardRange.from,
- task.shardRange.to));
+ shardRange.from,
+ shardRange.to));
}
lastRowId = currentRowId;
- if (currentRowId > task.shardRange.to) {
+ if (currentRowId > shardRange.to) {
break;
}
// Only write rows within this shard's range
- if (currentRowId >= task.shardRange.from) {
- long rowId = currentRowId - task.shardRange.from;
+ if (currentRowId >= shardRange.from) {
+ long rowId = currentRowId - shardRange.from;
if (multiColumn) {
((GlobalIndexMultiColumnWriter) indexWriter)
.write(rowId,
writerProjection.replaceRow(row));
@@ -709,8 +656,8 @@ public class GenericIndexTopoBuilder {
LOG.info(
"Finished shard [{}, {}]: saw {} rows, "
+ "produced {} result entries in {} ms.",
- task.shardRange.from,
- task.shardRange.to,
+ shardRange.from,
+ shardRange.to,
rowsSeen,
resultEntries.size(),
elapsed);
@@ -719,8 +666,8 @@ public class GenericIndexTopoBuilder {
LOG.warn(
"Shard [{}, {}] produced no index (all null or
empty), "
+ "skipping index flush.",
- task.shardRange.from,
- task.shardRange.to);
+ shardRange.from,
+ shardRange.to);
return;
}
@@ -728,7 +675,7 @@ public class GenericIndexTopoBuilder {
flushIndex(
table,
partition,
- task.shardRange,
+ shardRange,
indexedFields,
indexType,
resultEntries);
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
index 4a17bb68f1..b68611eb31 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilder.java
@@ -111,7 +111,7 @@ public class SortedIndexTopoBuilder {
}
Optional<Pair<RowRangeIndex, List<DataSplit>>> indexRangeAndSplits
=
- indexBuilder.scan();
+ indexBuilder.incrementalScan();
if (!indexRangeAndSplits.isPresent()) {
continue;
}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilderTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilderTest.java
index c69b59ad6e..916d6c2578 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilderTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilderTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BinaryRowWriter;
import org.apache.paimon.data.BinaryString;
import org.apache.paimon.fs.Path;
import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.IndexedSplit;
import org.apache.paimon.io.PojoDataFileMeta;
import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.ManifestEntry;
@@ -74,12 +75,11 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 100));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 99));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
99));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(1);
}
@Test
@@ -88,17 +88,16 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 250));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).hasSize(3);
// Shard 0: [0, 99], shard 1: [0, 199] clamped to [100, 199], shard 2:
[0, 249] clamped
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 99));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(100, 199));
- assertThat(tasks.get(2).shardRange).isEqualTo(new Range(200, 249));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
99));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(100,
199));
+ assertThat(tasks.get(2).rowRanges().get(0)).isEqualTo(new Range(200,
249));
// Each shard should contain the same file
- for (GenericIndexTopoBuilder.ShardTask task : tasks) {
- assertThat(task.split.dataFiles()).hasSize(1);
+ for (IndexedSplit task : tasks) {
+ assertThat(task.dataSplit().dataFiles()).hasSize(1);
}
}
@@ -109,12 +108,11 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 50));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 50L, 50));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 99));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(2);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
99));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(2);
}
@Test
@@ -124,15 +122,14 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 30));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 70L, 30));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
// Gap produces two separate tasks within the same shard
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 29));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(1);
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(70, 99));
- assertThat(tasks.get(1).split.dataFiles()).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
29));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(1);
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(70,
99));
+ assertThat(tasks.get(1).dataSplit().dataFiles()).hasSize(1);
}
@Test
@@ -142,13 +139,12 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, null, 100));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 50));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
// Only the file with valid firstRowId should produce a task
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).split.dataFiles()).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 49));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
49));
}
@Test
@@ -160,17 +156,17 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(partA, 0L, 50));
entries.add(createEntry(partB, 100L, 50));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).hasSize(2);
// Each partition should have its own task
- assertThat(tasks.stream().map(t ->
t.split.partition()).distinct().count()).isEqualTo(2);
+ assertThat(tasks.stream().map(t ->
t.dataSplit().partition()).distinct().count())
+ .isEqualTo(2);
}
@Test
void testEmptyEntries() throws IOException {
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table,
Collections.emptyList(), 100);
assertThat(tasks).isEmpty();
@@ -182,8 +178,7 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, null, 100));
entries.add(createEntry(BinaryRow.EMPTY_ROW, null, 200));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).isEmpty();
}
@@ -195,16 +190,15 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 80));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 80L, 80));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
// Shard 0: both files (contiguous), range [0, 99]
// Shard 1: only file2, range [100, 159]
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 99));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(2);
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(100, 159));
- assertThat(tasks.get(1).split.dataFiles()).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
99));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(2);
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(100,
159));
+ assertThat(tasks.get(1).dataSplit().dataFiles()).hasSize(1);
}
@Test
@@ -213,13 +207,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 50L, 100));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
- GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 100);
assertThat(tasks).hasSize(2);
// Clamped: shard 0 starts at 50 (not 0), shard 1 ends at 149 (not 199)
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(50, 99));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(100, 149));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(50,
99));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(100,
149));
}
// ========== Incremental build scenarios (maxIndexedRowId) ==========
@@ -232,12 +225,12 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 100));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 100L, 100));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
-1);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 199));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(2);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
199));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(2);
}
@Test
@@ -246,11 +239,41 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 200L, 200));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
+ }
+
+ @Test
+ void testIncrementalUsesUnindexedRangesInsteadOfMaxRowId() {
+ // Existing index may cover a later range while an earlier
partition/range
+ // is still unindexed. Building from ranges must not skip the earlier
gap.
+ List<ManifestEntry> entries = new ArrayList<>();
+ entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 100));
+ entries.add(createEntry(BinaryRow.EMPTY_ROW, 100L, 100));
+ List<Range> unindexedRanges = Collections.singletonList(new Range(0,
99));
+
+ List<IndexedSplit> tasks =
+ GenericIndexTopoBuilder.computeShardTasks(table, entries, 100,
unindexedRanges);
+
+ assertThat(tasks).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
99));
+ }
+
+ @Test
+ void testIncrementalRangeCanSplitOneShardIntoMultipleTasks() {
+ List<ManifestEntry> entries = new ArrayList<>();
+ entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 100));
+ List<Range> unindexedRanges = Arrays.asList(new Range(0, 9), new
Range(90, 99));
+
+ List<IndexedSplit> tasks =
+ GenericIndexTopoBuilder.computeShardTasks(table, entries, 100,
unindexedRanges);
+
+ assertThat(tasks).hasSize(2);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0, 9));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(90,
99));
}
@Test
@@ -259,7 +282,7 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 400));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
399);
assertThat(tasks).isEmpty();
@@ -273,17 +296,17 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 100L, 200)); // D[100,299]
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 299));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
299));
}
@Test
void testIncrementalCompactOnlyIndexedFiles() {
// Compact two indexed files → empty entries → no tasks.
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table,
Collections.emptyList(), 200, 199);
assertThat(tasks).isEmpty();
@@ -297,11 +320,11 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 200L, 400)); // D[200,599]
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
399);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(400, 599));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(400,
599));
}
@Test
@@ -312,11 +335,11 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 400));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
}
@Test
@@ -325,12 +348,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 250));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
-1);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 199));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(200, 249));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
199));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(200,
249));
}
@Test
@@ -342,12 +365,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 600));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(400, 599));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(400,
599));
}
@Test
@@ -356,11 +379,11 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 200L, 200));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
}
@Test
@@ -371,12 +394,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 350));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
149);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(150, 199));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(200, 349));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(150,
199));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(200,
349));
}
@Test
@@ -386,12 +409,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 600));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(400, 599));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(400,
599));
}
@Test
@@ -400,12 +423,12 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, null, 100));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 200L, 100));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 299));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(1);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
299));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(1);
}
@Test
@@ -415,12 +438,12 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 200L, 100));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 300L, 100));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 400,
199);
assertThat(tasks).hasSize(1);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(200, 399));
- assertThat(tasks.get(0).split.dataFiles()).hasSize(2);
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(200,
399));
+ assertThat(tasks.get(0).dataSplit().dataFiles()).hasSize(2);
}
@Test
@@ -430,12 +453,12 @@ class GenericIndexTopoBuilderTest {
entries.add(createEntry(BinaryRow.EMPTY_ROW, 0L, 50));
entries.add(createEntry(BinaryRow.EMPTY_ROW, 150L, 50));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
-1);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(0, 49));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(150, 199));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(0,
49));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(150,
199));
}
@Test
@@ -446,12 +469,12 @@ class GenericIndexTopoBuilderTest {
List<ManifestEntry> entries = new ArrayList<>();
entries.add(createEntry(BinaryRow.EMPTY_ROW, 300L, 200));
- List<GenericIndexTopoBuilder.ShardTask> tasks =
+ List<IndexedSplit> tasks =
GenericIndexTopoBuilder.computeShardTasks(table, entries, 200,
250);
assertThat(tasks).hasSize(2);
- assertThat(tasks.get(0).shardRange).isEqualTo(new Range(300, 399));
- assertThat(tasks.get(1).shardRange).isEqualTo(new Range(400, 499));
+ assertThat(tasks.get(0).rowRanges().get(0)).isEqualTo(new Range(300,
399));
+ assertThat(tasks.get(1).rowRanges().get(0)).isEqualTo(new Range(400,
499));
}
@Test
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilderTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilderTest.java
index 89a15bef31..21941e35e6 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilderTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/globalindex/SortedIndexTopoBuilderTest.java
@@ -34,6 +34,7 @@ import java.util.Optional;
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.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -44,7 +45,7 @@ public class SortedIndexTopoBuilderTest {
public void testBuildIndexReturnsFalseWhenNoBuildTask() throws Exception {
SortedGlobalIndexBuilder indexBuilder =
mock(SortedGlobalIndexBuilder.class);
when(indexBuilder.withIndexField("id")).thenReturn(indexBuilder);
- when(indexBuilder.scan()).thenReturn(Optional.empty());
+ when(indexBuilder.incrementalScan()).thenReturn(Optional.empty());
StreamExecutionEnvironment env =
mock(StreamExecutionEnvironment.class);
assertThat(
@@ -56,6 +57,7 @@ public class SortedIndexTopoBuilderTest {
null,
new Options()))
.isFalse();
+ verify(indexBuilder).incrementalScan();
verifyNoInteractions(env);
}
diff --git a/paimon-python/pypaimon/globalindex/build_plan.py
b/paimon-python/pypaimon/globalindex/build_plan.py
new file mode 100644
index 0000000000..74893d16b2
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/build_plan.py
@@ -0,0 +1,331 @@
+# 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.
+
+"""Reusable global index build planning helpers."""
+
+from typing import List, Optional, Sequence
+
+from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.read.split import DataSplit
+from pypaimon.utils.range import Range
+from pypaimon.utils.range_helper import RangeHelper
+
+
+def calc_row_range(split) -> Range:
+ ranges = []
+ for file in split.files:
+ row_range = file.row_id_range()
+ if row_range is None:
+ raise ValueError(
+ "Cannot build global index because file '%s' has no row id
range."
+ % file.file_name
+ )
+ ranges.append(row_range)
+ if not ranges:
+ raise ValueError("Cannot build global index for an empty split.")
+ merged = Range.sort_and_merge_overlap(ranges, True, True)
+ return Range(merged[0].from_, merged[-1].to)
+
+
+def unindexed_row_ranges(
+ table,
+ snapshot,
+ partition_filter,
+ index_field_id: int,
+ index_type: str,
+) -> List[Range]:
+ next_row_id = getattr(snapshot, "next_row_id", None)
+ if snapshot is None or next_row_id is None or next_row_id <= 0:
+ return []
+
+ indexed_ranges = indexed_row_ranges(
+ table, snapshot, partition_filter, index_field_id, index_type)
+ return Range.sort_and_merge_overlap(
+ Range(0, next_row_id - 1).exclude(indexed_ranges), True)
+
+
+def indexed_row_ranges(
+ table,
+ snapshot,
+ partition_filter,
+ index_field_id: int,
+ index_type: str,
+) -> List[Range]:
+ from pypaimon.index.index_file_handler import IndexFileHandler
+
+ ranges = []
+ for entry in IndexFileHandler(table).scan(snapshot):
+ if getattr(entry, "kind", 0) != 0:
+ continue
+ if (partition_filter is not None
+ and not partition_filter.test(entry.partition)):
+ continue
+
+ index_file = entry.index_file
+ meta = index_file.global_index_meta
+ if (
+ meta is None
+ or index_file.index_type != index_type
+ or meta.index_field_id != index_field_id
+ or meta.extra_field_ids
+ ):
+ continue
+ ranges.append(Range(meta.row_range_start, meta.row_range_end))
+ return Range.sort_and_merge_overlap(ranges, True)
+
+
+def split_by_contiguous_row_range(splits):
+ result = []
+ for split in splits:
+ result.extend(split_one_by_contiguous_row_range(split))
+ return result
+
+
+def split_by_contiguous_unindexed_row_range(splits, unindexed_ranges):
+ result = []
+ for split in split_by_contiguous_row_range(splits):
+ row_range = calc_row_range(split)
+ for index_range in Range.and_([row_range], unindexed_ranges):
+ index_split = indexed_split_for_row_range(split, index_range)
+ if index_split is not None:
+ result.append((index_split, index_range))
+ return result
+
+
+def indexed_split_for_row_range(split, row_range):
+ files = [
+ file for file in split.files
+ if file.row_id_range() is not None
+ and file.row_id_range().overlaps(row_range)
+ ]
+ if not files:
+ return None
+ return IndexedSplit(copy_split_with_files(split, files), [row_range])
+
+
+def filter_non_indexable_splits(table, splits, index_columns: Sequence[str]):
+ boundary = find_min_non_indexable_row_id(
+ table.schema_manager,
+ [file for split in splits for file in split.files],
+ index_columns,
+ )
+ if boundary is None:
+ return splits
+
+ result = []
+ for split in splits:
+ files = [
+ file for file in split.files
+ if file.row_id_range() is not None
+ and file.row_id_range().from_ < boundary
+ ]
+ if files:
+ result.append(copy_split_with_files(split, files))
+ return result
+
+
+def find_min_non_indexable_row_id(schema_manager, files, index_columns):
+ schema_contains_columns = {}
+ index_column_set = set(index_columns)
+ boundary = None
+ for file in files:
+ row_range = file.row_id_range()
+ if row_range is None:
+ continue
+
+ schema_id = file.schema_id
+ if schema_id not in schema_contains_columns:
+ schema = schema_manager.get_schema(schema_id)
+ schema_field_names = {field.name for field in schema.fields}
+ schema_contains_columns[schema_id] = (
+ index_column_set.issubset(schema_field_names)
+ )
+ if not schema_contains_columns[schema_id]:
+ if boundary is None or row_range.from_ < boundary:
+ boundary = row_range.from_
+ return boundary
+
+
+def split_by_global_index_shard(
+ splits,
+ rows_per_shard: int,
+ row_ranges_to_build: Optional[List[Range]] = None,
+):
+ if rows_per_shard <= 0:
+ raise ValueError(
+ "Option 'global-index.row-count-per-shard' must be greater than 0."
+ )
+ if row_ranges_to_build is not None:
+ row_ranges_to_build = Range.sort_and_merge_overlap(
+ row_ranges_to_build, True)
+ if not row_ranges_to_build:
+ return []
+
+ groups = {}
+ for split in splits:
+ key = (partition_key(split.partition), split.bucket)
+ if key not in groups:
+ groups[key] = {
+ "partition": split.partition,
+ "bucket": split.bucket,
+ "files": [],
+ }
+ for file in split.files:
+ if file.row_id_range() is None:
+ continue
+ groups[key]["files"].append(file)
+
+ result = []
+ for group in groups.values():
+ files_by_shard = {}
+ for file in group["files"]:
+ file_range = file.row_id_range()
+ start_shard = file_range.from_ // rows_per_shard
+ end_shard = file_range.to // rows_per_shard
+ for shard_id in range(start_shard, end_shard + 1):
+ shard_start = shard_id * rows_per_shard
+ files_by_shard.setdefault(shard_start, []).append(file)
+
+ for shard_start in sorted(files_by_shard):
+ shard_end = shard_start + rows_per_shard - 1
+ shard_files = sorted(
+ files_by_shard[shard_start],
+ key=lambda file: file.row_id_range().from_,
+ )
+ current_group = []
+ current_group_end = None
+ for file in shard_files:
+ file_range = file.row_id_range()
+ if not current_group:
+ current_group.append(file)
+ current_group_end = file_range.to
+ elif file_range.from_ <= current_group_end + 1:
+ current_group.append(file)
+ current_group_end = max(current_group_end, file_range.to)
+ else:
+ append_shard_split(
+ result,
+ current_group,
+ shard_start,
+ shard_end,
+ group["partition"],
+ group["bucket"],
+ row_ranges_to_build,
+ )
+ current_group = [file]
+ current_group_end = file_range.to
+
+ if current_group:
+ append_shard_split(
+ result,
+ current_group,
+ shard_start,
+ shard_end,
+ group["partition"],
+ group["bucket"],
+ row_ranges_to_build,
+ )
+
+ return result
+
+
+def partition_key(partition):
+ values = getattr(partition, "values", None)
+ return tuple(values) if values is not None else partition
+
+
+def append_shard_split(
+ result,
+ files,
+ shard_start,
+ shard_end,
+ partition,
+ bucket,
+ row_ranges_to_build,
+):
+ group_start = min(file.row_id_range().from_ for file in files)
+ group_end = max(file.row_id_range().to for file in files)
+ row_range = Range(max(group_start, shard_start), min(group_end, shard_end))
+ task_ranges = (
+ [row_range]
+ if row_ranges_to_build is None
+ else Range.and_([row_range], row_ranges_to_build)
+ )
+ if not task_ranges:
+ return
+
+ data_split = DataSplit(
+ files=list(files),
+ partition=partition,
+ bucket=bucket,
+ raw_convertible=False,
+ )
+ for task_range in task_ranges:
+ result.append((IndexedSplit(data_split, [task_range]), task_range))
+
+
+def split_one_by_contiguous_row_range(split):
+ for file in split.files:
+ if file.row_id_range() is None:
+ raise ValueError(
+ "Cannot build global index because file '%s' has no row id
range."
+ % file.file_name
+ )
+
+ range_helper = RangeHelper(lambda file: file.row_id_range())
+ ranges = range_helper.merge_overlapping_ranges(split.files)
+ if not ranges:
+ return []
+
+ result = []
+ current_segment = []
+ current_max_row_id = None
+ for range_files in ranges:
+ min_row_id = min(file.row_id_range().from_ for file in range_files)
+ max_row_id = max(file.row_id_range().to for file in range_files)
+ if (
+ not current_segment
+ or current_max_row_id is None
+ or current_max_row_id >= min_row_id - 1
+ ):
+ current_segment.extend(range_files)
+ current_max_row_id = max_row_id
+ else:
+ result.append(copy_split_with_files(split, current_segment))
+ current_segment = list(range_files)
+ current_max_row_id = max_row_id
+
+ if current_segment:
+ result.append(copy_split_with_files(split, current_segment))
+ return result
+
+
+def copy_split_with_files(split, files):
+ data_deletion_files = None
+ if getattr(split, "data_deletion_files", None) is not None:
+ index_by_file = {id(file): i for i, file in enumerate(split.files)}
+ data_deletion_files = [
+ split.data_deletion_files[index_by_file[id(file)]]
+ for file in files
+ ]
+ return DataSplit(
+ files=list(files),
+ partition=split.partition,
+ bucket=split.bucket,
+ raw_convertible=getattr(split, "raw_convertible", False),
+ data_deletion_files=data_deletion_files,
+ )
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py
b/paimon-python/pypaimon/globalindex/create_global_index.py
index 6fe5ae82e3..3cf1547edf 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -33,6 +33,12 @@ from pypaimon.globalindex.bitmap.bitmap_index_writer import (
BITMAP_IDENTIFIER,
BitmapIndexWriter,
)
+from pypaimon.globalindex.build_plan import (
+ filter_non_indexable_splits as _filter_non_indexable_splits,
+ split_by_contiguous_unindexed_row_range as
_split_by_contiguous_unindexed_row_range,
+ split_by_global_index_shard as _split_by_global_index_shard,
+ unindexed_row_ranges as _unindexed_row_ranges,
+)
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
from pypaimon.globalindex.key_serializer import create_serializer
from pypaimon.globalindex.tantivy.tantivy_full_text_global_index_reader import
(
@@ -49,11 +55,9 @@ from pypaimon.globalindex.vindex.vindex_vector_index_writer
import (
)
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
-from pypaimon.read.split import DataSplit
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
-from pypaimon.utils.range_helper import RangeHelper
from pypaimon.write.commit_message import CommitMessage
@@ -156,11 +160,23 @@ class GlobalIndexBuilder:
read_builder = read_builder.with_partition_filter(partition_filter)
scan = read_builder.new_scan()
- splits = scan.plan().splits()
+ plan = scan.plan()
+ splits = plan.splits()
if not splits:
return []
index_field = self._table.field_dict[self._index_columns[0]]
+ snapshot = self._snapshot_for_plan(plan)
+ unindexed_ranges = _unindexed_row_ranges(
+ self._table,
+ snapshot,
+ partition_filter,
+ index_field.id,
+ self._index_type,
+ )
+ if not unindexed_ranges:
+ return []
+
if self._index_type in _GENERIC_INDEX_IDENTIFIERS:
splits = _filter_non_indexable_splits(
self._table, splits, self._index_columns)
@@ -180,12 +196,19 @@ class GlobalIndexBuilder:
if self._index_type in _SORTED_INDEX_IDENTIFIERS:
return self._build_sorted_index(
- splits, index_field, table_read, index_path)
+ splits, unindexed_ranges, index_field, table_read, index_path)
return self._build_generic_index(
- splits, index_field, table_read, index_path)
+ splits, unindexed_ranges, index_field, table_read, index_path)
+
+ def _snapshot_for_plan(self, plan):
+ snapshot_id = getattr(plan, "snapshot_id", None)
+ snapshot_manager = self._table.snapshot_manager()
+ if snapshot_id is not None:
+ return snapshot_manager.get_snapshot_by_id(snapshot_id)
+ return snapshot_manager.get_latest_snapshot()
def _build_sorted_index(
- self, splits, index_field, table_read, index_path: str
+ self, splits, unindexed_ranges, index_field, table_read, index_path:
str
) -> List[CommitMessage]:
key_serializer = create_serializer(index_field.type)
configured_records_per_range = (
@@ -198,8 +221,9 @@ class GlobalIndexBuilder:
)
messages = []
- for split in _split_by_contiguous_row_range(splits):
- row_range = _calc_row_range(split)
+ for split, row_range in _split_by_contiguous_unindexed_row_range(
+ splits, unindexed_ranges
+ ):
table = table_read.to_arrow([split])
if table is None or table.num_rows == 0:
continue
@@ -208,6 +232,7 @@ class GlobalIndexBuilder:
self._index_columns[0],
SpecialFields.ROW_ID.name,
key_serializer,
+ row_range,
)
if not rows:
continue
@@ -259,7 +284,7 @@ class GlobalIndexBuilder:
raise ValueError("Unsupported sorted global index type: %s" %
self._index_type)
def _build_generic_index(
- self, splits, index_field, table_read, index_path: str
+ self, splits, unindexed_ranges, index_field, table_read, index_path:
str
) -> List[CommitMessage]:
rows_per_shard = self._core_options.global_index_row_count_per_shard()
if rows_per_shard <= 0:
@@ -268,10 +293,10 @@ class GlobalIndexBuilder:
)
messages = []
- for split, row_range in _split_by_global_index_shard(
- splits, rows_per_shard
+ for index_split, index_range in _split_by_global_index_shard(
+ splits, rows_per_shard, unindexed_ranges
):
- table = table_read.to_arrow([split])
+ table = table_read.to_arrow([index_split])
if table is None or table.num_rows == 0:
continue
@@ -281,14 +306,14 @@ class GlobalIndexBuilder:
table,
self._index_columns[0],
SpecialFields.ROW_ID.name,
- row_range,
+ index_range,
):
- writer.write(value, row_id - row_range.from_)
+ writer.write(value, row_id - index_range.from_)
index_adds = _to_index_manifest_entries(
self._table,
- split.partition,
- row_range,
+ index_split.partition,
+ index_range,
index_field.id,
self._index_type,
writer.finish(),
@@ -298,7 +323,7 @@ class GlobalIndexBuilder:
if index_adds:
messages.append(
CommitMessage(
- partition=tuple(split.partition.values),
+ partition=tuple(index_split.partition.values),
bucket=0,
new_files=[],
index_adds=index_adds,
@@ -385,229 +410,12 @@ def _merged_options(table, options: Optional[Dict[str,
object]]) -> Options:
return Options(merged)
-def _calc_row_range(split) -> Range:
- ranges = []
- for file in split.files:
- row_range = file.row_id_range()
- if row_range is None:
- raise ValueError(
- "Cannot build global index because file '%s' has no row id
range."
- % file.file_name
- )
- ranges.append(row_range)
- if not ranges:
- raise ValueError("Cannot build global index for an empty split.")
- merged = Range.sort_and_merge_overlap(ranges, True, True)
- return Range(merged[0].from_, merged[-1].to)
-
-
-def _split_by_contiguous_row_range(splits):
- result = []
- for split in splits:
- result.extend(_split_one_by_contiguous_row_range(split))
- return result
-
-
-def _filter_non_indexable_splits(table, splits, index_columns):
- boundary = _find_min_non_indexable_row_id(
- table.schema_manager,
- [file for split in splits for file in split.files],
- index_columns,
- )
- if boundary is None:
- return splits
-
- result = []
- for split in splits:
- files = [
- file for file in split.files
- if file.row_id_range() is not None
- and file.row_id_range().from_ < boundary
- ]
- if files:
- result.append(_copy_split_with_files(split, files))
- return result
-
-
-def _find_min_non_indexable_row_id(schema_manager, files, index_columns):
- schema_contains_columns = {}
- index_column_set = set(index_columns)
- boundary = None
- for file in files:
- row_range = file.row_id_range()
- if row_range is None:
- continue
-
- schema_id = file.schema_id
- if schema_id not in schema_contains_columns:
- schema = schema_manager.get_schema(schema_id)
- schema_field_names = {field.name for field in schema.fields}
- schema_contains_columns[schema_id] = (
- index_column_set.issubset(schema_field_names)
- )
- if not schema_contains_columns[schema_id]:
- if boundary is None or row_range.from_ < boundary:
- boundary = row_range.from_
- return boundary
-
-
-def _split_by_global_index_shard(splits, rows_per_shard):
- if rows_per_shard <= 0:
- raise ValueError(
- "Option 'global-index.row-count-per-shard' must be greater than 0."
- )
-
- groups = {}
- for split in splits:
- key = (_partition_key(split.partition), split.bucket)
- if key not in groups:
- groups[key] = {
- "partition": split.partition,
- "bucket": split.bucket,
- "files": [],
- }
- for file in split.files:
- if file.row_id_range() is None:
- continue
- groups[key]["files"].append(file)
-
- result = []
- for group in groups.values():
- files_by_shard = {}
- for file in group["files"]:
- file_range = file.row_id_range()
- start_shard = file_range.from_ // rows_per_shard
- end_shard = file_range.to // rows_per_shard
- for shard_id in range(start_shard, end_shard + 1):
- shard_start = shard_id * rows_per_shard
- files_by_shard.setdefault(shard_start, []).append(file)
-
- for shard_start in sorted(files_by_shard):
- shard_end = shard_start + rows_per_shard - 1
- shard_files = sorted(
- files_by_shard[shard_start],
- key=lambda file: file.row_id_range().from_,
- )
- current_group = []
- current_group_end = None
- for file in shard_files:
- file_range = file.row_id_range()
- if not current_group:
- current_group.append(file)
- current_group_end = file_range.to
- elif file_range.from_ <= current_group_end + 1:
- current_group.append(file)
- current_group_end = max(current_group_end, file_range.to)
- else:
- _append_shard_split(
- result,
- current_group,
- shard_start,
- shard_end,
- group["partition"],
- group["bucket"],
- )
- current_group = [file]
- current_group_end = file_range.to
-
- if current_group:
- _append_shard_split(
- result,
- current_group,
- shard_start,
- shard_end,
- group["partition"],
- group["bucket"],
- )
-
- return result
-
-
-def _partition_key(partition):
- values = getattr(partition, "values", None)
- return tuple(values) if values is not None else partition
-
-
-def _append_shard_split(
- result,
- files,
- shard_start,
- shard_end,
- partition,
- bucket,
-):
- group_start = min(file.row_id_range().from_ for file in files)
- group_end = max(file.row_id_range().to for file in files)
- row_range = Range(max(group_start, shard_start), min(group_end, shard_end))
- result.append((
- DataSplit(
- files=list(files),
- partition=partition,
- bucket=bucket,
- raw_convertible=False,
- ),
- row_range,
- ))
-
-
-def _split_one_by_contiguous_row_range(split):
- for file in split.files:
- if file.row_id_range() is None:
- raise ValueError(
- "Cannot build global index because file '%s' has no row id
range."
- % file.file_name
- )
-
- range_helper = RangeHelper(lambda file: file.row_id_range())
- ranges = range_helper.merge_overlapping_ranges(split.files)
- if not ranges:
- return []
-
- result = []
- current_segment = []
- current_max_row_id = None
- for range_files in ranges:
- min_row_id = min(file.row_id_range().from_ for file in range_files)
- max_row_id = max(file.row_id_range().to for file in range_files)
- if (
- not current_segment
- or current_max_row_id is None
- or current_max_row_id >= min_row_id - 1
- ):
- current_segment.extend(range_files)
- current_max_row_id = max_row_id
- else:
- result.append(_copy_split_with_files(split, current_segment))
- current_segment = list(range_files)
- current_max_row_id = max_row_id
-
- if current_segment:
- result.append(_copy_split_with_files(split, current_segment))
- return result
-
-
-def _copy_split_with_files(split, files):
- data_deletion_files = None
- if getattr(split, "data_deletion_files", None) is not None:
- index_by_file = {id(file): i for i, file in enumerate(split.files)}
- data_deletion_files = [
- split.data_deletion_files[index_by_file[id(file)]]
- for file in files
- ]
- return DataSplit(
- files=list(files),
- partition=split.partition,
- bucket=split.bucket,
- raw_convertible=getattr(split, "raw_convertible", False),
- data_deletion_files=data_deletion_files,
- )
-
-
def _extract_sorted_rows(
table: pa.Table,
index_column: str,
row_id_column: str,
key_serializer,
+ row_range: Optional[Range] = None,
):
keys = table.column(index_column).to_pylist()
row_ids = table.column(row_id_column).to_pylist()
@@ -615,7 +423,10 @@ def _extract_sorted_rows(
for key, row_id in zip(keys, row_ids):
if row_id is None:
raise ValueError("Cannot build global index because _ROW_ID is
null.")
- rows.append((key, int(row_id)))
+ row_id = int(row_id)
+ if row_range is not None and not row_range.contains(row_id):
+ continue
+ rows.append((key, row_id))
comparator = key_serializer.create_comparator()
diff --git a/paimon-python/pypaimon/tests/global_index_build_test.py
b/paimon-python/pypaimon/tests/global_index_build_test.py
index 9210d82091..ee73162319 100644
--- a/paimon-python/pypaimon/tests/global_index_build_test.py
+++ b/paimon-python/pypaimon/tests/global_index_build_test.py
@@ -25,10 +25,10 @@ import types
import pyarrow as pa
-from pypaimon.globalindex.create_global_index import (
- _filter_non_indexable_splits,
- _split_by_global_index_shard,
- _split_one_by_contiguous_row_range,
+from pypaimon.globalindex.build_plan import (
+ filter_non_indexable_splits as _filter_non_indexable_splits,
+ split_by_global_index_shard as _split_by_global_index_shard,
+ split_one_by_contiguous_row_range as _split_one_by_contiguous_row_range,
)
from pypaimon.globalindex.key_serializer import create_serializer
from pypaimon.globalindex.tantivy.tantivy_full_text_global_index_reader import
(
@@ -536,7 +536,7 @@ class GlobalIndexBuildTest(
self.assertTrue(external_path.startswith(external_root + '/'))
self.assertTrue(table.file_io.exists(external_path))
- def test_create_global_index_rejects_overlapping_existing_range(self):
+ def test_create_global_index_skips_existing_ranges(self):
table = self._create_table()
self._write_arrow(table, pa.table(
{
@@ -553,12 +553,47 @@ class GlobalIndexBuildTest(
snapshot = table.snapshot_manager().get_latest_snapshot()
self.assertEqual(2, len(IndexFileHandler(table).scan(snapshot)))
- with self.assertRaisesRegex(RuntimeError, 'overlapping row range'):
- table.create_global_index('id', options=options)
+ self.assertEqual(0, table.create_global_index('id', options=options))
latest_snapshot = table.snapshot_manager().get_latest_snapshot()
self.assertEqual(2, len(IndexFileHandler(table).scan(latest_snapshot)))
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [5, 4],
+ 'name': ['e', 'd'],
+ 'age': [50, 40],
+ 'city': ['v', 'u'],
+ },
+ schema=self.pa_schema,
+ ))
+
+ self.assertEqual(1, table.create_global_index('id', options=options))
+ latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+ entries = sorted(
+ IndexFileHandler(table).scan(latest_snapshot),
+ key=lambda entry: (
+ entry.index_file.global_index_meta.row_range_start,
+ entry.index_file.file_name,
+ ),
+ )
+ self.assertEqual(
+ [(0, 3), (0, 3), (4, 5)],
+ [
+ (
+ entry.index_file.global_index_meta.row_range_start,
+ entry.index_file.global_index_meta.row_range_end,
+ )
+ for entry in entries
+ ],
+ )
+ self.assertEqual(0, table.create_global_index('id', options=options))
+ self.assertEqual(
+ 3,
+ len(IndexFileHandler(table).scan(
+ table.snapshot_manager().get_latest_snapshot())),
+ )
+
def test_create_btree_global_index_for_java_scalar_types(self):
schema = pa.schema([
('flag', pa.bool_()),
@@ -707,6 +742,98 @@ class GlobalIndexBuildTest(
],
)
+ def test_create_vindex_global_index_skips_existing_ranges(self):
+ schema = pa.schema([
+ ('id', pa.int32()),
+ ('embedding', pa.list_(pa.float32())),
+ ])
+ table = self._create_table(pa_schema=schema,
options=self.table_options)
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [1, 2, 3],
+ 'embedding': pa.array(
+ [[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]],
+ type=pa.list_(pa.float32()),
+ ),
+ },
+ schema=schema,
+ ))
+
+ old_module = sys.modules.get("paimon_vindex")
+ sys.modules["paimon_vindex"] = types.SimpleNamespace(
+ VectorIndexWriter=_FakeVectorIndexWriter)
+ _FakeVectorIndexWriter.instances = []
+ options = {
+ 'global-index.row-count-per-shard': '2',
+ 'ivf-flat.dimension': '2',
+ }
+ try:
+ self.assertEqual(
+ 2,
+ table.create_global_index(
+ 'embedding',
+ index_type='ivf-flat',
+ options=options,
+ ),
+ )
+ self.assertEqual(
+ 0,
+ table.create_global_index(
+ 'embedding',
+ index_type='ivf-flat',
+ options=options,
+ ),
+ )
+
+ self._write_arrow(table, pa.table(
+ {
+ 'id': [4, 5],
+ 'embedding': pa.array(
+ [[0.2, 0.8], [0.9, 0.1]],
+ type=pa.list_(pa.float32()),
+ ),
+ },
+ schema=schema,
+ ))
+
+ self.assertEqual(
+ 2,
+ table.create_global_index(
+ 'embedding',
+ index_type='ivf-flat',
+ options=options,
+ ),
+ )
+ self.assertEqual(
+ 0,
+ table.create_global_index(
+ 'embedding',
+ index_type='ivf-flat',
+ options=options,
+ ),
+ )
+ finally:
+ if old_module is None:
+ sys.modules.pop("paimon_vindex", None)
+ else:
+ sys.modules["paimon_vindex"] = old_module
+
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ entries = sorted(
+ IndexFileHandler(table).scan(snapshot),
+ key=lambda entry:
entry.index_file.global_index_meta.row_range_start,
+ )
+ self.assertEqual(
+ [(0, 1), (2, 2), (3, 3), (4, 4)],
+ [
+ (
+ entry.index_file.global_index_meta.row_range_start,
+ entry.index_file.global_index_meta.row_range_end,
+ )
+ for entry in entries
+ ],
+ )
+
def test_create_tantivy_fulltext_global_index_from_python(self):
schema = pa.schema([
('id', pa.int32()),
@@ -973,6 +1100,25 @@ class GlobalIndexBuildTest(
],
)
+ def test_split_by_global_index_shard_uses_unindexed_ranges(self):
+ split = _FakeSplit([
+ _FakeFile('a', 0, 100),
+ ])
+
+ shards = _split_by_global_index_shard(
+ [split], 100, [Range(0, 9), Range(90, 99)])
+
+ self.assertEqual(
+ [
+ (['a'], 0, 9),
+ (['a'], 90, 99),
+ ],
+ [
+ ([file.file_name for file in shard.files], row_range.from_,
row_range.to)
+ for shard, row_range in shards
+ ],
+ )
+
def test_split_by_global_index_shard_skips_files_without_row_ids(self):
split = _FakeSplit([
_FakeFile('no-row-id', None, 2),
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexTopoBuilder.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexTopoBuilder.java
index b2112149a1..c8dc0bc011 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexTopoBuilder.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexTopoBuilder.java
@@ -18,12 +18,12 @@
package org.apache.paimon.spark.globalindex;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.fs.Path;
import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
import org.apache.paimon.globalindex.IndexedSplit;
-import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.PartitionPredicate;
@@ -32,12 +32,10 @@ import org.apache.paimon.schema.SchemaManager;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.sink.CommitMessage;
import org.apache.paimon.table.sink.CommitMessageSerializer;
-import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.CloseableIterator;
-import org.apache.paimon.utils.FileStorePathFactory;
import org.apache.paimon.utils.InstantiationUtil;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Range;
@@ -46,12 +44,11 @@ import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.sql.SparkSession;
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
@@ -107,8 +104,17 @@ public class DefaultGlobalIndexTopoBuilder implements
GlobalIndexTopologyBuilder
rowsPerShard > 0,
"Option 'global-index.row-count-per-shard' must be greater
than 0.");
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ if (snapshot == null) {
+ return Collections.emptyList();
+ }
List<ManifestEntry> entries =
-
table.store().newScan().withPartitionFilter(partitionPredicate).plan().files();
+ table.store()
+ .newScan()
+ .withSnapshot(snapshot)
+ .withPartitionFilter(partitionPredicate)
+ .plan()
+ .files();
List<DataField> indexFields = new ArrayList<>();
indexFields.add(indexField);
indexFields.addAll(extraFields);
@@ -119,33 +125,36 @@ public class DefaultGlobalIndexTopoBuilder implements
GlobalIndexTopologyBuilder
GlobalIndexBuilderUtils.findMinNonIndexableRowId(
schemaManager, entries, indexColumns);
entries = GlobalIndexBuilderUtils.filterEntriesBefore(entries,
boundaryRowId);
+ List<Range> rowRangesToBuild =
+ GlobalIndexBuilderUtils.unindexedRowRanges(
+ table, snapshot, indexType, indexFields,
partitionPredicate);
+ if (rowRangesToBuild.isEmpty()) {
+ return Collections.emptyList();
+ }
// generate splits for each partition && shard
- Map<BinaryRow, List<IndexedSplit>> splits = split(table, entries,
rowsPerShard);
+ List<IndexedSplit> splits =
+ GlobalIndexBuilderUtils.createShardIndexedSplits(
+ table, entries, rowsPerShard, rowRangesToBuild);
JavaSparkContext javaSparkContext = new
JavaSparkContext(spark.sparkContext());
List<Pair<byte[], byte[]>> taskList = new ArrayList<>();
- for (Map.Entry<BinaryRow, List<IndexedSplit>> entry :
splits.entrySet()) {
- BinaryRow partition = entry.getKey();
- List<IndexedSplit> partitions = entry.getValue();
-
- for (IndexedSplit indexedSplit : partitions) {
- checkArgument(
- indexedSplit.rowRanges().size() == 1,
- "Each IndexedSplit should contain exactly one row
range.");
- DefaultGlobalIndexBuilder builder =
- new DefaultGlobalIndexBuilder(
- table,
- partition,
- readType,
- indexField,
- extraFields,
- indexType,
- indexedSplit.rowRanges().get(0),
- options);
- byte[] builderBytes =
InstantiationUtil.serializeObject(builder);
- byte[] splitBytes =
InstantiationUtil.serializeObject(indexedSplit);
- taskList.add(Pair.of(builderBytes, splitBytes));
- }
+ for (IndexedSplit indexedSplit : splits) {
+ checkArgument(
+ indexedSplit.rowRanges().size() == 1,
+ "Each IndexedSplit should contain exactly one row range.");
+ DefaultGlobalIndexBuilder builder =
+ new DefaultGlobalIndexBuilder(
+ table,
+ indexedSplit.dataSplit().partition(),
+ readType,
+ indexField,
+ extraFields,
+ indexType,
+ indexedSplit.rowRanges().get(0),
+ options);
+ byte[] builderBytes = InstantiationUtil.serializeObject(builder);
+ byte[] splitBytes =
InstantiationUtil.serializeObject(indexedSplit);
+ taskList.add(Pair.of(builderBytes, splitBytes));
}
if (taskList.isEmpty()) {
@@ -176,21 +185,9 @@ public class DefaultGlobalIndexTopoBuilder implements
GlobalIndexTopologyBuilder
}
}
- private static Map<BinaryRow, List<IndexedSplit>> split(
- FileStoreTable table, List<ManifestEntry> entries, long
rowsPerShard) {
- FileStorePathFactory pathFactory = table.store().pathFactory();
-
- // Group manifest entries by partition
- Map<BinaryRow, List<ManifestEntry>> entriesByPartition =
-
entries.stream().collect(Collectors.groupingBy(ManifestEntry::partition));
-
- return groupFilesIntoShardsByPartition(
- entriesByPartition, rowsPerShard, pathFactory::bucketPath);
- }
-
/**
- * Groups files into shards by partition. This method is extracted from
split() to make it more
- * testable.
+ * Groups files into shards by partition. This method delegates to the
generic global index
+ * build planner and keeps the previous test surface stable.
*
* @param entriesByPartition manifest entries grouped by partition
* @param rowsPerShard number of rows per shard
@@ -201,130 +198,24 @@ public class DefaultGlobalIndexTopoBuilder implements
GlobalIndexTopologyBuilder
Map<BinaryRow, List<ManifestEntry>> entriesByPartition,
long rowsPerShard,
BiFunction<BinaryRow, Integer, Path> pathFactory) {
- Map<BinaryRow, List<IndexedSplit>> result = new HashMap<>();
-
- for (Map.Entry<BinaryRow, List<ManifestEntry>> partitionEntry :
- entriesByPartition.entrySet()) {
- BinaryRow partition = partitionEntry.getKey();
- List<ManifestEntry> partitionEntries = partitionEntry.getValue();
-
- // Group files into shards - a file may belong to multiple shards
- Map<Long, List<DataFileMeta>> filesByShard = new LinkedHashMap<>();
-
- for (ManifestEntry entry : partitionEntries) {
- DataFileMeta file = entry.file();
- Long firstRowId = file.firstRowId();
- if (firstRowId == null) {
- continue; // Skip files without row tracking
- }
-
- // Calculate the row ID range this file covers
- Range fileRange = file.nonNullRowIdRange();
-
- // Calculate which shards this file overlaps with
- long startShardId = fileRange.from / rowsPerShard;
- long endShardId = fileRange.to / rowsPerShard;
-
- // Add this file to all shards it overlaps with
- for (long shardId = startShardId; shardId <= endShardId;
shardId++) {
- long shardStartRowId = shardId * rowsPerShard;
- filesByShard.computeIfAbsent(shardStartRowId, k -> new
ArrayList<>()).add(file);
- }
- }
-
- // Create DataSplit for each shard with exact ranges
- List<IndexedSplit> shardSplits = new ArrayList<>();
- for (Map.Entry<Long, List<DataFileMeta>> shardEntry :
filesByShard.entrySet()) {
- long shardStart = shardEntry.getKey();
- long shardEnd = shardStart + rowsPerShard - 1;
- List<DataFileMeta> shardFiles = shardEntry.getValue();
-
- if (shardFiles.isEmpty()) {
- continue;
- }
-
- // Sort files by firstRowId to ensure sequential order
-
shardFiles.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
-
- // Group contiguous files and create separate DataSplits for
each group
- List<DataFileMeta> currentGroup = new ArrayList<>();
- long currentGroupEnd = -1;
-
- for (DataFileMeta file : shardFiles) {
- long fileStart = file.nonNullFirstRowId();
- long fileEnd = fileStart + file.rowCount() - 1;
-
- if (currentGroup.isEmpty()) {
- // Start a new group
- currentGroup.add(file);
- currentGroupEnd = fileEnd;
- } else if (fileStart <= currentGroupEnd + 1) {
- // File is contiguous with current group (adjacent or
overlapping)
- currentGroup.add(file);
- currentGroupEnd = Math.max(currentGroupEnd, fileEnd);
- } else {
- // Gap detected, finalize current group and start a
new one
- createDataSplitForGroup(
- currentGroup,
- shardStart,
- shardEnd,
- partition,
- pathFactory,
- shardSplits);
- currentGroup = new ArrayList<>();
- currentGroup.add(file);
- currentGroupEnd = fileEnd;
- }
- }
-
- // Don't forget to process the last group
- if (!currentGroup.isEmpty()) {
- createDataSplitForGroup(
- currentGroup,
- shardStart,
- shardEnd,
- partition,
- pathFactory,
- shardSplits);
- }
- }
-
- if (!shardSplits.isEmpty()) {
- result.put(partition, shardSplits);
- }
- }
-
- return result;
+ return groupFilesIntoShardsByPartition(entriesByPartition,
rowsPerShard, pathFactory, null);
}
- private static void createDataSplitForGroup(
- List<DataFileMeta> files,
- long shardStart,
- long shardEnd,
- BinaryRow partition,
+ public static Map<BinaryRow, List<IndexedSplit>>
groupFilesIntoShardsByPartition(
+ Map<BinaryRow, List<ManifestEntry>> entriesByPartition,
+ long rowsPerShard,
BiFunction<BinaryRow, Integer, Path> pathFactory,
- List<IndexedSplit> shardSplits) {
- // Calculate the actual row range covered by the files
- long groupMinRowId = files.get(0).nonNullFirstRowId();
- long groupMaxRowId =
- files.stream().mapToLong(f ->
f.nonNullRowIdRange().to).max().getAsLong();
-
- // Clamp to shard boundaries
- // Range.from >= shardStart, Range.to <= shardEnd
- long rangeFrom = Math.max(groupMinRowId, shardStart);
- long rangeTo = Math.min(groupMaxRowId, shardEnd);
-
- Range range = new Range(rangeFrom, rangeTo);
-
- DataSplit dataSplit =
- DataSplit.builder()
- .withPartition(partition)
- .withBucket(0)
- .withDataFiles(files)
- .withBucketPath(pathFactory.apply(partition,
0).toString())
- .rawConvertible(false)
- .build();
-
- shardSplits.add(new IndexedSplit(dataSplit,
Collections.singletonList(range), null));
+ @Nullable List<Range> rowRangesToBuild) {
+ List<ManifestEntry> entries =
+ entriesByPartition.values().stream()
+ .flatMap(List::stream)
+ .collect(Collectors.toList());
+ return GlobalIndexBuilderUtils.createShardIndexedSplits(
+ entries,
+ rowsPerShard,
+ (partition, bucket) -> pathFactory.apply(partition,
bucket).toString(),
+ rowRangesToBuild)
+ .stream()
+ .collect(Collectors.groupingBy(split ->
split.dataSplit().partition()));
}
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
index 2174418009..9a23d1944a 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/sorted/SortedIndexTopoBuilder.java
@@ -91,7 +91,8 @@ public class SortedIndexTopoBuilder implements
GlobalIndexTopologyBuilder {
indexBuilder =
indexBuilder.withPartitionPredicate(partitionPredicate);
}
- Optional<Pair<RowRangeIndex, List<DataSplit>>> indexRangeAndSplits =
indexBuilder.scan();
+ Optional<Pair<RowRangeIndex, List<DataSplit>>> indexRangeAndSplits =
+ indexBuilder.incrementalScan();
if (!indexRangeAndSplits.isPresent()) {
return Collections.emptyList();
}
diff --git
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.java
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.java
index 991f5d8627..f40c751fe2 100644
---
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.java
+++
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/procedure/CreateGlobalIndexProcedureTest.java
@@ -231,6 +231,34 @@ public class CreateGlobalIndexProcedureTest {
assertThat(shardToSplit.get(shard2).dataFiles()).contains(file3);
}
+ @Test
+ void testGroupFilesIntoShardsByPartitionUsesUnindexedRanges() {
+ BinaryRow partition = createPartition(0);
+ DataFileMeta indexedLaterFile = createDataFileMeta(1000L, 100L);
+ DataFileMeta unindexedEarlierFile = createDataFileMeta(0L, 100L);
+
+ Map<BinaryRow, List<ManifestEntry>> entriesByPartition = new
HashMap<>();
+ entriesByPartition.put(
+ partition,
+ Arrays.asList(
+ createManifestEntry(partition, indexedLaterFile),
+ createManifestEntry(partition, unindexedEarlierFile)));
+
+ Map<BinaryRow, List<IndexedSplit>> result =
+ DefaultGlobalIndexTopoBuilder.groupFilesIntoShardsByPartition(
+ entriesByPartition,
+ 1000L,
+ pathFactory,
+ Collections.singletonList(new Range(0L, 99L)));
+
+ assertThat(result).hasSize(1);
+ List<IndexedSplit> shardSplits = result.get(partition);
+ assertThat(shardSplits).hasSize(1);
+ assertThat(shardSplits.get(0).rowRanges()).containsExactly(new
Range(0L, 99L));
+ assertThat(shardSplits.get(0).dataSplit().dataFiles())
+ .containsExactly(unindexedEarlierFile);
+ }
+
@Test
void testGroupFilesIntoShardsByPartitionMultiplePartitions() {
// Create two partitions