This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new cdb3e502b3 [flink] Maintain scalar indexes during Data Evolution 
clustering (#10682)
cdb3e502b3 is described below

commit cdb3e502b3dc7a9e621e00af2b0dfbdc506c1e93
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 12:48:38 2026 +0800

    [flink] Maintain scalar indexes during Data Evolution clustering (#10682)
---
 .../data-evolution-maintenance.mdx                 |  20 +-
 .../dataevolution/ClusteringIndexRewriter.java     | 469 ++++++++++
 .../paimon/operation/FileStoreCommitImpl.java      |  59 +-
 .../dataevolution/DataEvolutionClusterTest.java    | 945 ++++++++++++++++++++-
 .../paimon/operation/FileStoreCommitTest.java      |  32 +-
 .../cluster/IncrementalClusterSplitSource.java     |  16 +-
 .../flink/compact/IncrementalClusterCompact.java   |  47 +-
 .../paimon/flink/DataEvolutionDeleteSqlITCase.java | 103 ++-
 8 files changed, 1643 insertions(+), 48 deletions(-)

diff --git a/docs/docs/multimodal-table/data-evolution-maintenance.mdx 
b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
index 682334e99b..9c71595773 100644
--- a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
+++ b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
@@ -201,10 +201,22 @@ in batch mode. Explicit compact jobs use clustering while 
this option is enabled
 To consolidate column versions while preserving row IDs, disable clustering for
 the compact invocation with `options => 'clustering.incremental=false'`.
 
-Clustering removes the applied deletion vectors and drops global indexes in
-affected partitions because their row IDs no longer identify the same rows.
-Rebuild those indexes after clustering, or use `scalar-index.search-mode = 
full`
-for complete scalar query results while only part of the table remains indexed.
+Clustering removes the applied deletion vectors. Flink clustering also rewrites
+B-tree and bitmap index files with the new row IDs, reusing their stored keys.
+Indexes outside the rewritten row-ID ranges are preserved, including those in
+the same partition. Partially indexed data retains its exact coverage after 
sorting.
+The current contiguous-range index protocol limits rewriting to 64 new coverage
+fragments per index definition, and 64 retained plus new fragments per source 
file.
+If sorting would exceed either bound, clustering fails before committing; 
rebuild
+complete indexes with smaller shards first.
+The job records the old row IDs alongside each output file so that concurrent
+index creation and optimistic commit retries use the final assigned row IDs.
+
+Indexes with stale keys after indexed-column updates, and affected index types
+without RowID rewrite support, are dropped. Spark clustering currently drops
+global indexes in the affected partitions. Rebuild dropped indexes, or use
+`scalar-index.search-mode = full` for complete scalar query results while part
+of the table remains unindexed.
 Concurrent updates or deletes that overlap the selected row-ID groups cause
 commit to fail, preventing stale rewrites from replacing newer values.
 Historical snapshots and tags retain
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
new file mode 100644
index 0000000000..3cf1f2f687
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringIndexRewriter.java
@@ -0,0 +1,469 @@
+/*
+ * 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.append.dataevolution;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.DataEvolutionGlobalIndexRefreshPlanner;
+import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.GlobalIndexFileReadWrite;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexer;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
+import org.apache.paimon.index.GlobalIndexMeta;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.index.IndexPathFactory;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.FileStorePathFactory;
+import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RowRangeIndex;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Rewrites scalar index postings after clustering output files receive their 
final row IDs. */
+public final class ClusteringIndexRewriter {
+
+    // The current metadata protocol represents coverage with contiguous 
ranges. Bound both
+    // tiny-file fan-out and repeated source scans until sparse coverage has 
its own protocol.
+    private static final int MAX_COVERAGE_FRAGMENTS = 64;
+
+    private ClusteringIndexRewriter() {}
+
+    public static List<IndexManifestEntry> rewrite(
+            FileIO fileIO,
+            FileStorePathFactory pathFactory,
+            SchemaManager schemaManager,
+            CoreOptions options,
+            @Nullable IOManager ioManager,
+            List<ManifestEntry> dataFiles,
+            List<IndexManifestEntry> indexFiles,
+            Set<String> mappedClusteringOutputs,
+            long newSnapshotId,
+            List<Path> createdFiles)
+            throws IOException {
+        if (indexFiles.stream()
+                .noneMatch(
+                        entry ->
+                                entry.kind() == FileKind.DELETE
+                                        && entry.indexFile().globalIndexMeta() 
!= null)) {
+            return indexFiles;
+        }
+        Map<Pair<BinaryRow, Integer>, List<ManifestEntry>> groups =
+                dataFiles.stream()
+                        .collect(Collectors.groupingBy(e -> 
Pair.of(e.partition(), e.bucket())));
+        Map<Pair<BinaryRow, Integer>, List<IndexManifestEntry>> deletes = new 
HashMap<>();
+        List<IndexManifestEntry> result = new ArrayList<>();
+        for (IndexManifestEntry entry : indexFiles) {
+            if (entry.kind() == FileKind.DELETE && 
entry.indexFile().globalIndexMeta() != null) {
+                deletes.computeIfAbsent(
+                                Pair.of(entry.partition(), entry.bucket()), k 
-> new ArrayList<>())
+                        .add(entry);
+            } else {
+                result.add(entry);
+            }
+        }
+        for (Map.Entry<Pair<BinaryRow, Integer>, List<IndexManifestEntry>> 
group :
+                deletes.entrySet()) {
+            List<ManifestEntry> files =
+                    groups.getOrDefault(group.getKey(), 
Collections.emptyList());
+            List<DataFileMeta> after =
+                    files.stream()
+                            .filter(e -> e.kind() == FileKind.ADD)
+                            .map(ManifestEntry::file)
+                            .collect(Collectors.toList());
+            // Other materialization callers do not supply a permutation and 
retain their existing
+            // invalidation behavior. A mixed mapping must never silently 
retain a stale posting.
+            if (after.stream()
+                    .anyMatch(file -> 
!mappedClusteringOutputs.contains(file.fileName()))) {
+                result.addAll(group.getValue());
+                continue;
+            }
+            List<ManifestEntry> before =
+                    files.stream()
+                            .filter(e -> e.kind() == FileKind.DELETE)
+                            .map(
+                                    e ->
+                                            ManifestEntry.create(
+                                                    FileKind.ADD,
+                                                    e.partition(),
+                                                    e.bucket(),
+                                                    e.totalBuckets(),
+                                                    e.file()))
+                            .collect(Collectors.toList());
+            if (before.isEmpty()) {
+                result.addAll(group.getValue());
+                continue;
+            }
+            
after.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
+            long firstAssignedRowId =
+                    after.isEmpty() ? Long.MAX_VALUE : 
after.get(0).nonNullFirstRowId();
+            List<Range> removed =
+                    Range.sortAndMergeOverlap(
+                            before.stream()
+                                    .map(ManifestEntry::file)
+                                    .map(DataFileMeta::nonNullRowIdRange)
+                                    .collect(Collectors.toList()),
+                            true);
+            DataFilePathFactory dataPath =
+                    pathFactory.createDataFilePathFactory(
+                            group.getKey().getLeft(), 
group.getKey().getRight());
+            try (DiskMapping mapping = new DiskMapping(removed, ioManager)) {
+                for (DataFileMeta file : after) {
+                    readMapping(
+                            fileIO,
+                            dataPath,
+                            file,
+                            (ordinal, oldId) ->
+                                    mapping.put(oldId, 
file.nonNullFirstRowId() + ordinal));
+                }
+                RowType rowType =
+                        schemaManager
+                                .latestOrThrow("Missing clustering table 
schema")
+                                .logicalRowType();
+                Map<Pair<String, List<Integer>>, List<IndexManifestEntry>> 
validSources =
+                        new LinkedHashMap<>();
+                for (IndexManifestEntry entry : group.getValue()) {
+                    GlobalIndexMeta meta = entry.indexFile().globalIndexMeta();
+                    if (Range.and(Collections.singletonList(meta.rowRange()), 
removed).isEmpty()) {
+                        continue;
+                    }
+                    result.add(entry);
+                    String type = entry.indexFile().indexType();
+                    if (!type.equals("btree") && !type.equals("bitmap")) {
+                        continue;
+                    }
+                    // Dropped indexed columns can leave old index files in 
the manifest.
+                    if 
(!meta.getIndexedFieldIds().stream().allMatch(rowType::containsField)) {
+                        continue;
+                    }
+                    List<DataField> fields = meta.getIndexedFields(rowType);
+                    IndexManifestEntry source =
+                            new IndexManifestEntry(
+                                    FileKind.ADD,
+                                    entry.partition(),
+                                    entry.bucket(),
+                                    entry.indexFile());
+                    // Updating row IDs cannot repair keys from an older 
indexed-column value.
+                    if 
(!DataEvolutionIndexSourceMeta.isDataEvolutionMeta(meta.sourceMeta())
+                            || 
!DataEvolutionGlobalIndexRefreshPlanner.findIndexesToRefresh(
+                                            schemaManager,
+                                            before,
+                                            Collections.singletonList(source),
+                                            fields)
+                                    .isEmpty()) {
+                        continue;
+                    }
+                    validSources
+                            .computeIfAbsent(
+                                    Pair.of(type, meta.getIndexedFieldIds()),
+                                    ignored -> new ArrayList<>())
+                            .add(entry);
+                }
+                for (Map.Entry<Pair<String, List<Integer>>, 
List<IndexManifestEntry>> sourceGroup :
+                        validSources.entrySet()) {
+                    List<IndexManifestEntry> sources = sourceGroup.getValue();
+                    String type = sourceGroup.getKey().getLeft();
+                    GlobalIndexMeta firstMeta = 
sources.get(0).indexFile().globalIndexMeta();
+                    List<DataField> fields = 
firstMeta.getIndexedFields(rowType);
+                    RowRangeIndex coverage =
+                            RowRangeIndex.create(
+                                    sources.stream()
+                                            .map(
+                                                    source ->
+                                                            source.indexFile()
+                                                                    
.globalIndexMeta()
+                                                                    
.rowRange())
+                                            .collect(Collectors.toList()),
+                                    false);
+                    String sourceName = sources.get(0).indexFile().fileName();
+                    List<Range> freshFragments = new ArrayList<>();
+                    // Source shards can interleave after sorting. Their 
union, rather than each
+                    // original shard, defines the coverage shared by the 
rewritten payload files.
+                    for (DataFileMeta file : after) {
+                        final long[] start = {-1};
+                        final long[] end = {-1};
+                        readMapping(
+                                fileIO,
+                                dataPath,
+                                file,
+                                (ordinal, oldId) -> {
+                                    long newId = file.nonNullFirstRowId() + 
ordinal;
+                                    if (coverage.intersects(oldId, oldId)) {
+                                        if (start[0] < 0) {
+                                            start[0] = newId;
+                                        }
+                                        end[0] = newId;
+                                    } else if (start[0] >= 0) {
+                                        addFragment(
+                                                freshFragments,
+                                                new Range(start[0], end[0]),
+                                                sourceName,
+                                                firstAssignedRowId);
+                                        start[0] = -1;
+                                    }
+                                });
+                        if (start[0] >= 0) {
+                            addFragment(
+                                    freshFragments,
+                                    new Range(start[0], end[0]),
+                                    sourceName,
+                                    firstAssignedRowId);
+                        }
+                    }
+                    GlobalIndexer indexer =
+                            GlobalIndexer.create(type, fields, 
Options.fromMap(options.toMap()));
+                    IndexPathFactory indexPath = 
pathFactory.globalIndexFileFactory();
+                    GlobalIndexFileReadWrite indexIO =
+                            trackingIndexIO(fileIO, indexPath, createdFiles);
+                    for (IndexManifestEntry source : sources) {
+                        GlobalIndexMeta meta = 
source.indexFile().globalIndexMeta();
+                        List<Range> fragments = new ArrayList<>();
+                        for (Range untouched : 
meta.rowRange().exclude(removed)) {
+                            addFragment(
+                                    fragments,
+                                    untouched,
+                                    source.indexFile().fileName(),
+                                    firstAssignedRowId);
+                        }
+                        for (Range fresh : freshFragments) {
+                            addFragment(
+                                    fragments,
+                                    fresh,
+                                    source.indexFile().fileName(),
+                                    firstAssignedRowId);
+                        }
+                        GlobalIndexIOMeta input =
+                                new GlobalIndexIOMeta(
+                                        indexPath.toPath(source.indexFile()),
+                                        source.indexFile().fileSize(),
+                                        source.indexFile().rowCount(),
+                                        meta.indexMeta());
+                        for (Range fragment : fragments) {
+                            List<ResultEntry> rewritten =
+                                    indexer.rewriteRowIds(
+                                            indexIO,
+                                            Collections.singletonList(input),
+                                            meta.rowRange().count(),
+                                            fragment.count(),
+                                            relative -> {
+                                                try {
+                                                    long target =
+                                                            mapping.get(
+                                                                    relative
+                                                                            + 
meta.rowRangeStart());
+                                                    return target >= 
fragment.from
+                                                                    && target 
<= fragment.to
+                                                            ? target - 
fragment.from
+                                                            : -1;
+                                                } catch (IOException e) {
+                                                    throw new 
UncheckedIOException(e);
+                                                }
+                                            },
+                                            indexIO);
+                            // Only newly assigned coverage shares a fresh 
baseline. Old fragments
+                            // keep their source's baseline, even when another 
shard is older.
+                            byte[] sourceMeta =
+                                    fragment.from >= firstAssignedRowId
+                                            ? new 
DataEvolutionIndexSourceMeta(newSnapshotId)
+                                                    .serialize()
+                                            : meta.sourceMeta();
+                            for (IndexFileMeta rewrittenFile :
+                                    GlobalIndexBuilderUtils.toIndexFileMetas(
+                                            fileIO,
+                                            indexPath,
+                                            options,
+                                            fragment,
+                                            fields,
+                                            type,
+                                            rewritten,
+                                            sourceMeta)) {
+                                result.add(
+                                        new IndexManifestEntry(
+                                                FileKind.ADD,
+                                                source.partition(),
+                                                source.bucket(),
+                                                rewrittenFile));
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        return result;
+    }
+
+    private static void addFragment(
+            List<Range> fragments, Range fragment, String fileName, long 
firstAssignedRowId) {
+        if (!fragments.isEmpty()) {
+            Range previous = fragments.get(fragments.size() - 1);
+            if (previous.to == fragment.from - 1
+                    && (previous.from >= firstAssignedRowId)
+                            == (fragment.from >= firstAssignedRowId)) {
+                fragments.set(fragments.size() - 1, new Range(previous.from, 
fragment.to));
+                return;
+            }
+        }
+        checkArgument(
+                fragments.size() < MAX_COVERAGE_FRAGMENTS,
+                "Clustering exceeds %s index coverage fragments for %s. "
+                        + "Build smaller, fully covered index shards before 
clustering this range.",
+                MAX_COVERAGE_FRAGMENTS,
+                fileName);
+        fragments.add(fragment);
+    }
+
+    private static GlobalIndexFileReadWrite trackingIndexIO(
+            FileIO fileIO, IndexPathFactory indexPath, List<Path> 
createdFiles) {
+        return new GlobalIndexFileReadWrite(fileIO, indexPath) {
+            @Override
+            public PositionOutputStream newOutputStream(String fileName) 
throws IOException {
+                // Track before opening: failed writes can leave a partial 
file without a result.
+                createdFiles.add(indexPath.toPath(fileName));
+                return super.newOutputStream(fileName);
+            }
+        };
+    }
+
+    private static void readMapping(
+            FileIO fileIO,
+            DataFilePathFactory pathFactory,
+            DataFileMeta file,
+            ClusteringRowIdMapping.RowIdConsumer consumer)
+            throws IOException {
+        ClusteringRowIdMapping.read(
+                fileIO,
+                
pathFactory.toAlignedPath(ClusteringRowIdMapping.fileName(file.fileName()), 
file),
+                file.rowCount(),
+                consumer);
+    }
+
+    /**
+     * Disk-backed inverse permutation; memory is proportional to selected 
ranges, not row count.
+     */
+    private static final class DiskMapping implements Closeable {
+        private final NavigableMap<Long, Pair<Range, Long>> offsets = new 
TreeMap<>();
+        private final File path;
+        private final RandomAccessFile file;
+
+        private DiskMapping(List<Range> ranges, @Nullable IOManager ioManager) 
throws IOException {
+            long rows = 0;
+            for (Range range : ranges) {
+                offsets.put(range.from, Pair.of(range, rows));
+                rows = Math.addExact(rows, range.count());
+            }
+            long length = Math.multiplyExact(rows, Long.BYTES);
+            path =
+                    ioManager == null
+                            ? File.createTempFile("paimon-cluster-row-id-", 
".mapping")
+                            : ioManager.createChannel().getPathFile();
+            RandomAccessFile opened = null;
+            try {
+                opened = new RandomAccessFile(path, "rw");
+                opened.setLength(length);
+                file = opened;
+            } catch (IOException | RuntimeException | Error failure) {
+                if (opened != null) {
+                    try {
+                        opened.close();
+                    } catch (IOException closeFailure) {
+                        failure.addSuppressed(closeFailure);
+                    }
+                }
+                try {
+                    Files.deleteIfExists(path.toPath());
+                } catch (IOException cleanupFailure) {
+                    failure.addSuppressed(cleanupFailure);
+                }
+                throw failure;
+            }
+        }
+
+        @Nullable
+        private Long offset(long oldId) {
+            Map.Entry<Long, Pair<Range, Long>> entry = 
offsets.floorEntry(oldId);
+            if (entry == null || oldId > entry.getValue().getLeft().to) {
+                return null;
+            }
+            return Math.multiplyExact(
+                    entry.getValue().getRight() + oldId - entry.getKey(), 
Long.BYTES);
+        }
+
+        private void put(long oldId, long newId) throws IOException {
+            Long offset = offset(oldId);
+            checkArgument(
+                    offset != null, "Clustering output refers to unselected 
row ID %s", oldId);
+            file.seek(offset);
+            checkArgument(file.readLong() == 0, "Duplicate clustering row ID 
%s", oldId);
+            file.seek(offset);
+            file.writeLong(Math.addExact(newId, 1));
+        }
+
+        private long get(long oldId) throws IOException {
+            Long offset = offset(oldId);
+            if (offset == null) {
+                return oldId;
+            }
+            file.seek(offset);
+            return file.readLong() - 1;
+        }
+
+        @Override
+        public void close() throws IOException {
+            try {
+                file.close();
+            } finally {
+                Files.deleteIfExists(path.toPath());
+            }
+        }
+    }
+}
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 d1573b9412..5ff13ba2f1 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
@@ -22,6 +22,8 @@ import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.Snapshot.CommitKind;
 import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.append.dataevolution.ClusteringIndexRewriter;
+import org.apache.paimon.append.dataevolution.ClusteringRowIdMapping;
 import org.apache.paimon.catalog.SnapshotCommit;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
@@ -888,8 +890,8 @@ public class FileStoreCommitImpl implements FileStoreCommit 
{
                             .collect(Collectors.toList());
 
             // This provider is invoked again after every optimistic-commit 
conflict. Scanning the
-            // latest snapshot here guarantees that an index committed 
concurrently is either
-            // deleted by this attempt or makes this attempt retry and is 
deleted by the next one.
+            // latest snapshot includes concurrently created indexes in this 
attempt's changes.
+            // Clustering can then remap their postings after the final row 
IDs have been assigned.
             if (latestSnapshot != null && latestSnapshot.indexManifest() != 
null) {
                 for (IndexManifestEntry entry :
                         
indexManifestFile.read(latestSnapshot.indexManifest())) {
@@ -1153,6 +1155,23 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         List<ManifestEntry> deltaFiles = changes.tableFiles;
         List<ManifestEntry> changelogFiles = changes.changelogFiles;
         List<IndexManifestEntry> indexFiles = changes.indexFiles;
+        // Side files remain attached to committed data. Only outputs awaiting 
their first row-ID
+        // assignment carry a permutation for this commit; later metadata 
rewrites must ignore it.
+        Set<String> mappedClusteringOutputs =
+                commitKind == CommitKind.COMPACT && 
options.dataEvolutionEnabled()
+                        ? deltaFiles.stream()
+                                .filter(entry -> entry.kind() == FileKind.ADD)
+                                .filter(entry -> entry.file().firstRowId() == 
null)
+                                .filter(
+                                        entry ->
+                                                entry.file()
+                                                        .extraFiles()
+                                                        .contains(
+                                                                
ClusteringRowIdMapping.fileName(
+                                                                        
entry.file().fileName())))
+                                .map(entry -> entry.file().fileName())
+                                .collect(Collectors.toSet())
+                        : Collections.emptySet();
         Set<Long> rebasedReassignments = 
changesProvider.rebasedReassignments();
 
         long newSnapshotId = Snapshot.FIRST_SNAPSHOT_ID;
@@ -1261,6 +1280,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         List<ManifestFileMeta> mergeAfterManifests = new ArrayList<>();
         boolean skipManifestMerge = false;
         long nextRowIdStart = firstRowIdStart;
+        List<Path> clusteringIndexOutputs = new ArrayList<>();
         try {
             long previousTotalRecordCount = 0L;
             Long currentWatermark = watermark;
@@ -1326,6 +1346,23 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                 deltaFiles = assigned.assignedEntries;
             }
 
+            if (commitKind == CommitKind.COMPACT
+                    && options.dataEvolutionEnabled()
+                    && deltaFiles.stream().anyMatch(entry -> entry.kind() == 
FileKind.DELETE)) {
+                indexFiles =
+                        ClusteringIndexRewriter.rewrite(
+                                fileIO,
+                                pathFactory,
+                                schemaManager,
+                                options,
+                                ioManager,
+                                deltaFiles,
+                                indexFiles,
+                                mappedClusteringOutputs,
+                                newSnapshotId,
+                                clusteringIndexOutputs);
+            }
+
             if (options.snapshotSequenceOrdering()) {
                 deltaFiles = stampSequenceWithSnapshotId(newSnapshotId, 
commitKind, deltaFiles);
             }
@@ -1406,6 +1443,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                             operation);
         } catch (Throwable e) {
             // fails when preparing for commit, we should clean up
+            clusteringIndexOutputs.forEach(fileIO::deleteQuietly);
             commitCleaner.cleanUpReuseTmpManifests(
                     deltaManifestList, changelogManifestList, 
oldIndexManifest, indexManifest);
             commitCleaner.cleanUpNoReuseTmpManifests(
@@ -1421,13 +1459,22 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         boolean success;
         final List<SimpleFileEntry> finalBaseFiles = baseDataFiles;
         final List<ManifestEntry> finalDeltaFiles = deltaFiles;
-        commitPreCallbacks.forEach(
-                callback ->
-                        callback.call(finalBaseFiles, finalDeltaFiles, 
indexFiles, newSnapshot));
+        final List<IndexManifestEntry> finalIndexFiles = indexFiles;
+        try {
+            commitPreCallbacks.forEach(
+                    callback ->
+                            callback.call(
+                                    finalBaseFiles, finalDeltaFiles, 
finalIndexFiles, newSnapshot));
+        } catch (RuntimeException | Error failure) {
+            // No snapshot publication has been attempted, so these new 
payloads are unreferenced.
+            clusteringIndexOutputs.forEach(fileIO::deleteQuietly);
+            throw failure;
+        }
         try {
             success = commitSnapshotImpl(latestSnapshot, newSnapshot, 
deltaPartitionEntries);
         } catch (Exception e) {
             // commit exception, not sure about the situation and should not 
clean up the files
+            // This also retains index payloads generated for this possibly 
published candidate.
             LOG.warn(
                     "Retry commit for exception when committing snapshot #{} 
for table {} by user {}.",
                     newSnapshotId,
@@ -1438,6 +1485,8 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
         }
 
         if (!success) {
+            // Some committers can publish the snapshot and still report 
false. Retain the index
+            // payloads together with the manifests until the next attempt 
reconciles publication.
             long commitTime = (System.currentTimeMillis() - startMillis) / 
1000;
             LOG.warn(
                     "Atomic commit failed for snapshot #{} for table {} by 
user {} "
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
index 51e79a1c31..48b60d3ab8 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionClusterTest.java
@@ -35,13 +35,17 @@ import 
org.apache.paimon.deletionvectors.BitmapDeletionVector;
 import org.apache.paimon.deletionvectors.DeletionVector;
 import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
 import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.globalindex.DataEvolutionGlobalIndexScanner;
 import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.GlobalIndexResult;
 import org.apache.paimon.globalindex.sorted.SortedGlobalIndexScanner;
 import org.apache.paimon.globalindex.sorted.SortedGlobalIndexTestUtils;
+import org.apache.paimon.index.DataEvolutionIndexSourceMeta;
 import org.apache.paimon.index.GlobalIndexMeta;
 import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
 import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.manifest.FileKind;
 import org.apache.paimon.manifest.IndexManifestEntry;
@@ -49,6 +53,8 @@ import org.apache.paimon.operation.Lock;
 import org.apache.paimon.operation.commit.RowIdExistenceConflictException;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.reader.RecordReaderIterator;
 import org.apache.paimon.schema.Schema;
@@ -69,13 +75,16 @@ import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.MutableObjectIterator;
 import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.ProjectedRow;
 import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.SnapshotManager;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -83,6 +92,7 @@ import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Random;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
@@ -415,6 +425,689 @@ class DataEvolutionClusterTest extends TableTestBase {
                 .containsExactly("a|10|base-10|3", "a|20|base-20|4", 
"a|30|base-30|5");
     }
 
+    @ParameterizedTest
+    @CsvSource({"btree,false", "btree,true", "bitmap,false", "bitmap,true"})
+    void testClusterRemapsScalarIndexPostings(String indexType, boolean 
deletionVectors)
+            throws Exception {
+        FileStoreTable table = createTable(deletionVectors);
+        appendIndexRows(
+                table,
+                new int[] {30, 10, 20, 40, 50},
+                new String[] {"x", "x", null, "deleted", "y"});
+        commit(
+                table,
+                prepareGlobalIndex(
+                        table, table.latestSnapshot().get(), "payload", 
indexType, null));
+        if (deletionVectors) {
+            delete(table, "a", new Range(0, 4), 3L);
+        }
+        table = 
table.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_ROW_NUM.key(), 
"2"));
+        Snapshot snapshot = table.latestSnapshot().get();
+        commitCluster(table, snapshot, prepareMappedCluster(table, snapshot, 
true));
+
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int payload = table.rowType().getFieldIndex("payload");
+        assertIndexRows(
+                table,
+                predicates.equal(payload, BinaryString.fromString("x")),
+                new Range(5, 5),
+                new Range(7, 7));
+        assertIndexRows(table, predicates.isNull(payload), new Range(6, 6));
+        assertIndexRows(
+                table,
+                predicates.equal(payload, BinaryString.fromString("y")),
+                new Range(deletionVectors ? 8 : 9, deletionVectors ? 8 : 9));
+        assertIndexRows(
+                table,
+                predicates.equal(payload, BinaryString.fromString("deleted")),
+                deletionVectors ? new Range[0] : new Range[] {new Range(8, 
8)});
+        assertThat(readRows(table))
+                .containsExactlyElementsOf(
+                        deletionVectors
+                                ? Arrays.asList("a|10|x|5", "a|20|null|6", 
"a|30|x|7", "a|50|y|8")
+                                : Arrays.asList(
+                                        "a|10|x|5",
+                                        "a|20|null|6",
+                                        "a|30|x|7",
+                                        "a|40|deleted|8",
+                                        "a|50|y|9"));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"btree", "bitmap"})
+    void testClusterPreservesExactPartialIndexCoverage(String indexType) 
throws Exception {
+        FileStoreTable table = createTable(false);
+        append(table, "a", 90, 10, 50);
+        append(table, "a", 80, 20, 40);
+        commit(
+                table,
+                prepareGlobalIndex(
+                        table, table.latestSnapshot().get(), "id", indexType, 
new Range(0, 2)));
+        table = 
table.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_ROW_NUM.key(), 
"2"));
+        Snapshot snapshot = table.latestSnapshot().get();
+        commitCluster(table, snapshot, prepareMappedCluster(table, snapshot, 
true));
+
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        assertIndexRows(table, predicates.equal(id, 10), new Range(6, 6));
+        assertIndexRows(table, predicates.equal(id, 50), new Range(9, 9));
+        assertIndexRows(table, predicates.equal(id, 90), new Range(11, 11));
+        assertIndexRows(table, predicates.equal(id, 20));
+        List<Range> coverage =
+                table.store().newIndexFileHandler().scanEntries().stream()
+                        .filter(entry -> entry.indexFile().globalIndexMeta() 
!= null)
+                        .map(entry -> 
entry.indexFile().globalIndexMeta().rowRange())
+                        .collect(Collectors.toList());
+        assertThat(Range.sortAndMergeOverlap(coverage, true))
+                .containsExactly(new Range(6, 6), new Range(9, 9), new 
Range(11, 11));
+        FileStoreTable detail =
+                table.copy(
+                        Collections.singletonMap(
+                                CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(), 
"detail"));
+        assertThat(readRows(detail, predicates.equal(id, 
20))).containsExactly("a|20|base-20|7");
+        assertThat(readRows(detail, predicates.equal(id, 
80))).containsExactly("a|80|base-80|10");
+    }
+
+    @Test
+    void testClusterRejectsTooManyIndexCoverageFragments() throws Exception {
+        FileStoreTable table = createTable(false);
+        int[] indexed = new int[65];
+        int[] unindexed = new int[65];
+        for (int i = 0; i < indexed.length; i++) {
+            indexed[i] = i * 2 + 1;
+            unindexed[i] = i * 2 + 2;
+        }
+        append(table, "a", indexed);
+        append(table, "a", unindexed);
+        commit(
+                table,
+                prepareGlobalIndex(
+                        table, table.latestSnapshot().get(), "id", "btree", 
new Range(0, 64)));
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<String> beforeFiles = fileNames(table, "a");
+        List<String> beforeIndexes = globalIndexNames(table);
+        List<String> beforeRows = readRows(table);
+        List<CommitMessage> messages = prepareMappedCluster(table, snapshot, 
true);
+        assertThat(messages).hasSize(1);
+        assertThat(((CommitMessageImpl) 
messages.get(0)).compactIncrement().compactAfter())
+                .hasSize(1);
+
+        // One indexed row alternates with one unindexed row: 65 exact 
coverage fragments.
+        assertThatThrownBy(() -> commitCluster(table, snapshot, messages))
+                .isInstanceOf(RuntimeException.class)
+                .hasStackTraceContaining("coverage fragments")
+                .hasStackTraceContaining("64");
+        assertThat(table.latestSnapshot().get().id()).isEqualTo(snapshot.id());
+        assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(130L);
+        assertThat(fileNames(table, "a")).isEqualTo(beforeFiles);
+        assertThat(globalIndexNames(table)).isEqualTo(beforeIndexes);
+        assertThat(readRows(table)).isEqualTo(beforeRows);
+        assertIndexRows(
+                table,
+                new 
PredicateBuilder(table.rowType()).equal(table.rowType().getFieldIndex("id"), 1),
+                new Range(0, 0));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"btree", "bitmap"})
+    void 
testClusterCombinesFullyIndexedSourceRangesBeforeCountingFragments(String 
indexType)
+            throws Exception {
+        FileStoreTable table = createTable(false);
+        int[] odd = new int[65];
+        int[] even = new int[65];
+        for (int i = 0; i < odd.length; i++) {
+            odd[i] = i * 2 + 1;
+            even[i] = i * 2 + 2;
+        }
+        append(table, "a", odd);
+        append(table, "a", even);
+        Snapshot indexed = table.latestSnapshot().get();
+        List<CommitMessage> indexes =
+                new ArrayList<>(
+                        prepareGlobalIndex(table, indexed, "id", indexType, 
new Range(0, 64)));
+        indexes.addAll(prepareGlobalIndex(table, indexed, "id", indexType, new 
Range(65, 129)));
+        commit(table, indexes);
+        assertThat(globalIndexNames(table)).hasSize(2);
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<CommitMessage> messages = prepareMappedCluster(table, snapshot, 
true);
+        assertThat(messages).hasSize(1);
+        assertThat(((CommitMessageImpl) 
messages.get(0)).compactIncrement().compactAfter())
+                .hasSize(1);
+
+        // Each old range maps to 65 alternating positions, but their union 
has no coverage gap.
+        commitCluster(table, snapshot, messages);
+
+        assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(260L);
+        assertThat(
+                        
table.store().newIndexFileHandler().scanEntries().stream()
+                                .filter(entry -> 
entry.indexFile().globalIndexMeta() != null)
+                                .map(entry -> 
entry.indexFile().globalIndexMeta().rowRange()))
+                .isNotEmpty()
+                .allSatisfy(range -> assertThat(range).isEqualTo(new 
Range(130, 259)));
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        List<String> expectedRows = new ArrayList<>();
+        for (int value = 1; value <= 130; value++) {
+            long rowId = 129L + value;
+            assertIndexRows(table, predicates.equal(id, value), new 
Range(rowId, rowId));
+            expectedRows.add("a|" + value + "|base-" + value + "|" + rowId);
+        }
+        assertIndexRows(table, predicates.equal(id, 0));
+        assertIndexRows(table, predicates.isNull(id));
+        assertThat(readRows(table)).containsExactlyElementsOf(expectedRows);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testMinorClusterRemapsIndexesAcrossDisjointRanges(boolean 
deletionVectors)
+            throws Exception {
+        FileStoreTable table = createMinorClusterTable(deletionVectors);
+        List<String> untouchedRows = rowsInRange(table, new Range(12, 14));
+        List<String> untouchedFiles = filesInRange(table, new Range(12, 14));
+        commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(), 
"id"));
+        List<String> oldIndexFiles = globalIndexNames(table);
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<CommitMessage> messages = prepareMinorCluster(table, snapshot);
+        messages =
+                IncrementalClusterRewriter.create(table, 
snapshot).prepareCommitMessages(messages);
+        List<CommitMessage> preparedGapIndex =
+                prepareGlobalIndex(table, snapshot, "payload", "bitmap", new 
Range(12, 14));
+        commit(table, prepareGlobalIndex(table, snapshot, "payload"));
+        commitChecked(table, messages);
+
+        
assertThat(globalIndexNames(table)).isNotEmpty().doesNotContainAnyElementsOf(oldIndexFiles);
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        int payload = table.rowType().getFieldIndex("payload");
+        assertIndexRows(table, predicates.equal(id, 40), new Range(12, 12));
+        assertIndexRows(table, predicates.equal(id, 50), new Range(13, 13));
+        assertIndexRows(table, predicates.equal(id, 60), new Range(14, 14));
+        long seventy = deletionVectors ? 23 : 24;
+        assertIndexRows(table, predicates.equal(id, 70), new Range(seventy, 
seventy));
+        assertIndexRows(
+                table,
+                predicates.equal(payload, 
BinaryString.fromString("updated-70")),
+                new Range(seventy, seventy));
+        assertMinorClusterResult(table, deletionVectors, untouchedRows, 
untouchedFiles, 21L, 4);
+
+        // A pre-clustering index can still commit for an unchanged row-ID 
group.
+        commit(table, preparedGapIndex);
+        assertIndexRows(table, predicates.equal(id, 40), new Range(12, 12));
+        assertMinorClusterResult(table, deletionVectors, untouchedRows, 
untouchedFiles, 21L, 4);
+    }
+
+    @Test
+    void testMinorClusterRemapsIndexWhenAllSelectedRowsAreDeleted() throws 
Exception {
+        FileStoreTable table = createMinorClusterTable(true);
+        List<String> untouchedRows = rowsInRange(table, new Range(12, 14));
+        List<String> untouchedFiles = filesInRange(table, new Range(12, 14));
+        commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(), 
"id"));
+        delete(table, "a", new Range(9, 11), 9L, 10L, 11L);
+        delete(table, "a", new Range(15, 17), 15L, 16L, 17L);
+        delete(table, "a", new Range(18, 20), 18L, 19L, 20L);
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<CommitMessage> messages = prepareMinorCluster(table, snapshot);
+        assertThat(messages)
+                .allSatisfy(
+                        message ->
+                                assertThat(
+                                                ((CommitMessageImpl) message)
+                                                        .compactIncrement()
+                                                        .compactAfter())
+                                        .isEmpty());
+        commitCluster(table, snapshot, messages);
+
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        assertIndexRows(table, predicates.equal(id, 40), new Range(12, 12));
+        assertIndexRows(table, predicates.equal(id, 20));
+        assertIndexRows(table, predicates.equal(id, 100));
+        assertThat(
+                        
table.store().newIndexFileHandler().scanEntries().stream()
+                                .filter(entry -> 
entry.indexFile().globalIndexMeta() != null)
+                                .map(entry -> 
entry.indexFile().globalIndexMeta().rowRange()))
+                .containsExactly(new Range(12, 14));
+        assertThat(readRows(table)).containsExactlyElementsOf(untouchedRows);
+        assertThat(fileNames(table, 
"a")).containsExactlyElementsOf(untouchedFiles);
+        assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(21L);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testMinorClusterRetryRemapsConcurrentIndexAfterAppend(boolean 
deletionVectors)
+            throws Exception {
+        Map<String, String> retryOptions = new HashMap<>();
+        retryOptions.put(CoreOptions.COMMIT_MIN_RETRY_WAIT.key(), "0ms");
+        retryOptions.put(CoreOptions.COMMIT_MAX_RETRY_WAIT.key(), "0ms");
+        FileStoreTable original = 
createMinorClusterTable(deletionVectors).copy(retryOptions);
+        List<String> untouchedRows = rowsInRange(original, new Range(12, 14));
+        List<String> untouchedFiles = filesInRange(original, new Range(12, 
14));
+        commit(original, prepareGlobalIndex(original, 
original.latestSnapshot().get(), "id"));
+        Snapshot scanSnapshot = original.latestSnapshot().get();
+        List<CommitMessage> messages = prepareMinorCluster(original, 
scanSnapshot);
+        AtomicInteger attempts = new AtomicInteger();
+        CatalogEnvironment environment =
+                new CatalogEnvironment(null, null, null, null, null, null, 
false, false) {
+                    @Override
+                    public SnapshotCommit snapshotCommit(
+                            SnapshotManager manager, CoreOptions options, 
String commitUser) {
+                        SnapshotCommit delegate = new 
RenamingSnapshotCommit(manager, Lock.empty());
+                        return new SnapshotCommit(Lock.empty()) {
+                            @Override
+                            protected boolean commitImpl(
+                                    String baseUuid,
+                                    Snapshot snapshot,
+                                    String branch,
+                                    List<PartitionStatistics> statistics)
+                                    throws Exception {
+                                assertThat(snapshot.commitKind())
+                                        
.isEqualTo(Snapshot.CommitKind.COMPACT);
+                                int attempt = attempts.incrementAndGet();
+                                assertThat(
+                                                original.store()
+                                                        
.indexManifestFileFactory()
+                                                        .create()
+                                                        
.read(snapshot.indexManifest()))
+                                        .isNotEmpty();
+                                if (attempt == 1) {
+                                    
assertThat(original.latestSnapshot().get().id())
+                                            .isEqualTo(scanSnapshot.id());
+                                    DataEvolutionClusterTest.this.commit(
+                                            original,
+                                            prepareGlobalIndex(original, 
scanSnapshot, "payload"));
+                                    append(original, "a", 200, 180, 190);
+                                }
+                                boolean committed =
+                                        delegate.commit(baseUuid, snapshot, 
branch, statistics);
+                                assertThat(committed).isEqualTo(attempt == 2);
+                                return committed;
+                            }
+
+                            @Override
+                            protected void closeImpl() throws Exception {
+                                delegate.close();
+                            }
+                        };
+                    }
+                };
+        FileStoreTable committingTable =
+                FileStoreTableFactory.create(
+                        original.fileIO(), original.location(), 
original.schema(), environment);
+        commitCluster(committingTable, scanSnapshot, messages);
+
+        assertThat(attempts).hasValue(2);
+        
assertThat(original.latestSnapshot().get().id()).isEqualTo(scanSnapshot.id() + 
3);
+        long seventy = deletionVectors ? 26 : 27;
+        PredicateBuilder predicates = new PredicateBuilder(original.rowType());
+        assertIndexRows(
+                original,
+                predicates.equal(original.rowType().getFieldIndex("id"), 70),
+                new Range(seventy, seventy));
+        assertIndexRows(
+                original,
+                predicates.equal(
+                        original.rowType().getFieldIndex("payload"),
+                        BinaryString.fromString("updated-70")),
+                new Range(seventy, seventy));
+        untouchedRows.addAll(
+                Arrays.asList("a|200|base-200|21", "a|180|base-180|22", 
"a|190|base-190|23"));
+        assertMinorClusterResult(original, deletionVectors, untouchedRows, 
untouchedFiles, 24L, 5);
+        FileStoreTable detail =
+                original.copy(
+                        Collections.singletonMap(
+                                CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(), 
"detail"));
+        assertThat(readRows(detail, 
predicates.equal(original.rowType().getFieldIndex("id"), 180)))
+                .containsExactly("a|180|base-180|22");
+    }
+
+    @Test
+    void testClusterDoesNotMakeStaleIndexKeysFresh() throws Exception {
+        FileStoreTable table =
+                createTable(false)
+                        .copy(
+                                Collections.singletonMap(
+                                        
CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(),
+                                        "ignore"));
+        append(table, "a", 30, 10, 20);
+        Snapshot indexed = table.latestSnapshot().get();
+        List<CommitMessage> indexes =
+                new ArrayList<>(prepareGlobalIndex(table, indexed, "payload"));
+        indexes.addAll(prepareGlobalIndex(table, indexed, "id"));
+        commit(table, indexes);
+        updatePayload(table, "a", 0L, 30, 10, 20);
+        Snapshot snapshot = table.latestSnapshot().get();
+        commitCluster(table, snapshot, prepareMappedCluster(table, snapshot, 
true));
+
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        assertIndexRows(
+                table, predicates.equal(table.rowType().getFieldIndex("id"), 
20), new Range(4, 4));
+        assertThat(
+                        
table.store().newIndexFileHandler().scanEntries().stream()
+                                .filter(entry -> 
entry.indexFile().globalIndexMeta() != null)
+                                .flatMap(
+                                        entry ->
+                                                
entry.indexFile().globalIndexMeta()
+                                                        
.getIndexedFieldIds().stream()))
+                .doesNotContain(table.rowType().getField("payload").id());
+        FileStoreTable detail =
+                table.copy(
+                        Collections.singletonMap(
+                                CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(), 
"detail"));
+        assertThat(
+                        readRows(
+                                detail,
+                                predicates.equal(
+                                        
table.rowType().getFieldIndex("payload"),
+                                        
BinaryString.fromString("updated-20"))))
+                .containsExactly("a|20|updated-20|4");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testClusterKeepsIndexPayloadsAfterUncertainPublication(boolean 
publicationThrows)
+            throws Exception {
+        FileStoreTable original = createTable(false);
+        append(original, "a", 30, 10, 20);
+        commit(original, prepareGlobalIndex(original, 
original.latestSnapshot().get(), "id"));
+        Snapshot scanSnapshot = original.latestSnapshot().get();
+        List<CommitMessage> messages = prepareMappedCluster(original, 
scanSnapshot, true);
+        AtomicInteger publications = new AtomicInteger();
+        CatalogEnvironment environment =
+                new CatalogEnvironment(null, null, null, null, null, null, 
false, false) {
+                    @Override
+                    public SnapshotCommit snapshotCommit(
+                            SnapshotManager manager, CoreOptions options, 
String commitUser) {
+                        SnapshotCommit delegate = new 
RenamingSnapshotCommit(manager, Lock.empty());
+                        return new SnapshotCommit(Lock.empty()) {
+                            @Override
+                            protected boolean commitImpl(
+                                    String baseUuid,
+                                    Snapshot snapshot,
+                                    String branch,
+                                    List<PartitionStatistics> statistics)
+                                    throws Exception {
+                                publications.incrementAndGet();
+                                assertThat(delegate.commit(baseUuid, snapshot, 
branch, statistics))
+                                        .isTrue();
+                                if (publicationThrows) {
+                                    throw new IOException("Publication 
response lost");
+                                }
+                                return false;
+                            }
+
+                            @Override
+                            protected void closeImpl() throws Exception {
+                                delegate.close();
+                            }
+                        };
+                    }
+                };
+        FileStoreTable committingTable =
+                FileStoreTableFactory.create(
+                        original.fileIO(), original.location(), 
original.schema(), environment);
+        commitCluster(committingTable, scanSnapshot, messages);
+
+        assertThat(publications).hasValue(1);
+        
assertThat(original.latestSnapshot().get().id()).isEqualTo(scanSnapshot.id() + 
1);
+        assertThat(original.latestSnapshot().get().nextRowId()).isEqualTo(6L);
+        List<IndexFileMeta> indexes =
+                original.store().newIndexFileHandler().scanEntries().stream()
+                        .map(IndexManifestEntry::indexFile)
+                        .filter(file -> file.globalIndexMeta() != null)
+                        .collect(Collectors.toList());
+        assertThat(indexes).isNotEmpty();
+        for (IndexFileMeta index : indexes) {
+            assertThat(
+                            original.fileIO()
+                                    .exists(
+                                            original.store()
+                                                    .pathFactory()
+                                                    .globalIndexFileFactory()
+                                                    .toPath(index)))
+                    .isTrue();
+        }
+        PredicateBuilder predicates = new PredicateBuilder(original.rowType());
+        int id = original.rowType().getFieldIndex("id");
+        assertIndexRows(original, predicates.equal(id, 10), new Range(3, 3));
+        assertIndexRows(original, predicates.equal(id, 20), new Range(4, 4));
+        assertIndexRows(original, predicates.equal(id, 30), new Range(5, 5));
+        assertThat(readRows(original))
+                .containsExactly("a|10|base-10|3", "a|20|base-20|4", 
"a|30|base-30|5");
+    }
+
+    @Test
+    void testClusterRemapsCompositeBTreeIndex() throws Exception {
+        FileStoreTable table = createTable(false);
+        appendIndexRows(table, new int[] {30, 10, 20, 40}, new String[] {"x", 
"x", null, "y"});
+        Snapshot indexed = table.latestSnapshot().get();
+        List<String> fields = Arrays.asList("id", "payload");
+        List<CommitMessage> indexes = new ArrayList<>();
+        for (DataSplit split :
+                GlobalIndexBuilderUtils.splitByContiguousRowRange(
+                        new SortedGlobalIndexScanner(table, "btree")
+                                .withIndexFields(fields)
+                                .withSnapshot(indexed)
+                                .scan()
+                                .get()
+                                .entries())) {
+            indexes.addAll(
+                    SortedGlobalIndexTestUtils.buildIndex(
+                            table, "btree", fields, split, indexed.id()));
+        }
+        assertThat(indexes).isNotEmpty();
+        commit(table, indexes);
+        table = 
table.copy(Collections.singletonMap(CoreOptions.TARGET_FILE_ROW_NUM.key(), 
"2"));
+        Snapshot snapshot = table.latestSnapshot().get();
+        commitCluster(table, snapshot, prepareMappedCluster(table, snapshot, 
true));
+
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        int payload = table.rowType().getFieldIndex("payload");
+        assertIndexRows(
+                table,
+                PredicateBuilder.and(
+                        predicates.equal(id, 10),
+                        predicates.equal(payload, 
BinaryString.fromString("x"))),
+                new Range(4, 4));
+        assertIndexRows(
+                table,
+                PredicateBuilder.and(predicates.equal(id, 20), 
predicates.isNull(payload)),
+                new Range(5, 5));
+        assertIndexRows(
+                table,
+                PredicateBuilder.and(
+                        predicates.equal(id, 30),
+                        predicates.equal(payload, 
BinaryString.fromString("x"))),
+                new Range(6, 6));
+        assertIndexRows(
+                table,
+                PredicateBuilder.and(
+                        predicates.equal(id, 20),
+                        predicates.equal(payload, 
BinaryString.fromString("y"))));
+    }
+
+    @Test
+    void testClusterPreservesIndexOnlyDeleteForAnotherPartition() throws 
Exception {
+        FileStoreTable table = createTable(false);
+        append(table, "a", 30, 10, 20);
+        append(table, "b", 60, 40, 50);
+        commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(), 
"id"));
+        List<String> untouchedFiles = fileNames(table, "b");
+        List<IndexFileMeta> deletedIndexes =
+                table.store().newIndexFileHandler().scanEntries().stream()
+                        .filter(entry -> 
entry.partition().equals(partition(table, "b")))
+                        .map(IndexManifestEntry::indexFile)
+                        .filter(file -> file.globalIndexMeta() != null)
+                        .collect(Collectors.toList());
+        assertThat(deletedIndexes).hasSize(1);
+        Snapshot snapshot = table.latestSnapshot().get();
+        IncrementalClusterManager manager =
+                new IncrementalClusterManager(table, partitionPredicate(table, 
"a"), snapshot);
+        Map<BinaryRow, CompactUnit> units = manager.createCompactUnits(true);
+        assertThat(units).containsOnlyKeys(partition(table, "a"));
+        List<CommitMessage> messages =
+                manager.prepareCommitMessages(rewriteClusterUnits(table, 
manager, units, true));
+        messages.add(
+                new CommitMessageImpl(
+                                partition(table, "b"),
+                                UNAWARE_BUCKET,
+                                null,
+                                DataIncrement.emptyIncrement(),
+                                new CompactIncrement(
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        deletedIndexes))
+                        .withCheckFromSnapshot(snapshot.id()));
+        commitChecked(table, messages);
+
+        assertThat(table.store().newIndexFileHandler().scanEntries())
+                .allSatisfy(
+                        entry -> 
assertThat(entry.partition()).isEqualTo(partition(table, "a")));
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        assertIndexRows(table, predicates.equal(id, 10), new Range(6, 6));
+        assertIndexRows(table, predicates.equal(id, 20), new Range(7, 7));
+        assertIndexRows(table, predicates.equal(id, 30), new Range(8, 8));
+        assertIndexRows(table, predicates.equal(id, 40));
+        assertThat(fileNames(table, "b")).isEqualTo(untouchedFiles);
+        assertThat(readRows(table))
+                .containsExactly(
+                        "b|60|base-60|3",
+                        "b|40|base-40|4",
+                        "b|50|base-50|5",
+                        "a|10|base-10|6",
+                        "a|20|base-20|7",
+                        "a|30|base-30|8");
+    }
+
+    @Test
+    void testAdjacentIndexCoverageKeepsSeparateFreshnessBaselines() throws 
Exception {
+        FileStoreTable table = createTable(false);
+        append(table, "a", 30, 10, 20);
+        append(table, "a", 60, 40, 50);
+        Snapshot indexed = table.latestSnapshot().get();
+        commit(table, prepareGlobalIndex(table, indexed, "id"));
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<DataFileMeta> selected =
+                
table.store().newScan().withSnapshot(snapshot).plan().files().stream()
+                        .map(entry -> entry.file())
+                        .filter(file -> file.nonNullRowIdRange().equals(new 
Range(0, 2)))
+                        .collect(Collectors.toList());
+        assertThat(selected).hasSize(1);
+        List<String> untouchedFiles = filesInRange(table, new Range(3, 5));
+        IncrementalClusterManager manager = new 
IncrementalClusterManager(table, null, snapshot);
+        Map<BinaryRow, CompactUnit> units =
+                Collections.singletonMap(
+                        partition(table, "a"), CompactUnit.fromFiles(1, 
selected, true));
+        commitCluster(table, snapshot, rewriteClusterUnits(table, manager, 
units, true));
+
+        Snapshot clustered = table.latestSnapshot().get();
+        Map<Range, Long> baselines =
+                table.store().newIndexFileHandler().scanEntries().stream()
+                        .map(IndexManifestEntry::indexFile)
+                        .filter(file -> file.globalIndexMeta() != null)
+                        .collect(
+                                Collectors.toMap(
+                                        file -> 
file.globalIndexMeta().rowRange(),
+                                        file ->
+                                                
DataEvolutionIndexSourceMeta.fromIndexFile(file)
+                                                        .scanSnapshotId()));
+        assertThat(baselines)
+                .hasSize(2)
+                .containsEntry(new Range(3, 5), indexed.id())
+                .containsEntry(new Range(6, 8), clustered.id());
+        assertThat(filesInRange(table, new Range(3, 
5))).isEqualTo(untouchedFiles);
+        PredicateBuilder predicates = new PredicateBuilder(table.rowType());
+        int id = table.rowType().getFieldIndex("id");
+        assertIndexRows(table, predicates.equal(id, 10), new Range(6, 6));
+        assertIndexRows(table, predicates.equal(id, 60), new Range(3, 3));
+
+        append(table, "a", 90, 70, 80);
+        Snapshot next = table.latestSnapshot().get();
+        commitCluster(table, next, prepareMappedCluster(table, next, true));
+
+        // Neither the retained coverage nor the first rewrite became stale on 
the second pass.
+        assertIndexRows(table, predicates.equal(id, 10), new Range(12, 12));
+        assertIndexRows(table, predicates.equal(id, 20), new Range(13, 13));
+        assertIndexRows(table, predicates.equal(id, 40), new Range(15, 15));
+        assertIndexRows(table, predicates.equal(id, 60), new Range(17, 17));
+        assertIndexRows(table, predicates.equal(id, 70));
+        assertThat(readRows(table))
+                .containsExactly(
+                        "a|10|base-10|12",
+                        "a|20|base-20|13",
+                        "a|30|base-30|14",
+                        "a|40|base-40|15",
+                        "a|50|base-50|16",
+                        "a|60|base-60|17",
+                        "a|70|base-70|18",
+                        "a|80|base-80|19",
+                        "a|90|base-90|20");
+    }
+
+    @Test
+    void testMetadataOnlyCompactDoesNotReplayOldClusteringMapping() throws 
Exception {
+        FileStoreTable table = createTable(false);
+        append(table, "a", 30, 10, 20);
+        commit(table, prepareGlobalIndex(table, table.latestSnapshot().get(), 
"id"));
+        Snapshot snapshot = table.latestSnapshot().get();
+        List<DataFileMeta> initialFiles =
+                
table.store().newScan().withSnapshot(snapshot).plan().files().stream()
+                        .map(entry -> entry.file())
+                        .collect(Collectors.toList());
+        assertThat(initialFiles).hasSize(1);
+        IncrementalClusterManager manager = new 
IncrementalClusterManager(table, null, snapshot);
+        Map<BinaryRow, CompactUnit> units =
+                Collections.singletonMap(
+                        partition(table, "a"), CompactUnit.fromFiles(1, 
initialFiles, true));
+        commitCluster(table, snapshot, rewriteClusterUnits(table, manager, 
units, true));
+
+        Snapshot clustered = table.latestSnapshot().get();
+        DataFileMeta clusteredFile =
+                
table.store().newScan().withSnapshot(clustered).plan().files().get(0).file();
+        assertThat(clusteredFile.nonNullRowIdRange()).isEqualTo(new Range(3, 
5));
+        
assertThat(clusteredFile.extraFiles()).anyMatch(ClusteringRowIdMapping::isMappingFile);
+        List<IndexFileMeta> deletedIndexes =
+                table.store().newIndexFileHandler().scanEntries().stream()
+                        .map(IndexManifestEntry::indexFile)
+                        .filter(file -> file.globalIndexMeta() != null)
+                        .collect(Collectors.toList());
+        assertThat(deletedIndexes).isNotEmpty();
+        commit(
+                table,
+                new CommitMessageImpl(
+                        partition(table, "a"),
+                        UNAWARE_BUCKET,
+                        null,
+                        DataIncrement.emptyIncrement(),
+                        new CompactIncrement(
+                                Collections.singletonList(clusteredFile),
+                                
Collections.singletonList(clusteredFile.upgrade(2)),
+                                Collections.emptyList(),
+                                Collections.emptyList(),
+                                deletedIndexes)));
+
+        assertThat(table.latestSnapshot().get().id()).isEqualTo(clustered.id() 
+ 1);
+        assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(6L);
+        assertThat(fileNames(table, 
"a")).containsExactly(clusteredFile.fileName());
+        DataFileMeta upgraded =
+                table.store()
+                        .newScan()
+                        .withSnapshot(table.latestSnapshot().get())
+                        .plan()
+                        .files()
+                        .get(0)
+                        .file();
+        assertThat(upgraded.level()).isEqualTo(2);
+        assertThat(upgraded.nonNullRowIdRange()).isEqualTo(new Range(3, 5));
+        
assertThat(upgraded.extraFiles()).isEqualTo(clusteredFile.extraFiles());
+        assertThat(globalIndexNames(table)).isEmpty();
+        assertThat(readRows(table))
+                .containsExactly("a|10|base-10|3", "a|20|base-20|4", 
"a|30|base-30|5");
+    }
+
     @ParameterizedTest
     @ValueSource(booleans = {false, true})
     void testClusterRetryDropsIndexCommittedBeforeSnapshotPublication(boolean 
deletionVectors)
@@ -589,6 +1282,119 @@ class DataEvolutionClusterTest extends TableTestBase {
         return getTableDefault();
     }
 
+    private FileStoreTable createMinorClusterTable(boolean deletionVectors) 
throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.TARGET_FILE_ROW_NUM.key(), "3");
+        options.put(CoreOptions.FILE_COMPRESSION.key(), "none");
+        
options.put(CoreOptions.COMPACTION_MAX_SIZE_AMPLIFICATION_PERCENT.key(), 
"10000");
+        options.put(CoreOptions.COMPACTION_SIZE_RATIO.key(), "0");
+        FileStoreTable table = createTable(deletionVectors).copy(options);
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite();
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int id : new int[] {90, 10, 60, 30, 80, 40, 20, 70, 50}) {
+                Random random = new Random(id);
+                int length = id >= 40 && id <= 60 ? 262144 : 8192;
+                StringBuilder payload = new StringBuilder(length);
+                for (int i = 0; i < length; i++) {
+                    payload.append((char) ('a' + random.nextInt(26)));
+                }
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString("a"),
+                                id,
+                                BinaryString.fromString(payload.toString())));
+            }
+            commit.commit(write.prepareCommit());
+        }
+        Snapshot initial = table.latestSnapshot().get();
+        commitCluster(table, initial, prepareCluster(table, initial, null, 
true));
+        List<DataFileMeta> clusteredFiles =
+                
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+                        .stream()
+                        .map(entry -> entry.file())
+                        
.sorted(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId))
+                        .collect(Collectors.toList());
+        assertThat(clusteredFiles)
+                .extracting(DataFileMeta::nonNullRowIdRange)
+                .containsExactly(new Range(9, 11), new Range(12, 14), new 
Range(15, 17));
+        assertThat(clusteredFiles.get(1).fileSize())
+                .isGreaterThan(
+                        (clusteredFiles.get(0).fileSize() + 
clusteredFiles.get(2).fileSize()) * 8);
+
+        updatePayload(table, "a", 9L, 10, 20, 30);
+        updatePayload(table, "a", 15L, 70, 80, 90);
+        append(table, "a", 120, 100, 110);
+        if (deletionVectors) {
+            delete(table, "a", new Range(9, 11), 9L);
+        }
+        return table;
+    }
+
+    private List<CommitMessage> prepareMinorCluster(FileStoreTable table, 
Snapshot snapshot)
+            throws Exception {
+        IncrementalClusterManager manager = new 
IncrementalClusterManager(table, null, snapshot);
+        Map<BinaryRow, CompactUnit> units = manager.createCompactUnits(false);
+        assertThat(units).hasSize(1);
+        List<DataFileMeta> selected = units.get(partition(table, "a")).files();
+        assertThat(selected)
+                .extracting(DataFileMeta::nonNullRowIdRange)
+                .containsExactlyInAnyOrder(
+                        new Range(9, 11),
+                        new Range(9, 11),
+                        new Range(15, 17),
+                        new Range(15, 17),
+                        new Range(18, 20));
+        assertThat(selected)
+                .extracting(DataFileMeta::fileName)
+                .doesNotContainAnyElementsOf(filesInRange(table, new Range(12, 
14)));
+        return rewriteClusterUnits(table, manager, units, true);
+    }
+
+    private void assertMinorClusterResult(
+            FileStoreTable table,
+            boolean deletionVectors,
+            List<String> untouchedRows,
+            List<String> untouchedFiles,
+            long firstAssignedRowId,
+            int expectedFileCount)
+            throws Exception {
+        List<String> expected = new ArrayList<>(untouchedRows);
+        long rowId = firstAssignedRowId;
+        for (int id : new int[] {10, 20, 30, 70, 80, 90, 100, 110, 120}) {
+            if (deletionVectors && id == 10) {
+                continue;
+            }
+            expected.add("a|" + id + "|" + (id < 100 ? "updated-" : "base-") + 
id + "|" + rowId++);
+        }
+        assertThat(readRows(table)).containsExactlyElementsOf(expected);
+        assertThat(table.latestSnapshot().get().nextRowId()).isEqualTo(rowId);
+        assertThat(filesInRange(table, new Range(12, 14)))
+                .containsExactlyElementsOf(untouchedFiles);
+        assertThat(fileNames(table, 
"a")).hasSize(expectedFileCount).containsAll(untouchedFiles);
+    }
+
+    private List<String> rowsInRange(FileStoreTable table, Range range) throws 
Exception {
+        return readRows(table).stream()
+                .filter(
+                        row -> {
+                            long rowId = 
Long.parseLong(row.substring(row.lastIndexOf('|') + 1));
+                            return rowId >= range.from && rowId <= range.to;
+                        })
+                .collect(Collectors.toList());
+    }
+
+    private List<String> filesInRange(FileStoreTable table, Range range) {
+        return 
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
+                .stream()
+                .filter(entry -> entry.partition().equals(partition(table, 
"a")))
+                .map(entry -> entry.file())
+                .filter(file -> file.nonNullRowIdRange().equals(range))
+                .map(DataFileMeta::fileName)
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
     private void append(FileStoreTable table, String partition, int... values) 
throws Exception {
         BatchWriteBuilder builder = table.newBatchWriteBuilder();
         try (BatchTableWrite write = builder.newWrite();
@@ -651,6 +1457,13 @@ class DataEvolutionClusterTest extends TableTestBase {
         return rewriteClusterUnits(table, manager, 
manager.createCompactUnits(fullCompaction));
     }
 
+    private List<CommitMessage> prepareMappedCluster(
+            FileStoreTable table, Snapshot snapshot, boolean fullCompaction) 
throws Exception {
+        IncrementalClusterManager manager = new 
IncrementalClusterManager(table, null, snapshot);
+        return rewriteClusterUnits(
+                table, manager, manager.createCompactUnits(fullCompaction), 
true);
+    }
+
     // Run the engine-independent read/sort/write stages in-process. The 
source selection and
     // split planning are the same shared manager used by the distributed 
engine implementations.
     private List<CommitMessage> rewriteClusterUnits(
@@ -658,6 +1471,15 @@ class DataEvolutionClusterTest extends TableTestBase {
             IncrementalClusterManager manager,
             Map<BinaryRow, CompactUnit> units)
             throws Exception {
+        return rewriteClusterUnits(table, manager, units, false);
+    }
+
+    private List<CommitMessage> rewriteClusterUnits(
+            FileStoreTable table,
+            IncrementalClusterManager manager,
+            Map<BinaryRow, CompactUnit> units,
+            boolean preserveIndexes)
+            throws Exception {
         Map<BinaryRow, Pair<List<DataSplit>, CommitMessage>> planned =
                 manager.toSplitsAndRewriteDvFiles(units);
         List<CommitMessage> messages = new ArrayList<>();
@@ -667,10 +1489,17 @@ class DataEvolutionClusterTest extends TableTestBase {
             Pair<List<DataSplit>, CommitMessage> partitionPlan = 
planned.get(entry.getKey());
             assertThat(partitionPlan.getRight()).isNull();
             List<DataFileMeta> after = new ArrayList<>();
+            List<Long> oldRowIds = new ArrayList<>();
+            RowType readType =
+                    preserveIndexes
+                            ? SpecialFields.rowTypeWithRowId(table.rowType())
+                            : table.rowType();
+            ProjectedRow dataRow = ProjectedRow.from(table.rowType(), 
readType);
             try (IOManager ioManager = IOManager.create(tempPath.toString());
                     RecordReaderIterator<InternalRow> reader =
                             new RecordReaderIterator<>(
                                     table.newReadBuilder()
+                                            .withReadType(readType)
                                             .newRead()
                                             .createReader(
                                                     new 
ArrayList<>(partitionPlan.getLeft())));
@@ -681,13 +1510,16 @@ class DataEvolutionClusterTest extends TableTestBase {
                                     .withWriteType(table.rowType())) {
                 if (reader.hasNext()) {
                     Sorter sorter =
-                            Sorter.getSorter(
-                                    reader, ioManager, table.rowType(), 
table.coreOptions());
+                            Sorter.getSorter(reader, ioManager, readType, 
table.coreOptions());
                     try {
                         MutableObjectIterator<BinaryRow> sorted = 
sorter.sort();
                         BinaryRow row = new BinaryRow(sorter.arity());
                         while ((row = sorted.next(row)) != null) {
-                            write.write(sorter.removeSortKey(row));
+                            InternalRow sortedRow = sorter.removeSortKey(row);
+                            if (preserveIndexes) {
+                                
oldRowIds.add(sortedRow.getLong(readType.getFieldCount() - 1));
+                            }
+                            write.write(dataRow.replaceRow(sortedRow));
                         }
                     } finally {
                         sorter.close();
@@ -697,6 +1529,24 @@ class DataEvolutionClusterTest extends TableTestBase {
                     after.addAll(((CommitMessageImpl) 
output).newFilesIncrement().newFiles());
                 }
             }
+            DataFilePathFactory pathFactory =
+                    
table.store().pathFactory().createDataFilePathFactory(entry.getKey(), 0);
+            int rowOffset = 0;
+            for (int i = 0; preserveIndexes && i < after.size(); i++) {
+                DataFileMeta file = after.get(i);
+                String mappingName = 
ClusteringRowIdMapping.fileName(file.fileName());
+                try (ClusteringRowIdMapping.Writer mapping =
+                        ClusteringRowIdMapping.createWriter(
+                                table.fileIO(), 
pathFactory.toAlignedPath(mappingName, file))) {
+                    for (long row = 0; row < file.rowCount(); row++) {
+                        mapping.write(oldRowIds.get(rowOffset++));
+                    }
+                }
+                List<String> extraFiles = new ArrayList<>(file.extraFiles());
+                extraFiles.add(mappingName);
+                after.set(i, file.copy(extraFiles));
+            }
+            assertThat(rowOffset).isEqualTo(oldRowIds.size());
             messages.add(
                     new CommitMessageImpl(
                             entry.getKey(),
@@ -774,6 +1624,11 @@ class DataEvolutionClusterTest extends TableTestBase {
     private void addGlobalIndex(
             FileStoreTable table, String value, String fileName, Range range, 
String column)
             throws Exception {
+        commit(table, globalIndexMessage(table, value, fileName, range, 
column));
+    }
+
+    private CommitMessage globalIndexMessage(
+            FileStoreTable table, String value, String fileName, Range range, 
String column) {
         IndexFileMeta index =
                 new IndexFileMeta(
                         "test-global-index",
@@ -787,14 +1642,12 @@ class DataEvolutionClusterTest extends TableTestBase {
                                 null,
                                 null),
                         null);
-        commit(
-                table,
-                new CommitMessageImpl(
-                        partition(table, value),
-                        UNAWARE_BUCKET,
-                        null,
-                        
DataIncrement.indexIncrement(Collections.singletonList(index)),
-                        CompactIncrement.emptyIncrement()));
+        return new CommitMessageImpl(
+                partition(table, value),
+                UNAWARE_BUCKET,
+                null,
+                DataIncrement.indexIncrement(Collections.singletonList(index)),
+                CompactIncrement.emptyIncrement());
     }
 
     private void commit(FileStoreTable table, CommitMessage message) throws 
Exception {
@@ -809,40 +1662,68 @@ class DataEvolutionClusterTest extends TableTestBase {
 
     private List<CommitMessage> prepareGlobalIndex(
             FileStoreTable table, Snapshot snapshot, String column) throws 
Exception {
+        return prepareGlobalIndex(table, snapshot, column, "btree", null);
+    }
+
+    private List<CommitMessage> prepareGlobalIndex(
+            FileStoreTable table,
+            Snapshot snapshot,
+            String column,
+            String indexType,
+            Range fileRange)
+            throws Exception {
         List<CommitMessage> messages = new ArrayList<>();
         for (DataSplit split :
                 GlobalIndexBuilderUtils.splitByContiguousRowRange(
-                        new SortedGlobalIndexScanner(table, "btree")
+                        new SortedGlobalIndexScanner(table, indexType)
                                 
.withIndexFields(Collections.singletonList(column))
                                 .withSnapshot(snapshot)
                                 .scan()
                                 .get()
                                 .entries())) {
+            if (fileRange != null) {
+                Optional<DataSplit> filtered =
+                        split.filterDataFile(file -> 
file.nonNullRowIdRange().equals(fileRange));
+                if (!filtered.isPresent()) {
+                    continue;
+                }
+                split = filtered.get();
+            }
             messages.addAll(
                     SortedGlobalIndexTestUtils.buildIndex(
-                            table, "btree", column, split, snapshot.id()));
+                            table, indexType, column, split, snapshot.id()));
         }
         assertThat(messages).isNotEmpty();
         return messages;
     }
 
     private List<String> readRows(FileStoreTable table) throws Exception {
+        return readRows(table, null);
+    }
+
+    private List<String> readRows(FileStoreTable table, Predicate predicate) 
throws Exception {
         ReadBuilder builder =
                 table.newReadBuilder()
                         
.withReadType(SpecialFields.rowTypeWithRowId(table.rowType()));
+        if (predicate != null) {
+            builder.withFilter(predicate);
+        }
         List<String> rows = new ArrayList<>();
         try (RecordReader<InternalRow> reader =
                 builder.newRead().createReader(builder.newScan().plan())) {
             reader.forEachRemaining(
-                    row ->
+                    row -> {
+                        if (predicate == null || predicate.test(row)) {
                             rows.add(
                                     row.getString(0)
                                             + "|"
                                             + row.getInt(1)
                                             + "|"
-                                            + row.getString(2)
+                                            + (row.isNullAt(2) ? "null" : 
row.getString(2))
                                             + "|"
-                                            + row.getLong(3)));
+                                            + row.getLong(3));
+                        }
+                    });
         }
         rows.sort(
                 Comparator.comparingLong(
@@ -850,6 +1731,38 @@ class DataEvolutionClusterTest extends TableTestBase {
         return rows;
     }
 
+    private void appendIndexRows(FileStoreTable table, int[] ids, String[] 
payloads)
+            throws Exception {
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite();
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int i = 0; i < ids.length; i++) {
+                write.write(
+                        GenericRow.of(
+                                BinaryString.fromString("a"),
+                                ids[i],
+                                payloads[i] == null ? null : 
BinaryString.fromString(payloads[i])));
+            }
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private void assertIndexRows(FileStoreTable table, Predicate predicate, 
Range... expected)
+            throws Exception {
+        Optional<DataEvolutionGlobalIndexScanner> scanner =
+                DataEvolutionGlobalIndexScanner.create(
+                        table, PartitionPredicate.ALWAYS_TRUE, predicate);
+        if (!scanner.isPresent() && expected.length == 0) {
+            return;
+        }
+        assertThat(scanner).isPresent();
+        try (DataEvolutionGlobalIndexScanner index = scanner.get()) {
+            Optional<GlobalIndexResult> result = index.scan(predicate);
+            assertThat(result).isPresent();
+            
assertThat(result.get().results().toRangeList()).containsExactly(expected);
+        }
+    }
+
     private List<String> fileNames(FileStoreTable table, String value) {
         BinaryRow partition = partition(table, value);
         return 
table.store().newScan().withSnapshot(table.latestSnapshot().get()).plan().files()
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 99fd1f6532..4c9291b25b 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
@@ -77,6 +77,7 @@ import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.FailingFileIO;
 import org.apache.paimon.utils.JsonSerdeUtil;
 import org.apache.paimon.utils.Pair;
+import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.SnapshotManager;
 import org.apache.paimon.utils.TraceableFileIO;
 
@@ -999,14 +1000,26 @@ public class FileStoreCommitTest {
         TestFileStore store = createStore(false, 2);
         BinaryRow partition = gen.getPartition(gen.next());
         IndexManifestEntry materializedBucketDelete =
-                globalIndexDeleteEntry(partition, 0, 
"materialized-bucket-index");
+                globalIndexDeleteEntry(partition, 0, 
"materialized-bucket-index", new Range(0, 0));
+        IndexManifestEntry disjointRangeDelete =
+                globalIndexDeleteEntry(partition, 0, "disjoint-range-index", 
new Range(1, 2));
         IndexManifestEntry otherBucketDelete =
-                globalIndexDeleteEntry(partition, 1, "other-bucket-index");
+                globalIndexDeleteEntry(partition, 1, "other-bucket-index", new 
Range(0, 0));
+        IndexManifestEntry nonGlobalDelete =
+                new IndexManifestEntry(
+                        FileKind.DELETE,
+                        partition,
+                        0,
+                        new IndexFileMeta(HASH_INDEX, "non-global-index", 1, 
1, null, null, null));
         CommitChanges changes =
                 new CommitChanges(
                         Collections.emptyList(),
                         Collections.emptyList(),
-                        Arrays.asList(materializedBucketDelete, 
otherBucketDelete));
+                        Arrays.asList(
+                                materializedBucketDelete,
+                                disjointRangeDelete,
+                                otherBucketDelete,
+                                nonGlobalDelete));
 
         try (FileStoreCommitImpl commit = store.newCommit()) {
             CommitChanges refreshed =
@@ -1014,15 +1027,22 @@ public class FileStoreCommitTest {
                                     changes, 
Collections.singleton(Pair.of(partition, 0)))
                             .provide(null);
 
-            
assertThat(refreshed.indexFiles).containsExactly(otherBucketDelete);
+            // Global-index removals from earlier preparation are replaced for 
the entire bucket,
+            // even if their ranges do not overlap the materialized input.
+            
assertThat(refreshed.indexFiles).containsExactly(otherBucketDelete, 
nonGlobalDelete);
         }
     }
 
     private static IndexManifestEntry globalIndexDeleteEntry(
-            BinaryRow partition, int bucket, String fileName) {
+            BinaryRow partition, int bucket, String fileName, Range range) {
         IndexFileMeta file =
                 new IndexFileMeta(
-                        "btree", fileName, 1, 1, new GlobalIndexMeta(0, 0, 0, 
null, null), null);
+                        "btree",
+                        fileName,
+                        1,
+                        range.count(),
+                        new GlobalIndexMeta(range.from, range.to, 0, null, 
null),
+                        null);
         return new IndexManifestEntry(FileKind.DELETE, partition, bucket, 
file);
     }
 
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/IncrementalClusterSplitSource.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/IncrementalClusterSplitSource.java
index 25fe923df5..967d862553 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/IncrementalClusterSplitSource.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/IncrementalClusterSplitSource.java
@@ -27,9 +27,11 @@ import org.apache.paimon.flink.source.SimpleSourceSplit;
 import org.apache.paimon.flink.source.operator.ReadOperator;
 import org.apache.paimon.flink.utils.JavaTypeInfo;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Pair;
 
 import org.apache.flink.api.common.eventtime.WatermarkStrategy;
@@ -108,15 +110,23 @@ public class IncrementalClusterSplitSource extends 
AbstractNonCoordinatedSource<
             partitioned.setParallelism(parallelism);
         }
 
+        RowType readType =
+                table.coreOptions().dataEvolutionEnabled()
+                        ? SpecialFields.rowTypeWithRowId(table.rowType())
+                        : table.rowType();
+        ReadOperator reader =
+                table.coreOptions().dataEvolutionEnabled()
+                        ? new ReadOperator(() -> 
table.newRead().withReadType(readType), null, null)
+                        : new ReadOperator(table::newRead, null, null);
+
         return Pair.of(
                 new DataStream<>(source.getExecutionEnvironment(), partitioned)
                         .transform(
                                 String.format(
                                         "Incremental-cluster reader: %s - %s",
                                         table.fullName(), partitionSpec),
-                                InternalTypeInfo.of(
-                                        
LogicalTypeConversion.toLogicalType(table.rowType())),
-                                new ReadOperator(table::newRead, null, null))
+                                
InternalTypeInfo.of(LogicalTypeConversion.toLogicalType(readType)),
+                                reader)
                         .setParallelism(partitioned.getParallelism()),
                 source.forward()
                         .transform(
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
index ef5a58632b..b848f2467e 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/compact/IncrementalClusterCompact.java
@@ -28,6 +28,7 @@ import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.JoinedRow;
 import org.apache.paimon.flink.FlinkRowData;
 import org.apache.paimon.flink.FlinkRowWrapper;
+import org.apache.paimon.flink.cluster.DataEvolutionClusterWriteOperator;
 import org.apache.paimon.flink.cluster.IncrementalClusterSplitSource;
 import 
org.apache.paimon.flink.cluster.RewriteIncrementalClusterCommittableOperator;
 import org.apache.paimon.flink.sink.Committable;
@@ -43,6 +44,7 @@ import org.apache.paimon.flink.utils.InternalTypeInfo;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.types.RowType;
@@ -71,6 +73,9 @@ import java.util.stream.Collectors;
 import static org.apache.paimon.flink.FlinkConnectorOptions.PRECOMMIT_COMPACT;
 import static org.apache.paimon.flink.FlinkConnectorOptions.SCAN_PARALLELISM;
 import static 
org.apache.paimon.flink.FlinkConnectorOptions.SINK_COORDINATOR_COMMIT_ENABLED;
+import static 
org.apache.paimon.flink.FlinkConnectorOptions.SINK_MANAGED_WRITER_BUFFER_MEMORY;
+import static 
org.apache.paimon.flink.FlinkConnectorOptions.SINK_USE_MANAGED_MEMORY;
+import static 
org.apache.paimon.flink.utils.ManagedMemoryUtils.declareManagedMemory;
 import static org.apache.paimon.table.PrimaryKeyTableUtils.addKeyNamePrefix;
 
 /** Compact for incremental clustering. */
@@ -169,9 +174,13 @@ public class IncrementalClusterCompact {
         }
 
         DataStream<RowData> sorted;
+        RowType clusterRowType =
+                table.coreOptions().dataEvolutionEnabled()
+                        ? SpecialFields.rowTypeWithRowId(table.rowType())
+                        : table.rowType();
         CoreOptions.ClusteringIncrementalMode mode = 
clusterManager.clusteringIncrementalMode();
         if (mode == CoreOptions.ClusteringIncrementalMode.LOCAL_SORT) {
-            sorted = localSort(sourcePair.getLeft(), sinkParallelism);
+            sorted = localSort(sourcePair.getLeft(), sinkParallelism, 
clusterRowType);
         } else {
             // GLOBAL_SORT: range shuffle + local sort (default)
             TableSortInfo sortInfo =
@@ -188,22 +197,34 @@ public class IncrementalClusterCompact {
                                     env,
                                     sourcePair.getLeft(),
                                     table.coreOptions(),
-                                    table.rowType(),
+                                    clusterRowType,
                                     sortInfo)
                             .sort();
         }
 
         // 2.3 write and then reorganize the committable
         // set parallelism to null, and it'll forward parallelism when 
doWrite()
-        RowAppendTableSink sink = new RowAppendTableSink(table, null, null);
-        DataStream<Committable> written =
-                sink.doWrite(
-                        FlinkSinkBuilder.mapToInternalRow(
-                                sorted,
-                                table.rowType(),
-                                table.catalogEnvironment().catalogContext()),
-                        commitUser,
-                        null);
+        DataStream<InternalRow> rows =
+                FlinkSinkBuilder.mapToInternalRow(
+                        sorted, clusterRowType, 
table.catalogEnvironment().catalogContext());
+        DataStream<Committable> written;
+        if (table.coreOptions().dataEvolutionEnabled()) {
+            written =
+                    rows.forward()
+                            .transform(
+                                    "Write data evolution cluster row-id 
mappings",
+                                    new CommittableTypeInfo(),
+                                    new DataEvolutionClusterWriteOperator(
+                                            table, partition, commitUser))
+                            .setParallelism(sinkParallelism);
+            Options writeOptions = Options.fromMap(table.options());
+            if (writeOptions.get(SINK_USE_MANAGED_MEMORY)) {
+                declareManagedMemory(written, 
writeOptions.get(SINK_MANAGED_WRITER_BUFFER_MEMORY));
+            }
+        } else {
+            RowAppendTableSink sink = new RowAppendTableSink(table, null, 
null);
+            written = sink.doWrite(rows, commitUser, null);
+        }
         DataStream<Committable> clusterCommittable =
                 written.forward()
                         .transform(
@@ -233,9 +254,9 @@ public class IncrementalClusterCompact {
      *
      * <p>Pipeline: add key prefix → SortOperator → remove key prefix → back 
to RowData
      */
-    private DataStream<RowData> localSort(DataStream<RowData> input, int 
sinkParallelism) {
+    private DataStream<RowData> localSort(
+            DataStream<RowData> input, int sinkParallelism, RowType rowType) {
         CoreOptions options = table.coreOptions();
-        RowType rowType = table.rowType();
         List<String> clusterKeys = clusterManager.clusterKeys();
 
         final int[] keyProjectionMap = rowType.projectIndexes(clusterKeys);
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
index e0504d20bd..4569ff31df 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/DataEvolutionDeleteSqlITCase.java
@@ -175,11 +175,103 @@ public class DataEvolutionDeleteSqlITCase extends 
CatalogITCaseBase {
                 .containsExactlyElementsOf(expectedRowIds);
         assertClusteredFileOrder(table, mode);
         assertThat(deletionVectorCardinality(table)).isZero();
-        assertThat(globalIndexPartitions(table)).containsExactly("B");
+        assertThat(globalIndexPartitions(table)).containsExactly("A", "B");
         sql("ALTER TABLE T SET ('scalar-index.search-mode' = 'full')");
         assertThat(sql("SELECT id FROM T WHERE name = 
'updated-3'")).containsExactly(Row.of(3));
     }
 
+    @ParameterizedTest
+    @CsvSource({"btree,global-sort", "btree,local-sort", "bitmap,global-sort", 
"bitmap,local-sort"})
+    public void testIncrementalClusteringRemapsScalarIndexRowIds(String 
indexType, String mode)
+            throws Exception {
+        tEnv.getConfig().set(TableConfigOptions.TABLE_DML_SYNC, true);
+        createTable();
+        sql(
+                "ALTER TABLE T SET ("
+                        + "'write-only' = 'true', "
+                        + "'clustering.incremental' = 'true', "
+                        + "'clustering.strategy' = 'order', "
+                        + "'clustering.columns' = 'id', "
+                        + "'clustering.incremental.mode' = '"
+                        + mode
+                        + "', 'scan.parallelism' = '2', "
+                        + "'source.split.target-size' = '1 b', "
+                        + "'sink.parallelism' = '1', 'target-file-row-num' = 
'7')");
+        List<String> odds = new ArrayList<>();
+        List<String> evens = new ArrayList<>();
+        for (int id = 39; id >= 1; id -= 2) {
+            odds.add(
+                    "("
+                            + id
+                            + ", "
+                            + (id == 9 ? "CAST(NULL AS STRING)" : "'key-" + id 
+ "'")
+                            + ", 'A')");
+            evens.add("(" + (id + 1) + ", 'key-" + (id + 1) + "', 'A')");
+        }
+        sql("INSERT INTO T VALUES " + String.join(", ", odds));
+        sql("INSERT INTO T VALUES " + String.join(", ", evens));
+        sql("INSERT INTO T VALUES (41, 'key-41', 'B')");
+        sql(
+                "CALL sys.create_global_index(`table` => 'default.T', "
+                        + "index_column => 'name', index_type => '"
+                        + indexType
+                        + "')");
+        // The old postings still contain these rows; the clustering mapping 
must omit them.
+        sql("DELETE FROM T WHERE id IN (2, 11)");
+        FileStoreTable table = paimonTable("T");
+        List<String> oldAIndexes = scalarIndexFiles(table, indexType, "A");
+        List<String> oldBIndexes = scalarIndexFiles(table, indexType, "B");
+        assertThat(oldAIndexes).isNotEmpty();
+        assertThat(oldBIndexes).isNotEmpty();
+        long beforeSnapshot = table.latestSnapshot().get().id();
+        long firstNewRowId = table.latestSnapshot().get().nextRowId();
+
+        sql(
+                "CALL sys.compact(`table` => 'default.T', partitions => 
'dt=A', "
+                        + "compact_strategy => 'full')");
+
+        // Data and remapped indexes become visible atomically, without a 
second index-build job.
+        assertThat(table.latestSnapshot().get().id()).isEqualTo(beforeSnapshot 
+ 1);
+        assertThat(table.latestSnapshot().get().commitKind())
+                .isEqualTo(Snapshot.CommitKind.COMPACT);
+        assertThat(scalarIndexFiles(table, indexType, "A"))
+                .isNotEmpty()
+                .doesNotContainAnyElementsOf(oldAIndexes);
+        assertThat(scalarIndexFiles(table, indexType, 
"B")).isEqualTo(oldBIndexes);
+        assertThat(
+                        
table.store().newIndexFileHandler().scan(indexType).stream()
+                                .filter(
+                                        entry ->
+                                                entry.partition()
+                                                        .getString(0)
+                                                        .toString()
+                                                        .equals("A")))
+                .allSatisfy(
+                        entry ->
+                                
assertThat(entry.indexFile().globalIndexMeta().rowRangeStart())
+                                        
.isGreaterThanOrEqualTo(firstNewRowId));
+        assertThat(sql("SELECT _ROW_ID FROM T$row_tracking WHERE dt = 'A'"))
+                .hasSize(38)
+                .allSatisfy(
+                        row ->
+                                assertThat((Long) row.getField(0))
+                                        
.isGreaterThanOrEqualTo(firstNewRowId));
+
+        // FAST searches only indexed rows, so data-scan fallback cannot hide 
missing postings.
+        sql("ALTER TABLE T SET ('scalar-index.search-mode' = 'fast')");
+        assertThat(sql("SELECT id FROM T WHERE name IN ('key-5', 'key-2', 
'key-11') ORDER BY id"))
+                .containsExactly(Row.of(5));
+        assertThat(sql("SELECT id FROM T WHERE name IS 
NULL")).containsExactly(Row.of(9));
+        List<Row> negativeMatches = new ArrayList<>();
+        for (int id = 1; id <= 40; id++) {
+            if (id != 2 && id != 11 && id != 5 && id != 9) {
+                negativeMatches.add(Row.of(id));
+            }
+        }
+        assertThat(sql("SELECT id FROM T WHERE dt = 'A' AND name <> 'key-5' 
ORDER BY id"))
+                .containsExactlyElementsOf(negativeMatches);
+    }
+
     @ParameterizedTest
     @ValueSource(strings = {"global-sort", "local-sort"})
     public void testIncrementalClusteringPreservesInlineBlobViews(String mode) 
throws Exception {
@@ -614,6 +706,15 @@ public class DataEvolutionDeleteSqlITCase extends 
CatalogITCaseBase {
                 .collect(Collectors.toList());
     }
 
+    private static List<String> scalarIndexFiles(
+            FileStoreTable table, String indexType, String partition) {
+        return table.store().newIndexFileHandler().scan(indexType).stream()
+                .filter(entry -> 
entry.partition().getString(0).toString().equals(partition))
+                .map(entry -> entry.indexFile().fileName())
+                .sorted()
+                .collect(Collectors.toList());
+    }
+
     private static void assertDeleteSnapshot(
             FileStoreTable table, List<String> originalFiles, long 
deletedRows) {
         
assertThat(table.latestSnapshot().get().operation()).isEqualTo(Snapshot.Operation.DELETE);

Reply via email to