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 dbaa953a92 [core] Keep anchor normal file when DE read-type pruning 
leaves only dedicated files (#9966)
dbaa953a92 is described below

commit dbaa953a92b1940a97953cb292308263c1fef6c8
Author: YangJie <[email protected]>
AuthorDate: Tue Oct 13 01:44:48 2026 -0400

    [core] Keep anchor normal file when DE read-type pruning leaves only 
dedicated files (#9966)
---
 .../operation/DataEvolutionFileStoreScan.java      |  18 +-
 .../paimon/operation/DataEvolutionSplitRead.java   |   3 +-
 .../operation/DataEvolutionVectorReadPlanner.java  | 100 ++++++-
 .../org/apache/paimon/append/BlobUpdateTest.java   |  98 ++++++-
 .../paimon/append/MultipleBlobTableTest.java       | 136 ++++++++++
 .../apache/paimon/append/VectorStoreTableTest.java | 220 +++++++++++++++-
 .../operation/DataEvolutionFileStoreScanTest.java  | 262 +++++++++++++++++++
 .../DataEvolutionVectorReadPlannerTest.java        | 290 +++++++++++++++++++++
 8 files changed, 1117 insertions(+), 10 deletions(-)

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 c8ad122f5d..442ad4c70d 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
@@ -225,7 +225,9 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
      * 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.
+     * anchor file to lookup corresponding Deletion Files. Without deletion 
vectors, the anchor is
+     * still kept when the kept files are all blob/vector-store files, which 
may cover only part of
+     * the group's row-id range.
      */
     private List<ManifestEntry> pruneByReadType(List<ManifestEntry> group) {
         if (readType == null || group.size() <= 1) {
@@ -281,6 +283,16 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
         if (anchor != null && !kept.contains(anchor)) {
             kept.add(anchor);
         }
+        // Blob and vector-store files may each cover only a sub-range of 
their group, and the
+        // reader would derive the group's range from them and drop the other 
rows, so keep the
+        // anchor. An incremental scan can see a group with no normal file at 
all, which has no
+        // anchor to keep.
+        if (anchor == null
+                && !kept.isEmpty()
+                && kept.stream().allMatch(e -> isDedicatedFile(e.file()))
+                && !group.stream().allMatch(e -> isDedicatedFile(e.file()))) {
+            kept.add(retrieveAnchorFile(group, ManifestEntry::file));
+        }
         // Group must contribute at least one file so the reader sees rowCount 
and can NULL-fill
         // missing columns for the projection's rows. The representative must 
be a full-range
         // normal file: a blob or vector-store file covers only a sub-range of 
the group's row
@@ -290,6 +302,10 @@ public class DataEvolutionFileStoreScan extends 
AppendOnlyFileStoreScan {
                 : kept;
     }
 
+    private static boolean isDedicatedFile(DataFileMeta file) {
+        return isBlobFile(file.fileName()) || 
isVectorStoreFile(file.fileName());
+    }
+
     private Set<Integer> fileFieldIdsForEntry(ManifestEntry entry) {
         return fileFieldIdsCache.computeIfAbsent(
                 Pair.of(entry.file().schemaId(), entry.file().writeCols()),
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 ae2fc03d54..b4d58af88b 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
@@ -346,7 +346,8 @@ public class DataEvolutionSplitRead implements 
SplitRead<InternalRow> {
                                 schemaFetcher
                                         .apply(file.schemaId())
                                         .dataFileSchema(file.writeCols())
-                                        .logicalRowType());
+                                        .logicalRowType(),
+                        rowRanges);
         if (vectorRanges != null) {
             List<FieldBunch> nonVectorBunches =
                     splitFieldBunches(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java
index 1b77489cbf..599b29caa6 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java
@@ -45,12 +45,16 @@ import static 
org.apache.paimon.types.VectorType.isVectorStoreFile;
 /** Plans ranges with fixed vector field providers before positional column 
merging. */
 class DataEvolutionVectorReadPlanner {
 
-    /** Returns null when the existing sequential column-group readers 
suffice. */
+    /**
+     * Returns null when the existing sequential column-group readers suffice. 
{@code rowRanges} are
+     * the pushed-down row ranges, or null when every row is read.
+     */
     @Nullable
     static List<ReadRange> plan(
             List<DataFileMeta> files,
             RowType readType,
-            Function<DataFileMeta, RowType> fileToRowType) {
+            Function<DataFileMeta, RowType> fileToRowType,
+            @Nullable List<Range> rowRanges) {
         Set<Integer> readIds =
                 
readType.getFields().stream().map(DataField::id).collect(Collectors.toSet());
         boolean vectorOnly = files.stream().allMatch(file -> 
isVectorStoreFile(file.fileName()));
@@ -93,14 +97,33 @@ class DataEvolutionVectorReadPlanner {
             }
         }
 
-        // Keep the sequential path for disjoint column groups, including 
rolled vector files.
-        if (!overlappingGroups) {
-            return null;
-        }
         if (logicalRange == null) {
             logicalRange = new Range(firstRowId, lastRowId);
         }
 
+        // The sequential path reads each vector column as one 
VectorFileBunch, which must yield
+        // exactly the group's rows. Otherwise plan explicit ranges, which 
NULL-fill uncovered rows
+        // and take every row from its latest file. Rolled files that tile the 
group stay
+        // sequential.
+        boolean partialVector = false;
+        for (int fieldId : vectorReadIds) {
+            List<DataFileMeta> fieldFiles = new ArrayList<>();
+            for (Candidate candidate : candidates) {
+                if (candidate.fieldIds.contains(fieldId)) {
+                    fieldFiles.add(candidate.file);
+                }
+            }
+            if (!readsSequentially(fieldFiles, logicalRange, rowRanges)) {
+                partialVector = true;
+                break;
+            }
+        }
+
+        // Keep the sequential path for disjoint column groups that each span 
the whole range.
+        if (!overlappingGroups && !partialVector) {
+            return null;
+        }
+
         // Resolve the original files before VectorFileBunch can discard older 
overlapping files.
         // A bunch may contain different sequences in adjacent ranges, so 
sorting whole bunches
         // by their maximum sequence cannot establish the latest provider for 
every row.
@@ -159,6 +182,71 @@ class DataEvolutionVectorReadPlanner {
         return result;
     }
 
+    /**
+     * Whether a VectorFileBunch built from {@code files} yields exactly the 
rows of {@code target},
+     * each from its latest file. The bunch takes files in first-row-id order, 
newest first, and
+     * skips a file older than the one kept just before it, without comparing 
it to later kept
+     * files. With row ranges pushed down, the bunch accepts gaps and every 
reader reads only the
+     * selected rows, so a gap matters only if it contains a selected row.
+     */
+    private static boolean readsSequentially(
+            List<DataFileMeta> files, Range target, @Nullable List<Range> 
rowRanges) {
+        files.sort(
+                Comparator.comparingLong(DataFileMeta::nonNullFirstRowId)
+                        .thenComparing(
+                                
Comparator.comparingLong(DataFileMeta::maxSequenceNumber)
+                                        .reversed()));
+        List<DataFileMeta> kept = new ArrayList<>();
+        List<DataFileMeta> skipped = new ArrayList<>();
+        long cursor = target.from;
+        long latestSequence = Long.MIN_VALUE;
+        for (DataFileMeta file : files) {
+            Range range = file.nonNullRowIdRange();
+            if (range.from < cursor) {
+                if (file.maxSequenceNumber() < latestSequence) {
+                    skipped.add(file);
+                    continue;
+                }
+                return false;
+            }
+            if (range.from > cursor && selects(cursor, range.from - 1, 
rowRanges)) {
+                return false;
+            }
+            kept.add(file);
+            cursor = range.to + 1;
+            latestSequence = file.maxSequenceNumber();
+        }
+        if (cursor > target.to + 1 || selects(cursor, target.to, rowRanges)) {
+            return false;
+        }
+        for (DataFileMeta older : skipped) {
+            Range olderRange = older.nonNullRowIdRange();
+            for (DataFileMeta file : kept) {
+                Range range = file.nonNullRowIdRange();
+                if (Range.intersect(olderRange.from, olderRange.to, 
range.from, range.to)
+                        && file.maxSequenceNumber() <= 
older.maxSequenceNumber()) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    private static boolean selects(long from, long to, @Nullable List<Range> 
rowRanges) {
+        if (from > to) {
+            return false;
+        }
+        if (rowRanges == null) {
+            return true;
+        }
+        for (Range range : rowRanges) {
+            if (Range.intersect(from, to, range.from, range.to)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     static class ReadRange {
 
         Range range;
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/BlobUpdateTest.java 
b/paimon-core/src/test/java/org/apache/paimon/append/BlobUpdateTest.java
index 9d1f05aa65..04d38c5003 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/BlobUpdateTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/BlobUpdateTest.java
@@ -45,6 +45,10 @@ import org.apache.paimon.schema.Schema;
 import org.apache.paimon.stats.SimpleStats;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.TableTestBase;
+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.ReadBuilder;
@@ -171,7 +175,13 @@ public class BlobUpdateTest extends TableTestBase {
         TableScan.Plan plan = readBuilder.newScan().plan();
         assertThat(plan.splits().size()).isEqualTo(1);
         DataSplit dataSplit = ((IndexedSplit) 
plan.splits().get(0)).dataSplit();
-        assertThat(dataSplit.dataFiles().size()).isEqualTo(3);
+        // The three blob files that intersect the pushed-down rows, plus the 
normal anchor.
+        assertThat(dataSplit.dataFiles().size()).isEqualTo(4);
+        assertThat(
+                        dataSplit.dataFiles().stream()
+                                .filter(file -> 
!BlobFileFormat.isBlobFile(file.fileName()))
+                                .count())
+                .isEqualTo(1L);
         RecordReader<InternalRow> reader = 
readBuilder.newRead().createReader(plan);
 
         List<byte[]> actual = new ArrayList<>();
@@ -182,6 +192,92 @@ public class BlobUpdateTest extends TableTestBase {
         assertThat(actual.get(1)).isEqualTo(updated9);
     }
 
+    @Test
+    public void testProjectBlobColumnBackfilledForSubRangeKeepsAllRows() 
throws Exception {
+        // A data-evolution table whose blob column is backfilled for only a 
sub-range of a group:
+        // the normal file writes f0/f1 over row ids [0, 9], while the blob 
file writes f2 over
+        // only [0, 4]. Projecting f2 alone (no filter, no row-range pushdown) 
keeps just the blob
+        // file, which spans [0, 4]; without the anchor the reader derives the 
group range from that
+        // sub-range and drops rows [5, 9]. See #9965.
+        createTableDefault();
+        FileStoreTable table = getTableDefault();
+        RowType normalType = table.rowType().project(Arrays.asList("f0", 
"f1"));
+        RowType blobType = 
table.rowType().project(Collections.singletonList("f2"));
+
+        // normal file writes f0/f1 over row ids [0, 9]
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(normalType)) {
+            for (int i = 0; i < 10; i++) {
+                write.write(GenericRow.of(i, BinaryString.fromString("row-" + 
i)));
+            }
+            BatchTableCommit commit = builder.newCommit();
+            commit.commit(write.prepareCommit());
+        }
+
+        // blob file writes f2 for only the first five rows [0, 4]
+        long firstRowId = 
getTableDefault().snapshotManager().latestSnapshot().nextRowId() - 10;
+        List<byte[]> blobs = new ArrayList<>();
+        builder = getTableDefault().newBatchWriteBuilder();
+        try (BatchTableWrite write = 
builder.newWrite().withWriteType(blobType)) {
+            for (int i = 0; i < 5; i++) {
+                byte[] bytes = ("blob-" + i).getBytes();
+                blobs.add(bytes);
+                write.write(GenericRow.of(new BlobData(bytes)));
+            }
+            BatchTableCommit commit = builder.newCommit();
+            List<CommitMessage> commitables = write.prepareCommit();
+            reassignFirstRowId(commitables, firstRowId);
+            commit.commit(commitables);
+        }
+
+        FileStoreTable readTable = getTableDefault();
+        assertBlobFileRowIdRanges(readTable, Collections.singletonList(new 
Range(0L, 4L)));
+
+        ReadBuilder readBuilder =
+                readTable
+                        .newReadBuilder()
+                        
.withReadType(readTable.rowType().project(Collections.singletonList("f2")));
+        RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan());
+
+        List<byte[]> actual = new ArrayList<>();
+        reader.forEachRemaining(
+                row -> actual.add(row.isNullAt(0) ? null : 
row.getBlob(0).toData()));
+
+        assertThat(actual.size()).isEqualTo(10);
+        for (int i = 0; i < 5; i++) {
+            assertThat(actual.get(i)).isEqualTo(blobs.get(i));
+        }
+        for (int i = 5; i < 10; i++) {
+            assertThat(actual.get(i)).isNull();
+        }
+
+        // Row-range pushdown across the end of the blob file: rows [5, 6] are 
still NULL.
+        readBuilder.withRowRanges(Collections.singletonList(new Range(3L, 
6L)));
+        List<byte[]> ranged = new ArrayList<>();
+        readBuilder
+                .newRead()
+                .createReader(readBuilder.newScan().plan())
+                .forEachRemaining(
+                        row -> ranged.add(row.isNullAt(0) ? null : 
row.getBlob(0).toData()));
+        assertThat(ranged.size()).isEqualTo(4);
+        assertThat(ranged.get(0)).isEqualTo(blobs.get(3));
+        assertThat(ranged.get(1)).isEqualTo(blobs.get(4));
+        assertThat(ranged.get(2)).isNull();
+        assertThat(ranged.get(3)).isNull();
+    }
+
+    private void reassignFirstRowId(List<CommitMessage> commitables, long 
firstRowId) {
+        for (CommitMessage message : commitables) {
+            CommitMessageImpl impl = (CommitMessageImpl) message;
+            List<DataFileMeta> newFiles = new 
ArrayList<>(impl.newFilesIncrement().newFiles());
+            impl.newFilesIncrement().newFiles().clear();
+            for (DataFileMeta file : newFiles) {
+                
impl.newFilesIncrement().newFiles().add(file.assignFirstRowId(firstRowId));
+            }
+        }
+    }
+
     /**
      * This test manually simulates the compacted layout described in 
BlobSequenceGroupRecordReader,
      * scaled down to row id [0, 9].
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/MultipleBlobTableTest.java 
b/paimon-core/src/test/java/org/apache/paimon/append/MultipleBlobTableTest.java
index a8fd2cc328..5c1617f83e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/MultipleBlobTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/MultipleBlobTableTest.java
@@ -32,7 +32,15 @@ import org.apache.paimon.schema.Schema;
 import org.apache.paimon.schema.SchemaChange;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.TableTestBase;
+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.ReadBuilder;
+import org.apache.paimon.table.source.StreamTableScan;
+import org.apache.paimon.table.source.TableScan;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 
@@ -198,6 +206,134 @@ public class MultipleBlobTableTest extends TableTestBase {
                 new BlobData(blobBytes2));
     }
 
+    @Test
+    public void testStreamScanReadsBlobOnlyDelta() throws Exception {
+        createTableDefault();
+        RowType rowType = getTableDefault().rowType();
+
+        // normal file writes f0/f1 over row ids [0, 1]
+        BatchWriteBuilder builder = getTableDefault().newBatchWriteBuilder();
+        try (BatchTableWrite write =
+                        builder.newWrite()
+                                
.withWriteType(rowType.project(Arrays.asList("f0", "f1")));
+                BatchTableCommit commit = builder.newCommit()) {
+            write.write(GenericRow.of(0, BinaryString.fromString("a")));
+            write.write(GenericRow.of(1, BinaryString.fromString("b")));
+            commit.commit(write.prepareCommit());
+        }
+
+        FileStoreTable table = getTableDefault();
+        ReadBuilder readBuilder =
+                
table.newReadBuilder().withReadType(rowType.project(Arrays.asList("f2", "f3")));
+        StreamTableScan streamScan = readBuilder.newStreamScan();
+        assertThat(readBlobs(readBuilder, 
streamScan.plan()).size()).isEqualTo(2);
+
+        // One commit updates both blob columns over [0, 1], so its delta 
holds only two .blob
+        // files; their normal file is in the previous snapshot.
+        builder = getTableDefault().newBatchWriteBuilder();
+        try (BatchTableWrite write =
+                        builder.newWrite()
+                                
.withWriteType(rowType.project(Arrays.asList("f2", "f3")));
+                BatchTableCommit commit = builder.newCommit()) {
+            write.write(GenericRow.of(new BlobData(blobBytes1), new 
BlobData(blobBytes2)));
+            write.write(GenericRow.of(new BlobData(blobBytes2), new 
BlobData(blobBytes1)));
+            List<CommitMessage> messages = write.prepareCommit();
+            assignFirstRowId(messages, 0L);
+            commit.commit(messages);
+        }
+
+        TableScan.Plan deltaPlan = streamScan.plan();
+        assertThat(
+                        deltaPlan.splits().stream()
+                                .flatMap(split -> ((DataSplit) 
split).dataFiles().stream())
+                                .allMatch(file -> isBlobFile(file.fileName())))
+                .isTrue();
+        List<byte[][]> delta = readBlobs(readBuilder, deltaPlan);
+        assertThat(delta.size()).isEqualTo(2);
+        assertThat(delta.get(0)[0]).isEqualTo(blobBytes1);
+        assertThat(delta.get(0)[1]).isEqualTo(blobBytes2);
+        assertThat(delta.get(1)[0]).isEqualTo(blobBytes2);
+        assertThat(delta.get(1)[1]).isEqualTo(blobBytes1);
+    }
+
+    @Test
+    public void testProjectTwoBlobColumnsCoveringDifferentSubRanges() throws 
Exception {
+        // normal [0, 9], f2 over [0, 6] and f3 over [3, 9]: together the blob 
files cover the
+        // group, but each blob column covers only part of it.
+        createTableDefault();
+        RowType rowType = getTableDefault().rowType();
+        BatchWriteBuilder builder = getTableDefault().newBatchWriteBuilder();
+        try (BatchTableWrite write =
+                        builder.newWrite()
+                                
.withWriteType(rowType.project(Arrays.asList("f0", "f1")));
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int i = 0; i < 10; i++) {
+                write.write(GenericRow.of(i, BinaryString.fromString("row-" + 
i)));
+            }
+            commit.commit(write.prepareCommit());
+        }
+        writeBlobColumn("f2", blobBytes1, 7, 0L);
+        writeBlobColumn("f3", blobBytes2, 7, 3L);
+
+        ReadBuilder readBuilder =
+                getTableDefault()
+                        .newReadBuilder()
+                        .withReadType(rowType.project(Arrays.asList("f2", 
"f3")));
+        List<byte[][]> rows = readBlobs(readBuilder, 
readBuilder.newScan().plan());
+        assertThat(rows.size()).isEqualTo(10);
+        for (int i = 0; i < 10; i++) {
+            assertThat(rows.get(i)[0]).isEqualTo(i <= 6 ? blobBytes1 : null);
+            assertThat(rows.get(i)[1]).isEqualTo(i >= 3 ? blobBytes2 : null);
+        }
+    }
+
+    private void writeBlobColumn(String column, byte[] bytes, int count, long 
firstRowId)
+            throws Exception {
+        FileStoreTable table = getTableDefault();
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write =
+                        builder.newWrite()
+                                .withWriteType(
+                                        table.rowType()
+                                                
.project(Collections.singletonList(column)));
+                BatchTableCommit commit = builder.newCommit()) {
+            for (int i = 0; i < count; i++) {
+                write.write(GenericRow.of(new BlobData(bytes)));
+            }
+            List<CommitMessage> messages = write.prepareCommit();
+            assignFirstRowId(messages, firstRowId);
+            commit.commit(messages);
+        }
+    }
+
+    private static List<byte[][]> readBlobs(ReadBuilder readBuilder, 
TableScan.Plan plan)
+            throws Exception {
+        List<byte[][]> rows = new ArrayList<>();
+        readBuilder
+                .newRead()
+                .createReader(plan)
+                .forEachRemaining(
+                        row ->
+                                rows.add(
+                                        new byte[][] {
+                                            row.isNullAt(0) ? null : 
row.getBlob(0).toData(),
+                                            row.isNullAt(1) ? null : 
row.getBlob(1).toData()
+                                        }));
+        return rows;
+    }
+
+    private static void assignFirstRowId(List<CommitMessage> messages, long 
firstRowId) {
+        for (CommitMessage message : messages) {
+            List<DataFileMeta> newFiles =
+                    ((CommitMessageImpl) 
message).newFilesIncrement().newFiles();
+            List<DataFileMeta> written = new ArrayList<>(newFiles);
+            newFiles.clear();
+            for (DataFileMeta file : written) {
+                newFiles.add(file.assignFirstRowId(firstRowId));
+            }
+        }
+    }
+
     @Test
     public void testProjectOnlyTwoBlobFields() throws Exception {
         createTableDefault();
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java 
b/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java
index 3ecd11bf33..ea6d242f40 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java
@@ -288,13 +288,231 @@ public class VectorStoreTableTest extends 
DataEvolutionTestBase {
                 .containsExactly((Float) null);
     }
 
+    @Test
+    public void 
testPartialUpdateWithMissingVectorRangeWithoutDeletionVectors() throws 
Exception {
+        // Same partial-vector layout as 
testPartialUpdateWithMissingVectorRange but without
+        // deletion vectors: embedding_v2 is populated only for rows [2, 3], 
so projecting it must
+        // still emit rows [0, 1] as NULL.
+        catalog.createTable(identifier(), vectorSchema("json").build(), false);
+        write(getTableDefault(), GenericRow.of(0, vector(1)), GenericRow.of(1, 
vector(1)));
+        catalog.alterTable(
+                identifier(),
+                Collections.singletonList(
+                        SchemaChange.addColumn(
+                                "embedding_v2", DataTypes.VECTOR(2, 
DataTypes.FLOAT()))),
+                false);
+        write(
+                getTableDefault(),
+                GenericRow.of(2, vector(1), vector(10)),
+                GenericRow.of(3, vector(1), vector(10)));
+        updateVectors(
+                2,
+                Collections.singletonList("embedding_v2"),
+                GenericRow.of(vector(200)),
+                GenericRow.of(vector(200)));
+        compactVectorTable();
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {2});
+        List<Split> splits = builder.newScan().plan().splits();
+        assertThat(splits).hasSize(1);
+        DataSplit split = (DataSplit) splits.get(0);
+        assertThat(readVectorValues(builder.newRead().createReader(split)))
+                .containsExactly(null, null, 200F, 200F);
+    }
+
+    @Test
+    public void testNewerVectorUpdateOverPartOfTheColumn() throws Exception {
+        // embedding is written for rows [0, 3], then updated for [0, 1] only. 
Together the two
+        // files cover the group, but the newer one overlaps the older, so the 
sequential reader
+        // would keep just [0, 1].
+        catalog.createTable(identifier(), vectorSchema("json").build(), false);
+        write(
+                getTableDefault(),
+                GenericRow.of(0, vector(1)),
+                GenericRow.of(1, vector(1)),
+                GenericRow.of(2, vector(1)),
+                GenericRow.of(3, vector(1)));
+        updateVectors(
+                0,
+                Collections.singletonList("embedding"),
+                GenericRow.of(vector(100)),
+                GenericRow.of(vector(100)));
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {1});
+        
assertThat(readVectorValues(builder.newRead().createReader(builder.newScan().plan())))
+                .containsExactly(100F, 100F, 1F, 1F);
+    }
+
+    @Test
+    public void testNewerVectorUpdateOverPartOfTheColumnWithDeletionVectors() 
throws Exception {
+        catalog.createTable(
+                identifier(),
+                vectorSchema("json")
+                        .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), 
"true")
+                        .build(),
+                false);
+        write(
+                getTableDefault(),
+                GenericRow.of(0, vector(10)),
+                GenericRow.of(1, vector(11)),
+                GenericRow.of(2, vector(12)),
+                GenericRow.of(3, vector(13)));
+        updateVectors(
+                0,
+                Collections.singletonList("embedding"),
+                GenericRow.of(vector(100)),
+                GenericRow.of(vector(101)));
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {1});
+        List<Split> splits = builder.newScan().plan().splits();
+        assertThat(splits).hasSize(1);
+        DataSplit split = (DataSplit) splits.get(0);
+
+        // Delete one row from the updated range and one from the original 
range.
+        DeletionVector deletionVector = new BitmapDeletionVector();
+        deletionVector.delete(1);
+        deletionVector.delete(2);
+        DeletionVectorsIndexFile indexFile =
+                getTableDefault()
+                        .store()
+                        .newIndexFileHandler()
+                        .dvIndex(split.partition(), split.bucket());
+        String anchor = retrieveAnchorFile(split.dataFiles(), 
Function.identity()).fileName();
+        Map<String, DeletionFile> deletionFiles =
+                indexFile.toDeletionFiles(
+                        Collections.singletonList(
+                                indexFile.writeSingleFile(
+                                        Collections.singletonMap(anchor, 
deletionVector))));
+        DataSplit deletedSplit =
+                DataSplit.builder()
+                        .withPartition(split.partition())
+                        .withBucket(split.bucket())
+                        .withBucketPath(split.bucketPath())
+                        .withDataFiles(split.dataFiles())
+                        .withDataDeletionFiles(
+                                split.dataFiles().stream()
+                                        .map(file -> 
deletionFiles.get(file.fileName()))
+                                        .collect(Collectors.toList()))
+                        .build();
+        
assertThat(readVectorValues(builder.newRead().createReader(deletedSplit)))
+                .containsExactly(100F, 13F);
+    }
+
+    @Test
+    public void testOlderVectorUpdateNewerThanTheNextKeptFile() throws 
Exception {
+        // [0, 9] s1, then [5, 9] s2, [2, 7] s3 and [0, 4] s4. The newest file 
and [5, 9] tile the
+        // group, but rows [5, 7] must come from [2, 7], which is older than 
[0, 4] only.
+        catalog.createTable(identifier(), vectorSchema("json").build(), false);
+        InternalRow[] rows = new InternalRow[10];
+        for (int i = 0; i < 10; i++) {
+            rows[i] = GenericRow.of(i, vector(1));
+        }
+        write(getTableDefault(), rows);
+        updateVectors(5, Collections.singletonList("embedding"), vectorRows(5, 
2));
+        updateVectors(2, Collections.singletonList("embedding"), vectorRows(6, 
3));
+        updateVectors(0, Collections.singletonList("embedding"), vectorRows(5, 
4));
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {1});
+        
assertThat(readVectorValues(builder.newRead().createReader(builder.newScan().plan()),
 10))
+                .containsExactly(4F, 4F, 4F, 4F, 4F, 3F, 3F, 3F, 2F, 2F);
+    }
+
+    @Test
+    public void testVectorColumnBackfilledByTwoUpdatesLeavesTailNull() throws 
Exception {
+        // embedding_v2 is backfilled for [0, 4] and [5, 7] by two updates; 
[8, 9] stay NULL.
+        catalog.createTable(identifier(), vectorSchema("json").build(), false);
+        InternalRow[] rows = new InternalRow[10];
+        for (int i = 0; i < 10; i++) {
+            rows[i] = GenericRow.of(i, vector(1));
+        }
+        write(getTableDefault(), rows);
+        catalog.alterTable(
+                identifier(),
+                Collections.singletonList(
+                        SchemaChange.addColumn(
+                                "embedding_v2", DataTypes.VECTOR(2, 
DataTypes.FLOAT()))),
+                false);
+        updateVectors(0, Collections.singletonList("embedding_v2"), 
vectorRows(5, 200));
+        updateVectors(5, Collections.singletonList("embedding_v2"), 
vectorRows(3, 300));
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {2});
+        
assertThat(readVectorValues(builder.newRead().createReader(builder.newScan().plan()),
 10))
+                .containsExactly(200F, 200F, 200F, 200F, 200F, 300F, 300F, 
300F, null, null);
+
+        // [0, 4] has no selected row and is pruned by the scan; [8, 9] still 
needs NULL-filling.
+        builder.withRowRanges(Collections.singletonList(new Range(6, 9)));
+        
assertThat(readVectorValues(builder.newRead().createReader(builder.newScan().plan())))
+                .containsExactly(300F, 300F, null, null);
+
+        // Every selected row is backfilled.
+        builder.withRowRanges(Arrays.asList(new Range(1, 1), new Range(6, 6)));
+        
assertThat(readVectorValues(builder.newRead().createReader(builder.newScan().plan())))
+                .containsExactly(200F, 300F);
+    }
+
+    @Test
+    public void testVectorColumnWithAGapUnderRowRanges() throws Exception {
+        // embedding_v2 is backfilled for [0, 2] and [6, 9]; rows [3, 5] stay 
NULL.
+        catalog.createTable(identifier(), vectorSchema("json").build(), false);
+        InternalRow[] rows = new InternalRow[10];
+        for (int i = 0; i < 10; i++) {
+            rows[i] = GenericRow.of(i, vector(1));
+        }
+        write(getTableDefault(), rows);
+        catalog.alterTable(
+                identifier(),
+                Collections.singletonList(
+                        SchemaChange.addColumn(
+                                "embedding_v2", DataTypes.VECTOR(2, 
DataTypes.FLOAT()))),
+                false);
+        updateVectors(0, Collections.singletonList("embedding_v2"), 
vectorRows(3, 200));
+        updateVectors(6, Collections.singletonList("embedding_v2"), 
vectorRows(4, 300));
+
+        ReadBuilder builder = 
getTableDefault().newReadBuilder().withProjection(new int[] {0, 2});
+        // No selected row is in the gap.
+        builder.withRowRanges(Arrays.asList(new Range(1, 1), new Range(7, 7)));
+        assertThat(readIdAndVector(builder)).containsExactly("1=200.0", 
"7=300.0");
+        // Row 4 is selected and must be NULL-filled.
+        builder.withRowRanges(Arrays.asList(new Range(1, 1), new Range(4, 4), 
new Range(7, 7)));
+        assertThat(readIdAndVector(builder)).containsExactly("1=200.0", 
"4=null", "7=300.0");
+    }
+
+    private static List<String> readIdAndVector(ReadBuilder builder) throws 
IOException {
+        List<String> actual = new ArrayList<>();
+        try (RecordReader<InternalRow> reader =
+                builder.newRead().createReader(builder.newScan().plan())) {
+            reader.forEachRemaining(
+                    row ->
+                            actual.add(
+                                    row.getInt(0)
+                                            + "="
+                                            + (row.isNullAt(1)
+                                                    ? null
+                                                    : 
row.getVector(1).toFloatArray()[0])));
+        }
+        return actual;
+    }
+
+    private static InternalRow[] vectorRows(int count, float value) {
+        InternalRow[] rows = new InternalRow[count];
+        for (int i = 0; i < count; i++) {
+            rows[i] = GenericRow.of(vector(value));
+        }
+        return rows;
+    }
+
     private List<Float> readVectorValues(RecordReader<InternalRow> reader) 
throws IOException {
+        return readVectorValues(reader, 4);
+    }
+
+    private List<Float> readVectorValues(RecordReader<InternalRow> reader, int 
maxRows)
+            throws IOException {
         List<Float> actual = new ArrayList<>();
         try (RecordReader<InternalRow> closeable = reader) {
             closeable.forEachRemaining(
                     row -> {
                         // Fail promptly if an all-missing projection produces 
unbounded NULL rows.
-                        assertThat(actual.size()).isLessThan(4);
+                        assertThat(actual.size()).isLessThan(maxRows);
                         actual.add(row.isNullAt(0) ? null : 
row.getVector(0).toFloatArray()[0]);
                     });
         }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
index a891ff683a..f571003d2d 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
@@ -129,6 +129,268 @@ public class DataEvolutionFileStoreScanTest {
                 .containsExactly("data-older.parquet");
     }
 
+    @Test
+    public void testReadTypePruningKeepsAnchorWhenOnlyDedicatedFilesRemain() {
+        // SELECT only a blob column: the anchor normal file does not write 
it, so without
+        // this fix the kept list would hold the blob file alone and the 
reader would derive
+        // the logical row range from the blob file's sub-range, dropping the 
rows outside
+        Schema schema = createSchema("v", "b");
+        TableSchema tableSchema = TableSchema.create(0L, schema);
+        schemas.put(0L, tableSchema);
+
+        // anchor normal file covers the whole group range [0, 9] but does not 
write "b"
+        ManifestEntry anchorFile =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-anchor.parquet",
+                        0L,
+                        new String[] {"v"},
+                        new String[] {"v"},
+                        null,
+                        1L,
+                        0L,
+                        10L);
+        // blob file covers only row ids [0, 2]
+        ManifestEntry blob =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b-0.blob",
+                        0L,
+                        new String[] {"b"},
+                        new String[] {"b"},
+                        null,
+                        5L,
+                        0L,
+                        3L);
+
+        RowType readType = DataTypes.ROW(DataTypes.FIELD(1, "b", 
DataTypes.STRING()));
+
+        List<ManifestEntry> pruned =
+                DataEvolutionFileStoreScan.pruneByReadType(
+                        Arrays.asList(anchorFile, blob),
+                        readType,
+                        Collections.emptySet(),
+                        false,
+                        entry -> 
fileFieldIds(schemas.get(entry.file().schemaId()), entry.file()));
+
+        assertThat(pruned)
+                .extracting(e -> e.file().fileName())
+                .containsExactlyInAnyOrder("data-b-0.blob", 
"data-anchor.parquet");
+    }
+
+    @Test
+    public void testReadTypePruningKeepsAnchorOnlyWhenNoNormalFileRemains() {
+        // projection references the blob column and a normal column that a 
newer full-range
+        // normal file writes: kept already contains a normal file, so the 
anchor must not
+        // be pulled in just because the group has dedicated files
+        Schema schema = createSchema("u", "v", "b");
+        TableSchema tableSchema = TableSchema.create(0L, schema);
+        schemas.put(0L, tableSchema);
+
+        // anchor writes only "u", which the query does not reference
+        ManifestEntry anchorFile =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-anchor.parquet",
+                        0L,
+                        new String[] {"u"},
+                        new String[] {"u"},
+                        null,
+                        1L,
+                        0L,
+                        10L);
+        ManifestEntry newer =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-newer.parquet",
+                        0L,
+                        new String[] {"v"},
+                        new String[] {"v"},
+                        null,
+                        5L,
+                        0L,
+                        10L);
+        ManifestEntry blob =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b-0.blob",
+                        0L,
+                        new String[] {"b"},
+                        new String[] {"b"},
+                        null,
+                        5L,
+                        0L,
+                        3L);
+        RowType readType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(1, "v", DataTypes.STRING()),
+                        DataTypes.FIELD(2, "b", DataTypes.INT()));
+
+        List<ManifestEntry> pruned =
+                DataEvolutionFileStoreScan.pruneByReadType(
+                        Arrays.asList(anchorFile, newer, blob),
+                        readType,
+                        Collections.emptySet(),
+                        false,
+                        entry -> 
fileFieldIds(schemas.get(entry.file().schemaId()), entry.file()));
+
+        assertThat(pruned)
+                .extracting(e -> e.file().fileName())
+                .containsExactlyInAnyOrder("data-newer.parquet", 
"data-b-0.blob");
+    }
+
+    @Test
+    public void 
testReadTypePruningKeepsAnchorEvenWhenDedicatedFilesCoverTheGroup() {
+        // Two rolled blob files cover the whole group [0, 9]; the anchor is 
kept anyway, since only
+        // its metadata is needed and the reader opens no bunch without 
requested fields.
+        Schema schema = createSchema("v", "b");
+        TableSchema tableSchema = TableSchema.create(0L, schema);
+        schemas.put(0L, tableSchema);
+
+        ManifestEntry anchorFile =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-anchor.parquet",
+                        0L,
+                        new String[] {"v"},
+                        new String[] {"v"},
+                        null,
+                        1L,
+                        0L,
+                        10L);
+        ManifestEntry blob0 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b-0.blob",
+                        0L,
+                        new String[] {"b"},
+                        new String[] {"b"},
+                        null,
+                        5L,
+                        0L,
+                        5L);
+        ManifestEntry blob5 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b-5.blob",
+                        0L,
+                        new String[] {"b"},
+                        new String[] {"b"},
+                        null,
+                        5L,
+                        5L,
+                        5L);
+        RowType readType = DataTypes.ROW(DataTypes.FIELD(1, "b", 
DataTypes.STRING()));
+
+        List<ManifestEntry> pruned =
+                DataEvolutionFileStoreScan.pruneByReadType(
+                        Arrays.asList(anchorFile, blob0, blob5),
+                        readType,
+                        Collections.emptySet(),
+                        false,
+                        entry -> 
fileFieldIds(schemas.get(entry.file().schemaId()), entry.file()));
+
+        assertThat(pruned)
+                .extracting(e -> e.file().fileName())
+                .containsExactlyInAnyOrder("data-b-0.blob", "data-b-5.blob", 
"data-anchor.parquet");
+    }
+
+    @Test
+    public void testReadTypePruningKeepsAnchorForMultiColumnBlobs() {
+        // SELECT two blob columns covering different sub-ranges: b1 over [0, 
6] and b2 over [3, 9].
+        // Their union spans the whole group, but each column is read as its 
own field bunch, so
+        // the anchor is needed for b1's gap at [7, 9].
+        Schema schema = createSchema("v", "b1", "b2");
+        TableSchema tableSchema = TableSchema.create(0L, schema);
+        schemas.put(0L, tableSchema);
+
+        ManifestEntry anchorFile =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-anchor.parquet",
+                        0L,
+                        new String[] {"v"},
+                        new String[] {"v"},
+                        null,
+                        1L,
+                        0L,
+                        10L);
+        ManifestEntry b1 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b1-0.blob",
+                        0L,
+                        new String[] {"b1"},
+                        new String[] {"b1"},
+                        null,
+                        5L,
+                        0L,
+                        7L);
+        ManifestEntry b2 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b2-3.blob",
+                        0L,
+                        new String[] {"b2"},
+                        new String[] {"b2"},
+                        null,
+                        5L,
+                        3L,
+                        7L);
+        RowType readType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(1, "b1", DataTypes.STRING()),
+                        DataTypes.FIELD(2, "b2", DataTypes.STRING()));
+
+        List<ManifestEntry> pruned =
+                DataEvolutionFileStoreScan.pruneByReadType(
+                        Arrays.asList(anchorFile, b1, b2),
+                        readType,
+                        Collections.emptySet(),
+                        false,
+                        entry -> 
fileFieldIds(schemas.get(entry.file().schemaId()), entry.file()));
+
+        assertThat(pruned)
+                .extracting(e -> e.file().fileName())
+                .containsExactlyInAnyOrder(
+                        "data-b1-0.blob", "data-b2-3.blob", 
"data-anchor.parquet");
+    }
+
+    @Test
+    public void testReadTypePruningKeepsDedicatedOnlyGroup() {
+        // An incremental (DELTA) scan only sees the files a snapshot added, 
so a group can hold two
+        // blob updates over [0, 1] while its normal file stays in an earlier 
snapshot.
+        Schema schema = createSchema("v", "b1", "b2");
+        TableSchema tableSchema = TableSchema.create(0L, schema);
+        schemas.put(0L, tableSchema);
+
+        ManifestEntry b1 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b1-0.blob",
+                        0L,
+                        new String[] {"b1"},
+                        new String[] {"b1"},
+                        null,
+                        5L,
+                        0L,
+                        2L);
+        ManifestEntry b2 =
+                createManifestEntryWithDifferentColsAndFileName(
+                        "data-b2-0.blob",
+                        0L,
+                        new String[] {"b2"},
+                        new String[] {"b2"},
+                        null,
+                        5L,
+                        0L,
+                        2L);
+        RowType readType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(1, "b1", DataTypes.STRING()),
+                        DataTypes.FIELD(2, "b2", DataTypes.STRING()));
+
+        List<ManifestEntry> pruned =
+                DataEvolutionFileStoreScan.pruneByReadType(
+                        Arrays.asList(b1, b2),
+                        readType,
+                        Collections.emptySet(),
+                        false,
+                        entry -> 
fileFieldIds(schemas.get(entry.file().schemaId()), entry.file()));
+
+        assertThat(pruned)
+                .extracting(e -> e.file().fileName())
+                .containsExactlyInAnyOrder("data-b1-0.blob", "data-b2-0.blob");
+    }
+
     @Test
     public void testEvolutionStatsSingleFile() {
         Schema schema = createSchema("f0", "f1");
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionVectorReadPlannerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionVectorReadPlannerTest.java
new file mode 100644
index 0000000000..ea33b7035c
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionVectorReadPlannerTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.stats.SimpleStats;
+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 java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.io.DataFileTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link DataEvolutionVectorReadPlanner}. */
+public class DataEvolutionVectorReadPlannerTest {
+
+    private static final RowType NORMAL_TYPE = RowType.of(new DataField(0, 
"id", DataTypes.INT()));
+    private static final RowType VECTOR_TYPE =
+            RowType.of(new DataField(1, "embedding", DataTypes.BYTES()));
+    private static final RowType E1_TYPE = RowType.of(new DataField(2, "e1", 
DataTypes.BYTES()));
+    private static final RowType E2_TYPE = RowType.of(new DataField(3, "e2", 
DataTypes.BYTES()));
+
+    @Test
+    public void testRolledVectorFilesCoveringTheGroupKeepSequentialRead() {
+        // A rolled vector column: two files that together cover the normal 
file's [0, 9].
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 5L, 2L),
+                        file("data-2.vector.lance", "embedding", 5L, 5L, 2L));
+
+        assertThat(DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null))
+                .isNull();
+    }
+
+    @Test
+    public void testVectorColumnCoveringPartOfTheGroupPlansRanges() {
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 5L, 2L));
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null);
+
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 4), new Range(5, 9));
+        assertThat(ranges.get(0).files)
+                .extracting(DataFileMeta::fileName)
+                .containsExactly("data-1.vector.lance");
+        assertThat(ranges.get(1).files).isEmpty();
+    }
+
+    @Test
+    public void testNewerVectorFileOverPartOfTheColumnPlansRanges() {
+        // [0, 9] then a newer update of [0, 4]: the sequential bunch would 
keep only [0, 4].
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 10L, 2L),
+                        file("data-2.vector.lance", "embedding", 0L, 5L, 3L));
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null);
+
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 4), new Range(5, 9));
+        assertThat(ranges.get(0).files)
+                .extracting(DataFileMeta::fileName)
+                .containsExactly("data-2.vector.lance");
+        assertThat(ranges.get(1).files)
+                .extracting(DataFileMeta::fileName)
+                .containsExactly("data-1.vector.lance");
+    }
+
+    @Test
+    public void testNewerVectorFileOverlappingTheTailPlansRanges() {
+        // [0, 6] then a newer update of [3, 9]: the sequential bunch would 
reject the overlap.
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 7L, 2L),
+                        file("data-2.vector.lance", "embedding", 3L, 7L, 3L));
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null);
+
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 2), new Range(3, 9));
+    }
+
+    @Test
+    public void testSkippedFileNewerThanTheNextKeptFilePlansRanges() {
+        // [0, 4] s4 and [5, 9] s2 tile the group, but [2, 7] s3 is newer than 
[5, 9] at [5, 7].
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 10L, 1L),
+                        file("data-2.vector.lance", "embedding", 5L, 5L, 2L),
+                        file("data-3.vector.lance", "embedding", 2L, 6L, 3L),
+                        file("data-4.vector.lance", "embedding", 0L, 5L, 4L));
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null);
+
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 4), new Range(5, 7), new 
Range(8, 9));
+        assertThat(ranges.get(1).files)
+                .extracting(DataFileMeta::fileName)
+                .containsExactly("data-3.vector.lance");
+    }
+
+    @Test
+    public void testFullRewriteRolledOverAnOlderFileKeepsSequentialRead() {
+        // A newer full rewrite rolled into [0, 6] and [7, 9] fully shadows 
the older [0, 9].
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 10L, 1L),
+                        file("data-2.vector.lance", "embedding", 0L, 7L, 2L),
+                        file("data-3.vector.lance", "embedding", 7L, 3L, 2L));
+
+        assertThat(DataEvolutionVectorReadPlanner.plan(files, VECTOR_TYPE, 
this::rowType, null))
+                .isNull();
+    }
+
+    @Test
+    public void testCoverageIsJudgedPerVectorColumn() {
+        // e1 covers [0, 9] and e2 only [0, 4]: their union spans the group, 
but e2's rows [5, 9]
+        // still need NULL-filling.
+        RowType readType = RowType.of(E1_TYPE.getFields().get(0), 
E2_TYPE.getFields().get(0));
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "e1", 0L, 10L, 3L),
+                        file("data-2.vector.lance", "e2", 0L, 5L, 2L));
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(files, readType, 
this::rowType, null);
+
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 4), new Range(5, 9));
+    }
+
+    @Test
+    public void testRolledVectorFilesPrunedByRowRangesKeepSequentialRead() {
+        // Rolled [0, 3], [4, 6], [7, 9]; the scan drops [4, 6] because no 
selected row is in it.
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 4L, 1L),
+                        file("data-3.vector.lance", "embedding", 7L, 3L, 1L));
+        List<Range> rowRanges = Arrays.asList(new Range(1, 1), new Range(8, 
8));
+
+        assertThat(
+                        DataEvolutionVectorReadPlanner.plan(
+                                files, VECTOR_TYPE, this::rowType, rowRanges))
+                .isNull();
+    }
+
+    @Test
+    public void testSelectedRowsOutsideTheVectorColumnPlanRanges() {
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 5L, 2L));
+
+        // No selected row falls outside [0, 4], so the sequential bunch reads 
every one of them.
+        assertThat(
+                        DataEvolutionVectorReadPlanner.plan(
+                                files,
+                                VECTOR_TYPE,
+                                this::rowType,
+                                Collections.singletonList(new Range(1, 1))))
+                .isNull();
+
+        List<DataEvolutionVectorReadPlanner.ReadRange> ranges =
+                DataEvolutionVectorReadPlanner.plan(
+                        files,
+                        VECTOR_TYPE,
+                        this::rowType,
+                        Arrays.asList(new Range(1, 1), new Range(7, 7)));
+        assertThat(ranges).isNotNull();
+        assertThat(ranges)
+                .extracting(r -> r.range)
+                .containsExactly(new Range(0, 4), new Range(5, 9));
+        assertThat(ranges.get(1).files).isEmpty();
+    }
+
+    @Test
+    public void testSelectedRowBeforeTheVectorColumnPlansRanges() {
+        // embedding covers only [5, 9].
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 5L, 5L, 2L));
+
+        assertThat(plan(files, new Range(5, 5), new Range(7, 7))).isNull();
+        assertThat(plan(files, new Range(1, 1), new Range(7, 7))).isNotNull();
+        assertThat(plan(files, new Range(4, 4), new Range(7, 7))).isNotNull();
+    }
+
+    @Test
+    public void testSelectedRowInAGapOrTheTailPlansRanges() {
+        // embedding covers [0, 2] and [6, 8]; [3, 5] and [9, 9] are uncovered.
+        List<DataFileMeta> files =
+                Arrays.asList(
+                        file("data-0.parquet", "id", 0L, 10L, 1L),
+                        file("data-1.vector.lance", "embedding", 0L, 3L, 2L),
+                        file("data-2.vector.lance", "embedding", 6L, 3L, 3L));
+
+        assertThat(plan(files, new Range(1, 1), new Range(7, 7))).isNull();
+        assertThat(plan(files, new Range(1, 1), new Range(3, 3))).isNotNull();
+        assertThat(plan(files, new Range(1, 1), new Range(5, 5))).isNotNull();
+        assertThat(plan(files, new Range(1, 1), new Range(9, 9))).isNotNull();
+    }
+
+    private List<DataEvolutionVectorReadPlanner.ReadRange> plan(
+            List<DataFileMeta> files, Range... rowRanges) {
+        return DataEvolutionVectorReadPlanner.plan(
+                files, VECTOR_TYPE, this::rowType, Arrays.asList(rowRanges));
+    }
+
+    private RowType rowType(DataFileMeta file) {
+        if (file.writeCols().contains("embedding")) {
+            return VECTOR_TYPE;
+        } else if (file.writeCols().contains("e1")) {
+            return E1_TYPE;
+        } else if (file.writeCols().contains("e2")) {
+            return E2_TYPE;
+        }
+        return NORMAL_TYPE;
+    }
+
+    private static DataFileMeta file(
+            String fileName, String column, long firstRowId, long rowCount, 
long sequence) {
+        return DataFileMeta.create(
+                fileName,
+                100L,
+                rowCount,
+                row(0),
+                row(0),
+                SimpleStats.EMPTY_STATS,
+                SimpleStats.EMPTY_STATS,
+                sequence,
+                sequence,
+                0L,
+                0,
+                null,
+                null,
+                FileSource.APPEND,
+                null,
+                firstRowId,
+                Collections.singletonList(column));
+    }
+}

Reply via email to