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

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


The following commit(s) were added to refs/heads/master by this push:
     new ea34d1c85b3 [fix](paimon) Fail scans of binary partitions with legacy 
partition names (#69571)
ea34d1c85b3 is described below

commit ea34d1c85b3854291358dff74bac039075146bbe
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Mon Oct 26 07:25:28 2026 +0000

    [fix](paimon) Fail scans of binary partitions with legacy partition names 
(#69571)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69567
    
    Problem Summary:
    
    **In short.** A query of a Paimon table with a BINARY / VARBINARY
    partition column now fails with an error naming
    `'partition.legacy-name'` when it would read a non-NULL binary partition
    whose files Paimon cannot locate, instead of silently returning only the
    rows of the other partitions. This is the read-side counterpart of
    #69567, which rejects writes to such tables.
    
    **Background.** Paimon looks for the files of a partition under the
    directory its partition values name, computed when the split is planned;
    a writer puts the files under the name it computes when it writes. With
    `partition.legacy-name = true`, the default, the name of a value is its
    `toString()`, which for a binary value is the Java array object
    (`[B@1b6d3586`) rather than its bytes, so every resolution differs. With
    `partition.legacy-name = false` it is the text of the bytes (`p=AB` for
    `X'4142'`). The option can be changed on an existing table, which
    changes how existing partitions are looked up but does not move their
    files. The BE skips a file of an external table that no longer exists
    (`ignore_not_found_file_in_external_table`, default true), which is
    meant for files removed between planning and reading.
    
    **The problem, and what it cost.** Measured on master with Paimon 2.1.0,
    on a `file://` Paimon catalog of a local FE + BE, with tables created
    and written through the Paimon API:
    
    | Table | Paimon API read | Doris before this PR |
    |---|---|---|
    | legacy names (default), rows in `X'4142'`, `X'EF'`, NULL | fails:
    `FileNotFoundException` (`p=%5BB@...`) | `SELECT *` returns only the
    NULL row; `COUNT(*)`, answered from metadata, returns 3 |
    | legacy names when `X'4142'` was written, then `'partition.legacy-name'
    = 'false'` and rows in `X'4344'`, NULL | fails: looks for `p=AB`, the
    file is under `p=%5BB@...` | returns 2 of 3 rows, the `X'4142'` row is
    missing |
    | new names when `X'4142'` and NULL were written, then
    `'partition.legacy-name' = 'true'` | fails | returns 1 of 2 rows |
    | legacy names, rows only in the NULL partition | reads | reads
    correctly |
    
    So the query succeeds with a partial result and nothing tells the user.
    Following the obvious fix, setting `'partition.legacy-name' = 'false'`,
    does not make the old partitions readable either; it only moves the loss
    to the files written before the change.
    
    **How this PR fixes it.** `PaimonScanPlanProvider.planScanInternal`
    checks every data split that will read files (after the splits whose
    `COUNT(*)` is answered from metadata) whose binary partition value is
    not NULL:
    
    - if the split is looked up under legacy names (the options of its
    table, or of its branch for a fallback read), planning fails;
    - otherwise, if one of its data files was written under a schema whose
    options use legacy names (the file's `schemaId`, looked up once per
    schema and branch, inside the catalog authentication) and is also
    missing at its planned path (`bucketPath/fileName`, the path the native
    reader is given), planning fails and names that file. The schema alone
    does not prove how a file was named: a writer's dynamic options
    (`spark.paimon.*`, Flink hints, `Table.copy`) keep the schema ID, so a
    file written with a dynamic `'partition.legacy-name' = 'false'` on a
    table that keeps the default refers to a schema with legacy names while
    it sits under the bytes of its value, and it is read once the table uses
    those names;
    - and, on a table with `'index-file-in-data-file-dir' = 'true'`, every
    deletion vector the split carries must exist, whether or not its data
    file has an external path, or planning fails and names the deletion
    vector file and its data file. Such a table keeps its deletion vectors
    with its data files: one written while no external data path is
    configured records only its file name and is looked up under the
    directory of its partition, which a legacy name never finds, while its
    data file keeps its external path, and the BE skips the whole data file
    when it cannot open the deletion vector. The option is immutable, so
    every writer used the reading table's value. Deletion vectors under the
    table's `index/` directory do not depend on partition names and are not
    checked.
    
    A NULL partition is named after `partition.default-name` under both
    namings, and a data file with an external path is read from the path its
    metadata records, so neither is rejected; the deletion vectors of a file
    with an external path are still checked. The system tables read through
    JNI (`$audit_log`, `$row_tracking`) report the options of the main
    branch, while a fallback split is planned and read under its own branch,
    so the check resolves the branch that produced each split from the
    pinned source of the wrapper, as it does for data tables. `$binlog`
    exposes its partition columns as arrays and is not checked; Paimon's own
    reader fails on a file it cannot find. A
    `scan.file-creation-time-millis` read of a fallback table plans the main
    branch through its `SnapshotReader`, and its plain splits are judged
    under the main branch. The error names the table, the partition and the
    option. The existence checks run inside the catalog authentication, once
    per path and scan: a table that keeps its index files under `index/` and
    whose files were written under its current naming makes no extra
    filesystem calls.
    
    **Not covered.** A file written with a dynamic `'partition.legacy-name'
    = 'true'` (or by a Paimon writer too old to know the option) while its
    schema says `'false'` sits under a legacy name but is looked up under
    its bytes, and the BE still skips it, as on master and branch-4.2.
    Catching it would take an existence call for every file of every
    non-NULL binary partition on every scan.
    
    **Results.** On the same local cluster, after this PR:
    
    | Query | Doris after this PR |
    |---|---|
    | `SELECT *` of the legacy table (also with `force_jni_scanner = true`)
    | fails: `Cannot read Paimon table d.api_legacy: the table uses legacy
    partition names ('partition.legacy-name' = 'true'), under which Paimon
    names partition p = 0xEF after a Java array object rather than its
    bytes, so the files of the partition are never found. Paimon cannot read
    the partition either; ...` |
    | `SELECT COUNT(*)` of the legacy table | 3, answered from metadata as
    before |
    | `SELECT * ... WHERE p IS NULL` of the legacy table | the NULL row;
    Paimon prunes the other partitions |
    | `SELECT *` of the table switched to new names | fails: `... data file
    data-b3abc825-...-0.parquet of partition p = 0x4142 was written while
    the table used legacy partition names ...`, the file the Paimon API read
    reports as not found |
    | `SELECT *` of the table switched back to legacy names | fails: `...
    the table uses legacy partition names ...` |
    | `SELECT *` of the legacy table with only NULL partitions, and of
    tables with new names | all rows, the same as the Paimon API read |
    
    `PaimonScanPlanProviderTest` adds eight tests: a scan under legacy names
    fails while the NULL partition and `COUNT(*)` still plan; a file written
    under legacy names fails after the option changes to false, and a file
    written under the new names fails after it changes back to true; a file
    at an external path still plans and points at an existing file; a file
    at an external path whose deletion vector was written after the external
    paths were removed (persisted the way
    `PaimonSparkWriter.persistDeletionVectors` does it for a DELETE) fails
    and names the deletion vector, while a deletion vector written with the
    external paths in place still plans and points at an existing file; a
    file written through `table.copy({"partition.legacy-name": "false"})` on
    a table that keeps the default fails as a legacy lookup, then plans once
    the option is altered to `false`; a fallback table whose main branch
    holds only a NULL partition and whose fallback branch holds a non-NULL
    binary partition plans the data table and `$audit_log` when the fallback
    branch names partitions by bytes, and the planned `$audit_log` splits
    read both rows back through Paimon, while both fail at planning with the
    legacy-name error when only the main branch names them by bytes; and a
    `scan.file-creation-time-millis` read of a fallback table with a
    non-NULL binary partition plans the main branch's file.
    `nativeBinaryPartitionsKeepAllPathValues`, added by #68786 for the
    rendering of binary partition values, now creates its table with
    `'partition.legacy-name' = 'false'`: under legacy names the files it
    planned did not exist. The new tests fail when the lookup check, the
    check of the files' schemas, the exemption of external paths or of NULL
    values is removed, when the check moves before the `COUNT(*)` splits,
    when the deletion vectors are not checked, when every deletion vector
    fails without its existence being checked, when a file whose schema uses
    legacy names fails without its existence being checked, when a system
    table read through JNI is checked under the wrapper's options, when
    every split is judged under the main branch, or when a split that does
    not come from the fallback scan is cast to a fallback split (the
    file-creation-time read then failed with a `ClassCastException`). The
    853 tests of fe-connector-paimon run without failures (one is skipped,
    as before).
    
    No regression test is added: Doris cannot create a binary column, so the
    tables would have to come from the Spark container of the Paimon docker
    environment, which was not run locally.
---
 .../connector/paimon/PaimonScanPlanProvider.java   | 125 +++++++-
 .../paimon/PaimonScanPlanProviderTest.java         | 326 +++++++++++++++++++++
 2 files changed, 450 insertions(+), 1 deletion(-)

diff --git 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
index 548907b5249..074639d7f3e 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
@@ -59,6 +59,7 @@ import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.io.DataOutputViewStreamWrapper;
 import org.apache.paimon.options.Options;
@@ -86,6 +87,7 @@ import org.apache.paimon.table.system.SystemTableLoader;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.MapType;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.InstantiationUtil;
@@ -989,6 +991,15 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
         // $ro wraps the pinned file-store table; resolve its schema 
dictionary once, only if native is considered.
         java.util.function.Supplier<Table> legacyOrcSchemaTable = 
com.google.common.base.Suppliers.memoize(
                 () -> resolveSchemaDictTable(table, paimonHandle));
+        // The wrappers read through JNI report the options of the main 
branch, but their splits come from a branch
+        // of the pinned source, which the timestamp-history checks resolve 
the same way.
+        java.util.function.Supplier<Table> binaryPartitionSourceTable = 
paimonHandle.isForceJni()
+                ? com.google.common.base.Suppliers.memoize(() -> 
withSchemaHistoryAuthentication(
+                        () -> resolveTimestampHistoryTable(table, 
paimonHandle)))
+                : legacyOrcSchemaTable;
+        int[] binaryPartitionFields = binaryPartitionFields(table);
+        Map<FileStoreTable, Map<Long, Boolean>> legacyPartitionNameSchemas = 
new java.util.IdentityHashMap<>();
+        Map<String, Boolean> fileExistence = new HashMap<>();
         // Process DataSplits
         for (DataSplit dataSplit : dataSplits) {
             if (isCountPushdownSplit(countPushdown, dataSplit)) {
@@ -998,6 +1009,10 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
                 }
                 continue;
             }
+            if (binaryPartitionFields.length > 0) {
+                checkBinaryPartitionFiles(paimonHandle, table, dataSplit, 
binaryPartitionFields,
+                        binaryPartitionSourceTable, 
legacyPartitionNameSchemas, fileExistence);
+            }
 
             Map<String, String> partitionValues = getPartitionInfoMap(
                     table, dataSplit.partition(), session.getTimeZone());
@@ -1428,7 +1443,7 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
         } catch (RuntimeException e) {
             throw e;
         } catch (Exception e) {
-            throw new RuntimeException("Failed to read Paimon timestamp schema 
history", e);
+            throw new RuntimeException("Failed to access Paimon table files", 
e);
         }
     }
 
@@ -2286,6 +2301,11 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
         FileStoreTable base = 
PaimonTableDecorators.unwrapToFallbackOrBase(table);
         if (base instanceof FallbackReadFileStoreTable) {
             FallbackReadFileStoreTable pair = (FallbackReadFileStoreTable) 
base;
+            if (!(split instanceof FallbackReadFileStoreTable.FallbackSplit)) {
+                // A split planned through the pair's SnapshotReader (a 
file-creation-time read) comes from its
+                // wrapped branch.
+                return pair.wrapped();
+            }
             boolean fallback = ((FallbackReadFileStoreTable.FallbackSplit) 
split).isFallback();
             // Match the SDK reader's branch selection before reading either 
options or historical schemas.
             return PaimonReaderOptions.isWrappedFirst(pair) != fallback ? 
pair.wrapped() : pair.other();
@@ -2293,6 +2313,109 @@ public class PaimonScanPlanProvider implements 
ConnectorScanPlanProvider {
         return base;
     }
 
+    /** Positions in the partition row of the partition columns that hold 
binary values. */
+    private static int[] binaryPartitionFields(Table table) {
+        RowType partitionType = table.rowType().project(table.partitionKeys());
+        return java.util.stream.IntStream.range(0, 
partitionType.getFieldCount()).filter(field -> {
+            DataTypeRoot typeRoot = 
partitionType.getTypeAt(field).getTypeRoot();
+            return typeRoot == DataTypeRoot.BINARY || typeRoot == 
DataTypeRoot.VARBINARY;
+        }).toArray();
+    }
+
+    /**
+     * Paimon finds the files of a partition under the directory its values 
name. With {@code partition.legacy-name}
+     * = true, the default, it names a partition after the string of each 
value, which for a non-NULL binary value
+     * is the Java array object ({@code [B@1b6d3586}) rather than its bytes, 
and every resolution of the name
+     * differs. A file written under such a name, or looked up under one, is 
never found: Paimon's own read fails,
+     * while the BE skips a missing file of an external table and would 
silently drop the partition's rows. A file
+     * with an external path is found by the path its metadata records. A 
system wrapper reports the options of the
+     * main branch while a fallback split is planned and read under its own 
branch, so the options and schemas come
+     * from the branch that produced the split; for the wrappers read through 
JNI that branch is resolved from the
+     * pinned source of the wrapper.
+     *
+     * <p>The schema a file refers to does not prove how the file was named, 
because dynamic options
+     * ({@code spark.paimon.*}, Flink hints, {@code Table.copy}) keep the 
schema ID: a file whose schema uses legacy
+     * names fails only if it is also missing at its planned path. With {@code 
index-file-in-data-file-dir} = true, a
+     * deletion vector written without an external data path is looked up 
under the directory of its partition too,
+     * whatever the path of its data file, and the BE skips the whole data 
file when it cannot open the deletion
+     * vector, so every deletion vector must exist. Each path is checked once 
per scan.
+     */
+    private void checkBinaryPartitionFiles(PaimonTableHandle handle, Table 
table, DataSplit split,
+            int[] binaryPartitionFields, Supplier<Table> schemaTable,
+            Map<FileStoreTable, Map<Long, Boolean>> 
legacyPartitionNameSchemas, Map<String, Boolean> fileExistence) {
+        BinaryRow partition = split.partition();
+        OptionalInt nonNullField = Arrays.stream(binaryPartitionFields)
+                .filter(field -> !partition.isNullAt(field)).findFirst();
+        if (!nonNullField.isPresent()) {
+            return;
+        }
+        int binaryField = nonNullField.getAsInt();
+        FileStoreTable splitTable = schemaTable.get() == null
+                ? null : legacyOrcSplitTable((FileStoreTable) 
schemaTable.get(), split);
+        Table optionsTable = splitTable == null ? table : splitTable;
+        CoreOptions options = CoreOptions.fromMap(optionsTable.options());
+        boolean legacyLookup = options.legacyPartitionName();
+        // Deletion vectors under the table's index directory do not depend on 
partition names. The option is
+        // immutable, so every writer of the table used this value.
+        List<DeletionFile> deletionFiles = options.indexFileInDataFileDir() ? 
split.deletionFiles().orElse(null) : null;
+        List<DataFileMeta> files = split.dataFiles();
+        for (int i = 0; i < files.size(); i++) {
+            DataFileMeta file = files.get(i);
+            if (!file.externalPath().isPresent()) {
+                if (legacyLookup) {
+                    throw binaryPartitionReadError(handle, "the table uses 
legacy partition names"
+                            + " ('partition.legacy-name' = 'true'), under 
which Paimon names partition "
+                            + binaryPartitionName(table, partition, 
binaryField) + " after a Java array object"
+                            + " rather than its bytes, so the files of the 
partition are never found");
+                }
+                if (splitTable != null && legacyPartitionNameSchemas
+                        .computeIfAbsent(splitTable, ignored -> new 
HashMap<>())
+                        .computeIfAbsent(file.schemaId(), id -> 
withSchemaHistoryAuthentication(() -> CoreOptions
+                                
.fromMap(splitTable.schemaManager().schema(id).options()).legacyPartitionName()))
+                        && !fileExists(optionsTable.fileIO(), 
split.bucketPath() + "/" + file.fileName(),
+                                fileExistence)) {
+                    throw binaryPartitionReadError(handle, "data file " + 
file.fileName() + " of partition "
+                            + binaryPartitionName(table, partition, 
binaryField) + " was written while the table"
+                            + " used legacy partition names 
('partition.legacy-name' = 'true'), under which Paimon"
+                            + " names the partition after a Java array object 
rather than its bytes, so the file is"
+                            + " not under the directory of the partition");
+                }
+            }
+            DeletionFile deletionFile = deletionFiles == null ? null : 
deletionFiles.get(i);
+            if (deletionFile != null && !fileExists(optionsTable.fileIO(), 
deletionFile.path(), fileExistence)) {
+                throw binaryPartitionReadError(handle, "deletion vector file "
+                        + new Path(deletionFile.path()).getName() + " of data 
file " + file.fileName()
+                        + " of partition " + binaryPartitionName(table, 
partition, binaryField)
+                        + " does not exist: the table keeps index files with 
its data files"
+                        + " ('index-file-in-data-file-dir' = 'true'), so a 
deletion vector written without an external"
+                        + " path is looked up under the directory of its 
partition, and legacy partition names"
+                        + " ('partition.legacy-name' = 'true') name the 
partition after a Java array object rather"
+                        + " than its bytes");
+            }
+        }
+    }
+
+    private static String binaryPartitionName(Table table, BinaryRow 
partition, int binaryField) {
+        return table.partitionKeys().get(binaryField) + " = 0x"
+                + 
java.util.HexFormat.of().withUpperCase().formatHex(partition.getBinary(binaryField));
+    }
+
+    private static DorisConnectorException 
binaryPartitionReadError(PaimonTableHandle handle, String reason) {
+        return new DorisConnectorException("Cannot read Paimon table " + 
handle.getDatabaseName() + "."
+                + handle.getTableName() + ": " + reason + ". Paimon cannot 
read the partition either; a table with a"
+                + " binary partition column needs 'partition.legacy-name' = 
'false' before its data is written");
+    }
+
+    private boolean fileExists(FileIO fileIO, String path, Map<String, 
Boolean> fileExistence) {
+        return fileExistence.computeIfAbsent(path, ignored -> 
withSchemaHistoryAuthentication(() -> {
+            try {
+                return fileIO.exists(new Path(path));
+            } catch (IOException e) {
+                throw new DorisConnectorException("Failed to check whether 
Paimon file " + path + " exists", e);
+            }
+        }));
+    }
+
     private static boolean requiresCrossFamilyTimestampReader(FileStoreTable 
table, Optional<List<RawFile>> rawFiles,
             Set<Integer> readFieldIds, Map<Long, Boolean> schemaTimestamps) {
         RowType readType = new RowType(table.rowType().getFields().stream()
diff --git 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
index d29cae9ba8d..bbb0513a29a 100644
--- 
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
@@ -50,9 +50,14 @@ import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.deletionvectors.BitmapDeletionVector;
+import org.apache.paimon.deletionvectors.append.BaseAppendDeleteFileMaintainer;
 import org.apache.paimon.fs.FileStatus;
 import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.index.IndexFileMeta;
+import org.apache.paimon.io.CompactIncrement;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
 import org.apache.paimon.io.DataInputViewStreamWrapper;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.reader.RecordReader;
@@ -69,11 +74,13 @@ import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
 import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.DeletionFile;
 import org.apache.paimon.table.source.RawFile;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.table.system.ReadOptimizedTable;
+import org.apache.paimon.table.system.SystemTableLoader;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
@@ -892,9 +899,11 @@ public class PaimonScanPlanProviderTest {
                     new 
org.apache.paimon.fs.Path(warehouse.resolve(format).toUri()))) {
                 catalog.createDatabase("db", false);
                 Identifier id = Identifier.create("db", "binary_parts");
+                // Legacy partition names cannot name a binary partition 
(legacyNamedBinaryPartitionsFailScanPlanning).
                 catalog.createTable(id, Schema.newBuilder().column("id", 
DataTypes.INT())
                         .column("key", DataTypes.BYTES()).column("region", 
DataTypes.STRING())
                         .partitionKeys("key", "region").option("bucket", "-1")
+                        .option("partition.legacy-name", "false")
                         .option("file.format", format).build(), false);
                 Table table = catalog.getTable(id);
                 BatchWriteBuilder writer = table.newBatchWriteBuilder();
@@ -925,6 +934,323 @@ public class PaimonScanPlanProviderTest {
         }
     }
 
+    @Test
+    public void legacyNamedBinaryPartitionsFailScanPlanning(@TempDir Path 
warehouse) throws Exception {
+        // With partition.legacy-name = true, the default, Paimon looks a 
non-NULL binary partition up under the name
+        // of a Java array object, so the planned files never exist and the BE 
would skip them as missing files.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Table legacy = binaryPartitionTable(catalog, "legacy", 
Collections.emptyMap(),
+                    new byte[] {0x41, 0x42}, null);
+            DorisConnectorException error = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> planBinaryPartitionScan(legacy, false));
+            Assertions.assertTrue(error.getMessage().startsWith("Cannot read 
Paimon table db.legacy: the table uses"
+                    + " legacy partition names ('partition.legacy-name' = 
'true'), under which Paimon names partition"
+                    + " key = 0x4142 after a Java array object"), 
error.getMessage());
+
+            // COUNT(*) answers from the metadata row counts and reads no file.
+            List<ConnectorScanRange> count = planBinaryPartitionScan(legacy, 
true);
+            Assertions.assertEquals(1, count.size());
+            Assertions.assertEquals(2, ((PaimonScanRange) 
count.get(0)).getPushDownRowCount());
+
+            // The NULL partition is named after partition.default-name, which 
Paimon finds again.
+            Table nullOnly = binaryPartitionTable(catalog, "null_only", 
Collections.emptyMap(), (byte[]) null);
+            Assertions.assertEquals(1, planBinaryPartitionScan(nullOnly, 
false).size());
+        }
+    }
+
+    @Test
+    public void binaryPartitionFilesOfAnotherNamingFailScanPlanning(@TempDir 
Path warehouse) throws Exception {
+        // Paimon writes a file under the name its partition has when written 
and looks it up under the name the
+        // partition has when read, so a file written under a legacy name 
stays lost after the option changes.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Table legacy = binaryPartitionTable(catalog, "renamed", 
Collections.emptyMap(), new byte[] {0x41, 0x42});
+            String legacyFile = ((DataSplit) 
legacy.newReadBuilder().newScan().plan().splits().get(0))
+                    .dataFiles().get(0).fileName();
+            catalog.alterTable(Identifier.create("db", "renamed"), 
Collections.singletonList(
+                    SchemaChange.setOption("partition.legacy-name", "false")), 
false);
+            writeBinaryPartitions(catalog.getTable(Identifier.create("db", 
"renamed")), new byte[] {0x43, 0x44});
+            Table current = catalog.getTable(Identifier.create("db", 
"renamed"));
+            DorisConnectorException error = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> planBinaryPartitionScan(current, false));
+            Assertions.assertTrue(error.getMessage().startsWith("Cannot read 
Paimon table db.renamed: data file "
+                    + legacyFile + " of partition key = 0x4142 was written 
while the table used legacy partition"
+                    + " names ('partition.legacy-name' = 'true')"), 
error.getMessage());
+
+            // A file written under the bytes of its value is looked up under 
a legacy name once those are back on.
+            binaryPartitionTable(catalog, "restored", 
Collections.singletonMap("partition.legacy-name", "false"),
+                    new byte[] {0x41, 0x42});
+            Table byBytes = catalog.getTable(Identifier.create("db", 
"restored"));
+            Assertions.assertEquals(1, planBinaryPartitionScan(byBytes, 
false).size());
+            catalog.alterTable(Identifier.create("db", "restored"), 
Collections.singletonList(
+                    SchemaChange.setOption("partition.legacy-name", "true")), 
false);
+            Table restored = catalog.getTable(Identifier.create("db", 
"restored"));
+            error = Assertions.assertThrows(DorisConnectorException.class,
+                    () -> planBinaryPartitionScan(restored, false));
+            Assertions.assertTrue(error.getMessage().startsWith("Cannot read 
Paimon table db.restored: the table"
+                    + " uses legacy partition names"), error.getMessage());
+        }
+    }
+
+    @Test
+    public void 
binaryPartitionFilesWrittenWithDynamicNamesStayReadable(@TempDir Path 
warehouse) throws Exception {
+        // Dynamic options keep the schema ID, so a file written with a 
dynamic partition.legacy-name = false refers
+        // to a schema with legacy names while it sits under the bytes of its 
value, where a lookup by bytes finds it.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "dynamic_names");
+            Table legacy = binaryPartitionTable(catalog, "dynamic_names", 
Collections.emptyMap());
+            
writeBinaryPartitions(legacy.copy(Collections.singletonMap("partition.legacy-name",
 "false")),
+                    new byte[] {0x41, 0x42});
+            Table current = catalog.getTable(id);
+            DorisConnectorException error = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> planBinaryPartitionScan(current, false));
+            Assertions.assertTrue(error.getMessage().startsWith("Cannot read 
Paimon table db.dynamic_names: the"
+                    + " table uses legacy partition names"), 
error.getMessage());
+
+            catalog.alterTable(id, Collections.singletonList(
+                    SchemaChange.setOption("partition.legacy-name", "false")), 
false);
+            List<ConnectorScanRange> ranges = 
planBinaryPartitionScan(catalog.getTable(id), false);
+            Assertions.assertEquals(1, ranges.size());
+            String path = 
ranges.get(0).getPath().orElseThrow(AssertionError::new);
+            Assertions.assertTrue(path.contains("/key=AB/"), path);
+            Assertions.assertTrue(LocalFileIO.create().exists(new 
org.apache.paimon.fs.Path(path)), path);
+        }
+    }
+
+    @Test
+    public void 
legacyNamedBinaryPartitionFilesAtExternalPathsStayReadable(@TempDir Path 
warehouse) throws Exception {
+        // A file at an external path is read from the path its metadata 
records, whatever its partition is named.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new 
org.apache.paimon.fs.Path(warehouse.resolve("warehouse").toUri()))) {
+            catalog.createDatabase("db", false);
+            String externalRoot = 
warehouse.resolve("external").toUri().toString();
+            Map<String, String> options = new HashMap<>();
+            options.put("data-file.external-paths", externalRoot);
+            options.put("data-file.external-paths.strategy", "round-robin");
+            Table legacy = binaryPartitionTable(catalog, "external_files", 
options, new byte[] {0x41, 0x42});
+            List<ConnectorScanRange> ranges = planBinaryPartitionScan(legacy, 
false);
+            Assertions.assertEquals(1, ranges.size());
+            String path = 
ranges.get(0).getPath().orElseThrow(AssertionError::new);
+            
Assertions.assertTrue(path.contains(warehouse.resolve("external").toString()), 
path);
+            Assertions.assertTrue(LocalFileIO.create().exists(new 
org.apache.paimon.fs.Path(path)), path);
+        }
+    }
+
+    @Test
+    public void 
externalBinaryPartitionFileWithLocalDeletionVectorFailsScanPlanning(@TempDir 
Path warehouse)
+            throws Exception {
+        // With index-file-in-data-file-dir, a deletion vector written while 
no external data path is configured
+        // records only its name and is looked up under the directory of its 
partition, which a legacy name never
+        // finds, while its data file keeps its external path. The BE skips 
the whole data file when it cannot open
+        // the deletion vector, rows that were not deleted included.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new 
org.apache.paimon.fs.Path(warehouse.resolve("warehouse").toUri()))) {
+            catalog.createDatabase("db", false);
+            Identifier id = Identifier.create("db", "local_dv");
+            deletionVectorBinaryPartitionTable(catalog, "local_dv", warehouse);
+            catalog.alterTable(id, 
Arrays.asList(SchemaChange.removeOption("data-file.external-paths"),
+                    
SchemaChange.removeOption("data-file.external-paths.strategy")), false);
+            Table table = catalog.getTable(id);
+            DataFileMeta file = ((DataSplit) 
table.newReadBuilder().newScan().plan().splits().get(0))
+                    .dataFiles().get(0);
+            Assertions.assertTrue(file.externalPath().isPresent());
+            String deletionVector = deleteFirstRow(table);
+            DorisConnectorException error = 
Assertions.assertThrows(DorisConnectorException.class,
+                    () -> planBinaryPartitionScan(table, false));
+            Assertions.assertTrue(error.getMessage().startsWith("Cannot read 
Paimon table db.local_dv: deletion vector"
+                    + " file " + deletionVector + " of data file " + 
file.fileName() + " of partition key = 0x4142"
+                    + " does not exist"), error.getMessage());
+        }
+    }
+
+    @Test
+    public void 
externalBinaryPartitionDeletionVectorAtExternalPathStaysReadable(@TempDir Path 
warehouse)
+            throws Exception {
+        // A deletion vector written while an external data path is configured 
records its absolute path.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new 
org.apache.paimon.fs.Path(warehouse.resolve("warehouse").toUri()))) {
+            catalog.createDatabase("db", false);
+            Table table = deletionVectorBinaryPartitionTable(catalog, 
"external_dv", warehouse);
+            deleteFirstRow(table);
+            List<ConnectorScanRange> ranges = planBinaryPartitionScan(table, 
false);
+            Assertions.assertEquals(1, ranges.size());
+            String deletionVector = 
ranges.get(0).getProperties().get("paimon.deletion_file.path");
+            
Assertions.assertTrue(deletionVector.contains(warehouse.resolve("external").toString()),
 deletionVector);
+            Assertions.assertTrue(LocalFileIO.create().exists(new 
org.apache.paimon.fs.Path(deletionVector)),
+                    deletionVector);
+        }
+    }
+
+    @Test
+    public void fallbackBinaryPartitionsUseTheNamingOfTheirBranch(@TempDir 
Path warehouse) throws Exception {
+        // A partition missing on the main branch is planned and read under 
the options of the fallback branch,
+        // while the table and its $audit_log wrapper report the options of 
the main branch.
+        for (boolean fallbackUsesBytes : new boolean[] {true, false}) {
+            try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                    new 
org.apache.paimon.fs.Path(warehouse.resolve("fallback_bytes_" + 
fallbackUsesBytes).toUri()))) {
+                catalog.createDatabase("db", false);
+                Map<String, String> byBytes = 
Collections.singletonMap("partition.legacy-name", "false");
+                // The main branch only holds the NULL partition, which every 
naming finds.
+                FileStoreTable main = (FileStoreTable) 
binaryPartitionTable(catalog, "main",
+                        fallbackUsesBytes ? Collections.emptyMap() : byBytes, 
(byte[]) null);
+                FileStoreTable fallback = (FileStoreTable) 
binaryPartitionTable(catalog, "fallback",
+                        fallbackUsesBytes ? byBytes : Collections.emptyMap(), 
new byte[] {0x41, 0x42});
+                FallbackReadFileStoreTable pair = new 
FallbackReadFileStoreTable(main, fallback, true);
+                if (!fallbackUsesBytes) {
+                    Map<String, Supplier<List<ConnectorScanRange>>> plans = 
new LinkedHashMap<>();
+                    plans.put("table", () -> planBinaryPartitionScan(pair, 
false));
+                    plans.put("$audit_log", () -> 
planSystemTableScan("audit_log", pair));
+                    plans.forEach((name, plan) -> {
+                        DorisConnectorException error = 
Assertions.assertThrows(DorisConnectorException.class,
+                                plan::get, name);
+                        
Assertions.assertTrue(error.getMessage().startsWith("Cannot read Paimon table 
db.main: the"
+                                + " table uses legacy partition names 
('partition.legacy-name' = 'true'), under which"
+                                + " Paimon names partition key = 0x4142"), 
error.getMessage());
+                    });
+                    continue;
+                }
+                List<ConnectorScanRange> ranges = 
planBinaryPartitionScan(pair, false);
+                Assertions.assertEquals(2, ranges.size());
+                for (ConnectorScanRange range : ranges) {
+                    String path = 
range.getPath().orElseThrow(AssertionError::new);
+                    Assertions.assertTrue(LocalFileIO.create().exists(new 
org.apache.paimon.fs.Path(path)), path);
+                }
+                Assertions.assertTrue(ranges.stream().anyMatch(range -> 
range.getPath().get().contains("/key=AB/")));
+
+                List<ConnectorScanRange> auditLog = 
planSystemTableScan("audit_log", pair);
+                Assertions.assertEquals(2, auditLog.size());
+                List<String> rows = new ArrayList<>();
+                SystemTableLoader.load("audit_log", 
pair).newReadBuilder().newRead()
+                        .createReader(deserializeJniSplits(auditLog))
+                        .forEachRemaining(row -> rows.add(row.getString(0) + " 
" + (row.isNullAt(2) ? "NULL"
+                                : 
java.util.HexFormat.of().withUpperCase().formatHex(row.getBinary(2)))));
+                rows.sort(Comparator.naturalOrder());
+                Assertions.assertEquals(Arrays.asList("+I 4142", "+I NULL"), 
rows);
+            }
+        }
+    }
+
+    @Test
+    public void 
fileCreationTimeScanOfFallbackTableChecksBinaryPartitionsOfTheMainBranch(@TempDir
 Path warehouse)
