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 1b47fba765 [core] Push down deletion vectors for merged data evolution 
groups (#9987)
1b47fba765 is described below

commit 1b47fba765d11ad244d355dbad89041f751fef3d
Author: sanshi <[email protected]>
AuthorDate: Thu Sep 24 17:41:21 2026 +0800

    [core] Push down deletion vectors for merged data evolution groups (#9987)
---
 .../org/apache/paimon/io/FileIndexEvaluator.java   | 36 +++++++++
 .../paimon/operation/DataEvolutionSplitRead.java   | 72 +++++++++++++++---
 .../paimon/table/DataEvolutionFileIndexTest.java   | 86 ++++++++++++++++++++--
 3 files changed, 175 insertions(+), 19 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/FileIndexEvaluator.java 
b/paimon-core/src/main/java/org/apache/paimon/io/FileIndexEvaluator.java
index 42e62d7b99..9d88923f2f 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/FileIndexEvaluator.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/FileIndexEvaluator.java
@@ -126,6 +126,42 @@ public class FileIndexEvaluator {
         }
     }
 
+    /**
+     * Intersects a previously evaluated file-index result with the live rows 
from a deletion
+     * vector.
+     *
+     * <p>The file index uses positions local to {@code file}, while a 
deletion vector may use
+     * positions relative to the merged group's anchor range. {@code 
fileOffset} converts the latter
+     * to the former. Keeping this operation separate from {@link #evaluate} 
allows callers to reuse
+     * an index result instead of reading the file-index sidecar a second time.
+     *
+     * <p>Only bitmap index results can be narrowed to a subset of live rows. 
A non-bitmap result
+     * remains conservative, except that an empty live-row selection always 
proves that the file can
+     * be skipped.
+     */
+    public static FileIndexResult intersectDeletionVector(
+            FileIndexResult result,
+            DataFileMeta file,
+            @Nullable DeletionVector dv,
+            long fileOffset) {
+        if (file.rowCount() > RoaringBitmap32.MAX_VALUE
+                || dv == null
+                || dv.isEmpty()
+                || dv instanceof Bitmap64DeletionVector) {
+            return result;
+        }
+        BitmapIndexResult liveRows = createBaseSelection(file, dv, fileOffset);
+        if (!liveRows.remain()) {
+            return FileIndexResult.SKIP;
+        }
+        if (result instanceof BitmapIndexResult) {
+            FileIndexResult intersected = result.and(liveRows);
+            return intersected.remain() ? intersected : FileIndexResult.SKIP;
+        }
+        return result;
+    }
+
+    /** Returns all local file positions minus the positions deleted by a 
bitmap DV. */
     private static BitmapIndexResult createBaseSelection(
             DataFileMeta file, @Nullable DeletionVector dv, long fileOffset) {
         BitmapIndexResult selection =
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
index 0b05ee835e..696831fbf6 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java
@@ -250,11 +250,23 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             } else {
                 suppliers.add(
                         () -> {
-                            if (skipByFileIndex(filters, needMergeFiles, 
dataFilePathFactory)) {
+                            // Reject the group using only file indexes before 
opening its DV.
+                            // This keeps the common selective path free of a 
DV sidecar read.
+                            List<FileIndexResultEntry> fileIndexResults =
+                                    evaluateFileIndexes(
+                                            filters, needMergeFiles, 
dataFilePathFactory);
+                            if (fileIndexResults.stream()
+                                    .anyMatch(entry -> 
!entry.result.remain())) {
                                 return new EmptyFileRecordReader<>();
                             }
                             DeletionVectorWithRange deletionVector =
                                     readDeletionVector(needMergeFiles, 
deletionVectorFactory);
+                            if (deletionVector != null
+                                    && !deletionVector.deletionVector.isEmpty()
+                                    && skipByFileIndex(
+                                            fileIndexResults, rowRanges, 
deletionVector)) {
+                                return new EmptyFileRecordReader<>();
+                            }
                             return createUnionReader(
                                     needMergeFiles,
                                     partition,
@@ -822,26 +834,24 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
     }
 
     /**
-     * Whether the file index proves that no row of a merged group can match 
the filters. Only plain
-     * data files are considered: {@link #mergeRangesAndSort} guarantees they 
all span the row id
-     * range of the whole group, while a blob or vector-store file only covers 
a sub range and can
-     * not prove anything for the other rows.
+     * Evaluates each applicable file index once for the winning fields of a 
merged row-id group.
      *
-     * <p>A column can be written by several files of the group; the column 
merge takes each field
-     * from the newest file that wrote it and older copies are dead. Files 
arrive newest first
-     * ({@link #mergeRangesAndSort}), so each file's index is evaluated only 
over the columns it is
-     * the newest writer of. A stale value in an older file must not veto a 
group whose winning file
-     * matches, mirroring the winner selection in {@link 
DataEvolutionFileStoreScan#evolutionStats}.
+     * <p>Only normal data files are considered. Blob and vector-store files 
can cover only a subset
+     * of the group range, so their indexes cannot prove that the whole group 
has no match. Files
+     * are visited newest first; {@code claimedFieldIds} prevents an older 
copy of an overwritten
+     * field from vetoing the group. The returned results are retained so a 
later deletion-vector
+     * pass can intersect them without reopening file-index sidecars.
      */
-    private boolean skipByFileIndex(
+    private List<FileIndexResultEntry> evaluateFileIndexes(
             @Nullable List<Predicate> filters,
             List<DataFileMeta> files,
             DataFilePathFactory pathFactory)
             throws IOException {
         if (!fileIndexReadEnabled || isNullOrEmpty(filters)) {
-            return false;
+            return Collections.emptyList();
         }
 
+        List<FileIndexResultEntry> results = new ArrayList<>();
         Set<Integer> claimedFieldIds = new HashSet<>();
         for (DataFileMeta file : files) {
             if (isBlobFile(file.fileName()) || 
isVectorStoreFile(file.fileName())) {
@@ -868,6 +878,33 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             FileIndexResult result =
                     FileIndexEvaluator.evaluate(
                             fileIO, dataSchema, dataFilters, null, null, 
pathFactory, file, null);
+            results.add(new FileIndexResultEntry(file, result));
+            if (!result.remain()) {
+                return results;
+            }
+        }
+        return results;
+    }
+
+    /**
+     * Applies a group deletion vector to already evaluated file-index results.
+     *
+     * <p>A merged group can only be skipped when one of the files responsible 
for the final
+     * predicate values has no live candidate rows. This method only 
intersects the saved results;
+     * it does not reopen or reevaluate any file index. The offset passed to 
the evaluator maps
+     * positions in the group's DV anchor range to positions local to the 
current file.
+     */
+    private boolean skipByFileIndex(
+            List<FileIndexResultEntry> fileIndexResults,
+            List<Range> rowRanges,
+            DeletionVectorWithRange deletionVector) {
+        DeletionVector dv = deletionVector.deletionVector;
+        for (FileIndexResultEntry entry : fileIndexResults) {
+            long fileOffset =
+                    deletionVectorOffset(entry.file.nonNullRowIdRange(), 
rowRanges, deletionVector);
+            FileIndexResult result =
+                    FileIndexEvaluator.intersectDeletionVector(
+                            entry.result, entry.file, dv, fileOffset);
             if (!result.remain()) {
                 return true;
             }
@@ -1139,6 +1176,17 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
         }
     }
 
+    private static class FileIndexResultEntry {
+
+        private final DataFileMeta file;
+        private final FileIndexResult result;
+
+        private FileIndexResultEntry(DataFileMeta file, FileIndexResult 
result) {
+            this.file = file;
+            this.result = result;
+        }
+    }
+
     private static class DeletionVectorWithRange {
 
         private final Range range;
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
index 9b0b7da89f..30f4be2dca 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionFileIndexTest.java
@@ -71,6 +71,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 import static org.apache.paimon.table.SpecialFields.rowTypeWithRowId;
@@ -541,6 +542,77 @@ public class DataEvolutionFileIndexTest extends 
DataEvolutionTestBase {
         assertThat(query(table, equalF1(f1(50)))).isEmpty();
     }
 
+    @Test
+    public void testMergedGroupFileIndexComposesWithDeletionVector() throws 
Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        FileStoreTable table = createTable("merged_bitmap_dv", options);
+        writeSplitColumns(table, ROW_COUNT, bitmapOptions("f1"), 
Collections.emptyMap());
+        writeSplitColumns(table, ROW_COUNT, bitmapOptions("f1"), 
Collections.emptyMap());
+
+        deleteRowsFrom(table, ROW_COUNT, 50);
+
+        FileStoreTable latest = getTable(identifier(table.name()));
+        DataSplit targetSplit =
+                latest.newReadBuilder().newScan().plan().splits().stream()
+                        .map(split -> (DataSplit) split)
+                        .filter(
+                                split ->
+                                        split.dataFiles().stream()
+                                                .anyMatch(
+                                                        file ->
+                                                                
file.nonNullFirstRowId()
+                                                                        == 
ROW_COUNT))
+                        .findFirst()
+                        .orElseThrow(IllegalStateException::new);
+        DataFileMeta anchor =
+                retrieveAnchorFile(
+                        targetSplit.dataFiles().stream()
+                                .filter(file -> file.nonNullFirstRowId() == 
ROW_COUNT)
+                                .collect(Collectors.toList()),
+                        file -> file);
+        Path anchorPath =
+                latest.store()
+                        .pathFactory()
+                        .createDataFilePathFactory(targetSplit.partition(), 
targetSplit.bucket())
+                        .toPath(anchor);
+        assertThat(latest.fileIO().delete(anchorPath, false)).isTrue();
+
+        // The deleted row is the only bitmap hit in the second merged group. 
The missing anchor
+        // file therefore proves that the group was skipped before any union 
reader opened it.
+        RowType readType =
+                
rowTypeWithRowId(rowType()).project(SpecialFields.ROW_ID.name(), "f1", "f2");
+        List<InternalRow> rows = readWithFilter(table, equalF1(f1(50)), 
readType);
+        assertThat(rowIds(rows)).containsExactlyElementsOf(rowIds(0, 
ROW_COUNT));
+
+        FileStoreTable neighbour = createTable("merged_bitmap_dv_neighbour", 
options);
+        writeSplitColumns(neighbour, ROW_COUNT, bitmapOptions("f1"), 
Collections.emptyMap());
+        deleteRows(neighbour, 51);
+        assertRow(assertSingleRow(query(neighbour, equalF1(f1(50)))), 50);
+    }
+
+    @Test
+    public void testMergedGroupFileIndexSkipsBeforeReadingDeletionVector() 
throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        FileStoreTable table = createTable("merged_bitmap_before_dv", options);
+        writeSplitColumns(table, ROW_COUNT, bitmapOptions("f1"), 
Collections.emptyMap());
+        deleteRows(table, 50);
+
+        FileStoreTable latest = getTable(identifier(table.name()));
+        DataSplit split = (DataSplit) 
latest.newReadBuilder().newScan().plan().splits().get(0);
+        Path deletionVectorPath =
+                split.deletionFiles().get().stream()
+                        .filter(Objects::nonNull)
+                        .map(file -> new Path(file.path()))
+                        .findFirst()
+                        .orElseThrow(IllegalStateException::new);
+        assertThat(latest.fileIO().delete(deletionVectorPath, false)).isTrue();
+
+        // The bitmap index already rejects this value, so the missing DV file 
must not be read.
+        assertThat(readWithFilter(table, equalF1(MISSING_F1))).isEmpty();
+    }
+
     /** Commits a deletion vector for the anchor file of the only row id group 
of {@code table}. */
     private void deleteRows(FileStoreTable table, long... positions) throws 
Exception {
         FileStoreTable latest = getTable(identifier(table.name()));
@@ -552,17 +624,17 @@ public class DataEvolutionFileIndexTest extends 
DataEvolutionTestBase {
     private void deleteRowsFrom(FileStoreTable table, long firstRowId, long... 
positions)
             throws Exception {
         FileStoreTable latest = getTable(identifier(table.name()));
-        DataFileMeta anchor =
+        List<DataFileMeta> group =
                 latest.newReadBuilder().newScan().plan().splits().stream()
                         .map(split -> (DataSplit) split)
                         .flatMap(split -> split.dataFiles().stream())
                         .filter(file -> file.nonNullFirstRowId() == firstRowId)
-                        .findFirst()
-                        .orElseThrow(
-                                () ->
-                                        new IllegalArgumentException(
-                                                "Cannot find data file with 
first row id "
-                                                        + firstRowId));
+                        .collect(Collectors.toList());
+        if (group.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Cannot find data file with first row id " + firstRowId);
+        }
+        DataFileMeta anchor = retrieveAnchorFile(group, file -> file);
         deleteRows(latest, anchor, positions);
     }
 

Reply via email to