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 d2be7eaced [core] Introducing DeletionVector mechanism for 
DataEvolution tables (#8380)
d2be7eaced is described below

commit d2be7eacedf7c001ed1022856a62ade089e954c4
Author: Faiz <[email protected]>
AuthorDate: Tue Jun 30 18:54:18 2026 +0800

    [core] Introducing DeletionVector mechanism for DataEvolution tables (#8380)
---
 .../DataEvolutionCompactCoordinator.java           |  12 +
 .../dataevolution/DataEvolutionCompactTask.java    |   7 +-
 .../ApplyDeletionFileRecordIterator.java           |  10 +-
 .../deletionvectors/ApplyDeletionVectorReader.java |  20 +-
 .../append/AppendDeleteFileMaintainer.java         |  14 +-
 .../operation/AllPlaceholdersRecordReader.java     | 149 ++++++
 .../paimon/operation/BlobFallbackRecordReader.java | 251 ++++-----
 .../operation/DataEvolutionFileStoreScan.java      |  12 +
 .../paimon/operation/DataEvolutionSplitRead.java   | 206 ++++---
 .../org/apache/paimon/schema/SchemaValidation.java |   3 -
 .../org/apache/paimon/table/source/DataSplit.java  |  24 +-
 .../apache/paimon/utils/DataEvolutionUtils.java    |  64 +++
 .../DataEvolutionCompactCoordinatorTest.java       |  36 ++
 .../paimon/deletionvectors/DeletionVectorTest.java | 114 ++++
 .../operation/AllPlaceholdersRecordReaderTest.java | 103 ++++
 .../operation/BlobFallbackRecordReaderTest.java    | 169 +++++-
 .../table/DataEvolutionDeletionVectorTest.java     | 590 +++++++++++++++++++++
 .../table/source/DataSplitCompatibleTest.java      |  25 +
 .../paimon/utils/DataEvolutionUtilsTest.java       |  93 ++++
 19 files changed, 1669 insertions(+), 233 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinator.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinator.java
index 3c1496b851..d183a8ebb5 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinator.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinator.java
@@ -64,6 +64,7 @@ public class DataEvolutionCompactCoordinator {
     private static final int FILES_BATCH = 100_000;
     private static final int BLOB_COMPACT_MIN_FILE_NUM = 2;
 
+    private final boolean deletionVectorsEnabled;
     private final CompactScanner scanner;
     private final CompactPlanner planner;
 
@@ -78,6 +79,13 @@ public class DataEvolutionCompactCoordinator {
             boolean compactBlob,
             boolean compactVector) {
         CoreOptions options = table.coreOptions();
+        this.deletionVectorsEnabled = options.deletionVectorsEnabled();
+        if (deletionVectorsEnabled) {
+            this.scanner = null;
+            this.planner = null;
+            return;
+        }
+
         long targetFileSize = options.targetFileSize(false);
         long openFileCost = options.splitOpenFileCost();
         long compactMinFileNum = options.compactionMinFileNum();
@@ -109,6 +117,10 @@ public class DataEvolutionCompactCoordinator {
     }
 
     public List<DataEvolutionCompactTask> plan() {
+        if (deletionVectorsEnabled) {
+            throw new EndOfScanException();
+        }
+
         // scan files in snapshot
         List<ManifestEntry> entries = scanner.scan();
         if (!entries.isEmpty()) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
index f8bdc959c3..c2ee672844 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactTask.java
@@ -96,6 +96,11 @@ public class DataEvolutionCompactTask extends 
AppendCompactTask {
     }
 
     public CommitMessage doCompact(FileStoreTable table, String commitUser) 
throws Exception {
+        CoreOptions options = table.coreOptions();
+        checkArgument(
+                !options.deletionVectorsEnabled(),
+                "Data evolution compaction does not support deletion 
vectors.");
+
         if (blobTask) {
             return doCompactBlobFiles(table, commitUser);
         }
@@ -104,8 +109,6 @@ public class DataEvolutionCompactTask extends 
AppendCompactTask {
             throw new UnsupportedOperationException("Vector-store task is not 
supported");
         }
 
-        CoreOptions options = table.coreOptions();
-
         Set<String> fieldsInDedicatedFile =
                 SetUtils.union(
                         fieldNamesInBlobFile(table.rowType(), 
options.blobInlineField()),
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionFileRecordIterator.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionFileRecordIterator.java
index 473b0fef53..39162c4783 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionFileRecordIterator.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionFileRecordIterator.java
@@ -26,15 +26,17 @@ import javax.annotation.Nullable;
 
 import java.io.IOException;
 
-/** A {@link FileRecordIterator} wraps a {@link FileRecordIterator} and {@link 
DeletionVector}. */
+/**
+ * A {@link FileRecordIterator} wraps a {@link FileRecordIterator} and {@link 
DeletionVectorJudger}.
+ */
 public class ApplyDeletionFileRecordIterator
         implements FileRecordIterator<InternalRow>, DeletionFileRecordIterator 
{
 
     private final FileRecordIterator<InternalRow> iterator;
-    private final DeletionVector deletionVector;
+    private final DeletionVectorJudger deletionVector;
 
     public ApplyDeletionFileRecordIterator(
-            FileRecordIterator<InternalRow> iterator, DeletionVector 
deletionVector) {
+            FileRecordIterator<InternalRow> iterator, DeletionVectorJudger 
deletionVector) {
         this.iterator = iterator;
         this.deletionVector = deletionVector;
     }
@@ -45,7 +47,7 @@ public class ApplyDeletionFileRecordIterator
     }
 
     @Override
-    public DeletionVector deletionVector() {
+    public DeletionVectorJudger deletionVector() {
         return deletionVector;
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionVectorReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionVectorReader.java
index 2fc292e54d..7787ac4b5a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionVectorReader.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/ApplyDeletionVectorReader.java
@@ -32,19 +32,33 @@ public class ApplyDeletionVectorReader implements 
FileRecordReader<InternalRow>
 
     private final FileRecordReader<InternalRow> reader;
 
-    private final DeletionVector deletionVector;
+    private final DeletionVectorJudger deletionVector;
 
     public ApplyDeletionVectorReader(
             FileRecordReader<InternalRow> reader, DeletionVector 
deletionVector) {
+        this(reader, deletionVector, 0L);
+    }
+
+    /**
+     * @param fileOffset offset from this reader's local returned position to 
the deletion vector
+     *     position. The wrapped judger is converted to reader-local positions 
here, so both {@link
+     *     ApplyDeletionFileRecordIterator#next()} and external consumers of 
{@link
+     *     DeletionFileRecordIterator#deletionVector()} use the same position 
mapping.
+     */
+    public ApplyDeletionVectorReader(
+            FileRecordReader<InternalRow> reader, DeletionVector 
deletionVector, long fileOffset) {
         this.reader = reader;
-        this.deletionVector = deletionVector;
+        this.deletionVector =
+                fileOffset == 0
+                        ? deletionVector
+                        : position -> deletionVector.isDeleted(fileOffset + 
position);
     }
 
     public RecordReader<InternalRow> reader() {
         return reader;
     }
 
-    public DeletionVector deletionVector() {
+    public DeletionVectorJudger deletionVector() {
         return deletionVector;
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/AppendDeleteFileMaintainer.java
 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/AppendDeleteFileMaintainer.java
index 70e00bc4ad..93ea8a7676 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/AppendDeleteFileMaintainer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/deletionvectors/append/AppendDeleteFileMaintainer.java
@@ -37,7 +37,19 @@ import java.util.Set;
 
 import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
 
-/** A {@link BaseAppendDeleteFileMaintainer} of unaware bucket append table. */
+/**
+ * A {@link BaseAppendDeleteFileMaintainer} for an unaware-bucket append 
table. This class maintains
+ * a mapping from data file names to DeletionVectors. It has different 
semantics for normal append
+ * tables and data evolution tables:
+ *
+ * <ul>
+ *   <li>For append tables, each entry maps a file to its corresponding 
deletion vector.
+ *   <li>For data evolution tables, a row is logically composed of several 
files covering the same
+ *       row range. The key becomes the oldest file in the group (i.e., the 
one with the smallest
+ *       max_seq_num). For each file group, the oldest file is used to look up 
the corresponding
+ *       deletion vectors.
+ * </ul>
+ */
 public class AppendDeleteFileMaintainer implements 
BaseAppendDeleteFileMaintainer {
 
     private final DeletionVectorsIndexFile dvIndexFile;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/AllPlaceholdersRecordReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/AllPlaceholdersRecordReader.java
new file mode 100644
index 0000000000..5a087439b0
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/AllPlaceholdersRecordReader.java
@@ -0,0 +1,149 @@
+/*
+ * 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.operation;
+
+import org.apache.paimon.data.BlobPlaceholder;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+
+import javax.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.List;
+
+/** A {@link FileRecordReader} which emits blob placeholder rows for a row-id 
range. */
+class AllPlaceholdersRecordReader implements FileRecordReader<InternalRow> {
+
+    private static final Path PLACEHOLDER_PATH = new Path("placeholder");
+
+    private final long firstRowId;
+    private final int fieldCount;
+    private final int blobIndex;
+    private final int rowIdIndex;
+    private final int seqNumIndex;
+    private final long sequenceNumber;
+    private final List<Range> selectedRanges;
+    private boolean returned;
+
+    AllPlaceholdersRecordReader(
+            long firstRowId,
+            long rowCount,
+            @Nullable List<Range> rowRanges,
+            RowType readRowType,
+            int blobIndex,
+            long sequenceNumber) {
+        this.firstRowId = firstRowId;
+        this.fieldCount = readRowType.getFieldCount();
+        this.blobIndex = blobIndex;
+        this.rowIdIndex = 
readRowType.getFieldIndex(SpecialFields.ROW_ID.name());
+        this.seqNumIndex = 
readRowType.getFieldIndex(SpecialFields.SEQUENCE_NUMBER.name());
+        this.sequenceNumber = sequenceNumber;
+        this.selectedRanges = selectedRanges(firstRowId, rowCount, rowRanges);
+    }
+
+    @Nullable
+    @Override
+    public FileRecordIterator<InternalRow> readBatch() {
+        if (returned || selectedRanges.isEmpty()) {
+            return null;
+        }
+        returned = true;
+        return new PlaceholderIterator();
+    }
+
+    @Override
+    public void close() {
+        // nothing to close
+    }
+
+    private List<Range> selectedRanges(
+            long firstRowId, long rowCount, @Nullable List<Range> rowRanges) {
+        if (rowCount <= 0) {
+            return Collections.emptyList();
+        }
+
+        List<Range> fullRange =
+                Collections.singletonList(new Range(firstRowId, firstRowId + 
rowCount - 1));
+        if (rowRanges == null) {
+            return fullRange;
+        }
+
+        return Range.and(fullRange, Range.sortAndMergeOverlap(rowRanges));
+    }
+
+    private InternalRow placeholderRow(long rowId) {
+        GenericRow row = new GenericRow(fieldCount);
+        row.setField(blobIndex, BlobPlaceholder.INSTANCE);
+        if (rowIdIndex >= 0) {
+            row.setField(rowIdIndex, rowId);
+        }
+        if (seqNumIndex >= 0) {
+            row.setField(seqNumIndex, sequenceNumber);
+        }
+        return row;
+    }
+
+    /** Iterator to emit placeholders with row ranges pushed. */
+    private class PlaceholderIterator implements 
FileRecordIterator<InternalRow> {
+
+        private int rangeIndex = 0;
+        private long nextRowId = selectedRanges.get(0).from;
+        private long returnedRowId = firstRowId - 1;
+
+        @Override
+        public long returnedPosition() {
+            return returnedRowId - firstRowId;
+        }
+
+        @Override
+        public Path filePath() {
+            return PLACEHOLDER_PATH;
+        }
+
+        @Nullable
+        @Override
+        public InternalRow next() {
+            while (rangeIndex < selectedRanges.size()) {
+                Range range = selectedRanges.get(rangeIndex);
+                if (nextRowId <= range.to) {
+                    returnedRowId = nextRowId;
+                    nextRowId++;
+                    return placeholderRow(returnedRowId);
+                }
+
+                rangeIndex++;
+                if (rangeIndex < selectedRanges.size()) {
+                    nextRowId = selectedRanges.get(rangeIndex).from;
+                }
+            }
+            return null;
+        }
+
+        @Override
+        public void releaseBatch() {
+            // nothing to release
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
index 30f214afcf..3a81b4d791 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFallbackRecordReader.java
@@ -23,7 +23,11 @@ import org.apache.paimon.data.BlobPlaceholder;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.mergetree.compact.ConcatRecordReader;
+import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.ReaderSupplier;
 import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Preconditions;
 import org.apache.paimon.utils.Range;
@@ -57,16 +61,22 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
     private final List<RecordReader<InternalRow>> groupReaders = new 
ArrayList<>();
     private final int blobIndex;
     private final int fieldCount;
+    private final int rowIdIndex;
+    private final int seqNumIndex;
     private boolean returned;
 
     BlobFallbackRecordReader(
             List<DataFileMeta> files,
             BlobFileReaderFactory readerFactory,
+            ReaderWrapper readerWrapper,
             List<Range> rowRanges,
             RowType readRowType,
-            int blobIndex) {
+            int blobIndex)
+            throws IOException {
         this.blobIndex = blobIndex;
         this.fieldCount = readRowType.getFieldCount();
+        this.rowIdIndex = 
readRowType.getFieldIndex(SpecialFields.ROW_ID.name());
+        this.seqNumIndex = 
readRowType.getFieldIndex(SpecialFields.SEQUENCE_NUMBER.name());
 
         checkArgument(!files.isEmpty(), "Blob bunch should not be empty.");
         long firstRowId = Long.MAX_VALUE;
@@ -104,8 +114,10 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
             groupReaders.add(
                     new ForceSingleBatchReader(
                             new BlobSequenceGroupRecordReader(
+                                    entry.getKey(),
                                     groupFiles,
                                     readerFactory,
+                                    readerWrapper,
                                     rowRanges,
                                     readRowType,
                                     blobIndex,
@@ -146,6 +158,7 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
             @Override
             public InternalRow next() throws IOException {
                 InternalRow result = null;
+                long rowId = -1L;
                 // We should always move each iterator forward
                 // This may significantly increase memory usage and decrease 
read efficiency
                 // if `blob-as-descriptor` is disabled and many non-null blobs 
are updated
@@ -172,9 +185,12 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
                     if (result == null && !isPlaceHolder(row)) {
                         result = row;
                     }
+                    if (rowIdIndex >= 0 && rowId < 0) {
+                        rowId = row.getLong(rowIdIndex);
+                    }
                 }
                 if (result == null) {
-                    result = nullBlobRow();
+                    result = nullBlobRow(rowId);
                 }
                 return result;
             }
@@ -188,8 +204,16 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
         };
     }
 
-    private InternalRow nullBlobRow() {
-        return new GenericRow(fieldCount);
+    private InternalRow nullBlobRow(long rowId) {
+        GenericRow row = new GenericRow(fieldCount);
+        if (rowIdIndex >= 0) {
+            row.setField(rowIdIndex, rowId);
+        }
+        // Set seq num as -1 to mark this row as an all-placeholder null
+        if (seqNumIndex >= 0) {
+            row.setField(seqNumIndex, -1L);
+        }
+        return row;
     }
 
     private boolean isPlaceHolder(InternalRow row) {
@@ -262,172 +286,105 @@ public class BlobFallbackRecordReader implements 
RecordReader<InternalRow> {
      */
     public static class BlobSequenceGroupRecordReader implements 
RecordReader<InternalRow> {
 
-        private final List<DataFileMeta> files;
-        private final BlobFileReaderFactory readerFactory;
-        // pushed row ranges
-        private final List<Range> rowRanges;
-        private final RowType readRowType;
-        private final int blobIndex;
-        private final long lastRowId;
-
-        private RecordReader<InternalRow> currentReader;
-        private DataFileMeta currentFile;
-        private int nextFileIndex;
-        private int nextRowRangeIndex;
-        // expected next row id
-        private long nextRowId;
-
-        private InternalRow placeholderRow;
+        private final RecordReader<InternalRow> reader;
 
         BlobSequenceGroupRecordReader(
+                long maxSeq,
                 List<DataFileMeta> files,
                 BlobFileReaderFactory readerFactory,
+                ReaderWrapper readerWrapper,
                 List<Range> rowRanges,
                 RowType readRowType,
                 int blobIndex,
                 long firstRowId,
-                long lastRowId) {
-            this.files = files;
-            this.readerFactory = readerFactory;
-            this.rowRanges = rowRanges == null ? null : 
Range.sortAndMergeOverlap(rowRanges);
-            this.readRowType = readRowType;
-            this.blobIndex = blobIndex;
-            this.lastRowId = lastRowId;
-
-            this.nextFileIndex = 0;
-            this.nextRowRangeIndex = 0;
-            setNextRowId(firstRowId);
-
-            this.placeholderRow = null;
-        }
-
-        @Nullable
-        @Override
-        public RecordIterator<InternalRow> readBatch() throws IOException {
-            while (true) {
-                if (currentReader != null) {
-                    RecordIterator<InternalRow> batch = 
currentReader.readBatch();
-                    if (batch != null) {
-                        return batch;
-                    }
-                    // row ranges have been pushed to readers
-                    // directly set nextRowId as the lastRowId + 1
-                    setNextRowId(lastRowId(currentFile) + 1);
-                    closeCurrentFileReader();
-                    continue;
-                }
-
-                if (nextRowId > lastRowId) {
-                    return null;
-                }
-
-                // skip files whose ranges are before nextRowId
-                while (nextFileIndex < files.size()
-                        && lastRowId(files.get(nextFileIndex)) < nextRowId) {
-                    nextFileIndex++;
-                }
-                if (nextFileIndex >= files.size()) {
-                    return placeHolderBatch(lastRowId);
-                }
-
-                DataFileMeta nextFile = files.get(nextFileIndex);
-                if (nextFile.nonNullFirstRowId() > nextRowId) {
-                    return placeHolderBatch(nextFile.nonNullFirstRowId() - 1);
-                }
-
-                createReader(nextFile);
-            }
-        }
-
-        /**
-         * Set nextRowId and try to move to the next selected row id. So the 
final nextRowId may be
-         * greater than the input value.
-         */
-        private void setNextRowId(long nextRowId) {
-            this.nextRowId = nextRowId;
-            tryMoveToSelectedRow();
-        }
-
-        private void tryMoveToSelectedRow() {
-            if (nextRowId > lastRowId || rowRanges == null) {
-                return;
-            }
-
-            while (nextRowRangeIndex < rowRanges.size()) {
-                Range range = rowRanges.get(nextRowRangeIndex);
-                if (nextRowId >= range.from && nextRowId <= range.to) {
-                    // if nextRowId is within the range, do not need to move
-                    return;
-                } else if (nextRowId < range.from) {
-                    // else if nextRowId < next range, move to next range's 
`from`
-                    nextRowId = range.from;
-                    return;
-                }
-                // else nextRowId > range.to, try next range
-                nextRowRangeIndex++;
-            }
-
-            // all ranges consumed, no need to read
-            nextRowId = lastRowId + 1;
+                long lastRowId)
+                throws IOException {
+            this.reader =
+                    ConcatRecordReader.create(
+                            createReaders(
+                                    maxSeq,
+                                    files,
+                                    readerFactory,
+                                    readerWrapper,
+                                    rowRanges,
+                                    readRowType,
+                                    blobIndex,
+                                    firstRowId,
+                                    lastRowId));
         }
 
-        private RecordIterator<InternalRow> placeHolderBatch(long endRowId) {
-            return new RecordIterator<InternalRow>() {
-                long rowId;
-
-                @Nullable
-                @Override
-                public InternalRow next() {
-                    rowId = nextRowId;
-                    if (rowId > endRowId) {
-                        return null;
-                    }
-                    setNextRowId(rowId + 1);
-                    return placeHolderRow();
-                }
-
-                @Override
-                public void releaseBatch() {
-                    // nothing to release
+        private List<ReaderSupplier<InternalRow>> createReaders(
+                long maxSeq,
+                List<DataFileMeta> files,
+                BlobFileReaderFactory readerFactory,
+                ReaderWrapper readerWrapper,
+                List<Range> rowRanges,
+                RowType readRowType,
+                int blobIndex,
+                long firstRowId,
+                long lastRowId) {
+            List<ReaderSupplier<InternalRow>> suppliers = new ArrayList<>();
+            long nextRowId = firstRowId;
+            for (DataFileMeta file : files) {
+                Range fileRange = file.nonNullRowIdRange();
+                if (nextRowId < fileRange.from) {
+                    long gapFirstRowId = nextRowId;
+                    long gapRowCount = fileRange.from - gapFirstRowId;
+                    Range gapRange = new Range(gapFirstRowId, fileRange.from - 
1);
+                    suppliers.add(
+                            () ->
+                                    readerWrapper.wrap(
+                                            new AllPlaceholdersRecordReader(
+                                                    gapFirstRowId,
+                                                    gapRowCount,
+                                                    rowRanges,
+                                                    readRowType,
+                                                    blobIndex,
+                                                    maxSeq),
+                                            gapRange));
                 }
-            };
-        }
-
-        private InternalRow placeHolderRow() {
-            if (placeholderRow == null) {
-                GenericRow row = new GenericRow(readRowType.getFieldCount());
-                row.setField(blobIndex, BlobPlaceholder.INSTANCE);
-                placeholderRow = row;
+                suppliers.add(() -> readerFactory.create(file));
+                nextRowId = fileRange.to + 1;
             }
-            return placeholderRow;
-        }
-
-        private long lastRowId(DataFileMeta file) {
-            return file.nonNullFirstRowId() + file.rowCount() - 1;
-        }
-
-        private void closeCurrentFileReader() throws IOException {
-            if (currentReader != null) {
-                currentReader.close();
-                currentReader = null;
+            if (nextRowId <= lastRowId) {
+                long gapFirstRowId = nextRowId;
+                long gapRowCount = lastRowId - gapFirstRowId + 1;
+                Range gapRange = new Range(gapFirstRowId, lastRowId);
+                suppliers.add(
+                        () ->
+                                readerWrapper.wrap(
+                                        new AllPlaceholdersRecordReader(
+                                                gapFirstRowId,
+                                                gapRowCount,
+                                                rowRanges,
+                                                readRowType,
+                                                blobIndex,
+                                                maxSeq),
+                                        gapRange));
             }
-            currentFile = null;
+            return suppliers;
         }
 
-        private void createReader(DataFileMeta nextFile) throws IOException {
-            currentFile = nextFile;
-            currentReader = readerFactory.create(nextFile);
-            nextFileIndex++;
+        @Nullable
+        @Override
+        public RecordIterator<InternalRow> readBatch() throws IOException {
+            return reader.readBatch();
         }
 
         @Override
         public void close() throws IOException {
-            closeCurrentFileReader();
+            reader.close();
         }
     }
 
     /** Factory to create readers. */
     interface BlobFileReaderFactory {
-        RecordReader<InternalRow> create(DataFileMeta file) throws IOException;
+        FileRecordReader<InternalRow> create(DataFileMeta file) throws 
IOException;
+    }
+
+    /** Wraps placeholder readers with shared post-processing. e.g. Apply 
Deletion Vectors. */
+    interface ReaderWrapper {
+        FileRecordReader<InternalRow> wrap(FileRecordReader<InternalRow> 
reader, Range range)
+                throws IOException;
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
index b5ea80b060..e097b3be53 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
@@ -62,12 +62,14 @@ import java.util.stream.Collectors;
 import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
 import static org.apache.paimon.manifest.ManifestFileMeta.allContainsRowId;
 import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
 
 /** {@link FileStoreScan} for data-evolution enabled table. */
 public class DataEvolutionFileStoreScan extends AppendOnlyFileStoreScan {
 
     private boolean dropStats = false;
     @Nullable private RowType readType;
+    private final boolean deletionVectorsEnabled;
 
     // Cache file's physical field id set per (schemaId, writeCols) to avoid 
recomputing during
     // per-file column pruning in postFilterManifestEntries.
@@ -94,6 +96,7 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
                 false,
                 deletionVectorsEnabled,
                 true);
+        this.deletionVectorsEnabled = deletionVectorsEnabled;
     }
 
     @Override
@@ -140,6 +143,7 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
         if (inputFilter != null
                 || limit == null
                 || limit <= 0
+                || deletionVectorsEnabled
                 || !allContainsRowId(manifestFiles)) {
             return super.readManifestEntries(manifestFiles, useSequential);
         }
@@ -212,11 +216,16 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
      * <p>When every file in the group lacks a requested column (e.g. an ADD 
COLUMN projection over
      * a row-disjoint pre-ALTER group), one file is kept as a row-count 
representative so the reader
      * can emit the right number of NULL-filled rows.
+     *
+     * <p>If Deletion-Vector is enabled, we always keep the oldest normal file 
for each group as the
+     * anchor file to lookup corresponding Deletion Files.
      */
     private List<ManifestEntry> pruneByReadType(List<ManifestEntry> group) {
         if (readType == null || group.size() <= 1) {
             return group;
         }
+        ManifestEntry anchor =
+                deletionVectorsEnabled ? retrieveAnchorFile(group, 
ManifestEntry::file) : null;
         Set<Integer> readFieldIds = new HashSet<>();
         for (DataField f : readType.getFields()) {
             readFieldIds.add(f.id());
@@ -231,6 +240,9 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
                 }
             }
         }
+        if (anchor != null && !kept.contains(anchor)) {
+            kept.add(anchor);
+        }
         // Group must contribute at least one file so the reader sees rowCount 
and can NULL-fill
         // missing columns for the projection's rows.
         return kept.isEmpty() ? Collections.singletonList(group.get(0)) : kept;
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 31ff6cd614..1fd7f55c3a 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
@@ -23,6 +23,8 @@ import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.append.ForceSingleBatchReader;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.deletionvectors.ApplyDeletionVectorReader;
+import org.apache.paimon.deletionvectors.DeletionVector;
 import org.apache.paimon.disk.IOManager;
 import org.apache.paimon.format.FileFormatDiscover;
 import org.apache.paimon.format.FormatKey;
@@ -45,6 +47,7 @@ import org.apache.paimon.schema.SchemaManager;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.DeletionFile;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypeRoot;
@@ -78,6 +81,7 @@ import static java.util.Comparator.comparingLong;
 import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
 import static org.apache.paimon.table.SpecialFields.rowTypeWithRowTracking;
 import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
 
@@ -160,6 +164,13 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
     private RecordReader<InternalRow> createReader(
             DataSplit dataSplit, List<Range> rowRanges, RowType readRowType) 
throws IOException {
         List<DataFileMeta> files = dataSplit.dataFiles();
+
+        List<DeletionFile> deletionFiles = 
dataSplit.deletionFiles().orElse(null);
+        DeletionVector.Factory deletionVectorFactory =
+                deletionFiles == null || 
deletionFiles.stream().allMatch(Objects::isNull)
+                        ? null
+                        : DeletionVector.factory(fileIO, files, deletionFiles);
+
         BinaryRow partition = dataSplit.partition();
         DataFilePathFactory dataFilePathFactory =
                 pathFactory.createDataFilePathFactory(partition, 
dataSplit.bucket());
@@ -182,25 +193,33 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             if (needMergeFiles.size() == 1 || 
readRowType.getFields().isEmpty()) {
                 // No need to merge fields, just create a single file reader
                 suppliers.add(
-                        () ->
-                                createFileReader(
-                                        partition,
-                                        dataFilePathFactory,
-                                        needMergeFiles.get(0),
-                                        formatBuilder,
-                                        rowRanges,
-                                        readRowType));
+                        () -> {
+                            DeletionVectorWithRange deletionVector =
+                                    readDeletionVector(needMergeFiles, 
deletionVectorFactory);
+                            return createFileReader(
+                                    partition,
+                                    dataFilePathFactory,
+                                    needMergeFiles.get(0),
+                                    formatBuilder,
+                                    rowRanges,
+                                    readRowType,
+                                    deletionVector);
+                        });
 
             } else {
                 suppliers.add(
-                        () ->
-                                createUnionReader(
-                                        needMergeFiles,
-                                        partition,
-                                        dataFilePathFactory,
-                                        formatBuilder,
-                                        rowRanges,
-                                        readRowType));
+                        () -> {
+                            DeletionVectorWithRange deletionVector =
+                                    readDeletionVector(needMergeFiles, 
deletionVectorFactory);
+                            return createUnionReader(
+                                    needMergeFiles,
+                                    partition,
+                                    dataFilePathFactory,
+                                    formatBuilder,
+                                    rowRanges,
+                                    readRowType,
+                                    deletionVector);
+                        });
             }
         }
 
@@ -222,7 +241,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             DataFilePathFactory dataFilePathFactory,
             Builder formatBuilder,
             List<Range> rowRanges,
-            RowType readRowType)
+            RowType readRowType,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         List<FieldBunch> fieldsFiles =
                 splitFieldBunches(
@@ -318,7 +338,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                                         dataFilePathFactory,
                                         formatReaderMapping,
                                         rowRanges,
-                                        partialReadRowType));
+                                        partialReadRowType,
+                                        deletionVector));
             }
         }
 
@@ -341,7 +362,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             DataFilePathFactory dataFilePathFactory,
             FormatReaderMapping formatReaderMapping,
             List<Range> rowRanges,
-            RowType readRowType)
+            RowType readRowType,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         if (bunch instanceof DataBunch) {
             // for data bunch, directly read the single file
@@ -351,11 +373,17 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                     dataFilePathFactory,
                     formatReaderMapping,
                     rowRanges,
-                    readRowType);
+                    readRowType,
+                    deletionVector);
         } else if (bunch instanceof VectorFileBunch) {
             // for vector bunch, sequential read all data files and concat them
             return sequentialReadFiles(
-                    bunch.files(), partition, dataFilePathFactory, 
formatReaderMapping, rowRanges);
+                    bunch.files(),
+                    partition,
+                    dataFilePathFactory,
+                    formatReaderMapping,
+                    rowRanges,
+                    deletionVector);
         } else if (bunch instanceof BlobFileBunch) {
             // for blob bunch, fallback on placeholders
 
@@ -366,7 +394,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                         partition,
                         dataFilePathFactory,
                         formatReaderMapping,
-                        rowRanges);
+                        rowRanges,
+                        deletionVector);
             }
             int blobIndex = findBlobFieldIndex(readRowType);
             checkArgument(blobIndex >= 0, "Blob bunch read type should contain 
a blob field.");
@@ -379,7 +408,9 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                                     dataFilePathFactory,
                                     formatReaderMapping,
                                     rowRanges,
-                                    readRowType),
+                                    readRowType,
+                                    deletionVector),
+                    (reader, range) -> applyDeletionVector(reader, range, 
deletionVector),
                     rowRanges,
                     readRowType,
                     blobIndex);
@@ -393,30 +424,24 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             BinaryRow partition,
             DataFilePathFactory dataFilePathFactory,
             FormatReaderMapping formatReaderMapping,
-            List<Range> rowRanges)
+            List<Range> rowRanges,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         List<ReaderSupplier<InternalRow>> readerSuppliers = new ArrayList<>();
         for (DataFileMeta file : files) {
-            RoaringBitmap32 selection = file.toFileSelection(rowRanges);
-            FormatReaderContext formatReaderContext =
-                    new FormatReaderContext(
-                            fileIO, dataFilePathFactory.toPath(file), 
file.fileSize(), selection);
             readerSuppliers.add(
                     () ->
-                            new DataFileRecordReader(
+                            createFileReader(
+                                    partition,
+                                    file,
+                                    formatReaderMapping,
+                                    rowRanges,
                                     readRowType,
-                                    formatReaderMapping.getReaderFactory(),
-                                    formatReaderContext,
-                                    coreOptions.scanIgnoreCorruptFile(),
-                                    coreOptions.scanIgnoreLostFile(),
-                                    formatReaderMapping.getIndexMapping(),
-                                    formatReaderMapping.getCastMapping(),
-                                    PartitionUtils.create(
-                                            
formatReaderMapping.getPartitionPair(), partition),
-                                    true,
-                                    file.firstRowId(),
-                                    file.maxSequenceNumber(),
-                                    formatReaderMapping.getSystemFields()));
+                                    new FileReadTarget(
+                                            
DataFilePathFactory.formatIdentifier(file.fileName()),
+                                            dataFilePathFactory.toPath(file),
+                                            file.fileSize()),
+                                    deletionVector));
         }
         return ConcatRecordReader.create(readerSuppliers);
     }
@@ -436,7 +461,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             DataFileMeta file,
             Builder formatBuilder,
             List<Range> rowRanges,
-            RowType readRowType)
+            RowType readRowType,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         FileReadTarget readTarget = readTarget(file, dataFilePathFactory, 
rowRanges);
         String formatIdentifier = readTarget.formatIdentifier;
@@ -452,7 +478,13 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                                                 ? schema
                                                 : 
schemaFetcher.apply(schemaId)));
         return createFileReader(
-                partition, file, formatReaderMapping, rowRanges, readRowType, 
readTarget);
+                partition,
+                file,
+                formatReaderMapping,
+                rowRanges,
+                readRowType,
+                readTarget,
+                deletionVector);
     }
 
     private FileRecordReader<InternalRow> createFileReader(
@@ -461,7 +493,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             DataFilePathFactory dataFilePathFactory,
             FormatReaderMapping formatReaderMapping,
             List<Range> rowRanges,
-            RowType readRowType)
+            RowType readRowType,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         return createFileReader(
                 partition,
@@ -469,7 +502,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                 formatReaderMapping,
                 rowRanges,
                 readRowType,
-                readTarget(file, dataFilePathFactory, rowRanges));
+                readTarget(file, dataFilePathFactory, rowRanges),
+                deletionVector);
     }
 
     private FileRecordReader<InternalRow> createFileReader(
@@ -478,24 +512,67 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
             FormatReaderMapping formatReaderMapping,
             List<Range> rowRanges,
             RowType readRowType,
-            FileReadTarget readTarget)
+            FileReadTarget readTarget,
+            @Nullable DeletionVectorWithRange deletionVector)
             throws IOException {
         RoaringBitmap32 selection = file.toFileSelection(rowRanges);
         FormatReaderContext formatReaderContext =
                 new FormatReaderContext(fileIO, readTarget.path, 
readTarget.fileSize, selection);
-        return new DataFileRecordReader(
-                readRowType,
-                formatReaderMapping.getReaderFactory(),
-                formatReaderContext,
-                coreOptions.scanIgnoreCorruptFile(),
-                coreOptions.scanIgnoreLostFile(),
-                formatReaderMapping.getIndexMapping(),
-                formatReaderMapping.getCastMapping(),
-                PartitionUtils.create(formatReaderMapping.getPartitionPair(), 
partition),
-                true,
-                file.firstRowId(),
-                file.maxSequenceNumber(),
-                formatReaderMapping.getSystemFields());
+        FileRecordReader<InternalRow> fileRecordReader =
+                new DataFileRecordReader(
+                        readRowType,
+                        formatReaderMapping.getReaderFactory(),
+                        formatReaderContext,
+                        coreOptions.scanIgnoreCorruptFile(),
+                        coreOptions.scanIgnoreLostFile(),
+                        formatReaderMapping.getIndexMapping(),
+                        formatReaderMapping.getCastMapping(),
+                        
PartitionUtils.create(formatReaderMapping.getPartitionPair(), partition),
+                        true,
+                        file.firstRowId(),
+                        file.maxSequenceNumber(),
+                        formatReaderMapping.getSystemFields());
+        return applyDeletionVector(fileRecordReader, file.nonNullRowIdRange(), 
deletionVector);
+    }
+
+    private FileRecordReader<InternalRow> applyDeletionVector(
+            FileRecordReader<InternalRow> reader,
+            Range readerRange,
+            @Nullable DeletionVectorWithRange deletionVector) {
+        if (deletionVector == null || deletionVector.deletionVector.isEmpty()) 
{
+            return reader;
+        }
+
+        checkArgument(
+                deletionVector.range.from <= readerRange.from
+                        && deletionVector.range.to >= readerRange.to,
+                "Deletion vector range %s should contain reader range %s.",
+                deletionVector.range,
+                readerRange);
+
+        return new ApplyDeletionVectorReader(
+                reader,
+                deletionVector.deletionVector,
+                // Convert anchor-range DV positions to this reader's local 
returned positions.
+                readerRange.from - deletionVector.range.from);
+    }
+
+    @Nullable
+    private DeletionVectorWithRange readDeletionVector(
+            List<DataFileMeta> group, @Nullable DeletionVector.Factory 
deletionVectorFactory)
+            throws IOException {
+        if (deletionVectorFactory == null) {
+            return null;
+        }
+
+        // pack row ranges and deletion vector
+        DataFileMeta anchor = retrieveAnchorFile(group, Function.identity());
+        Range range = anchor.nonNullRowIdRange();
+
+        return deletionVectorFactory
+                .create(anchor.fileName())
+                .map(dv -> new DeletionVectorWithRange(range, dv))
+                .orElse(null);
     }
 
     private FileReadTarget readTarget(
@@ -603,6 +680,17 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
         }
     }
 
