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 10cf6ebc7c [java][python] Read Python-planned splits with the native 
reader (#10017)
10cf6ebc7c is described below

commit 10cf6ebc7c4f0ba6066d32d8aed2b0b85e0b8349
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 21:11:17 2026 +0800

    [java][python] Read Python-planned splits with the native reader (#10017)
---
 .../DataEvolutionNormalCompactTask.java            |  15 +-
 .../DataEvolutionGlobalIndexRefreshPlanner.java    |   4 +-
 .../java/org/apache/paimon/io/DataFileMeta.java    |   8 +-
 .../apache/paimon/io/DataFileMetaSerializer.java   |   6 +-
 .../org/apache/paimon/io/PojoDataFileMeta.java     |  41 +-
 .../apache/paimon/io/ProjectedDataFileMeta.java    |   6 +-
 .../apache/paimon/utils/DataEvolutionUtils.java    |   6 +-
 .../DataEvolutionNormalCompactTaskTest.java        |  29 +-
 ...DataEvolutionGlobalIndexRefreshPlannerTest.java |  24 +-
 .../paimon/io/DataFileMetaSerializerTest.java      |  40 +-
 .../paimon/io/ProjectedDataFileMetaTest.java       |   8 +-
 ...festCommittableSerializerCompatibilityTest.java |   2 +-
 .../manifest/ManifestEntrySerializerTest.java      |   9 +-
 .../apache/paimon/manifest/ManifestFileTest.java   |   6 +-
 .../table/sink/CommitMessageSerializerTest.java    |   2 +-
 .../table/source/DataSplitCompatibleTest.java      |   4 +-
 .../paimon/utils/DataEvolutionUtilsTest.java       |  12 +-
 .../ChangelogCompactTaskSerializerTest.java        |   2 +-
 .../PendingSplitsCheckpointSerializerTest.java     |  18 +-
 .../pypaimon/manifest/manifest_file_manager.py     |   4 +
 .../pypaimon/manifest/schema/data_file_meta.py     |  11 +
 paimon-python/pypaimon/read/native_plan.py         |  22 +-
 .../pypaimon/read/reader/auth_masking_reader.py    |  84 ++--
 .../read/scanner/chunk_shuffle_split_generator.py  |   7 +-
 .../read/scanner/data_evolution_split_generator.py |   8 +-
 .../pypaimon/read/scanner/file_scanner.py          |  11 +-
 .../scanner/primary_key_table_split_generator.py   |   7 +-
 .../pypaimon/read/scanner/split_generator.py       |   7 +-
 paimon-python/pypaimon/read/sliced_split.py        |   4 +
 paimon-python/pypaimon/read/split.py               |  12 +-
 paimon-python/pypaimon/read/split_serializer.py    | 444 +++++++++++++++++++--
 .../pypaimon/read/streaming_table_scan.py          |  15 +-
 paimon-python/pypaimon/read/table_read.py          |  22 +-
 paimon-python/pypaimon/read/table_scan.py          |   4 +-
 .../table/source/primary_key_scored_result.py      |   9 +-
 .../tests/data_evolution_split_generator_test.py   |   4 +-
 .../pypaimon/tests/interval_partition_test.py      |   4 +-
 .../tests/manifest/manifest_manager_test.py        |   3 +
 .../tests/native_plan_chunk_shuffle_test.py        |   2 +
 .../pypaimon/tests/native_plan_incremental_test.py |   4 +-
 .../pypaimon/tests/native_plan_integration_test.py | 113 +++++-
 paimon-python/pypaimon/tests/native_read_test.py   |  52 +++
 .../tests/primary_key_index_definitions_test.py    |   1 +
 .../scanner/chunk_shuffle_split_generator_test.py  |   8 +-
 .../pypaimon/tests/split_serializer_test.py        |  89 ++++-
 .../paimon/spark/copy/CopyFilesUtilTest.java       |   6 +-
 46 files changed, 971 insertions(+), 228 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
index 6a46a4425f..7065a37bc1 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTask.java
@@ -177,9 +177,9 @@ public class DataEvolutionNormalCompactTask extends 
DataEvolutionCompactTask {
         long minSequenceNumber = minSequenceId(compactBefore);
         long maxSequenceNumber = maxSequenceId(compactBefore);
         long nextRowId = firstRowId;
-        long[] columnMaxSequenceNumbers =
+        long[] writeColsSequences =
                 options.ignoreIndexColumnUpdate() && !writeResult.isEmpty()
-                        ? compactedColumnMaxSequenceNumbers(
+                        ? compactedWriteColsSequences(
                                 table,
                                 writeResult
                                         .get(0)
@@ -189,8 +189,8 @@ public class DataEvolutionNormalCompactTask extends 
DataEvolutionCompactTask {
             DataFileMeta dataFileMeta =
                     file.assignFirstRowId(nextRowId)
                             .assignSequenceNumber(minSequenceNumber, 
maxSequenceNumber);
-            if (columnMaxSequenceNumbers != null) {
-                dataFileMeta = 
dataFileMeta.withColumnMaxSequenceNumbers(columnMaxSequenceNumbers);
+            if (writeColsSequences != null) {
+                dataFileMeta = 
dataFileMeta.withWriteColsSequences(writeColsSequences);
             }
             compactAfter.add(dataFileMeta);
             nextRowId += dataFileMeta.rowCount();
@@ -230,8 +230,7 @@ public class DataEvolutionNormalCompactTask extends 
DataEvolutionCompactTask {
     }
 
     @Nullable
-    private long[] compactedColumnMaxSequenceNumbers(
-            FileStoreTable table, DataFileMeta outputFile) {
+    private long[] compactedWriteColsSequences(FileStoreTable table, 
DataFileMeta outputFile) {
         SchemaManager schemaManager = table.schemaManager();
         Map<Long, TableSchema> schemaCache = new HashMap<>();
         Function<Long, TableSchema> schemaLoader =
@@ -244,12 +243,12 @@ public class DataEvolutionNormalCompactTask extends 
DataEvolutionCompactTask {
                     fileFieldsCache.computeIfAbsent(
                             Pair.of(input.schemaId(), input.writeCols()),
                             key -> fileFields(schemaLoader, input));
-            long[] inputColumnSequences = input.columnMaxSequenceNumbers();
+            long[] inputWriteColsSequences = input.writeColsSequences();
             for (int inputPosition = 0; inputPosition < inputFields.size(); 
inputPosition++) {
                 fieldMaxSequences.merge(
                         inputFields.get(inputPosition).id(),
                         fieldMaxSequenceNumber(
-                                input, inputColumnSequences, inputPosition, 
inputFields.size()),
+                                input, inputWriteColsSequences, inputPosition, 
inputFields.size()),
                         Math::max);
             }
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
index c1d888587a..4dc6fcaf70 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlanner.java
@@ -255,7 +255,7 @@ public final class DataEvolutionGlobalIndexRefreshPlanner {
                             TableSchema fileSchema = 
schemaLoader.apply(file.schemaId());
                             return fileFields(fileSchema, file);
                         });
-        long[] columnSequences = file.columnMaxSequenceNumbers();
+        long[] writeColsSequences = file.writeColsSequences();
         long indexedMaxSequence = Long.MIN_VALUE;
         for (int position = 0; position < physicalFields.size(); position++) {
             if (indexedFieldIds.contains(physicalFields.get(position).id())) {
@@ -263,7 +263,7 @@ public final class DataEvolutionGlobalIndexRefreshPlanner {
                         Math.max(
                                 indexedMaxSequence,
                                 fieldMaxSequenceNumber(
-                                        file, columnSequences, position, 
physicalFields.size()));
+                                        file, writeColsSequences, position, 
physicalFields.size()));
             }
         }
         if (indexedMaxSequence != Long.MIN_VALUE) {
diff --git a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java 
b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
index a8983256e1..955750613a 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMeta.java
@@ -266,7 +266,7 @@ public interface DataFileMeta {
             @Nullable String externalPath,
             @Nullable Long firstRowId,
             @Nullable List<String> writeCols,
-            @Nullable long[] columnMaxSequenceNumbers) {
+            @Nullable long[] writeColsSequences) {
         return new PojoDataFileMeta(
                 fileName,
                 fileSize,
@@ -288,7 +288,7 @@ public interface DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     String fileName();
@@ -377,7 +377,7 @@ public interface DataFileMeta {
      * value otherwise. A null value means that only the file-level sequence 
range is available.
      */
     @Nullable
-    long[] columnMaxSequenceNumbers();
+    long[] writeColsSequences();
 
     DataFileMeta upgrade(int newLevel);
 
@@ -387,7 +387,7 @@ public interface DataFileMeta {
 
     DataFileMeta assignSequenceNumber(long minSequenceNumber, long 
maxSequenceNumber);
 
-    DataFileMeta withColumnMaxSequenceNumbers(long[] columnMaxSequenceNumbers);
+    DataFileMeta withWriteColsSequences(long[] writeColsSequences);
 
     DataFileMeta assignFirstRowId(long firstRowId);
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMetaSerializer.java 
b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMetaSerializer.java
index f148bb397e..ba49e4e547 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/DataFileMetaSerializer.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/DataFileMetaSerializer.java
@@ -43,7 +43,7 @@ public class DataFileMetaSerializer extends 
ObjectSerializer<DataFileMeta> {
 
     @Override
     public InternalRow toRow(DataFileMeta meta) {
-        long[] columnMaxSequenceNumbers = meta.columnMaxSequenceNumbers();
+        long[] writeColsSequences = meta.writeColsSequences();
         return GenericRow.of(
                 BinaryString.fromString(meta.fileName()),
                 meta.fileSize(),
@@ -65,9 +65,7 @@ public class DataFileMetaSerializer extends 
ObjectSerializer<DataFileMeta> {
                 meta.externalPath().map(BinaryString::fromString).orElse(null),
                 meta.firstRowId(),
                 meta.writeCols() == null ? null : 
toStringArrayData(meta.writeCols()),
-                columnMaxSequenceNumbers == null
-                        ? null
-                        : new GenericArray(columnMaxSequenceNumbers));
+                writeColsSequences == null ? null : new 
GenericArray(writeColsSequences));
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java 
b/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
index 6cf1eaa0d5..af65b0800e 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/PojoDataFileMeta.java
@@ -78,7 +78,7 @@ public class PojoDataFileMeta implements DataFileMeta {
 
     private final @Nullable List<String> writeCols;
 
-    private final @Nullable long[] columnMaxSequenceNumbers;
+    private final @Nullable long[] writeColsSequences;
 
     public PojoDataFileMeta(
             String fileName,
@@ -101,7 +101,7 @@ public class PojoDataFileMeta implements DataFileMeta {
             @Nullable String externalPath,
             @Nullable Long firstRowId,
             @Nullable List<String> writeCols,
-            @Nullable long[] columnMaxSequenceNumbers) {
+            @Nullable long[] writeColsSequences) {
         this.fileName = fileName;
         this.fileSize = fileSize;
 
@@ -126,8 +126,7 @@ public class PojoDataFileMeta implements DataFileMeta {
         this.externalPath = externalPath;
         this.firstRowId = firstRowId;
         this.writeCols = writeCols;
-        this.columnMaxSequenceNumbers =
-                columnMaxSequenceNumbers == null ? null : 
columnMaxSequenceNumbers.clone();
+        this.writeColsSequences = writeColsSequences == null ? null : 
writeColsSequences.clone();
     }
 
     @Override
@@ -246,8 +245,8 @@ public class PojoDataFileMeta implements DataFileMeta {
 
     @Nullable
     @Override
-    public long[] columnMaxSequenceNumbers() {
-        return columnMaxSequenceNumbers == null ? null : 
columnMaxSequenceNumbers.clone();
+    public long[] writeColsSequences() {
+        return writeColsSequences == null ? null : writeColsSequences.clone();
     }
 
     @Override
@@ -274,7 +273,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -301,7 +300,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 newExternalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -327,7 +326,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -353,11 +352,11 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
-    public PojoDataFileMeta withColumnMaxSequenceNumbers(long[] 
columnMaxSequenceNumbers) {
+    public PojoDataFileMeta withWriteColsSequences(long[] writeColsSequences) {
         return new PojoDataFileMeta(
                 fileName,
                 fileSize,
@@ -379,7 +378,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -405,7 +404,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -431,7 +430,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 newFirstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -457,7 +456,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -483,7 +482,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 newExternalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -509,7 +508,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                columnMaxSequenceNumbers);
+                writeColsSequences);
     }
 
     @Override
@@ -541,7 +540,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 && Objects.equals(externalPath, 
that.externalPath().orElse(null))
                 && Objects.equals(firstRowId, that.firstRowId())
                 && Objects.equals(writeCols, that.writeCols())
-                && Arrays.equals(columnMaxSequenceNumbers, 
that.columnMaxSequenceNumbers());
+                && Arrays.equals(writeColsSequences, 
that.writeColsSequences());
     }
 
     @Override
@@ -567,7 +566,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                Arrays.hashCode(columnMaxSequenceNumbers));
+                Arrays.hashCode(writeColsSequences));
     }
 
     @Override
@@ -578,7 +577,7 @@ public class PojoDataFileMeta implements DataFileMeta {
                         + "minSequenceNumber: %d, maxSequenceNumber: %d, "
                         + "schemaId: %d, level: %d, extraFiles: %s, 
creationTime: %s, "
                         + "deleteRowCount: %d, fileSource: %s, valueStatsCols: 
%s, externalPath: %s, "
-                        + "firstRowId: %s, writeCols: %s, 
columnMaxSequenceNumbers: %s}",
+                        + "firstRowId: %s, writeCols: %s, writeColsSequences: 
%s}",
                 fileName,
                 fileSize,
                 rowCount,
@@ -599,6 +598,6 @@ public class PojoDataFileMeta implements DataFileMeta {
                 externalPath,
                 firstRowId,
                 writeCols,
-                Arrays.toString(columnMaxSequenceNumbers));
+                Arrays.toString(writeColsSequences));
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/ProjectedDataFileMeta.java 
b/paimon-core/src/main/java/org/apache/paimon/io/ProjectedDataFileMeta.java
index 70da63dba7..1eaaf9af39 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/ProjectedDataFileMeta.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/ProjectedDataFileMeta.java
@@ -256,7 +256,7 @@ public final class ProjectedDataFileMeta implements 
DataFileMeta {
 
     @Nullable
     @Override
-    public long[] columnMaxSequenceNumbers() {
+    public long[] writeColsSequences() {
         int position = requiredPosition(Fields.WRITE_COLS_SEQUENCES);
         InternalRow row = currentRow();
         if (row.isNullAt(position)) {
@@ -308,8 +308,8 @@ public final class ProjectedDataFileMeta implements 
DataFileMeta {
     }
 
     @Override
-    public DataFileMeta withColumnMaxSequenceNumbers(long[] 
columnMaxSequenceNumbers) {
-        throw unsupportedOperation("withColumnMaxSequenceNumbers(long[])");
+    public DataFileMeta withWriteColsSequences(long[] writeColsSequences) {
+        throw unsupportedOperation("withWriteColsSequences(long[])");
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
index a4e702184b..f8a2112eb6 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/DataEvolutionUtils.java
@@ -291,13 +291,13 @@ public class DataEvolutionUtils {
     /** Returns the latest sequence known for a physical field position in the 
file. */
     public static long fieldMaxSequenceNumber(
             DataFileMeta file,
-            @Nullable long[] columnSequences,
+            @Nullable long[] writeColsSequences,
             int fieldPosition,
             int physicalFieldCount) {
-        if (columnSequences == null || columnSequences.length != 
physicalFieldCount) {
+        if (writeColsSequences == null || writeColsSequences.length != 
physicalFieldCount) {
             return file.maxSequenceNumber();
         }
-        return columnSequences[fieldPosition];
+        return writeColsSequences[fieldPosition];
     }
 
     /**
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
index 6d15a8c345..2464cbf892 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionNormalCompactTaskTest.java
@@ -82,7 +82,7 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
     }
 
     @Test
-    public void testPropagateColumnSequencesAcrossCompactions() throws 
Exception {
+    public void testPropagateWriteColsSequencesAcrossCompactions() throws 
Exception {
         write();
 
         int f0Id = getTableDefault().rowType().getField("f0").id();
@@ -93,22 +93,23 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
                         1,
                         CoreOptions.GlobalIndexColumnUpdateAction.IGNORE);
 
-        long f0Sequence = columnSequence(firstCompact, f0Id);
+        long f0Sequence = writeColSequence(firstCompact, f0Id);
         assertThat(f0Sequence).isLessThan(firstCompact.maxSequenceNumber());
-        assertThat(columnSequence(firstCompact, 
f1Id)).isEqualTo(firstCompact.maxSequenceNumber());
+        assertThat(writeColSequence(firstCompact, f1Id))
+                .isEqualTo(firstCompact.maxSequenceNumber());
 
         DataFileMeta secondCompact =
                 updateColumnsAndCompact(
                         Collections.singletonList("f1"),
                         2,
                         CoreOptions.GlobalIndexColumnUpdateAction.IGNORE);
-        assertThat(columnSequence(secondCompact, f0Id)).isEqualTo(f0Sequence);
-        assertThat(columnSequence(secondCompact, f1Id))
+        assertThat(writeColSequence(secondCompact, 
f0Id)).isEqualTo(f0Sequence);
+        assertThat(writeColSequence(secondCompact, f1Id))
                 .isEqualTo(secondCompact.maxSequenceNumber());
     }
 
     @Test
-    public void testOmitAndReconstructRedundantColumnSequences() throws 
Exception {
+    public void testOmitAndReconstructRedundantWriteColsSequences() throws 
Exception {
         write();
 
         DataFileMeta fullUpdate =
@@ -116,7 +117,7 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
                         Arrays.asList("f0", "f1"),
                         1,
                         CoreOptions.GlobalIndexColumnUpdateAction.IGNORE);
-        assertThat(fullUpdate.columnMaxSequenceNumbers()).isNull();
+        assertThat(fullUpdate.writeColsSequences()).isNull();
 
         int f0Id = getTableDefault().rowType().getField("f0").id();
         DataFileMeta partialUpdate =
@@ -124,13 +125,13 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
                         Collections.singletonList("f1"),
                         2,
                         CoreOptions.GlobalIndexColumnUpdateAction.IGNORE);
-        assertThat(columnSequence(partialUpdate, f0Id))
+        assertThat(writeColSequence(partialUpdate, f0Id))
                 .isEqualTo(fullUpdate.maxSequenceNumber())
                 .isLessThan(partialUpdate.maxSequenceNumber());
     }
 
     @Test
-    public void testOmitColumnSequencesUnlessUpdatesAreIgnored() throws 
Exception {
+    public void testOmitWriteColsSequencesUnlessUpdatesAreIgnored() throws 
Exception {
         write();
 
         DataFileMeta compacted =
@@ -138,7 +139,7 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
                         Collections.singletonList("f1"),
                         1,
                         CoreOptions.GlobalIndexColumnUpdateAction.THROW_ERROR);
-        assertThat(compacted.columnMaxSequenceNumbers()).isNull();
+        assertThat(compacted.writeColsSequences()).isNull();
     }
 
     @ParameterizedTest
@@ -221,9 +222,9 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
             
assertThat(file.minSequenceNumber()).isEqualTo(original.minSequenceNumber());
             assertThat(file.maxSequenceNumber()).isEqualTo(maxSequenceNumber);
             if (updateColumn) {
-                assertThat(columnSequence(file, 
table.rowType().getField("f1").id()))
+                assertThat(writeColSequence(file, 
table.rowType().getField("f1").id()))
                         .isEqualTo(original.maxSequenceNumber());
-                assertThat(columnSequence(file, 
table.rowType().getField("f0").id()))
+                assertThat(writeColSequence(file, 
table.rowType().getField("f0").id()))
                         .isEqualTo(maxSequenceNumber);
             }
             nextRowId += file.rowCount();
@@ -793,12 +794,12 @@ public class DataEvolutionNormalCompactTaskTest extends 
TableTestBase {
         return rowRangeFiles.get(0);
     }
 
-    private long columnSequence(DataFileMeta file, int fieldId) throws 
Exception {
+    private long writeColSequence(DataFileMeta file, int fieldId) throws 
Exception {
         TableSchema fileSchema = 
getTableDefault().schemaManager().schema(file.schemaId());
         boolean nestedFieldEnabled =
                 new 
CoreOptions(fileSchema.options()).dataEvolutionNestedFieldEnabled();
         List<DataField> fields = fileFields(fileSchema.fields(), file, 
nestedFieldEnabled);
-        long[] sequences = file.columnMaxSequenceNumbers();
+        long[] sequences = file.writeColsSequences();
         assertThat(sequences).hasSize(fields.size());
         for (int i = 0; i < fields.size(); i++) {
             if (fields.get(i).id() == fieldId) {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
index f7bc10051c..0b8e07b39d 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/globalindex/DataEvolutionGlobalIndexRefreshPlannerTest.java
@@ -155,13 +155,13 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
     }
 
     @Test
-    void testUsesColumnSequenceNumbersForCompactedFullFile() {
+    void testUsesWriteColsSequencesForCompactedFullFile() {
         IndexManifestEntry index = index("index", 0, 99, 5L, 
BinaryRow.EMPTY_ROW, 0);
 
         assertThat(
                         plan(
                                 Collections.singletonList(
-                                        dataWithColumnSequences(
+                                        dataWithWriteColsSequences(
                                                 "unrelated-compact",
                                                 0,
                                                 100,
@@ -172,7 +172,7 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
         assertThat(
                         plan(
                                 Collections.singletonList(
-                                        dataWithColumnSequences(
+                                        dataWithWriteColsSequences(
                                                 "index-compact",
                                                 0,
                                                 100,
@@ -187,13 +187,13 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
     }
 
     @Test
-    void testColumnSequenceNumbersFollowWriteColsOrder() {
+    void testWriteColsSequencesFollowWriteColsOrder() {
         IndexManifestEntry index = index("index", 0, 99, 5L, 
BinaryRow.EMPTY_ROW, 0);
 
         assertThat(
                         plan(
                                 Collections.singletonList(
-                                        dataWithColumnSequences(
+                                        dataWithWriteColsSequences(
                                                 "reordered-compact",
                                                 0,
                                                 100,
@@ -206,13 +206,13 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
     }
 
     @Test
-    void testColumnSequenceNumbersIgnoreRowTrackingFields() {
+    void testWriteColsSequencesIgnoreRowTrackingFields() {
         IndexManifestEntry index = index("index", 0, 99, 5L, 
BinaryRow.EMPTY_ROW, 0);
 
         assertThat(
                         plan(
                                 Collections.singletonList(
-                                        dataWithColumnSequences(
+                                        dataWithWriteColsSequences(
                                                 "row-tracking-compact",
                                                 0,
                                                 100,
@@ -241,13 +241,13 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
     }
 
     @Test
-    void testMalformedColumnSequenceNumbersFallBackToFileSequence() {
+    void testMalformedWriteColsSequencesFallBackToFileSequence() {
         IndexManifestEntry index = index("index", 0, 99, 5L, 
BinaryRow.EMPTY_ROW, 0);
 
         assertThat(
                         plan(
                                 Collections.singletonList(
-                                        dataWithColumnSequences(
+                                        dataWithWriteColsSequences(
                                                 "malformed-compact",
                                                 0,
                                                 100,
@@ -599,17 +599,17 @@ class DataEvolutionGlobalIndexRefreshPlannerTest {
         return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1, 
file);
     }
 
-    private ManifestEntry dataWithColumnSequences(
+    private ManifestEntry dataWithWriteColsSequences(
             String fileName,
             long firstRowId,
             long rowCount,
             long maxSequenceNumber,
-            long[] columnSequences,
+            long[] writeColsSequences,
             String... writeCols) {
         DataFileMeta file =
                 data(fileName, firstRowId, rowCount, maxSequenceNumber, 1, 
writeCols)
                         .file()
-                        .withColumnMaxSequenceNumbers(columnSequences);
+                        .withWriteColsSequences(writeColsSequences);
         return ManifestEntry.create(FileKind.ADD, BinaryRow.EMPTY_ROW, 0, 1, 
file);
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/io/DataFileMetaSerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/io/DataFileMetaSerializerTest.java
index be87c063aa..d7f1c1bbf7 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/io/DataFileMetaSerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/io/DataFileMetaSerializerTest.java
@@ -42,44 +42,44 @@ public class DataFileMetaSerializerTest extends 
ObjectSerializerTestBase<DataFil
         return gen.next()
                 .meta
                 .copy(Arrays.asList("extra1", "extra2"))
-                .withColumnMaxSequenceNumbers(new long[] {3L, 42L});
+                .withWriteColsSequences(new long[] {3L, 42L});
     }
 
     @Test
-    void testCopyOperationsPreserveColumnSequences() {
+    void testCopyOperationsPreserveWriteColsSequences() {
         DataFileMeta file = object();
-        assertColumnSequences(file.upgrade(file.level() + 1));
-        assertColumnSequences(file.rename("renamed.parquet"));
-        assertColumnSequences(file.copyWithoutStats());
-        assertColumnSequences(file.assignSequenceNumber(1L, 2L));
-        assertColumnSequences(file.assignFirstRowId(1L));
-        assertColumnSequences(file.newFirstRowId(null));
-        assertColumnSequences(file.copy(Collections.emptyList()));
-        
assertColumnSequences(file.newExternalPath("external/renamed.parquet"));
-        assertColumnSequences(file.copy(new byte[] {1}));
+        assertWriteColsSequences(file.upgrade(file.level() + 1));
+        assertWriteColsSequences(file.rename("renamed.parquet"));
+        assertWriteColsSequences(file.copyWithoutStats());
+        assertWriteColsSequences(file.assignSequenceNumber(1L, 2L));
+        assertWriteColsSequences(file.assignFirstRowId(1L));
+        assertWriteColsSequences(file.newFirstRowId(null));
+        assertWriteColsSequences(file.copy(Collections.emptyList()));
+        
assertWriteColsSequences(file.newExternalPath("external/renamed.parquet"));
+        assertWriteColsSequences(file.copy(new byte[] {1}));
     }
 
     @Test
-    void testLegacySerializerDropsColumnSequences() {
+    void testLegacySerializerDropsWriteColsSequences() {
         DataFileMetaWriteColsLegacySerializer legacy = new 
DataFileMetaWriteColsLegacySerializer();
         DataFileMeta file = legacy.fromRow(legacy.toRow(object()));
-        assertThat(file.columnMaxSequenceNumbers()).isNull();
+        assertThat(file.writeColsSequences()).isNull();
     }
 
     @Test
-    void testColumnSequencesAreDefensivelyCopied() {
+    void testWriteColsSequencesAreDefensivelyCopied() {
         long[] sequences = {3L, 42L};
-        DataFileMeta file = 
gen.next().meta.withColumnMaxSequenceNumbers(sequences);
+        DataFileMeta file = gen.next().meta.withWriteColsSequences(sequences);
 
         sequences[0] = 100L;
-        assertColumnSequences(file);
+        assertWriteColsSequences(file);
 
-        long[] returned = file.columnMaxSequenceNumbers();
+        long[] returned = file.writeColsSequences();
         returned[1] = 100L;
-        assertColumnSequences(file);
+        assertWriteColsSequences(file);
     }
 
-    private void assertColumnSequences(DataFileMeta file) {
-        assertThat(file.columnMaxSequenceNumbers()).containsExactly(3L, 42L);
+    private void assertWriteColsSequences(DataFileMeta file) {
+        assertThat(file.writeColsSequences()).containsExactly(3L, 42L);
     }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/io/ProjectedDataFileMetaTest.java 
b/paimon-core/src/test/java/org/apache/paimon/io/ProjectedDataFileMetaTest.java
index 744aa14ed0..f1d6b35bbd 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/io/ProjectedDataFileMetaTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/io/ProjectedDataFileMetaTest.java
@@ -56,7 +56,7 @@ public class ProjectedDataFileMetaTest {
                                 "external/dir/data.parquet",
                                 10L,
                                 Collections.singletonList("write_col"))
-                        .withColumnMaxSequenceNumbers(new long[] {11L});
+                        .withWriteColsSequences(new long[] {11L});
         ProjectedDataFileMeta actual =
                 ProjectedDataFileMeta.Projection.create(DataFileMeta.SCHEMA)
                         .createDataFile()
@@ -92,7 +92,7 @@ public class ProjectedDataFileMetaTest {
         assertThat(actual.firstRowId()).isEqualTo(10L);
         assertThat(actual.nonNullFirstRowId()).isEqualTo(10L);
         assertThat(actual.writeCols()).containsExactly("write_col");
-        assertThat(actual.columnMaxSequenceNumbers()).containsExactly(11L);
+        assertThat(actual.writeColsSequences()).containsExactly(11L);
         
assertThat(actual.containsWriteColumn(BinaryString.fromString("write_col"))).isTrue();
         
assertThat(actual.containsWriteColumn(BinaryString.fromString("other"))).isFalse();
         assertThat(actual.toFileSelection(Collections.singletonList(new 
Range(11L, 12L))))
@@ -104,8 +104,8 @@ public class ProjectedDataFileMetaTest {
         assertUnsupported(
                 () -> actual.assignSequenceNumber(4L, 5L), 
"assignSequenceNumber(long, long)");
         assertUnsupported(
-                () -> actual.withColumnMaxSequenceNumbers(new long[] {2L}),
-                "withColumnMaxSequenceNumbers(long[])");
+                () -> actual.withWriteColsSequences(new long[] {2L}),
+                "withWriteColsSequences(long[])");
         assertUnsupported(() -> actual.assignFirstRowId(20L), 
"assignFirstRowId(long)");
         assertUnsupported(() -> actual.newFirstRowId(20L), 
"newFirstRowId(Long)");
         assertUnsupported(() -> actual.copy(Collections.emptyList()), 
"copy(List)");
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestCommittableSerializerCompatibilityTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestCommittableSerializerCompatibilityTest.java
index dbe1ebfab0..c25bee603e 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestCommittableSerializerCompatibilityTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestCommittableSerializerCompatibilityTest.java
@@ -75,7 +75,7 @@ public class ManifestCommittableSerializerCompatibilityTest {
                                 1L,
                                 Arrays.asList("a", "b"),
                                 null)
-                        .withColumnMaxSequenceNumbers(new long[] {3L, 5L});
+                        .withWriteColsSequences(new long[] {3L, 5L});
         IndexFileMeta indexFile =
                 new IndexFileMeta(
                         "index-type", "index-file", 100L, 10L, 
(GlobalIndexMeta) null, null);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySerializerTest.java
index 5005f04cc9..80e82dd945 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestEntrySerializerTest.java
@@ -40,21 +40,22 @@ public class ManifestEntrySerializerTest extends 
ObjectSerializerTestBase<Manife
     @Test
     void testWriteColsLegacySerializer() throws IOException {
         ManifestEntry expected = gen.next();
-        ManifestEntry withColumnSequences =
+        ManifestEntry withWriteColsSequences =
                 ManifestEntry.create(
                         expected.kind(),
                         expected.partition(),
                         expected.bucket(),
                         expected.totalBuckets(),
-                        expected.file().withColumnMaxSequenceNumbers(new 
long[] {3L, 42L}));
+                        expected.file().withWriteColsSequences(new long[] {3L, 
42L}));
         ManifestEntryWriteColsLegacySerializer serializer =
                 new ManifestEntryWriteColsLegacySerializer();
 
         ManifestEntry actual =
-                
serializer.deserializeFromBytes(serializer.serializeToBytes(withColumnSequences));
+                serializer.deserializeFromBytes(
+                        serializer.serializeToBytes(withWriteColsSequences));
 
         assertThat(actual).isEqualTo(expected);
-        assertThat(actual.file().columnMaxSequenceNumbers()).isNull();
+        assertThat(actual.file().writeColsSequences()).isNull();
     }
 
     @Override
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java 
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
index 24f84659d1..2eb0c1f4cb 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
@@ -624,7 +624,7 @@ public class ManifestFileTest {
     }
 
     @Test
-    void testLegacyAvroReaderSkipsColumnSequenceNumbers() throws Exception {
+    void testLegacyAvroReaderSkipsWriteColsSequences() throws Exception {
         ManifestEntry expected = gen.next();
         ManifestEntry source =
                 ManifestEntry.create(
@@ -632,7 +632,7 @@ public class ManifestFileTest {
                         expected.partition(),
                         expected.bucket(),
                         expected.totalBuckets(),
-                        expected.file().withColumnMaxSequenceNumbers(new 
long[] {3L, 42L}));
+                        expected.file().withWriteColsSequences(new long[] {3L, 
42L}));
         List<DataField> legacyManifestFields =
                 ManifestEntry.MANIFEST_ROW_TYPE.getFields().stream()
                         .map(
@@ -664,7 +664,7 @@ public class ManifestFileTest {
         }
 
         assertThat(actual).isEqualTo(expected);
-        assertThat(actual.file().columnMaxSequenceNumbers()).isNull();
+        assertThat(actual.file().writeColsSequences()).isNull();
     }
 
     @ParameterizedTest
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/sink/CommitMessageSerializerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/sink/CommitMessageSerializerTest.java
index bc36deedca..04f40f71e5 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/sink/CommitMessageSerializerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/sink/CommitMessageSerializerTest.java
@@ -47,7 +47,7 @@ public class CommitMessageSerializerTest {
                         dataIncrement
                                 .newFiles()
                                 .get(0)
-                                .withColumnMaxSequenceNumbers(new long[] {3L, 
42L}));
+                                .withWriteColsSequences(new long[] {3L, 42L}));
         dataIncrement.newIndexFiles().addAll(Arrays.asList(randomIndexFile(), 
randomIndexFile()));
         dataIncrement
                 .deletedIndexFiles()
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 e3f5403c68..7e7a76b815 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
@@ -221,7 +221,7 @@ public class DataSplitCompatibleTest {
         DataFileTestDataGenerator gen = 
DataFileTestDataGenerator.builder().build();
         DataFileTestDataGenerator.Data data = gen.next();
         List<DataFileMeta> files = new ArrayList<>();
-        files.add(gen.next().meta.withColumnMaxSequenceNumbers(new long[] {3L, 
42L}));
+        files.add(gen.next().meta.withWriteColsSequences(new long[] {3L, 
42L}));
         for (int i = 0; i < ThreadLocalRandom.current().nextInt(10); i++) {
             files.add(gen.next().meta);
         }
@@ -833,7 +833,7 @@ public class DataSplitCompatibleTest {
                                 12L,
                                 Arrays.asList("a", "b", "c", "f"),
                                 null)
-                        .withColumnMaxSequenceNumbers(new long[] {15L, 100L, 
150L, 200L});
+                        .withWriteColsSequences(new long[] {15L, 100L, 150L, 
200L});
         List<DataFileMeta> dataFiles = Collections.singletonList(dataFile);
 
         DeletionFile deletionFile = new DeletionFile("deletion_file", 100, 22, 
33L);
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
index 4d82ecd479..028c12ee44 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/utils/DataEvolutionUtilsTest.java
@@ -367,21 +367,19 @@ public class DataEvolutionUtilsTest {
     public void 
testFieldMaxSequenceNumberFallsBackForMissingOrMalformedArray() {
         DataFileMeta legacy = dataFile("legacy.parquet", 10, null);
         DataFileMeta malformed =
-                dataFile("malformed.parquet", 10, null)
-                        .withColumnMaxSequenceNumbers(new long[] {5L});
+                dataFile("malformed.parquet", 10, 
null).withWriteColsSequences(new long[] {5L});
         DataFileMeta valid =
-                dataFile("valid.parquet", 10, null)
-                        .withColumnMaxSequenceNumbers(new long[] {5L, 8L});
+                dataFile("valid.parquet", 10, null).withWriteColsSequences(new 
long[] {5L, 8L});
 
         assertThat(
                         DataEvolutionUtils.fieldMaxSequenceNumber(
-                                legacy, legacy.columnMaxSequenceNumbers(), 0, 
2))
+                                legacy, legacy.writeColsSequences(), 0, 2))
                 .isEqualTo(10L);
         assertThat(
                         DataEvolutionUtils.fieldMaxSequenceNumber(
-                                malformed, 
malformed.columnMaxSequenceNumbers(), 0, 2))
+                                malformed, malformed.writeColsSequences(), 0, 
2))
                 .isEqualTo(10L);
-        long[] validSequences = valid.columnMaxSequenceNumbers();
+        long[] validSequences = valid.writeColsSequences();
         assertThat(DataEvolutionUtils.fieldMaxSequenceNumber(valid, 
validSequences, 0, 2))
                 .isEqualTo(5L);
         assertThat(DataEvolutionUtils.fieldMaxSequenceNumber(valid, 
validSequences, 1, 2))
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/compact/changelog/ChangelogCompactTaskSerializerTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/compact/changelog/ChangelogCompactTaskSerializerTest.java
index 2dba94abd8..fe35fed0d4 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/compact/changelog/ChangelogCompactTaskSerializerTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/compact/changelog/ChangelogCompactTaskSerializerTest.java
@@ -104,6 +104,6 @@ public class ChangelogCompactTaskSerializerTest {
                         null,
                         null,
                         null)
-                .withColumnMaxSequenceNumbers(new long[] {1L});
+                .withWriteColsSequences(new long[] {1L});
     }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/PendingSplitsCheckpointSerializerTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/PendingSplitsCheckpointSerializerTest.java
index e89ef16bb2..3a697d2d61 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/PendingSplitsCheckpointSerializerTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/PendingSplitsCheckpointSerializerTest.java
@@ -164,8 +164,8 @@ public class PendingSplitsCheckpointSerializerTest {
         assertThat(incremental.afterFiles())
                 .extracting(DataFileMeta::fileName)
                 .containsExactly("after.parquet");
-        
assertThat(incremental.beforeFiles().get(0).columnMaxSequenceNumbers()).isNull();
-        
assertThat(incremental.afterFiles().get(0).columnMaxSequenceNumbers()).isNull();
+        
assertThat(incremental.beforeFiles().get(0).writeColsSequences()).isNull();
+        
assertThat(incremental.afterFiles().get(0).writeColsSequences()).isNull();
         assertThat(incremental.beforeDeletionFiles())
                 .containsExactly(new DeletionFile("before.dv", 0L, 1L, 1L));
         assertThat(incremental.afterDeletionFiles())
@@ -179,7 +179,7 @@ public class PendingSplitsCheckpointSerializerTest {
                 .extracting(DataFileMeta::fileName)
                 .containsExactly("before.parquet", "after.parquet");
         assertThat(chain.dataFiles())
-                .allSatisfy(file -> 
assertThat(file.columnMaxSequenceNumbers()).isNull());
+                .allSatisfy(file -> 
assertThat(file.writeColsSequences()).isNull());
         assertThat(chain.fileBranchMapping())
                 .containsOnly(
                         org.assertj.core.data.MapEntry.entry("before.parquet", 
"snapshot"),
@@ -209,7 +209,7 @@ public class PendingSplitsCheckpointSerializerTest {
     }
 
     private static DataFileMeta file(String fileName, long sequence) {
-        return newFile(0).rename(fileName).withColumnMaxSequenceNumbers(new 
long[] {sequence});
+        return newFile(0).rename(fileName).withWriteColsSequences(new long[] 
{sequence});
     }
 
     private static void assertIncrementalSplit(
@@ -217,8 +217,8 @@ public class PendingSplitsCheckpointSerializerTest {
         assertThat(sourceSplit.recordsToSkip()).isEqualTo(recordsToSkip);
         assertThat(sourceSplit.split()).isInstanceOf(IncrementalSplit.class);
         IncrementalSplit split = (IncrementalSplit) sourceSplit.split();
-        assertColumnSequences(split.beforeFiles(), 1L);
-        assertColumnSequences(split.afterFiles(), 2L);
+        assertWriteColsSequences(split.beforeFiles(), 1L);
+        assertWriteColsSequences(split.afterFiles(), 2L);
         assertThat(split.beforeDeletionFiles())
                 .containsExactly(new DeletionFile("before.dv", 0L, 1L, 1L));
         assertThat(split.afterDeletionFiles())
@@ -233,17 +233,17 @@ public class PendingSplitsCheckpointSerializerTest {
         assertThat(sourceSplit.recordsToSkip()).isEqualTo(recordsToSkip);
         assertThat(sourceSplit.split()).isInstanceOf(ChainSplit.class);
         ChainSplit split = (ChainSplit) sourceSplit.split();
-        assertColumnSequences(split.dataFiles(), 1L, 2L);
+        assertWriteColsSequences(split.dataFiles(), 1L, 2L);
         assertThat(split.fileBranchMapping()).isEqualTo(branchMapping);
         assertThat(split.fileBucketPathMapping()).isEqualTo(bucketPathMapping);
         assertThat(split.deletionFiles())
                 .hasValue(Arrays.asList(null, new DeletionFile("chain.dv", 2L, 
1L, 1L)));
     }
 
-    private static void assertColumnSequences(List<DataFileMeta> files, 
long... sequences) {
+    private static void assertWriteColsSequences(List<DataFileMeta> files, 
long... sequences) {
         assertThat(files).hasSize(sequences.length);
         for (int i = 0; i < sequences.length; i++) {
-            
assertThat(files.get(i).columnMaxSequenceNumbers()).containsExactly(sequences[i]);
+            
assertThat(files.get(i).writeColsSequences()).containsExactly(sequences[i]);
         }
     }
 
diff --git a/paimon-python/pypaimon/manifest/manifest_file_manager.py 
b/paimon-python/pypaimon/manifest/manifest_file_manager.py
index 1d833f3d35..c5b9e22c03 100644
--- a/paimon-python/pypaimon/manifest/manifest_file_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_file_manager.py
@@ -292,6 +292,8 @@ class ManifestFileManager:
                 external_path=file_dict.get('_EXTERNAL_PATH'),
                 first_row_id=file_dict['_FIRST_ROW_ID'] if '_FIRST_ROW_ID' in 
file_dict else None,
                 write_cols=file_dict['_WRITE_COLS'] if '_WRITE_COLS' in 
file_dict else None,
+                write_cols_sequences=file_dict.get(
+                    '_WRITE_COLS_SEQUENCES'),
             )
             if partition is None:
                 partition = GenericRowDeserializer.from_bytes(
@@ -432,6 +434,8 @@ class ManifestFileManager:
                 "_EXTERNAL_PATH": entry.file.external_path,
                 "_FIRST_ROW_ID": entry.file.first_row_id,
                 "_WRITE_COLS": entry.file.write_cols,
+                "_WRITE_COLS_SEQUENCES": (
+                    entry.file.write_cols_sequences),
             }
         }
 
diff --git a/paimon-python/pypaimon/manifest/schema/data_file_meta.py 
b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
index 5a5a70b3e7..a315c5157f 100644
--- a/paimon-python/pypaimon/manifest/schema/data_file_meta.py
+++ b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
@@ -54,6 +54,9 @@ class DataFileMeta:
 
     # not a schema field, just for internal usage
     file_path: str = None
+    # Current DataFileMeta v9 field. Kept after the historical constructor
+    # fields so positional callers retain their existing argument mapping.
+    write_cols_sequences: Optional[List[int]] = None
 
     def row_id_range(self) -> Optional[Range]:
         if self.first_row_id is None:
@@ -108,6 +111,7 @@ class DataFileMeta:
         first_row_id: Optional[int] = None,
         write_cols: Optional[List[str]] = None,
         file_path: Optional[str] = None,
+        write_cols_sequences: Optional[List[int]] = None,
     ) -> 'DataFileMeta':
         if creation_time is None:
             creation_time = Timestamp.now()
@@ -133,6 +137,7 @@ class DataFileMeta:
             external_path=external_path,
             first_row_id=first_row_id,
             write_cols=write_cols,
+            write_cols_sequences=write_cols_sequences,
             file_path=file_path,
         )
 
@@ -170,6 +175,7 @@ class DataFileMeta:
             external_path=self.external_path,
             first_row_id=self.first_row_id,
             write_cols=self.write_cols,
+            write_cols_sequences=self.write_cols_sequences,
             file_path=self.file_path
         )
 
@@ -204,6 +210,7 @@ class DataFileMeta:
             external_path=self.external_path,
             first_row_id=first_row_id,
             write_cols=self.write_cols,
+            write_cols_sequences=self.write_cols_sequences,
             file_path=self.file_path
         )
 
@@ -230,6 +237,7 @@ class DataFileMeta:
             external_path=self.external_path,
             first_row_id=self.first_row_id,
             write_cols=self.write_cols,
+            write_cols_sequences=self.write_cols_sequences,
             file_path=self.file_path
         )
 
@@ -266,5 +274,8 @@ DATA_FILE_META_SCHEMA = {
         {"name": "_WRITE_COLS",
          "type": ["null", {"type": "array", "items": "string"}],
          "default": None},
+        {"name": "_WRITE_COLS_SEQUENCES",
+         "type": ["null", {"type": "array", "items": "long"}],
+         "default": None},
     ]
 }
diff --git a/paimon-python/pypaimon/read/native_plan.py 
b/paimon-python/pypaimon/read/native_plan.py
index 89ba3616cc..f1e4d0a870 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -32,7 +32,8 @@ from pypaimon.common.options.options_utils import OptionsUtils
 from pypaimon.common.predicate import Predicate
 from pypaimon.read.plan import Plan
 from pypaimon.read.split import Split
-from pypaimon.read.split_serializer import deserialize_split_v1
+from pypaimon.read.split_serializer import (
+    deserialize_split_v1, serialize_split_v1)
 
 
 def native_runtime_available() -> bool:
@@ -56,6 +57,25 @@ def native_reader_available() -> bool:
             and native_method_available('TableRead', 'read'))
 
 
+def native_split_bridge_available() -> bool:
+    """Whether Rust accepts Java-compatible split bytes from Python plans."""
+    return native_reader_available() and native_method_available(
+        'Split', 'deserialize')
+
+
+def native_split_from_python(split):
+    """Convert a Python-planned DataSplit/IndexedSplit for the Rust reader.
+
+    Vector scores intentionally stay on the Python IndexedSplit. Rust needs
+    only its row ranges to perform the physical read.
+    """
+    if not native_split_bridge_available():
+        return None
+    from pypaimon_rust.datafusion import Split as NativeSplit
+    return NativeSplit.deserialize(
+        serialize_split_v1(split, include_scores=False))
+
+
 def native_family_search_modes_available() -> bool:
     """Whether Rust supports family-specific global-index search modes."""
     return native_version_at_least(0, 4)
diff --git a/paimon-python/pypaimon/read/reader/auth_masking_reader.py 
b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
index aeee5e13e9..bf425581ab 100644
--- a/paimon-python/pypaimon/read/reader/auth_masking_reader.py
+++ b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
@@ -153,6 +153,45 @@ class AuthFilterReader(RecordBatchReader):
         self._inner.close()
 
 
+def compile_auth_masking_rules(masking_rules: Dict[str, str], read_fields: 
List):
+    """Parse and validate masking transforms for batch and native readers."""
+    read_field_names = {f.name for f in read_fields}
+    parsed = {}
+    for col, transform_json in masking_rules.items():
+        if col not in read_field_names or not transform_json:
+            continue
+        transform = json.loads(transform_json)
+        if transform is not None:
+            parsed[col] = transform
+    for col_name, transform in parsed.items():
+        for ref_name in _collect_all_field_refs_from_transform(transform):
+            if ref_name not in read_field_names:
+                raise RuntimeError(
+                    f"Column masking refers to field '{ref_name}' which is not 
present "
+                    f"in output row type. Available fields: {read_field_names}"
+                )
+    return parsed
+
+
+def apply_auth_masking(batch: pa.RecordBatch, parsed_rules: Dict[str, dict]):
+    """Apply compiled masking rules against the same original input batch."""
+    original_batch = batch
+    masked_columns = {}
+    for col_name, transform in parsed_rules.items():
+        if col_name in original_batch.schema.names:
+            col_idx = original_batch.schema.get_field_index(col_name)
+            target_col_type = original_batch.schema.field(col_idx).type
+            masked_columns[col_idx] = _apply_predicate_transform(
+                transform, original_batch, null_type=target_col_type)
+    for col_idx, masked_array in masked_columns.items():
+        original_field = original_batch.schema.field(col_idx)
+        batch = batch.set_column(
+            col_idx,
+            pa.field(original_field.name, masked_array.type, nullable=True),
+            masked_array)
+    return batch
+
+
 class AuthMaskingReader(RecordBatchReader):
 
     def __init__(self, inner_reader: RecordBatchReader, masking_rules: 
Dict[str, str], read_fields: List):
@@ -160,57 +199,18 @@ class AuthMaskingReader(RecordBatchReader):
         self._masking_rules = masking_rules
         self._read_fields = read_fields
         self._adopt_metadata(inner_reader)
-        read_field_names = {f.name for f in read_fields}
-        parsed = {}
-        for col, tj in masking_rules.items():
-            if col not in read_field_names:
-                continue
-            if not tj:
-                continue
-            transform = json.loads(tj)
-            if transform is None:
-                continue
-            parsed[col] = transform
-        self._parsed_rules = parsed
-        for col_name, transform in self._parsed_rules.items():
-            for ref_name in _collect_all_field_refs_from_transform(transform):
-                if ref_name not in read_field_names:
-                    raise RuntimeError(
-                        f"Column masking refers to field '{ref_name}' which is 
not present "
-                        f"in output row type. Available fields: 
{read_field_names}"
-                    )
+        self._parsed_rules = compile_auth_masking_rules(
+            masking_rules, read_fields)
 
     def read_arrow_batch(self) -> Optional[pa.RecordBatch]:
         batch = self._inner.read_arrow_batch()
         if batch is None:
             return None
-        original_batch = batch
-        masked_columns = {}
-        for col_name, transform in self._parsed_rules.items():
-            if col_name in original_batch.schema.names:
-                col_idx = original_batch.schema.get_field_index(col_name)
-                target_col_type = original_batch.schema.field(col_idx).type
-                masked_columns[col_idx] = 
self._apply_masking_transform(transform, original_batch, target_col_type)
-        for col_idx, masked_array in masked_columns.items():
-            original_field = original_batch.schema.field(col_idx)
-            batch = batch.set_column(
-                col_idx,
-                pa.field(original_field.name, masked_array.type, 
nullable=True),
-                masked_array)
-        return batch
+        return apply_auth_masking(batch, self._parsed_rules)
 
     def close(self):
         self._inner.close()
 
-    def _apply_masking_transform(
-            self,
-            transform: dict,
-            original_batch: pa.RecordBatch,
-            target_col_type: pa.DataType,
-    ) -> pa.Array:
-        return _apply_predicate_transform(
-            transform, original_batch, null_type=target_col_type)
-
 
 class ColumnProjectReader(RecordBatchReader):
 
diff --git 
a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py 
b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
index 33881bafcb..11bf86c1f7 100644
--- a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
@@ -198,8 +198,11 @@ class 
ChunkShuffleSplitGeneratorBase(AbstractSplitGenerator):
         deletion_files_map=None,
         seed: int = 0,
         chunk_size: int = 0,
+        snapshot_id: Optional[int] = None,
     ):
-        super().__init__(table, target_split_size, open_file_cost, 
deletion_files_map)
+        super().__init__(
+            table, target_split_size, open_file_cost, deletion_files_map,
+            snapshot_id)
         self.seed = seed
         self.chunk_size = chunk_size
 
@@ -429,6 +432,7 @@ class 
AppendChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
             bucket=chunk.bucket,
             raw_convertible=True,
             data_deletion_files=data_deletion_files,
+            snapshot_id=self.snapshot_id,
         )
 
         exact_merged_row_count = sum(
@@ -589,6 +593,7 @@ class 
DataEvolutionChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
             bucket=chunk.bucket,
             raw_convertible=False,
             data_deletion_files=data_deletion_files,
+            snapshot_id=self.snapshot_id,
         )
         return IndexedSplit(
             data_split,
diff --git 
a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py 
b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
index 9a8015347c..8da8b57a6d 100644
--- a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
@@ -41,8 +41,11 @@ class DataEvolutionSplitGenerator(AbstractSplitGenerator):
         row_ranges: Optional[List] = None,
         score_getter=None,
         group_stats_filter=None,
+        snapshot_id: Optional[int] = None,
     ):
-        super().__init__(table, target_split_size, open_file_cost, 
deletion_files_map)
+        super().__init__(
+            table, target_split_size, open_file_cost, deletion_files_map,
+            snapshot_id)
         self.row_ranges = row_ranges
         self.score_getter = score_getter
         self.group_stats_filter = group_stats_filter
@@ -154,7 +157,8 @@ class DataEvolutionSplitGenerator(AbstractSplitGenerator):
                     partition=file_entries[0].partition,
                     bucket=file_entries[0].bucket,
                     raw_convertible=raw_convertible,
-                    data_deletion_files=data_deletion_files
+                    data_deletion_files=data_deletion_files,
+                    snapshot_id=self.snapshot_id,
                 )
                 splits.append(split)
         return splits
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index bc89cf16d6..17599d970d 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -355,6 +355,7 @@ class FileScanner:
                     self._deletion_files_map(entries),
                     seed=seed,
                     chunk_size=chunk_size,
+                    snapshot_id=self._scanned_snapshot_id,
                 )
             else:
                 split_generator = AppendChunkShuffleSplitGenerator(
@@ -364,6 +365,7 @@ class FileScanner:
                     self._deletion_files_map(entries),
                     seed=seed,
                     chunk_size=chunk_size,
+                    snapshot_id=self._scanned_snapshot_id,
                 )
         elif self.table.is_primary_key_table:
             entries = self.plan_files()
@@ -371,7 +373,8 @@ class FileScanner:
                 self.table,
                 self.target_split_size,
                 self.open_file_cost,
-                self._deletion_files_map(entries)
+                self._deletion_files_map(entries),
+                snapshot_id=self._scanned_snapshot_id,
             )
         elif self.data_evolution:
             entries, split_generator = 
self._create_data_evolution_split_generator()
@@ -381,7 +384,8 @@ class FileScanner:
                 self.table,
                 self.target_split_size,
                 self.open_file_cost,
-                self._deletion_files_map(entries)
+                self._deletion_files_map(entries),
+                snapshot_id=self._scanned_snapshot_id,
             )
 
         if not entries:
@@ -400,7 +404,6 @@ class FileScanner:
                 while callable(getattr(split, 'data_split', None)):
                     split = split.data_split()
                 split.is_streaming = True
-                split.snapshot_id = self._scanned_snapshot_id
 
         if self.data_evolution and self.scan_stats is not None:
             # Data-evolution stats pruning happens on complete row-id groups
@@ -496,6 +499,7 @@ class FileScanner:
                 row_ranges,
                 score_getter,
                 None,
+                snapshot_id=self._scanned_snapshot_id,
             )
 
         # Position selection counts the complete candidate row-id space. Early
@@ -534,6 +538,7 @@ class FileScanner:
             row_ranges,
             score_getter,
             group_stats_filter,
+            snapshot_id=self._scanned_snapshot_id,
         )
 
     def plan_files(self) -> List[ManifestEntry]:
diff --git 
a/paimon-python/pypaimon/read/scanner/primary_key_table_split_generator.py 
b/paimon-python/pypaimon/read/scanner/primary_key_table_split_generator.py
index 5d7d7fc9a2..06b1fc3414 100644
--- a/paimon-python/pypaimon/read/scanner/primary_key_table_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/primary_key_table_split_generator.py
@@ -36,9 +36,12 @@ class PrimaryKeyTableSplitGenerator(AbstractSplitGenerator):
             table,
             target_split_size: int,
             open_file_cost: int,
-            deletion_files_map=None
+            deletion_files_map=None,
+            snapshot_id=None,
     ):
-        super().__init__(table, target_split_size, open_file_cost, 
deletion_files_map)
+        super().__init__(
+            table, target_split_size, open_file_cost, deletion_files_map,
+            snapshot_id)
         self.deletion_vectors_enabled = 
table.options.deletion_vectors_enabled()
         self.merge_engine = table.options.merge_engine()
 
diff --git a/paimon-python/pypaimon/read/scanner/split_generator.py 
b/paimon-python/pypaimon/read/scanner/split_generator.py
index 4c61ebae22..100fbec2e9 100644
--- a/paimon-python/pypaimon/read/scanner/split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/split_generator.py
@@ -41,12 +41,14 @@ class AbstractSplitGenerator(ABC):
         table,
         target_split_size: int,
         open_file_cost: int,
-        deletion_files_map: Optional[Dict] = None
+        deletion_files_map: Optional[Dict] = None,
+        snapshot_id: Optional[int] = None,
     ):
         self.table = table
         self.target_split_size = target_split_size
         self.open_file_cost = open_file_cost
         self.deletion_files_map = deletion_files_map or {}
+        self.snapshot_id = snapshot_id
         self.default_part_value = table.options.options.get(
             CoreOptions.PARTITION_DEFAULT_NAME, "__DEFAULT_PARTITION__")
         
@@ -123,7 +125,8 @@ class AbstractSplitGenerator(ABC):
                     partition=file_entries[0].partition,
                     bucket=file_entries[0].bucket,
                     raw_convertible=raw_convertible,
-                    data_deletion_files=data_deletion_files
+                    data_deletion_files=data_deletion_files,
+                    snapshot_id=self.snapshot_id,
                 )
                 splits.append(split)
         return splits
diff --git a/paimon-python/pypaimon/read/sliced_split.py 
b/paimon-python/pypaimon/read/sliced_split.py
index e166be0451..8d69f644c6 100644
--- a/paimon-python/pypaimon/read/sliced_split.py
+++ b/paimon-python/pypaimon/read/sliced_split.py
@@ -93,6 +93,10 @@ class SlicedSplit(Split):
     def is_streaming(self):
         return getattr(self._data_split, 'is_streaming', False)
 
+    @property
+    def snapshot_id(self):
+        return self._data_split.snapshot_id
+
     @property
     def raw_convertible(self):
         return self._data_split.raw_convertible
diff --git a/paimon-python/pypaimon/read/split.py 
b/paimon-python/pypaimon/read/split.py
index a7800da72e..ccb907beb4 100644
--- a/paimon-python/pypaimon/read/split.py
+++ b/paimon-python/pypaimon/read/split.py
@@ -81,15 +81,23 @@ class DataSplit(Split):
         data_deletion_files: Optional[List[DeletionFile]] = None,
         snapshot_id: Optional[int] = None,
         is_streaming: bool = False,
+        bucket_path: Optional[str] = None,
+        total_buckets: Optional[int] = None,
     ):
         self._files = files
         self._partition = partition
         self._bucket = bucket
         self.raw_convertible = raw_convertible
         self.data_deletion_files = data_deletion_files
-        # Scanned snapshot; None unless populated (e.g. by the native planner).
+        # Scanned snapshot, supplied by planners so this split can be
+        # serialized independently from its enclosing Plan.
         self.snapshot_id = snapshot_id
         self.is_streaming = is_streaming
+        # Retained when decoding SplitSerializer v1 so a cross-language
+        # decode/encode round-trip does not lose wire metadata. Python-planned
+        # splits normally leave these unset and derive paths from DataFileMeta.
+        self.bucket_path = bucket_path
+        self.total_buckets = total_buckets
 
     @property
     def files(self) -> List[DataFileMeta]:
@@ -129,6 +137,8 @@ class DataSplit(Split):
             data_deletion_files=filtered_data_deletion_files,
             snapshot_id=self.snapshot_id,
             is_streaming=self.is_streaming,
+            bucket_path=self.bucket_path,
+            total_buckets=self.total_buckets,
         )
 
     @property
diff --git a/paimon-python/pypaimon/read/split_serializer.py 
b/paimon-python/pypaimon/read/split_serializer.py
index 9a4e3a4270..c329a34e2f 100644
--- a/paimon-python/pypaimon/read/split_serializer.py
+++ b/paimon-python/pypaimon/read/split_serializer.py
@@ -15,13 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Deserialize the cross-language ``SplitSerializer`` v1 binary into a pypaimon
-:class:`DataSplit`.
+"""Read and write the cross-language ``SplitSerializer`` v1 binary format.
 
-Mirror of the Java ``DataSplit#serialize`` (versions 8 and 9) frame wrapped in 
the
-``SplitSerializer`` v1 header, as produced by ``pypaimon_rust``'s
-``Split.serialize()``. Extracts the fields the reader needs, plus per-file
-min/max keys for PK merge-on-read; key/value stats (planning-only) stay empty.
+This is the stable bridge between Java, PyPaimon and ``pypaimon_rust``. It
+supports DataSplit and IndexedSplit (including scores on the Python side) and
+the current DataSplit v9/DataFileMeta v21 layout. The reader remains compatible
+with DataSplit v8.
 """
 
 import struct
@@ -34,7 +33,8 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.read.split import DataSplit, Split
 from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.table.row.binary_row import BinaryRow
-from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer
+from pypaimon.table.row.generic_row import (
+    GenericRow, GenericRowDeserializer, GenericRowSerializer)
 from pypaimon.table.source.deletion_file import DeletionFile
 from pypaimon.utils.range import Range
 
@@ -47,6 +47,7 @@ _INDEXED_SPLIT_MAGIC = -938472394838495695
 _INDEXED_SPLIT_VERSION = 1
 _SPLIT_MAGIC = -2394839472490812314
 _DFM_ARITY_BY_VERSION = {8: 20, 9: 21}
+_SPLIT_VERSION = 9
 
 
 def _f(idx, name, dtype):
@@ -54,7 +55,6 @@ def _f(idx, name, dtype):
 
 
 # DataFileMeta layout (order/types mirror DataFileMetaSerializer#toRow).
-# Fields 3/4 (min/max key) are decoded for PK tables; 5/6 (stats) stay unread.
 _DFM_FIELDS: List[DataField] = [
     _f(0, '_FILE_NAME', AtomicType('STRING')),
     _f(1, '_FILE_SIZE', AtomicType('BIGINT')),
@@ -82,23 +82,109 @@ _DFM_FIELDS: List[DataField] = [
 
 assert len(_DFM_FIELDS) == max(_DFM_ARITY_BY_VERSION.values())
 
+_SIMPLE_STATS_FIELDS: List[DataField] = [
+    _f(0, '_MIN_VALUES', AtomicType('BYTES')),
+    _f(1, '_MAX_VALUES', AtomicType('BYTES')),
+    _f(2, '_NULL_COUNTS', AtomicType('BYTES')),
+]
+
+
+def _round_to_word(size: int) -> int:
+    return (size + 7) & ~7
+
+
+def _binary_array_header(size: int) -> int:
+    return 4 + ((size + 31) // 32) * 4
+
+
+def _encode_str_array(values: List[str]) -> bytes:
+    """Encode non-null strings using Paimon's BinaryArray layout."""
+    header = _binary_array_header(len(values))
+    fixed_size = _round_to_word(header + len(values) * 8)
+    data = bytearray(fixed_size)
+    struct.pack_into('<i', data, 0, len(values))
+    for index, value in enumerate(values):
+        if not isinstance(value, str):
+            raise TypeError("BinaryArray string values must be strings")
+        encoded = value.encode('utf-8')
+        element_offset = header + index * 8
+        if len(encoded) <= 7:
+            data[element_offset:element_offset + len(encoded)] = encoded
+            data[element_offset + 7] = 0x80 | len(encoded)
+        else:
+            variable_offset = len(data)
+            data.extend(encoded)
+            data.extend(b'\x00' * (_round_to_word(len(encoded)) - 
len(encoded)))
+            struct.pack_into(
+                '<Q', data, element_offset,
+                (variable_offset << 32) | len(encoded))
+    return bytes(data)
+
+
+def _encode_long_array(values: List[Optional[int]]) -> bytes:
+    """Encode nullable BIGINTs using Paimon's BinaryArray layout."""
+    header = _binary_array_header(len(values))
+    data = bytearray(_round_to_word(header + len(values) * 8))
+    struct.pack_into('<i', data, 0, len(values))
+    for index, value in enumerate(values):
+        if value is None:
+            data[4 + index // 8] |= 1 << (index % 8)
+        else:
+            struct.pack_into('<q', data, header + index * 8, value)
+    return bytes(data)
+
+
+def _decode_long_array(data: Optional[bytes]) -> Optional[List[Optional[int]]]:
+    if data is None:
+        return None
+    if len(data) < 4:
+        raise ValueError("BinaryArray<long> is shorter than its header")
+    size = struct.unpack_from('<i', data, 0)[0]
+    if size < 0:
+        raise ValueError("negative BinaryArray<long> size")
+    header = _binary_array_header(size)
+    if header + size * 8 > len(data):
+        raise ValueError("BinaryArray<long> elements exceed its buffer")
+    result = []
+    for index in range(size):
+        is_null = data[4 + index // 8] & (1 << (index % 8))
+        result.append(
+            None if is_null else struct.unpack_from(
+                '<q', data, header + index * 8)[0])
+    return result
+
 
 def _decode_str_array(b: Optional[bytes]) -> Optional[List[str]]:
     """Decode a Paimon ``BinaryArray<string>`` (non-null elements)."""
-    if not b:
-        return [] if b == b'' else None
+    if b is None:
+        return None
+    if len(b) < 4:
+        raise ValueError("BinaryArray<string> is shorter than its header")
     n = struct.unpack_from('<i', b, 0)[0]
-    header = 4 + ((n + 31) // 32) * 4  # count + null bitset
+    if n < 0:
+        raise ValueError("negative BinaryArray<string> size")
+    if n == 0:
+        return []
+    header = _binary_array_header(n)
+    fixed_size = _round_to_word(header + n * 8)
+    if fixed_size > len(b):
+        raise ValueError("BinaryArray<string> elements exceed its buffer")
     out = []
     for k in range(n):
+        if b[4 + k // 8] & (1 << (k % 8)):
+            raise ValueError("array<string not null> contains a null element")
         eo = header + k * 8
         if b[eo + 7] & 0x80:  # inline: value in first 7 bytes, len in low 7 
bits of byte 7
             length = b[eo + 7] & 0x7F
+            if length > 7:
+                raise ValueError("invalid inline BinaryArray<string> length")
             out.append(b[eo:eo + length].decode('utf-8'))
         else:  # pointer: (var_off << 32) | len
-            slot = struct.unpack_from('<q', b, eo)[0]
+            slot = struct.unpack_from('<Q', b, eo)[0]
             var_off = (slot >> 32) & 0xFFFFFFFF
             length = slot & 0xFFFFFFFF
+            if var_off < fixed_size or var_off + length > len(b):
+                raise ValueError("BinaryArray<string> value exceeds its 
buffer")
             out.append(b[var_off:var_off + length].decode('utf-8'))
     return out
 
@@ -114,11 +200,18 @@ def _decode_modified_utf8(raw: bytes) -> str:
             units.append(b)
             i += 1
         elif b & 0xE0 == 0xC0:
+            if i + 1 >= n or raw[i + 1] & 0xC0 != 0x80:
+                raise ValueError("invalid modified UTF-8 two-byte sequence")
             units.append(((b & 0x1F) << 6) | (raw[i + 1] & 0x3F))
             i += 2
-        else:  # 0xE0: 3-byte
+        elif b & 0xF0 == 0xE0:
+            if (i + 2 >= n or raw[i + 1] & 0xC0 != 0x80
+                    or raw[i + 2] & 0xC0 != 0x80):
+                raise ValueError("invalid modified UTF-8 three-byte sequence")
             units.append(((b & 0x0F) << 12) | ((raw[i + 1] & 0x3F) << 6) | 
(raw[i + 2] & 0x3F))
             i += 3
+        else:
+            raise ValueError("invalid modified UTF-8 leading byte")
     out = []
     j, m = 0, len(units)
     while j < m:
@@ -132,41 +225,298 @@ def _decode_modified_utf8(raw: bytes) -> str:
     return ''.join(chr(c) for c in out)
 
 
+def _encode_modified_utf8(value: str) -> bytes:
+    """Encode Java DataOutput.writeUTF's modified UTF-8 payload."""
+    utf16 = value.encode('utf-16-be', errors='surrogatepass')
+    result = bytearray()
+    for offset in range(0, len(utf16), 2):
+        unit = struct.unpack_from('>H', utf16, offset)[0]
+        if 0x0001 <= unit <= 0x007F:
+            result.append(unit)
+        elif unit > 0x07FF:
+            result.extend((
+                0xE0 | (unit >> 12),
+                0x80 | ((unit >> 6) & 0x3F),
+                0x80 | (unit & 0x3F),
+            ))
+        else:
+            result.extend((
+                0xC0 | (unit >> 6),
+                0x80 | (unit & 0x3F),
+            ))
+    if len(result) > 0xFFFF:
+        raise ValueError(
+            "string too long for Java writeUTF: %d bytes (max 65535)"
+            % len(result))
+    return bytes(result)
+
+
+class _Writer:
+    def __init__(self):
+        self.parts = []
+
+    def i32(self, value: int):
+        self.parts.append(struct.pack('>i', value))
+
+    def i64(self, value: int):
+        self.parts.append(struct.pack('>q', value))
+
+    def f32(self, value: float):
+        self.parts.append(struct.pack('>f', value))
+
+    def u8(self, value: int):
+        self.parts.append(struct.pack('B', value))
+
+    def take(self, value: bytes):
+        self.parts.append(value)
+
+    def java_utf(self, value: str):
+        encoded = _encode_modified_utf8(value)
+        self.parts.append(struct.pack('>H', len(encoded)))
+        self.parts.append(encoded)
+
+    def finish(self) -> bytes:
+        return b''.join(self.parts)
+
+
+def serialize_split_v1(split: Split, include_scores: bool = True) -> bytes:
+    """Serialize a Python DataSplit/IndexedSplit as SplitSerializer v1.
+
+    ``include_scores=False`` is used by the Rust read bridge because native
+    DataSplit models row ranges but not vector scores. Scores stay attached to
+    the original Python IndexedSplit and are never needed to select rows.
+    """
+    writer = _Writer()
+    writer.i64(_SPLIT_SER_MAGIC)
+    writer.i32(_SPLIT_SER_VERSION)
+    if isinstance(split, IndexedSplit):
+        data_split = split.data_split()
+        if not isinstance(data_split, DataSplit):
+            raise TypeError(
+                "IndexedSplit must directly wrap a DataSplit for native 
serialization")
+        writer.i32(_TYPE_INDEXED_SPLIT)
+        writer.i64(_INDEXED_SPLIT_MAGIC)
+        writer.i32(_INDEXED_SPLIT_VERSION)
+        _write_datasplit_body(writer, data_split)
+        ranges = split.row_ranges()
+        writer.i32(len(ranges))
+        for row_range in ranges:
+            if row_range.from_ > row_range.to:
+                raise ValueError(
+                    "row range from %d exceeds to %d"
+                    % (row_range.from_, row_range.to))
+            writer.i64(row_range.from_)
+            writer.i64(row_range.to)
+        scores = split.scores()
+        if include_scores and scores is not None:
+            writer.u8(1)
+            writer.i32(len(scores))
+            for score in scores:
+                writer.f32(score)
+        else:
+            writer.u8(0)
+    elif isinstance(split, DataSplit):
+        writer.i32(_TYPE_DATA_SPLIT)
+        _write_datasplit_body(writer, split)
+    else:
+        raise TypeError(
+            "SplitSerializer v1 supports DataSplit and IndexedSplit, got %s"
+            % type(split).__name__)
+    return writer.finish()
+
+
+def _serialized_row(row) -> bytes:
+    if row is None:
+        row = GenericRow([], [])
+    return GenericRowSerializer.to_bytes(row)
+
+
+def _write_datasplit_body(writer: _Writer, split: DataSplit):
+    files = list(split.files)
+    bucket_path = (
+        split.bucket_path
+        if split.bucket_path is not None else _common_bucket_path(files))
+    writer.i64(_SPLIT_MAGIC)
+    writer.i32(_SPLIT_VERSION)
+    writer.i64(-1 if split.snapshot_id is None else split.snapshot_id)
+    partition = _serialized_row(split.partition)
+    writer.i32(len(partition))
+    writer.take(partition)
+    writer.i32(split.bucket)
+    writer.java_utf(bucket_path)
+    if split.total_buckets is None:
+        writer.u8(0)
+    else:
+        writer.u8(1)
+        writer.i32(split.total_buckets)
+    writer.i32(0)  # deprecated beforeFiles
+    writer.u8(0)  # beforeDeletionFiles = null
+    writer.i32(len(files))
+    for data_file in files:
+        row = _serialize_data_file_meta(data_file, bucket_path)
+        writer.i32(len(row))
+        writer.take(row)
+    _write_deletion_list(writer, split.data_deletion_files, len(files))
+    writer.u8(1 if split.is_streaming else 0)
+    writer.u8(1 if split.raw_convertible else 0)
+
+
+def _common_bucket_path(files: List[DataFileMeta]) -> str:
+    """Find a shared path only when every file path is unambiguous.
+
+    When Python planning has produced files from different directories, each
+    exact path is written as DataFileMeta.external_path instead.
+    """
+    if not files:
+        return ''
+    parents = []
+    for data_file in files:
+        if data_file.external_path:
+            return ''
+        file_path = data_file.file_path
+        suffix = '/' + data_file.file_name
+        if not file_path or not file_path.endswith(suffix):
+            return ''
+        parents.append(file_path[:-len(suffix)])
+    first = parents[0]
+    return first if all(path == first for path in parents) else ''
+
+
+def _effective_external_path(data_file: DataFileMeta, bucket_path: str):
+    if data_file.external_path is not None:
+        return data_file.external_path
+    file_path = data_file.file_path
+    if file_path is None:
+        return None
+    expected = '%s/%s' % (bucket_path.rstrip('/'), data_file.file_name)
+    if bucket_path and file_path == expected:
+        return None
+    return file_path
+
+
+def _serialize_data_file_meta(data_file: DataFileMeta, bucket_path: str) -> 
bytes:
+    values = [
+        data_file.file_name,
+        data_file.file_size,
+        data_file.row_count,
+        _serialized_row(data_file.min_key),
+        _serialized_row(data_file.max_key),
+        _serialize_simple_stats(data_file.key_stats),
+        _serialize_simple_stats(data_file.value_stats),
+        data_file.min_sequence_number,
+        data_file.max_sequence_number,
+        data_file.schema_id,
+        data_file.level,
+        _encode_str_array(data_file.extra_files),
+        (data_file.creation_time.get_millisecond()
+         if data_file.creation_time is not None else None),
+        data_file.delete_row_count,
+        data_file.embedded_index,
+        data_file.file_source,
+        (_encode_str_array(data_file.value_stats_cols)
+         if data_file.value_stats_cols is not None else None),
+        _effective_external_path(data_file, bucket_path),
+        data_file.first_row_id,
+        (_encode_str_array(data_file.write_cols)
+         if data_file.write_cols is not None else None),
+        (_encode_long_array(data_file.write_cols_sequences)
+         if data_file.write_cols_sequences is not None else None),
+    ]
+    serialized = GenericRowSerializer.to_bytes(GenericRow(values, _DFM_FIELDS))
+    return serialized[4:]  # DataSplit carries a raw BinaryRow without arity.
+
+
+def _serialize_simple_stats(stats: Optional[SimpleStats]) -> bytes:
+    if stats is None:
+        stats = SimpleStats.empty_stats()
+    values = [
+        _serialized_row(stats.min_values),
+        _serialized_row(stats.max_values),
+        _encode_long_array(stats.null_counts or []),
+    ]
+    return GenericRowSerializer.to_bytes(
+        GenericRow(values, _SIMPLE_STATS_FIELDS))[4:]
+
+
+def _write_deletion_list(writer: _Writer, deletion_files, file_count: int):
+    if deletion_files is None:
+        writer.u8(0)
+        return
+    if len(deletion_files) != file_count:
+        raise ValueError(
+            "deletion-file count %d does not match data-file count %d"
+            % (len(deletion_files), file_count))
+    writer.u8(1)
+    writer.i32(len(deletion_files))
+    for deletion_file in deletion_files:
+        if deletion_file is None:
+            writer.u8(0)
+            continue
+        writer.u8(1)
+        writer.java_utf(deletion_file.dv_index_path)
+        writer.i64(deletion_file.offset)
+        writer.i64(deletion_file.length)
+        writer.i64(
+            -1 if deletion_file.cardinality is None
+            else deletion_file.cardinality)
+
+
 class _Reader:
     def __init__(self, data: bytes):
         self.d = data
         self.p = 0
 
     def i32(self) -> int:
+        self._require(4)
         v = struct.unpack_from('>i', self.d, self.p)[0]
         self.p += 4
         return v
 
     def i64(self) -> int:
+        self._require(8)
         v = struct.unpack_from('>q', self.d, self.p)[0]
         self.p += 8
         return v
 
     def f32(self) -> float:
+        self._require(4)
         v = struct.unpack_from('>f', self.d, self.p)[0]
         self.p += 4
         return v
 
     def u8(self) -> int:
+        self._require(1)
         v = self.d[self.p]
         self.p += 1
         return v
 
     def take(self, n: int) -> bytes:
+        if n < 0:
+            raise ValueError("negative byte length %d" % n)
+        self._require(n)
         v = self.d[self.p:self.p + n]
         self.p += n
         return v
 
     def java_utf(self) -> str:
+        self._require(2)
         n = struct.unpack_from('>H', self.d, self.p)[0]
         self.p += 2
         return _decode_modified_utf8(self.take(n))
 
+    def _require(self, size: int):
+        available = len(self.d) - self.p
+        if available < size:
+            raise ValueError(
+                "split buffer underrun: need %d bytes, have %d"
+                % (size, available))
+
+    def finish(self):
+        remaining = len(self.d) - self.p
+        if remaining:
+            raise ValueError(
+                "%d trailing bytes after SplitSerializer v1 frame" % remaining)
+
 
 def deserialize_split_v1(data: bytes, partition_fields: List[DataField],
                          key_fields: Optional[List[DataField]] = None) -> 
Split:
@@ -185,8 +535,8 @@ def deserialize_split_v1(data: bytes, partition_fields: 
List[DataField],
             "unsupported SplitSerializer version %d (expected %d)" % (version, 
_SPLIT_SER_VERSION))
     type_id = r.i32()
     if type_id == _TYPE_DATA_SPLIT:
-        return _read_datasplit_body(r, partition_fields, key_fields)
-    if type_id == _TYPE_INDEXED_SPLIT:
+        split = _read_datasplit_body(r, partition_fields, key_fields)
+    elif type_id == _TYPE_INDEXED_SPLIT:
         imagic = r.i64()
         if imagic != _INDEXED_SPLIT_MAGIC:
             raise ValueError("bad IndexedSplit magic %d" % imagic)
@@ -197,15 +547,31 @@ def deserialize_split_v1(data: bytes, partition_fields: 
List[DataField],
         data_split = _read_datasplit_body(r, partition_fields, key_fields)
         # row_ranges select which global row ids to read -- must be preserved,
         # else the reader scans the whole file instead of the ANN/row-id 
result.
-        row_ranges = [Range(r.i64(), r.i64()) for _ in range(r.i32())]
-        return IndexedSplit(data_split, row_ranges, _read_scores(r))
-    raise ValueError("unsupported split type id %d" % type_id)
+        range_count = r.i32()
+        if range_count < 0:
+            raise ValueError("negative IndexedSplit row-range count")
+        row_ranges = []
+        for _ in range(range_count):
+            from_, to = r.i64(), r.i64()
+            if from_ > to:
+                raise ValueError(
+                    "row range from %d exceeds to %d" % (from_, to))
+            row_ranges.append(Range(from_, to))
+        scores = _read_scores(r)
+        split = IndexedSplit(data_split, row_ranges, scores)
+    else:
+        raise ValueError("unsupported split type id %d" % type_id)
+    r.finish()
+    return split
 
 
 def _read_scores(r: '_Reader') -> Optional[List[float]]:
     if r.u8() == 0:
         return None
-    return [r.f32() for _ in range(r.i32())]
+    count = r.i32()
+    if count < 0:
+        raise ValueError("negative IndexedSplit score count")
+    return [r.f32() for _ in range(count)]
 
 
 def _read_datasplit_body(r: _Reader, partition_fields: List[DataField],
@@ -220,13 +586,14 @@ def _read_datasplit_body(r: _Reader, partition_fields: 
List[DataField],
     partition = GenericRowDeserializer.from_bytes(r.take(r.i32()), 
partition_fields)
     bucket = r.i32()
     bucket_path = r.java_utf()
-    if r.u8() == 1:
-        r.i32()  # total_buckets
+    total_buckets = r.i32() if r.u8() != 0 else None
     if r.i32() != 0:                      # deprecated beforeFiles; Java 
rejects non-empty
         raise ValueError("cannot deserialize a split with before files")
     if r.u8() != 0:                       # beforeDeletionFiles must be null
         raise ValueError("cannot deserialize a split with before deletion 
files")
     file_count = r.i32()
+    if file_count < 0:
+        raise ValueError("negative DataSplit file count")
     arity = _DFM_ARITY_BY_VERSION[version]
     files = [_datafilemeta_from_row(r.take(r.i32()), bucket_path, arity, 
key_fields)
              for _ in range(file_count)]
@@ -241,6 +608,8 @@ def _read_datasplit_body(r: _Reader, partition_fields: 
List[DataField],
         is_streaming=is_streaming,
         data_deletion_files=data_deletion_files,
         snapshot_id=snapshot_id,
+        bucket_path=bucket_path,
+        total_buckets=total_buckets,
     )
 
 
@@ -259,15 +628,16 @@ def _datafilemeta_from_row(row_bytes: bytes, bucket_path: 
str, arity: int,
     external_path = g(17)
     ct = g(12)
     ct = Timestamp(int(ct)) if ct is not None else None
-    # min/max keys drive PK merge-on-read; stats unused, left empty.
+    key_stats = _decode_simple_stats(g(5))
+    value_stats = _decode_simple_stats(g(6))
     meta = DataFileMeta(
         file_name=file_name,
         file_size=g(1),
         row_count=g(2),
         min_key=_decode_key(g(3), key_fields),
         max_key=_decode_key(g(4), key_fields),
-        key_stats=SimpleStats.empty_stats(),
-        value_stats=SimpleStats.empty_stats(),
+        key_stats=key_stats,
+        value_stats=value_stats,
         min_sequence_number=g(7),
         max_sequence_number=g(8),
         schema_id=g(9),
@@ -281,16 +651,40 @@ def _datafilemeta_from_row(row_bytes: bytes, bucket_path: 
str, arity: int,
         external_path=external_path,
         first_row_id=g(18),
         write_cols=_decode_str_array(g(19)),
+        write_cols_sequences=(
+            _decode_non_null_long_array(g(20)) if arity >= 21 else None),
     )
     meta.file_path = external_path if external_path else "%s/%s" % 
(bucket_path.rstrip('/'), file_name)
     return meta
 
 
+def _decode_simple_stats(data: bytes) -> SimpleStats:
+    row = BinaryRow(struct.pack('>i', 3) + data, _SIMPLE_STATS_FIELDS)
+    null_counts = _decode_long_array(row.get_field(2))
+    return SimpleStats(
+        BinaryRow(row.get_field(0), []),
+        BinaryRow(row.get_field(1), []),
+        null_counts,
+    )
+
+
+def _decode_non_null_long_array(data: Optional[bytes]) -> Optional[List[int]]:
+    values = _decode_long_array(data)
+    if values is None:
+        return None
+    if any(value is None for value in values):
+        raise ValueError("array<bigint not null> contains a null element")
+    return values
+
+
 def _read_deletion_list(r: _Reader) -> Optional[List[Optional[DeletionFile]]]:
     if r.u8() == 0:
         return None
     result: List[Optional[DeletionFile]] = []
-    for _ in range(r.i32()):
+    count = r.i32()
+    if count < 0:
+        raise ValueError("negative deletion-file count")
+    for _ in range(count):
         if r.u8() == 0:
             result.append(None)
             continue
diff --git a/paimon-python/pypaimon/read/streaming_table_scan.py 
b/paimon-python/pypaimon/read/streaming_table_scan.py
index 7cdc137e01..f34eb48ada 100644
--- a/paimon-python/pypaimon/read/streaming_table_scan.py
+++ b/paimon-python/pypaimon/read/streaming_table_scan.py
@@ -376,6 +376,14 @@ class AsyncStreamingTableScan:
         if (not self.table.options.native_plan_enabled()
                 or self._bucket_filter is not None):
             return None
+        # Rust's full-snapshot scan currently has batch visibility semantics.
+        # For first-row and non-MOR deletion-vector tables it hides level-0
+        # files, while an initial streaming scan must include those files.
+        # Incremental scans already use Rust's streaming split mode and do not
+        # need this fallback.
+        if (incremental_range is None
+                and self.table.options.batch_scan_skip_level0()):
+            return None
         try:
             from pypaimon.read.native_plan import native_plan
             plan = native_plan(
@@ -440,20 +448,21 @@ class AsyncStreamingTableScan:
                 self.table,
                 target_split_size,
                 open_file_cost,
-                deletion_files_map={}
+                deletion_files_map={},
+                snapshot_id=snapshot_id,
             )
         else:
             split_generator = AppendTableSplitGenerator(
                 self.table,
                 target_split_size,
                 open_file_cost,
-                deletion_files_map={}
+                deletion_files_map={},
+                snapshot_id=snapshot_id,
             )
 
         splits = split_generator.create_splits(entries)
         for split in splits:
             split.is_streaming = True
-            split.snapshot_id = snapshot_id
         return Plan(splits, snapshot_id=snapshot_id)
 
     def _should_use_diff_catch_up(self) -> bool:
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 5595298422..61ce079032 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -421,8 +421,13 @@ class TableRead:
             return []
         if self._deferred_blob_limit_may_prune(splits):
             return None
+        # Query authorization has additional filtering, masking and projection
+        # semantics which are already implemented by the Python reader.
+        if any(isinstance(split, QueryAuthSplit) for split in splits):
+            return None
         try:
-            from pypaimon.read.native_plan import native_read
+            from pypaimon.read.native_plan import (
+                native_read, native_split_from_python)
         except Exception as e:
             logger.warning(
                 "Native read failed, falling back to the Python reader: %s", e)
@@ -430,13 +435,19 @@ class TableRead:
         rust_splits = []
         split_weights = []
         for split in splits:
-            if isinstance(split, QueryAuthSplit):
-                return None
             if not self._native_split_files_supported(split):
                 return None
             rust_split = getattr(split, '_native_split', None)
             if rust_split is None:
-                return None
+                try:
+                    rust_split = native_split_from_python(split)
+                except Exception as e:
+                    logger.warning(
+                        "Native split conversion failed, falling back to the "
+                        "Python reader: %s", e)
+                    return None
+                if rust_split is None:
+                    return None
             rust_splits.append(rust_split)
             split_weights.append(self._native_split_weight(split))
         if (parallelism is not None
@@ -713,15 +724,14 @@ class TableRead:
         return True
 
     def _convert_native_batches(self, batches, schema):
-        """Apply PyPaimon's exact output limit lazily to native batches."""
         remaining = self.limit
         try:
             for batch in batches:
                 if batch.num_rows == 0:
                     continue
+                batch = self._prepare_native_batch(batch, schema)
                 if remaining is not None and batch.num_rows > remaining:
                     batch = batch.slice(0, remaining)
-                batch = self._prepare_native_batch(batch, schema)
                 batch = self._project_batch_to_output(batch)
                 yield self._try_to_pad_batch_by_schema(batch, schema)
                 if remaining is not None:
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 6d269957e6..8a6536bc0a 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -369,14 +369,14 @@ class TableScan:
                           else AppendChunkShuffleSplitGenerator)
         seed, chunk_size = fs.chunk_shuffle
         generator = generator_type(self.table, fs.target_split_size, 
fs.open_file_cost,
-                                   deletions, seed=seed, chunk_size=chunk_size)
+                                   deletions, seed=seed, chunk_size=chunk_size,
+                                   snapshot_id=snapshot_id)
         if fs.idx_of_this_subtask is not None:
             generator.with_shard(fs.idx_of_this_subtask, 
fs.number_of_para_subtasks)
         chunks = generator.create_splits(entries)
         for split in chunks:
             while callable(getattr(split, 'data_split', None)):
                 split = split.data_split()
-            split.snapshot_id = snapshot_id
             split.is_streaming = fs.is_streaming
         return chunks
 
diff --git a/paimon-python/pypaimon/table/source/primary_key_scored_result.py 
b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
index a8334d2437..c0e1c2fe83 100644
--- a/paimon-python/pypaimon/table/source/primary_key_scored_result.py
+++ b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
@@ -54,7 +54,8 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
         self._positions = tuple(positions)
         if len(set(_position_key(position) for position in positions)) != 
len(positions):
             raise ValueError("Primary-key search result contains duplicate 
physical positions.")
-        self._splits = tuple(_materialize(source_splits, positions))
+        self._splits = tuple(_materialize(
+            source_splits, positions, snapshot_id))
 
     @property
     def snapshot_id(self):
@@ -69,7 +70,7 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
         return self._splits
 
 
-def _materialize(source_splits, positions):
+def _materialize(source_splits, positions, snapshot_id):
     sources = {}
     for split in source_splits:
         partition_bytes = _partition_bytes(split.partition)
@@ -96,7 +97,9 @@ def _materialize(source_splits, positions):
         deletion_files = None
         if split.data_deletion_files is not None:
             deletion_files = [split.data_deletion_files[file_index]]
-        single = DataSplit([data_file], split.partition, split.bucket, False, 
deletion_files)
+        single = DataSplit(
+            [data_file], split.partition, split.bucket, False, deletion_files,
+            snapshot_id=snapshot_id)
         ordered = sorted(scores_by_position)
         yield IndexedSplit(single, _ranges(ordered),
                            [scores_by_position[position] for position in 
ordered])
diff --git 
a/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py 
b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
index 4d2c3fb118..88fc69fb31 100644
--- a/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
@@ -161,9 +161,11 @@ class SplitOrderTest(unittest.TestCase):
             self._entry('c.parquet', 2),
         ]
         splits = DataEvolutionSplitGenerator(
-            self._Table(), target_split_size=1024, open_file_cost=0
+            self._Table(), target_split_size=1024, open_file_cost=0,
+            snapshot_id=7,
         ).create_splits(entries)
 
+        self.assertEqual(7, splits[0].snapshot_id)
         self.assertEqual(
             ['a.parquet', 'b.parquet', 'c.parquet'],
             [file.file_name for file in splits[0].files],
diff --git a/paimon-python/pypaimon/tests/interval_partition_test.py 
b/paimon-python/pypaimon/tests/interval_partition_test.py
index 6fbae3624d..173ed6c9ee 100644
--- a/paimon-python/pypaimon/tests/interval_partition_test.py
+++ b/paimon-python/pypaimon/tests/interval_partition_test.py
@@ -62,8 +62,10 @@ def 
test_signed_zero_key_ranges_keep_versions_in_one_split(type_name):
 
     table = SimpleNamespace(table_path='/tmp/interval-test', 
options=CoreOptions(Options({})))
     entries = [ManifestEntry(0, GenericRow([], []), 0, 1, file) for file in 
files]
-    splits = PrimaryKeyTableSplitGenerator(table, 1, 1).create_splits(entries)
+    splits = PrimaryKeyTableSplitGenerator(
+        table, 1, 1, snapshot_id=7).create_splits(entries)
     assert len(splits) == 1
+    assert splits[0].snapshot_id == 7
     assert sorted(file.file_name for file in splits[0].files) == ['broad', 
'point']
     assert not splits[0].raw_convertible
 
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py 
b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
index bb0c03420f..de585bccb9 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
@@ -478,6 +478,7 @@ class ManifestFileManagerTest(_ManifestManagerSetup):
                 value_stats_cols=None, external_path=None,
                 first_row_id=0,
                 write_cols=["id", "_ROW_ID", "_SEQUENCE_NUMBER"],
+                write_cols_sequences=[12, 50, 50],
             ),
         )
         manager.write("dirty-manifest.avro", [entry])
@@ -486,6 +487,8 @@ class ManifestFileManagerTest(_ManifestManagerSetup):
         self.assertEqual(len(entries), 1)
         self.assertEqual(
             entries[0].file.write_cols, ["id", "_ROW_ID", "_SEQUENCE_NUMBER"])
+        self.assertEqual(
+            entries[0].file.write_cols_sequences, [12, 50, 50])
 
         read_stats = entries[0].file.value_stats
         stats_field_names = [f.name for f in read_stats.min_values.fields]
diff --git a/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py 
b/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py
index c2755aca17..ea156d8dcc 100644
--- a/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py
@@ -119,6 +119,8 @@ def _chunks(table, seed, chunk_size=3, shard=None, 
predicate=None, projection=No
                 plan = scan.plan()
         else:
             plan = scan.plan()
+        assert all(
+            split.snapshot_id == plan.snapshot_id for split in plan.splits())
         chunks = []
         for split in plan.splits():
             if 
table.options.options.contains_key('incremental-between-timestamp'):
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py 
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index afc46d5608..d633338f9e 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -140,14 +140,14 @@ def 
test_timestamp_windows_preserve_append_events_and_end_snapshot(
     plan, rows = _read(history, native, window)
     assert plan.snapshot_id == snapshot_id
     assert sorted((row['k'], row['v']) for row in rows) == expected
-    if native:
-        assert all(split.snapshot_id == snapshot_id for split in plan.splits())
+    assert all(split.snapshot_id == snapshot_id for split in plan.splits())
 
 
 def test_predicate_can_select_an_earlier_event(native, history):
     predicate = history.new_read_builder().new_predicate_builder().equal('v', 
'intermediate')
     plan, rows = _read(history, native, (100, 200), predicate=predicate, 
with_stats=True)
     assert plan.snapshot_id == 3
+    assert all(split.snapshot_id == plan.snapshot_id for split in 
plan.splits())
     assert rows == [{'k': 1, 'v': 'intermediate'}]
 
 
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py 
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
index 35eeb9edbc..9581cead18 100644
--- a/paimon-python/pypaimon/tests/native_plan_integration_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -18,6 +18,7 @@
 import datetime
 import importlib.util
 import io
+import json
 import os
 import pickle
 import tempfile
@@ -28,10 +29,12 @@ import pyarrow as pa
 import pytest
 
 from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
 from pypaimon.read.native_plan import (
     native_family_search_modes_available, native_method_available, native_read,
-    native_reader_available,
+    native_reader_available, native_split_bridge_available,
+    native_split_from_python,
 )
 from pypaimon.schema.schema_change import SchemaChange
 from pypaimon.table.row.blob import BlobDescriptor, BlobRef
@@ -203,6 +206,112 @@ class NativePlanIntegrationTest(unittest.TestCase):
         self.assertEqual(streamed, rows)
         self.assertTrue(builder.explain().native_planned)
 
+    @unittest.skipUnless(native_split_bridge_available(),
+                         "pypaimon-rust split bridge API not installed")
+    def test_native_read_bridges_python_planned_splits(self):
+        self.cat.create_table(
+            'default.python_plan_native_read',
+            Schema.from_pyarrow_schema(self.schema), False)
+        self._write('python_plan_native_read', [
+            {'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}, {'k': 3, 'v': 'c'}])
+        table = self.cat.get_table('default.python_plan_native_read').copy({
+            'read.native.enabled': 'true',
+        })
+        builder = table.new_read_builder().with_projection(['k'])
+        scan = builder.new_scan()
+        # Force the capability branch that motivates this bridge: Python owns
+        # planning, while Rust still performs the physical read.
+        with patch.object(scan, '_native_plan_supported', return_value=False):
+            plan = scan.plan()
+        self.assertTrue(plan.splits())
+        self.assertTrue(all(
+            getattr(split, '_native_split', None) is None
+            for split in plan.splits()))
+
+        with patch(
+                'pypaimon.read.native_plan.native_split_from_python',
+                wraps=native_split_from_python) as bridge, patch(
+                    'pypaimon.read.native_plan.native_read',
+                    wraps=native_read) as rust_read, patch(
+                        
'pypaimon.read.table_read.TableRead._create_split_read',
+                        side_effect=AssertionError('Python reader was used')):
+            rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+
+        self.assertEqual(rows, [{'k': 1}, {'k': 2}, {'k': 3}])
+        self.assertEqual(bridge.call_count, len(plan.splits()))
+        self.assertGreaterEqual(rust_read.call_count, 1)
+
+    @unittest.skipUnless(native_split_bridge_available(),
+                         "pypaimon-rust split bridge API not installed")
+    def test_native_read_bridges_python_indexed_data_evolution_split(self):
+        self.cat.create_table(
+            'default.python_indexed_native_read',
+            Schema.from_pyarrow_schema(self.schema, options={
+                'data-evolution.enabled': 'true',
+                'row-tracking.enabled': 'true',
+            }), False)
+        self._write('python_indexed_native_read', [
+            {'k': 10, 'v': 'a'}, {'k': 20, 'v': 'b'}, {'k': 30, 'v': 'c'}])
+        table = self.cat.get_table('default.python_indexed_native_read').copy({
+            'read.native.enabled': 'true',
+        })
+        builder = table.new_read_builder()
+        scan = builder.new_scan().with_row_ranges([Range(1, 1)])
+        with patch.object(scan, '_native_plan_supported', return_value=False):
+            plan = scan.plan()
+
+        with patch(
+                'pypaimon.read.table_read.TableRead._create_split_read',
+                side_effect=AssertionError('Python reader was used')), patch(
+                    'pypaimon.read.native_plan.native_read',
+                    wraps=native_read) as rust_read:
+            rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+
+        self.assertEqual(rows, [{'k': 20, 'v': 'b'}])
+        self.assertGreaterEqual(rust_read.call_count, 1)
+
+    def test_query_auth_uses_python_plan_and_reader(self):
+        self.cat.create_table(
+            'default.query_auth_native_read',
+            Schema.from_pyarrow_schema(self.schema), False)
+        self._write('query_auth_native_read', [
+            {'k': 1, 'v': 'sales'},
+            {'k': 2, 'v': 'eng'},
+            {'k': 3, 'v': 'eng'},
+        ])
+        table = self.cat.get_table('default.query_auth_native_read').copy({
+            'read.native.enabled': 'true',
+        })
+        auth_filter = json.dumps({
+            'kind': 'LEAF',
+            'transform': {
+                'name': 'FIELD_REF',
+                'fieldRef': {'index': 1, 'name': 'v', 'type': 'STRING'},
+            },
+            'function': 'EQUAL',
+            'literals': ['eng'],
+        })
+        auth = TableQueryAuthResult(
+            [auth_filter], {'k': json.dumps({'name': 'NULL'})})
+        table.catalog_environment.table_query_auth = (
+            lambda options, identifier: lambda select: auth)
+        builder = table.new_read_builder().with_projection(['k']).with_limit(1)
+        plan = builder.new_scan().plan()
+        self.assertTrue(all(
+            split.__class__.__name__ == 'QueryAuthSplit'
+            for split in plan.splits()))
+
+        with patch(
+                'pypaimon.read.native_plan.native_read',
+                wraps=native_read) as rust_read:
+            rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
+            streamed = builder.new_read().to_arrow_batch_reader(
+                plan.splits()).read_all().to_pylist()
+
+        self.assertEqual(rows, [{'k': None}])
+        self.assertEqual(streamed, rows)
+        rust_read.assert_not_called()
+
     @unittest.skipUnless(native_reader_available(),
                          "pypaimon-rust native reader API not installed")
     def test_native_read_uses_effective_split_parallelism(self):
@@ -1219,7 +1328,7 @@ class NativePlanIntegrationTest(unittest.TestCase):
         with patch('pypaimon.read.native_plan.native_read',
                    wraps=native_read) as read:
             rows = builder.new_read().to_arrow(plan.splits()).to_pylist()
-        read.assert_not_called()
+        self.assertEqual(read.call_count, len(plan.splits()))
         self.assertEqual(sorted(rows, key=lambda row: row['k']), [
             {'k': 1, 'p': 'a/b'},
             {'k': 2, 'p': 'a/b'},
diff --git a/paimon-python/pypaimon/tests/native_read_test.py 
b/paimon-python/pypaimon/tests/native_read_test.py
index 8376a878d7..841324086e 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -21,6 +21,7 @@ from unittest.mock import Mock, patch
 import pyarrow as pa
 import pytest
 
+from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.table_read import TableRead
 from pypaimon.schema.data_types import AtomicType, DataField
 
@@ -156,6 +157,57 @@ def 
test_native_read_falls_back_for_transformed_python_split():
     native.assert_not_called()
 
 
+def test_native_read_bridges_python_planned_split():
+    read = _table_read()
+    schema = pa.schema([('id', pa.int32())])
+    split = _Split()
+    converted = object()
+
+    with patch(
+            'pypaimon.read.native_plan.native_split_from_python',
+            return_value=converted) as bridge, patch(
+                'pypaimon.read.native_plan.native_read',
+                return_value=[_id_batch([1])]) as native:
+        result = list(read._try_native_batches([split], schema))
+
+    assert result[0].column('id').to_pylist() == [1]
+    bridge.assert_called_once_with(split)
+    native.assert_called_once_with(
+        read.table,
+        [converted],
+        predicate=None,
+        limit=None,
+        projection=['id'],
+    )
+
+
+def test_native_split_bridge_failure_falls_back_before_starting_reader():
+    read = _table_read()
+    split = _Split()
+    with patch(
+            'pypaimon.read.native_plan.native_split_from_python',
+            side_effect=ValueError('not serializable')), patch(
+                'pypaimon.read.native_plan.native_read') as native:
+        assert read._try_native_batches(
+            [split], pa.schema([('id', pa.int32())])) is None
+    native.assert_not_called()
+
+
+def test_native_query_auth_falls_back_to_python_reader():
+    read = _table_read()
+    split = _Split()
+    split._native_split = object()
+    wrapped = QueryAuthSplit(split, object())
+
+    with patch(
+            'pypaimon.read.native_plan.native_split_from_python') as convert, 
patch(
+            'pypaimon.read.native_plan.native_read') as native:
+        assert read._try_native_batches(
+            [wrapped], pa.schema([('id', pa.int32())])) is None
+    convert.assert_not_called()
+    native.assert_not_called()
+
+
 def test_native_read_uses_effective_parallelism_from_table_option():
     read = _table_read()
     read._read_parallelism = 2
diff --git a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py 
b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
index 2b9329dcd2..28b67863c1 100644
--- a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
+++ b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
@@ -182,6 +182,7 @@ class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
         ])
 
         self.assertEqual(7, result.snapshot_id)
+        self.assertEqual(7, result.splits[0].snapshot_id)
         self.assertEqual([(1, 2), (4, 4)],
                          [(r.from_, r.to) for r in 
result.splits[0].row_ranges()])
         self.assertEqual([0.9, 0.8, 0.7], result.splits[0].scores())
diff --git 
a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py 
b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
index 7bf7c9e927..b35f120f75 100644
--- a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
@@ -66,7 +66,9 @@ def _mock_entry(partition_values, bucket, file_name, 
row_count, file_size=1024):
     return entry
 
 
-def _make_generator(seed, chunk_size, table=None, deletion_files_map=None):
+def _make_generator(
+        seed, chunk_size, table=None, deletion_files_map=None,
+        snapshot_id=None):
     if table is None:
         table = _mock_table()
     return AppendChunkShuffleSplitGenerator(
@@ -76,6 +78,7 @@ def _make_generator(seed, chunk_size, table=None, 
deletion_files_map=None):
         deletion_files_map=deletion_files_map,
         seed=seed,
         chunk_size=chunk_size,
+        snapshot_id=snapshot_id,
     )
 
 
@@ -233,12 +236,13 @@ class 
ChunkShuffleSplitGeneratorAlgoTest(unittest.TestCase):
     def test_chunk_truncates_inside_file(self):
         # one file of 250 rows, chunk_size 100 → 3 chunks: 100, 100, 50
         entries = [_mock_entry([], 0, 'f1', 250)]
-        gen = _make_generator(seed=1, chunk_size=100)
+        gen = _make_generator(seed=1, chunk_size=100, snapshot_id=7)
         splits = gen.create_splits(entries)
         self.assertEqual(len(splits), 3)
         # All three chunks slice the same file → all SlicedSplit
         for s in splits:
             self.assertIsInstance(s, SlicedSplit)
+            self.assertEqual(s.snapshot_id, 7)
         # union of (start, end) intervals must cover [0, 250)
         intervals = sorted(s.shard_file_idx_map()['f1'] for s in splits)
         self.assertEqual(intervals, [(0, 100), (100, 200), (200, 250)])
diff --git a/paimon-python/pypaimon/tests/split_serializer_test.py 
b/paimon-python/pypaimon/tests/split_serializer_test.py
index 50561905e2..21533e857e 100644
--- a/paimon-python/pypaimon/tests/split_serializer_test.py
+++ b/paimon-python/pypaimon/tests/split_serializer_test.py
@@ -21,7 +21,8 @@ import unittest
 
 from pypaimon.globalindex.indexed_split import IndexedSplit
 from pypaimon.read.split_serializer import (
-    _decode_modified_utf8, _decode_str_array, deserialize_split_v1)
+    _decode_modified_utf8, _decode_str_array, _encode_modified_utf8,
+    deserialize_split_v1, serialize_split_v1)
 from pypaimon.schema.data_types import AtomicType, DataField
 
 # Hand-built BinaryArray<string> == ["id", "longcolumn12"]: hits both the 
inline
@@ -201,6 +202,8 @@ class SplitSerializerTest(unittest.TestCase):
                 self.assertEqual(file.value_stats_cols, ['field1', 'field2', 
'field3'])
                 self.assertEqual(file.first_row_id, 12)
                 self.assertEqual(file.write_cols, ['a', 'b', 'c', 'f'])
+                self.assertEqual(
+                    file.write_cols_sequences, [15, 100, 150, 200])
                 self.assertEqual(len(split.data_deletion_files), 1)
                 dv = split.data_deletion_files[0]
                 self.assertEqual(
@@ -210,6 +213,90 @@ class SplitSerializerTest(unittest.TestCase):
                     self.assertEqual([(r.from_, r.to) for r in 
split.row_ranges()], [(13, 14)])
                     self.assertEqual(split.scores(), [0.75])
 
+    def test_v9_roundtrip_is_byte_compatible_with_java(self):
+        fields = [DataField(0, 's', AtomicType('STRING'))]
+        frame = _GOLDEN_DATA_SPLIT_V1[:16] + _GOLDEN_DATA_SPLIT_V9
+
+        split = deserialize_split_v1(frame, fields, fields)
+
+        self.assertEqual(serialize_split_v1(split), frame)
+
+    def test_v8_is_upgraded_to_v9_without_losing_reader_metadata(self):
+        fields = self._partition_fields()
+        split = deserialize_split_v1(_GOLDEN_DATA_SPLIT_V1, fields)
+
+        upgraded = deserialize_split_v1(serialize_split_v1(split), fields)
+
+        self.assertEqual(upgraded.snapshot_id, split.snapshot_id)
+        self.assertEqual(upgraded.bucket_path, split.bucket_path)
+        self.assertEqual(upgraded.total_buckets, split.total_buckets)
+        self.assertEqual(
+            [file.file_path for file in upgraded.files],
+            [file.file_path for file in split.files])
+        self.assertEqual(
+            [file.max_sequence_number for file in upgraded.files], [100, 200])
+        self.assertEqual(
+            upgraded.files[0].key_stats.null_counts,
+            split.files[0].key_stats.null_counts)
+
+    def test_indexed_roundtrip_preserves_or_strips_scores_explicitly(self):
+        fields = self._partition_fields()
+        split = deserialize_split_v1(_GOLDEN_INDEXED_SPLIT_V1, fields)
+
+        with_scores = deserialize_split_v1(
+            serialize_split_v1(split), fields)
+        without_scores = deserialize_split_v1(
+            serialize_split_v1(split, include_scores=False), fields)
+
+        self.assertEqual(with_scores.scores(), [0.5, 0.25, 0.125])
+        self.assertIsNone(without_scores.scores())
+        self.assertEqual(
+            [(r.from_, r.to) for r in without_scores.row_ranges()],
+            [(1, 4), (11, 13)])
+
+    def test_disjoint_file_paths_use_per_file_external_paths(self):
+        fields = self._partition_fields()
+        split = deserialize_split_v1(_GOLDEN_DATA_SPLIT_V1, fields)
+        split.bucket_path = None
+        split.files[0].file_path = 's3://bucket-a/data/file-a'
+        split.files[1].file_path = 's3://bucket-b/data/file-b'
+
+        restored = deserialize_split_v1(serialize_split_v1(split), fields)
+
+        self.assertEqual(restored.bucket_path, '')
+        self.assertEqual(
+            [file.external_path for file in restored.files],
+            ['s3://bucket-a/data/file-a', 's3://bucket-b/data/file-b'])
+        self.assertEqual(
+            [file.file_path for file in restored.files],
+            ['s3://bucket-a/data/file-a', 's3://bucket-b/data/file-b'])
+
+    def test_modified_utf8_roundtrip_nul_bmp_and_supplementary(self):
+        value = 'a\x00\u07ff\u0800\U0001f600'
+        self.assertEqual(
+            _decode_modified_utf8(_encode_modified_utf8(value)), value)
+
+    def test_modified_utf8_roundtrip_unpaired_surrogates(self):
+        value = '\ud800x\udc00'
+        self.assertEqual(
+            _decode_modified_utf8(_encode_modified_utf8(value)), value)
+
+    def test_modified_utf8_rejects_oversized_value(self):
+        with self.assertRaisesRegex(ValueError, 'too long'):
+            _encode_modified_utf8('\u0800' * 21846)
+
+    def test_serializer_rejects_mismatched_deletion_files(self):
+        split = deserialize_split_v1(
+            _GOLDEN_DATA_SPLIT_V1, self._partition_fields())
+        split.data_deletion_files = [None]
+        with self.assertRaisesRegex(ValueError, 'does not match'):
+            serialize_split_v1(split)
+
+    def test_deserializer_rejects_trailing_bytes(self):
+        with self.assertRaisesRegex(ValueError, 'trailing bytes'):
+            deserialize_split_v1(
+                _GOLDEN_DATA_SPLIT_V1 + b'junk', self._partition_fields())
+
     def test_unsupported_data_split_version_raises(self):
         for version in (7, 10):
             with self.subTest(version=version):
diff --git 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
index e60c7bf5b3..08d529725a 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
+++ 
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
@@ -36,7 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 public class CopyFilesUtilTest {
 
     @Test
-    void testClearColumnSequencesWhenChangingSchemaId() {
+    void testClearWriteColsSequencesWhenChangingSchemaId() {
         DataFileMeta source =
                 DataFileMeta.forAppend(
                                 "source.parquet",
@@ -53,14 +53,14 @@ public class CopyFilesUtilTest {
                                 null,
                                 null,
                                 Arrays.asList("a", "b"))
-                        .withColumnMaxSequenceNumbers(new long[] {2L, 3L});
+                        .withWriteColsSequences(new long[] {2L, 3L});
 
         DataFileMeta copied = CopyFilesUtil.toNewDataFileMeta(source, 
"copied.parquet", 6L);
 
         assertThat(copied.fileName()).isEqualTo("copied.parquet");
         assertThat(copied.schemaId()).isEqualTo(6L);
         assertThat(copied.writeCols()).containsExactly("a", "b");
-        assertThat(copied.columnMaxSequenceNumbers()).isNull();
+        assertThat(copied.writeColsSequences()).isNull();
     }
 
     @Test

Reply via email to