+            throws Exception {
+        // A scan.file-creation-time-millis read plans a fallback table 
through its SnapshotReader, which reads the
+        // main branch and returns plain DataSplits rather than the 
FallbackSplits of the fallback scan.
+        try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+                new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+            catalog.createDatabase("db", false);
+            Map<String, String> byBytes = 
Collections.singletonMap("partition.legacy-name", "false");
+            FileStoreTable main = (FileStoreTable) 
binaryPartitionTable(catalog, "main", byBytes,
+                    new byte[] {0x41, 0x42});
+            FileStoreTable fallback = (FileStoreTable) 
binaryPartitionTable(catalog, "fallback", byBytes,
+                    new byte[] {0x43, 0x44});
+            FallbackReadFileStoreTable pair = new 
FallbackReadFileStoreTable(main, fallback, true);
+            RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+            ops.table = pair;
+            PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+                    PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+            PaimonTableHandle handle = new PaimonTableHandle("db", "main", 
Collections.emptyList(),
+                    
Collections.emptyList()).withScanOptions(PaimonScanParams.markAsOptions(
+                            PaimonScanParams.resolveOptions(pair, 
Collections.singletonMap(
+                                    
CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(), "0"))));
+            List<ConnectorScanRange> ranges = provider.planScan(
+                    sessionWithProps(Collections.emptyMap(), new 
TestStatementScope()),
+                    ConnectorScanRequest.builder(handle, Arrays.asList(new 
PaimonColumnHandle("id", 0),
+                            new PaimonColumnHandle("key", 1))).build());
+            Assertions.assertEquals(1, ranges.size());
+            String path = 
ranges.get(0).getPath().orElseThrow(AssertionError::new);
+            Assertions.assertTrue(path.contains("/key=AB/"), path);
+
+            DataSplit split = (DataSplit) 
pair.newSnapshotReader().read().splits().get(0);
+            Assertions.assertFalse(split instanceof 
FallbackReadFileStoreTable.FallbackSplit);
+            Assertions.assertSame(main, 
PaimonScanPlanProvider.legacyOrcSplitTable(pair, split));
+        }
+    }
+
+    private static Table binaryPartitionTable(Catalog catalog, String name, 
Map<String, String> options,
+            byte[]... keys) throws Exception {
+        Identifier id = Identifier.create("db", name);
+        Schema.Builder schema = Schema.newBuilder().column("id", 
DataTypes.INT()).column("key", DataTypes.BYTES())
+                .partitionKeys("key").option("bucket", "-1");
+        options.forEach(schema::option);
+        catalog.createTable(id, schema.build(), false);
+        writeBinaryPartitions(catalog.getTable(id), keys);
+        return catalog.getTable(id);
+    }
+
+    private static void writeBinaryPartitions(Table table, byte[]... keys) 
throws Exception {
+        BatchWriteBuilder writer = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writer.newWrite()) {
+            for (int i = 0; i < keys.length; i++) {
+                write.write(GenericRow.of(i, keys[i]));
+            }
+            try (BatchTableCommit commit = writer.newCommit()) {
+                commit.commit(write.prepareCommit());
+            }
+        }
+    }
+
+    private static List<ConnectorScanRange> planBinaryPartitionScan(Table 
table, boolean countPushdown) {
+        RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+        ops.table = table;
+        PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+                PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+        PaimonTableHandle handle = new PaimonTableHandle("db", table.name(),
+                Collections.emptyList(), Collections.emptyList());
+        return provider.planScan(sessionWithProps(Collections.emptyMap(), new 
TestStatementScope()),
+                ConnectorScanRequest.builder(handle, Arrays.asList(new 
PaimonColumnHandle("id", 0),
+                        new PaimonColumnHandle("key", 
1))).countPushdown(countPushdown).build());
+    }
+
+    /** Plans a scan of {@code source$kind} the way the connector metadata 
builds its system table handle. */
+    private static List<ConnectorScanRange> planSystemTableScan(String kind, 
FileStoreTable source) {
+        RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+        ops.table = source;
+        PaimonScanPlanProvider provider = new PaimonScanPlanProvider(
+                PaimonCatalogProperties.of(Collections.emptyMap()), ops);
+        PaimonTableHandle handle = PaimonTableHandle.forSystemTable("db", 
source.name(), kind,
+                PaimonScanParams.requiresPaimonReader(kind));
+        handle.setPaimonTable(SystemTableLoader.load(kind, source));
+        handle.setSysBaseTable(source);
+        handle.setSystemTableSource(source);
+        return provider.planScan(sessionWithProps(Collections.emptyMap(), new 
TestStatementScope()),
+                ConnectorScanRequest.builder(handle, 
Collections.emptyList()).build());
+    }
+
+    private static Table deletionVectorBinaryPartitionTable(Catalog catalog, 
String name, Path warehouse)
+            throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put("deletion-vectors.enabled", "true");
+        options.put("index-file-in-data-file-dir", "true");
+        options.put("data-file.external-paths", 
warehouse.resolve("external").toUri().toString());
+        options.put("data-file.external-paths.strategy", "round-robin");
+        return binaryPartitionTable(catalog, name, options, new byte[] {0x41, 
0x42}, new byte[] {0x41, 0x42});
+    }
+
+    /** Deletes the first row of the first data file the way a Spark DELETE 
persists its deletion vectors. */
+    private static String deleteFirstRow(Table table) throws Exception {
+        DataSplit split = (DataSplit) 
table.newReadBuilder().newScan().plan().splits().get(0);
+        // PaimonSparkWriter.persistDeletionVectors writes the index files 
under the table's current options.
+        BaseAppendDeleteFileMaintainer maintainer = 
BaseAppendDeleteFileMaintainer.forUnawareAppend(
+                ((FileStoreTable) table).store().newIndexFileHandler(), 
table.latestSnapshot().get(),
+                split.partition());
+        BitmapDeletionVector deletionVector = new BitmapDeletionVector();
+        deletionVector.delete(0);
+        
maintainer.notifyNewDeletionVector(split.dataFiles().get(0).fileName(), 
deletionVector);
+        List<IndexFileMeta> indexFiles = new ArrayList<>();
+        maintainer.persist().forEach(entry -> 
indexFiles.add(entry.indexFile()));
+        try (BatchTableCommit commit = 
table.newBatchWriteBuilder().newCommit()) {
+            commit.commit(Collections.singletonList(new 
CommitMessageImpl(maintainer.getPartition(),
+                    maintainer.getBucket(), null, 
DataIncrement.indexIncrement(indexFiles),
+                    CompactIncrement.emptyIncrement())));
+        }
+        return indexFiles.get(0).fileName();
+    }
+
     @Test
     public void planScanPushesLimitIntoPaimonSplitPlanning(@TempDir Path 
warehouse) throws Exception {
         try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to