github-actions[bot] commented on code in PR #66176:
URL: https://github.com/apache/doris/pull/66176#discussion_r3668358964


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ExecuteCommand.java:
##########
@@ -77,6 +79,29 @@ public void run(ConnectContext ctx, StmtExecutor executor) 
throws Exception {
         }
         PrepareCommand prepareCommand = preparedStmtCtx.command;
         LogicalPlan logicalPlan = prepareCommand.getLogicalPlan();
+        LogicalPlan relationRoot = logicalPlan;
+        if (logicalPlan instanceof InsertIntoTableCommand) {
+            relationRoot = ((InsertIntoTableCommand) 
logicalPlan).getLogicalQuery();
+        } else if (logicalPlan instanceof InsertOverwriteTableCommand) {
+            relationRoot = ((InsertOverwriteTableCommand) 
logicalPlan).getLogicalQuery();
+        } else if (logicalPlan instanceof UpdateCommand) {
+            relationRoot = ((UpdateCommand) logicalPlan).getLogicalQuery();
+        } else if (logicalPlan instanceof Command) {

Review Comment:
   [P1] Reset every relation-bearing prepared command
   
   This generic branch includes relation-bearing DML that retains source plans: 
`DeleteFromUsingCommand.logicalQuery` is executed through an insert command, 
and top-level `MergeIntoCommand` reuses its `source` for both OLAP and Iceberg 
targets. Either source can contain a Paimon `@options` relation, but neither is 
traversed here, so repeated EXECUTEs keep the snapshot resolved by the first 
execution. Please introduce a common relation-root contract (or explicitly 
cover both commands) and test repeated DELETE USING and MERGE after advancing 
the Paimon source.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java:
##########
@@ -106,6 +106,32 @@ public Table getPaimonTable(Optional<MvccSnapshot> 
snapshot) {
         }
     }
 
+    public Table getPaimonTable(TableScanParams scanParams) {
+        if (scanParams != null && scanParams.isOptions()) {
+            Map<String, String> options = scanParams.getMapParams();
+            Table statementTable = 
getPaimonTable(MvccUtil.getSnapshotFromContext(this));
+            Table resolutionTable = 
PaimonScanParams.usesStatementSnapshot(options)
+                    ? statementTable
+                    : getBasePaimonTable();
+            Map<String, String> resolvedOptions = 
scanParams.getOrResolveMapParams(
+                    relationOptions -> 
PaimonScanParams.resolveOptions(resolutionTable, relationOptions));
+            // Startup options are normalized to an immutable snapshot before 
schema binding. The
+            // scan phase reuses that exact resolution instead of consulting a 
mutable tag or clock.
+            Table table = PaimonScanParams.selectsSchema(resolvedOptions)

Review Comment:
   [P1] Preserve current schema when pinning latest state
   
   For selector-free `default`/`latest`/`latest-full`/`full`, resolution starts 
from the statement table, which is intentionally a pinned snapshot followed by 
`copyWithLatestSchema()`. File-creation-time resolution similarly pins the 
latest snapshot while native Paimon keeps the current row type. Both paths then 
carry a `scan.snapshot-id` into this condition, rebuild from the base table, 
and time-travel to the data snapshot's older schema. After a schema-only ADD 
COLUMN, these OPTIONS forms can reject a column native latest/file-creation 
reads expose as null. Please preserve latest-schema provenance for both pinned 
states and add schema-only evolution regressions.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java:
##########
@@ -0,0 +1,391 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.paimon;
+
+import org.apache.doris.analysis.TableScanParams;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.options.ConfigOption;
+import org.apache.paimon.options.FallbackKey;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.snapshot.FullCompactedStartingScanner;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validation and application rules for relation-scoped Paimon scan parameters.
+ */
+public final class PaimonScanParams {
+    private static final String PINNED_FILE_CREATION_TIME =
+            "doris.internal.paimon.file-creation-time-millis";
+    private static final String PINNED_EMPTY_SCAN = 
"doris.internal.paimon.empty-scan";
+
+    private static final Set<String> QUERY_OPTION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_MODE.key(),
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key(),
+            CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+            CoreOptions.SCAN_PLAN_SORT_PARTITION.key());
+
+    private static final Set<String> STARTUP_POSITION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key());
+
+    private static final Set<String> INHERITED_READ_STATE_KEYS = 
inheritedReadStateKeys();
+
+    // FilesScan enumerates the latest partitions before applying its 
range-aware per-partition scan,
+    // so it cannot safely read a range when a partition in that range has 
since been dropped.
+    private static final Set<String> INCREMENTAL_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "partitions", "ro", "row_tracking");
+
+    private static final Set<String> PAIMON_READER_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "row_tracking");
+
+    private static final Set<String> OPTIONS_SYSTEM_TABLES = ImmutableSet.of(
+            // A system table may advertise OPTIONS only when every 
row-producing stage observes
+            // the selected snapshot; files and buckets still consult latest 
metadata internally.
+            "audit_log", "binlog", "manifests", "partitions", "ro",
+            "row_tracking", "table_indexes");
+
+    private PaimonScanParams() {
+    }
+
+    public static void validateOptions(Map<String, String> options) {
+        if (options.containsKey(CoreOptions.SCAN_FALLBACK_BRANCH.key())) {
+            throw new IllegalArgumentException("Paimon query option '"
+                    + CoreOptions.SCAN_FALLBACK_BRANCH.key()
+                    + "' is not supported because it requires rebuilding the 
table through the catalog factory.");
+        }
+
+        Set<String> unsupported = options.keySet().stream()
+                .filter(key -> !QUERY_OPTION_KEYS.contains(key))
+                .collect(Collectors.toSet());
+        if (!unsupported.isEmpty()) {
+            throw new IllegalArgumentException("Unsupported Paimon query 
option(s): " + unsupported);
+        }
+
+        String scanMode = options.get(CoreOptions.SCAN_MODE.key());
+        if ("from-creation-timestamp".equalsIgnoreCase(scanMode)
+                && options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()) == 
null) {
+            // Paimon 1.3.1 does not validate this newer mode, but its 
starting scanner
+            // requires the creation timestamp and otherwise fails after 
analysis.
+            throw new IllegalArgumentException("Paimon scan mode 
'from-creation-timestamp' requires query option '"
+                    + CoreOptions.SCAN_CREATION_TIME_MILLIS.key() + "'.");
+        }
+
+        long positionCount = 
options.keySet().stream().filter(STARTUP_POSITION_KEYS::contains).count();
+        if (positionCount > 1) {
+            throw new IllegalArgumentException(
+                    "Only one Paimon startup position can be specified: " + 
STARTUP_POSITION_KEYS);
+        }
+
+        if (options.containsKey(CoreOptions.SCAN_MODE.key()) && positionCount 
== 1) {
+            String position = options.keySet().stream()
+                    .filter(STARTUP_POSITION_KEYS::contains)
+                    .findFirst()
+                    .get();
+            String mode = scanMode.toLowerCase(Locale.ROOT);
+            if (!isCompatibleStartupMode(position, mode)) {
+                throw new IllegalArgumentException("Paimon scan mode '" + mode
+                        + "' is incompatible with startup position '" + 
position + "'.");
+            }
+        }
+    }
+
+    public static Table applyOptions(Table table, Map<String, String> options) 
{
+        Map<String, String> tableOptions = userOptions(options);
+        validateOptions(tableOptions);
+        Map<String, String> isolatedOptions = new HashMap<>(tableOptions);
+        if (hasStartupOptions(tableOptions)) {
+            // Startup mode, position, and range form one inherited state 
family in Paimon. Clear
+            // absent members so one relation cannot accidentally reuse 
another relation's read state.
+            INHERITED_READ_STATE_KEYS.stream()
+                    .filter(key -> !tableOptions.containsKey(key))
+                    .forEach(key -> isolatedOptions.put(key, null));
+        }
+        return table.copy(isolatedOptions);
+    }
+
+    private static Set<String> inheritedReadStateKeys() {
+        ImmutableSet.Builder<String> keys = ImmutableSet.builder();
+        for (ConfigOption<?> option : Arrays.asList(
+                CoreOptions.SCAN_TIMESTAMP,
+                CoreOptions.SCAN_TIMESTAMP_MILLIS,
+                CoreOptions.SCAN_WATERMARK,
+                CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_SNAPSHOT_ID,
+                CoreOptions.SCAN_TAG_NAME,
+                CoreOptions.SCAN_VERSION,
+                CoreOptions.SCAN_MODE,
+                CoreOptions.SCAN_BOUNDED_WATERMARK,
+                CoreOptions.INCREMENTAL_BETWEEN,
+                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
+                CoreOptions.INCREMENTAL_BETWEEN_SCAN_MODE,
+                CoreOptions.INCREMENTAL_TO_AUTO_TAG)) {
+            keys.add(option.key());
+            for (FallbackKey fallbackKey : option.fallbackKeys()) {
+                keys.add(fallbackKey.getKey());
+            }
+        }
+        return keys.build();
+    }
+
+    public static boolean hasStartupOptions(Map<String, String> options) {
+        return options.containsKey(CoreOptions.SCAN_MODE.key())
+                || 
options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains);
+    }
+
+    public static boolean selectsSchema(Map<String, String> options) {
+        return hasStartupOptions(options);
+    }
+
+    public static boolean usesStatementSnapshot(Map<String, String> options) {
+        if 
(options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains)) {
+            return false;
+        }
+        String mode = options.get(CoreOptions.SCAN_MODE.key());
+        return mode == null
+                || "default".equalsIgnoreCase(mode)
+                || "latest".equalsIgnoreCase(mode)
+                || "latest-full".equalsIgnoreCase(mode)
+                || "full".equalsIgnoreCase(mode);
+    }
+
+    public static Map<String, String> resolveOptions(Table table, Map<String, 
String> options) {
+        validateOptions(options);
+        if (!hasStartupOptions(options)) {
+            return options;
+        }
+        if (usesStatementSnapshot(options)) {
+            String pinnedSnapshotId = 
table.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key());
+            if (pinnedSnapshotId != null) {
+                return resolvedSnapshotOptions(options, pinnedSnapshotId);
+            }
+        }
+        if (!(table instanceof FileStoreTable)) {
+            throw new IllegalArgumentException("Paimon startup options require 
a file-store data table.");
+        }
+        FileStoreTable fileStoreTable = (FileStoreTable) table;
+        if 
(options.containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())) {
+            return resolveFileCreationTime(
+                    options,
+                    fileStoreTable,
+                    
Long.parseLong(options.get(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())));
+        }
+        if (options.containsKey(CoreOptions.SCAN_CREATION_TIME_MILLIS.key())) {
+            long creationTime = 
Long.parseLong(options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()));
+            Long previousSnapshotId = TimeTravelUtil.earlierThanTimeMills(
+                    fileStoreTable.snapshotManager(),
+                    fileStoreTable.changelogManager(),
+                    creationTime,
+                    fileStoreTable.coreOptions().changelogLifecycleDecoupled(),
+                    true);
+            if (previousSnapshotId != null
+                    && 
fileStoreTable.snapshotManager().snapshotExists(previousSnapshotId + 1)) {
+                return resolvedSnapshotOptions(options, 
String.valueOf(previousSnapshotId + 1));
+            }
+            return resolveFileCreationTime(options, fileStoreTable, 
creationTime);
+        }
+
+        Table selectedTable = applyOptions(table, options);
+        if 
("compacted-full".equalsIgnoreCase(options.get(CoreOptions.SCAN_MODE.key()))) {
+            Long snapshotId = compactedFullSnapshotId((FileStoreTable) 
selectedTable);
+            return snapshotId == null
+                    ? resolvedEmptyOptions(options)
+                    : resolvedSnapshotOptions(options, 
String.valueOf(snapshotId));
+        }
+        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest((FileStoreTable) 
selectedTable);
+        if (snapshot == null) {
+            return resolvedEmptyOptions(options);
+        }
+        String tagName = selectedTagName(options, (FileStoreTable) 
selectedTable);
+        if (tagName != null) {
+            // A tag owns a retained Snapshot copy even after the ordinary 
snapshot file expires.
+            // Keep the canonical tag selector so planning reads that retained 
metadata path.
+            return resolvedTagOptions(options, tagName);

Review Comment:
   [P1] Pin the selected tag state, not only its mutable name
   
   This keeps `scan.tag-name` so expired ordinary snapshots remain readable, 
but both schema binding and scan initialization later call `applyOptions()` on 
fresh base tables. Each Paimon `Table.copy` resolves the tag name again, so a 
concurrent tag replacement can make one statement bind the old tag schema and 
execute the new tag snapshot. Please retain an immutable selected tagged 
table/snapshot that both phases reuse, and test replacing a tag between output 
binding and split initialization.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -559,8 +597,17 @@ boolean shouldForceJniForSystemTable() {
         }
         PaimonSysExternalTable paimonSysExternalTable = 
(PaimonSysExternalTable) externalTable;
         String sysTableType = paimonSysExternalTable.getSysTableType();
-        return PAIMON_BINLOG_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType)
-                || 
PAIMON_AUDIT_LOG_SYSTEM_TABLE_TYPE.equalsIgnoreCase(sysTableType);
+        return PaimonScanParams.requiresPaimonReader(sysTableType);