+    private static class DeletionVectorWithRange {
+
+        private final Range range;
+        private final DeletionVector deletionVector;
+
+        private DeletionVectorWithRange(Range range, DeletionVector 
deletionVector) {
+            this.range = range;
+            this.deletionVector = deletionVector;
+        }
+    }
+
     @VisibleForTesting
     public static List<FieldBunch> splitFieldBunches(
             List<DataFileMeta> needMergeFiles, Function<DataFileMeta, RowType> 
fileToRowType) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index b67c94ac81..491da71ff1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -894,9 +894,6 @@ public class SchemaValidation {
             checkArgument(
                     rowTrackingEnabled,
                     "Data evolution config must enabled with 
row-tracking.enabled");
-            checkArgument(
-                    !options.deletionVectorsEnabled(),
-                    "Data evolution config must disabled with 
deletion-vectors.enabled");
             checkArgument(
                     !options.clusteringIncrementalEnabled(),
                     "Data evolution config must disabled with 
clustering.incremental");
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java 
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java
index 2ba4410f34..df31763a30 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java
@@ -72,6 +72,11 @@ public class DataSplit implements Split {
     @Nullable private Integer totalBuckets;
 
     private List<DataFileMeta> dataFiles;
+
+    /**
+     * This list should have the same size as dataFiles. For data evolution 
tables, only anchor
+     * files would have corresponding deletion file.
+     */
     @Nullable private List<DeletionFile> dataDeletionFiles;
 
     private boolean isStreaming = false;
@@ -141,7 +146,7 @@ public class DataSplit implements Split {
     }
 
     private boolean rawMergedRowCountAvailable() {
-        return rawConvertible
+        return rawConvertible()
                 && (dataDeletionFiles == null
                         || dataDeletionFiles.stream()
                                 .allMatch(f -> f == null || f.cardinality() != 
null));
@@ -168,6 +173,14 @@ public class DataSplit implements Split {
                 return false;
             }
         }
+
+        if (dataDeletionFiles != null) {
+            for (DeletionFile deletionFile : dataDeletionFiles) {
+                if (deletionFile != null && deletionFile.cardinality() == 
null) {
+                    return false;
+                }
+            }
+        }
         return true;
     }
 
@@ -182,6 +195,13 @@ public class DataSplit implements Split {
             }
             sum += maxCount;
         }
+        if (dataDeletionFiles != null) {
+            for (DeletionFile deletionFile : dataDeletionFiles) {
+                if (deletionFile != null) {
+                    sum -= deletionFile.cardinality();
+                }
+            }
+        }
         return sum;
     }
 
@@ -245,7 +265,7 @@ public class DataSplit implements Split {
 
     @Override
     public Optional<List<RawFile>> convertToRawFiles() {
-        if (rawConvertible) {
+        if (rawConvertible()) {
             return Optional.of(
                     dataFiles.stream()
                             .map(f -> makeRawTableFile(bucketPath, f))
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
new file mode 100644
index 0000000000..4f6cb81cda
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
@@ -0,0 +1,64 @@
+/*
+ * 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.utils;
+
+import org.apache.paimon.io.DataFileMeta;
+
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.function.Function;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** Util class for Deletion Vectors. */
+public class DataEvolutionUtils {
+
+    /**
+     * Retrieve the anchor file of a row range group. Always the oldest normal 
file. Files are
+     * compared by (max_seq, fileName) pairs.
+     */
+    public static <T> T retrieveAnchorFile(
+            Collection<T> entries, Function<T, DataFileMeta> fileMetaFunc) {
+        T anchor = null;
+        DataFileMeta minMeta = null;
+
+        Comparator<DataFileMeta> fileComparator =
+                Comparator.comparingLong(DataFileMeta::maxSequenceNumber)
+                        .thenComparing(DataFileMeta::fileName);
+
+        for (T entry : entries) {
+            DataFileMeta meta = fileMetaFunc.apply(entry);
+            if (isBlobFile(meta.fileName()) || 
isVectorStoreFile(meta.fileName())) {
+                continue;
+            }
+
+            if (minMeta == null || fileComparator.compare(meta, minMeta) < 0) {
+                minMeta = meta;
+                anchor = entry;
+            }
+        }
+
+        checkState(
+                anchor != null,
+                "Data-evolution deletion vectors should have a normal anchor 
file in each row range group.");
+        return anchor;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinatorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinatorTest.java
index 3c1ba1f796..6f900cf32c 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinatorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionCompactCoordinatorTest.java
@@ -34,6 +34,7 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.partition.PartitionPredicate;
 import org.apache.paimon.stats.StatsTestUtils;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.EndOfScanException;
 import org.apache.paimon.table.source.ScanMode;
 import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.types.DataField;
@@ -56,6 +57,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.LongFunction;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -554,6 +556,40 @@ public class DataEvolutionCompactCoordinatorTest {
                 .containsExactly(b1.file().fileName(), b2.file().fileName());
     }
 
+    @Test
+    public void testPlanEndsScanWhenDeletionVectorsEnabled() {
+        Options options = new Options();
+        options.set(CoreOptions.DATA_EVOLUTION_ENABLED, true);
+        options.set(CoreOptions.DELETION_VECTORS_ENABLED, true);
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.coreOptions()).thenReturn(new CoreOptions(options));
+
+        DataEvolutionCompactCoordinator coordinator =
+                new DataEvolutionCompactCoordinator(table, false, false);
+
+        
assertThatThrownBy(coordinator::plan).isInstanceOf(EndOfScanException.class);
+    }
+
+    @Test
+    public void testCompactTaskRejectsDeletionVectorEnabledTable() {
+        Options options = new Options();
+        options.set(CoreOptions.DELETION_VECTORS_ENABLED, true);
+        FileStoreTable table = mock(FileStoreTable.class);
+        when(table.coreOptions()).thenReturn(new CoreOptions(options));
+
+        DataEvolutionCompactTask task =
+                new DataEvolutionCompactTask(
+                        BinaryRow.EMPTY_ROW,
+                        Collections.singletonList(
+                                createDataFileMeta("file1.parquet", 0L, 100L, 
0, 1024)),
+                        false);
+
+        assertThatThrownBy(() -> task.doCompact(table, "user"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(
+                        "Data evolution compaction does not support deletion 
vectors.");
+    }
+
     private ManifestEntry makeEntry(
             String fileName, long firstRowId, long rowCount, long fileSize) {
         return makeEntryWithSize(
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/deletionvectors/DeletionVectorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/deletionvectors/DeletionVectorTest.java
index a33f729963..1800f4c332 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/deletionvectors/DeletionVectorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/deletionvectors/DeletionVectorTest.java
@@ -18,8 +18,17 @@
 
 package org.apache.paimon.deletionvectors;
 
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.FileRecordReader;
+
 import org.junit.jupiter.api.Test;
 
+import javax.annotation.Nullable;
+
+import java.io.IOException;
 import java.util.HashSet;
 import java.util.Random;
 import java.util.concurrent.ThreadLocalRandom;
@@ -29,6 +38,54 @@ import static org.assertj.core.api.Assertions.assertThat;
 /** Test for {@link DeletionVector}. */
 public class DeletionVectorTest {
 
+    @Test
+    public void testApplyDeletionFileRecordIteratorUsesProvidedJudger() throws 
Exception {
+        DeletionVector deletionVector = new BitmapDeletionVector();
+        deletionVector.checkedDelete(12);
+        deletionVector.checkedDelete(14);
+
+        ApplyDeletionFileRecordIterator iterator =
+                new ApplyDeletionFileRecordIterator(
+                        new TestingFileRecordIterator(5),
+                        position -> deletionVector.isDeleted(10 + position));
+
+        assertThat(iterator.deletionVector().isDeleted(2)).isTrue();
+        assertThat(iterator.deletionVector().isDeleted(4)).isTrue();
+        assertThat(iterator.deletionVector().isDeleted(12)).isFalse();
+
+        assertThat(iterator.next().getInt(0)).isEqualTo(0);
+        assertThat(iterator.next().getInt(0)).isEqualTo(1);
+        assertThat(iterator.next().getInt(0)).isEqualTo(3);
+        assertThat(iterator.next()).isNull();
+    }
+
+    @Test
+    public void testApplyDeletionVectorReaderUsesOffsetAwareJudger() throws 
Exception {
+        DeletionVector deletionVector = new BitmapDeletionVector();
+        deletionVector.checkedDelete(12);
+        deletionVector.checkedDelete(14);
+
+        ApplyDeletionVectorReader reader =
+                new ApplyDeletionVectorReader(
+                        new TestingFileRecordReader(new 
TestingFileRecordIterator(5)),
+                        deletionVector,
+                        10);
+
+        assertThat(reader.deletionVector().isDeleted(2)).isTrue();
+        assertThat(reader.deletionVector().isDeleted(4)).isTrue();
+        assertThat(reader.deletionVector().isDeleted(12)).isFalse();
+
+        FileRecordIterator<InternalRow> batch = reader.readBatch();
+        assertThat(batch).isInstanceOf(ApplyDeletionFileRecordIterator.class);
+        ApplyDeletionFileRecordIterator iterator = 
(ApplyDeletionFileRecordIterator) batch;
+        
assertThat(iterator.deletionVector()).isSameAs(reader.deletionVector());
+
+        assertThat(iterator.next().getInt(0)).isEqualTo(0);
+        assertThat(iterator.next().getInt(0)).isEqualTo(1);
+        assertThat(iterator.next().getInt(0)).isEqualTo(3);
+        assertThat(iterator.next()).isNull();
+    }
+
     @Test
     public void testBitmapDeletionVector() {
         HashSet<Integer> toDelete = new HashSet<>();
@@ -138,4 +195,61 @@ public class DeletionVectorTest {
             assertThat(bitmap64DeletionVector.isDeleted(i)).isFalse();
         }
     }
+
+    private static class TestingFileRecordIterator implements 
FileRecordIterator<InternalRow> {
+
+        private final int rows;
+        private int nextPosition;
+        private int returnedPosition = -1;
+
+        private TestingFileRecordIterator(int rows) {
+            this.rows = rows;
+        }
+
+        @Override
+        public long returnedPosition() {
+            return returnedPosition;
+        }
+
+        @Override
+        public Path filePath() {
+            return new Path("/tmp/testing");
+        }
+
+        @Nullable
+        @Override
+        public InternalRow next() throws IOException {
+            if (nextPosition >= rows) {
+                return null;
+            }
+            returnedPosition = nextPosition++;
+            return GenericRow.of(returnedPosition);
+        }
+
+        @Override
+        public void releaseBatch() {}
+    }
+
+    private static class TestingFileRecordReader implements 
FileRecordReader<InternalRow> {
+
+        private final FileRecordIterator<InternalRow> iterator;
+        private boolean returned;
+
+        private TestingFileRecordReader(FileRecordIterator<InternalRow> 
iterator) {
+            this.iterator = iterator;
+        }
+
+        @Nullable
+        @Override
+        public FileRecordIterator<InternalRow> readBatch() {
+            if (returned) {
+                return null;
+            }
+            returned = true;
+            return iterator;
+        }
+
+        @Override
+        public void close() {}
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/AllPlaceholdersRecordReaderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/AllPlaceholdersRecordReaderTest.java
new file mode 100644
index 0000000000..7fbf912a64
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/AllPlaceholdersRecordReaderTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.operation;
+
+import org.apache.paimon.data.BlobPlaceholder;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link AllPlaceholdersRecordReader}. */
+public class AllPlaceholdersRecordReaderTest {
+
+    private static final int BLOB_INDEX = 0;
+    private static final int ROW_ID_INDEX = 1;
+    private static final int SEQUENCE_NUMBER_INDEX = 2;
+    private static final long SEQUENCE_NUMBER = 100L;
+    private static final RowType READ_ROW_TYPE =
+            new RowType(
+                    Arrays.asList(
+                            new DataField(BLOB_INDEX, "blob_col", 
DataTypes.BLOB()),
+                            new DataField(
+                                    ROW_ID_INDEX, SpecialFields.ROW_ID.name(), 
DataTypes.BIGINT()),
+                            new DataField(
+                                    SEQUENCE_NUMBER_INDEX,
+                                    SpecialFields.SEQUENCE_NUMBER.name(),
+                                    DataTypes.BIGINT())));
+
+    @Test
+    public void testFullScan() throws Exception {
+        AllPlaceholdersRecordReader reader =
+                new AllPlaceholdersRecordReader(
+                        5L, 4L, null, READ_ROW_TYPE, BLOB_INDEX, 
SEQUENCE_NUMBER);
+
+        FileRecordIterator<InternalRow> iterator = reader.readBatch();
+        assertNextPlaceholder(iterator, 5L, 0L);
+        assertNextPlaceholder(iterator, 6L, 1L);
+        assertNextPlaceholder(iterator, 7L, 2L);
+        assertNextPlaceholder(iterator, 8L, 3L);
+        assertThat(iterator.next()).isNull();
+        iterator.releaseBatch();
+        assertThat(reader.readBatch()).isNull();
+    }
+
+    @Test
+    public void testRowRangePushed() throws Exception {
+        AllPlaceholdersRecordReader reader =
+                new AllPlaceholdersRecordReader(
+                        10L,
+                        10L,
+                        Arrays.asList(new Range(8, 11), new Range(13, 14), new 
Range(18, 22)),
+                        READ_ROW_TYPE,
+                        BLOB_INDEX,
+                        SEQUENCE_NUMBER);
+
+        FileRecordIterator<InternalRow> iterator = reader.readBatch();
+        assertNextPlaceholder(iterator, 10L, 0L);
+        assertNextPlaceholder(iterator, 11L, 1L);
+        assertNextPlaceholder(iterator, 13L, 3L);
+        assertNextPlaceholder(iterator, 14L, 4L);
+        assertNextPlaceholder(iterator, 18L, 8L);
+        assertNextPlaceholder(iterator, 19L, 9L);
+        assertThat(iterator.next()).isNull();
+        iterator.releaseBatch();
+        assertThat(reader.readBatch()).isNull();
+    }
+
+    private static void assertNextPlaceholder(
+            FileRecordIterator<InternalRow> iterator, long rowId, long 
returnedPosition)
+            throws Exception {
+        InternalRow row = iterator.next();
+        assertThat(row).isNotNull();
+        assertThat(row.getBlob(BLOB_INDEX)).isSameAs(BlobPlaceholder.INSTANCE);
+        assertThat(row.getLong(ROW_ID_INDEX)).isEqualTo(rowId);
+        
assertThat(row.getLong(SEQUENCE_NUMBER_INDEX)).isEqualTo(SEQUENCE_NUMBER);
+        assertThat(iterator.returnedPosition()).isEqualTo(returnedPosition);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
index 596dbcd955..fc57cf9f2f 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/BlobFallbackRecordReaderTest.java
@@ -23,9 +23,14 @@ import org.apache.paimon.data.BlobPlaceholder;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.deletionvectors.ApplyDeletionVectorReader;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.manifest.FileSource;
 import 
org.apache.paimon.operation.BlobFallbackRecordReader.BlobSequenceGroupRecordReader;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.reader.FileRecordReader;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.reader.RecordReader.RecordIterator;
 import org.apache.paimon.stats.SimpleStats;
@@ -58,6 +63,17 @@ public class BlobFallbackRecordReaderTest {
                             new DataField(1, SpecialFields.ROW_ID.name(), 
DataTypes.BIGINT()),
                             new DataField(
                                     2, SpecialFields.SEQUENCE_NUMBER.name(), 
DataTypes.BIGINT())));
+    private static final RowType READ_ROW_TYPE_WITH_ROW_ID_ONLY =
+            new RowType(
+                    Arrays.asList(
+                            new DataField(BLOB_INDEX, BLOB_FIELD, 
DataTypes.BLOB()),
+                            new DataField(1, SpecialFields.ROW_ID.name(), 
DataTypes.BIGINT())));
+    private static final RowType READ_ROW_TYPE_WITH_SEQUENCE_ONLY =
+            new RowType(
+                    Arrays.asList(
+                            new DataField(BLOB_INDEX, BLOB_FIELD, 
DataTypes.BLOB()),
+                            new DataField(
+                                    1, SpecialFields.SEQUENCE_NUMBER.name(), 
DataTypes.BIGINT())));
 
     @Test
     public void testBlobSequenceGroupReaderWithRowRanges() throws Exception {
@@ -144,6 +160,48 @@ public class BlobFallbackRecordReaderTest {
                         placeholderRows(newFile, 0, oldFile, 0));
 
         assertThat(rows.rowIds).isEmpty();
+        assertThat(rows.nullBlobRowIds).containsExactly(0L);
+        assertThat(rows.nullBlobSequenceNumbers).containsExactly(-1L);
+        assertThat(rows.nullBlobRowCount).isEqualTo(1);
+        assertThat(rows.placeholderRowCount).isEqualTo(0);
+    }
+
+    @Test
+    public void 
testBlobFallbackRecordReaderReturnsRowIdIfAllRowsArePlaceholders()
+            throws Exception {
+        DataFileMeta newFile = blobFile("new-placeholder-file", 0, 1, 2);
+        DataFileMeta oldFile = blobFile("old-placeholder-file", 0, 1, 1);
+
+        ReadResult rows =
+                readFallback(
+                        Arrays.asList(newFile, oldFile),
+                        null,
+                        placeholderRows(newFile, 0, oldFile, 0),
+                        READ_ROW_TYPE_WITH_ROW_ID_ONLY);
+
+        assertThat(rows.rowIds).isEmpty();
+        assertThat(rows.nullBlobRowIds).containsExactly(0L);
+        assertThat(rows.nullBlobSequenceNumbers).isEmpty();
+        assertThat(rows.nullBlobRowCount).isEqualTo(1);
+        assertThat(rows.placeholderRowCount).isEqualTo(0);
+    }
+
+    @Test
+    public void 
testBlobFallbackRecordReaderReturnsSequenceIfAllRowsArePlaceholders()
+            throws Exception {
+        DataFileMeta newFile = blobFile("new-placeholder-file", 0, 1, 2);
+        DataFileMeta oldFile = blobFile("old-placeholder-file", 0, 1, 1);
+
+        ReadResult rows =
+                readFallback(
+                        Arrays.asList(newFile, oldFile),
+                        null,
+                        placeholderRows(newFile, 0, oldFile, 0),
+                        READ_ROW_TYPE_WITH_SEQUENCE_ONLY);
+
+        assertThat(rows.rowIds).isEmpty();
+        assertThat(rows.nullBlobRowIds).isEmpty();
+        assertThat(rows.nullBlobSequenceNumbers).containsExactly(-1L);
         assertThat(rows.nullBlobRowCount).isEqualTo(1);
         assertThat(rows.placeholderRowCount).isEqualTo(0);
     }
@@ -168,16 +226,62 @@ public class BlobFallbackRecordReaderTest {
                 .containsExactly(2L, 2L, 2L, 2L, 2L, 1L, 1L, 1L, 1L, 1L, 1L, 
1L, 1L, 2L, 2L);
     }
 
+    @Test
+    public void 
testBlobFallbackRecordReaderAppliesDeletionVectorToPlaceholderGaps()
+            throws Exception {
+        DataFileMeta oldFile = blobFile("old-file", 0, 6, 1);
+        DataFileMeta newFile = blobFile("new-file", 0, 3, 2);
+        BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+        deletionVector.delete(4);
+
+        ReadResult rows =
+                ReadResult.read(
+                        new BlobFallbackRecordReader(
+                                Arrays.asList(newFile, oldFile),
+                                file ->
+                                        new ApplyDeletionVectorReader(
+                                                oneRowPerBatchReader(
+                                                        file,
+                                                        fileRows(
+                                                                file,
+                                                                null,
+                                                                
placeholderRows(newFile, 1))),
+                                                deletionVector,
+                                                file.nonNullFirstRowId()),
+                                (reader, range) ->
+                                        new ApplyDeletionVectorReader(
+                                                reader, deletionVector, 
range.from),
+                                null,
+                                READ_ROW_TYPE,
+                                BLOB_INDEX));
+
+        assertThat(rows.rowIds).containsExactly(0L, 1L, 2L, 3L, 5L);
+        assertThat(rows.sequenceNumbers).containsExactly(2L, 1L, 2L, 1L, 1L);
+    }
+
     private static ReadResult readFallback(
             List<DataFileMeta> files, List<Range> rowRanges, Set<String> 
placeholderRows)
             throws Exception {
+        return readFallback(files, rowRanges, placeholderRows, READ_ROW_TYPE);
+    }
+
+    private static ReadResult readFallback(
+            List<DataFileMeta> files,
+            List<Range> rowRanges,
+            Set<String> placeholderRows,
+            RowType readRowType)
+            throws Exception {
         return ReadResult.read(
                 new BlobFallbackRecordReader(
                         files,
-                        file -> oneRowPerBatchReader(fileRows(file, rowRanges, 
placeholderRows)),
+                        file ->
+                                oneRowPerBatchReader(
+                                        file, fileRows(file, rowRanges, 
placeholderRows)),
+                        (reader, range) -> reader,
                         rowRanges,
-                        READ_ROW_TYPE,
-                        BLOB_INDEX));
+                        readRowType,
+                        BLOB_INDEX),
+                readRowType);
     }
 
     private static ReadResult readSequenceGroup(
@@ -189,13 +293,16 @@ public class BlobFallbackRecordReaderTest {
             throws Exception {
         return ReadResult.read(
                 new BlobSequenceGroupRecordReader(
+                        sequenceNumber,
                         files,
-                        file -> oneRowPerBatchReader(fileRows(file, 
rowRanges)),
+                        file -> oneRowPerBatchReader(file, fileRows(file, 
rowRanges)),
+                        (reader, range) -> reader,
                         rowRanges,
                         READ_ROW_TYPE,
                         BLOB_INDEX,
                         firstRowId,
-                        lastRowId));
+                        lastRowId),
+                READ_ROW_TYPE);
     }
 
     private static DataFileMeta blobFile(
@@ -324,27 +431,40 @@ public class BlobFallbackRecordReaderTest {
         return row;
     }
 
-    private static RecordReader<InternalRow> 
oneRowPerBatchReader(List<InternalRow> rows) {
-        return new RecordReader<InternalRow>() {
+    private static FileRecordReader<InternalRow> oneRowPerBatchReader(
+            DataFileMeta file, List<InternalRow> rows) {
+        return new FileRecordReader<InternalRow>() {
 
             int index;
+            long returnedPosition = -1L;
 
             @Override
-            public RecordIterator<InternalRow> readBatch() {
+            public FileRecordIterator<InternalRow> readBatch() {
                 if (index >= rows.size()) {
                     return null;
                 }
                 InternalRow row = rows.get(index++);
-                return new RecordIterator<InternalRow>() {
+                return new FileRecordIterator<InternalRow>() {
 
                     boolean returned;
 
+                    @Override
+                    public long returnedPosition() {
+                        return returnedPosition;
+                    }
+
+                    @Override
+                    public Path filePath() {
+                        return new Path(file.fileName());
+                    }
+
                     @Override
                     public InternalRow next() {
                         if (returned) {
                             return null;
                         }
                         returned = true;
+                        returnedPosition = row.getLong(1) - 
file.nonNullFirstRowId();
                         return row;
                     }
 
@@ -361,13 +481,20 @@ public class BlobFallbackRecordReaderTest {
     private static class ReadResult {
         final List<Long> rowIds = new ArrayList<>();
         final List<Long> sequenceNumbers = new ArrayList<>();
+        final List<Long> nullBlobRowIds = new ArrayList<>();
+        final List<Long> nullBlobSequenceNumbers = new ArrayList<>();
         final List<Integer> batchSizes = new ArrayList<>();
         int placeholderRowCount;
         int nullBlobRowCount;
 
         static ReadResult read(RecordReader<InternalRow> reader) throws 
Exception {
+            return read(reader, READ_ROW_TYPE);
+        }
+
+        static ReadResult read(RecordReader<InternalRow> reader, RowType 
readRowType)
+                throws Exception {
             try {
-                ReadResult result = new ReadResult();
+                ReadResult result = new ReadResult(readRowType);
                 RecordIterator<InternalRow> batch;
                 while ((batch = reader.readBatch()) != null) {
                     int batchSize = 0;
@@ -385,14 +512,32 @@ public class BlobFallbackRecordReaderTest {
             }
         }
 
+        private final int rowIdIndex;
+        private final int seqNumIndex;
+
+        private ReadResult(RowType readRowType) {
+            this.rowIdIndex = 
readRowType.getFieldIndex(SpecialFields.ROW_ID.name());
+            this.seqNumIndex = 
readRowType.getFieldIndex(SpecialFields.SEQUENCE_NUMBER.name());
+        }
+
         private void add(InternalRow row) {
             if (row.isNullAt(BLOB_INDEX)) {
                 nullBlobRowCount++;
+                if (rowIdIndex >= 0) {
+                    nullBlobRowIds.add(row.getLong(rowIdIndex));
+                }
+                if (seqNumIndex >= 0) {
+                    nullBlobSequenceNumbers.add(row.getLong(seqNumIndex));
+                }
             } else if (row.getBlob(BLOB_INDEX) == BlobPlaceholder.INSTANCE) {
                 placeholderRowCount++;
             } else {
-                rowIds.add(row.getLong(1));
-                sequenceNumbers.add(row.getLong(2));
+                if (rowIdIndex >= 0) {
+                    rowIds.add(row.getLong(rowIdIndex));
+                }
+                if (seqNumIndex >= 0) {
+                    sequenceNumbers.add(row.getLong(seqNumIndex));
+                }
             }
         }
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
new file mode 100644
index 0000000000..3290f6b80d
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionDeletionVectorTest.java
@@ -0,0 +1,590 @@
+/*
+ * 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.table;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.DeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
+import org.apache.paimon.format.blob.BlobFileFormat;
+import org.apache.paimon.globalindex.IndexedSplit;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileKind;
+import org.apache.paimon.manifest.IndexManifestEntry;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.DeletionFile;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Range;
+import org.apache.paimon.utils.RangeHelper;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static org.apache.paimon.table.BucketMode.UNAWARE_BUCKET;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests filename-anchored deletion vectors for data evolution tables. */
+public class DataEvolutionDeletionVectorTest extends DataEvolutionTestBase {
+
+    private static final Range FULL_RANGE = new Range(0, 14);
+    private static final Range FIRST_RANGE = new Range(0, 4);
+    private static final List<DvSpec> DEFAULT_DV_SPECS =
+            Arrays.asList(
+                    new DvSpec(new Range(0, 4), 1, 4),
+                    new DvSpec(new Range(5, 9), 6),
+                    new DvSpec(new Range(10, 14), 10, 12));
+
+    @Test
+    public void testReadAfterDeletionVectors() throws Exception {
+        // basic read
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        assertBaseFileLayout(table);
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        assertReadMatrix(getTableDefault(), "base");
+    }
+
+    @Test
+    public void testReadAfterUpdatingDeletionVectors() throws Exception {
+        // update DVs then read
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        assertBaseFileLayout(table);
+        commitDeletionVectors(
+                table,
+                Arrays.asList(new DvSpec(new Range(0, 4), 1), new DvSpec(new 
Range(5, 9), 6)));
+        table = getTableDefault();
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        assertReadMatrix(getTableDefault(), "base");
+    }
+
+    @Test
+    public void testReadAfterAddingColumnAndDeletionVectors() throws Exception 
{
+        // DVs with adding new columns.
+        Schema.Builder schemaBuilder = Schema.newBuilder();
+        schemaBuilder.column("f0", DataTypes.INT());
+        schemaBuilder.column("f1", DataTypes.STRING());
+        schemaBuilder.column("f3", DataTypes.BLOB());
+        schemaBuilder.option(CoreOptions.TARGET_FILE_SIZE.key(), "128 MB");
+        schemaBuilder.option(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "1 b");
+        schemaBuilder.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true");
+        catalog.createTable(identifier(), schemaBuilder.build(), true);
+
+        FileStoreTable table = getTableDefault();
+        for (int batch = 0; batch < 3; batch++) {
+            BatchWriteBuilder builder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = builder.newWrite();
+                    BatchTableCommit commit = builder.newCommit()) {
+                for (int rowId = batch * 5; rowId < batch * 5 + 5; rowId++) {
+                    write.write(
+                            GenericRow.of(
+                                    rowId,
+                                    BinaryString.fromString("name-" + rowId),
+                                    new BlobData(new byte[] {(byte) rowId})));
+                }
+                commit.commit(write.prepareCommit());
+            }
+        }
+        assertBaseFileLayout(table);
+        commitDeletionVectors(table, DEFAULT_DV_SPECS);
+
+        catalog.alterTable(
+                identifier(),
+                SchemaChange.addColumn(
+                        "f2", DataTypes.STRING(), null, 
SchemaChange.Move.before("f2", "f3")),
+                false);
+        table = getTableDefault();
+        updateStructuredColumn(table);
+
+        assertReadMatrix(getTableDefault(), "updated");
+    }
+
+    @Test
+    public void testDataEvolutionDeletionFilesDoNotLeakAcrossSplits() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        assertBaseFileLayout(table);
+        updateStructuredColumn(table);
+        commitDeletionVectors(table, Collections.singletonList(new DvSpec(new 
Range(0, 4), 1)));
+
+        table = getTableDefault();
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(), "1 B");
+        dynamicOptions.put(CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(), "1 
B");
+        table = table.copy(dynamicOptions);
+
+        ReadBuilder readBuilder = table.newReadBuilder();
+        TableScan.Plan plan = readBuilder.newScan().plan();
+        List<DataSplit> splits =
+                plan.splits().stream()
+                        .map(DataEvolutionDeletionVectorTest::toDataSplit)
+                        .sorted(Comparator.comparingLong(split -> 
splitRowRange(split).from))
+                        .collect(Collectors.toList());
+        assertThat(splits).hasSize(3);
+        assertDeletionFileRanges(splits.get(0), new Range(0, 4));
+        assertDeletionFileRanges(splits.get(1));
+        assertDeletionFileRanges(splits.get(2));
+        assertThat(splits.get(1).mergedRowCount()).hasValue(5L);
+        assertThat(splits.get(2).mergedRowCount()).hasValue(5L);
+
+        assertThat(readRows(readBuilder, plan))
+                .containsExactly(
+                        "0|name-0|updated-0|0",
+                        "2|name-2|updated-2|2",
+                        "3|name-3|updated-3|3",
+                        "4|name-4|updated-4|4",
+                        "5|name-5|updated-5|5",
+                        "6|name-6|updated-6|6",
+                        "7|name-7|updated-7|7",
+                        "8|name-8|updated-8|8",
+                        "9|name-9|updated-9|9",
+                        "10|name-10|updated-10|10",
+                        "11|name-11|updated-11|11",
+                        "12|name-12|updated-12|12",
+                        "13|name-13|updated-13|13",
+                        "14|name-14|updated-14|14");
+    }
+
+    @Test
+    public void testLimitPushDownWithHeavilyDeletedFirstRange() throws 
Exception {
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        writeBaseRows(table);
+        assertBaseFileLayout(table);
+        commitDeletionVectors(
+                table, Collections.singletonList(new DvSpec(new Range(0, 4), 
1, 2, 3, 4)));
+
+        table = getTableDefault();
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(), "1 B");
+        dynamicOptions.put(CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(), "1 
B");
+        table = table.copy(dynamicOptions);
+
+        ReadBuilder readBuilder = table.newReadBuilder().withLimit(2);
+        TableScan.Plan plan = readBuilder.newScan().plan();
+        List<DataSplit> splits =
+                plan.splits().stream()
+                        .map(DataEvolutionDeletionVectorTest::toDataSplit)
+                        .sorted(Comparator.comparingLong(split -> 
splitRowRange(split).from))
+                        .collect(Collectors.toList());
+
+        // Limit pushdown works at split level. If the first split's DV 
cardinality is ignored,
+        // limit=2 would incorrectly keep only the first split even though it 
has one visible row.
+        assertThat(splits).hasSize(2);
+        assertThat(splits.get(0).mergedRowCount()).hasValue(1L);
+        assertThat(splits.get(1).mergedRowCount()).hasValue(5L);
+        assertThat(readRows(table.newReadBuilder(), plan))
+                .containsExactly(
+                        "0|name-0|base-0|0",
+                        "5|name-5|base-5|5",
+                        "6|name-6|base-6|6",
+                        "7|name-7|base-7|7",
+                        "8|name-8|base-8|8",
+                        "9|name-9|base-9|9");
+    }
+
+    @Override
+    protected Schema schemaDefault() {
+        Schema.Builder schemaBuilder = Schema.newBuilder();
+        schemaBuilder.column("f0", DataTypes.INT());
+        schemaBuilder.column("f1", DataTypes.STRING());
+        schemaBuilder.column("f2", DataTypes.STRING());
+        schemaBuilder.column("f3", DataTypes.BLOB());
+        schemaBuilder.option(CoreOptions.TARGET_FILE_SIZE.key(), "128 MB");
+        schemaBuilder.option(CoreOptions.BLOB_TARGET_FILE_SIZE.key(), "1 b");
+        schemaBuilder.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+        schemaBuilder.option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true");
+        return schemaBuilder.build();
+    }
+
+    private void writeBaseRows(FileStoreTable table) throws Exception {
+        for (int batch = 0; batch < 3; batch++) {
+            BatchWriteBuilder builder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = builder.newWrite();
+                    BatchTableCommit commit = builder.newCommit()) {
+                for (int rowId = batch * 5; rowId < batch * 5 + 5; rowId++) {
+                    write.write(
+                            GenericRow.of(
+                                    rowId,
+                                    BinaryString.fromString("name-" + rowId),
+                                    BinaryString.fromString("base-" + rowId),
+                                    new BlobData(new byte[] {(byte) rowId})));
+                }
+                commit.commit(write.prepareCommit());
+            }
+        }
+    }
+
+    private void updateStructuredColumn(FileStoreTable table) throws Exception 
{
+        RowType writeType = 
table.rowType().project(Collections.singletonList("f2"));
+        for (int batch = 0; batch < 3; batch++) {
+            BatchWriteBuilder builder = table.newBatchWriteBuilder();
+            try (BatchTableWrite write = 
builder.newWrite().withWriteType(writeType);
+                    BatchTableCommit commit = builder.newCommit()) {
+                long firstRowId = batch * 5L;
+                for (int rowId = batch * 5; rowId < batch * 5 + 5; rowId++) {
+                    
write.write(GenericRow.of(BinaryString.fromString("updated-" + rowId)));
+                }
+                List<CommitMessage> commitables = write.prepareCommit();
+                setFirstRowId(commitables, firstRowId);
+                commit.commit(commitables);
+            }
+        }
+    }
+
+    private void commitDeletionVectors(FileStoreTable table, List<DvSpec> 
deletionVectorSpecs)
+            throws Exception {
+        BaseAppendDeleteFileMaintainer maintainer =
+                BaseAppendDeleteFileMaintainer.forUnawareAppend(
+                        table.store().newIndexFileHandler(),
+                        table.latestSnapshot().get(),
+                        BinaryRow.EMPTY_ROW);
+        Map<Range, String> anchorFiles = anchorFilesByRange(table);
+
+        for (DvSpec spec : deletionVectorSpecs) {
+            DeletionVector deletionVector = new BitmapDeletionVector();
+            for (long rowId : spec.deletedRowIds) {
+                deletionVector.delete(rowId - spec.range.from);
+            }
+            maintainer.notifyNewDeletionVector(anchorFiles.get(spec.range), 
deletionVector);
+        }
+
+        List<IndexFileMeta> newIndexFiles = new ArrayList<>();
+        List<IndexFileMeta> deletedIndexFiles = new ArrayList<>();
+        for (IndexManifestEntry entry : maintainer.persist()) {
+            if (entry.kind() == FileKind.ADD) {
+                newIndexFiles.add(entry.indexFile());
+            } else if (entry.kind() == FileKind.DELETE) {
+                deletedIndexFiles.add(entry.indexFile());
+            }
+        }
+
+        commitDefault(
+                Collections.singletonList(
+                        new CommitMessageImpl(
+                                BinaryRow.EMPTY_ROW,
+                                UNAWARE_BUCKET,
+                                null,
+                                new DataIncrement(
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        Collections.emptyList(),
+                                        newIndexFiles,
+                                        deletedIndexFiles),
+                                CompactIncrement.emptyIncrement())));
+    }
+
+    private Map<Range, String> anchorFilesByRange(FileStoreTable table) {
+        List<DataFileMeta> dataFiles =
+                table.store().newScan().plan().files().stream()
+                        .map(ManifestEntry::file)
+                        .collect(Collectors.toList());
+        RangeHelper<DataFileMeta> rangeHelper = new 
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+        Map<Range, String> result = new HashMap<>();
+        for (List<DataFileMeta> group : 
rangeHelper.mergeOverlappingRanges(dataFiles)) {
+            DataFileMeta anchor = retrieveAnchorFile(group, file -> file);
+            result.put(anchor.nonNullRowIdRange(), anchor.fileName());
+        }
+        return result;
+    }
+
+    private static void assertReadMatrix(FileStoreTable table, String 
structuredValuePrefix)
+            throws Exception {
+        List<String> expectedRows = expectedRows(structuredValuePrefix, 
FULL_RANGE);
+        List<String> expectedFirstRangeRows = 
expectedRows(structuredValuePrefix, FIRST_RANGE);
+        List<String> expectedProjectedStrings =
+                expectedProjectedStrings(structuredValuePrefix, FULL_RANGE);
+        List<Integer> expectedBlobValues = expectedBlobValues(FULL_RANGE);
+
+        
assertThat(readRows(table.newReadBuilder())).containsExactlyElementsOf(expectedRows);
+        assertThat(
+                        readRows(
+                                table.newReadBuilder()
+                                        
.withRowRanges(Collections.singletonList(FULL_RANGE))))
+                .containsExactlyElementsOf(expectedRows);
+        assertThat(
+                        readRows(
+                                table.newReadBuilder()
+                                        
.withRowRanges(Collections.singletonList(FIRST_RANGE))))
+                .containsExactlyElementsOf(expectedFirstRangeRows);
+        assertThat(
+                        readRows(
+                                table.newReadBuilder()
+                                        
.withRowRanges(Collections.singletonList(new Range(4, 4)))))
+                .isEmpty();
+        assertThat(
+                        readRows(
+                                table.newReadBuilder()
+                                        
.withRowRanges(Collections.singletonList(new Range(7, 7)))))
+                .containsExactly(expectedRow(structuredValuePrefix, 7));
+
+        
assertThat(readProjectedStrings(table.newReadBuilder().withProjection(new int[] 
{2})))
+                .containsExactlyElementsOf(expectedProjectedStrings);
+        assertThat(
+                        readProjectedStrings(
+                                table.newReadBuilder()
+                                        .withProjection(new int[] {2})
+                                        
.withRowRanges(Collections.singletonList(FULL_RANGE))))
+                .containsExactlyElementsOf(expectedProjectedStrings);
+        
assertThat(readProjectedBlobValues(table.newReadBuilder().withProjection(new 
int[] {3})))
+                .containsExactlyElementsOf(expectedBlobValues);
+        assertThat(
+                        readProjectedBlobValues(
+                                table.newReadBuilder()
+                                        .withProjection(new int[] {3})
+                                        
.withRowRanges(Collections.singletonList(FULL_RANGE))))
+                .containsExactlyElementsOf(expectedBlobValues);
+
+        DataSplit fullRangeSplit = planDataSplit(table, FULL_RANGE);
+        assertDeletionFileRanges(
+                fullRangeSplit, new Range(0, 4), new Range(5, 9), new 
Range(10, 14));
+        assertThat(fullRangeSplit.mergedRowCount()).hasValue(10L);
+        assertThat(planDataSplit(table, 
FIRST_RANGE).mergedRowCount()).hasValue(3L);
+    }
+
+    private static void assertDeletionFileRanges(DataSplit split, Range... 
expectedRanges) {
+        List<DeletionFile> deletionFiles = 
split.deletionFiles().orElse(Collections.emptyList());
+        assertThat(deletionFiles).hasSize(split.dataFiles().size());
+
+        Map<Range, DeletionFile> actual = new HashMap<>();
+        RangeHelper<DataFileMeta> rangeHelper = new 
RangeHelper<>(DataFileMeta::nonNullRowIdRange);
+        for (List<DataFileMeta> group : 
rangeHelper.mergeOverlappingRanges(split.dataFiles())) {
+            DataFileMeta anchor = retrieveAnchorFile(group, file -> file);
+            DeletionFile deletionFile = 
deletionFiles.get(split.dataFiles().indexOf(anchor));
+            if (deletionFile != null) {
+                actual.put(anchor.nonNullRowIdRange(), deletionFile);
+            }
+        }
+
+        assertThat(deletionFiles.stream().filter(file -> file != null).count())
+                .isEqualTo((long) expectedRanges.length);
+        assertThat(actual.keySet()).containsExactlyInAnyOrder(expectedRanges);
+    }
+
+    private static List<String> expectedRows(String structuredValuePrefix, 
Range range) {
+        List<String> rows = new ArrayList<>();
+        for (int rowId = (int) range.from; rowId <= range.to; rowId++) {
+            if (!isDeletedByDefaultDv(rowId)) {
+                rows.add(expectedRow(structuredValuePrefix, rowId));
+            }
+        }
+        return rows;
+    }
+
+    private static List<String> expectedProjectedStrings(
+            String structuredValuePrefix, Range range) {
+        List<String> rows = new ArrayList<>();
+        for (int rowId = (int) range.from; rowId <= range.to; rowId++) {
+            if (!isDeletedByDefaultDv(rowId)) {
+                rows.add(structuredValuePrefix + "-" + rowId);
+            }
+        }
+        return rows;
+    }
+
+    private static List<Integer> expectedBlobValues(Range range) {
+        List<Integer> rows = new ArrayList<>();
+        for (int rowId = (int) range.from; rowId <= range.to; rowId++) {
+            if (!isDeletedByDefaultDv(rowId)) {
+                rows.add(rowId);
+            }
+        }
+        return rows;
+    }
+
+    private static boolean isDeletedByDefaultDv(int rowId) {
+        for (DvSpec spec : DEFAULT_DV_SPECS) {
+            for (long deletedRowId : spec.deletedRowIds) {
+                if (deletedRowId == rowId) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static String expectedRow(String structuredValuePrefix, int rowId) 
{
+        return rowId + "|name-" + rowId + "|" + structuredValuePrefix + "-" + 
rowId + "|" + rowId;
+    }
+
+    private static void assertBaseFileLayout(FileStoreTable table) {
+        assertRegularFileRowRanges(
+                table.store().newScan().plan().files().stream()
+                        .map(ManifestEntry::file)
+                        .collect(Collectors.toList()),
+                Arrays.asList(new Range(0, 4), new Range(5, 9), new Range(10, 
14)));
+        assertFirstBlobFileRowRanges(
+                table, Arrays.asList(new Range(0, 0), new Range(1, 1), new 
Range(2, 2)), 15);
+    }
+
+    private static DataSplit planDataSplit(FileStoreTable table, Range range) {
+        ReadBuilder readBuilder =
+                
table.newReadBuilder().withRowRanges(Collections.singletonList(range));
+        TableScan.Plan plan = readBuilder.newScan().plan();
+        assertThat(plan.splits()).hasSize(1);
+        return toDataSplit(plan.splits().get(0));
+    }
+
+    private static List<String> readRows(ReadBuilder readBuilder, 
TableScan.Plan plan)
+            throws IOException {
+        List<String> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan)) {
+            reader.forEachRemaining(row -> rows.add(formatRow(row)));
+        }
+        
rows.sort(Comparator.comparingInt(DataEvolutionDeletionVectorTest::rowId));
+        return rows;
+    }
+
+    private static List<String> readRows(ReadBuilder readBuilder) throws 
IOException {
+        return readRows(readBuilder, readBuilder.newScan().plan());
+    }
+
+    private static List<String> readProjectedStrings(ReadBuilder readBuilder) 
throws IOException {
+        List<String> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(row -> 
rows.add(row.getString(0).toString()));
+        }
+        
rows.sort(Comparator.comparingInt(DataEvolutionDeletionVectorTest::projectedRowId));
+        return rows;
+    }
+
+    private static List<Integer> readProjectedBlobValues(ReadBuilder 
readBuilder)
+            throws IOException {
+        List<Integer> rows = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(row -> rows.add(row.getBlob(0).toData()[0] 
& 0xFF));
+        }
+        Collections.sort(rows);
+        return rows;
+    }
+
+    private static int rowId(String row) {
+        return Integer.parseInt(row.substring(0, row.indexOf('|')));
+    }
+
+    private static int projectedRowId(String row) {
+        return Integer.parseInt(row.substring(row.lastIndexOf('-') + 1));
+    }
+
+    private static String formatRow(InternalRow row) {
+        return row.getInt(0)
+                + "|"
+                + row.getString(1)
+                + "|"
+                + row.getString(2)
+                + "|"
+                + (row.getBlob(3).toData()[0] & 0xFF);
+    }
+
+    private static DataSplit toDataSplit(Split split) {
+        if (split instanceof IndexedSplit) {
+            return ((IndexedSplit) split).dataSplit();
+        }
+        return (DataSplit) split;
+    }
+
+    private static void assertRegularFileRowRanges(
+            List<DataFileMeta> dataFiles, List<Range> expected) {
+        List<Range> actual =
+                dataFiles.stream()
+                        .filter(DataEvolutionDeletionVectorTest::isNormalFile)
+                        .map(DataFileMeta::nonNullRowIdRange)
+                        .sorted(Comparator.comparingLong(range -> range.from))
+                        .collect(Collectors.toList());
+        assertThat(actual).isEqualTo(expected);
+    }
+
+    private static void assertFirstBlobFileRowRanges(
+            FileStoreTable table, List<Range> expectedFirstRanges, int 
expectedCount) {
+        List<Range> actual =
+                table.store().newScan().plan().files().stream()
+                        .map(ManifestEntry::file)
+                        .filter(file -> 
BlobFileFormat.isBlobFile(file.fileName()))
+                        .map(DataFileMeta::nonNullRowIdRange)
+                        .sorted(Comparator.comparingLong(range -> range.from))
+                        .collect(Collectors.toList());
+        assertThat(actual).hasSize(expectedCount);
+        assertThat(actual.subList(0, 
expectedFirstRanges.size())).isEqualTo(expectedFirstRanges);
+    }
+
+    private static Range splitRowRange(DataSplit split) {
+        return split.dataFiles().stream()
+                .filter(DataEvolutionDeletionVectorTest::isNormalFile)
+                .map(DataFileMeta::nonNullRowIdRange)
+                .min(Comparator.comparingLong(range -> range.from))
+                .get();
+    }
+
+    private static boolean isNormalFile(DataFileMeta file) {
+        return !BlobFileFormat.isBlobFile(file.fileName()) && 
!isVectorStoreFile(file.fileName());
+    }
+
+    private static class DvSpec {
+
+        private final Range range;
+        private final long[] deletedRowIds;
+
+        private DvSpec(Range range, long... deletedRowIds) {
+            this.range = range;
+            this.deletedRowIds = deletedRowIds;
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/DataSplitCompatibleTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/DataSplitCompatibleTest.java
index dcd888b7f1..a0c76537ab 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/DataSplitCompatibleTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/DataSplitCompatibleTest.java
@@ -90,6 +90,31 @@ public class DataSplitCompatibleTest {
         assertThat(split.mergedRowCount()).hasValue(5700L);
     }
 
+    @Test
+    public void testDeletionFilesSerialize() throws Exception {
+        List<DataFileMeta> dataFiles =
+                Collections.singletonList(
+                        newDataFile(10, SimpleStats.EMPTY_STATS, 
null).assignFirstRowId(0));
+        List<DeletionFile> deletionFiles =
+                Collections.singletonList(new DeletionFile("p", 1, 2, 3L));
+        DataSplit split =
+                DataSplit.builder()
+                        .withSnapshot(1)
+                        .withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(1)
+                        .withBucketPath("my path")
+                        .rawConvertible(true)
+                        .withDataFiles(dataFiles)
+                        .withDataDeletionFiles(deletionFiles)
+                        .build();
+
+        DataSplit actual = InstantiationUtil.clone(split);
+
+        assertThat(actual.rawConvertible()).isTrue();
+        assertThat(actual.deletionFiles()).hasValue(deletionFiles);
+        assertThat(actual.mergedRowCount()).hasValue(7L);
+    }
+
     @Test
     public void testSplitMinMaxValue() {
         Map<Long, List<DataField>> schemas = new HashMap<>();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
new file mode 100644
index 0000000000..6f51235362
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.utils;
+
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.stats.SimpleStats;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link DataEvolutionUtils}. */
+public class DataEvolutionUtilsTest {
+
+    @Test
+    public void testRetrieveAnchorFileSkipsSpecialFiles() {
+        DataFileMeta blobFile = dataFile("blob-file.blob", 1);
+        DataFileMeta vectorFile = dataFile("data.vector.lance", 2);
+        DataFileMeta oldestNormalFile = dataFile("oldest-normal.parquet", 3);
+        DataFileMeta newestNormalFile = dataFile("newest-normal.parquet", 4);
+
+        assertThat(
+                        DataEvolutionUtils.retrieveAnchorFile(
+                                Arrays.asList(
+                                        blobFile, newestNormalFile, 
vectorFile, oldestNormalFile),
+                                Function.identity()))
+                .isSameAs(oldestNormalFile);
+    }
+
+    @Test
+    public void testRetrieveAnchorFileFailsWithoutNormalFile() {
+        assertThatThrownBy(
+                        () ->
+                                DataEvolutionUtils.retrieveAnchorFile(
+                                        Arrays.asList(
+                                                dataFile("blob-file.blob", 1),
+                                                dataFile("data.vector.lance", 
2)),
+                                        Function.identity()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("normal anchor file");
+    }
+
+    @Test
+    public void testRetrieveAnchorFileTieBreaksWithFileName() {
+        DataFileMeta largerFileName = dataFile("normal-2.parquet", 1);
+        DataFileMeta smallerFileName = dataFile("normal-1.parquet", 1);
+
+        assertThat(
+                        DataEvolutionUtils.retrieveAnchorFile(
+                                Arrays.asList(largerFileName, smallerFileName),
+                                Function.identity()))
+                .isSameAs(smallerFileName);
+    }
+
+    private static DataFileMeta dataFile(String fileName, long 
maxSequenceNumber) {
+        return DataFileMeta.forAppend(
+                fileName,
+                1L,
+                1L,
+                SimpleStats.EMPTY_STATS,
+                maxSequenceNumber,
+                maxSequenceNumber,
+                1L,
+                Collections.emptyList(),
+                null,
+                null,
+                null,
+                null,
+                0L,
+                Collections.emptyList());
+    }
+}

Reply via email to