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


##########
fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/ConnectorMetadata.java:
##########
@@ -70,6 +71,12 @@ default Optional<ConnectorMvccSnapshot> beginQuerySnapshot(
         return Optional.empty();
     }
 
+    /** Whether relation-level options need the statement's latest snapshot as 
their version fence. */
+    default boolean usesStatementSnapshotForOptions(

Review Comment:
   [P1] Regenerate the ConnectorMetadata surface baseline
   
   This adds a public `usesStatementSnapshotForOptions(...)` method, but the 
recorded `connector-metadata-methods.txt` surface still lacks its rendered 
signature. `ConnectorMetadataSurfaceTest` reflects every public method and 
exact-compares that set with this resource, so the `fe-connector-api` suite 
fails unconditionally on this head. Regenerate and commit the baseline in the 
same change, then run the connector API surface test.



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java:
##########
@@ -0,0 +1,331 @@
+// 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.connector.paimon;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.ConfigOption;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.DelegatedFileStoreTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.system.SystemTableLoader;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.Set;
+
+/** Validation shared by catalog-scoped and relation-scoped Paimon reader 
tuning. */
+public final class PaimonReaderOptions {
+    public static final String TABLE_OPTION_PREFIX = "paimon.table-option.";
+    public static final int MIN_READ_BATCH_SIZE = 1;
+    public static final int MAX_READ_BATCH_SIZE = 65536;
+    // Keep catalog replay deterministic while bounding a single option's 
JVM-wide thread impact.
+    public static final int MAX_MANIFEST_PARALLELISM = 256;
+    public static final long MIN_ASYNC_THRESHOLD_BYTES = 1024L * 1024L;
+    public static final long MAX_ASYNC_THRESHOLD_BYTES = 1024L * 1024L * 1024L;
+
+    // Keep this list to batch-read controls consumed by Doris' Paimon scan 
path. Context selectors,
+    // streaming-source settings, storage layout, and write options are unsafe 
after schema binding.
+    private static final Set<String> SUPPORTED_OPTIONS = ImmutableSet.of(
+            CoreOptions.READ_BATCH_SIZE.key(),
+            CoreOptions.FILE_READER_ASYNC_THRESHOLD.key(),
+            CoreOptions.FILE_INDEX_READ_ENABLED.key(),
+            CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
+            CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(),
+            CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+            CoreOptions.SCAN_PLAN_SORT_PARTITION.key());
+
+    // These settings do not alter the selected snapshot or manifest 
projection, so relation-local
+    // copies can reuse the memoized partition projection while still planning 
splits from the copy.
+    private static final Set<String> METADATA_NEUTRAL_OPTIONS = 
ImmutableSet.of(
+            CoreOptions.READ_BATCH_SIZE.key(),
+            CoreOptions.FILE_READER_ASYNC_THRESHOLD.key(),
+            CoreOptions.FILE_INDEX_READ_ENABLED.key(),
+            CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
+            CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key());
+
+    private PaimonReaderOptions() {
+    }
+
+    public static Set<String> supportedOptions() {
+        return SUPPORTED_OPTIONS;
+    }
+
+    public static Set<String> metadataNeutralOptions() {
+        return METADATA_NEUTRAL_OPTIONS;
+    }
+
+    public static void validate(String key, String value) {
+        if (!SUPPORTED_OPTIONS.contains(key)) {
+            throw new IllegalArgumentException("Unsupported Paimon dynamic 
reader option '" + key
+                    + "'. Supported options are " + SUPPORTED_OPTIONS);
+        }
+
+        if (CoreOptions.READ_BATCH_SIZE.key().equals(key)) {
+            int batchSize = parse(key, value, CoreOptions.READ_BATCH_SIZE);
+            // A zero batch can make Paimon's vectorized reader report success 
without
+            // advancing input; the upper bound also prevents one relation 
from over-allocating.
+            requireRange(key, batchSize, MIN_READ_BATCH_SIZE, 
MAX_READ_BATCH_SIZE);
+        } else if (CoreOptions.FILE_READER_ASYNC_THRESHOLD.key().equals(key)) {
+            MemorySize threshold = parse(key, value, 
CoreOptions.FILE_READER_ASYNC_THRESHOLD);
+            // Bound the trigger on both sides so a query cannot fan out tiny 
async reads or
+            // silently disable asynchronous reading with an effectively 
infinite threshold.
+            requireRange(key, threshold.getBytes(),
+                    MIN_ASYNC_THRESHOLD_BYTES, MAX_ASYNC_THRESHOLD_BYTES);
+        } else if (CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key().equals(key)) {
+            MemorySize targetSize = parse(key, value, 
CoreOptions.SOURCE_SPLIT_TARGET_SIZE);
+            // A split-size option represents byte capacity; non-positive 
values silently defeat
+            // Paimon's bin packing and turn every data file into a separate 
Doris scan range.
+            requireRange(key, targetSize.getBytes(), 1, Long.MAX_VALUE);
+        } else if (CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key().equals(key)) {
+            parse(key, value, CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST);
+        } else if (CoreOptions.SCAN_MANIFEST_PARALLELISM.key().equals(key)) {
+            validateManifestParallelism(value);
+        } else if (CoreOptions.FILE_INDEX_READ_ENABLED.key().equals(key)) {
+            parse(key, value, CoreOptions.FILE_INDEX_READ_ENABLED);
+        } else {
+            parse(key, value, CoreOptions.SCAN_PLAN_SORT_PARTITION);
+        }
+    }
+
+    public static void validateCatalogProperties(Map<String, String> 
properties) {
+        properties.forEach((key, value) -> {
+            if (!key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX)) 
{
+                return;
+            }
+            String optionKey = key.substring(TABLE_OPTION_PREFIX.length());
+            if (optionKey.isEmpty()) {
+                throw new IllegalArgumentException(
+                        "Paimon table option name must not be empty after 
prefix " + TABLE_OPTION_PREFIX);
+            }
+            validate(optionKey, value);
+        });
+    }
+
+    public static Map<String, String> compatibleCatalogOptions(Map<String, 
String> properties) {
+        Map<String, String> compatibleOptions = new LinkedHashMap<>();
+        properties.forEach((key, value) -> {
+            if (!key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX)) 
{
+                return;
+            }
+            String optionKey = key.substring(TABLE_OPTION_PREFIX.length());
+            try {
+                validate(optionKey, value);
+                compatibleOptions.put(optionKey, value);
+            } catch (IllegalArgumentException ignored) {
+                // Images written before the reader-only allowlist may contain 
arbitrary Paimon
+                // options. Keep the catalog loadable, but never apply an 
unsafe legacy option.
+            }
+        });
+        return Collections.unmodifiableMap(compatibleOptions);
+    }
+
+    public static void validateReaderOptions(Map<String, String> options) {
+        SUPPORTED_OPTIONS.stream()
+                .filter(options::containsKey)
+                .forEach(key -> validate(key, options.get(key)));
+    }
+
+    public static void validateEffectiveTableOptions(Map<String, String> 
options) {
+        validateReaderOptions(options);
+        validateIfPresentForRuntime(options, 
CoreOptions.SCAN_MANIFEST_PARALLELISM.key());
+    }
+
+    public static Map<String, String> runtimeSafeCopyOptions(Table table, 
Map<String, String> copyOptions) {
+        Map<String, String> safeOptions = new LinkedHashMap<>(copyOptions);
+        String key = CoreOptions.SCAN_MANIFEST_PARALLELISM.key();
+        if (safeOptions.containsKey(key)) {
+            String configured = safeOptions.get(key);
+            if (configured == null) {
+                return safeOptions;
+            }
+            validateManifestParallelism(configured);
+            int requested = Integer.parseInt(configured);
+            int localCapacity = Runtime.getRuntime().availableProcessors();
+            if (requested > localCapacity) {
+                safeOptions.put(key, String.valueOf(localCapacity));
+            }
+            return safeOptions;
+        }
+
+        OptionalInt safeParallelism = runtimeSafeManifestParallelism(table);
+        if (!safeParallelism.isPresent()) {
+            return safeOptions;
+        }
+        List<Integer> configuredValues = new ArrayList<>();
+        collectManifestParallelism(table, configuredValues);
+        int localCapacity = Runtime.getRuntime().availableProcessors();
+        if (configuredValues.stream().anyMatch(value -> value > 
localCapacity)) {
+            // Keep persisted semantics stable across heterogeneous FEs, but 
cap the execution copy
+            // conservatively across every nested planner hidden by a wrapper.
+            safeOptions.put(key, String.valueOf(safeParallelism.getAsInt()));
+        }
+        return safeOptions;
+    }
+
+    public static OptionalInt runtimeSafeManifestParallelism(Table table) {
+        List<Integer> configuredValues = new ArrayList<>();
+        collectManifestParallelism(table, configuredValues);
+        if (configuredValues.isEmpty()) {
+            return OptionalInt.empty();
+        }
+        int localCapacity = Runtime.getRuntime().availableProcessors();
+        return OptionalInt.of(Math.min(
+                
configuredValues.stream().mapToInt(Integer::intValue).min().getAsInt(),
+                localCapacity));
+    }
+
+    public static Table runtimeSafeTable(Table table) {
+        Map<String, String> runtimeOptions = runtimeSafeCopyOptions(table, 
Collections.emptyMap());
+        // Catalog handles stay hardware-neutral; every local planning 
consumer receives its own
+        // capped copy before it can resize Paimon's JVM-wide manifest 
executor.
+        return runtimeOptions.isEmpty() ? table : table.copy(runtimeOptions);
+    }
+
+    public static Table runtimeSafeSystemTable(
+            String systemTableType, Table systemTable, Table sourceTable, 
Map<String, String> scanOptions) {
+        Table effectiveSource = runtimeSafeSystemSource(sourceTable, 
scanOptions);
+        validateEffectiveTable(effectiveSource);
+        OptionalInt parallelism = 
runtimeSafeManifestParallelism(effectiveSource);
+        if (!parallelism.isPresent()) {
+            return systemTable;
+        }
+        Map<String, String> cap = Collections.singletonMap(
+                CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+                String.valueOf(parallelism.getAsInt()));
+        if (effectiveSource instanceof FileStoreTable) {
+            // Copying a system wrapper replays inherited time-travel options 
and can rewind a
+            // schema-only ALTER; cap the source without time travel, then 
rebuild the same wrapper.
+            FileStoreTable cappedSource = ((FileStoreTable) 
effectiveSource).copyWithoutTimeTravel(cap);
+            Table rebuilt = SystemTableLoader.load(systemTableType, 
cappedSource);

Review Comment:
   [P1] Preserve the fallback pair when rebuilding a capped `$ro`
   
   `getSysTableHandle` deliberately peels `PrivilegedFileStoreTable` before 
building `$ro`, because `ReadOptimizedTable` selects `FallbackReadScan` only 
when `FallbackReadFileStoreTable` is its immediate child. Here 
`effectiveSource` comes from the decorated `systemTableSource`, 
`copyWithoutTimeTravel` preserves that decorator, and this rebuild produces 
`ReadOptimized(Privileged(FallbackRead(...)))`; `$ro` then scans only the main 
branch and silently omits fallback-only rows. Any configured manifest value, 
even `1`, reaches this path. Peel non-fallback delegates from the capped source 
before rebuilding, and cover a privilege-wrapped fallback `$ro` scan/statistics 
case.



##########
fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanParamsTest.java:
##########
@@ -72,6 +72,69 @@ public void testValidateKnownScanOptions() {
                 "scan.plan-sort-partition", "true"));
     }
 
+    @Test
+    public void testValidateRelationScopedReaderOptions() {
+        PaimonScanParams.validateOptions(ImmutableMap.of(
+                "read.batch-size", "4096",
+                "file-reader-async-threshold", "16 MB",
+                "file-index.read.enabled", "false",
+                "source.split.target-size", "64 MB",
+                "source.split.open-file-cost", "1 MB",
+                "scan.manifest.parallelism", "1",
+                "scan.plan-sort-partition", "true"));
+
+        for (Map<String, String> options : new Map[] {
+                ImmutableMap.of("read.batch-size", "0"),
+                ImmutableMap.of("read.batch-size", "-1"),
+                ImmutableMap.of("read.batch-size", "65537"),
+                ImmutableMap.of("file-reader-async-threshold", "512 KB"),
+                ImmutableMap.of("file-reader-async-threshold", "2 GB")
+        }) {
+            Assertions.assertThrows(IllegalArgumentException.class,
+                    () -> PaimonScanParams.validateOptions(options));
+        }
+    }
+
+    @Test
+    public void testPlanningOptionsDoNotReuseMetadataProjection() {
+        
Assertions.assertTrue(PaimonScanParams.hasOnlyReaderOptions(ImmutableMap.of(
+                "file-index.read.enabled", "false",
+                "source.split.target-size", "64 MB")));
+        Assertions.assertFalse(PaimonScanParams.hasOnlyReaderOptions(
+                ImmutableMap.of("scan.manifest.parallelism", "1")));
+        Assertions.assertFalse(PaimonScanParams.hasOnlyReaderOptions(
+                ImmutableMap.of("scan.plan-sort-partition", "true")));
+    }
+
+    @Test
+    public void testManifestParallelismCannotMutateGlobalPoolCapacity() {
+        int availableProcessors = Runtime.getRuntime().availableProcessors();
+        PaimonScanParams.validateOptions(ImmutableMap.of(

Review Comment:
   [P1] Keep this test valid above 256 processors
   
   This success case passes the raw `availableProcessors()` value, but 
production validation first enforces the intentional hardware-independent 
`1..256` limit and only then checks the local CPU bound. A JVM exposing 257 or 
more processors therefore throws on the supposedly valid call, and the Maven 
test configuration does not clamp `ActiveProcessorCount`; adjacent tests 
already clamp or skip this boundary. Use `min(availableProcessors, 
MAX_MANIFEST_PARALLELISM)` for the valid case, guard the local `+1` assertion 
when applicable, and retain an explicit `MAX_MANIFEST_PARALLELISM + 1` 
rejection.



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java:
##########
@@ -749,7 +767,10 @@ public Optional<ConnectorMvccSnapshot> resolveTimeTravel(
                             
.properties(PaimonScanParams.markAsOptions(resolved))
                             .build());
                 }
-                long pinnedId = pinnedSnapshotId(table, resolved);
+                long pinnedId = spec.getLatestSnapshotFence().isPresent()

Review Comment:
   [P1] Keep latest-schema semantics for planning-only OPTIONS
   
   When this branch reuses statement fence `S`, the code below derives 
`schemaId` from snapshot `S`. Paimon schema-only ALTERs advance 
`schemaManager.latest()` without creating a new data snapshot, which is why 
`beginQuerySnapshot` deliberately leaves `schemaId=-1`. A plain `t` therefore 
binds the current schema, while `t@options('scan.manifest.parallelism'='1')` 
binds `S`'s historical schema even though the option is planning-only and both 
aliases share the same version. Preserve latest-schema (`schemaId=-1`) 
semantics for fence-derived options, and add a schema-only ALTER alias test.



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonReaderOptions.java:
##########
@@ -0,0 +1,331 @@
+// 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.connector.paimon;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.options.ConfigOption;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.DelegatedFileStoreTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.system.SystemTableLoader;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.Set;
+
+/** Validation shared by catalog-scoped and relation-scoped Paimon reader 
tuning. */
+public final class PaimonReaderOptions {
+    public static final String TABLE_OPTION_PREFIX = "paimon.table-option.";
+    public static final int MIN_READ_BATCH_SIZE = 1;
+    public static final int MAX_READ_BATCH_SIZE = 65536;
+    // Keep catalog replay deterministic while bounding a single option's 
JVM-wide thread impact.
+    public static final int MAX_MANIFEST_PARALLELISM = 256;
+    public static final long MIN_ASYNC_THRESHOLD_BYTES = 1024L * 1024L;
+    public static final long MAX_ASYNC_THRESHOLD_BYTES = 1024L * 1024L * 1024L;
+
+    // Keep this list to batch-read controls consumed by Doris' Paimon scan 
path. Context selectors,
+    // streaming-source settings, storage layout, and write options are unsafe 
after schema binding.
+    private static final Set<String> SUPPORTED_OPTIONS = ImmutableSet.of(
+            CoreOptions.READ_BATCH_SIZE.key(),
+            CoreOptions.FILE_READER_ASYNC_THRESHOLD.key(),
+            CoreOptions.FILE_INDEX_READ_ENABLED.key(),
+            CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
+            CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(),
+            CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+            CoreOptions.SCAN_PLAN_SORT_PARTITION.key());
+
+    // These settings do not alter the selected snapshot or manifest 
projection, so relation-local
+    // copies can reuse the memoized partition projection while still planning 
splits from the copy.
+    private static final Set<String> METADATA_NEUTRAL_OPTIONS = 
ImmutableSet.of(
+            CoreOptions.READ_BATCH_SIZE.key(),
+            CoreOptions.FILE_READER_ASYNC_THRESHOLD.key(),
+            CoreOptions.FILE_INDEX_READ_ENABLED.key(),
+            CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
+            CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key());
+
+    private PaimonReaderOptions() {
+    }
+
+    public static Set<String> supportedOptions() {
+        return SUPPORTED_OPTIONS;
+    }
+
+    public static Set<String> metadataNeutralOptions() {
+        return METADATA_NEUTRAL_OPTIONS;
+    }
+
+    public static void validate(String key, String value) {
+        if (!SUPPORTED_OPTIONS.contains(key)) {
+            throw new IllegalArgumentException("Unsupported Paimon dynamic 
reader option '" + key
+                    + "'. Supported options are " + SUPPORTED_OPTIONS);
+        }
+
+        if (CoreOptions.READ_BATCH_SIZE.key().equals(key)) {
+            int batchSize = parse(key, value, CoreOptions.READ_BATCH_SIZE);
+            // A zero batch can make Paimon's vectorized reader report success 
without
+            // advancing input; the upper bound also prevents one relation 
from over-allocating.
+            requireRange(key, batchSize, MIN_READ_BATCH_SIZE, 
MAX_READ_BATCH_SIZE);
+        } else if (CoreOptions.FILE_READER_ASYNC_THRESHOLD.key().equals(key)) {
+            MemorySize threshold = parse(key, value, 
CoreOptions.FILE_READER_ASYNC_THRESHOLD);
+            // Bound the trigger on both sides so a query cannot fan out tiny 
async reads or
+            // silently disable asynchronous reading with an effectively 
infinite threshold.
+            requireRange(key, threshold.getBytes(),
+                    MIN_ASYNC_THRESHOLD_BYTES, MAX_ASYNC_THRESHOLD_BYTES);
+        } else if (CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key().equals(key)) {
+            MemorySize targetSize = parse(key, value, 
CoreOptions.SOURCE_SPLIT_TARGET_SIZE);
+            // A split-size option represents byte capacity; non-positive 
values silently defeat
+            // Paimon's bin packing and turn every data file into a separate 
Doris scan range.
+            requireRange(key, targetSize.getBytes(), 1, Long.MAX_VALUE);
+        } else if (CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key().equals(key)) {
+            parse(key, value, CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST);
+        } else if (CoreOptions.SCAN_MANIFEST_PARALLELISM.key().equals(key)) {
+            validateManifestParallelism(value);
+        } else if (CoreOptions.FILE_INDEX_READ_ENABLED.key().equals(key)) {
+            parse(key, value, CoreOptions.FILE_INDEX_READ_ENABLED);
+        } else {
+            parse(key, value, CoreOptions.SCAN_PLAN_SORT_PARTITION);
+        }
+    }
+
+    public static void validateCatalogProperties(Map<String, String> 
properties) {
+        properties.forEach((key, value) -> {
+            if (!key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX)) 
{
+                return;
+            }
+            String optionKey = key.substring(TABLE_OPTION_PREFIX.length());
+            if (optionKey.isEmpty()) {
+                throw new IllegalArgumentException(
+                        "Paimon table option name must not be empty after 
prefix " + TABLE_OPTION_PREFIX);
+            }
+            validate(optionKey, value);
+        });
+    }
+
+    public static Map<String, String> compatibleCatalogOptions(Map<String, 
String> properties) {
+        Map<String, String> compatibleOptions = new LinkedHashMap<>();
+        properties.forEach((key, value) -> {
+            if (!key.toLowerCase(Locale.ROOT).startsWith(TABLE_OPTION_PREFIX)) 
{
+                return;
+            }
+            String optionKey = key.substring(TABLE_OPTION_PREFIX.length());
+            try {
+                validate(optionKey, value);
+                compatibleOptions.put(optionKey, value);
+            } catch (IllegalArgumentException ignored) {
+                // Images written before the reader-only allowlist may contain 
arbitrary Paimon
+                // options. Keep the catalog loadable, but never apply an 
unsafe legacy option.
+            }
+        });
+        return Collections.unmodifiableMap(compatibleOptions);
+    }
+
+    public static void validateReaderOptions(Map<String, String> options) {
+        SUPPORTED_OPTIONS.stream()
+                .filter(options::containsKey)
+                .forEach(key -> validate(key, options.get(key)));
+    }
+
+    public static void validateEffectiveTableOptions(Map<String, String> 
options) {
+        validateReaderOptions(options);
+        validateIfPresentForRuntime(options, 
CoreOptions.SCAN_MANIFEST_PARALLELISM.key());
+    }
+
+    public static Map<String, String> runtimeSafeCopyOptions(Table table, 
Map<String, String> copyOptions) {
+        Map<String, String> safeOptions = new LinkedHashMap<>(copyOptions);
+        String key = CoreOptions.SCAN_MANIFEST_PARALLELISM.key();
+        if (safeOptions.containsKey(key)) {
+            String configured = safeOptions.get(key);
+            if (configured == null) {
+                return safeOptions;
+            }
+            validateManifestParallelism(configured);
+            int requested = Integer.parseInt(configured);
+            int localCapacity = Runtime.getRuntime().availableProcessors();
+            if (requested > localCapacity) {
+                safeOptions.put(key, String.valueOf(localCapacity));
+            }
+            return safeOptions;
+        }
+
+        OptionalInt safeParallelism = runtimeSafeManifestParallelism(table);
+        if (!safeParallelism.isPresent()) {
+            return safeOptions;
+        }
+        List<Integer> configuredValues = new ArrayList<>();
+        collectManifestParallelism(table, configuredValues);
+        int localCapacity = Runtime.getRuntime().availableProcessors();
+        if (configuredValues.stream().anyMatch(value -> value > 
localCapacity)) {
+            // Keep persisted semantics stable across heterogeneous FEs, but 
cap the execution copy
+            // conservatively across every nested planner hidden by a wrapper.
+            safeOptions.put(key, String.valueOf(safeParallelism.getAsInt()));
+        }
+        return safeOptions;
+    }
+
+    public static OptionalInt runtimeSafeManifestParallelism(Table table) {
+        List<Integer> configuredValues = new ArrayList<>();
+        collectManifestParallelism(table, configuredValues);
+        if (configuredValues.isEmpty()) {
+            return OptionalInt.empty();
+        }
+        int localCapacity = Runtime.getRuntime().availableProcessors();
+        return OptionalInt.of(Math.min(
+                
configuredValues.stream().mapToInt(Integer::intValue).min().getAsInt(),
+                localCapacity));
+    }
+
+    public static Table runtimeSafeTable(Table table) {
+        Map<String, String> runtimeOptions = runtimeSafeCopyOptions(table, 
Collections.emptyMap());
+        // Catalog handles stay hardware-neutral; every local planning 
consumer receives its own
+        // capped copy before it can resize Paimon's JVM-wide manifest 
executor.
+        return runtimeOptions.isEmpty() ? table : table.copy(runtimeOptions);
+    }
+
+    public static Table runtimeSafeSystemTable(
+            String systemTableType, Table systemTable, Table sourceTable, 
Map<String, String> scanOptions) {
+        Table effectiveSource = runtimeSafeSystemSource(sourceTable, 
scanOptions);
+        validateEffectiveTable(effectiveSource);
+        OptionalInt parallelism = 
runtimeSafeManifestParallelism(effectiveSource);
+        if (!parallelism.isPresent()) {
+            return systemTable;
+        }
+        Map<String, String> cap = Collections.singletonMap(
+                CoreOptions.SCAN_MANIFEST_PARALLELISM.key(),
+                String.valueOf(parallelism.getAsInt()));
+        if (effectiveSource instanceof FileStoreTable) {
+            // Copying a system wrapper replays inherited time-travel options 
and can rewind a
+            // schema-only ALTER; cap the source without time travel, then 
rebuild the same wrapper.
+            FileStoreTable cappedSource = ((FileStoreTable) 
effectiveSource).copyWithoutTimeTravel(cap);
+            Table rebuilt = SystemTableLoader.load(systemTableType, 
cappedSource);
+            if (rebuilt == null) {
+                throw new IllegalArgumentException("Unsupported Paimon system 
table '"
+                        + systemTableType + "'");
+            }
+            return rebuilt;
+        }
+        return systemTable.copy(cap);
+    }
+
+    public static Table runtimeSafeSystemSource(Table sourceTable, Map<String, 
String> scanOptions) {
+        return PaimonScanParams.isOptionsPin(scanOptions)
+                ? PaimonScanParams.applyOptions(sourceTable, scanOptions)
+                : runtimeSafeTable(sourceTable);

Review Comment:
   [P1] Reapply `@incr` before rebuilding the system wrapper
   
   `resolveScanTable` first applies the reset-aware incremental range to the 
system wrapper, but this helper rebuilds from the original source and reapplies 
scan options only for OPTIONS-marked maps. For `@incr`, the configured manifest 
value therefore makes `SystemTableLoader.load` replace the correctly ranged 
wrapper with an unbounded/latest one; this affects supported incremental system 
tables during FE planning and statistics, before any later BE reapply can help. 
Apply the non-OPTIONS incremental map to the exact source before 
capping/rebuilding, and test a real incremental system wrapper with manifest 
parallelism configured.



##########
fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanParams.java:
##########
@@ -336,6 +350,16 @@ private static Map<String, String> resolvedSnapshotOptions(
         return resolved;
     }
 
+    public static Map<String, String> pinOptionsToSnapshot(
+            Map<String, String> options, long snapshotId) {
+        // Statement-fence pinning removes inherited selectors, so validate 
the raw map first;
+        // otherwise an unsupported inherited key can disappear before the 
common validation path.
+        validateOptions(options);
+        return snapshotId < 0

Review Comment:
   [P1] Freeze an empty latest fence for plain aliases too
   
   This maps fence id `-1` to the internal pinned-empty marker only for an 
OPTIONS projection. A plain alias sharing the same fence goes through 
`applySnapshot(-1)`, which returns the base handle unchanged and leaves its 
scan live. If the table receives its first commit after fence capture, the 
OPTIONS alias remains empty while plain `t` can read the new rows. Represent an 
empty latest fence with scan semantics honored by both paths, and test the 
first-commit race in both alias orders.



##########
fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/PluginDrivenMvccExternalTable.java:
##########
@@ -347,15 +355,71 @@ private static ListPartitionItem 
toListPartitionItem(String partitionName, List<
 
     @Override
     public MvccSnapshot loadSnapshot(Optional<TableSnapshot> tableSnapshot, 
Optional<TableScanParams> scanParams) {
+        return loadSnapshotInternal(tableSnapshot, scanParams, 
Optional.empty());
+    }
+
+    @Override
+    public MvccSnapshot loadLatestSnapshotFence() {
+        makeSureInitialized();
+        PluginDrivenExternalCatalog pluginCatalog = 
(PluginDrivenExternalCatalog) catalog;
+        Connector connector = pluginCatalog.getConnector();
+        if (connector == null) {
+            return new PluginDrivenMvccSnapshot(emptySnapshot(),
+                    Collections.emptyMap(), Collections.emptyMap());
+        }
+        ConnectorSession session = pluginCatalog.buildConnectorSession();
+        ConnectorMetadata metadata = PluginDrivenMetadata.get(session, 
connector);
+        Optional<ConnectorTableHandle> handle = 
resolveConnectorTableHandle(session, metadata);
+        ConnectorMvccSnapshot fence = handle.isPresent()
+                ? metadata.beginQuerySnapshot(session, 
handle.get()).orElseGet(this::emptySnapshot)
+                : emptySnapshot();
+        // A fence carries version identity only; raw partitions may be 
invalid for relation options.
+        return new PluginDrivenMvccSnapshot(fence, Collections.emptyMap(), 
Collections.emptyMap());
+    }
+
+    @Override
+    public boolean requiresLatestSnapshotFence(
+            Optional<TableSnapshot> tableSnapshot, Optional<TableScanParams> 
scanParams) {
+        if (tableSnapshot.isPresent() || !scanParams.isPresent() || 
!scanParams.get().isOptions()) {
+            return false;
+        }
+        makeSureInitialized();
+        PluginDrivenExternalCatalog pluginCatalog = 
(PluginDrivenExternalCatalog) catalog;
+        Connector connector = pluginCatalog.getConnector();
+        if (connector == null) {
+            return false;
+        }
+        ConnectorSession session = pluginCatalog.buildConnectorSession();
+        ConnectorMetadata metadata = PluginDrivenMetadata.get(session, 
connector);
+        Optional<ConnectorTableHandle> handle = 
resolveConnectorTableHandle(session, metadata);
+        return handle.isPresent() && metadata.usesStatementSnapshotForOptions(
+                session, handle.get(), scanParams.get().getMapParams());
+    }
+
+    @Override
+    public MvccSnapshot loadSnapshot(

Review Comment:
   [P1] Thread the statement fence into system-table OPTIONS
   
   This fence-aware overload is never reached by 
`PluginDrivenSysExternalTable`: `BindRelation.handleMetaTable` returns before 
`StatementContext.loadSnapshots`, and `resolveScanPin` calls the old 
two-argument source loader. Consequently `t` joined with 
`t$partitions@options('scan.manifest.parallelism'='1')` can pin `S` for the 
plain alias and resolve live `S+1` for the system view after an intervening 
commit. The system table's memo only prevents drift within that one relation. 
Route its source pin through the statement-scoped fence registry (in both 
relation orders) and add a commit-between-resolutions test.



-- 
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