Review Comment:
   [P1] Make the V1 scanner honor required JNI routing
   
   This forces a logical split and FE sets `reader_type=PAIMON_JNI`, but when 
FileScannerV2 is disabled the V1 `FileScanner` ignores that field and chooses 
`PaimonCppReader` whenever `enable_paimon_cpp_reader=true`. The C++ reader does 
not consume the serialized processed table or the required 
row-tracking/audit/binlog transformations, so these queries can execute on the 
wrong table semantics. Please make V1 honor `PAIMON_JNI` before the session 
override and add V1-disabled routing coverage.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -610,11 +657,37 @@ public List<org.apache.paimon.table.source.Split> 
getPaimonSplitFromAPI() throws
         long startTime = System.currentTimeMillis();
         try {
             Table paimonTable = getProcessedTable();
+            Map<String, String> resolvedOptions = scanParams == null
+                    ? Collections.emptyMap()
+                    : 
scanParams.getResolvedMapParams().orElse(Collections.emptyMap());
+            if (PaimonScanParams.isPinnedEmptyScan(resolvedOptions)) {
+                return Collections.emptyList();
+            }
+            Optional<Long> fileCreationTime = 
PaimonScanParams.getPinnedFileCreationTime(resolvedOptions);
+            if (fileCreationTime.isPresent()) {
+                if (!(paimonTable instanceof FileStoreTable)) {
+                    throw new UserException("Paimon file-creation OPTIONS 
require a data table.");
+                }
+                FileStoreTable fileStoreTable = (FileStoreTable) paimonTable;
+                SnapshotReader snapshotReader = 
fileStoreTable.newSnapshotReader()

Review Comment:
   [P1] Preserve Paimon query authorization on the direct snapshot path
   
   Normal `DataTableBatchScan.plan()` calls Paimon's `authQuery()` with the 
projected field names; for REST catalogs with `query-auth.enabled`, that 
reaches `Catalog.authTableQuery()` and can deny the table or columns. This 
file-creation path instead constructs `SnapshotReader` directly and returns its 
splits, bypassing that authorization hook entirely. A user denied by the normal 
projected query can therefore plan the same files through 
`@options('scan.file-creation-time-millis'=...)`. Please run the standard 
table/column authorization before this direct read, or retain it through a scan 
API that supports the pinned manifest filter, with an equivalent-denial REST 
test.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java:
##########
@@ -0,0 +1,391 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.paimon;
+
+import org.apache.doris.analysis.TableScanParams;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.options.ConfigOption;
+import org.apache.paimon.options.FallbackKey;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.snapshot.FullCompactedStartingScanner;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validation and application rules for relation-scoped Paimon scan parameters.
+ */
+public final class PaimonScanParams {
+    private static final String PINNED_FILE_CREATION_TIME =
+            "doris.internal.paimon.file-creation-time-millis";
+    private static final String PINNED_EMPTY_SCAN = 
"doris.internal.paimon.empty-scan";
+
+    private static final Set<String> QUERY_OPTION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_MODE.key(),
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key(),
+            CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+            CoreOptions.SCAN_PLAN_SORT_PARTITION.key());
+
+    private static final Set<String> STARTUP_POSITION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key());
+
+    private static final Set<String> INHERITED_READ_STATE_KEYS = 
inheritedReadStateKeys();
+
+    // FilesScan enumerates the latest partitions before applying its 
range-aware per-partition scan,
+    // so it cannot safely read a range when a partition in that range has 
since been dropped.
+    private static final Set<String> INCREMENTAL_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "partitions", "ro", "row_tracking");
+
+    private static final Set<String> PAIMON_READER_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "row_tracking");
+
+    private static final Set<String> OPTIONS_SYSTEM_TABLES = ImmutableSet.of(
+            // A system table may advertise OPTIONS only when every 
row-producing stage observes
+            // the selected snapshot; files and buckets still consult latest 
metadata internally.
+            "audit_log", "binlog", "manifests", "partitions", "ro",
+            "row_tracking", "table_indexes");
+
+    private PaimonScanParams() {
+    }
+
+    public static void validateOptions(Map<String, String> options) {
+        if (options.containsKey(CoreOptions.SCAN_FALLBACK_BRANCH.key())) {
+            throw new IllegalArgumentException("Paimon query option '"
+                    + CoreOptions.SCAN_FALLBACK_BRANCH.key()
+                    + "' is not supported because it requires rebuilding the 
table through the catalog factory.");
+        }
+
+        Set<String> unsupported = options.keySet().stream()
+                .filter(key -> !QUERY_OPTION_KEYS.contains(key))
+                .collect(Collectors.toSet());
+        if (!unsupported.isEmpty()) {
+            throw new IllegalArgumentException("Unsupported Paimon query 
option(s): " + unsupported);
+        }
+
+        String scanMode = options.get(CoreOptions.SCAN_MODE.key());
+        if ("from-creation-timestamp".equalsIgnoreCase(scanMode)
+                && options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()) == 
null) {
+            // Paimon 1.3.1 does not validate this newer mode, but its 
starting scanner
+            // requires the creation timestamp and otherwise fails after 
analysis.
+            throw new IllegalArgumentException("Paimon scan mode 
'from-creation-timestamp' requires query option '"
+                    + CoreOptions.SCAN_CREATION_TIME_MILLIS.key() + "'.");
+        }
+
+        long positionCount = 
options.keySet().stream().filter(STARTUP_POSITION_KEYS::contains).count();
+        if (positionCount > 1) {
+            throw new IllegalArgumentException(
+                    "Only one Paimon startup position can be specified: " + 
STARTUP_POSITION_KEYS);
+        }
+
+        if (options.containsKey(CoreOptions.SCAN_MODE.key()) && positionCount 
== 1) {
+            String position = options.keySet().stream()
+                    .filter(STARTUP_POSITION_KEYS::contains)
+                    .findFirst()
+                    .get();
+            String mode = scanMode.toLowerCase(Locale.ROOT);
+            if (!isCompatibleStartupMode(position, mode)) {
+                throw new IllegalArgumentException("Paimon scan mode '" + mode
+                        + "' is incompatible with startup position '" + 
position + "'.");
+            }
+        }
+    }
+
+    public static Table applyOptions(Table table, Map<String, String> options) 
{
+        Map<String, String> tableOptions = userOptions(options);
+        validateOptions(tableOptions);
+        Map<String, String> isolatedOptions = new HashMap<>(tableOptions);
+        if (hasStartupOptions(tableOptions)) {
+            // Startup mode, position, and range form one inherited state 
family in Paimon. Clear
+            // absent members so one relation cannot accidentally reuse 
another relation's read state.
+            INHERITED_READ_STATE_KEYS.stream()
+                    .filter(key -> !tableOptions.containsKey(key))
+                    .forEach(key -> isolatedOptions.put(key, null));
+        }
+        return table.copy(isolatedOptions);
+    }
+
+    private static Set<String> inheritedReadStateKeys() {
+        ImmutableSet.Builder<String> keys = ImmutableSet.builder();
+        for (ConfigOption<?> option : Arrays.asList(
+                CoreOptions.SCAN_TIMESTAMP,
+                CoreOptions.SCAN_TIMESTAMP_MILLIS,
+                CoreOptions.SCAN_WATERMARK,
+                CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_SNAPSHOT_ID,
+                CoreOptions.SCAN_TAG_NAME,
+                CoreOptions.SCAN_VERSION,
+                CoreOptions.SCAN_MODE,
+                CoreOptions.SCAN_BOUNDED_WATERMARK,
+                CoreOptions.INCREMENTAL_BETWEEN,
+                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
+                CoreOptions.INCREMENTAL_BETWEEN_SCAN_MODE,
+                CoreOptions.INCREMENTAL_TO_AUTO_TAG)) {
+            keys.add(option.key());
+            for (FallbackKey fallbackKey : option.fallbackKeys()) {
+                keys.add(fallbackKey.getKey());
+            }
+        }
+        return keys.build();
+    }
+
+    public static boolean hasStartupOptions(Map<String, String> options) {
+        return options.containsKey(CoreOptions.SCAN_MODE.key())
+                || 
options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains);
+    }
+
+    public static boolean selectsSchema(Map<String, String> options) {
+        return hasStartupOptions(options);
+    }
+
+    public static boolean usesStatementSnapshot(Map<String, String> options) {
+        if 
(options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains)) {
+            return false;
+        }
+        String mode = options.get(CoreOptions.SCAN_MODE.key());
+        return mode == null
+                || "default".equalsIgnoreCase(mode)
+                || "latest".equalsIgnoreCase(mode)
+                || "latest-full".equalsIgnoreCase(mode)
+                || "full".equalsIgnoreCase(mode);
+    }
+
+    public static Map<String, String> resolveOptions(Table table, Map<String, 
String> options) {
+        validateOptions(options);
+        if (!hasStartupOptions(options)) {
+            return options;
+        }
+        if (usesStatementSnapshot(options)) {
+            String pinnedSnapshotId = 
table.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key());
+            if (pinnedSnapshotId != null) {
+                return resolvedSnapshotOptions(options, pinnedSnapshotId);
+            }
+        }
+        if (!(table instanceof FileStoreTable)) {
+            throw new IllegalArgumentException("Paimon startup options require 
a file-store data table.");
+        }
+        FileStoreTable fileStoreTable = (FileStoreTable) table;
+        if 
(options.containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())) {
+            return resolveFileCreationTime(
+                    options,
+                    fileStoreTable,
+                    
Long.parseLong(options.get(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())));
+        }
+        if (options.containsKey(CoreOptions.SCAN_CREATION_TIME_MILLIS.key())) {
+            long creationTime = 
Long.parseLong(options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()));
+            Long previousSnapshotId = TimeTravelUtil.earlierThanTimeMills(
+                    fileStoreTable.snapshotManager(),
+                    fileStoreTable.changelogManager(),
+                    creationTime,
+                    fileStoreTable.coreOptions().changelogLifecycleDecoupled(),
+                    true);
+            if (previousSnapshotId != null
+                    && 
fileStoreTable.snapshotManager().snapshotExists(previousSnapshotId + 1)) {
+                return resolvedSnapshotOptions(options, 
String.valueOf(previousSnapshotId + 1));
+            }
+            return resolveFileCreationTime(options, fileStoreTable, 
creationTime);
+        }
+
+        Table selectedTable = applyOptions(table, options);
+        if 
("compacted-full".equalsIgnoreCase(options.get(CoreOptions.SCAN_MODE.key()))) {
+            Long snapshotId = compactedFullSnapshotId((FileStoreTable) 
selectedTable);
+            return snapshotId == null
+                    ? resolvedEmptyOptions(options)
+                    : resolvedSnapshotOptions(options, 
String.valueOf(snapshotId));
+        }
+        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest((FileStoreTable) 
selectedTable);
+        if (snapshot == null) {
+            return resolvedEmptyOptions(options);
+        }
+        String tagName = selectedTagName(options, (FileStoreTable) 
selectedTable);
+        if (tagName != null) {
+            // A tag owns a retained Snapshot copy even after the ordinary 
snapshot file expires.
+            // Keep the canonical tag selector so planning reads that retained 
metadata path.
+            return resolvedTagOptions(options, tagName);
+        }
+        return resolvedSnapshotOptions(options, String.valueOf(snapshot.id()));
+    }
+
+    private static String selectedTagName(Map<String, String> options, 
FileStoreTable selectedTable) {
+        String tagName = options.get(CoreOptions.SCAN_TAG_NAME.key());
+        if (tagName != null) {
+            return tagName;
+        }
+        // Paimon normalizes a tag-valued scan.version while copying the 
selected table.
+        return selectedTable.options().get(CoreOptions.SCAN_TAG_NAME.key());
+    }
+
+    private static Long compactedFullSnapshotId(FileStoreTable table) {
+        CoreOptions coreOptions = table.coreOptions();
+        int deltaCommits = coreOptions.toConfiguration()
+                .getOptional(CoreOptions.FULL_COMPACTION_DELTA_COMMITS)
+                .orElse(1);
+        if (coreOptions.changelogProducer() == 
CoreOptions.ChangelogProducer.FULL_COMPACTION
+                || 
coreOptions.toConfiguration().contains(CoreOptions.FULL_COMPACTION_DELTA_COMMITS))
 {
+            return table.snapshotManager().pickOrLatest(snapshot ->
+                    snapshot.commitKind() == Snapshot.CommitKind.COMPACT
+                            && 
FullCompactedStartingScanner.isFullCompactedIdentifier(
+                                    snapshot.commitIdentifier(), 
deltaCommits));
+        }
+        // COMPACTED_FULL means the newest compact snapshot, which can be 
older than latest.
+        return table.snapshotManager().pickOrLatest(
+                snapshot -> snapshot.commitKind() == 
Snapshot.CommitKind.COMPACT);
+    }
+
+    private static Map<String, String> resolveFileCreationTime(
+            Map<String, String> options, FileStoreTable table, long 
creationTime) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        Optional<Snapshot> latestSnapshot = table.latestSnapshot();
+        if (latestSnapshot.isPresent()) {
+            resolved.put(CoreOptions.SCAN_SNAPSHOT_ID.key(),
+                    String.valueOf(latestSnapshot.get().id()));
+        } else {
+            resolved.put(PINNED_EMPTY_SCAN, Boolean.TRUE.toString());
+        }
+        // Paimon's file-creation scanner consults latest lazily. Keep its 
filter as internal
+        // metadata while replacing the live latest lookup with the snapshot 
fixed above.
+        resolved.put(PINNED_FILE_CREATION_TIME, String.valueOf(creationTime));
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedSnapshotOptions(
+            Map<String, String> options, String snapshotId) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        resolved.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId);
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedTagOptions(Map<String, String> 
options, String tagName) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        resolved.put(CoreOptions.SCAN_TAG_NAME.key(), tagName);
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedEmptyOptions(Map<String, 
String> options) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        // An empty table is also a statement state. Remember it explicitly so 
a commit between
+        // binding and split planning cannot turn the relation into a 
non-empty scan.
+        resolved.put(PINNED_EMPTY_SCAN, Boolean.TRUE.toString());
+        return resolved;
+    }
+
+    private static Map<String, String> userOptions(Map<String, String> 
options) {
+        return options.entrySet().stream()
+                .filter(entry -> 
!entry.getKey().startsWith("doris.internal.paimon."))
+                .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
+    }
+
+    public static Optional<Long> getPinnedFileCreationTime(Map<String, String> 
options) {
+        return 
Optional.ofNullable(options.get(PINNED_FILE_CREATION_TIME)).map(Long::parseLong);
+    }
+
+    public static boolean isPinnedEmptyScan(Map<String, String> options) {
+        return Boolean.parseBoolean(options.get(PINNED_EMPTY_SCAN));
+    }
+
+    public static Map<String, String> isolateIncrementalRead(Map<String, 
String> incrementalOptions) {
+        Map<String, String> isolatedOptions = new HashMap<>();
+        INHERITED_READ_STATE_KEYS.forEach(key -> isolatedOptions.put(key, 
null));
+        isolatedOptions.putAll(incrementalOptions);
+        return isolatedOptions;
+    }
+
+    private static boolean isCompatibleStartupMode(String position, String 
mode) {
+        if ("default".equals(mode)) {
+            return true;
+        }
+        if (CoreOptions.SCAN_TIMESTAMP.key().equals(position)
+                || CoreOptions.SCAN_TIMESTAMP_MILLIS.key().equals(position)) {
+            return "from-timestamp".equals(mode);
+        }
+        if (CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key().equals(position)) 
{
+            return "from-file-creation-time".equals(mode);
+        }
+        if (CoreOptions.SCAN_CREATION_TIME_MILLIS.key().equals(position)) {
+            return "from-creation-timestamp".equals(mode);
+        }
+        return "from-snapshot".equals(mode)

Review Comment:
   [P2] Canonicalize numeric scan.version before mode validation
   
   A digit-only `scan.version` is Paimon's alias for `scan.snapshot-id`; Paimon 
1.3.1 converts it during `Table.copy()` before validating that 
`from-snapshot-full` has a snapshot ID. This pre-validation looks only at the 
raw key, so Doris rejects a combination the bundled dependency accepts and 
implements. Please canonicalize the version first (or classify a digit-only 
value as snapshot ID here) and add a positive full-snapshot test.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonSysExternalTable.java:
##########
@@ -133,6 +134,29 @@ public Table getSysPaimonTable() {
         return paimonSysTable;
     }
 
+    public Table getSysPaimonTable(TableScanParams scanParams) {
+        Table table = getSysPaimonTable();
+        if (scanParams == null || !scanParams.isOptions()) {
+            return table;
+        }
+        Map<String, String> resolvedOptions = scanParams.getOrResolveMapParams(
+                options -> 
PaimonScanParams.resolveOptions(sourceTable.getBasePaimonTable(), options));

Review Comment:
   [P1] Resolve system-table latest OPTIONS from the statement snapshot
   
   Selector-free system-table OPTIONS are classified as latest statement reads, 
but this resolver uses `sourceTable.getBasePaimonTable()` directly. Meta-table 
binding returns before the normal source-table `loadSnapshots()` path, whereas 
data-table OPTIONS resolve from the statement-pinned table. An external commit 
between binding data and system aliases can thus make one statement observe 
different source commits. Please load/reuse the source table's MVCC snapshot 
here and add a mixed data/system-alias consistency test.



##########
regression-test/suites/external_table_p0/PAIMON_ICEBERG_READ_WRITE_P0_COVERAGE.md:
##########
@@ -0,0 +1,102 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Paimon / Iceberg Read and Write P0 Coverage
+
+## Scope
+
+This matrix compares the supported Doris surface with the format capabilities 
documented by
+[Apache Paimon](https://paimon.apache.org/docs/1.0/), its
+[ecosystem matrix](https://paimon.apache.org/docs/master/ecosystem/), and 
Iceberg's
+[write](https://iceberg.apache.org/docs/latest/spark-writes/) and
+[evolution](https://iceberg.apache.org/docs/latest/evolution/) documentation. 
A format feature is a Doris P0 contract
+only when Doris supports it. Unsupported write or format boundaries require 
deterministic negative
+coverage and no metadata or data mutation.
+
+Doris currently documents Paimon data access as read-only. Master also 
supports Paimon catalog,
+database and table metadata creation/deletion, but it has no Paimon data sink. 
Iceberg supports read,
+DDL, INSERT, INSERT OVERWRITE, CTAS and, for compatible V2/V3 tables, DELETE, 
UPDATE and MERGE INTO.
+
+## Audit result
+
+The master inventory before this change contained 41 Paimon suites and 145 
Iceberg suites, including
+35 Iceberg write suites and four dedicated Iceberg DML suites. Iceberg P0 is 
complete for the Doris-
+supported read/write surface: its existing matrices cover file-format 
versions, delete encodings,
+evolution, time travel, DDL, distributed writes, row-level DML, failure 
atomicity and unsupported
+boundaries.
+
+Paimon P0 was incomplete. No suite selected a `merge-engine`, and write 
rejection was not checked
+across all DML shapes with pre/post data and snapshot invariants. PM01-PM03 
below close those gaps.
+PM04 exposes one remaining product defect as an opt-in negative regression: 
failed Paimon CTAS leaves
+target metadata. Streaming writes, overwrite, delete/update and merge listed 
as unsupported by the
+Paimon ecosystem matrix are boundary tests rather than positive Doris P0 
contracts.
+
+## Risks
+
+| ID | Risk | Source | Impact | Priority |
+| --- | --- | --- | --- | --- |
+| R01 | Append and primary-key tables are planned with the wrong merge 
semantics | Black box: Paimon table models | Silent wrong results | P0 |
+| R02 | Deduplicate, partial-update, aggregation and first-row produce the 
same result for duplicate keys | White box: LSM sorted-run merge | Silent wrong 
results | P0 |
+| R03 | Fixed and dynamic bucket tables expose duplicate or stale 
cross-partition keys | Paimon data distribution | Silent wrong results | P0 |
+| R04 | Automatic and forced-JNI routing disagree on Paimon merged rows, or 
native raw-file reads disagree where conversion is supported | Doris split 
routing | Query correctness | P0 |
+| R05 | A rejected Paimon write creates a snapshot, changes data or leaves a 
CTAS table | Doris read-only boundary | Data or metadata mutation | P0 |
+| R06 | Iceberg schema or partition evolution binds old field/spec IDs during 
current or historical reads | Iceberg evolution | Silent wrong results | P0 |
+| R07 | Position/equality deletes or V3 deletion vectors are applied to the 
wrong file or snapshot | Iceberg row-level deletes | Deleted data visible or 
live data lost | P0 |
+| R08 | Iceberg writes lose rows, route them to the wrong transform, or 
publish partial failed commits | Doris distributed Iceberg sink | Data loss or 
corruption | P0 |
+| R09 | Snapshot, tag or branch reads/writes leak schema or data across 
references | Both formats | Historical data corruption | P0 |
+| R10 | Unsupported format, DML mode or CTAS mutates state before rejection | 
Capability boundary | Partial commits or orphan metadata | P0 |
+
+## Feature matrix
+
+| Format | Capability | P0 status | Main suites |
+| --- | --- | --- | --- |
+| Paimon | Append table, partitioned table, primitive and nested types | 
Covered | `test_paimon_catalog`, `test_paimon_partition_table`, 
`test_paimon_full_schema_change` |
+| Paimon | Primary-key deduplicate, partial-update, aggregation and first-row 
| Covered | `test_paimon_merge_engine_matrix` |
+| Paimon | Fixed bucket, dynamic bucket and cross-partition update | Covered | 
`test_paimon_merge_engine_matrix`, `test_paimon_partition_pk_delete_refs` |
+| Paimon | Parquet/ORC and mixed-format reads; JNI/native parity | Covered | 
`test_paimon_merge_engine_matrix`, `paimon_tb_mix_format`, 
`test_paimon_cpp_reader` |

Review Comment:
   [P2] Require evidence that the parity runs use different readers
   
   The cited `test_paimon_cpp_reader` toggles `enable_paimon_cpp_reader`, but 
that flag is consumed only by legacy V1 while FileScannerV2 defaults on. The 
other cited suites compare forced JNI with automatic routing without asserting 
that the automatic run contains any native split, so all comparisons can 
exercise JNI and still pass. Please add EXPLAIN assertions for JNI versus 
`paimonNativeReadSplits`/NATIVE (as `test_paimon_partition_pk_delete_refs` 
does), or cite and extend a suite that proves both routes before marking parity 
covered.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalTablePreloadInfo.java:
##########
@@ -50,6 +50,7 @@ public boolean hasNonLatestRelation() {
     }
 
     public boolean shouldPreloadLatestSnapshot() {
-        return hasLatestOnlyRelation && !hasNonLatestRelation;
+        // A historical alias has independent scan state and must not cancel 
the latest alias warmup.
+        return hasLatestOnlyRelation;

Review Comment:
   [P1] Preserve the historical alias's snapshot when preloading latest
   
   When one statement references this table through both a latest alias and a 
historical alias, returning true here makes `PreloadExternalMetadata` install 
the latest snapshot in `StatementContext.snapshots`. That map is keyed only by 
table identity, and `loadSnapshots()` never replaces an existing entry, so the 
later explicit snapshot/tag load is ignored. The historical alias can 
consequently bind latest schema/partitions (or fail after a historical 
rename/drop). Please either skip this preload when any non-latest alias exists 
or key the cached snapshot by relation/selector, and extend the mixed-alias 
test through historical binding rather than only asserting that latest preload 
ran.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java:
##########
@@ -121,6 +141,91 @@ public Map<String, String> getCatalogOptionsMap() {
         }
     }
 
+    public Map<String, String> getTableOptionsMap() {
+        return tableOptionsMap;
+    }
+
+    /**
+     * Returns Catalog table options which are not explicitly configured by 
the Paimon table.
+     *
+     * <p>The comparison is based on Paimon {@link ConfigOption}s so canonical 
and fallback keys
+     * follow the same precedence rule.
+     */
+    public Map<String, String> getTableOptionsForCopy(Map<String, String> 
currentTableOptions) {
+        if (tableOptionsMap.isEmpty() || currentTableOptions.isEmpty()) {
+            return tableOptionsMap;
+        }
+
+        Options existingOptions = new Options(currentTableOptions);
+        Map<String, String> optionsForCopy = new LinkedHashMap<>();
+        tableOptionsMap.forEach((key, value) -> {
+            ConfigOption<?> option = SUPPORTED_TABLE_OPTIONS.find(key);
+            if (!existingOptions.contains(option)) {
+                optionsForCopy.put(key, value);
+            }
+        });
+        return Collections.unmodifiableMap(optionsForCopy);
+    }
+
+    public static boolean isTableOptionProperty(String key) {
+        return key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX);
+    }
+
+    private Map<String, String> extractTableOptions() {
+        Map<String, String> tableOptions = new LinkedHashMap<>();
+        origProps.forEach((key, value) -> {
+            if (isTableOptionProperty(key)) {
+                String tableOptionKey = 
key.substring(TABLE_OPTION_PREFIX.length());
+                if (StringUtils.isBlank(tableOptionKey)) {
+                    throw new IllegalArgumentException(
+                            "Paimon table option name must not be empty after 
prefix " + TABLE_OPTION_PREFIX);
+                }
+                validateTableOption(tableOptionKey, value);
+                tableOptions.put(tableOptionKey, value);
+            }
+        });
+        return Collections.unmodifiableMap(tableOptions);
+    }
+
+    private void validateTableOption(String key, String value) {
+        ConfigOption<?> option = SUPPORTED_TABLE_OPTIONS.find(key);
+        if (option == null) {
+            throw new IllegalArgumentException("Unsupported Paimon table 
option '" + key
+                    + "' for the bundled Paimon version");
+        }
+
+        try {
+            new Options(Collections.singletonMap(key, value)).get(option);
+        } catch (IllegalArgumentException e) {
+            throw new IllegalArgumentException("Invalid value for Paimon table 
option '" + key + "': "
+                    + e.getMessage(), e);
+        }
+    }
+
+    private static final class SupportedTableOptions {
+        /** Canonical and fallback option names which support direct lookup. */
+        private final Map<String, ConfigOption<?>> exactOptions;
+
+        private SupportedTableOptions(Map<String, ConfigOption<?>> 
exactOptions) {
+            this.exactOptions = exactOptions;
+        }
+
+        private static SupportedTableOptions build() {
+            Map<String, ConfigOption<?>> exactOptions = new HashMap<>();
+            for (ConfigOption<?> option : CoreOptions.getOptions()) {

Review Comment:
   [P1] Restrict catalog defaults to options safe across every internal copy
   
   This allowlist includes every reflected `CoreOptions` entry, while 
validation only parses its type. Canonical immutable keys such as 
`merge-engine` fail on the later `table.copy()`, while fallback aliases can 
evade that raw-key guard: `deduplicate.ignore-delete=true` is still resolved by 
Paimon and can suppress retracts, changing visible PK rows. Even copy-legal 
startup defaults are unsafe: `scan.mode=latest` later conflicts with the 
projection loader's snapshot ID, while a catalog snapshot ID is silently 
replaced by live latest. Please canonicalize names and define a copy- and 
lifecycle-safe allowlist that excludes immutable aliases and read-state 
selectors, tested through real nonempty-table initialization.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java:
##########
@@ -53,13 +59,25 @@ public abstract class AbstractPaimonProperties extends 
MetastoreProperties {
     public abstract String getPaimonCatalogType();
 
     private static final String USER_PROPERTY_PREFIX = "paimon.";
+    private static final String DORIS_JNI_PROPERTY_PREFIX = "paimon.jni.";
+    /** The suffix after this prefix is passed to Paimon as a dynamic table 
option. */
+    public static final String TABLE_OPTION_PREFIX = "paimon.table-option.";
+    private static final SupportedTableOptions SUPPORTED_TABLE_OPTIONS = 
SupportedTableOptions.build();
+
+    private Map<String, String> tableOptionsMap = Collections.emptyMap();
 
     protected AbstractPaimonProperties(Map<String, String> props) {
         super(Type.PAIMON, props);
     }
 
     public abstract Catalog initializeCatalog(String catalogName, 
List<StorageProperties> storagePropertiesList);
 
+    @Override
+    public void initNormalizeAndCheckProps() {
+        super.initNormalizeAndCheckProps();
+        tableOptionsMap = extractTableOptions();

Review Comment:
   [P1] Roll back catalog properties on these validation failures
   
   `ALTER CATALOG` first applies the new map through `tryModifyCatalogProps()` 
and only then calls `checkProperties()`. The rollback in 
`CatalogMgr.replayAlterCatalogProps` catches `DdlException`, but this new 
validation throws `IllegalArgumentException` (and the metastore property path 
does not translate it). A rejected value such as a non-integer 
`read.batch-size` therefore remains in the running catalog even though no edit 
log was written. Please translate these failures or roll back on every 
validation exception, and assert the post-failure property map in an ALTER test.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -95,15 +100,15 @@ public class PaimonScanNode extends FileQueryScanNode {
     private static final String DORIS_END_TIMESTAMP = "endTimestamp";
     private static final String DORIS_INCREMENTAL_BETWEEN_SCAN_MODE = 
"incrementalBetweenScanMode";
     private static final String PAIMON_PROPERTY_PREFIX = "paimon.";
-    private static final String DORIS_ENABLE_JNI_IO_MANAGER = 
"doris.enable_jni_io_manager";
-    private static final String DORIS_JNI_IO_MANAGER_TMP_DIR = 
"doris.jni_io_manager.tmp_dir";
-    private static final String DORIS_JNI_IO_MANAGER_IMPL_CLASS = 
"doris.jni_io_manager.impl_class";
+    private static final String DORIS_ENABLE_FILE_READER_ASYNC = 
"jni.enable_file_reader_async";
+    private static final String DORIS_ENABLE_JNI_IO_MANAGER = 
"jni.enable_jni_io_manager";
+    private static final String DORIS_JNI_IO_MANAGER_TMP_DIR = 
"jni.io_manager.tmp_dir";
+    private static final String DORIS_JNI_IO_MANAGER_IMPL_CLASS = 
"jni.io_manager.impl_class";
     private static final List<String> BACKEND_PAIMON_OPTIONS = Arrays.asList(
             DORIS_ENABLE_JNI_IO_MANAGER,
             DORIS_JNI_IO_MANAGER_TMP_DIR,
-            DORIS_JNI_IO_MANAGER_IMPL_CLASS);
-    private static final String PAIMON_BINLOG_SYSTEM_TABLE_TYPE = "binlog";
-    private static final String PAIMON_AUDIT_LOG_SYSTEM_TABLE_TYPE = 
"audit_log";
+            DORIS_JNI_IO_MANAGER_IMPL_CLASS,
+            DORIS_ENABLE_FILE_READER_ASYNC);

Review Comment:
   [P2] Make the forwarded async-reader option effective
   
   This adds `jni.enable_file_reader_async` to the backend options, but neither 
the Java scanner nor another consumer reads 
`paimon.jni.enable_file_reader_async`; reader construction still uses Paimon's 
unrelated `file-reader-async-threshold`. Toggling the catalog boolean therefore 
has no effect. Please map it into the actual reader configuration before 
`newReadBuilder()`, with true/false construction tests, or remove the 
ineffective advertised option.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java:
##########
@@ -0,0 +1,391 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.paimon;
+
+import org.apache.doris.analysis.TableScanParams;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.options.ConfigOption;
+import org.apache.paimon.options.FallbackKey;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.snapshot.FullCompactedStartingScanner;
+import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validation and application rules for relation-scoped Paimon scan parameters.
+ */
+public final class PaimonScanParams {
+    private static final String PINNED_FILE_CREATION_TIME =
+            "doris.internal.paimon.file-creation-time-millis";
+    private static final String PINNED_EMPTY_SCAN = 
"doris.internal.paimon.empty-scan";
+
+    private static final Set<String> QUERY_OPTION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_MODE.key(),
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key(),
+            CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+            CoreOptions.SCAN_PLAN_SORT_PARTITION.key());
+
+    private static final Set<String> STARTUP_POSITION_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_TIMESTAMP.key(),
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_VERSION.key());
+
+    private static final Set<String> INHERITED_READ_STATE_KEYS = 
inheritedReadStateKeys();
+
+    // FilesScan enumerates the latest partitions before applying its 
range-aware per-partition scan,
+    // so it cannot safely read a range when a partition in that range has 
since been dropped.
+    private static final Set<String> INCREMENTAL_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "partitions", "ro", "row_tracking");
+
+    private static final Set<String> PAIMON_READER_SYSTEM_TABLES = 
ImmutableSet.of(
+            "audit_log", "binlog", "row_tracking");
+
+    private static final Set<String> OPTIONS_SYSTEM_TABLES = ImmutableSet.of(
+            // A system table may advertise OPTIONS only when every 
row-producing stage observes
+            // the selected snapshot; files and buckets still consult latest 
metadata internally.
+            "audit_log", "binlog", "manifests", "partitions", "ro",
+            "row_tracking", "table_indexes");
+
+    private PaimonScanParams() {
+    }
+
+    public static void validateOptions(Map<String, String> options) {
+        if (options.containsKey(CoreOptions.SCAN_FALLBACK_BRANCH.key())) {
+            throw new IllegalArgumentException("Paimon query option '"
+                    + CoreOptions.SCAN_FALLBACK_BRANCH.key()
+                    + "' is not supported because it requires rebuilding the 
table through the catalog factory.");
+        }
+
+        Set<String> unsupported = options.keySet().stream()
+                .filter(key -> !QUERY_OPTION_KEYS.contains(key))
+                .collect(Collectors.toSet());
+        if (!unsupported.isEmpty()) {
+            throw new IllegalArgumentException("Unsupported Paimon query 
option(s): " + unsupported);
+        }
+
+        String scanMode = options.get(CoreOptions.SCAN_MODE.key());
+        if ("from-creation-timestamp".equalsIgnoreCase(scanMode)
+                && options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()) == 
null) {
+            // Paimon 1.3.1 does not validate this newer mode, but its 
starting scanner
+            // requires the creation timestamp and otherwise fails after 
analysis.
+            throw new IllegalArgumentException("Paimon scan mode 
'from-creation-timestamp' requires query option '"
+                    + CoreOptions.SCAN_CREATION_TIME_MILLIS.key() + "'.");
+        }
+
+        long positionCount = 
options.keySet().stream().filter(STARTUP_POSITION_KEYS::contains).count();
+        if (positionCount > 1) {
+            throw new IllegalArgumentException(
+                    "Only one Paimon startup position can be specified: " + 
STARTUP_POSITION_KEYS);
+        }
+
+        if (options.containsKey(CoreOptions.SCAN_MODE.key()) && positionCount 
== 1) {
+            String position = options.keySet().stream()
+                    .filter(STARTUP_POSITION_KEYS::contains)
+                    .findFirst()
+                    .get();
+            String mode = scanMode.toLowerCase(Locale.ROOT);
+            if (!isCompatibleStartupMode(position, mode)) {
+                throw new IllegalArgumentException("Paimon scan mode '" + mode
+                        + "' is incompatible with startup position '" + 
position + "'.");
+            }
+        }
+    }
+
+    public static Table applyOptions(Table table, Map<String, String> options) 
{
+        Map<String, String> tableOptions = userOptions(options);
+        validateOptions(tableOptions);
+        Map<String, String> isolatedOptions = new HashMap<>(tableOptions);
+        if (hasStartupOptions(tableOptions)) {
+            // Startup mode, position, and range form one inherited state 
family in Paimon. Clear
+            // absent members so one relation cannot accidentally reuse 
another relation's read state.
+            INHERITED_READ_STATE_KEYS.stream()
+                    .filter(key -> !tableOptions.containsKey(key))
+                    .forEach(key -> isolatedOptions.put(key, null));
+        }
+        return table.copy(isolatedOptions);
+    }
+
+    private static Set<String> inheritedReadStateKeys() {
+        ImmutableSet.Builder<String> keys = ImmutableSet.builder();
+        for (ConfigOption<?> option : Arrays.asList(
+                CoreOptions.SCAN_TIMESTAMP,
+                CoreOptions.SCAN_TIMESTAMP_MILLIS,
+                CoreOptions.SCAN_WATERMARK,
+                CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_CREATION_TIME_MILLIS,
+                CoreOptions.SCAN_SNAPSHOT_ID,
+                CoreOptions.SCAN_TAG_NAME,
+                CoreOptions.SCAN_VERSION,
+                CoreOptions.SCAN_MODE,
+                CoreOptions.SCAN_BOUNDED_WATERMARK,
+                CoreOptions.INCREMENTAL_BETWEEN,
+                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
+                CoreOptions.INCREMENTAL_BETWEEN_SCAN_MODE,
+                CoreOptions.INCREMENTAL_TO_AUTO_TAG)) {
+            keys.add(option.key());
+            for (FallbackKey fallbackKey : option.fallbackKeys()) {
+                keys.add(fallbackKey.getKey());
+            }
+        }
+        return keys.build();
+    }
+
+    public static boolean hasStartupOptions(Map<String, String> options) {
+        return options.containsKey(CoreOptions.SCAN_MODE.key())
+                || 
options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains);
+    }
+
+    public static boolean selectsSchema(Map<String, String> options) {
+        return hasStartupOptions(options);
+    }
+
+    public static boolean usesStatementSnapshot(Map<String, String> options) {
+        if 
(options.keySet().stream().anyMatch(STARTUP_POSITION_KEYS::contains)) {
+            return false;
+        }
+        String mode = options.get(CoreOptions.SCAN_MODE.key());
+        return mode == null
+                || "default".equalsIgnoreCase(mode)
+                || "latest".equalsIgnoreCase(mode)
+                || "latest-full".equalsIgnoreCase(mode)
+                || "full".equalsIgnoreCase(mode);
+    }
+
+    public static Map<String, String> resolveOptions(Table table, Map<String, 
String> options) {
+        validateOptions(options);
+        if (!hasStartupOptions(options)) {
+            return options;
+        }
+        if (usesStatementSnapshot(options)) {
+            String pinnedSnapshotId = 
table.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key());
+            if (pinnedSnapshotId != null) {
+                return resolvedSnapshotOptions(options, pinnedSnapshotId);
+            }
+        }
+        if (!(table instanceof FileStoreTable)) {
+            throw new IllegalArgumentException("Paimon startup options require 
a file-store data table.");
+        }
+        FileStoreTable fileStoreTable = (FileStoreTable) table;
+        if 
(options.containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())) {
+            return resolveFileCreationTime(
+                    options,
+                    fileStoreTable,
+                    
Long.parseLong(options.get(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())));
+        }
+        if (options.containsKey(CoreOptions.SCAN_CREATION_TIME_MILLIS.key())) {
+            long creationTime = 
Long.parseLong(options.get(CoreOptions.SCAN_CREATION_TIME_MILLIS.key()));
+            Long previousSnapshotId = TimeTravelUtil.earlierThanTimeMills(
+                    fileStoreTable.snapshotManager(),
+                    fileStoreTable.changelogManager(),
+                    creationTime,
+                    fileStoreTable.coreOptions().changelogLifecycleDecoupled(),
+                    true);
+            if (previousSnapshotId != null
+                    && 
fileStoreTable.snapshotManager().snapshotExists(previousSnapshotId + 1)) {
+                return resolvedSnapshotOptions(options, 
String.valueOf(previousSnapshotId + 1));
+            }
+            return resolveFileCreationTime(options, fileStoreTable, 
creationTime);
+        }
+
+        Table selectedTable = applyOptions(table, options);
+        if 
("compacted-full".equalsIgnoreCase(options.get(CoreOptions.SCAN_MODE.key()))) {
+            Long snapshotId = compactedFullSnapshotId((FileStoreTable) 
selectedTable);
+            return snapshotId == null
+                    ? resolvedEmptyOptions(options)
+                    : resolvedSnapshotOptions(options, 
String.valueOf(snapshotId));
+        }
+        Snapshot snapshot = TimeTravelUtil.tryTravelOrLatest((FileStoreTable) 
selectedTable);
+        if (snapshot == null) {
+            return resolvedEmptyOptions(options);
+        }
+        String tagName = selectedTagName(options, (FileStoreTable) 
selectedTable);
+        if (tagName != null) {
+            // A tag owns a retained Snapshot copy even after the ordinary 
snapshot file expires.
+            // Keep the canonical tag selector so planning reads that retained 
metadata path.
+            return resolvedTagOptions(options, tagName);
+        }
+        return resolvedSnapshotOptions(options, String.valueOf(snapshot.id()));
+    }
+
+    private static String selectedTagName(Map<String, String> options, 
FileStoreTable selectedTable) {
+        String tagName = options.get(CoreOptions.SCAN_TAG_NAME.key());
+        if (tagName != null) {
+            return tagName;
+        }
+        // Paimon normalizes a tag-valued scan.version while copying the 
selected table.
+        return selectedTable.options().get(CoreOptions.SCAN_TAG_NAME.key());
+    }
+
+    private static Long compactedFullSnapshotId(FileStoreTable table) {
+        CoreOptions coreOptions = table.coreOptions();
+        int deltaCommits = coreOptions.toConfiguration()
+                .getOptional(CoreOptions.FULL_COMPACTION_DELTA_COMMITS)
+                .orElse(1);
+        if (coreOptions.changelogProducer() == 
CoreOptions.ChangelogProducer.FULL_COMPACTION
+                || 
coreOptions.toConfiguration().contains(CoreOptions.FULL_COMPACTION_DELTA_COMMITS))
 {
+            return table.snapshotManager().pickOrLatest(snapshot ->
+                    snapshot.commitKind() == Snapshot.CommitKind.COMPACT
+                            && 
FullCompactedStartingScanner.isFullCompactedIdentifier(
+                                    snapshot.commitIdentifier(), 
deltaCommits));
+        }
+        // COMPACTED_FULL means the newest compact snapshot, which can be 
older than latest.
+        return table.snapshotManager().pickOrLatest(
+                snapshot -> snapshot.commitKind() == 
Snapshot.CommitKind.COMPACT);
+    }
+
+    private static Map<String, String> resolveFileCreationTime(
+            Map<String, String> options, FileStoreTable table, long 
creationTime) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        Optional<Snapshot> latestSnapshot = table.latestSnapshot();
+        if (latestSnapshot.isPresent()) {
+            resolved.put(CoreOptions.SCAN_SNAPSHOT_ID.key(),
+                    String.valueOf(latestSnapshot.get().id()));
+        } else {
+            resolved.put(PINNED_EMPTY_SCAN, Boolean.TRUE.toString());
+        }
+        // Paimon's file-creation scanner consults latest lazily. Keep its 
filter as internal
+        // metadata while replacing the live latest lookup with the snapshot 
fixed above.
+        resolved.put(PINNED_FILE_CREATION_TIME, String.valueOf(creationTime));
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedSnapshotOptions(
+            Map<String, String> options, String snapshotId) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        resolved.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), snapshotId);
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedTagOptions(Map<String, String> 
options, String tagName) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        resolved.put(CoreOptions.SCAN_TAG_NAME.key(), tagName);
+        return resolved;
+    }
+
+    private static Map<String, String> resolvedEmptyOptions(Map<String, 
String> options) {
+        Map<String, String> resolved = new HashMap<>(options);
+        INHERITED_READ_STATE_KEYS.forEach(resolved::remove);
+        // An empty table is also a statement state. Remember it explicitly so 
a commit between
+        // binding and split planning cannot turn the relation into a 
non-empty scan.
+        resolved.put(PINNED_EMPTY_SCAN, Boolean.TRUE.toString());
+        return resolved;
+    }
+
+    private static Map<String, String> userOptions(Map<String, String> 
options) {
+        return options.entrySet().stream()
+                .filter(entry -> 
!entry.getKey().startsWith("doris.internal.paimon."))
+                .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
+    }
+
+    public static Optional<Long> getPinnedFileCreationTime(Map<String, String> 
options) {
+        return 
Optional.ofNullable(options.get(PINNED_FILE_CREATION_TIME)).map(Long::parseLong);
+    }
+
+    public static boolean isPinnedEmptyScan(Map<String, String> options) {
+        return Boolean.parseBoolean(options.get(PINNED_EMPTY_SCAN));
+    }
+
+    public static Map<String, String> isolateIncrementalRead(Map<String, 
String> incrementalOptions) {
+        Map<String, String> isolatedOptions = new HashMap<>();
+        INHERITED_READ_STATE_KEYS.forEach(key -> isolatedOptions.put(key, 
null));
+        isolatedOptions.putAll(incrementalOptions);
+        return isolatedOptions;
+    }
+
+    private static boolean isCompatibleStartupMode(String position, String 
mode) {
+        if ("default".equals(mode)) {
+            return true;
+        }
+        if (CoreOptions.SCAN_TIMESTAMP.key().equals(position)
+                || CoreOptions.SCAN_TIMESTAMP_MILLIS.key().equals(position)) {
+            return "from-timestamp".equals(mode);
+        }
+        if (CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key().equals(position)) 
{
+            return "from-file-creation-time".equals(mode);
+        }
+        if (CoreOptions.SCAN_CREATION_TIME_MILLIS.key().equals(position)) {
+            return "from-creation-timestamp".equals(mode);
+        }
+        return "from-snapshot".equals(mode)
+                || (CoreOptions.SCAN_SNAPSHOT_ID.key().equals(position)
+                && "from-snapshot-full".equals(mode));
+    }
+
+    public static boolean supportsIncrementalRead(String systemTableType) {
+        return systemTableType != null && 
INCREMENTAL_SYSTEM_TABLES.contains(systemTableType.toLowerCase());
+    }
+
+    public static boolean supportsOptions(String systemTableType) {
+        return systemTableType != null && 
OPTIONS_SYSTEM_TABLES.contains(systemTableType.toLowerCase());
+    }
+
+    public static boolean requiresPaimonReader(String systemTableType) {
+        // Range support and reader requirements are independent capabilities. 
File-backed system
+        // tables can honor INCR while still using Doris' native reader. 
Unknown mocked or legacy
+        // system-table types retain the previous behavior and do not force 
the Paimon reader.
+        return systemTableType != null && 
PAIMON_READER_SYSTEM_TABLES.contains(systemTableType.toLowerCase());
+    }
+
+    public static void validateSystemTable(String systemTableType, 
TableScanParams scanParams) {
+        if (scanParams == null) {
+            return;
+        }
+        if (scanParams.incrementalRead() && 
!supportsIncrementalRead(systemTableType)) {
+            throw new IllegalArgumentException(
+                    "Paimon system table '" + systemTableType + "' does not 
support INCR scan params.");
+        }
+        if (scanParams.isOptions() && !supportsOptions(systemTableType)) {
+            throw new IllegalArgumentException(
+                    "Paimon system table '" + systemTableType + "' does not 
support OPTIONS scan params.");
+        }
+        if (scanParams.isOptions()
+                && 
scanParams.getMapParams().containsKey(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key()))
 {

Review Comment:
   [P2] Make system-table creation-time support independent of retention
   
   This rejects `scan.file-creation-time-millis` but still admits 
`scan.creation-time-millis` for every OPTIONS-capable system table. The latter 
resolves to a snapshot only when `previousSnapshotId + 1` is retained; at a 
boundary or after expiration it falls back to the same internal file-creation 
predicate, which `PaimonSysExternalTable` then rejects. Identical SQL can 
therefore switch from success to failure as retention changes. Please reject 
both forms up front or implement the filter for these wrappers, and test a 
supported system table at the retention boundary.



##########
regression-test/data/external_table_p0/paimon/paimon_system_table.out:
##########
@@ -65,3 +65,12 @@ delta_record_count   bigint  Yes     true    \N      NONE
 changelog_record_count bigint  Yes     true    \N      NONE
 watermark      bigint  Yes     true    \N      NONE
 
+-- !files_at_first_snapshot --

Review Comment:
   [P2] Remove or execute these orphan expected-output blocks
   
   The suite now executes only `order_qt_files_without_options`; there are no 
`files_at_first_snapshot` or `files_at_latest_snapshot` query tags anywhere in 
`paimon_system_table.groovy`. These two added blocks therefore are not produced 
by the suite and leave the checked-in output out of correspondence. Please 
remove them, or restore real tagged queries only for a system-table selector 
that remains supported.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/property/metastore/AbstractPaimonProperties.java:
##########
@@ -121,6 +141,91 @@ public Map<String, String> getCatalogOptionsMap() {
         }
     }
 
+    public Map<String, String> getTableOptionsMap() {
+        return tableOptionsMap;
+    }
+
+    /**
+     * Returns Catalog table options which are not explicitly configured by 
the Paimon table.
+     *
+     * <p>The comparison is based on Paimon {@link ConfigOption}s so canonical 
and fallback keys
+     * follow the same precedence rule.
+     */
+    public Map<String, String> getTableOptionsForCopy(Map<String, String> 
currentTableOptions) {
+        if (tableOptionsMap.isEmpty() || currentTableOptions.isEmpty()) {
+            return tableOptionsMap;
+        }
+
+        Options existingOptions = new Options(currentTableOptions);
+        Map<String, String> optionsForCopy = new LinkedHashMap<>();
+        tableOptionsMap.forEach((key, value) -> {
+            ConfigOption<?> option = SUPPORTED_TABLE_OPTIONS.find(key);
+            if (!existingOptions.contains(option)) {
+                optionsForCopy.put(key, value);
+            }
+        });
+        return Collections.unmodifiableMap(optionsForCopy);
+    }
+
+    public static boolean isTableOptionProperty(String key) {
+        return key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX);
+    }
+
+    private Map<String, String> extractTableOptions() {
+        Map<String, String> tableOptions = new LinkedHashMap<>();
+        origProps.forEach((key, value) -> {
+            if (isTableOptionProperty(key)) {
+                String tableOptionKey = 
key.substring(TABLE_OPTION_PREFIX.length());
+                if (StringUtils.isBlank(tableOptionKey)) {
+                    throw new IllegalArgumentException(
+                            "Paimon table option name must not be empty after 
prefix " + TABLE_OPTION_PREFIX);
+                }
+                validateTableOption(tableOptionKey, value);
+                tableOptions.put(tableOptionKey, value);
+            }
+        });
+        return Collections.unmodifiableMap(tableOptions);
+    }
+
+    private void validateTableOption(String key, String value) {
+        ConfigOption<?> option = SUPPORTED_TABLE_OPTIONS.find(key);
+        if (option == null) {
+            throw new IllegalArgumentException("Unsupported Paimon table 
option '" + key
+                    + "' for the bundled Paimon version");
+        }
+
+        try {
+            new Options(Collections.singletonMap(key, value)).get(option);

Review Comment:
   [P1] Reject a zero Paimon read batch size
   
   Type parsing accepts `paimon.table-option.read.batch-size=0`. In Paimon 
1.3.1 the Parquet reader then advances by `min(batchSize, remaining)`, returns 
a non-null zero-row batch, and never increments its row position. 
`PaimonJniScanner` exhausts that empty iterator and immediately calls 
`readBatch()` again, so a nonempty scan spins without reaching EOF. Please 
require a strictly positive value and add zero-value CREATE/ALTER plus scanner 
forward-progress coverage.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java:
##########
@@ -95,15 +100,15 @@ public class PaimonScanNode extends FileQueryScanNode {
     private static final String DORIS_END_TIMESTAMP = "endTimestamp";
     private static final String DORIS_INCREMENTAL_BETWEEN_SCAN_MODE = 
"incrementalBetweenScanMode";
     private static final String PAIMON_PROPERTY_PREFIX = "paimon.";
-    private static final String DORIS_ENABLE_JNI_IO_MANAGER = 
"doris.enable_jni_io_manager";
-    private static final String DORIS_JNI_IO_MANAGER_TMP_DIR = 
"doris.jni_io_manager.tmp_dir";
-    private static final String DORIS_JNI_IO_MANAGER_IMPL_CLASS = 
"doris.jni_io_manager.impl_class";
+    private static final String DORIS_ENABLE_FILE_READER_ASYNC = 
"jni.enable_file_reader_async";
+    private static final String DORIS_ENABLE_JNI_IO_MANAGER = 
"jni.enable_jni_io_manager";

Review Comment:
   [P1] Preserve the existing IO-manager property namespace during upgrades
   
   These are persisted catalog properties and FE/BE wire keys, but the patch 
replaces `paimon.doris.*` everywhere instead of accepting it as a legacy alias. 
Existing catalogs lose their configured IO manager after an FE upgrade, and 
either direction of a rolling FE/BE upgrade sends keys the other side does not 
consume, silently disabling spill management. Please accept both namespaces for 
a compatibility window (new key wins) and cover a persisted old catalog plus 
mixed-version wire contracts.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to