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

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


The following commit(s) were added to refs/heads/master by this push:
     new 98a7a7e9548 [improvement](fe) Preload external table metadata before 
internal table lock in mixed queries (#64035)
98a7a7e9548 is described below

commit 98a7a7e95489338e2b38a2e7dcc46e9b6b8033b6
Author: Wen Zhenghu <[email protected]>
AuthorDate: Fri Jun 5 22:29:51 2026 +0800

    [improvement](fe) Preload external table metadata before internal table 
lock in mixed queries (#64035)
    
    Problem Summary:
    
    This PR aims to reduce the time that Doris FE holds internal table
    plan-time read locks during Nereids planning.
    
    Problem
    In mixed queries that involve both internal tables and external tables,
    FE may access external table metadata while internal table read locks
    are already held. For some external catalogs, metadata loading is lazy
    and may be slow, such as schema initialization or latest snapshot
    loading. As a result, the internal table lock can be held much longer
    than necessary, which increases the chance of blocking concurrent
    operations that need the table write lock.
    
    Solution
    This PR introduces an external metadata preload phase before internal
    table locks are acquired in Nereids planning.
    
    The main idea is:
    1. Collect external tables during relation collection.
    2. Preload the metadata that will likely be needed later, before locking
    internal tables.
    3. Acquire internal table locks only after the preload phase finishes.
    4. Continue the normal analysis flow with the preloaded metadata already
    cached.
    
    The preload currently covers:
    - Hive/Hudi external tables
    - Iceberg external tables
    - Paimon external tables
    - JDBC external tables
    
    For snapshot-based engines, this PR only preloads the latest snapshot
    when the query is using the latest view of the table. It does not
    preload explicit historical snapshots.
    
    For JDBC catalogs, this PR preloads schema metadata before the lock
    phase, so the lazy schema initialization no longer extends the internal
    table lock holding window.
    
    ### Implementation summary
    
    This PR reduces the internal table plan-time read lock window by moving
    eligible external metadata loading ahead of `statementContext.lock()`.
    The implementation now follows this flow:
    
    `collect relations -> register external preload candidates -> preload
    external metadata -> lock internal tables -> analyze`
    
    The key point is that the external metadata work is no longer triggered
    lazily after internal table locks are acquired. Instead, for eligible
    external tables, it is executed before the lock stage.
    
    ### 1. Preload capability is implemented as a table trait
    
    Instead of hard-coding table type checks in planner logic, preload
    capability is now declared on `TableIf`:
    
    - `supportsExternalMetadataPreload()`
    - `supportsLatestSnapshotPreload()`
    
    This keeps the capability decision close to the table implementation
    itself.
    
    Current coverage is:
    
    - `HMSExternalTable`
      - supports preload for Hive/Hudi
      - supports latest-snapshot preload only for Hudi
    - `IcebergExternalTable`
      - supports preload
      - supports latest-snapshot preload
    - `PaimonExternalTable`
      - supports preload
      - supports latest-snapshot preload
    - `PluginDrivenExternalTable`
      - preload is currently limited to JDBC plugin catalogs only
    
    ### 2. StatementContext only records preload candidates
    
    `StatementContext` no longer owns the preload execution logic.
    
    During relation collection, when an external table is encountered, it
    records relation-level preload metadata through
    `registerExternalTableForPreload(...)`.
    
    This metadata is represented by `ExternalTablePreloadInfo`, which tracks
    whether the same external table is referenced as:
    
    - a latest relation
    - a non-latest relation (for example snapshot / branch / tag /
    time-travel style access)
    
    This distinction is important because snapshot-aware external tables
    should not warm latest schema/partitions when they are referenced only
    through non-latest relations.
    
    ### 3. Preload execution is implemented as a Nereids analysis rule
    
    The actual preload logic is implemented in a dedicated rule:
    `PreloadExternalMetadata`.
    
    This rule runs after relation collection and before internal table locks
    are acquired.
    
    It executes at most once per statement context and produces an
    `ExternalMetadataPreloadResult`, which records:
    
    - whether preload actually ran
    - candidate table count
    - preloaded table count
    - skip reason
    - elapsed time
    
    ### 4. Preload is gated by explicit conditions
    
    The preload rule skips execution when any of the following is true:
    
    - `enable_preload_external_metadata` is disabled
    - no eligible external preload candidates were collected
    - the statement does not involve any internal table that requires a
    plan-time read lock
    
    This means the optimization only runs when it can actually help reduce
    internal lock holding time.
    
    ### 5. Preload behavior is table-type aware
    
    For each external table candidate, preload may do one or more of the
    following:
    
    - preload latest snapshot metadata
    - preload schema
    - preload selected partition metadata
    
    For snapshot-aware tables, latest snapshot/schema/partition warmup is
    now gated by whether the table is referenced by latest-only relations.
    
    In particular, if a table is referenced only by non-latest relations,
    this PR avoids warming the latest schema/partitions. That prevents
    useless cache warmup for time-travel / branch / tag queries.
    
    ### 6. Planner/profile integration was adjusted to avoid double counting
    
    `NereidsPlanner` now reads the preload result produced by the
    collect-phase rule and records it into a dedicated profile counter:
    
    - `Nereids Preload External Metadata Time`
    
    At the same time, `Nereids Lock Table Time` was narrowed to cover only
    the actual `statementContext.lock()` call.
    
    This avoids double counting preload time into both:
    
    - `Nereids Preload External Metadata Time`
    - `Nereids Lock Table Time`
    
    After this change:
    
    - when preload is disabled, external schema initialization can still
    show up in `Nereids Analysis Time`
    - when preload is enabled, that cost is shifted into `Nereids Preload
    External Metadata Time`
    
    ### 7. Session variable
    
    This PR introduces the session variable:
    
    - `enable_preload_external_metadata`
    
    It is currently default-off and acts as the main switch for this
    optimization.
    
    ### Why this helps
    
    Before this change, slow external metadata operations could extend the
    duration for which internal table plan-time read locks were held.
    
    After this change, the eligible external metadata work is moved before
    lock acquisition, so the internal lock window is shorter and less
    sensitive to slow external metadata paths.
    
    ### Release note
    
    Improve FE planning by moving external metadata preload ahead of
    internal table plan-time read locks.
    
    ### Check List (For Author)
    
    - Test: FE Unit Test / Manual test
    - FE Unit Test: `./run-fe-ut.sh --run
    
org.apache.doris.nereids.StatementContextTest,org.apache.doris.common.profile.SummaryProfileTest,org.apache.doris.datasource.PluginDrivenExternalTableEngineTest`
    - Manual test: validated the JDBC preload profile case against a custom
    Doris environment with an equivalent setup
    - Behavior changed: Yes
    - Does this need documentation: No
---
 .../java/org/apache/doris/catalog/TableIf.java     |  14 +
 .../doris/common/profile/SummaryProfile.java       |  29 +-
 .../apache/doris/datasource/ExternalCatalog.java   |  15 +
 .../apache/doris/datasource/ExternalDatabase.java  |  15 +
 .../datasource/PluginDrivenExternalTable.java      |  25 +
 .../doris/datasource/hive/HMSExternalTable.java    |  12 +
 .../datasource/iceberg/IcebergExternalTable.java   |  10 +
 .../datasource/paimon/PaimonExternalTable.java     |  10 +
 .../org/apache/doris/nereids/CascadesContext.java  |   6 +-
 .../nereids/ExternalMetadataPreloadResult.java     |  65 +++
 .../doris/nereids/ExternalTablePreloadInfo.java    |  55 ++
 .../org/apache/doris/nereids/NereidsPlanner.java   |  30 +-
 .../org/apache/doris/nereids/StatementContext.java |  56 +++
 .../executor/TableCollectAndHookInitializer.java   |  26 +-
 .../org/apache/doris/nereids/rules/RuleType.java   |   1 +
 .../nereids/rules/analysis/CollectRelation.java    |   5 +
 .../rules/analysis/PreloadExternalMetadata.java    | 134 +++++
 .../java/org/apache/doris/qe/SessionVariable.java  |  17 +
 .../doris/common/profile/SummaryProfileTest.java   |  19 +-
 .../PluginDrivenExternalTableEngineTest.java       |  72 ++-
 .../apache/doris/nereids/NereidsPlannerTest.java   |  82 +++
 .../apache/doris/nereids/StatementContextTest.java | 559 +++++++++++++++++++++
 .../org/apache/doris/qe/SessionVariablesTest.java  |  12 +
 23 files changed, 1248 insertions(+), 21 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
index def2c17a536..cae4d20f0b9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/TableIf.java
@@ -208,6 +208,20 @@ public interface TableIf {
         return false;
     }
 
+    /**
+     * Returns whether the table can preload planning metadata before internal 
table locks are acquired.
+     */
+    default boolean supportsExternalMetadataPreload() {
+        return false;
+    }
+
+    /**
+     * Returns whether the table has a meaningful latest snapshot that can be 
preloaded ahead of analysis.
+     */
+    default boolean supportsLatestSnapshotPreload() {
+        return false;
+    }
+
     /**
      * Doris table type.
      */
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java 
b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
index 2b71352ba83..373e81dd1bd 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
@@ -118,6 +118,7 @@ public class SummaryProfile {
     public static final String REMOTE_READ_BYTES_PER_SECOND = "Remote Read 
Bytes Per Second";
 
     public static final String PARSE_SQL_TIME = "Parse SQL Time";
+    public static final String NEREIDS_PRELOAD_EXTERNAL_METADATA_TIME = 
"Nereids Preload External Metadata Time";
     public static final String NEREIDS_LOCK_TABLE_TIME = "Nereids Lock Table 
Time";
     public static final String NEREIDS_ANALYSIS_TIME = "Nereids Analysis Time";
     public static final String NEREIDS_REWRITE_TIME = "Nereids Rewrite Time";
@@ -185,6 +186,7 @@ public class SummaryProfile {
             PARSE_SQL_TIME,
             PLAN_TIME,
             NEREIDS_GARBAGE_COLLECT_TIME,
+            NEREIDS_PRELOAD_EXTERNAL_METADATA_TIME,
             NEREIDS_LOCK_TABLE_TIME,
             NEREIDS_ANALYSIS_TIME,
             NEREIDS_REWRITE_TIME,
@@ -242,6 +244,7 @@ public class SummaryProfile {
     public static ImmutableMap<String, Integer> 
EXECUTION_SUMMARY_KEYS_INDENTATION
             = ImmutableMap.<String, Integer>builder()
             .put(NEREIDS_GARBAGE_COLLECT_TIME, 1)
+            .put(NEREIDS_PRELOAD_EXTERNAL_METADATA_TIME, 1)
             .put(NEREIDS_LOCK_TABLE_TIME, 1)
             .put(NEREIDS_ANALYSIS_TIME, 1)
             .put(NEREIDS_REWRITE_TIME, 1)
@@ -304,6 +307,10 @@ public class SummaryProfile {
     private long parseSqlStartTime = -1;
     @SerializedName(value = "parseSqlFinishTime")
     private long parseSqlFinishTime = -1;
+    @SerializedName(value = "nereidsPreloadExternalMetadataTime")
+    private long nereidsPreloadExternalMetadataTime = 0;
+    @SerializedName(value = "nereidsLockTableStartTime")
+    private long nereidsLockTableStartTime = -1;
     @SerializedName(value = "nereidsLockTableFinishTime")
     private long nereidsLockTableFinishTime = -1;
     @SerializedName(value = "nereidsCollectTablePartitionFinishTime")
@@ -577,6 +584,8 @@ public class SummaryProfile {
         executionSummaryProfile.addInfoString(PARSE_SQL_TIME, 
getPrettyParseSqlTime());
         executionSummaryProfile.addInfoString(PLAN_TIME,
                 getPrettyTime(queryPlanFinishTime, parseSqlFinishTime, 
TUnit.TIME_MS));
+        
executionSummaryProfile.addInfoString(NEREIDS_PRELOAD_EXTERNAL_METADATA_TIME,
+                getPrettyNereidsPreloadExternalMetadataTime());
         executionSummaryProfile.addInfoString(NEREIDS_LOCK_TABLE_TIME, 
getPrettyNereidsLockTableTime());
         executionSummaryProfile.addInfoString(NEREIDS_ANALYSIS_TIME, 
getPrettyNereidsAnalysisTime());
         executionSummaryProfile.addInfoString(NEREIDS_REWRITE_TIME, 
getPrettyNereidsRewriteTime());
@@ -695,6 +704,10 @@ public class SummaryProfile {
         this.parseSqlFinishTime = parseSqlFinishTime;
     }
 
+    public void setNereidsLockTableStartTime(long lockTableStartTime) {
+        this.nereidsLockTableStartTime = lockTableStartTime;
+    }
+
     public void setNereidsLockTableFinishTime(long lockTableFinishTime) {
         this.nereidsLockTableFinishTime = lockTableFinishTime;
     }
@@ -872,7 +885,11 @@ public class SummaryProfile {
     }
 
     public int getNereidsLockTableTimeMs() {
-        return getTimeMs(nereidsLockTableFinishTime, parseSqlFinishTime);
+        return getTimeMs(nereidsLockTableFinishTime, 
nereidsLockTableStartTime);
+    }
+
+    public long getNereidsPreloadExternalMetadataTimeMs() {
+        return nereidsPreloadExternalMetadataTime;
     }
 
     public int getNereidsAnalysisTimeMs() {
@@ -960,8 +977,12 @@ public class SummaryProfile {
         return getPrettyTime(parseSqlFinishTime, parseSqlStartTime, 
TUnit.TIME_MS);
     }
 
+    public String getPrettyNereidsPreloadExternalMetadataTime() {
+        return RuntimeProfile.printCounter(nereidsPreloadExternalMetadataTime, 
TUnit.TIME_MS);
+    }
+
     public String getPrettyNereidsLockTableTime() {
-        return getPrettyTime(nereidsLockTableFinishTime, parseSqlFinishTime, 
TUnit.TIME_MS);
+        return getPrettyTime(nereidsLockTableFinishTime, 
nereidsLockTableStartTime, TUnit.TIME_MS);
     }
 
     public String getPrettyNereidsAnalysisTime() {
@@ -1234,6 +1255,10 @@ public class SummaryProfile {
         this.nereidsMvRewriteTime += ms;
     }
 
+    public void addNereidsPreloadExternalMetadataTime(long ms) {
+        this.nereidsPreloadExternalMetadataTime += ms;
+    }
+
     public long getNereidsMvRewriteTimeMs() {
         return nereidsMvRewriteTime;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
index f7f242c10e8..44de2185869 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
@@ -38,6 +38,7 @@ import org.apache.doris.common.ThreadPoolManager;
 import org.apache.doris.common.UserException;
 import org.apache.doris.common.Version;
 import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.common.util.DebugPointUtil;
 import org.apache.doris.common.util.Util;
 import 
org.apache.doris.datasource.connectivity.CatalogConnectivityTestCoordinator;
 import org.apache.doris.datasource.doris.RemoteDorisExternalDatabase;
@@ -271,6 +272,20 @@ public abstract class ExternalCatalog
         if (metadataOps == null) {
             throw new UnsupportedOperationException("List databases is not 
supported for catalog: " + getName());
         } else {
+            // Allow manual regression to isolate catalog-level metadata 
enumeration cost during collect.
+            if 
(DebugPointUtil.isEnable("ExternalCatalog.listDatabaseNames.sleep")) {
+                long sleepMs = DebugPointUtil.getDebugParamOrDefault(
+                        "ExternalCatalog.listDatabaseNames.sleep", "sleepMs", 
0L);
+                if (sleepMs > 0) {
+                    LOG.info("debug point 
ExternalCatalog.listDatabaseNames.sleep hit for {}, sleep {}ms",
+                            getName(), sleepMs);
+                    try {
+                        Thread.sleep(sleepMs);
+                    } catch (InterruptedException ignore) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            }
             return metadataOps.listDatabaseNames();
         }
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
index c688ccea89a..2a456195d62 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalDatabase.java
@@ -29,6 +29,7 @@ import org.apache.doris.common.FeConstants;
 import org.apache.doris.common.MetaNotFoundException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.lock.MonitoredReentrantReadWriteLock;
+import org.apache.doris.common.util.DebugPointUtil;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.datasource.infoschema.ExternalInfoSchemaDatabase;
 import org.apache.doris.datasource.infoschema.ExternalMysqlDatabase;
@@ -195,6 +196,20 @@ public abstract class ExternalDatabase<T extends 
ExternalTable>
                     })
                     .collect(Collectors.toList());
         } else {
+            // Allow manual regression to isolate database-level table 
enumeration cost during collect.
+            if 
(DebugPointUtil.isEnable("ExternalDatabase.listTableNames.sleep")) {
+                long sleepMs = DebugPointUtil.getDebugParamOrDefault(
+                        "ExternalDatabase.listTableNames.sleep", "sleepMs", 
0L);
+                if (sleepMs > 0) {
+                    LOG.info("debug point 
ExternalDatabase.listTableNames.sleep hit for {}.{}, sleep {}ms",
+                            extCatalog.getName(), remoteName, sleepMs);
+                    try {
+                        Thread.sleep(sleepMs);
+                    } catch (InterruptedException ignore) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            }
             tableNames = extCatalog.listTableNames(null, 
remoteName).stream().map(tableName -> {
                 String localTableName = 
extCatalog.fromRemoteTableName(remoteName, tableName);
                 if (this.isStoredTableNamesLowerCase()) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java
index 00717f1c89a..020c3703ff0 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/PluginDrivenExternalTable.java
@@ -20,6 +20,7 @@ package org.apache.doris.datasource;
 import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.TableIf.TableType;
+import org.apache.doris.common.util.DebugPointUtil;
 import org.apache.doris.connector.api.Connector;
 import org.apache.doris.connector.api.ConnectorCapability;
 import org.apache.doris.connector.api.ConnectorColumn;
@@ -75,9 +76,33 @@ public class PluginDrivenExternalTable extends ExternalTable 
{
                 && 
connector.getCapabilities().contains(ConnectorCapability.SUPPORTS_PARALLEL_WRITE);
     }
 
+    @Override
+    public boolean supportsExternalMetadataPreload() {
+        if (!(catalog instanceof PluginDrivenExternalCatalog)) {
+            return false;
+        }
+        // Keep plugin-driven preload limited to JDBC until other connector 
types are validated.
+        return "jdbc".equalsIgnoreCase(((PluginDrivenExternalCatalog) 
catalog).getType());
+    }
+
     @Override
     public Optional<SchemaCacheValue> initSchema() {
         PluginDrivenExternalCatalog pluginCatalog = 
(PluginDrivenExternalCatalog) catalog;
+        // Keep the JDBC schema delay debug point available for manual 
regression verification.
+        if ("jdbc".equalsIgnoreCase(pluginCatalog.getType())
+                && 
DebugPointUtil.isEnable("PluginDrivenExternalTable.initSchema.sleep")) {
+            long sleepMs = DebugPointUtil.getDebugParamOrDefault(
+                    "PluginDrivenExternalTable.initSchema.sleep", "sleepMs", 
0L);
+            if (sleepMs > 0) {
+                LOG.info("debug point 
PluginDrivenExternalTable.initSchema.sleep hit for {}.{}, sleep {}ms",
+                        db != null ? db.getRemoteName() : "", getRemoteName(), 
sleepMs);
+                try {
+                    Thread.sleep(sleepMs);
+                } catch (InterruptedException ignore) {
+                    Thread.currentThread().interrupt();
+                }
+            }
+        }
         Connector connector = pluginCatalog.getConnector();
         ConnectorSession session = pluginCatalog.buildConnectorSession();
         ConnectorMetadata metadata = connector.getMetadata(session);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java
index dad5b632fa2..1e42c7878c2 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSExternalTable.java
@@ -440,6 +440,18 @@ public class HMSExternalTable extends ExternalTable 
implements MTMVRelatedTableI
         return getDlaType() == DLAType.HIVE || getDlaType() == DLAType.HUDI;
     }
 
+    @Override
+    public boolean supportsExternalMetadataPreload() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsLatestSnapshotPreload() {
+        // HMSExternalTable may represent Hive, Hudi, or Iceberg tables.
+        // Only snapshot-aware table types should preload latest snapshot 
metadata.
+        return getDlaType() == DLAType.HUDI || getDlaType() == DLAType.ICEBERG;
+    }
+
     @Override
     public Optional<SortedPartitionRanges<String>> 
getSortedPartitionRanges(CatalogRelation scan) {
         if (getDlaType() != DLAType.HIVE) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
index 64d64a3102e..f96ea825a54 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalTable.java
@@ -298,6 +298,16 @@ public class IcebergExternalTable extends ExternalTable 
implements MTMVRelatedTa
         return true;
     }
 
+    @Override
+    public boolean supportsExternalMetadataPreload() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsLatestSnapshotPreload() {
+        return true;
+    }
+
     @VisibleForTesting
     public boolean isValidRelatedTableCached() {
         return isValidRelatedTableCached;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
index 78639761ede..1775a984f2c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonExternalTable.java
@@ -319,6 +319,16 @@ public class PaimonExternalTable extends ExternalTable 
implements MTMVRelatedTab
         return true;
     }
 
+    @Override
+    public boolean supportsExternalMetadataPreload() {
+        return true;
+    }
+
+    @Override
+    public boolean supportsLatestSnapshotPreload() {
+        return true;
+    }
+
     @Override
     public List<Column> getFullSchema() {
         return 
getPaimonSchemaCacheValue(MvccUtil.getSnapshotFromContext(this)).getSchema();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/CascadesContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/CascadesContext.java
index 8d58732eef9..4cba825ee0b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/CascadesContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/CascadesContext.java
@@ -287,7 +287,11 @@ public class CascadesContext implements ScheduleContext {
     }
 
     public TableCollectAndHookInitializer newTableCollector(boolean 
firstLevel) {
-        return new TableCollectAndHookInitializer(this, firstLevel);
+        return newTableCollector(firstLevel, false);
+    }
+
+    public TableCollectAndHookInitializer newTableCollector(boolean 
firstLevel, boolean enablePreloadRule) {
+        return new TableCollectAndHookInitializer(this, firstLevel, 
enablePreloadRule);
     }
 
     public Analyzer newAnalyzer() {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalMetadataPreloadResult.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalMetadataPreloadResult.java
new file mode 100644
index 00000000000..c6cea9916d4
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalMetadataPreloadResult.java
@@ -0,0 +1,65 @@
+// 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.nereids;
+
+/** Summarizes whether external metadata preload ran and what it processed. */
+public class ExternalMetadataPreloadResult {
+    private final boolean executed;
+    private final int candidateTableCount;
+    private final int preloadedTableCount;
+    private final String skipReason;
+    private final long elapsedTimeMs;
+
+    private ExternalMetadataPreloadResult(boolean executed, int 
candidateTableCount,
+            int preloadedTableCount, String skipReason, long elapsedTimeMs) {
+        this.executed = executed;
+        this.candidateTableCount = candidateTableCount;
+        this.preloadedTableCount = preloadedTableCount;
+        this.skipReason = skipReason;
+        this.elapsedTimeMs = elapsedTimeMs;
+    }
+
+    public static ExternalMetadataPreloadResult executed(
+            int candidateTableCount, int preloadedTableCount, long 
elapsedTimeMs) {
+        return new ExternalMetadataPreloadResult(true, candidateTableCount, 
preloadedTableCount, "", elapsedTimeMs);
+    }
+
+    public static ExternalMetadataPreloadResult skipped(int 
candidateTableCount, String skipReason) {
+        return new ExternalMetadataPreloadResult(false, candidateTableCount, 
0, skipReason, 0);
+    }
+
+    public boolean isExecuted() {
+        return executed;
+    }
+
+    public int getCandidateTableCount() {
+        return candidateTableCount;
+    }
+
+    public int getPreloadedTableCount() {
+        return preloadedTableCount;
+    }
+
+    public String getSkipReason() {
+        return skipReason;
+    }
+
+    public long getElapsedTimeMs() {
+        return elapsedTimeMs;
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalTablePreloadInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalTablePreloadInfo.java
new file mode 100644
index 00000000000..715b8c232c1
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/ExternalTablePreloadInfo.java
@@ -0,0 +1,55 @@
+// 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.nereids;
+
+import org.apache.doris.datasource.ExternalTable;
+
+/** Tracks how a single external table is referenced before metadata preload 
happens. */
+public class ExternalTablePreloadInfo {
+    private final ExternalTable table;
+    private boolean hasLatestOnlyRelation;
+    private boolean hasNonLatestRelation;
+
+    public ExternalTablePreloadInfo(ExternalTable table) {
+        this.table = table;
+    }
+
+    public ExternalTable getTable() {
+        return table;
+    }
+
+    public void markLatestRelation() {
+        hasLatestOnlyRelation = true;
+    }
+
+    public void markNonLatestRelation() {
+        hasNonLatestRelation = true;
+    }
+
+    public boolean hasLatestOnlyRelation() {
+        return hasLatestOnlyRelation;
+    }
+
+    public boolean hasNonLatestRelation() {
+        return hasNonLatestRelation;
+    }
+
+    public boolean shouldPreloadLatestSnapshot() {
+        return hasLatestOnlyRelation && !hasNonLatestRelation;
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
index 5ccca2a185f..64142641e05 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/NereidsPlanner.java
@@ -419,7 +419,35 @@ public class NereidsPlanner extends Planner {
         if (LOG.isDebugEnabled()) {
             LOG.debug("Start collect and lock table");
         }
-        keepOrShowPlanProcess(showPlanProcess, () -> 
cascadesContext.newTableCollector(true).collect());
+        keepOrShowPlanProcess(showPlanProcess, () -> 
cascadesContext.newTableCollector(true, true).collect());
+        // Read the preload result produced by the collect-phase rule before 
taking internal table locks.
+        ExternalMetadataPreloadResult preloadResult = 
statementContext.getExternalMetadataPreloadResult()
+                .orElse(ExternalMetadataPreloadResult.skipped(
+                        
statementContext.getExternalTablePreloadCandidateCount(), "preload rule did not 
run"));
+        // Record preload timing in the query profile as a dedicated planner 
sub-stage.
+        if (statementContext.getConnectContext().getExecutor() != null && 
preloadResult.isExecuted()) {
+            
statementContext.getConnectContext().getExecutor().getSummaryProfile()
+                    
.addNereidsPreloadExternalMetadataTime(preloadResult.getElapsedTimeMs());
+        }
+        // Keep a concise debug summary for the entire preload phase.
+        if (LOG.isDebugEnabled()) {
+            if (preloadResult.isExecuted()) {
+                LOG.debug("{} preloaded external metadata for {} of {} 
candidate tables in {} ms",
+                        
statementContext.getConnectContext().getQueryIdentifier(),
+                        preloadResult.getPreloadedTableCount(),
+                        preloadResult.getCandidateTableCount(),
+                        preloadResult.getElapsedTimeMs());
+            } else {
+                LOG.debug("{} skip external metadata preload before lock: {} 
[candidateTableCount={}]",
+                        
statementContext.getConnectContext().getQueryIdentifier(), 
preloadResult.getSkipReason(),
+                        preloadResult.getCandidateTableCount());
+            }
+        }
+        if (statementContext.getConnectContext().getExecutor() != null) {
+            // Track only the actual lock() call here so the dedicated preload 
stage is not double counted.
+            
statementContext.getConnectContext().getExecutor().getSummaryProfile()
+                    .setNereidsLockTableStartTime(TimeUtils.getStartTimeMs());
+        }
         statementContext.lock();
         cascadesContext.setCteContext(new CTEContext());
         NereidsTracer.logImportantTime("EndCollectAndLockTables");
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 37a7a2b3768..cd6a985218d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -30,6 +30,7 @@ import org.apache.doris.catalog.View;
 import org.apache.doris.common.Id;
 import org.apache.doris.common.IdGenerator;
 import org.apache.doris.common.Pair;
+import org.apache.doris.datasource.ExternalTable;
 import org.apache.doris.datasource.mvcc.MvccSnapshot;
 import org.apache.doris.datasource.mvcc.MvccTable;
 import org.apache.doris.datasource.mvcc.MvccTableInfo;
@@ -268,6 +269,9 @@ public class StatementContext implements Closeable {
     private Backend groupCommitMergeBackend;
 
     private final Map<MvccTableInfo, MvccSnapshot> snapshots = 
Maps.newHashMap();
+    // Record external tables that can be preloaded before internal table 
locks are acquired.
+    private final Map<Long, ExternalTablePreloadInfo> 
externalTablePreloadInfos = new LinkedHashMap<>();
+    private ExternalMetadataPreloadResult externalMetadataPreloadResult;
 
     private boolean privChecked;
 
@@ -484,6 +488,27 @@ public class StatementContext implements Closeable {
         usedAIResourceNames.add(resourceName);
     }
 
+    /**
+     * Register an external relation that may preload metadata before internal 
table locks are acquired.
+     *
+     * @param table external table referenced by the relation
+     * @param tableSnapshot optional explicit snapshot specification on the 
relation
+     * @param scanParams optional relation scan parameters such as branch or 
tag
+     */
+    public void registerExternalTableForPreload(TableIf table, 
Optional<TableSnapshot> tableSnapshot,
+            Optional<TableScanParams> scanParams) {
+        if (!(table instanceof ExternalTable) || 
!table.supportsExternalMetadataPreload()) {
+            return;
+        }
+        ExternalTablePreloadInfo preloadInfo = 
externalTablePreloadInfos.computeIfAbsent(table.getId(),
+                id -> new ExternalTablePreloadInfo((ExternalTable) table));
+        if (tableSnapshot.isPresent() || scanParams.isPresent()) {
+            preloadInfo.markNonLatestRelation();
+        } else {
+            preloadInfo.markLatestRelation();
+        }
+    }
+
     public void setOriginStatement(OriginStatement originStatement) {
         this.originStatement = originStatement;
         if (originStatement != null && sqlCacheContext != null) {
@@ -992,6 +1017,37 @@ public class StatementContext implements Closeable {
         snapshots.put(mvccTableInfo, snapshot);
     }
 
+    public Collection<ExternalTablePreloadInfo> getExternalTablePreloadInfos() 
{
+        return 
Collections.unmodifiableCollection(externalTablePreloadInfos.values());
+    }
+
+    public int getExternalTablePreloadCandidateCount() {
+        return externalTablePreloadInfos.size();
+    }
+
+    public boolean hasAnyPlanReadLockTable() {
+        return containsPlanReadLockTable(tables.values())
+                || containsPlanReadLockTable(mtmvRelatedTables.values())
+                || containsPlanReadLockTable(insertTargetTables.values());
+    }
+
+    public Optional<ExternalMetadataPreloadResult> 
getExternalMetadataPreloadResult() {
+        return Optional.ofNullable(externalMetadataPreloadResult);
+    }
+
+    public void setExternalMetadataPreloadResult(ExternalMetadataPreloadResult 
result) {
+        this.externalMetadataPreloadResult = result;
+    }
+
+    private boolean containsPlanReadLockTable(Collection<TableIf> tableIfs) {
+        for (TableIf tableIf : tableIfs) {
+            if (tableIf.needReadLockWhenPlan()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private static class CloseableResource implements Closeable {
         public final String resourceName;
         public final String threadName;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/jobs/executor/TableCollectAndHookInitializer.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/jobs/executor/TableCollectAndHookInitializer.java
index 02b2dc76fb5..c796a742097 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/jobs/executor/TableCollectAndHookInitializer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/jobs/executor/TableCollectAndHookInitializer.java
@@ -21,6 +21,7 @@ import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.jobs.rewrite.RewriteJob;
 import org.apache.doris.nereids.rules.analysis.AddInitMaterializationHook;
 import org.apache.doris.nereids.rules.analysis.CollectRelation;
+import org.apache.doris.nereids.rules.analysis.PreloadExternalMetadata;
 import org.apache.doris.nereids.trees.plans.logical.LogicalView;
 
 import com.google.common.collect.ImmutableSet;
@@ -35,14 +36,22 @@ public class TableCollectAndHookInitializer extends 
AbstractBatchJobExecutor {
 
     public final List<RewriteJob> collectJobs;
 
+    /**
+     * Keep the legacy collector entry point for nested collect passes that 
should not trigger preload.
+     */
+    public TableCollectAndHookInitializer(CascadesContext cascadesContext, 
boolean firstLevel) {
+        this(cascadesContext, firstLevel, false);
+    }
+
     /**
      * constructor of Analyzer. For view, we only do bind relation since other 
analyze step will do by outer Analyzer.
      *
      * @param cascadesContext current context for analyzer
      */
-    public TableCollectAndHookInitializer(CascadesContext cascadesContext, 
boolean firstLevel) {
+    public TableCollectAndHookInitializer(
+            CascadesContext cascadesContext, boolean firstLevel, boolean 
enablePreloadRule) {
         super(cascadesContext);
-        collectJobs = buildCollectTableJobs(firstLevel);
+        collectJobs = buildCollectTableJobs(firstLevel, enablePreloadRule);
     }
 
     @Override
@@ -57,14 +66,21 @@ public class TableCollectAndHookInitializer extends 
AbstractBatchJobExecutor {
         execute();
     }
 
-    private static List<RewriteJob> buildCollectTableJobs(boolean firstLevel) {
+    private static List<RewriteJob> buildCollectTableJobs(boolean firstLevel, 
boolean enablePreloadRule) {
         return notTraverseChildrenOf(
                 ImmutableSet.of(LogicalView.class),
-                () -> 
TableCollectAndHookInitializer.buildCollectorJobs(firstLevel)
+                () -> 
TableCollectAndHookInitializer.buildCollectorJobs(firstLevel, enablePreloadRule)
         );
     }
 
-    private static List<RewriteJob> buildCollectorJobs(boolean firstLevel) {
+    private static List<RewriteJob> buildCollectorJobs(boolean firstLevel, 
boolean enablePreloadRule) {
+        if (enablePreloadRule) {
+            return jobs(
+                    topDown(new AddInitMaterializationHook()),
+                    topDown(new CollectRelation(firstLevel)),
+                    topDown(new PreloadExternalMetadata())
+            );
+        }
         return jobs(
                 topDown(new AddInitMaterializationHook()),
                 topDown(new CollectRelation(firstLevel))
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java
index 26d40c59d1a..170823cb035 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/RuleType.java
@@ -49,6 +49,7 @@ public enum RuleType {
     INIT_MATERIALIZATION_HOOK_FOR_FILE_SINK(RuleTypeClass.REWRITE),
     INIT_MATERIALIZATION_HOOK_FOR_TABLE_SINK(RuleTypeClass.REWRITE),
     INIT_MATERIALIZATION_HOOK_FOR_RESULT_SINK(RuleTypeClass.REWRITE),
+    PRELOAD_EXTERNAL_METADATA(RuleTypeClass.REWRITE),
     BINDING_INSERT_FILE(RuleTypeClass.REWRITE),
     BINDING_ONE_ROW_RELATION_SLOT(RuleTypeClass.REWRITE),
     BINDING_RELATION(RuleTypeClass.REWRITE),
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectRelation.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectRelation.java
index b9f6efc5f12..34819b56821 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectRelation.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectRelation.java
@@ -209,6 +209,11 @@ public class CollectRelation implements 
AnalysisRuleFactory {
         } else {
             StatementContext statementContext = 
cascadesContext.getConnectContext().getStatementContext();
             table = statementContext.getAndCacheTable(tableQualifier, 
tableFrom, unboundRelation);
+            // Record relation-level metadata so the planner can preload 
latest external metadata before locking.
+            if (tableFrom == TableFrom.QUERY && unboundRelation.isPresent()) {
+                statementContext.registerExternalTableForPreload(table, 
unboundRelation.get().getTableSnapshot(),
+                        
Optional.ofNullable(unboundRelation.get().getScanParams()));
+            }
             if (firstLevel) {
                 statementContext.getOneLevelTables().put(tableQualifier, 
table);
             }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/PreloadExternalMetadata.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/PreloadExternalMetadata.java
new file mode 100644
index 00000000000..524a8b6e79d
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/PreloadExternalMetadata.java
@@ -0,0 +1,134 @@
+// 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.nereids.rules.analysis;
+
+import org.apache.doris.common.util.TimeUtils;
+import org.apache.doris.datasource.ExternalTable;
+import org.apache.doris.nereids.ExternalMetadataPreloadResult;
+import org.apache.doris.nereids.ExternalTablePreloadInfo;
+import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.nereids.rules.Rule;
+import org.apache.doris.nereids.rules.RuleType;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Preload external metadata after relation collection and before internal 
table locks are acquired.
+ */
+public class PreloadExternalMetadata implements AnalysisRuleFactory {
+    private static final Logger LOG = 
LogManager.getLogger(PreloadExternalMetadata.class);
+
+    @Override
+    public List<Rule> buildRules() {
+        return ImmutableList.of(
+                any().thenApply(ctx -> {
+                    StatementContext statementContext = ctx.statementContext;
+                    // Run preload at most once even if the collect pipeline 
re-enters the same statement context.
+                    if 
(!statementContext.getExternalMetadataPreloadResult().isPresent()) {
+                        
statementContext.setExternalMetadataPreloadResult(executePreload(statementContext));
+                    }
+                    return ctx.root;
+                }).toRule(RuleType.PRELOAD_EXTERNAL_METADATA)
+        );
+    }
+
+    /**
+     * Execute external metadata preload after relation collection and before 
internal table locks.
+     */
+    public ExternalMetadataPreloadResult executePreload(StatementContext 
statementContext) {
+        long preloadStartTime = TimeUtils.getStartTimeMs();
+        Optional<String> skipReason = getSkipReason(statementContext);
+        if (skipReason.isPresent()) {
+            if (LOG.isDebugEnabled()) {
+                LOG.debug("{} skip external metadata preload before lock: {}",
+                        getPreloadQueryIdentifier(statementContext), 
skipReason.get());
+            }
+            return ExternalMetadataPreloadResult.skipped(
+                    statementContext.getExternalTablePreloadCandidateCount(), 
skipReason.get());
+        }
+        int preloadedTableCount = 0;
+        for (ExternalTablePreloadInfo preloadInfo : 
statementContext.getExternalTablePreloadInfos()) {
+            if (preloadExternalTable(statementContext, preloadInfo)) {
+                preloadedTableCount++;
+            }
+        }
+        return ExternalMetadataPreloadResult.executed(
+                statementContext.getExternalTablePreloadCandidateCount(),
+                preloadedTableCount,
+                TimeUtils.getElapsedTimeMs(preloadStartTime));
+    }
+
+    private Optional<String> getSkipReason(StatementContext statementContext) {
+        ConnectContext connectContext = statementContext.getConnectContext();
+        if (connectContext == null || connectContext.getSessionVariable() == 
null
+                || 
!connectContext.getSessionVariable().isEnablePreloadExternalMetadata()) {
+            return Optional.of("session variable 
enable_preload_external_metadata is disabled");
+        }
+        if (statementContext.getExternalTablePreloadCandidateCount() == 0) {
+            return Optional.of("no external preload candidates were 
collected");
+        }
+        if (!statementContext.hasAnyPlanReadLockTable()) {
+            return Optional.of("no internal tables require plan-time read 
lock");
+        }
+        return Optional.empty();
+    }
+
+    private boolean preloadExternalTable(StatementContext statementContext, 
ExternalTablePreloadInfo preloadInfo) {
+        ExternalTable table = preloadInfo.getTable();
+        long preloadStartTime = TimeUtils.getStartTimeMs();
+        boolean supportsLatestSnapshot = table.supportsLatestSnapshotPreload();
+        boolean latestOnlyRelation = preloadInfo.shouldPreloadLatestSnapshot();
+        boolean preloadLatestSnapshot = latestOnlyRelation && 
supportsLatestSnapshot;
+        // Skip schema and partition warmup for snapshot-aware tables when 
only non-latest relations are referenced.
+        boolean preloadSchema = !supportsLatestSnapshot || latestOnlyRelation;
+        boolean preloadPartition = preloadSchema && 
table.supportInternalPartitionPruned();
+        if (preloadLatestSnapshot) {
+            statementContext.loadSnapshots(table, Optional.empty(), 
Optional.empty());
+        }
+        if (preloadSchema) {
+            table.getBaseSchema();
+        }
+        if (preloadPartition) {
+            table.initSelectedPartitions(statementContext.getSnapshot(table));
+        }
+        if (LOG.isDebugEnabled()) {
+            LOG.debug("{} preloaded external metadata for table {} "
+                            + "[supportsLatestSnapshot={}, 
preloadLatestSnapshot={}, preloadSchema={}, "
+                            + "preloadPartition={}, hasLatestRelation={}, 
hasNonLatestRelation={}, elapsedMs={}]",
+                    getPreloadQueryIdentifier(statementContext), 
getExternalTableLogName(table), supportsLatestSnapshot,
+                    preloadLatestSnapshot, preloadSchema, preloadPartition, 
preloadInfo.hasLatestOnlyRelation(),
+                    preloadInfo.hasNonLatestRelation(), 
TimeUtils.getElapsedTimeMs(preloadStartTime));
+        }
+        return preloadLatestSnapshot || preloadSchema || preloadPartition;
+    }
+
+    private String getPreloadQueryIdentifier(StatementContext 
statementContext) {
+        ConnectContext connectContext = statementContext.getConnectContext();
+        return connectContext == null ? "stmt[unknown]" : 
connectContext.getQueryIdentifier();
+    }
+
+    private String getExternalTableLogName(ExternalTable table) {
+        return table.getCatalog().getName() + "." + table.getDbName() + "." + 
table.getName();
+    }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 1227dce7ba6..72478bde221 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -404,6 +404,7 @@ public class SessionVariable implements Serializable, 
Writable {
     public static final String REQUIRED_GROUP_IDS = "required_group_ids";
 
     public static final String ENABLE_NEREIDS_PLANNER = 
"enable_nereids_planner";
+    public static final String ENABLE_PRELOAD_EXTERNAL_METADATA = 
"enable_preload_external_metadata";
     public static final String ENABLE_NEREIDS_DISTRIBUTE_PLANNER = 
"enable_nereids_distribute_planner";
     public static final String DISABLE_NEREIDS_RULES = "disable_nereids_rules";
     public static final String ENABLE_NEREIDS_RULES = "enable_nereids_rules";
@@ -1984,6 +1985,14 @@ public class SessionVariable implements Serializable, 
Writable {
     @VarAttrDef.VarAttr(name = ENABLE_NEREIDS_PLANNER, needForward = true, 
varType = VariableAnnotation.REMOVED)
     private boolean enableNereidsPlanner = true;
 
+    @VarAttrDef.VarAttr(name = ENABLE_PRELOAD_EXTERNAL_METADATA,
+            needForward = true, fuzzy = false, varType = 
VariableAnnotation.EXPERIMENTAL, description = {
+                "是否在获取内表规划期读锁前预加载 Hive/Hudi/Iceberg/Paimon/JDBC 外表元数据",
+                "Whether to preload Hive/Hudi/Iceberg/Paimon/JDBC external 
table metadata before internal table "
+                        + "plan-time read locks are acquired"
+            })
+    private boolean enablePreloadExternalMetadata = false;
+
     @VarAttrDef.VarAttr(name = DISABLE_NEREIDS_RULES, needForward = true)
     private String disableNereidsRules = "";
 
@@ -4498,6 +4507,14 @@ public class SessionVariable implements Serializable, 
Writable {
         return enableSqlCache;
     }
 
+    public boolean isEnablePreloadExternalMetadata() {
+        return enablePreloadExternalMetadata;
+    }
+
+    public void setEnablePreloadExternalMetadata(boolean enablePreload) {
+        this.enablePreloadExternalMetadata = enablePreload;
+    }
+
     public void setEnableSqlCache(boolean enableSqlCache) {
         this.enableSqlCache = enableSqlCache;
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/common/profile/SummaryProfileTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/common/profile/SummaryProfileTest.java
index a809d4d982f..afe40a68dc8 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/common/profile/SummaryProfileTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/common/profile/SummaryProfileTest.java
@@ -29,6 +29,7 @@ public class SummaryProfileTest {
         profile.setQueryBeginTime(1);
         profile.setParseSqlStartTime(3);
         profile.setParseSqlFinishTime(6);
+        profile.setNereidsLockTableStartTime(8);
         profile.setNereidsLockTableFinishTime(10);
         profile.setNereidsAnalysisTime(15);
         profile.setNereidsRewriteTime(21);
@@ -41,6 +42,8 @@ public class SummaryProfileTest {
         profile.setQueryScheduleFinishTime(78);
         profile.setQueryFetchResultFinishTime(91);
 
+        // Record the standalone preload stage before the planner takes 
internal table locks.
+        profile.addNereidsPreloadExternalMetadataTime(2);
         profile.addCollectTablePartitionTime(7);
         // update summary time
         profile.update(ImmutableMap.of());
@@ -48,7 +51,9 @@ public class SummaryProfileTest {
         RuntimeProfile executionSummary = profile.getExecutionSummary();
         
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.PARSE_SQL_TIME),
 "3ms");
         
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.PLAN_TIME),
 "60ms");
-        
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.NEREIDS_LOCK_TABLE_TIME),
 "4ms");
+        Assertions.assertEquals(executionSummary.getInfoString(
+                SummaryProfile.NEREIDS_PRELOAD_EXTERNAL_METADATA_TIME), "2ms");
+        
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.NEREIDS_LOCK_TABLE_TIME),
 "2ms");
         
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.NEREIDS_ANALYSIS_TIME),
 "5ms");
         
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.NEREIDS_REWRITE_TIME),
 "6ms");
 
@@ -61,6 +66,18 @@ public class SummaryProfileTest {
         
Assertions.assertEquals(executionSummary.getInfoString(SummaryProfile.WAIT_FETCH_RESULT_TIME),
 "13ms");
     }
 
+    @Test
+    public void testPreloadExternalMetadataTimeCounter() {
+        SummaryProfile profile = new SummaryProfile();
+
+        // Verify the dedicated preload counter is accumulated independently 
from other planner stages.
+        profile.addNereidsPreloadExternalMetadataTime(12);
+        profile.addNereidsPreloadExternalMetadataTime(8);
+
+        Assertions.assertEquals(20, 
profile.getNereidsPreloadExternalMetadataTimeMs());
+        Assertions.assertEquals("20ms", 
profile.getPrettyNereidsPreloadExternalMetadataTime());
+    }
+
     @Test
     public void testExternalTableMetaSummary() {
         SummaryProfile profile = new SummaryProfile();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java
index e02b1d25612..2c3173af8c0 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/PluginDrivenExternalTableEngineTest.java
@@ -19,7 +19,12 @@ package org.apache.doris.datasource;
 
 import org.apache.doris.catalog.TableIf.TableType;
 import org.apache.doris.connector.api.Connector;
+import org.apache.doris.connector.api.ConnectorColumn;
 import org.apache.doris.connector.api.ConnectorMetadata;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.ConnectorTableSchema;
+import org.apache.doris.connector.api.ConnectorType;
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -29,6 +34,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 /**
  * Tests that {@link PluginDrivenExternalTable} returns the correct legacy 
engine
@@ -93,11 +99,37 @@ public class PluginDrivenExternalTableEngineTest {
                 "Internal table type should always be PLUGIN_EXTERNAL_TABLE");
     }
 
+    @Test
+    public void testInitSchemaReturnsEmptyWhenTableHandleMissing() {
+        Connector connector = createMockConnector(false, false);
+        PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", 
connector);
+
+        // Return an empty schema result when the connector cannot resolve the 
table handle.
+        Assertions.assertFalse(table.initSchema().isPresent(),
+                "Missing connector table handles should produce an empty 
schema result");
+    }
+
+    @Test
+    public void testInitSchemaAppliesRemoteColumnNameMapping() {
+        Connector connector = createMockConnector(true, true);
+        PluginDrivenExternalTable table = createTableWithCatalogType("jdbc", 
connector);
+
+        // Verify that plugin-driven schema loading preserves mapped column 
names from the connector.
+        Optional<SchemaCacheValue> schema = table.initSchema();
+        Assertions.assertTrue(schema.isPresent(), "Schema should be present 
when a table handle exists");
+        Assertions.assertEquals("mapped_id", 
schema.get().getSchema().get(0).getName(),
+                "Mapped remote column names should be reflected in Doris 
schema metadata");
+    }
+
     // -------- Helpers --------
 
     private PluginDrivenExternalTable createTableWithCatalogType(String 
catalogType) {
-        TestablePluginCatalog catalog = new TestablePluginCatalog(catalogType);
-        ExternalDatabase<PluginDrivenExternalTable> db = 
Mockito.mock(ExternalDatabase.class);
+        return createTableWithCatalogType(catalogType, 
createMockConnector(true, false));
+    }
+
+    private PluginDrivenExternalTable createTableWithCatalogType(String 
catalogType, Connector connector) {
+        TestablePluginCatalog catalog = new TestablePluginCatalog(catalogType, 
connector);
+        ExternalDatabase<PluginDrivenExternalTable> db = 
mockExternalDatabase();
         Mockito.when(db.getFullName()).thenReturn("test_db");
         Mockito.when(db.getRemoteName()).thenReturn("test_db");
 
@@ -106,6 +138,31 @@ public class PluginDrivenExternalTableEngineTest {
         return table;
     }
 
+    private Connector createMockConnector(boolean tableExists, boolean 
renameColumn) {
+        Connector connector = Mockito.mock(Connector.class);
+        ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+        ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+        ConnectorTableSchema schema = new ConnectorTableSchema("test_table",
+                Collections.singletonList(new ConnectorColumn(
+                        "id", ConnectorType.of("INT", -1, -1), "", true, null, 
true)),
+                null, Collections.emptyMap());
+        
Mockito.when(connector.getMetadata(Mockito.any())).thenReturn(metadata);
+        
Mockito.when(metadata.getTableHandle(Mockito.any(ConnectorSession.class), 
Mockito.anyString(), Mockito.anyString()))
+                .thenReturn(tableExists ? Optional.of(handle) : 
Optional.empty());
+        
Mockito.when(metadata.getTableSchema(Mockito.any(ConnectorSession.class), 
Mockito.eq(handle))).thenReturn(schema);
+        
Mockito.when(metadata.fromRemoteColumnName(Mockito.any(ConnectorSession.class), 
Mockito.anyString(),
+                Mockito.anyString(), 
Mockito.anyString())).thenAnswer(invocation -> {
+                    String remoteName = invocation.getArgument(3);
+                    return renameColumn ? "mapped_" + remoteName : remoteName;
+                });
+        return connector;
+    }
+
+    @SuppressWarnings("unchecked")
+    private ExternalDatabase<PluginDrivenExternalTable> mockExternalDatabase() 
{
+        return Mockito.mock(ExternalDatabase.class);
+    }
+
     /**
      * Minimal testable PluginDrivenExternalCatalog that returns a 
configurable type
      * without requiring full Doris environment initialization.
@@ -113,8 +170,8 @@ public class PluginDrivenExternalTableEngineTest {
     private static class TestablePluginCatalog extends 
PluginDrivenExternalCatalog {
         private final String catalogType;
 
-        TestablePluginCatalog(String catalogType) {
-            super(1L, "test-catalog", null, makeProps(catalogType), "", 
mockConnector());
+        TestablePluginCatalog(String catalogType, Connector connector) {
+            super(1L, "test-catalog", null, makeProps(catalogType), "", 
connector);
             this.catalogType = catalogType;
         }
 
@@ -143,12 +200,5 @@ public class PluginDrivenExternalTableEngineTest {
             props.put("type", type);
             return props;
         }
-
-        private static Connector mockConnector() {
-            Connector c = Mockito.mock(Connector.class);
-            ConnectorMetadata meta = Mockito.mock(ConnectorMetadata.class);
-            Mockito.when(c.getMetadata(Mockito.any())).thenReturn(meta);
-            return c;
-        }
     }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/NereidsPlannerTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/NereidsPlannerTest.java
new file mode 100644
index 00000000000..b57a30a2d15
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/NereidsPlannerTest.java
@@ -0,0 +1,82 @@
+// 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.nereids;
+
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.common.profile.SummaryProfile;
+import org.apache.doris.nereids.jobs.executor.TableCollectAndHookInitializer;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.OriginStatement;
+import org.apache.doris.qe.StmtExecutor;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class NereidsPlannerTest {
+
+    @Test
+    public void testCollectAndLockTableRecordsPreloadTimeWhenExecuted() {
+        SummaryProfile summaryProfile = Mockito.mock(SummaryProfile.class);
+        NereidsPlanner planner = createPlanner(
+                ExternalMetadataPreloadResult.executed(2, 1, 123L), 
summaryProfile);
+
+        // Record the dedicated preload counter only when the external preload 
phase is executed.
+        planner.collectAndLockTable(false);
+
+        
Mockito.verify(summaryProfile).addNereidsPreloadExternalMetadataTime(Mockito.anyLong());
+        
Mockito.verify(summaryProfile).setNereidsLockTableStartTime(Mockito.anyLong());
+        
Mockito.verify(summaryProfile).setNereidsLockTableFinishTime(Mockito.anyLong());
+    }
+
+    @Test
+    public void testCollectAndLockTableSkipsPreloadCounterWhenNotExecuted() {
+        SummaryProfile summaryProfile = Mockito.mock(SummaryProfile.class);
+        NereidsPlanner planner = createPlanner(
+                ExternalMetadataPreloadResult.skipped(1, "skip preload"), 
summaryProfile);
+
+        // Keep the dedicated preload counter untouched when preload is 
skipped before table locking.
+        planner.collectAndLockTable(false);
+
+        Mockito.verify(summaryProfile, 
Mockito.never()).addNereidsPreloadExternalMetadataTime(Mockito.anyLong());
+        
Mockito.verify(summaryProfile).setNereidsLockTableStartTime(Mockito.anyLong());
+        
Mockito.verify(summaryProfile).setNereidsLockTableFinishTime(Mockito.anyLong());
+    }
+
+    private NereidsPlanner createPlanner(ExternalMetadataPreloadResult 
preloadResult, SummaryProfile summaryProfile) {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        StmtExecutor executor = Mockito.mock(StmtExecutor.class);
+        StatementContext statementContext = Mockito.spy(
+                new StatementContext(connectContext, new 
OriginStatement("select 1", 0)));
+        CascadesContext cascadesContext = Mockito.mock(CascadesContext.class);
+        TableCollectAndHookInitializer tableCollector = 
Mockito.mock(TableCollectAndHookInitializer.class);
+
+        // Mock the planner entry point so the test only exercises the 
preload/profile control flow.
+        Mockito.when(connectContext.getExecutor()).thenReturn(executor);
+        Mockito.when(executor.getSummaryProfile()).thenReturn(summaryProfile);
+        Mockito.when(cascadesContext.newTableCollector(true, 
true)).thenReturn(tableCollector);
+        Mockito.doAnswer(invocation -> {
+            statementContext.setExternalMetadataPreloadResult(preloadResult);
+            return null;
+        }).when(tableCollector).collect();
+        Mockito.doNothing().when(statementContext).lock();
+
+        NereidsPlanner planner = new NereidsPlanner(statementContext);
+        Deencapsulation.setField(planner, "cascadesContext", cascadesContext);
+        return planner;
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java
new file mode 100644
index 00000000000..8db96ac9a6d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/StatementContextTest.java
@@ -0,0 +1,559 @@
+// 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.nereids;
+
+import org.apache.doris.analysis.TableSnapshot;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.datasource.PluginDrivenExternalTable;
+import org.apache.doris.datasource.hive.HMSExternalTable;
+import org.apache.doris.datasource.hive.HMSExternalTable.DLAType;
+import org.apache.doris.datasource.iceberg.IcebergExternalTable;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
+import org.apache.doris.datasource.paimon.PaimonExternalTable;
+import org.apache.doris.nereids.rules.analysis.PreloadExternalMetadata;
+import 
org.apache.doris.nereids.trees.plans.logical.LogicalFileScan.SelectedPartitions;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.OriginStatement;
+import org.apache.doris.qe.SessionVariable;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Optional;
+
+public class StatementContextTest {
+
+    @Test
+    public void testPreloadExternalTablesBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Mock the latest Hudi preload path and a lock-requiring internal 
table.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        
Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-1");
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(10L);
+        Mockito.when(hmsExternalTable.getName()).thenReturn("hudi_tbl");
+        Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HUDI);
+        
Mockito.when(hmsExternalTable.loadSnapshot(Mockito.<Optional<TableSnapshot>>any(),
 Mockito.any()))
+                .thenReturn(mvccSnapshot);
+        
Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(true);
+        
Mockito.when(hmsExternalTable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            Mockito.verify(hmsExternalTable, Mockito.times(1))
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema();
+            Mockito.verify(hmsExternalTable, 
Mockito.times(1)).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testPreloadHiveSchemaAndPartitionsBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Cover the plain Hive path: no latest snapshot preload, but schema 
and partition metadata are warmed.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        
Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-hive");
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(19L);
+        Mockito.when(hmsExternalTable.getName()).thenReturn("hive_tbl");
+        Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(false);
+        Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HIVE);
+        
Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(true);
+        
Mockito.when(hmsExternalTable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            Mockito.verify(hmsExternalTable, Mockito.never())
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema();
+            Mockito.verify(hmsExternalTable, 
Mockito.times(1)).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipPreloadWhenSessionVariableDisabled() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        SessionVariable sessionVariable = new SessionVariable();
+
+        // Keep the preload switch disabled so no external access should 
happen.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(11L);
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertFalse(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(
+                    "session variable enable_preload_external_metadata is 
disabled", result.getSkipReason());
+            Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema();
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipLatestPreloadWhenExplicitSnapshotExists() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Mark one relation as latest and another relation as explicit 
snapshot, then skip latest snapshot preload.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        
Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-2");
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(12L);
+        Mockito.when(hmsExternalTable.getName()).thenReturn("hudi_tbl");
+        Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(hmsExternalTable.supportsLatestSnapshotPreload()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.HUDI);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+            statementContext.registerExternalTableForPreload(hmsExternalTable,
+                    Optional.of(new TableSnapshot("2024-01-01 00:00:00", 
TableSnapshot.VersionType.TIME)),
+                    Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            Mockito.verify(hmsExternalTable, Mockito.never())
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema();
+            Mockito.verify(hmsExternalTable, 
Mockito.never()).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testPreloadHmsIcebergLatestSnapshotBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Cover the HMS Iceberg branch using the real trait implementation.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(14L);
+        Mockito.when(hmsExternalTable.getName()).thenReturn("hms_iceberg_tbl");
+        Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.doCallRealMethod().when(hmsExternalTable).supportsLatestSnapshotPreload();
+        
Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.ICEBERG);
+        
Mockito.when(hmsExternalTable.loadSnapshot(Mockito.<Optional<TableSnapshot>>any(),
 Mockito.any()))
+                .thenReturn(mvccSnapshot);
+        
Mockito.when(hmsExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(false);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            Mockito.verify(hmsExternalTable, Mockito.times(1))
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(hmsExternalTable, Mockito.times(1)).getBaseSchema();
+            Mockito.verify(hmsExternalTable, 
Mockito.never()).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipHmsIcebergPreloadWhenOnlyNonLatestRelationExists() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Skip latest schema warmup when HMS Iceberg is referenced only by 
non-latest relations.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(15L);
+        Mockito.when(hmsExternalTable.getName()).thenReturn("hms_iceberg_tbl");
+        Mockito.when(hmsExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.doCallRealMethod().when(hmsExternalTable).supportsLatestSnapshotPreload();
+        
Mockito.when(hmsExternalTable.getDlaType()).thenReturn(DLAType.ICEBERG);
+        
Mockito.when(hmsExternalTable.supportInternalPartitionPruned()).thenReturn(false);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable,
+                    Optional.of(new TableSnapshot("2024-01-01 00:00:00", 
TableSnapshot.VersionType.TIME)),
+                    Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            Mockito.verify(hmsExternalTable, Mockito.never())
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema();
+            Mockito.verify(hmsExternalTable, 
Mockito.never()).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testPreloadJdbcExternalTablesBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        PluginDrivenExternalTable jdbcExternalTable = 
Mockito.mock(PluginDrivenExternalTable.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Route preload through the JDBC plugin catalog and keep it 
schema-only.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        
Mockito.when(connectContext.getQueryIdentifier()).thenReturn("query-3");
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(jdbcExternalTable.getId()).thenReturn(13L);
+        
Mockito.when(jdbcExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(jdbcExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(jdbcExternalTable.supportInternalPartitionPruned()).thenReturn(false);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            
statementContext.registerExternalTableForPreload(jdbcExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            Mockito.verify(jdbcExternalTable, 
Mockito.times(1)).getBaseSchema();
+            Mockito.verify(jdbcExternalTable, 
Mockito.never()).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipPreloadForNonJdbcPluginExternalTable() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        PluginDrivenExternalTable pluginExternalTable = 
Mockito.mock(PluginDrivenExternalTable.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Keep non-JDBC plugin catalogs outside the preload whitelist.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(pluginExternalTable.getId()).thenReturn(14L);
+        
Mockito.when(pluginExternalTable.supportsExternalMetadataPreload()).thenReturn(false);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            
statementContext.registerExternalTableForPreload(pluginExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertFalse(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(
+                    "no external preload candidates were collected", 
result.getSkipReason());
+            Mockito.verify(pluginExternalTable, 
Mockito.never()).getBaseSchema();
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipPreloadWhenNoInternalTableNeedsPlanReadLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        HMSExternalTable hmsExternalTable = 
Mockito.mock(HMSExternalTable.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Skip preload when the statement does not require any internal 
plan-time read lock.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(false);
+        Mockito.when(hmsExternalTable.getId()).thenReturn(15L);
+        
Mockito.when(hmsExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            statementContext.registerExternalTableForPreload(hmsExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertFalse(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(
+                    "no internal tables require plan-time read lock", 
result.getSkipReason());
+            Mockito.verify(hmsExternalTable, Mockito.never()).getBaseSchema();
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testPreloadIcebergLatestSnapshotBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        IcebergExternalTable icebergExternalTable = 
Mockito.mock(IcebergExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Cover the dedicated Iceberg latest-snapshot preload branch before 
the lock phase.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(icebergExternalTable.getId()).thenReturn(16L);
+        Mockito.when(icebergExternalTable.getName()).thenReturn("iceberg_tbl");
+        Mockito.when(icebergExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(icebergExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(icebergExternalTable.supportsLatestSnapshotPreload()).thenReturn(true);
+        
Mockito.when(icebergExternalTable.loadSnapshot(Mockito.<Optional<TableSnapshot>>any(),
 Mockito.any()))
+                .thenReturn(mvccSnapshot);
+        
Mockito.when(icebergExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(icebergExternalTable.supportInternalPartitionPruned()).thenReturn(false);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            
statementContext.registerExternalTableForPreload(icebergExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            Mockito.verify(icebergExternalTable, Mockito.times(1))
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(icebergExternalTable, 
Mockito.times(1)).getBaseSchema();
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testSkipIcebergPreloadWhenOnlyNonLatestRelationExists() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        IcebergExternalTable icebergExternalTable = 
Mockito.mock(IcebergExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Skip schema and partition warmup when Iceberg is referenced only by 
non-latest relations.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(icebergExternalTable.getId()).thenReturn(18L);
+        Mockito.when(icebergExternalTable.getName()).thenReturn("iceberg_tbl");
+        Mockito.when(icebergExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(icebergExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(icebergExternalTable.supportsLatestSnapshotPreload()).thenReturn(true);
+        
Mockito.when(icebergExternalTable.supportInternalPartitionPruned()).thenReturn(true);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            
statementContext.registerExternalTableForPreload(icebergExternalTable,
+                    Optional.of(new TableSnapshot("2024-01-01 00:00:00", 
TableSnapshot.VersionType.TIME)),
+                    Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(0, 
result.getPreloadedTableCount());
+            Mockito.verify(icebergExternalTable, Mockito.never())
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            Mockito.verify(icebergExternalTable, 
Mockito.never()).getBaseSchema();
+            Mockito.verify(icebergExternalTable, 
Mockito.never()).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @Test
+    public void testPreloadPaimonLatestSnapshotBeforeLock() {
+        ConnectContext connectContext = Mockito.mock(ConnectContext.class);
+        TableIf internalTable = Mockito.mock(TableIf.class);
+        PaimonExternalTable paimonExternalTable = 
Mockito.mock(PaimonExternalTable.class);
+        DatabaseIf<TableIf> database = mockDatabase();
+        CatalogIf<?> catalog = mockCatalog();
+        MvccSnapshot mvccSnapshot = Mockito.mock(MvccSnapshot.class);
+        SessionVariable sessionVariable = new SessionVariable();
+        sessionVariable.setEnablePreloadExternalMetadata(true);
+
+        // Cover the dedicated Paimon latest-snapshot preload branch before 
the lock phase.
+        
Mockito.when(connectContext.getSessionVariable()).thenReturn(sessionVariable);
+        Mockito.when(internalTable.needReadLockWhenPlan()).thenReturn(true);
+        Mockito.when(paimonExternalTable.getId()).thenReturn(17L);
+        Mockito.when(paimonExternalTable.getName()).thenReturn("paimon_tbl");
+        Mockito.when(paimonExternalTable.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ctl");
+        
Mockito.when(paimonExternalTable.supportsExternalMetadataPreload()).thenReturn(true);
+        
Mockito.when(paimonExternalTable.supportsLatestSnapshotPreload()).thenReturn(true);
+        
Mockito.when(paimonExternalTable.loadSnapshot(Mockito.<Optional<TableSnapshot>>any(),
 Mockito.any()))
+                .thenReturn(mvccSnapshot);
+        
Mockito.when(paimonExternalTable.getBaseSchema()).thenReturn(Collections.emptyList());
+        
Mockito.when(paimonExternalTable.supportInternalPartitionPruned()).thenReturn(true);
+        
Mockito.when(paimonExternalTable.initSelectedPartitions(Mockito.any())).thenReturn(SelectedPartitions.NOT_PRUNED);
+
+        StatementContext statementContext = new 
StatementContext(connectContext, new OriginStatement("select 1", 0));
+        try {
+            statementContext.getTables().put(ImmutableList.of("ctl", "db", 
"internal"), internalTable);
+            
statementContext.registerExternalTableForPreload(paimonExternalTable, 
Optional.empty(), Optional.empty());
+
+            ExternalMetadataPreloadResult result = 
executePreload(statementContext);
+
+            org.junit.jupiter.api.Assertions.assertTrue(result.isExecuted());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getCandidateTableCount());
+            org.junit.jupiter.api.Assertions.assertEquals(1, 
result.getPreloadedTableCount());
+            // Verify the latest snapshot is loaded before partition metadata 
warmup consumes it.
+            InOrder inOrder = Mockito.inOrder(paimonExternalTable);
+            inOrder.verify(paimonExternalTable, Mockito.times(1))
+                    .loadSnapshot(Mockito.<Optional<TableSnapshot>>any(), 
Mockito.any());
+            inOrder.verify(paimonExternalTable, 
Mockito.times(1)).getBaseSchema();
+            inOrder.verify(paimonExternalTable, 
Mockito.times(1)).initSelectedPartitions(Mockito.any());
+        } finally {
+            statementContext.close();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private DatabaseIf<TableIf> mockDatabase() {
+        return Mockito.mock(DatabaseIf.class);
+    }
+
+    private CatalogIf<?> mockCatalog() {
+        return Mockito.mock(CatalogIf.class);
+    }
+
+    private ExternalMetadataPreloadResult executePreload(StatementContext 
statementContext) {
+        ExternalMetadataPreloadResult result = new 
PreloadExternalMetadata().executePreload(statementContext);
+        statementContext.setExternalMetadataPreloadResult(result);
+        return result;
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
index b33a3ef0ad5..8a8e6f5c641 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/SessionVariablesTest.java
@@ -277,4 +277,16 @@ public class SessionVariablesTest extends 
TestWithFeService {
             Assertions.assertFalse(sv.isEnableStrictConsistencyDml());
         }
     }
+
+    @Test
+    public void testEnablePreloadExternalMetadata() throws DdlException {
+        
Assertions.assertFalse(sessionVariable.isEnablePreloadExternalMetadata());
+
+        // Verify the new preload switch can be changed through the standard 
session variable path.
+        VariableMgr.setVar(sessionVariable, new SetVar(SetType.SESSION,
+                SessionVariable.ENABLE_PRELOAD_EXTERNAL_METADATA,
+                new StringLiteral("true")));
+
+        
Assertions.assertTrue(sessionVariable.isEnablePreloadExternalMetadata());
+    }
 }


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

Reply via email to