diff --git a/docs/generated/core_configuration.html b/docs/generated/core_configuration.html
index 0776eaf9e369..432edf85c52b 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -782,6 +782,12 @@
String |
Global index root directory, if not set, the global index files will be stored under the <table-root-directory>/index. |
+
+ global-index.ignore-missing-delete |
+ false |
+ Boolean |
+ Whether to ignore deleting a global index file which does not exist in the previous index manifest. |
+
global-index.row-count-per-shard |
100000 |
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index ec6a2296bf9a..f0039d3852c9 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2507,6 +2507,13 @@ public String toString() {
"Defines the action to take when an update modifies columns that are covered by a global index. "
+ "IGNORE leaves existing index files unchanged and may make the index stale.");
+ public static final ConfigOption GLOBAL_INDEX_IGNORE_MISSING_DELETE =
+ key("global-index.ignore-missing-delete")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Whether to ignore deleting a global index file which does not exist in the previous index manifest.");
+
public static final ConfigOption LOOKUP_MERGE_BUFFER_SIZE =
key("lookup.merge-buffer-size")
.memoryType()
@@ -3559,6 +3566,10 @@ public GlobalIndexColumnUpdateAction globalIndexColumnUpdateAction() {
return options.get(GLOBAL_INDEX_COLUMN_UPDATE_ACTION);
}
+ public boolean globalIndexIgnoreMissingDelete() {
+ return options.get(GLOBAL_INDEX_IGNORE_MISSING_DELETE);
+ }
+
public LookupStrategy lookupStrategy() {
return LookupStrategy.from(
mergeEngine().equals(MergeEngine.FIRST_ROW),
diff --git a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
index d81d98ed7046..c8daa1fe2dcf 100644
--- a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
+++ b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
@@ -857,7 +857,8 @@ private RewrittenIndexManifest rewriteIndexManifest(Assignment assignment) {
rewrittenRange.to,
globalIndex.indexFieldId(),
globalIndex.extraFieldIds(),
- globalIndex.indexMeta());
+ globalIndex.indexMeta(),
+ globalIndex.sourceMeta());
IndexFileMeta newIndexFile =
new IndexFileMeta(
indexFile.indexType(),
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
new file mode 100644
index 000000000000..44ed39685d24
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.globalindex;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.TreeMap;
+
+import static org.apache.paimon.utils.DataEvolutionUtils.fileFieldIds;
+
+/** Plans existing global index files which need refresh after data-evolution updates. */
+public final class DataEvolutionGlobalIndexRefreshPlanner {
+
+ private DataEvolutionGlobalIndexRefreshPlanner() {}
+
+ public static List findIndexesToRefresh(
+ SchemaManager schemaManager,
+ List dataEntries,
+ List indexEntries,
+ List indexedFields) {
+ Set indexedFieldIds = new HashSet<>();
+ for (DataField field : indexedFields) {
+ indexedFieldIds.add(field.id());
+ }
+
+ Map, RefreshGroup> groups = new HashMap<>();
+ for (int i = 0; i < indexEntries.size(); i++) {
+ IndexManifestEntry indexEntry = indexEntries.get(i);
+ GlobalIndexMeta indexMeta = indexEntry.indexFile().globalIndexMeta();
+ if (indexEntry.kind() != FileKind.ADD
+ || indexMeta == null
+ || !matchesFields(indexMeta, indexedFields)) {
+ continue;
+ }
+
+ byte[] sourceMeta = indexMeta.sourceMeta();
+ if (!DataEvolutionIndexSourceMeta.isDataEvolutionMeta(sourceMeta)) {
+ // Legacy indexes have no trustworthy scan baseline and require an explicit rebuild.
+ continue;
+ }
+ long scanSnapshotId =
+ DataEvolutionIndexSourceMeta.deserialize(sourceMeta).scanSnapshotId();
+ groups.computeIfAbsent(
+ Pair.of(indexEntry.partition(), indexEntry.bucket()),
+ key -> new RefreshGroup())
+ .addIndex(i, indexMeta.rowRange(), scanSnapshotId);
+ }
+
+ Map>, Set> fileFieldIdsCache = new HashMap<>();
+ for (ManifestEntry dataEntry : dataEntries) {
+ DataFileMeta file = dataEntry.file();
+ if (dataEntry.kind() != FileKind.ADD || file.firstRowId() == null) {
+ continue;
+ }
+
+ RefreshGroup group = groups.get(Pair.of(dataEntry.partition(), dataEntry.bucket()));
+ if (group == null || !group.mayContainUpdate(file)) {
+ continue;
+ }
+
+ Set physicalFieldIds =
+ fileFieldIdsCache.computeIfAbsent(
+ Pair.of(file.schemaId(), file.writeCols()),
+ key -> fileFieldIds(schemaManager::schema, file));
+ if (!disjoint(indexedFieldIds, physicalFieldIds)) {
+ group.addDataFile(file);
+ }
+ }
+
+ boolean[] indexesToRefresh = new boolean[indexEntries.size()];
+ for (RefreshGroup group : groups.values()) {
+ group.markIndexesToRefresh(indexesToRefresh);
+ }
+
+ List result = new ArrayList<>();
+ for (int i = 0; i < indexEntries.size(); i++) {
+ if (indexesToRefresh[i]) {
+ result.add(indexEntries.get(i));
+ }
+ }
+ return result;
+ }
+
+ private static final class RefreshGroup {
+
+ private final List indexes = new ArrayList<>();
+ private final List dataFiles = new ArrayList<>();
+ private final MergedRanges indexedRanges = new MergedRanges();
+ private long minScanSnapshotId = Long.MAX_VALUE;
+
+ private void addIndex(int ordinal, Range rowRange, long scanSnapshotId) {
+ indexes.add(new IndexQuery(ordinal, rowRange, scanSnapshotId));
+ indexedRanges.add(rowRange);
+ minScanSnapshotId = Math.min(minScanSnapshotId, scanSnapshotId);
+ }
+
+ private boolean mayContainUpdate(DataFileMeta file) {
+ return file.maxSequenceNumber() > minScanSnapshotId
+ && indexedRanges.intersects(file.nonNullRowIdRange());
+ }
+
+ private void addDataFile(DataFileMeta file) {
+ dataFiles.add(file);
+ }
+
+ private void markIndexesToRefresh(boolean[] result) {
+ // As scan watermarks decrease, eligible data files only grow.
+ dataFiles.sort(Comparator.comparingLong(DataFileMeta::maxSequenceNumber).reversed());
+ indexes.sort((left, right) -> Long.compare(right.scanSnapshotId, left.scanSnapshotId));
+
+ MergedRanges updatedRanges = new MergedRanges();
+ int nextFile = 0;
+ for (IndexQuery index : indexes) {
+ while (nextFile < dataFiles.size()
+ && dataFiles.get(nextFile).maxSequenceNumber() > index.scanSnapshotId) {
+ updatedRanges.add(dataFiles.get(nextFile).nonNullRowIdRange());
+ nextFile++;
+ }
+ if (updatedRanges.intersects(index.rowRange)) {
+ result[index.ordinal] = true;
+ }
+ }
+ }
+ }
+
+ private static final class IndexQuery {
+
+ private final int ordinal;
+ private final Range rowRange;
+ private final long scanSnapshotId;
+
+ private IndexQuery(int ordinal, Range rowRange, long scanSnapshotId) {
+ this.ordinal = ordinal;
+ this.rowRange = rowRange;
+ this.scanSnapshotId = scanSnapshotId;
+ }
+ }
+
+ /** Dynamically merged inclusive ranges supporting logarithmic intersection checks. */
+ private static final class MergedRanges {
+
+ private final NavigableMap ranges = new TreeMap<>();
+
+ private void add(Range range) {
+ long from = range.from;
+ long to = range.to;
+
+ Map.Entry floor = ranges.floorEntry(from);
+ if (floor != null && floor.getValue() >= from) {
+ from = floor.getKey();
+ to = Math.max(to, floor.getValue());
+ ranges.remove(floor.getKey());
+ }
+
+ Map.Entry next = ranges.ceilingEntry(from);
+ while (next != null && next.getKey() <= to) {
+ to = Math.max(to, next.getValue());
+ ranges.remove(next.getKey());
+ next = ranges.ceilingEntry(from);
+ }
+ ranges.put(from, to);
+ }
+
+ private boolean intersects(Range range) {
+ Map.Entry floor = ranges.floorEntry(range.to);
+ return floor != null && floor.getValue() >= range.from;
+ }
+ }
+
+ private static boolean matchesFields(GlobalIndexMeta meta, List fields) {
+ if (fields.isEmpty() || meta.indexFieldId() != fields.get(0).id()) {
+ return false;
+ }
+ int[] expectedExtraFields =
+ fields.size() == 1
+ ? null
+ : fields.subList(1, fields.size()).stream()
+ .mapToInt(DataField::id)
+ .toArray();
+ int[] actualExtraFields = meta.extraFieldIds();
+ if (actualExtraFields == null || actualExtraFields.length == 0) {
+ return expectedExtraFields == null || expectedExtraFields.length == 0;
+ }
+ return expectedExtraFields != null && Arrays.equals(actualExtraFields, expectedExtraFields);
+ }
+
+ private static boolean disjoint(Set left, Set right) {
+ for (Integer value : left) {
+ if (right.contains(value)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtils.java
index 0537e50114e8..f79d10b660c1 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
@@ -31,7 +31,6 @@
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;
@@ -47,7 +46,6 @@
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;
@@ -70,7 +68,15 @@ public static List toIndexFileMetas(
List entries)
throws IOException {
return toIndexFileMetas(
- fileIO, indexPathFactory, options, range, indexFieldId, null, indexType, entries);
+ fileIO,
+ indexPathFactory,
+ options,
+ range,
+ indexFieldId,
+ null,
+ indexType,
+ entries,
+ null);
}
/**
@@ -86,21 +92,19 @@ public static List toIndexFileMetas(
Range range,
List fields,
String indexType,
- List entries)
+ List entries,
+ @Nullable byte[] sourceMeta)
throws IOException {
- // The first column is the primary index column and is stored as indexFieldId; the
- // remaining columns (if any) go into extraFieldIds.
- int indexFieldId = fields.get(0).id();
- int[] extraFieldIds = extraFieldIds(fields);
return toIndexFileMetas(
fileIO,
indexPathFactory,
options,
range,
- indexFieldId,
- extraFieldIds,
+ fields.get(0).id(),
+ extraFieldIds(fields),
indexType,
- entries);
+ entries,
+ sourceMeta);
}
public static List unindexedRowRanges(
@@ -349,7 +353,8 @@ private static List toIndexFileMetas(
int indexFieldId,
@Nullable int[] extraFieldIds,
String indexType,
- List entries)
+ List entries,
+ @Nullable byte[] sourceMeta)
throws IOException {
List results = new ArrayList<>();
for (ResultEntry entry : entries) {
@@ -357,7 +362,12 @@ private static List toIndexFileMetas(
long fileSize = fileIO.getFileSize(indexPathFactory.toPath(fileName));
GlobalIndexMeta globalIndexMeta =
new GlobalIndexMeta(
- range.from, range.to, indexFieldId, extraFieldIds, entry.meta());
+ range.from,
+ range.to,
+ indexFieldId,
+ extraFieldIds,
+ entry.meta(),
+ sourceMeta);
Path externalPathDir = options.globalIndexExternalPath();
String externalPathString = null;
@@ -397,68 +407,16 @@ public static GlobalIndexWriter createIndexWriter(
return globalIndexer.createWriter(createGlobalIndexFileReadWrite(table));
}
- /**
- * Find the minimum firstRowId among files whose schema does not contain all index columns.
- * Files at or beyond this rowId cannot be indexed because the column was added later via ALTER
- * TABLE.
- *
- * @return the boundary rowId, or {@link Long#MAX_VALUE} if all files contain the columns
- */
- public static long findMinNonIndexableRowId(
- SchemaManager schemaManager, List entries, List indexColumns) {
- Map schemaContainsColumns = new HashMap<>();
- long minRowId = Long.MAX_VALUE;
- long minSchemaId = -1;
- for (ManifestEntry entry : entries) {
- long sid = entry.file().schemaId();
- boolean contains =
- schemaContainsColumns.computeIfAbsent(
- sid,
- id -> schemaManager.schema(id).fieldNames().containsAll(indexColumns));
- if (!contains && entry.file().firstRowId() != null) {
- long rowId = entry.file().nonNullFirstRowId();
- if (rowId < minRowId) {
- minRowId = rowId;
- minSchemaId = sid;
- }
- }
- }
- if (minRowId != Long.MAX_VALUE) {
- List schemaFields = schemaManager.schema(minSchemaId).fieldNames();
- List missingColumns = new ArrayList<>();
- for (String col : indexColumns) {
- if (!schemaFields.contains(col)) {
- missingColumns.add(col);
- }
- }
- LOG.info(
- "Found non-indexable files: schemaId={} missing columns {}, boundaryRowId={}.",
- minSchemaId,
- missingColumns,
- minRowId);
- }
- return minRowId;
- }
-
- /** Keep only entries whose firstRowId is strictly less than the given boundary. */
- public static List filterEntriesBefore(
- List entries, long boundaryRowId) {
- if (boundaryRowId == Long.MAX_VALUE) {
- return entries;
- }
- List result = new ArrayList<>();
- for (ManifestEntry entry : entries) {
- if (entry.file().firstRowId() != null
- && entry.file().nonNullFirstRowId() < boundaryRowId) {
- result.add(entry);
- }
- }
- LOG.info(
- "Filtered {} files to {} indexable files (boundaryRowId={}).",
- entries.size(),
- result.size(),
- boundaryRowId);
- return result;
+ /** Whether a global index should be refreshed automatically for data-evolution updates. */
+ public static boolean shouldRefreshDataEvolutionIndex(
+ FileStoreTable table,
+ String indexType,
+ DataField indexField,
+ List extraFields,
+ Options options) {
+ return table.coreOptions().dataEvolutionEnabled()
+ && GlobalIndexer.create(indexType, indexField, extraFields, options)
+ instanceof VectorGlobalIndexer;
}
private static GlobalIndexFileReadWrite createGlobalIndexFileReadWrite(FileStoreTable table) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java b/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java
new file mode 100644
index 000000000000..fb39bc2f8ea1
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/index/DataEvolutionIndexSourceMeta.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.index;
+
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Data snapshot scanned to build a global index for a data-evolution table. */
+public final class DataEvolutionIndexSourceMeta {
+
+ // "DEIX". The marker distinguishes this metadata from primary-key index source metadata.
+ private static final int MAGIC = 0x44454958;
+ private static final int VERSION = 1;
+
+ private final long scanSnapshotId;
+
+ public DataEvolutionIndexSourceMeta(long scanSnapshotId) {
+ checkArgument(scanSnapshotId > 0, "Scan snapshot id must be positive.");
+ this.scanSnapshotId = scanSnapshotId;
+ }
+
+ public long scanSnapshotId() {
+ return scanSnapshotId;
+ }
+
+ public byte[] serialize() {
+ try {
+ DataOutputSerializer output = new DataOutputSerializer(16);
+ output.writeInt(MAGIC);
+ output.writeInt(VERSION);
+ output.writeLong(scanSnapshotId);
+ return output.getCopyOfBuffer();
+ } catch (IOException e) {
+ throw new RuntimeException(
+ "Failed to serialize data-evolution index source metadata.", e);
+ }
+ }
+
+ public static boolean isDataEvolutionMeta(@Nullable byte[] bytes) {
+ if (bytes == null || bytes.length < Integer.BYTES) {
+ return false;
+ }
+ try {
+ return new DataInputDeserializer(bytes).readInt() == MAGIC;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ public static DataEvolutionIndexSourceMeta deserialize(byte[] bytes) {
+ try {
+ DataInputDeserializer input = new DataInputDeserializer(bytes);
+ int magic = input.readInt();
+ checkArgument(magic == MAGIC, "Not data-evolution index source metadata.");
+ int version = input.readInt();
+ checkArgument(
+ version == VERSION,
+ "Unsupported data-evolution index source version: %s.",
+ version);
+ long scanSnapshotId = input.readLong();
+ checkArgument(
+ input.available() == 0,
+ "Unexpected trailing bytes in data-evolution index source metadata.");
+ return new DataEvolutionIndexSourceMeta(scanSnapshotId);
+ } catch (IOException e) {
+ throw new IllegalArgumentException(
+ "Failed to deserialize data-evolution index source metadata.", e);
+ }
+ }
+
+ public static DataEvolutionIndexSourceMeta fromIndexFile(IndexFileMeta indexFile) {
+ GlobalIndexMeta globalIndexMeta = indexFile.globalIndexMeta();
+ checkArgument(
+ globalIndexMeta != null && isDataEvolutionMeta(globalIndexMeta.sourceMeta()),
+ "Index file %s has no data-evolution source metadata.",
+ indexFile.fileName());
+ return deserialize(globalIndexMeta.sourceMeta());
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
index a46762c1af77..484ae957d9b1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFile.java
@@ -62,16 +62,23 @@ public Path indexManifestFilePath(String fileName) {
return pathFactory.toPath(fileName);
}
- /** Write new index files to index manifest. */
+ /**
+ * Write new index files to index manifest.
+ *
+ * @param ignoreMissingGlobalIndexDelete whether to ignore deleting a global index file which
+ * does not exist in the previous index manifest
+ */
@Nullable
public String writeIndexFiles(
@Nullable String previousIndexManifest,
List newIndexFiles,
- BucketMode bucketMode) {
+ BucketMode bucketMode,
+ boolean ignoreMissingGlobalIndexDelete) {
if (newIndexFiles.isEmpty()) {
return previousIndexManifest;
}
- IndexManifestFileHandler handler = new IndexManifestFileHandler(this, bucketMode);
+ IndexManifestFileHandler handler =
+ new IndexManifestFileHandler(this, bucketMode, ignoreMissingGlobalIndexDelete);
return handler.write(previousIndexManifest, newIndexFiles);
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
index 486f39fef4da..45a8705f33d4 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/IndexManifestFileHandler.java
@@ -19,6 +19,7 @@
package org.apache.paimon.manifest;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.DeletionVectorMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
@@ -51,9 +52,19 @@ public class IndexManifestFileHandler {
private final BucketMode bucketMode;
+ private final boolean ignoreMissingGlobalIndexDelete;
+
IndexManifestFileHandler(IndexManifestFile indexManifestFile, BucketMode bucketMode) {
+ this(indexManifestFile, bucketMode, false);
+ }
+
+ IndexManifestFileHandler(
+ IndexManifestFile indexManifestFile,
+ BucketMode bucketMode,
+ boolean ignoreMissingGlobalIndexDelete) {
this.indexManifestFile = indexManifestFile;
this.bucketMode = bucketMode;
+ this.ignoreMissingGlobalIndexDelete = ignoreMissingGlobalIndexDelete;
}
String write(@Nullable String previousIndexManifest, List newIndexFiles) {
@@ -96,7 +107,7 @@ private Map> separateIndexEntries(
private IndexManifestFileCombiner getIndexManifestFileCombine(String indexType) {
if (!DELETION_VECTORS_INDEX.equals(indexType) && !HASH_INDEX.equals(indexType)) {
- return new GlobalIndexCombiner();
+ return new GlobalIndexCombiner(ignoreMissingGlobalIndexDelete);
}
if (DELETION_VECTORS_INDEX.equals(indexType) && BucketMode.BUCKET_UNAWARE == bucketMode) {
@@ -202,6 +213,12 @@ public List combine(
/** We combine the previous and new index files by file name. */
static class GlobalIndexCombiner implements IndexManifestFileCombiner {
+ private final boolean ignoreMissingDelete;
+
+ GlobalIndexCombiner(boolean ignoreMissingDelete) {
+ this.ignoreMissingDelete = ignoreMissingDelete;
+ }
+
@Override
public List combine(
List prevIndexFiles, List newIndexFiles) {
@@ -220,7 +237,12 @@ public List combine(
.filter(f -> f.kind() == FileKind.ADD)
.collect(Collectors.toList());
for (IndexManifestEntry entry : removed) {
- indexEntries.remove(entry.indexFile().fileName());
+ String fileName = entry.indexFile().fileName();
+ checkState(
+ ignoreMissingDelete || indexEntries.containsKey(fileName),
+ "Trying to delete global index file %s which does not exist.",
+ fileName);
+ indexEntries.remove(fileName);
}
validateRetainedIndexFiles(indexEntries.values(), added);
for (IndexManifestEntry entry : added) {
@@ -241,7 +263,12 @@ private void validateRetainedIndexFiles(
for (IndexManifestEntry added : addedIndexFiles) {
GlobalIndexMeta addedMeta = added.indexFile().globalIndexMeta();
if (addedMeta == null
- || (retainedMeta.sourceMeta() != null && addedMeta.sourceMeta() != null)
+ || (retainedMeta.sourceMeta() != null
+ && addedMeta.sourceMeta() != null
+ && !DataEvolutionIndexSourceMeta.isDataEvolutionMeta(
+ retainedMeta.sourceMeta())
+ && !DataEvolutionIndexSourceMeta.isDataEvolutionMeta(
+ addedMeta.sourceMeta()))
|| retainedMeta.indexFieldId() != addedMeta.indexFieldId()
|| (Arrays.equals(
retainedMeta.extraFieldIds(), addedMeta.extraFieldIds())
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
index e097b3be5318..eef706c36843 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
@@ -62,6 +62,7 @@
import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
import static org.apache.paimon.manifest.ManifestFileMeta.allContainsRowId;
import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.DataEvolutionUtils.fileFieldIds;
import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
/** {@link FileStoreScan} for data-evolution enabled table. */
@@ -251,24 +252,7 @@ private List pruneByReadType(List group) {
private Set fileFieldIdsForEntry(ManifestEntry entry) {
return fileFieldIdsCache.computeIfAbsent(
Pair.of(entry.file().schemaId(), entry.file().writeCols()),
- pair -> computeFileFieldIds(this::scanTableSchema, entry.file()));
- }
-
- /**
- * Field ids of the columns physically present in {@code file}, resolved through the file's own
- * schema (i.e. the schema the file was written under). Field id, not field name, is the stable
- * identity across schemas — necessary so a renamed column matches an old file written under the
- * pre-rename name.
- */
- @VisibleForTesting
- static Set computeFileFieldIds(
- Function scanTableSchema, DataFileMeta file) {
- Set ids = new HashSet<>();
- for (DataField f :
- scanTableSchema.apply(file.schemaId()).project(file.writeCols()).fields()) {
- ids.add(f.id());
- }
- return ids;
+ pair -> fileFieldIds(this::scanTableSchema, entry.file()));
}
/** TODO: Optimize implementation of this method. */
@@ -283,9 +267,7 @@ static EvolutionStats evolutionStats(
entry ->
isBlobFile(entry.file().fileName())
|| isVectorStoreFile(entry.file().fileName()))
- .flatMap(
- entry ->
- computeFileFieldIds(scanTableSchema, entry.file()).stream())
+ .flatMap(entry -> fileFieldIds(scanTableSchema, entry.file()).stream())
.collect(Collectors.toSet());
// exclude blob and vector-store files, useless for predicate eval
metas =
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index b44032021254..eada47b1b41c 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -1105,7 +1105,11 @@ CommitResult tryCommitOnce(
}
indexManifest =
- indexManifestFile.writeIndexFiles(oldIndexManifest, indexFiles, bucketMode);
+ indexManifestFile.writeIndexFiles(
+ oldIndexManifest,
+ indexFiles,
+ bucketMode,
+ options.globalIndexIgnoreMissingDelete());
long latestSchemaId =
schemaManager
diff --git a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
index 57ba93ae199d..c1294f39462f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
@@ -19,10 +19,14 @@
package org.apache.paimon.utils;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.types.DataField;
import java.util.Collection;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -34,6 +38,24 @@
/** Util class for data evolution. */
public class DataEvolutionUtils {
+ /**
+ * Table field ids physically present in a file, resolved through the schema used to write it.
+ */
+ public static Set fileFieldIds(
+ Function scanTableSchema, DataFileMeta file) {
+ TableSchema schema = scanTableSchema.apply(file.schemaId());
+ List writeCols = file.writeCols();
+ Set writeColNames = writeCols == null ? null : new HashSet<>(writeCols);
+ Set ids = new HashSet<>();
+ for (DataField field : schema.fields()) {
+ // writeCols may also contain physical row-tracking fields outside the table schema.
+ if (writeColNames == null || writeColNames.contains(field.name())) {
+ ids.add(field.id());
+ }
+ }
+ return ids;
+ }
+
/**
* Retrieve the anchor file of a row range group. Always the oldest normal file. Files are
* compared by (max_seq, fileName) pairs.
diff --git a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
index b3e91a3a4b53..c0629b19aa59 100644
--- a/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/CoreOptionsTest.java
@@ -107,6 +107,15 @@ public void testIgnoreGlobalIndexColumnUpdateAction() {
.isEqualTo(CoreOptions.GlobalIndexColumnUpdateAction.IGNORE);
}
+ @Test
+ public void testGlobalIndexIgnoreMissingDelete() {
+ Options conf = new Options();
+ assertThat(new CoreOptions(conf).globalIndexIgnoreMissingDelete()).isFalse();
+
+ conf.set(CoreOptions.GLOBAL_INDEX_IGNORE_MISSING_DELETE, true);
+ assertThat(new CoreOptions(conf).globalIndexIgnoreMissingDelete()).isTrue();
+ }
+
@Test
public void testBlobSplitByFileSizeDefault() {
Options conf = new Options();
diff --git a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index 77cb9802f326..daa5942c5c43 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -29,6 +29,7 @@
import org.apache.paimon.fs.Path;
import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
import org.apache.paimon.globalindex.sorted.SortedGlobalIndexBuilder;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
@@ -887,6 +888,27 @@ public void testReassignGlobalIndexRowRanges() throws Exception {
assertThat(readPayloads(table, predicate)).containsExactly("v4");
}
+ @Test
+ public void testReassignPreservesGlobalIndexSourceMeta() throws Exception {
+ FileStoreTable table = createTableWithInterleavedPartitions();
+ createBTreeIndex(table);
+ long scanSnapshotId = table.snapshotManager().latestSnapshot().id();
+ setGlobalIndexSourceMeta(table, scanSnapshotId);
+
+ new DataEvolutionRowIdReassigner(table).reassign("test-preserve-index-source-meta");
+
+ List entries = table.store().newIndexFileHandler().scanEntries();
+ assertThat(entries).isNotEmpty();
+ assertThat(entries)
+ .allSatisfy(
+ entry ->
+ assertThat(
+ DataEvolutionIndexSourceMeta.fromIndexFile(
+ entry.indexFile())
+ .scanSnapshotId())
+ .isEqualTo(scanSnapshotId));
+ }
+
@Test
public void testReassignKeepsConcurrentDisjointGlobalIndexRange() throws Exception {
FileStoreTable table = createTableWithInterleavedPartitions();
@@ -1932,6 +1954,40 @@ private void createBTreeIndex(FileStoreTable table) throws Exception {
}
}
+ private void setGlobalIndexSourceMeta(FileStoreTable table, long scanSnapshotId)
+ throws Exception {
+ Snapshot latest = table.snapshotManager().latestSnapshot();
+ IndexManifestFile indexManifestFile = table.store().indexManifestFileFactory().create();
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ List rewritten = new ArrayList<>();
+ for (IndexManifestEntry entry : indexManifestFile.read(latest.indexManifest())) {
+ IndexFileMeta indexFile = entry.indexFile();
+ GlobalIndexMeta globalIndex = indexFile.globalIndexMeta();
+ assertThat(globalIndex).isNotNull();
+ rewritten.add(
+ new IndexManifestEntry(
+ entry.kind(),
+ entry.partition(),
+ entry.bucket(),
+ new IndexFileMeta(
+ indexFile.indexType(),
+ indexFile.fileName(),
+ indexFile.fileSize(),
+ indexFile.rowCount(),
+ indexFile.dvRanges(),
+ indexFile.externalPath(),
+ new GlobalIndexMeta(
+ globalIndex.rowRangeStart(),
+ globalIndex.rowRangeEnd(),
+ globalIndex.indexFieldId(),
+ globalIndex.extraFieldIds(),
+ globalIndex.indexMeta(),
+ sourceMeta))));
+ }
+ replaceLatestSnapshotIndexManifest(
+ table, latest, indexManifestFile.writeWithoutRolling(rewritten));
+ }
+
private void replaceGlobalIndexRangesWithPartitionSpanningRanges(FileStoreTable table)
throws Exception {
Snapshot latest = table.snapshotManager().latestSnapshot();
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
new file mode 100644
index 000000000000..1eb4632453a4
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
@@ -0,0 +1,454 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.globalindex;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.IntType;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Random;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link DataEvolutionGlobalIndexRefreshPlanner}. */
+class DataEvolutionGlobalIndexRefreshPlannerTest {
+
+ private static final DataField VECTOR_FIELD =
+ new DataField(1, "vector", new ArrayType(new FloatType()));
+ private static final DataField OTHER_FIELD = new DataField(2, "other", new IntType());
+ private static final DataField UNRELATED_FIELD = new DataField(3, "unrelated", new IntType());
+
+ private SchemaManager schemaManager;
+
+ @BeforeEach
+ void beforeEach() {
+ schemaManager = mock(SchemaManager.class);
+ when(schemaManager.schema(0L))
+ .thenReturn(
+ new TableSchema(
+ 0L,
+ Collections.singletonList(OTHER_FIELD),
+ 1,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ when(schemaManager.schema(1L))
+ .thenReturn(
+ new TableSchema(
+ 1L,
+ Arrays.asList(VECTOR_FIELD, OTHER_FIELD, UNRELATED_FIELD),
+ 3,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ when(schemaManager.schema(2L))
+ .thenReturn(
+ new TableSchema(
+ 2L,
+ Arrays.asList(
+ new DataField(
+ 1,
+ "renamed_vector",
+ new ArrayType(new FloatType())),
+ OTHER_FIELD,
+ UNRELATED_FIELD),
+ 3,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ ""));
+ }
+
+ @Test
+ void testRefreshesOnlyForNewPhysicalIndexColumnFile() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("vector-update", 0, 100, 6, 1, "vector")),
+ index))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("old-vector", 0, 100, 5, 1, "vector")),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("other-update", 0, 100, 6, 1, "other")),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("outside", 100, 100, 6, 1, "vector")),
+ index))
+ .isEmpty();
+ }
+
+ @Test
+ void testLegacyIndexIsNotRefreshedAutomatically() {
+ IndexManifestEntry legacy = index("legacy", 0, 99, null, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(plan(Collections.singletonList(data("base", 0, 100, 0, 1, "vector")), legacy))
+ .isEmpty();
+ }
+
+ @Test
+ void testUsesStableFieldIdAcrossRenameAndFullWrites() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("renamed", 0, 100, 6, 2, "renamed_vector")),
+ index))
+ .containsExactly(index);
+ assertThat(plan(Collections.singletonList(data("full", 0, 100, 6, 1)), index))
+ .containsExactly(index);
+ }
+
+ @Test
+ void testRefreshesFromUpdateLayerOverBaseSchemaWithoutIndexColumn() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+ ManifestEntry baseWithoutVector = data("base", 0, 100, 1, 0);
+ ManifestEntry vectorUpdate = data("vector-update", 0, 100, 6, 1, "vector");
+
+ assertThat(plan(Arrays.asList(baseWithoutVector, vectorUpdate), index))
+ .containsExactly(index);
+ }
+
+ @Test
+ void testHandlesSystemAndEmptyPhysicalColumns() {
+ IndexManifestEntry index = index("index", 0, 99, 5L, BinaryRow.EMPTY_ROW, 0);
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data(
+ "vector-with-system-fields",
+ 0,
+ 100,
+ 6,
+ 1,
+ SpecialFields.ROW_ID.name(),
+ "vector",
+ SpecialFields.SEQUENCE_NUMBER.name())),
+ index))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data(
+ "system-only",
+ 0,
+ 100,
+ 6,
+ 1,
+ SpecialFields.ROW_ID.name(),
+ SpecialFields.SEQUENCE_NUMBER.name())),
+ index))
+ .isEmpty();
+ assertThat(
+ plan(
+ Collections.singletonList(
+ dataWithWriteCols(
+ "empty", 0, 100, 6, 1, Collections.emptyList())),
+ index))
+ .isEmpty();
+ }
+
+ @Test
+ void testMultiColumnIndexRefreshesForEitherIndexedField() {
+ List indexedFields = Arrays.asList(VECTOR_FIELD, OTHER_FIELD);
+ IndexManifestEntry index =
+ index(
+ "multi-column",
+ 0,
+ 99,
+ 5L,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ new int[] {OTHER_FIELD.id()});
+
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("vector-update", 0, 100, 6, 1, "vector")),
+ Collections.singletonList(index),
+ indexedFields))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("extra-field-update", 0, 100, 6, 1, "other")),
+ Collections.singletonList(index),
+ indexedFields))
+ .containsExactly(index);
+ assertThat(
+ plan(
+ Collections.singletonList(
+ data("unrelated-update", 0, 100, 6, 1, "unrelated")),
+ Collections.singletonList(index),
+ indexedFields))
+ .isEmpty();
+ }
+
+ @Test
+ void testIsolatesPartitionAndBucket() {
+ BinaryRow partition = BinaryRow.singleColumn(1);
+ IndexManifestEntry index = index("index", 0, 99, 5L, partition, 2);
+
+ ManifestEntry wrongPartition =
+ data("wrong-partition", 0, 100, 6, 1, BinaryRow.singleColumn(2), 2, "vector");
+ ManifestEntry wrongBucket = data("wrong-bucket", 0, 100, 6, 1, partition, 1, "vector");
+ assertThat(plan(Arrays.asList(wrongPartition, wrongBucket), index)).isEmpty();
+
+ ManifestEntry matching = data("matching", 0, 100, 6, 1, partition, 2, "vector");
+ assertThat(plan(Collections.singletonList(matching), index)).containsExactly(index);
+ }
+
+ @Test
+ void testSequenceSweepHandlesOverlappingRangesAndPreservesOrder() {
+ IndexManifestEntry equalSequence = index("equal", 20, 29, 5L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry boundary = index("boundary", 10, 19, 6L, BinaryRow.EMPTY_ROW, 0);
+ IndexManifestEntry first = index("first", 0, 9, 8L, BinaryRow.EMPTY_ROW, 0);
+
+ List dataEntries =
+ Arrays.asList(
+ data("wide-equal", 0, 30, 5, 1, "vector"),
+ data("high-outside", 30, 10, 100, 1, "vector"),
+ data("boundary-update", 9, 2, 7, 1, "vector"),
+ data("first-update", 0, 10, 9, 1, "vector"));
+
+ assertThat(plan(dataEntries, Arrays.asList(equalSequence, boundary, first)))
+ .containsExactly(boundary, first);
+ }
+
+ @Test
+ void testSequenceSweepMatchesBruteForceForOverlappingRanges() {
+ Random random = new Random(123456L);
+ for (int round = 0; round < 10; round++) {
+ List dataEntries = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ dataEntries.add(
+ data(
+ "data-" + round + "-" + i,
+ random.nextInt(1000),
+ random.nextInt(100) + 1,
+ random.nextInt(25),
+ 1,
+ "vector"));
+ }
+
+ List indexEntries = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ long from = random.nextInt(1000);
+ indexEntries.add(
+ index(
+ "index-" + round + "-" + i,
+ from,
+ from + random.nextInt(100),
+ (long) random.nextInt(24) + 1,
+ BinaryRow.EMPTY_ROW,
+ 0));
+ }
+ Collections.shuffle(dataEntries, random);
+ Collections.shuffle(indexEntries, random);
+
+ assertThat(plan(dataEntries, indexEntries))
+ .containsExactlyElementsOf(bruteForcePlan(dataEntries, indexEntries));
+ }
+ }
+
+ private List plan(
+ List dataEntries, IndexManifestEntry indexEntry) {
+ return plan(dataEntries, Collections.singletonList(indexEntry));
+ }
+
+ private List plan(
+ List dataEntries, List indexEntries) {
+ return plan(dataEntries, indexEntries, Collections.singletonList(VECTOR_FIELD));
+ }
+
+ private List plan(
+ List dataEntries,
+ List indexEntries,
+ List indexedFields) {
+ return DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ schemaManager, dataEntries, indexEntries, indexedFields);
+ }
+
+ private List bruteForcePlan(
+ List dataEntries, List indexEntries) {
+ List result = new ArrayList<>();
+ for (IndexManifestEntry indexEntry : indexEntries) {
+ GlobalIndexMeta indexMeta = indexEntry.indexFile().globalIndexMeta();
+ long scanSnapshotId =
+ DataEvolutionIndexSourceMeta.deserialize(indexMeta.sourceMeta())
+ .scanSnapshotId();
+ for (ManifestEntry dataEntry : dataEntries) {
+ DataFileMeta file = dataEntry.file();
+ if (file.maxSequenceNumber() > scanSnapshotId
+ && file.nonNullRowIdRange().hasIntersection(indexMeta.rowRange())) {
+ result.add(indexEntry);
+ break;
+ }
+ }
+ }
+ return result;
+ }
+
+ private IndexManifestEntry index(
+ String fileName,
+ long from,
+ long to,
+ Long scanSnapshotId,
+ BinaryRow partition,
+ int bucket) {
+ return index(fileName, from, to, scanSnapshotId, partition, bucket, null);
+ }
+
+ private IndexManifestEntry index(
+ String fileName,
+ long from,
+ long to,
+ Long scanSnapshotId,
+ BinaryRow partition,
+ int bucket,
+ int[] extraFieldIds) {
+ byte[] sourceMeta =
+ scanSnapshotId == null
+ ? null
+ : new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize();
+ return new IndexManifestEntry(
+ FileKind.ADD,
+ partition,
+ bucket,
+ new IndexFileMeta(
+ "lumina",
+ fileName,
+ 1L,
+ to - from + 1,
+ new GlobalIndexMeta(
+ from, to, VECTOR_FIELD.id(), extraFieldIds, null, sourceMeta),
+ null));
+ }
+
+ private ManifestEntry data(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ String... writeCols) {
+ return data(
+ fileName,
+ firstRowId,
+ rowCount,
+ maxSequenceNumber,
+ schemaId,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ writeCols);
+ }
+
+ private ManifestEntry data(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ BinaryRow partition,
+ int bucket,
+ String... writeCols) {
+ List physicalColumns = writeCols.length == 0 ? null : Arrays.asList(writeCols);
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ fileName,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ maxSequenceNumber,
+ maxSequenceNumber,
+ schemaId,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ physicalColumns);
+ return ManifestEntry.create(FileKind.ADD, partition, bucket, 1, file);
+ }
+
+ private ManifestEntry dataWithWriteCols(
+ String fileName,
+ long firstRowId,
+ long rowCount,
+ long maxSequenceNumber,
+ long schemaId,
+ List writeCols) {
+ DataFileMeta file =
+ DataFileMeta.forAppend(
+ fileName,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ maxSequenceNumber,
+ maxSequenceNumber,
+ schemaId,
+ Collections.emptyList(),
+ null,
+ null,
+ null,
+ null,
+ firstRowId,
+ writeCols);
+ return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1, file);
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
index 11c8ddb64c3f..c05a1ee501db 100644
--- a/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/globalindex/GlobalIndexBuilderUtilsTest.java
@@ -23,6 +23,7 @@
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexPathFactory;
import org.apache.paimon.io.PojoDataFileMeta;
@@ -94,7 +95,14 @@ void testToIndexFileMetasMultiColumn() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
@@ -115,13 +123,39 @@ void testToIndexFileMetasSingleColumn() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
assertThat(metas.get(0).globalIndexMeta().extraFieldIds()).isNull();
}
+ @Test
+ void testToIndexFileMetasWithSourceMeta() throws IOException {
+ DataField field = new DataField(1, "vec", new ArrayType(new FloatType()));
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(7L).serialize();
+
+ List metas =
+ GlobalIndexBuilderUtils.toIndexFileMetas(
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ new Range(0, 9),
+ Collections.singletonList(field),
+ "lumina",
+ createDummyResultEntries(),
+ sourceMeta);
+
+ assertThat(metas.get(0).globalIndexMeta().sourceMeta()).containsExactly(sourceMeta);
+ }
+
// Test: 3 columns (title + vec + id), primary column title is indexFieldId, rest in
// extraFieldIds
@Test
@@ -136,7 +170,14 @@ void testToIndexFileMetasThreeColumns() throws IOException {
List metas =
GlobalIndexBuilderUtils.toIndexFileMetas(
- fileIO, indexPathFactory, coreOptions, range, fields, "test-type", entries);
+ fileIO,
+ indexPathFactory,
+ coreOptions,
+ range,
+ fields,
+ "test-type",
+ entries,
+ null);
assertThat(metas).hasSize(1);
assertThat(metas.get(0).globalIndexMeta().indexFieldId()).isEqualTo(1);
diff --git a/paimon-core/src/test/java/org/apache/paimon/index/DataEvolutionIndexSourceMetaTest.java b/paimon-core/src/test/java/org/apache/paimon/index/DataEvolutionIndexSourceMetaTest.java
new file mode 100644
index 000000000000..c281ebdd2fdd
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/index/DataEvolutionIndexSourceMetaTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.index;
+
+import org.apache.paimon.io.DataOutputSerializer;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link DataEvolutionIndexSourceMeta}. */
+class DataEvolutionIndexSourceMetaTest {
+
+ @Test
+ void testRoundTripAndDetection() {
+ DataEvolutionIndexSourceMeta sourceMeta = new DataEvolutionIndexSourceMeta(42L);
+
+ byte[] serialized = sourceMeta.serialize();
+
+ assertThat(DataEvolutionIndexSourceMeta.isDataEvolutionMeta(serialized)).isTrue();
+ assertThat(DataEvolutionIndexSourceMeta.deserialize(serialized).scanSnapshotId())
+ .isEqualTo(42L);
+ assertThat(DataEvolutionIndexSourceMeta.isDataEvolutionMeta(null)).isFalse();
+ assertThat(DataEvolutionIndexSourceMeta.isDataEvolutionMeta(new byte[] {1})).isFalse();
+ }
+
+ @Test
+ void testRejectsInvalidSnapshotId() {
+ assertThatThrownBy(() -> new DataEvolutionIndexSourceMeta(0L))
+ .hasMessageContaining("snapshot id must be positive");
+ }
+
+ @Test
+ void testRejectsWrongMagicAndVersion() throws Exception {
+ DataOutputSerializer wrongMagic = new DataOutputSerializer(16);
+ wrongMagic.writeInt(1);
+ wrongMagic.writeInt(1);
+ wrongMagic.writeLong(1L);
+ assertThatThrownBy(
+ () ->
+ DataEvolutionIndexSourceMeta.deserialize(
+ wrongMagic.getCopyOfBuffer()))
+ .hasMessageContaining("Not data-evolution index source metadata");
+
+ byte[] wrongVersion = new DataEvolutionIndexSourceMeta(1L).serialize();
+ wrongVersion[7] = 2;
+ assertThatThrownBy(() -> DataEvolutionIndexSourceMeta.deserialize(wrongVersion))
+ .hasMessageContaining("Unsupported data-evolution index source version");
+ }
+
+ @Test
+ void testRejectsTruncatedAndTrailingBytes() {
+ byte[] serialized = new DataEvolutionIndexSourceMeta(1L).serialize();
+
+ assertThatThrownBy(
+ () ->
+ DataEvolutionIndexSourceMeta.deserialize(
+ Arrays.copyOf(serialized, serialized.length - 1)))
+ .hasMessageContaining("Failed to deserialize data-evolution index source metadata");
+
+ byte[] trailing = Arrays.copyOf(serialized, serialized.length + 1);
+ assertThatThrownBy(() -> DataEvolutionIndexSourceMeta.deserialize(trailing))
+ .hasMessageContaining("Unexpected trailing bytes");
+ }
+
+ @Test
+ void testReadsFromIndexFile() {
+ DataEvolutionIndexSourceMeta sourceMeta = new DataEvolutionIndexSourceMeta(9L);
+ IndexFileMeta indexFile =
+ new IndexFileMeta(
+ "lumina",
+ "index-1",
+ 1L,
+ 10L,
+ new GlobalIndexMeta(0, 9, 1, null, null, sourceMeta.serialize()),
+ null);
+
+ assertThat(DataEvolutionIndexSourceMeta.fromIndexFile(indexFile).scanSnapshotId())
+ .isEqualTo(9L);
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
index cfcc896525f3..0ab0a3980806 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/IndexManifestFileHandlerTest.java
@@ -21,6 +21,7 @@
import org.apache.paimon.TestAppendFileStore;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.table.BucketMode;
@@ -162,6 +163,72 @@ public void testGlobalIndexOverlappingRangeAllowedAfterDelete() throws Exception
assertThat(entries).containsExactly(added);
}
+ @Test
+ public void testMissingGlobalIndexDeleteRejectedByDefault() throws Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new HashMap<>());
+ IndexManifestFile indexManifestFile = createIndexManifestFile(fileStore);
+
+ IndexManifestEntry previous = globalIndexEntry("prev-index", 0, 99, 1);
+ String manifest =
+ indexManifestFile.writeIndexFiles(
+ null, Arrays.asList(previous), BucketMode.BUCKET_UNAWARE, false);
+ IndexManifestEntry missing = globalIndexEntry("missing-index", 100, 199, 1);
+
+ assertThatThrownBy(
+ () ->
+ indexManifestFile.writeIndexFiles(
+ manifest,
+ Arrays.asList(missing.toDeleteEntry()),
+ BucketMode.BUCKET_UNAWARE,
+ false))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining(
+ "Trying to delete global index file missing-index which does not exist.");
+ }
+
+ @Test
+ public void testMissingGlobalIndexDeleteCanBeIgnoredExplicitly() throws Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new HashMap<>());
+ IndexManifestFile indexManifestFile = createIndexManifestFile(fileStore);
+
+ IndexManifestEntry previous = globalIndexEntry("prev-index", 0, 99, 1);
+ String manifest =
+ indexManifestFile.writeIndexFiles(
+ null, Arrays.asList(previous), BucketMode.BUCKET_UNAWARE, false);
+ IndexManifestEntry missing = globalIndexEntry("missing-index", 100, 199, 1);
+
+ String ignored =
+ indexManifestFile.writeIndexFiles(
+ manifest,
+ Arrays.asList(missing.toDeleteEntry()),
+ BucketMode.BUCKET_UNAWARE,
+ true);
+
+ assertThat(indexManifestFile.read(ignored)).containsExactly(previous);
+ }
+
+ @Test
+ public void testDataEvolutionSourceMetaDoesNotDisableRangeValidation() throws Exception {
+ TestAppendFileStore fileStore =
+ TestAppendFileStore.createAppendStore(tempDir, new HashMap<>());
+ IndexManifestFile indexManifestFile = createIndexManifestFile(fileStore);
+ IndexManifestFileHandler handler =
+ new IndexManifestFileHandler(indexManifestFile, BucketMode.BUCKET_UNAWARE);
+
+ IndexManifestEntry previous = dataEvolutionIndexEntry("old-index", 0, 99, 1, 1);
+ String manifest = handler.write(null, Arrays.asList(previous));
+ IndexManifestEntry replacement = dataEvolutionIndexEntry("new-index", 0, 99, 1, 2);
+
+ assertThatThrownBy(() -> handler.write(manifest, Arrays.asList(replacement)))
+ .hasMessageContaining("overlapping row range");
+
+ String replaced =
+ handler.write(manifest, Arrays.asList(previous.toDeleteEntry(), replacement));
+ assertThat(indexManifestFile.read(replaced)).containsExactly(replacement);
+ }
+
@Test
public void testGlobalIndexOverlappingRangeAllowedForDifferentFieldId() throws Exception {
TestAppendFileStore fileStore =
@@ -282,4 +349,29 @@ private IndexManifestEntry pkVectorEntry(String indexType, String fileName) {
new GlobalIndexMeta(0, 1, 1, null, null, new byte[] {1}),
null));
}
+
+ private IndexManifestEntry dataEvolutionIndexEntry(
+ String fileName,
+ long rowRangeStart,
+ long rowRangeEnd,
+ int indexFieldId,
+ long scanSnapshotId) {
+ return new IndexManifestEntry(
+ FileKind.ADD,
+ BinaryRow.EMPTY_ROW,
+ 0,
+ new IndexFileMeta(
+ "lumina",
+ fileName,
+ 1L,
+ rowRangeEnd - rowRangeStart + 1,
+ new GlobalIndexMeta(
+ rowRangeStart,
+ rowRangeEnd,
+ indexFieldId,
+ null,
+ null,
+ new DataEvolutionIndexSourceMeta(scanSnapshotId).serialize()),
+ null));
+ }
}
diff --git a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
index 97f4a5b80426..868eee8f8245 100644
--- a/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/operation/FileStoreCommitTest.java
@@ -1360,6 +1360,39 @@ public void testGlobalIndexCommitFailsForMissingRowIds() throws Exception {
}
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ public void testGlobalIndexIgnoreMissingDelete(boolean ignoreMissingDelete) throws Exception {
+ Map options = new HashMap<>();
+ if (ignoreMissingDelete) {
+ options.put(CoreOptions.GLOBAL_INDEX_IGNORE_MISSING_DELETE.key(), "true");
+ }
+ TestFileStore store = createStore(false, 1, CoreOptions.ChangelogProducer.NONE, options);
+ KeyValue record = gen.next();
+ BinaryRow partition = gen.getPartition(record);
+ store.commitData(Collections.singletonList(record), s -> partition, kv -> 0);
+
+ if (ignoreMissingDelete) {
+ try (FileStoreCommitImpl commit = store.newCommit()) {
+ commit.commit(deleteIndexCommittable(partition, "missing-index", 0, 0), false);
+ }
+ } else {
+ assertThatThrownBy(
+ () -> {
+ try (FileStoreCommitImpl commit = store.newCommit()) {
+ commit.commit(
+ deleteIndexCommittable(
+ partition, "missing-index", 0, 0),
+ false);
+ }
+ })
+ .satisfies(
+ anyCauseMatches(
+ IllegalStateException.class,
+ "Trying to delete global index file missing-index which does not exist."));
+ }
+ }
+
@Test
public void testCommitTwiceWithDifferentKind() throws Exception {
TestFileStore store = createStore(false);
@@ -1918,6 +1951,28 @@ private ManifestCommittable indexCommittable(
return committable;
}
+ private ManifestCommittable deleteIndexCommittable(
+ BinaryRow partition, String fileName, long rowRangeStart, long rowRangeEnd) {
+ ManifestCommittable committable = new ManifestCommittable(0);
+ committable.addFileCommittable(
+ new CommitMessageImpl(
+ partition,
+ 0,
+ null,
+ DataIncrement.deleteIndexIncrement(
+ Collections.singletonList(
+ new IndexFileMeta(
+ "btree",
+ fileName,
+ 1,
+ 1,
+ new GlobalIndexMeta(
+ rowRangeStart, rowRangeEnd, 0, null, null),
+ null))),
+ CompactIncrement.emptyIncrement()));
+ return committable;
+ }
+
private static List tableFilesFrom(
ManifestCommittable committable, CoreOptions options) {
ManifestEntryChanges changes = new ManifestEntryChanges(options.bucket());
diff --git a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
index 12af712f1d36..f9708e0c85d7 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
@@ -912,7 +912,8 @@ private void buildAndCommitIndexWithFields(
rowRange,
indexFields,
TestFullTextGlobalIndexerFactory.IDENTIFIER,
- entries);
+ entries,
+ null);
DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
CommitMessage message =
@@ -950,7 +951,8 @@ private void buildAndCommitSourceBackedIndex(FileStoreTable table, String[] docu
new Range(0, documents.length - 1),
Collections.singletonList(textField),
TestFullTextGlobalIndexerFactory.IDENTIFIER,
- writer.finish());
+ writer.finish(),
+ null);
byte[] sourceMeta =
new PrimaryKeyIndexSourceMeta(
1, new PrimaryKeyIndexSourceFile("data-file", documents.length))
@@ -1021,7 +1023,8 @@ private void buildAndCommitIndexRange(
rowRange,
indexFields,
TestFullTextGlobalIndexerFactory.IDENTIFIER,
- entries);
+ entries,
+ null);
DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
CommitMessage message =
diff --git a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
index 2153b44f2b3a..5afead03ee7d 100644
--- a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
@@ -1894,7 +1894,8 @@ private void buildAndCommitVectorIndexWithFields(
rowRange,
indexFields,
TestVectorGlobalIndexerFactory.IDENTIFIER,
- entries);
+ entries,
+ null);
DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
CommitMessage message =
diff --git a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
index 6f512353621a..33feb9d850e1 100644
--- a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
@@ -19,12 +19,18 @@
package org.apache.paimon.utils;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.schema.TableSchema;
import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.IntType;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,6 +39,76 @@
/** Test for {@link DataEvolutionUtils}. */
public class DataEvolutionUtilsTest {
+ @Test
+ public void testFileFieldIdsIgnoresSystemFields() {
+ TableSchema schema =
+ new TableSchema(
+ 1L,
+ Arrays.asList(
+ new DataField(1, "indexed", new IntType()),
+ new DataField(2, "other", new IntType())),
+ 2,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ "");
+
+ assertThat(
+ DataEvolutionUtils.fileFieldIds(
+ ignored -> schema,
+ dataFile(
+ "mixed.parquet",
+ 1,
+ Arrays.asList(
+ SpecialFields.ROW_ID.name(),
+ "indexed",
+ SpecialFields.SEQUENCE_NUMBER.name()))))
+ .containsExactly(1);
+ assertThat(
+ DataEvolutionUtils.fileFieldIds(
+ ignored -> schema,
+ dataFile(
+ "system-only.parquet",
+ 1,
+ Arrays.asList(
+ SpecialFields.ROW_ID.name(),
+ SpecialFields.SEQUENCE_NUMBER.name()))))
+ .isEmpty();
+ }
+
+ @Test
+ public void testFileFieldIdsHandlesFullEmptyAndUnrelatedWrites() {
+ TableSchema schema =
+ new TableSchema(
+ 1L,
+ Arrays.asList(
+ new DataField(1, "indexed", new IntType()),
+ new DataField(2, "other", new IntType())),
+ 2,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ new HashMap<>(),
+ "");
+
+ assertThat(
+ DataEvolutionUtils.fileFieldIds(
+ ignored -> schema, dataFile("full.parquet", 1, null)))
+ .containsExactlyInAnyOrder(1, 2);
+ assertThat(
+ DataEvolutionUtils.fileFieldIds(
+ ignored -> schema,
+ dataFile("empty.parquet", 1, Collections.emptyList())))
+ .isEmpty();
+ assertThat(
+ DataEvolutionUtils.fileFieldIds(
+ ignored -> schema,
+ dataFile(
+ "unrelated.parquet",
+ 1,
+ Collections.singletonList("other"))))
+ .containsExactly(2);
+ }
+
@Test
public void testRetrieveAnchorFileSkipsSpecialFiles() {
DataFileMeta blobFile = dataFile("blob-file.blob", 1);
@@ -74,6 +150,11 @@ public void testRetrieveAnchorFileTieBreaksWithFileName() {
}
private static DataFileMeta dataFile(String fileName, long maxSequenceNumber) {
+ return dataFile(fileName, maxSequenceNumber, Collections.emptyList());
+ }
+
+ private static DataFileMeta dataFile(
+ String fileName, long maxSequenceNumber, List writeCols) {
return DataFileMeta.forAppend(
fileName,
1L,
@@ -88,6 +169,6 @@ private static DataFileMeta dataFile(String fileName, long maxSequenceNumber) {
null,
null,
0L,
- Collections.emptyList());
+ writeCols);
}
}
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 34461d7f7595..5f6dfc90496c 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
@@ -30,11 +30,13 @@
import org.apache.paimon.flink.utils.BoundedOneInputOperator;
import org.apache.paimon.flink.utils.JavaTypeInfo;
import org.apache.paimon.flink.utils.StreamExecutionEnvironmentUtils;
+import org.apache.paimon.globalindex.DataEvolutionGlobalIndexRefreshPlanner;
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.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
@@ -74,15 +76,15 @@
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.shouldRefreshDataEvolutionIndex;
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;
import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkState;
/**
* Builds a Flink topology for creating generic (non-btree) global indexes with parallelism. Each
@@ -304,6 +306,13 @@ private static boolean buildIndexInternal(
List entries = indexBuilder.scan();
List deletedIndexEntries = indexBuilder.deletedIndexEntries();
+ Snapshot scanSnapshot = indexBuilder.scanSnapshot();
+ if (scanSnapshot == null) {
+ checkState(
+ entries.isEmpty() && deletedIndexEntries.isEmpty(),
+ "Global index builder returned index work without a scan snapshot.");
+ return false;
+ }
return buildTopology(
env,
@@ -315,7 +324,7 @@ private static boolean buildIndexInternal(
entries,
deletedIndexEntries,
partitionPredicate,
- indexBuilder.scanSnapshot(),
+ scanSnapshot,
maxIndexedRowId,
autoIncremental);
}
@@ -336,7 +345,7 @@ private static boolean buildTopology(
List entries,
List deletedIndexEntries,
PartitionPredicate partitionPredicate,
- @Nullable Snapshot scanSnapshot,
+ Snapshot scanSnapshot,
long maxIndexedRowId,
boolean autoIncremental)
throws Exception {
@@ -354,10 +363,6 @@ private static boolean buildTopology(
indexType,
indexColumns);
- long minNonIndexableRowId =
- findMinNonIndexableRowId(table.schemaManager(), entries, indexColumns);
- entries = filterEntriesBefore(entries, minNonIndexableRowId);
-
RowType rowType = table.rowType();
DataField indexField = rowType.getField(indexColumn);
List extraFields =
@@ -371,22 +376,50 @@ private static boolean buildTopology(
RowType projectedRowType = SpecialFields.rowTypeWithRowId(rowType).project(readColumns);
Options mergedOptions = new Options(table.options(), userOptions.toMap());
+ boolean refreshDataEvolutionIndex =
+ shouldRefreshDataEvolutionIndex(
+ table, indexType, indexField, extraFields, mergedOptions);
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(scanSnapshot.id()).serialize();
long rowsPerShard = mergedOptions.get(CoreOptions.GLOBAL_INDEX_ROW_COUNT_PER_SHARD);
checkArgument(
rowsPerShard > 0,
"Option 'global-index.row-count-per-shard' must be greater than 0.");
+ deletedIndexEntries = new ArrayList<>(deletedIndexEntries);
List rowRangesToBuild = null;
if (deletedIndexEntries.isEmpty()) {
if (autoIncremental) {
- Snapshot snapshot =
- scanSnapshot == null
- ? table.snapshotManager().latestSnapshot()
- : scanSnapshot;
rowRangesToBuild =
- unindexedRowRanges(
- table, snapshot, indexType, indexedFields, partitionPredicate);
+ new ArrayList<>(
+ unindexedRowRanges(
+ table,
+ scanSnapshot,
+ indexType,
+ indexedFields,
+ partitionPredicate));
+ if (refreshDataEvolutionIndex) {
+ List currentIndexes =
+ table.store().newIndexFileHandler().scan(scanSnapshot, indexType)
+ .stream()
+ .filter(
+ entry ->
+ partitionPredicate == null
+ || partitionPredicate.test(
+ entry.partition()))
+ .collect(Collectors.toList());
+ List indexesToRefresh =
+ DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ table.schemaManager(), entries, currentIndexes, indexedFields);
+ deletedIndexEntries.addAll(indexesToRefresh);
+ for (IndexManifestEntry index : indexesToRefresh) {
+ rowRangesToBuild.add(index.indexFile().globalIndexMeta().rowRange());
+ }
+ rowRangesToBuild = Range.sortAndMergeOverlap(rowRangesToBuild, true);
+ LOG.info(
+ "Selected {} data-evolution index files for refresh.",
+ indexesToRefresh.size());
+ }
LOG.info("Automatically selected unindexed row ranges: {}.", rowRangesToBuild);
} else if (maxIndexedRowId != NO_MAX_INDEXED_ROW_ID) {
rowRangesToBuild = rowRangesAfter(maxIndexedRowId);
@@ -441,7 +474,8 @@ private static boolean buildTopology(
indexField,
extraFields,
projectedRowType,
- mergedOptions))
+ mergedOptions,
+ sourceMeta))
.setParallelism(parallelism);
if (!deletedIndexEntries.isEmpty()) {
@@ -531,6 +565,7 @@ private static class BuildIndexOperator
private final List extraFields;
private final RowType projectedRowType;
private final Options mergedOptions;
+ private final @Nullable byte[] sourceMeta;
private transient TableRead tableRead;
private transient List indexedFields;
@@ -546,7 +581,8 @@ private static class BuildIndexOperator
DataField indexField,
List extraFields,
RowType projectedRowType,
- Options mergedOptions) {
+ Options mergedOptions,
+ @Nullable byte[] sourceMeta) {
this.readBuilder = readBuilder;
this.table = table;
this.indexType = indexType;
@@ -554,6 +590,7 @@ private static class BuildIndexOperator
this.extraFields = extraFields;
this.projectedRowType = projectedRowType;
this.mergedOptions = mergedOptions;
+ this.sourceMeta = sourceMeta;
}
@Override
@@ -678,7 +715,8 @@ public void processElement(StreamRecord element) throws Exception
shardRange,
indexedFields,
indexType,
- resultEntries);
+ resultEntries,
+ sourceMeta);
output.collect(
new StreamRecord<>(
new Committable(
@@ -702,7 +740,8 @@ private static CommitMessage flushIndex(
Range rowRange,
List indexFields,
String indexType,
- List resultEntries)
+ List resultEntries,
+ @Nullable byte[] sourceMeta)
throws IOException {
List indexFileMetas =
toIndexFileMetas(
@@ -712,7 +751,8 @@ private static CommitMessage flushIndex(
rowRange,
indexFields,
indexType,
- resultEntries);
+ resultEntries,
+ sourceMeta);
return new CommitMessageImpl(
partition, 0, null, indexIncrement(indexFileMetas), emptyIncrement());
}
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LuminaVectorGlobalIndexITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LuminaVectorGlobalIndexITCase.java
index c7e11a5cd825..3f04e749477c 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LuminaVectorGlobalIndexITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LuminaVectorGlobalIndexITCase.java
@@ -21,6 +21,7 @@
import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.flink.globalindex.GenericGlobalIndexBuilder;
import org.apache.paimon.flink.globalindex.GenericIndexTopoBuilder;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
@@ -46,6 +47,11 @@ public class LuminaVectorGlobalIndexITCase extends CatalogITCaseBase {
private static final String INDEX_TYPE = "lumina";
private static final String LEGACY_INDEX_TYPE = "lumina-vector-ann";
+ @Override
+ protected Boolean sqlSyncMode() {
+ return true;
+ }
+
@BeforeAll
static void checkLuminaAvailable() {
try {
@@ -541,6 +547,92 @@ public List deletedIndexEntries() {
assertThat(newFileNames).doesNotContainAnyElementsOf(oldFileNames);
}
+ @Test
+ public void testDataEvolutionUpdateRefreshesIndexAtomically() throws Exception {
+ sql(
+ "CREATE TABLE T_REFRESH (id INT, v ARRAY) WITH ("
+ + "'bucket' = '-1', "
+ + "'row-tracking.enabled' = 'true', "
+ + "'data-evolution.enabled' = 'true', "
+ + "'global-index.column-update-action' = 'IGNORE', "
+ + "'lumina.index.dimension' = '3', "
+ + "'lumina.distance.metric' = 'l2'"
+ + ")");
+ String nullValues =
+ IntStream.range(0, 10)
+ .mapToObj(i -> "(" + i + ", CAST(NULL AS ARRAY))")
+ .collect(Collectors.joining(","));
+ sql("INSERT INTO T_REFRESH VALUES " + nullValues + "," + vectorValues(10, 20, 3));
+
+ FileStoreTable table = paimonTable("T_REFRESH");
+ long firstDataSnapshotId = table.snapshotManager().latestSnapshot().id();
+ sql(
+ "CALL sys.create_global_index("
+ + "`table` => 'default.T_REFRESH', "
+ + "index_column => 'v', "
+ + "index_type => '"
+ + INDEX_TYPE
+ + "')");
+
+ List oldEntries = table.store().newIndexFileHandler().scan(INDEX_TYPE);
+ assertThat(oldEntries).isNotEmpty();
+ assertThat(oldEntries)
+ .allSatisfy(
+ entry ->
+ assertThat(
+ DataEvolutionIndexSourceMeta.fromIndexFile(
+ entry.indexFile())
+ .scanSnapshotId())
+ .isEqualTo(firstDataSnapshotId));
+ List oldFileNames =
+ oldEntries.stream()
+ .map(entry -> entry.indexFile().fileName())
+ .collect(Collectors.toList());
+
+ sql("CREATE TABLE S_REFRESH (id INT, v ARRAY)");
+ sql(
+ "INSERT INTO S_REFRESH VALUES "
+ + "(1, ARRAY[CAST(0.0 AS FLOAT), CAST(1.0 AS FLOAT), "
+ + "CAST(2.0 AS FLOAT)])");
+ sql(
+ "CALL sys.data_evolution_merge_into("
+ + "'default.T_REFRESH', '', '', 'S_REFRESH', "
+ + "'T_REFRESH._ROW_ID=S_REFRESH.id', 'v=S_REFRESH.v', 2)");
+ assertThat(sql("SELECT id FROM T_REFRESH WHERE id = 1 AND v IS NOT NULL")).hasSize(1);
+
+ long updatedSnapshotId = table.snapshotManager().latestSnapshot().id();
+ assertThat(
+ table.store().newIndexFileHandler().scan(INDEX_TYPE).stream()
+ .map(entry -> entry.indexFile().fileName()))
+ .containsExactlyInAnyOrderElementsOf(oldFileNames);
+
+ sql(
+ "CALL sys.create_global_index("
+ + "`table` => 'default.T_REFRESH', "
+ + "index_column => 'v', "
+ + "index_type => '"
+ + INDEX_TYPE
+ + "')");
+
+ assertThat(table.snapshotManager().latestSnapshot().id()).isEqualTo(updatedSnapshotId + 1);
+ List refreshedEntries =
+ table.store().newIndexFileHandler().scan(INDEX_TYPE);
+ assertThat(refreshedEntries).isNotEmpty();
+ assertThat(
+ refreshedEntries.stream()
+ .map(entry -> entry.indexFile().fileName())
+ .collect(Collectors.toList()))
+ .doesNotContainAnyElementsOf(oldFileNames);
+ assertThat(refreshedEntries)
+ .allSatisfy(
+ entry ->
+ assertThat(
+ DataEvolutionIndexSourceMeta.fromIndexFile(
+ entry.indexFile())
+ .scanSnapshotId())
+ .isEqualTo(updatedSnapshotId));
+ }
+
// -- Helpers --
private List getVectorIndexFiles(FileStoreTable table) {
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 916d6c257877..658a25e619f9 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
@@ -18,23 +18,31 @@
package org.apache.paimon.flink.globalindex;
+import org.apache.paimon.CoreOptions;
import org.apache.paimon.FileStore;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.data.BinaryRow;
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.globalindex.testfulltext.TestFullTextGlobalIndexerFactory;
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.schema.SchemaManager;
import org.apache.paimon.schema.TableSchema;
import org.apache.paimon.stats.SimpleStats;
import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.FileStorePathFactory;
import org.apache.paimon.utils.Range;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -46,6 +54,7 @@
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -172,6 +181,28 @@ void testEmptyEntries() throws IOException {
assertThat(tasks).isEmpty();
}
+ @Test
+ void testRejectsIndexWorkWithoutScanSnapshot() {
+ GenericGlobalIndexBuilder indexBuilder = mock(GenericGlobalIndexBuilder.class);
+ when(indexBuilder.scan())
+ .thenReturn(Collections.singletonList(createEntry(BinaryRow.EMPTY_ROW, 0L, 1)));
+ when(indexBuilder.deletedIndexEntries()).thenReturn(Collections.emptyList());
+ when(indexBuilder.scanSnapshot()).thenReturn(null);
+
+ assertThatThrownBy(
+ () ->
+ GenericIndexTopoBuilder.buildIndex(
+ StreamExecutionEnvironment.getExecutionEnvironment(),
+ () -> indexBuilder,
+ table,
+ "id",
+ "test-index",
+ null,
+ new Options()))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("index work without a scan snapshot");
+ }
+
@Test
void testAllFilesNullRowId() throws IOException {
List entries = new ArrayList<>();
@@ -478,10 +509,13 @@ void testIncrementalFileStartsAfterEffectiveStart() {
}
@Test
- void testAppendFilterOldFilesBeforeNewFiles() {
- // Typical append: write file0[0,99](schema1), file1[100,199](schema1),
- // then file2[200,299](schema0) arrives (old schema).
- // Boundary = 200, keep files with firstRowId < 200.
+ void testAppendIncludesFilesAfterOldSchemaBoundary() throws Exception {
+ // The first file uses a schema from before "vec" was added. It must not prevent later
+ // files written with the new schema from participating in index topology construction.
+ List entries = new ArrayList<>();
+ entries.add(createEntryWithSchemaId(BinaryRow.EMPTY_ROW, 0L, 100, 0L));
+ entries.add(createEntryWithSchemaId(BinaryRow.EMPTY_ROW, 100L, 100, 1L));
+
SchemaManager schemaManager = mock(SchemaManager.class);
TableSchema oldSchema = mock(TableSchema.class);
TableSchema newSchema = mock(TableSchema.class);
@@ -489,21 +523,34 @@ void testAppendFilterOldFilesBeforeNewFiles() {
when(schemaManager.schema(1L)).thenReturn(newSchema);
when(oldSchema.fieldNames()).thenReturn(Arrays.asList("id", "name"));
when(newSchema.fieldNames()).thenReturn(Arrays.asList("id", "name", "vec"));
-
- List entries = new ArrayList<>();
- entries.add(createEntryWithSchemaId(BinaryRow.EMPTY_ROW, 0L, 100, 1L));
- entries.add(createEntryWithSchemaId(BinaryRow.EMPTY_ROW, 100L, 100, 1L));
- entries.add(createEntryWithSchemaId(BinaryRow.EMPTY_ROW, 200L, 100, 0L));
-
- List result =
- GlobalIndexBuilderUtils.filterEntriesBefore(
- entries,
- GlobalIndexBuilderUtils.findMinNonIndexableRowId(
- schemaManager, entries, Collections.singletonList("vec")));
-
- assertThat(result).hasSize(2);
- assertThat(result.get(0).file().nonNullFirstRowId()).isEqualTo(0L);
- assertThat(result.get(1).file().nonNullFirstRowId()).isEqualTo(100L);
+ when(table.schemaManager()).thenReturn(schemaManager);
+
+ GenericGlobalIndexBuilder indexBuilder = mock(GenericGlobalIndexBuilder.class);
+ when(indexBuilder.scan()).thenReturn(entries);
+ when(indexBuilder.deletedIndexEntries()).thenReturn(Collections.emptyList());
+ Snapshot scanSnapshot = mock(Snapshot.class);
+ when(scanSnapshot.id()).thenReturn(1L);
+ when(indexBuilder.scanSnapshot()).thenReturn(scanSnapshot);
+
+ RowType rowType = RowType.of(new DataType[] {DataTypes.STRING()}, new String[] {"vec"});
+ when(table.rowType()).thenReturn(rowType);
+ when(table.options()).thenReturn(Collections.emptyMap());
+ CoreOptions coreOptions = mock(CoreOptions.class);
+ when(coreOptions.dataEvolutionEnabled()).thenReturn(false);
+ when(table.coreOptions()).thenReturn(coreOptions);
+ when(table.newReadBuilder()).thenReturn(mock(ReadBuilder.class));
+
+ assertThat(
+ GenericIndexTopoBuilder.buildIndex(
+ StreamExecutionEnvironment.getExecutionEnvironment(),
+ () -> indexBuilder,
+ table,
+ "vec",
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ null,
+ new Options(),
+ 99L))
+ .isTrue();
}
// -- Helpers --
diff --git a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/FullTextSearchProcedureITCase.java b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/FullTextSearchProcedureITCase.java
index b621d0bc9f44..f1783bd1e2b8 100644
--- a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/FullTextSearchProcedureITCase.java
+++ b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/procedure/FullTextSearchProcedureITCase.java
@@ -19,7 +19,10 @@
package org.apache.paimon.flink.procedure;
import org.apache.paimon.flink.CatalogITCaseBase;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
import org.apache.paimon.index.pkfulltext.PkFullTextIndexFile;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.table.FileStoreTable;
import org.apache.flink.table.api.config.TableConfigOptions;
import org.apache.flink.types.Row;
@@ -34,6 +37,37 @@
/** IT cases for {@link FullTextSearchProcedure}. */
public class FullTextSearchProcedureITCase extends CatalogITCaseBase {
+ @Test
+ public void testDataEvolutionSourceMetaForGenericFullTextIndex() throws Exception {
+ sql(
+ "CREATE TABLE T_DE (id INT, content STRING) WITH ("
+ + "'bucket' = '-1', "
+ + "'row-tracking.enabled' = 'true', "
+ + "'data-evolution.enabled' = 'true'"
+ + ")");
+ sql("INSERT INTO T_DE VALUES (1, 'apache paimon'), (2, 'lake format')");
+
+ FileStoreTable table = paimonTable("T_DE");
+ long scanSnapshotId = table.snapshotManager().latestSnapshot().id();
+ tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+ sql(
+ "CALL sys.create_global_index("
+ + "`table` => 'default.T_DE', "
+ + "index_column => 'content', "
+ + "index_type => 'full-text')");
+
+ List entries = table.store().newIndexFileHandler().scan("full-text");
+ assertThat(entries).isNotEmpty();
+ assertThat(entries)
+ .allSatisfy(
+ entry ->
+ assertThat(
+ DataEvolutionIndexSourceMeta.fromIndexFile(
+ entry.indexFile())
+ .scanSnapshotId())
+ .isEqualTo(scanSnapshotId));
+ }
+
@Test
public void testPrimaryKeyFullTextSearchWithScoreProjection() throws Exception {
createPrimaryKeyFullTextTable("T");
diff --git a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexBuilder.java b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexBuilder.java
index fd13f5ee0a49..5734cf84e396 100644
--- a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexBuilder.java
+++ b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/globalindex/DefaultGlobalIndexBuilder.java
@@ -39,6 +39,8 @@
import org.apache.paimon.utils.ProjectedRow;
import org.apache.paimon.utils.Range;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
@@ -61,6 +63,7 @@ public class DefaultGlobalIndexBuilder implements Serializable {
private final String indexType;
private final Range rowRange;
private final Options options;
+ private final @Nullable byte[] sourceMeta;
public DefaultGlobalIndexBuilder(
FileStoreTable table,
@@ -78,7 +81,8 @@ public DefaultGlobalIndexBuilder(
Collections.emptyList(),
indexType,
rowRange,
- options);
+ options,
+ null);
}
public DefaultGlobalIndexBuilder(
@@ -90,6 +94,28 @@ public DefaultGlobalIndexBuilder(
String indexType,
Range rowRange,
Options options) {
+ this(
+ table,
+ partition,
+ readType,
+ indexField,
+ extraFields,
+ indexType,
+ rowRange,
+ options,
+ null);
+ }
+
+ public DefaultGlobalIndexBuilder(
+ FileStoreTable table,
+ BinaryRow partition,
+ RowType readType,
+ DataField indexField,
+ List extraFields,
+ String indexType,
+ Range rowRange,
+ Options options,
+ @Nullable byte[] sourceMeta) {
this.table = table;
this.partition = partition;
this.readType = readType;
@@ -102,6 +128,7 @@ public DefaultGlobalIndexBuilder(
this.indexType = indexType;
this.rowRange = rowRange;
this.options = options;
+ this.sourceMeta = sourceMeta;
}
/** The primary index column followed by the extra columns, in index order. */
@@ -131,7 +158,8 @@ public CommitMessage build(CloseableIterator data) throws IOExcepti
rowRange,
indexedFields(),
indexType,
- resultEntries);
+ resultEntries,
+ sourceMeta);
DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFileMetas);
return new CommitMessageImpl(
partition, 0, null, dataIncrement, CompactIncrement.emptyIncrement());
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 faa85f6588b6..5b45607cecc1 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
@@ -22,15 +22,20 @@
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.globalindex.DataEvolutionGlobalIndexRefreshPlanner;
import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataIncrement;
+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.reader.RecordReader;
-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.CommitMessageImpl;
import org.apache.paimon.table.sink.CommitMessageSerializer;
import org.apache.paimon.table.source.ReadBuilder;
import org.apache.paimon.types.DataField;
@@ -112,16 +117,31 @@ public List buildIndex(
List indexFields = new ArrayList<>();
indexFields.add(indexField);
indexFields.addAll(extraFields);
- List indexColumns =
- indexFields.stream().map(DataField::name).collect(Collectors.toList());
- SchemaManager schemaManager = new SchemaManager(table.fileIO(), table.location());
- long boundaryRowId =
- GlobalIndexBuilderUtils.findMinNonIndexableRowId(
- schemaManager, entries, indexColumns);
- entries = GlobalIndexBuilderUtils.filterEntriesBefore(entries, boundaryRowId);
List rowRangesToBuild =
- GlobalIndexBuilderUtils.unindexedRowRanges(
- table, snapshot, indexType, indexFields, partitionPredicate);
+ new ArrayList<>(
+ GlobalIndexBuilderUtils.unindexedRowRanges(
+ table, snapshot, indexType, indexFields, partitionPredicate));
+ boolean refreshDataEvolutionIndex =
+ GlobalIndexBuilderUtils.shouldRefreshDataEvolutionIndex(
+ table, indexType, indexField, extraFields, options);
+ List indexesToRefresh = Collections.emptyList();
+ byte[] sourceMeta = new DataEvolutionIndexSourceMeta(snapshot.id()).serialize();
+ if (refreshDataEvolutionIndex) {
+ List currentIndexes =
+ table.store().newIndexFileHandler().scan(snapshot, indexType).stream()
+ .filter(
+ entry ->
+ partitionPredicate == null
+ || partitionPredicate.test(entry.partition()))
+ .collect(Collectors.toList());
+ indexesToRefresh =
+ DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+ table.schemaManager(), entries, currentIndexes, indexFields);
+ for (IndexManifestEntry index : indexesToRefresh) {
+ rowRangesToBuild.add(index.indexFile().globalIndexMeta().rowRange());
+ }
+ rowRangesToBuild = Range.sortAndMergeOverlap(rowRangesToBuild, true);
+ }
if (rowRangesToBuild.isEmpty()) {
return Collections.emptyList();
}
@@ -145,23 +165,34 @@ public List buildIndex(
extraFields,
indexType,
indexedSplit.rowRanges().get(0),
- options);
+ options,
+ sourceMeta);
byte[] builderBytes = InstantiationUtil.serializeObject(builder);
byte[] splitBytes = InstantiationUtil.serializeObject(indexedSplit);
taskList.add(Pair.of(builderBytes, splitBytes));
}
- if (taskList.isEmpty()) {
- return Collections.emptyList();
+ List commitMessages = new ArrayList<>();
+ if (!taskList.isEmpty()) {
+ int parallelism = parallelism(taskList.size(), options);
+ List commitMessageBytes =
+ javaSparkContext
+ .parallelize(taskList, parallelism)
+ .map(DefaultGlobalIndexTopoBuilder::buildIndex)
+ .collect();
+ commitMessages.addAll(CommitMessageSerializer.deserializeAll(commitMessageBytes));
}
-
- int parallelism = parallelism(taskList.size(), options);
- List commitMessageBytes =
- javaSparkContext
- .parallelize(taskList, parallelism)
- .map(DefaultGlobalIndexTopoBuilder::buildIndex)
- .collect();
- return CommitMessageSerializer.deserializeAll(commitMessageBytes);
+ for (IndexManifestEntry index : indexesToRefresh) {
+ commitMessages.add(
+ new CommitMessageImpl(
+ index.partition(),
+ index.bucket(),
+ null,
+ DataIncrement.deleteIndexIncrement(
+ Collections.singletonList(index.indexFile())),
+ CompactIncrement.emptyIncrement()));
+ }
+ return commitMessages;
}
static long rowsPerShard(Options options) {
diff --git a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FullTextSearchTest.scala b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FullTextSearchTest.scala
index 5777f867fb3a..4c131e1f0312 100644
--- a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FullTextSearchTest.scala
+++ b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FullTextSearchTest.scala
@@ -18,6 +18,7 @@
package org.apache.paimon.spark.sql
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta
import org.apache.paimon.spark.PaimonSparkTestBase
import scala.collection.JavaConverters._
@@ -44,6 +45,7 @@ class FullTextSearchTest extends PaimonSparkTestBase {
.map(i => s"($i, 'document number $i about paimon lake format')")
.mkString(",")
spark.sql(s"INSERT INTO T VALUES $values")
+ val scanSnapshotId = loadTable("T").snapshotManager().latestSnapshot().id()
val output = spark
.sql(
@@ -61,6 +63,11 @@ class FullTextSearchTest extends PaimonSparkTestBase {
.filter(_.indexFile().indexType() == indexType)
assert(indexEntries.nonEmpty)
+ assert(
+ indexEntries.forall(
+ entry =>
+ DataEvolutionIndexSourceMeta.fromIndexFile(entry.indexFile()).scanSnapshotId() ==
+ scanSnapshotId))
val totalRowCount = indexEntries.map(_.indexFile().rowCount()).sum
assert(totalRowCount == 100L)
}
diff --git a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/LuminaVectorIndexTest.scala b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/LuminaVectorIndexTest.scala
index 6f079d4dcf74..3cc2339704b3 100644
--- a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/LuminaVectorIndexTest.scala
+++ b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/LuminaVectorIndexTest.scala
@@ -18,6 +18,7 @@
package org.apache.paimon.spark.sql
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta
import org.apache.paimon.spark.PaimonSparkTestBase
import scala.collection.JavaConverters._
@@ -69,6 +70,54 @@ class LuminaVectorIndexTest extends PaimonSparkTestBase {
}
}
+ test("create lumina vector index after adding the index column") {
+ withTable("T") {
+ spark.sql("""
+ |CREATE TABLE T (id INT)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'global-index.row-count-per-shard' = '10000',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true')
+ |""".stripMargin)
+ spark.sql("INSERT INTO T VALUES (0), (1), (2), (3), (4)")
+
+ spark.sql("ALTER TABLE T ADD COLUMNS (v ARRAY)")
+ spark.sql("""
+ |INSERT INTO T VALUES
+ | (5, array(5.0f, 6.0f, 7.0f)),
+ | (6, array(6.0f, 7.0f, 8.0f)),
+ | (7, array(7.0f, 8.0f, 9.0f)),
+ | (8, array(8.0f, 9.0f, 10.0f)),
+ | (9, array(9.0f, 10.0f, 11.0f))
+ |""".stripMargin)
+
+ val output = spark
+ .sql(
+ s"CALL sys.create_global_index(table => 'test.T', index_column => 'v', index_type => '$indexType', options => '$defaultOptions')")
+ .collect()
+ .head
+ assert(output.getBoolean(0))
+
+ val indexEntries =
+ loadTable("T").store().newIndexFileHandler().scan(indexType).asScala
+ assert(indexEntries.size == 1)
+ val indexFile = indexEntries.head.indexFile()
+ assert(indexFile.rowCount() == 10L)
+ assert(indexFile.globalIndexMeta().rowRangeStart() == 0L)
+ assert(indexFile.globalIndexMeta().rowRangeEnd() == 9L)
+
+ val searchResult = spark
+ .sql("""
+ |SELECT id FROM vector_search(
+ | 'T', 'v', array(5.0f, 6.0f, 7.0f), 5,
+ | map('refine_factor', '5'))
+ |""".stripMargin)
+ .collect()
+ assert(searchResult.map(_.getInt(0)).toSet == (5 to 9).toSet)
+ }
+ }
+
test("create lumina vector index - legacy index type") {
withTable("T") {
spark.sql("""
@@ -406,4 +455,90 @@ class LuminaVectorIndexTest extends PaimonSparkTestBase {
assert(searchResult.length == 10)
}
}
+
+ test("incremental build atomically refreshes vectors updated inside an indexed range") {
+ withTable("T") {
+ spark.sql("""
+ |CREATE TABLE T (id INT, v ARRAY)
+ |TBLPROPERTIES (
+ | 'bucket' = '-1',
+ | 'global-index.row-count-per-shard' = '10000',
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'lumina.distance.metric' = 'l2',
+ | 'global-index.column-update-action' = 'IGNORE')
+ |""".stripMargin)
+
+ val values = (0 until 200)
+ .map {
+ case i if i < 100 => s"($i, cast(NULL as ARRAY))"
+ case i =>
+ s"($i, array(cast($i as float), cast(${i + 1} as float), cast(${i + 2} as float)))"
+ }
+ .mkString(",")
+ spark.sql(s"INSERT INTO T VALUES $values")
+
+ val table = loadTable("T")
+ val firstDataSnapshotId = table.snapshotManager().latestSnapshot().id()
+ spark.sql(
+ s"CALL sys.create_global_index(table => 'test.T', index_column => 'v', " +
+ s"index_type => '$indexType', options => '$defaultOptions')")
+
+ val oldEntries = table.store().newIndexFileHandler().scan(indexType).asScala
+ assert(oldEntries.nonEmpty)
+ assert(
+ oldEntries.forall(
+ entry =>
+ DataEvolutionIndexSourceMeta.fromIndexFile(entry.indexFile()).scanSnapshotId() ==
+ firstDataSnapshotId))
+ val oldFileNames = oldEntries.map(_.indexFile().fileName()).toSet
+
+ spark.sql("""
+ |MERGE INTO T
+ |USING (SELECT 0 AS id,
+ | array(cast(0 as float), cast(1 as float), cast(2 as float)) AS v) S
+ |ON T.id = S.id
+ |WHEN MATCHED THEN UPDATE SET v = S.v
+ |""".stripMargin)
+
+ val updatedSnapshot = table.snapshotManager().latestSnapshot()
+ val stillVisible =
+ table.store().newIndexFileHandler().scan(updatedSnapshot, indexType).asScala
+ assert(stillVisible.map(_.indexFile().fileName()).toSet == oldFileNames)
+ // Retrieve every indexed candidate before exact reranking so ANN recall is deterministic.
+ val beforeRefresh = spark
+ .sql("""
+ |SELECT id FROM vector_search(
+ | 'T', 'v', array(0.0f, 1.0f, 2.0f), 1,
+ | map('refine_factor', '200'))
+ |""".stripMargin)
+ .collect()
+ assert(beforeRefresh.head.getInt(0) != 0)
+
+ spark.sql(
+ s"CALL sys.create_global_index(table => 'test.T', index_column => 'v', " +
+ s"index_type => '$indexType', options => '$defaultOptions')")
+
+ val indexCommitSnapshot = table.snapshotManager().latestSnapshot()
+ assert(indexCommitSnapshot.id() == updatedSnapshot.id() + 1)
+ val refreshedEntries =
+ table.store().newIndexFileHandler().scan(indexCommitSnapshot, indexType).asScala
+ assert(refreshedEntries.nonEmpty)
+ assert(refreshedEntries.map(_.indexFile().fileName()).toSet.intersect(oldFileNames).isEmpty)
+ assert(
+ refreshedEntries.forall(
+ entry =>
+ DataEvolutionIndexSourceMeta.fromIndexFile(entry.indexFile()).scanSnapshotId() ==
+ updatedSnapshot.id()))
+
+ val afterRefresh = spark
+ .sql("""
+ |SELECT id FROM vector_search(
+ | 'T', 'v', array(0.0f, 1.0f, 2.0f), 1,
+ | map('refine_factor', '200'))
+ |""".stripMargin)
+ .collect()
+ assert(afterRefresh.head.getInt(0) == 0)
+ }
+ }
}