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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 914257a1fd0 branch-4.2: [improvement](datasource) Version Iceberg 
partition views and SQL cache metadata (#69299) (#69983)
914257a1fd0 is described below

commit 914257a1fd054265225bb19637546f5ab659ad4a
Author: 924060929 <[email protected]>
AuthorDate: Tue Nov 3 11:48:14 2026 +0800

    branch-4.2: [improvement](datasource) Version Iceberg partition views and 
SQL cache metadata (#69299) (#69983)
    
    cherry pick from #69299
---
 .../doris/common/cache/NereidsSqlCacheManager.java |  45 +-
 .../doris/datasource/hive/HMSExternalTable.java    |   3 +
 .../doris/datasource/hive/IcebergDlaTable.java     |  17 +-
 .../iceberg/IcebergCacheSizeEstimator.java         |   6 +-
 .../iceberg/IcebergExternalMetaCache.java          |  95 +++-
 .../datasource/iceberg/IcebergExternalTable.java   |  55 ++-
 .../datasource/iceberg/IcebergPartitionInfo.java   |  13 +-
 .../iceberg/IcebergSnapshotCacheValue.java         |  26 +-
 .../doris/datasource/iceberg/IcebergUtils.java     |  82 +++-
 .../datasource/iceberg/source/IcebergScanNode.java |   9 +
 .../doris/datasource/mvcc/MvccReadSelector.java    | 116 +++++
 .../org/apache/doris/mtmv/MTMVRefreshContext.java  |   3 +-
 .../org/apache/doris/nereids/SqlCacheContext.java  |  64 ++-
 .../doris/nereids/rules/analysis/BindRelation.java |  15 +-
 .../org/apache/doris/qe/cache/CacheAnalyzer.java   |  24 +-
 .../org/apache/doris/qe/cache/SqlCacheVersion.java |  44 ++
 .../doris/qe/cache/SqlCacheVersionProvider.java    |  31 ++
 .../iceberg/IcebergExternalMetaCacheTest.java      | 511 ++++++++++++++++++++-
 .../iceberg/IcebergPartitionInfoTest.java          |   3 +-
 .../iceberg/IcebergSqlCacheVersionTest.java        | 113 +++++
 20 files changed, 1182 insertions(+), 93 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
 
b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
index 1f77f1fc29b..31dd8bb1705 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/common/cache/NereidsSqlCacheManager.java
@@ -34,6 +34,8 @@ import org.apache.doris.common.util.DebugUtil;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.datasource.CatalogMgr;
 import org.apache.doris.datasource.hive.HMSExternalTable;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
 import org.apache.doris.mysql.privilege.DataMaskPolicy;
 import org.apache.doris.mysql.privilege.RowFilterPolicy;
 import org.apache.doris.nereids.CascadesContext;
@@ -42,6 +44,7 @@ import org.apache.doris.nereids.SqlCacheContext.CacheKeyType;
 import org.apache.doris.nereids.SqlCacheContext.FullColumnName;
 import org.apache.doris.nereids.SqlCacheContext.FullTableName;
 import org.apache.doris.nereids.SqlCacheContext.ScanTable;
+import org.apache.doris.nereids.SqlCacheContext.TableReference;
 import org.apache.doris.nereids.SqlCacheContext.TableVersion;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.UnboundVariable;
@@ -67,6 +70,8 @@ import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.qe.StmtExecutor;
 import org.apache.doris.qe.cache.CacheAnalyzer;
 import org.apache.doris.qe.cache.SqlCache;
+import org.apache.doris.qe.cache.SqlCacheVersion;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
 import org.apache.doris.rpc.RpcException;
 
 import com.github.benmanes.caffeine.cache.Cache;
@@ -126,8 +131,8 @@ public class NereidsSqlCacheManager {
         for (Entry<String, SqlCacheContext> kv : sqlCaches.asMap().entrySet()) 
{
             String key = kv.getKey();
             SqlCacheContext context = kv.getValue();
-            for (Entry<FullTableName, TableVersion> nameToVersion : 
context.getUsedTables().entrySet()) {
-                FullTableName tableName = nameToVersion.getKey();
+            for (Entry<TableReference, TableVersion> nameToVersion : 
context.getUsedTables().entrySet()) {
+                FullTableName tableName = nameToVersion.getKey().tableName;
                 TableVersion tableVersion = nameToVersion.getValue();
                 if (tableVersion.id == tableIf.getId()) {
                     invalidateKeys.add(key);
@@ -341,7 +346,7 @@ public class NereidsSqlCacheManager {
             if (privilegeChanged(connectContext, env, sqlCacheContext)) {
                 return invalidateCache(key);
             }
-            IsChanged tableIsChanged = tablesOrDataChanged(env, 
sqlCacheContext);
+            IsChanged tableIsChanged = tablesOrDataChanged(env, 
sqlCacheContext, connectContext.getStatementContext());
             if (tableIsChanged.changed) {
                 if (tableIsChanged.invalidCache) {
                     return invalidateCache(key);
@@ -427,7 +432,7 @@ public class NereidsSqlCacheManager {
         }
     }
 
-    private IsChanged tablesOrDataChanged(Env env, SqlCacheContext 
sqlCacheContext) {
+    private IsChanged tablesOrDataChanged(Env env, SqlCacheContext 
sqlCacheContext, StatementContext statement) {
         if (sqlCacheContext.hasUnsupportedTables()) {
             return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
         }
@@ -435,13 +440,14 @@ public class NereidsSqlCacheManager {
         // the query maybe scan empty partition of the table, we should check 
these table version too,
         // but the table not exists in sqlCacheContext.getScanTables(), so we 
need check here.
         // check table type and version
-        for (Entry<FullTableName, TableVersion> scanTable : 
sqlCacheContext.getUsedTables().entrySet()) {
+        for (Entry<TableReference, TableVersion> scanTable : 
sqlCacheContext.getUsedTables().entrySet()) {
             TableVersion tableVersion = scanTable.getValue();
             if (tableVersion.type != TableType.OLAP && tableVersion.type != 
TableType.MATERIALIZED_VIEW
-                    && tableVersion.type != TableType.HMS_EXTERNAL_TABLE) {
+                    && tableVersion.type != TableType.HMS_EXTERNAL_TABLE
+                    && tableVersion.type != TableType.ICEBERG_EXTERNAL_TABLE) {
                 return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
             }
-            TableIf tableIf = findTableIf(env, scanTable.getKey());
+            TableIf tableIf = findTableIf(env, scanTable.getKey().tableName);
             if (tableIf == null) {
                 return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
             } else if (tableIf.isTemporary()) {
@@ -471,9 +477,24 @@ public class NereidsSqlCacheManager {
                         return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
                     }
                 }
+            } else if (tableVersion.sqlCacheVersion != null) {
+                MvccReadSelector selector = scanTable.getKey().selector;
+                Optional<MvccSnapshot> pin = Optional.empty();
+                if (!selector.isLatest()) {
+                    // Reuse the analyzer's resolver: snapshot existence, 
timestamp semantics, ref kind,
+                    // branch/tag movement and schema selection all remain 
owned by the existing MVCC path.
+                    statement.loadSnapshots(tableIf, 
selector.getTableSnapshot(), selector.getScanParams());
+                    pin = statement.getSnapshot(tableIf, 
selector.getTableSnapshot(), selector.getScanParams());
+                }
+                Optional<SqlCacheVersion> current = ((SqlCacheVersionProvider) 
tableIf)
+                        .getSqlCacheVersion(pin);
+                if (!current.isPresent() || 
!tableVersion.sqlCacheVersion.getVersion()
+                        .equals(current.get().getVersion())) {
+                    return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
+                }
             } else if (tableIf instanceof HMSExternalTable) {
-                HMSExternalTable hiveTable = (HMSExternalTable) tableIf;
-                if (tableVersion.version != hiveTable.getUpdateTime()) {
+                // Preserve the existing Hive update-time validation when no 
version provider is available.
+                if (tableVersion.version != ((HMSExternalTable) 
tableIf).getUpdateTime()) {
                     return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
                 }
             } else {
@@ -494,7 +515,8 @@ public class NereidsSqlCacheManager {
                         return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
                     }
                 }
-            } else if (!(tableIf instanceof HMSExternalTable)) {
+            } else if (!(tableIf instanceof HMSExternalTable)
+                    && !(tableIf instanceof SqlCacheVersionProvider)) {
                 return IsChanged.CHANGED_AND_INVALIDATE_CACHE;
             }
         }
@@ -559,7 +581,8 @@ public class NereidsSqlCacheManager {
      */
     private boolean tryLockTables(ConnectContext connectContext, Env env, 
SqlCacheContext sqlCacheContext) {
         StatementContext currentStatementContext = 
connectContext.getStatementContext();
-        for (FullTableName fullTableName : 
sqlCacheContext.getUsedTables().keySet()) {
+        for (TableReference reference : 
sqlCacheContext.getUsedTables().keySet()) {
+            FullTableName fullTableName = reference.tableName;
             TableIf tableIf = findTableIf(env, fullTableName);
             if (tableIf == null) {
                 return false;
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 a02b5a6662e..60d437f246a 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
@@ -1102,6 +1102,9 @@ public class HMSExternalTable extends ExternalTable 
implements MTMVRelatedTableI
         if (dlaTable instanceof HiveDlaTable) {
             return ((HiveDlaTable) 
dlaTable).getPartitionSnapshots(partitionNames, snapshot);
         }
+        if (dlaTable instanceof IcebergDlaTable) {
+            return IcebergUtils.getPartitionSnapshots(this, partitionNames, 
context, snapshot);
+        }
         return MTMVRelatedTableIf.super.getPartitionSnapshots(partitionNames, 
context, snapshot);
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java
index 260fa7e55f4..886eeea112f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/IcebergDlaTable.java
@@ -68,21 +68,8 @@ public class IcebergDlaTable extends HMSDlaTable {
     @Override
     public MTMVSnapshotIf getPartitionSnapshot(String partitionName, 
MTMVRefreshContext context,
                                                Optional<MvccSnapshot> 
snapshot) throws AnalysisException {
-        IcebergSnapshotCacheValue snapshotValue = 
IcebergUtils.getSnapshotCacheValue(snapshot, hmsTable);
-        long latestSnapshotId = 
snapshotValue.getPartitionInfo().getLatestSnapshotId(partitionName);
-        // If partition snapshot ID is unavailable (<= 0), fallback to table 
snapshot ID
-        // This can happen when last_updated_snapshot_id is null in Iceberg 
metadata
-        if (latestSnapshotId <= 0) {
-            long tableSnapshotId = snapshotValue.getSnapshot().getSnapshotId();
-            // If table snapshot ID is also invalid, it means empty table
-            if (tableSnapshotId <= 0) {
-                throw new AnalysisException("can not find partition: " + 
partitionName
-                        + ", and table snapshot ID is also invalid");
-            }
-            // Use table snapshot ID as fallback when partition snapshot ID is 
unavailable
-            return new MTMVSnapshotIdSnapshot(tableSnapshotId);
-        }
-        return new MTMVSnapshotIdSnapshot(latestSnapshotId);
+        return IcebergUtils.getPartitionSnapshot(partitionName,
+                IcebergUtils.getSnapshotCacheValue(snapshot, hmsTable));
     }
 
     @Override
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergCacheSizeEstimator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergCacheSizeEstimator.java
index 62922f2a6f9..651398fa83d 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergCacheSizeEstimator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergCacheSizeEstimator.java
@@ -96,6 +96,8 @@ final class IcebergCacheSizeEstimator {
     private static final long METADATA_ENTRY_WEIGHT = 128L;
     private static final long SNAPSHOT_WEIGHT = 512L;
     private static final long CURRENT_SNAPSHOT_WEIGHT = 1024L;
+    // Reserve the projection's lazy maximum partition-update timestamp and 
its loaded flag.
+    private static final long SNAPSHOT_UPDATE_TIME_STATE_WEIGHT = 32L;
     private static final long SNAPSHOT_LOG_WEIGHT = 64L;
     private static final long METADATA_LOG_WEIGHT = 160L;
     private static final long SNAPSHOT_REF_WEIGHT = 256L;
@@ -161,7 +163,7 @@ final class IcebergCacheSizeEstimator {
 
     static MetaCacheSizeEstimate estimateSnapshotEntry(
             IcebergSnapshotEntryKey key, IcebergSnapshotCacheValue value) {
-        long bytes = KEY_BASE_WEIGHT;
+        long bytes = KEY_BASE_WEIGHT + SNAPSHOT_UPDATE_TIME_STATE_WEIGHT;
         bytes = MetaCacheWeightUtils.saturatedAdd(bytes,
                 
MetaCacheWeightUtils.estimatedNameMappingBytes(key.getNameMapping()));
         bytes = MetaCacheWeightUtils.saturatedAdd(bytes,
@@ -169,7 +171,7 @@ final class IcebergCacheSizeEstimator {
         bytes = MetaCacheWeightUtils.saturatedAdd(bytes,
                 
MetaCacheWeightUtils.estimatedStringBytes(key.getMetadataFileLocation()));
 
-        IcebergPartitionInfo partitionInfo = value.getPartitionInfo();
+        IcebergPartitionInfo partitionInfo = value.getRetainedPartitionInfo();
         bytes = addCount(bytes, partitionInfo.getNameToPartitionItem().size(), 
PARTITION_WEIGHT);
         bytes = addCount(bytes, 
partitionInfo.getNameToIcebergPartition().size(), PARTITION_WEIGHT);
         bytes = addCount(bytes, 
partitionInfo.getNameToIcebergPartitionNames().size(),
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java
index 4c31f3b2689..23bc3bf17fa 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCache.java
@@ -68,6 +68,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Consumer;
 import java.util.function.Function;
+import java.util.function.Supplier;
 import javax.annotation.Nullable;
 
 /**
@@ -99,6 +100,7 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
     public static final String ENGINE = "iceberg";
     public static final String ENTRY_TABLE = "table";
     public static final String ENTRY_SNAPSHOT = "snapshot";
+    public static final String ENTRY_PARTITION = "partition";
     public static final String ENTRY_VIEW = "view";
     public static final String ENTRY_MANIFEST = "manifest";
     public static final String ENTRY_SCHEMA = "schema";
@@ -107,6 +109,7 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
     private final EntryHandle<NameMapping, IcebergTableCacheValue> tableEntry;
     private final EntryHandle<IcebergSnapshotEntryKey, 
IcebergSnapshotCacheValue> snapshotEntry;
     private final EntryHandle<NameMapping, View> viewEntry;
+    private final EntryHandle<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitionEntry;
     private final EntryHandle<IcebergManifestEntryKey, ManifestCacheValue> 
manifestEntry;
     private final EntryHandle<IcebergSchemaCacheKey, SchemaCacheValue> 
schemaEntry;
 
@@ -131,6 +134,12 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
                 IcebergSnapshotEntryKey.class, 
IcebergSnapshotCacheValue.class, defaultEntryCacheSpec(),
                 
MetaCacheEntryInvalidation.forNameMapping(IcebergSnapshotEntryKey::getNameMapping))
                 .withSizeEstimator((key, value) -> 
value.prepareForCachePublication(key)));
+        partitionEntry = 
registerEntry(MetaCacheEntryDef.contextualOnly(ENTRY_PARTITION,
+                IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class, 
defaultEntryCacheSpec(),
+                
MetaCacheEntryInvalidation.forNameMapping(IcebergSnapshotEntryKey::getNameMapping))
+                .withSizeEstimator((key, value) -> 
IcebergCacheSizeEstimator.estimateSnapshotEntry(key,
+                        new IcebergSnapshotCacheValue(value,
+                                new IcebergSnapshot(key.getSnapshotId(), 
0)))));
         viewEntry = registerEntry(MetaCacheEntryDef.of(ENTRY_VIEW, 
NameMapping.class, View.class, this::loadView,
                 defaultEntryCacheSpec(), 
MetaCacheEntryInvalidation.forNameMapping(nameMapping -> nameMapping)));
         manifestEntry = 
registerEntry(MetaCacheEntryDef.contextualOnly(ENTRY_MANIFEST, 
IcebergManifestEntryKey.class,
@@ -384,7 +393,7 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
     private IcebergSnapshotCacheValue getSnapshotCache(
             ExternalTable dorisTable, NameMapping nameMapping, 
IcebergTableCacheValue tableValue) {
         Table retainedTable = tableValue.getRetainedIcebergTable();
-        java.util.Optional<IcebergSnapshotEntryKey> optionalKey =
+        Optional<IcebergSnapshotEntryKey> optionalKey =
                 IcebergSnapshotEntryKey.tryCreate(nameMapping, retainedTable);
         if (IcebergUtils.useSessionCatalog(dorisTable) || 
!optionalKey.isPresent()) {
             boolean isolateForQueries = tableValue.isQueryIsolationPrepared();
@@ -966,6 +975,14 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
                 snapshots.invalidateIf((key, value) -> 
key.getNameMapping().equals(nameMapping)
                         && (!key.belongsTo(currentValue) || 
!sharesOperationalResources(currentValue, value)));
             }
+            MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions =
+                    partitionEntry.getIfInitialized(nameMapping.getCtlId());
+            if (partitions != null) {
+                // Partition maps retain no FileIO; preserve reuse across 
handles of the same
+                // physical generation, but retire maps no latest reader can 
reach anymore.
+                partitions.invalidateIf(key -> 
key.getNameMapping().equals(nameMapping)
+                        && !key.belongsTo(currentValue));
+            }
             Optional<String> currentUuid = currentValue.getTableUuid();
             MetaCacheEntry<IcebergSchemaCacheKey, SchemaCacheValue> schemas =
                     schemaEntry.getIfInitialized(nameMapping.getCtlId());
@@ -990,6 +1007,11 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
         if (snapshots != null) {
             snapshots.invalidateIf(key -> 
key.getNameMapping().equals(nameMapping));
         }
+        MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions =
+                partitionEntry.getIfInitialized(nameMapping.getCtlId());
+        if (partitions != null) {
+            partitions.invalidateIf(key -> 
key.getNameMapping().equals(nameMapping));
+        }
         MetaCacheEntry<IcebergSchemaCacheKey, SchemaCacheValue> schemas =
                 schemaEntry.getIfInitialized(nameMapping.getCtlId());
         if (schemas != null) {
@@ -1167,27 +1189,44 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
         }
         try {
             IcebergSnapshot latestIcebergSnapshot = 
IcebergUtils.getLatestIcebergSnapshot(projectionTable);
-            IcebergPartitionInfo icebergPartitionInfo;
-            // The projection and its partition eligibility must be classified 
from the same
-            // retained table generation. The no-argument method performs 
another cache lookup,
-            // which can cross a concurrent refresh/reset boundary.
+            // Ordinary scans and SQL-cache versions need only this frozen 
metadata generation.
+            // Partition consumers load the separate, generation-keyed and 
budgeted view on demand.
+            IcebergPartitionInfo icebergPartitionInfo = 
IcebergPartitionInfo.empty();
             boolean validRelatedTable = 
IcebergUtils.isValidRelatedTable(projectionTable);
-            if (!validRelatedTable) {
-                icebergPartitionInfo = IcebergPartitionInfo.empty();
-            } else {
-                icebergPartitionInfo = 
IcebergUtils.loadPartitionInfo(dorisTable, projectionTable,
-                        latestIcebergSnapshot.getSnapshotId(), 
latestIcebergSnapshot.getSchemaId(), authenticator,
-                        enableMappingVarbinary, enableMappingTimestampTz);
-            }
             Optional<Map<Integer, List<String>>> nameMapping =
                     IcebergUtils.getNameMapping(projectionTable);
-            return isolateForQueries
+            IcebergSnapshotCacheValue value = isolateForQueries
                     ? new IcebergSnapshotCacheValue(
                             icebergPartitionInfo, latestIcebergSnapshot, 
nameMapping,
                             retainedTable, retainedCurrentSnapshotJson)
                     : new IcebergSnapshotCacheValue(
                             icebergPartitionInfo, latestIcebergSnapshot, 
nameMapping,
                             retainedTable);
+            if (validRelatedTable) {
+                NameMapping tableIdentity = dorisTable.getOrBuildNameMapping();
+                String displayName = dorisTable.getCatalog().getName() + "." + 
dorisTable.getDbName()
+                        + "." + dorisTable.getName();
+                Optional<IcebergSnapshotEntryKey> key = 
IcebergSnapshotEntryKey.tryCreate(tableIdentity, retainedTable);
+                boolean useSharedCache = key.isPresent() && 
!IcebergUtils.useSessionCatalog(dorisTable);
+                // Retained work can start after reset retired the group. 
Capture an existing entry
+                // when applicable; direct loads need only the retained 
metadata and authenticator.
+                MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions = useSharedCache
+                        ? 
partitionEntry.getIfInitialized(key.get().getNameMapping().getCtlId()) : null;
+                Supplier<IcebergPartitionInfo> loader = () -> {
+                    try {
+                        Table table = 
IcebergSnapshotCacheValue.createQueryScopedTable(
+                                retainedTable, retainedCurrentSnapshotJson);
+                        return IcebergUtils.loadPartitionInfo(displayName, 
table,
+                                latestIcebergSnapshot.getSnapshotId(), 
latestIcebergSnapshot.getSchemaId(),
+                                authenticator, enableMappingVarbinary, 
enableMappingTimestampTz);
+                    } catch (AnalysisException e) {
+                        throw new RuntimeException(e.getMessage(), e);
+                    }
+                };
+                value.withPartitionInfoLoader(partitions != null
+                        ? () -> getPartitionInfo(key.get(), partitions, 
loader) : loader);
+            }
+            return value;
         } catch (AnalysisException e) {
             throw new RuntimeException(ExceptionUtils.getRootCauseMessage(e), 
e);
         } catch (UserException e) {
@@ -1196,6 +1235,36 @@ public class IcebergExternalMetaCache extends 
AbstractExternalMetaCache {
         }
     }
 
+    private IcebergPartitionInfo getPartitionInfo(IcebergSnapshotEntryKey key,
+            MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions,
+            Supplier<IcebergPartitionInfo> loader) {
+        if (!canCachePartitionInfo(key)) {
+            // A pinned old projection can still read its immutable generation 
after retirement.
+            return loader.get();
+        }
+        IcebergPartitionInfo info = partitions.get(key, ignored -> 
loader.get());
+        if (!canCachePartitionInfo(key)) {
+            // Replacement can race the load, including a count-mode 
contextual miss. Do not leave
+            // the superseded map published after returning it to the query 
that still owns it.
+            partitions.invalidateKeyIfSame(key, info);
+        }
+        return info;
+    }
+
+    private boolean canCachePartitionInfo(IcebergSnapshotEntryKey key) {
+        MetaCacheEntry<NameMapping, IcebergTableCacheValue> tables =
+                tableEntry.getIfInitialized(key.getNameMapping().getCtlId());
+        if (tables == null) {
+            return false;
+        }
+        if (!tables.isEffectivelyEnabled()) {
+            // A disabled table entry never publishes handles; physical keys 
still permit reuse.
+            return true;
+        }
+        IcebergTableCacheValue current = 
tables.peekIfPresent(key.getNameMapping());
+        return current != null && key.belongsTo(current);
+    }
+
     private IcebergMetadataOps resolveMetadataOps(CatalogIf catalog) {
         if (catalog instanceof HMSExternalCatalog) {
             return ((HMSExternalCatalog) catalog).getIcebergMetadataOps();
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 687f49a4328..8a897b4b606 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
@@ -44,6 +44,8 @@ import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot;
 import org.apache.doris.mtmv.MTMVSnapshotIf;
 import org.apache.doris.nereids.trees.plans.commands.info.SortFieldInfo;
 import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.cache.SqlCacheVersion;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
 import org.apache.doris.statistics.AnalysisInfo;
 import org.apache.doris.statistics.BaseAnalysisTask;
 import org.apache.doris.statistics.ExternalAnalysisTask;
@@ -56,9 +58,11 @@ import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Maps;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.PartitionField;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.view.SQLViewRepresentation;
 import org.apache.iceberg.view.View;
 import org.apache.iceberg.view.ViewVersion;
@@ -71,7 +75,8 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-public class IcebergExternalTable extends ExternalTable implements 
MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable {
+public class IcebergExternalTable extends ExternalTable
+        implements MTMVRelatedTableIf, MTMVBaseTableIf, MvccTable, 
SqlCacheVersionProvider {
 
     private boolean isValidRelatedTableCached = false;
     private boolean isValidRelatedTable = false;
@@ -84,6 +89,27 @@ public class IcebergExternalTable extends ExternalTable 
implements MTMVRelatedTa
         super(id, name, remoteName, catalog, db, 
TableType.ICEBERG_EXTERNAL_TABLE);
     }
 
+    @Override
+    public Optional<SqlCacheVersion> getSqlCacheVersion(Optional<MvccSnapshot> 
snapshot) {
+        return IcebergUtils.withSnapshotCacheValue(snapshot, this, 
IcebergExternalTable::sqlCacheVersionOf);
+    }
+
+    static Optional<SqlCacheVersion> 
sqlCacheVersionOf(IcebergSnapshotCacheValue value) {
+        Optional<Table> table = value.getIcebergTable();
+        if (!table.isPresent() || !(table.get() instanceof 
HasTableOperations)) {
+            return Optional.empty();
+        }
+        TableMetadata metadata = ((HasTableOperations) table.get())
+                .operations().current();
+        if (metadata.uuid() == null || metadata.metadataFileLocation() == 
null) {
+            return Optional.empty();
+        }
+        IcebergSnapshot selected = value.getSnapshot();
+        String identity = metadata.uuid() + "|" + 
metadata.metadataFileLocation()
+                + "|" + selected.getSnapshotId() + "|" + 
selected.getSchemaId();
+        return Optional.of(new SqlCacheVersion(identity, 
metadata.lastUpdatedMillis()));
+    }
+
     @Override
     public String getMetaCacheEngine() {
         return IcebergExternalMetaCache.ENGINE;
@@ -195,21 +221,13 @@ public class IcebergExternalTable extends ExternalTable 
implements MTMVRelatedTa
     @Override
     public MTMVSnapshotIf getPartitionSnapshot(String partitionName, 
MTMVRefreshContext context,
                                                Optional<MvccSnapshot> 
snapshot) throws AnalysisException {
-        IcebergSnapshotCacheValue snapshotValue = 
IcebergUtils.getSnapshotCacheValue(snapshot, this);
-        long latestSnapshotId = 
snapshotValue.getPartitionInfo().getLatestSnapshotId(partitionName);
-        // If partition snapshot ID is unavailable (<= 0), fallback to table 
snapshot ID
-        // This can happen when last_updated_snapshot_id is null in Iceberg 
metadata
-        if (latestSnapshotId <= 0) {
-            long tableSnapshotId = snapshotValue.getSnapshot().getSnapshotId();
-            // If table snapshot ID is also invalid, it means empty table
-            if (tableSnapshotId <= 0) {
-                throw new AnalysisException("can not find partition: " + 
partitionName
-                        + ", and table snapshot ID is also invalid");
-            }
-            // Use table snapshot ID as fallback when partition snapshot ID is 
unavailable
-            return new MTMVSnapshotIdSnapshot(tableSnapshotId);
-        }
-        return new MTMVSnapshotIdSnapshot(latestSnapshotId);
+        return IcebergUtils.getPartitionSnapshot(partitionName, 
IcebergUtils.getSnapshotCacheValue(snapshot, this));
+    }
+
+    @Override
+    public Map<String, MTMVSnapshotIf> getPartitionSnapshots(Set<String> 
partitionNames,
+            MTMVRefreshContext context, Optional<MvccSnapshot> snapshot) 
throws AnalysisException {
+        return IcebergUtils.getPartitionSnapshots(this, partitionNames, 
context, snapshot);
     }
 
     @Override
@@ -227,9 +245,8 @@ public class IcebergExternalTable extends ExternalTable 
implements MTMVRelatedTa
 
     @Override
     public long getNewestUpdateVersionOrTime() {
-        return IcebergUtils.getLatestSnapshotCacheValue(this)
-                
.getPartitionInfo().getNameToIcebergPartition().values().stream()
-                
.mapToLong(IcebergPartition::getLastUpdateTime).max().orElse(0);
+        return IcebergUtils.withLatestSnapshotCacheValue(this,
+                IcebergSnapshotCacheValue::getNewestPartitionUpdateTime);
     }
 
     @Override
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java
index b618b4b51db..69659428162 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfo.java
@@ -20,6 +20,8 @@ package org.apache.doris.datasource.iceberg;
 import org.apache.doris.catalog.PartitionItem;
 import org.apache.doris.datasource.metacache.MetaCacheWeightUtils;
 
+import com.google.common.collect.ImmutableMap;
+
 import java.util.Collections;
 import java.util.Map;
 import java.util.Set;
@@ -40,7 +42,6 @@ public class IcebergPartitionInfo {
     private final Map<String, IcebergPartition> nameToIcebergPartition;
     private final Map<String, Set<String>> nameToIcebergPartitionNames;
     private final long retainedPayloadBytes;
-
     private static final IcebergPartitionInfo EMPTY = new 
IcebergPartitionInfo();
 
     private IcebergPartitionInfo() {
@@ -63,9 +64,13 @@ public class IcebergPartitionInfo {
                                 Map<String, IcebergPartition> 
nameToIcebergPartition,
                                 Map<String, Set<String>> 
nameToIcebergPartitionNames,
                                 long retainedPayloadBytes) {
-        this.nameToPartitionItem = nameToPartitionItem;
-        this.nameToIcebergPartition = nameToIcebergPartition;
-        this.nameToIcebergPartitionNames = nameToIcebergPartitionNames;
+        this.nameToPartitionItem = ImmutableMap.copyOf(nameToPartitionItem);
+        this.nameToIcebergPartition = 
ImmutableMap.copyOf(nameToIcebergPartition);
+        ImmutableMap.Builder<String, Set<String>> aliases =
+                ImmutableMap.builder();
+        nameToIcebergPartitionNames.forEach((name, names) -> aliases.put(name,
+                com.google.common.collect.ImmutableSet.copyOf(names)));
+        this.nameToIcebergPartitionNames = aliases.build();
         this.retainedPayloadBytes = retainedPayloadBytes;
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java
index e9728aaf72d..443437d76a2 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergSnapshotCacheValue.java
@@ -44,11 +44,15 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.function.Supplier;
 import javax.annotation.Nullable;
 
 public class IcebergSnapshotCacheValue {
 
     private final IcebergPartitionInfo partitionInfo;
+    private Supplier<IcebergPartitionInfo> partitionInfoLoader;
+    private long newestPartitionUpdateTime;
+    private boolean newestPartitionUpdateTimeLoaded;
     private final IcebergSnapshot snapshot;
     private final Optional<Map<Integer, List<String>>> nameMapping;
     private Optional<Table> icebergTable;
@@ -137,9 +141,29 @@ public class IcebergSnapshotCacheValue {
     }
 
     public IcebergPartitionInfo getPartitionInfo() {
+        return partitionInfoLoader == null ? partitionInfo : 
partitionInfoLoader.get();
+    }
+
+    synchronized long getNewestPartitionUpdateTime() {
+        if (!newestPartitionUpdateTimeLoaded) {
+            // A dictionary check needs only this scalar. Do not retain an 
unadmitted full view
+            // in the shared projection, and keep zero as a valid result for 
an empty view.
+            newestPartitionUpdateTime = 
getPartitionInfo().getNameToIcebergPartition().values().stream()
+                    
.mapToLong(IcebergPartition::getLastUpdateTime).max().orElse(0);
+            newestPartitionUpdateTimeLoaded = true;
+        }
+        return newestPartitionUpdateTime;
+    }
+
+    IcebergPartitionInfo getRetainedPartitionInfo() {
         return partitionInfo;
     }
 
+    IcebergSnapshotCacheValue 
withPartitionInfoLoader(Supplier<IcebergPartitionInfo> loader) {
+        this.partitionInfoLoader = loader;
+        return this;
+    }
+
     public IcebergSnapshot getSnapshot() {
         return snapshot;
     }
@@ -222,7 +246,7 @@ public class IcebergSnapshotCacheValue {
     }
 
     IcebergSnapshotCacheValue withoutRetainedTable() {
-        return new IcebergSnapshotCacheValue(partitionInfo, snapshot, 
nameMapping);
+        return new IcebergSnapshotCacheValue(getPartitionInfo(), snapshot, 
nameMapping);
     }
 
     public Optional<Table> getIcebergTable() {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
index e8f499b9cab..3471d9d9726 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergUtils.java
@@ -65,6 +65,10 @@ import 
org.apache.doris.datasource.property.metastore.HMSBaseProperties;
 import 
org.apache.doris.datasource.property.storage.AbstractS3CompatibleProperties;
 import org.apache.doris.datasource.property.storage.S3Properties;
 import org.apache.doris.datasource.property.storage.StorageProperties;
+import org.apache.doris.mtmv.MTMVRefreshContext;
+import org.apache.doris.mtmv.MTMVRelatedTableIf;
+import org.apache.doris.mtmv.MTMVSnapshotIdSnapshot;
+import org.apache.doris.mtmv.MTMVSnapshotIf;
 import org.apache.doris.nereids.exceptions.NotSupportedException;
 import org.apache.doris.nereids.trees.expressions.literal.Result;
 import org.apache.doris.nereids.types.VarBinaryType;
@@ -158,6 +162,7 @@ import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.EnumMap;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
@@ -1746,11 +1751,7 @@ public class IcebergUtils {
                     }
                 } else {
                     Table table = icebergTable != null ? icebergTable : 
getIcebergTable(dorisTable);
-                    if (schemaId == NEWEST_SCHEMA_ID || 
table.currentSnapshot() == null) {
-                        schema = table.schema();
-                    } else {
-                        schema = table.schemas().get((int) schemaId);
-                    }
+                    schema = schemaForTable(table, schemaId);
                 }
                 String type = isView ? "view" : "table";
                 Preconditions.checkNotNull(schema,
@@ -1764,6 +1765,11 @@ public class IcebergUtils {
 
     }
 
+    private static Schema schemaForTable(Table table, long schemaId) {
+        return schemaId == NEWEST_SCHEMA_ID || table.currentSnapshot() == null
+                ? table.schema() : table.schemas().get((int) schemaId);
+    }
+
     /**
      * Parse iceberg schema to doris schema
      */
@@ -2320,15 +2326,35 @@ public class IcebergUtils {
     static IcebergPartitionInfo loadPartitionInfo(ExternalTable dorisTable, 
Table table, long snapshotId,
             long schemaId, ExecutionAuthenticator authenticator,
             boolean enableMappingVarbinary, boolean enableMappingTimestampTz) 
throws AnalysisException {
+        String displayName = dorisTable.getCatalog().getName() + "." + 
dorisTable.getDbName()
+                + "." + dorisTable.getName();
+        return loadPartitionInfo(displayName, table, snapshotId, schemaId, 
authenticator,
+                enableMappingVarbinary, enableMappingTimestampTz);
+    }
+
+    static IcebergPartitionInfo loadPartitionInfo(String displayName, Table 
table,
+            long snapshotId, long schemaId, ExecutionAuthenticator 
authenticator,
+            boolean enableMappingVarbinary, boolean enableMappingTimestampTz) 
throws AnalysisException {
         if (snapshotId == IcebergUtils.UNKNOWN_SNAPSHOT_ID) {
             return IcebergPartitionInfo.empty();
         }
         List<IcebergPartition> icebergPartitions;
+        List<Column> partitionColumns;
         try {
             icebergPartitions = authenticator.execute(() -> 
loadIcebergPartition(table, snapshotId));
+            // A retained projection may outlive its catalog cache group. Its 
columns and ranges
+            // must use the same frozen table/schema, without looking up a new 
catalog generation.
+            partitionColumns = authenticator.execute(() -> {
+                List<Column> schema = 
parseSchema(Preconditions.checkNotNull(schemaForTable(table, schemaId),
+                        "Schema for Iceberg table %s is null", displayName),
+                        enableMappingVarbinary, enableMappingTimestampTz);
+                // This retained-table path bypasses the schema-cache loader's 
validation boundary.
+                new SchemaCacheValue(schema).validateSchema();
+                return buildTableSchemaCacheValue(table, 
schema).getPartitionColumns();
+            });
         } catch (Exception e) {
-            String errorMsg = String.format("Failed to get iceberg partition 
info, table: %s.%s.%s, snapshotId: %s",
-                    dorisTable.getCatalog().getName(), dorisTable.getDbName(), 
dorisTable.getName(), snapshotId);
+            String errorMsg = String.format("Failed to get iceberg partition 
info, table: %s, snapshotId: %s",
+                    displayName, snapshotId);
             LOG.warn(errorMsg, e);
             throw new AnalysisException(errorMsg, e);
         }
@@ -2336,9 +2362,6 @@ public class IcebergUtils {
         Map<String, PartitionItem> nameToPartitionItem = Maps.newHashMap();
         long retainedPayloadBytes = 0L;
 
-        List<Column> partitionColumns = 
icebergExternalMetaCache(dorisTable).getIcebergSchemaCacheValue(
-                dorisTable.getOrBuildNameMapping(), schemaId, table, 
authenticator,
-                enableMappingVarbinary, 
enableMappingTimestampTz).getPartitionColumns();
         for (IcebergPartition partition : icebergPartitions) {
             nameToPartition.put(partition.getPartitionName(), partition);
             retainedPayloadBytes = MetaCacheWeightUtils.saturatedAdd(
@@ -2626,6 +2649,45 @@ public class IcebergUtils {
         return withLatestSnapshotCacheValue(dorisTable, action);
     }
 
+    /** Derive all MV partition versions from one view without retaining an 
unbudgeted map. */
+    public static Map<String, MTMVSnapshotIf> 
getPartitionSnapshots(ExternalTable dorisTable,
+            Set<String> partitionNames, MTMVRefreshContext context, 
Optional<MvccSnapshot> snapshot)
+            throws AnalysisException {
+        Map<String, MTMVSnapshotIf> versions = new LinkedHashMap<>();
+        if (partitionNames.isEmpty()) {
+            return versions;
+        }
+        IcebergSnapshotCacheValue value = getSnapshotCacheValue(snapshot, 
dorisTable);
+        IcebergPartitionInfo info = value.getPartitionInfo();
+        for (String name : partitionNames) {
+            try {
+                versions.put(name, partitionSnapshotOf(name, info, 
value.getSnapshot().getSnapshotId()));
+            } catch (AnalysisException e) {
+                context.recordPartitionSnapshotFailure((MTMVRelatedTableIf) 
dorisTable, name, e);
+            }
+        }
+        return versions;
+    }
+
+    public static MTMVSnapshotIf getPartitionSnapshot(String partitionName, 
IcebergSnapshotCacheValue value)
+            throws AnalysisException {
+        return partitionSnapshotOf(partitionName, value.getPartitionInfo(), 
value.getSnapshot().getSnapshotId());
+    }
+
+    private static MTMVSnapshotIf partitionSnapshotOf(String partitionName,
+            IcebergPartitionInfo info, long tableSnapshotId) throws 
AnalysisException {
+        long latestSnapshotId = info.getLatestSnapshotId(partitionName);
+        // last_updated_snapshot_id can be absent; retain the table-snapshot 
fallback for MV refresh.
+        if (latestSnapshotId <= 0) {
+            if (tableSnapshotId <= 0) {
+                throw new AnalysisException("can not find partition: " + 
partitionName
+                        + ", and table snapshot ID is also invalid");
+            }
+            return new MTMVSnapshotIdSnapshot(tableSnapshotId);
+        }
+        return new MTMVSnapshotIdSnapshot(latestSnapshotId);
+    }
+
     public static IcebergSnapshotCacheValue 
getSnapshotCacheValue(Optional<MvccSnapshot> snapshot,
             ExternalTable dorisTable) {
         if (snapshot.isPresent() && snapshot.get() instanceof 
IcebergMvccSnapshot) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
index a9cf412aa18..743736f649a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
@@ -74,6 +74,8 @@ import org.apache.doris.planner.PlanNodeId;
 import org.apache.doris.planner.ScanContext;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.qe.cache.SqlCacheVersion;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
 import org.apache.doris.spi.Split;
 import org.apache.doris.statistics.StatisticalType;
 import org.apache.doris.system.Backend;
@@ -3211,6 +3213,13 @@ public class IcebergScanNode extends FileQueryScanNode {
                         manifestCacheHits, manifestCacheMisses, 
manifestCacheFailures));
     }
 
+    public Optional<SqlCacheVersion> getSqlCacheVersion() {
+        TableIf table = getTargetTable();
+        return table instanceof SqlCacheVersionProvider
+                ? ((SqlCacheVersionProvider) table)
+                        .getSqlCacheVersion(getPinnedRelationSnapshot()) : 
Optional.empty();
+    }
+
     @Override
     public TableIf getTargetTable() {
         return source.getTargetTable();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java
new file mode 100644
index 00000000000..7610d1b7d18
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/mvcc/MvccReadSelector.java
@@ -0,0 +1,116 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.mvcc;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.analysis.TableSnapshot;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+
+/**
+ * Immutable read selector shared by statement snapshot lookup and SQL 
result-cache dependencies.
+ * Stores the original selector, never a prior execution's resolved dynamic 
parameters. Every resolution
+ * receives fresh copies, so rebinding or PREPARE/EXECUTE cannot mutate a 
cached dependency.
+ */
+public final class MvccReadSelector {
+    public static final MvccReadSelector LATEST = new 
MvccReadSelector(Optional.empty(), Optional.empty());
+
+    private final Optional<TableSnapshot> tableSnapshot;
+    private final Optional<TableScanParams> scanParams;
+    private final String key;
+
+    private MvccReadSelector(Optional<TableSnapshot> tableSnapshot, 
Optional<TableScanParams> scanParams) {
+        this.tableSnapshot = tableSnapshot.map(TableSnapshot::new);
+        this.scanParams = scanParams.map(MvccReadSelector::copyScanParams);
+        this.key = versionKeyOf(this.tableSnapshot, this.scanParams);
+    }
+
+    public static MvccReadSelector of(Optional<TableSnapshot> tableSnapshot, 
Optional<TableScanParams> scanParams) {
+        return !tableSnapshot.isPresent() && !scanParams.isPresent()
+                ? LATEST : new MvccReadSelector(tableSnapshot, scanParams);
+    }
+
+    public Optional<TableSnapshot> getTableSnapshot() {
+        return tableSnapshot.map(TableSnapshot::new);
+    }
+
+    public Optional<TableScanParams> getScanParams() {
+        return scanParams.map(MvccReadSelector::copyScanParams);
+    }
+
+    public boolean isLatest() {
+        return key.isEmpty();
+    }
+
+    public String getKey() {
+        return key;
+    }
+
+    private static TableScanParams copyScanParams(TableScanParams params) {
+        return new TableScanParams(params.getParamType(), 
params.getMapParams(), params.getListParams());
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        return other instanceof MvccReadSelector && 
key.equals(((MvccReadSelector) other).key);
+    }
+
+    @Override
+    public int hashCode() {
+        return key.hashCode();
+    }
+
+    private static String versionKeyOf(Optional<TableSnapshot> tableSnapshot, 
Optional<TableScanParams> scanParams) {
+        // Concatenate both selectors (rather than returning on the first) so 
the key stays injective even
+        // if a reference ever carried BOTH a snapshot and scan params. Today 
the two are mutually exclusive
+        // (e.g. IcebergUtils.getQuerySpecSnapshot rejects FOR-TIME together 
with @branch/@tag), so in every
+        // reachable case exactly one part is non-empty and the key is 
"v:...", "p:...", or "".
+        StringBuilder key = new StringBuilder();
+        if (tableSnapshot.isPresent()) {
+            TableSnapshot ts = tableSnapshot.get();
+            key.append('v');
+            appendVersionKeyPart(key, ts.getType());
+            appendVersionKeyPart(key, ts.getValue());
+        }
+        if (scanParams.isPresent()) {
+            TableScanParams sp = scanParams.get();
+            key.append('p');
+            appendVersionKeyPart(key, sp.getParamType());
+            Map<String, String> sortedParams = new 
TreeMap<>(sp.getMapParams());
+            key.append('m').append(sortedParams.size()).append(':');
+            for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
+                // Length prefixes preserve entry boundaries even when user 
values contain Map.toString()
+                // delimiters; sorting separately keeps semantically identical 
maps order-independent.
+                appendVersionKeyPart(key, entry.getKey());
+                appendVersionKeyPart(key, entry.getValue());
+            }
+            key.append('l').append(sp.getListParams().size()).append(':');
+            for (String value : sp.getListParams()) {
+                appendVersionKeyPart(key, value);
+            }
+        }
+        return key.toString();
+    }
+
+    private static void appendVersionKeyPart(StringBuilder key, Object value) {
+        String text = String.valueOf(value);
+        key.append(text.length()).append(':').append(text);
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshContext.java
index 77fe3312119..bb9df121ca2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshContext.java
@@ -135,7 +135,8 @@ public class MTMVRefreshContext {
         knownMissing.addAll(stillMissing);
     }
 
-    void recordPartitionSnapshotFailure(
+    /** Preserve a per-partition failure reported by a related table's bulk 
loader. */
+    public void recordPartitionSnapshotFailure(
             MTMVRelatedTableIf table, String partitionName, AnalysisException 
failure) {
         partitionSnapshotFailureCache.computeIfAbsent(table, ignored -> new 
LinkedHashMap<>())
                 .put(partitionName, failure);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
index e532ce611fe..11e454b9442 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/SqlCacheContext.java
@@ -27,6 +27,8 @@ import org.apache.doris.catalog.TableIf.TableType;
 import org.apache.doris.common.Pair;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.datasource.hive.HMSExternalTable;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
 import org.apache.doris.mysql.FieldInfo;
 import org.apache.doris.mysql.privilege.Auth;
 import org.apache.doris.mysql.privilege.DataMaskPolicy;
@@ -40,6 +42,8 @@ import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.ResultSet;
 import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.qe.cache.CacheProxy;
+import org.apache.doris.qe.cache.SqlCacheVersion;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
 import org.apache.doris.thrift.TUniqueId;
 
 import com.google.common.collect.ImmutableList;
@@ -74,7 +78,7 @@ public class SqlCacheContext {
     private volatile long latestPartitionVersion = -1;
     private volatile long sumOfPartitionNum = -1;
     // value: version of table
-    private final Map<FullTableName, TableVersion> usedTables = 
Maps.newLinkedHashMap();
+    private final Map<TableReference, TableVersion> usedTables = 
Maps.newLinkedHashMap();
     // value: ddl sql
     private final Map<FullTableName, String> usedViews = 
Maps.newLinkedHashMap();
     // value: usedColumns
@@ -172,6 +176,17 @@ public class SqlCacheContext {
 
     /** addUsedTable */
     public synchronized void addUsedTable(TableIf tableIf) {
+        addUsedTable(tableIf, Optional.empty());
+    }
+
+    /** Record the exact MVCC generation consumed by this relation. */
+    public synchronized void addUsedTable(TableIf tableIf, 
Optional<MvccSnapshot> snapshot) {
+        addUsedTable(tableIf, snapshot, MvccReadSelector.LATEST);
+    }
+
+    /** Record one table reference independently of other versions of the same 
table. */
+    public synchronized void addUsedTable(TableIf tableIf, 
Optional<MvccSnapshot> snapshot,
+            MvccReadSelector selector) {
         if (tableIf == null) {
             return;
         }
@@ -190,10 +205,24 @@ public class SqlCacheContext {
         }
 
         long version = 0;
+        SqlCacheVersion sqlCacheVersion = null;
         try {
+            if (tableIf instanceof SqlCacheVersionProvider) {
+                sqlCacheVersion = ((SqlCacheVersionProvider) 
tableIf).getSqlCacheVersion(snapshot).orElse(null);
+            }
+            if (tableIf instanceof SqlCacheVersionProvider && sqlCacheVersion 
== null
+                    && !(tableIf instanceof HMSExternalTable)) {
+                setHasUnsupportedTables(true);
+                return;
+            }
+            if (!selector.isLatest() && sqlCacheVersion == null) {
+                // Legacy freshness is table-wide, so it cannot validate a 
historical/branch dependency.
+                setHasUnsupportedTables(true);
+                return;
+            }
             if (tableIf instanceof OlapTable) {
                 version = ((OlapTable) tableIf).getVisibleVersion();
-            } else if (tableIf instanceof HMSExternalTable) {
+            } else if (sqlCacheVersion == null && tableIf instanceof 
HMSExternalTable) {
                 HMSExternalTable hmsExternalTable = (HMSExternalTable) tableIf;
                 version = hmsExternalTable.getUpdateTime();
             }
@@ -204,11 +233,12 @@ public class SqlCacheContext {
         }
 
         usedTables.put(
-                new FullTableName(database.getCatalog().getName(), 
database.getFullName(), tableIf.getName()),
+                new TableReference(new 
FullTableName(database.getCatalog().getName(),
+                        database.getFullName(), tableIf.getName()), selector),
                 new TableVersion(
                         tableIf.getId(),
                         version,
-                        tableIf.getType()
+                        tableIf.getType(), sqlCacheVersion
                 )
         );
     }
@@ -358,7 +388,7 @@ public class SqlCacheContext {
         this.cacheProxy = cacheProxy;
     }
 
-    public Map<FullTableName, TableVersion> getUsedTables() {
+    public Map<TableReference, TableVersion> getUsedTables() {
         return Collections.unmodifiableMap(usedTables);
     }
 
@@ -442,15 +472,18 @@ public class SqlCacheContext {
     public synchronized PUniqueId doComputeCacheKeyMd5(
             Set<Variable> usedVariables, SessionVariable sessionVariable) {
         StringBuilder cacheKey = new 
StringBuilder(NereidsParser.removeCommentAndTrimBlank(originSql.trim()));
-        for (Entry<FullTableName, TableVersion> entry : usedTables.entrySet()) 
{
+        for (Entry<TableReference, TableVersion> entry : 
usedTables.entrySet()) {
             TableVersion tableVersion = entry.getValue();
-            FullTableName tableName = entry.getKey();
+            TableReference reference = entry.getKey();
+            String selectorKey = reference.selector.getKey();
             cacheKey.append("|")
-                    .append(tableName)
+                    .append(reference.tableName)
+                    
.append("#").append(selectorKey.length()).append(":").append(selectorKey)
                     .append("=")
                     .append(tableVersion.id)
                     .append("@")
-                    .append(tableVersion.version);
+                    .append(tableVersion.sqlCacheVersion == null ? 
Long.toString(tableVersion.version)
+                            : "sql-cache:" + 
tableVersion.sqlCacheVersion.getVersion());
         }
         for (Entry<FullTableName, String> entry : usedViews.entrySet()) {
             cacheKey.append("|")
@@ -586,6 +619,14 @@ public class SqlCacheContext {
         }
     }
 
+    /** A table dependency identified by the original read selector, not by an 
alias or resolved snapshot. */
+    @lombok.Data
+    @lombok.AllArgsConstructor
+    public static class TableReference {
+        public final FullTableName tableName;
+        public final MvccReadSelector selector;
+    }
+
     /** TableVersion */
     @lombok.Data
     @lombok.AllArgsConstructor
@@ -593,6 +634,11 @@ public class SqlCacheContext {
         public final long id;
         public final long version;
         public final TableType type;
+        public final SqlCacheVersion sqlCacheVersion;
+
+        public TableVersion(long id, long version, TableType type) {
+            this(id, version, type, null);
+        }
     }
 
     /** CacheKeyType */
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
index 19bc96b89f6..5aa6f8cb6ea 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
@@ -44,6 +44,7 @@ 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.iceberg.IcebergSysExternalTable;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
 import org.apache.doris.datasource.mvcc.MvccSnapshot;
 import org.apache.doris.datasource.paimon.PaimonExternalTable;
 import org.apache.doris.datasource.paimon.PaimonScanParams;
@@ -107,6 +108,7 @@ import org.apache.doris.nereids.util.RelationUtil;
 import org.apache.doris.nereids.util.Utils;
 import org.apache.doris.qe.AutoCloseSessionVariable;
 import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.cache.SqlCacheVersionProvider;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
@@ -635,9 +637,18 @@ public class BindRelation extends OneAnalysisRuleFactory {
                             sqlCacheContext.setHasUnsupportedTables(true);
                         } else if (table instanceof OlapTable) {
                             sqlCacheContext.addUsedTable(table);
-                        } else if (table instanceof HMSExternalTable
+                        } else if ((table instanceof HMSExternalTable || table 
instanceof SqlCacheVersionProvider)
                                 && 
cascadesContext.getConnectContext().getSessionVariable().enableHiveSqlCache) {
-                            sqlCacheContext.addUsedTable(table);
+                            TableScanParams params = 
unboundRelation.getScanParams();
+                            if (params != null && !params.isBranch() && 
!params.isTag()) {
+                                sqlCacheContext.setHasUnsupportedTables(true);
+                            } else {
+                                MvccReadSelector selector = 
MvccReadSelector.of(unboundRelation.getTableSnapshot(),
+                                        Optional.ofNullable(params));
+                                sqlCacheContext.addUsedTable(table, 
cascadesContext.getStatementContext()
+                                        .getSnapshot(table, 
selector.getTableSnapshot(), selector.getScanParams()),
+                                        selector);
+                            }
                         } else {
                             sqlCacheContext.setHasUnsupportedTables(true);
                         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
index e68ac3c25a9..6c918e5bbd2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/CacheAnalyzer.java
@@ -38,6 +38,7 @@ import org.apache.doris.common.util.DebugUtil;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.datasource.hive.HMSExternalTable;
 import org.apache.doris.datasource.hive.source.HiveScanNode;
+import org.apache.doris.datasource.iceberg.source.IcebergScanNode;
 import org.apache.doris.metric.MetricRepo;
 import org.apache.doris.nereids.NereidsPlanner;
 import org.apache.doris.nereids.SqlCacheContext;
@@ -313,9 +314,12 @@ public class CacheAnalyzer {
             MetricRepo.COUNTER_QUERY_TABLE.increase(1L);
             long olapScanNodeSize = 0;
             long hiveScanNodeSize = 0;
+            long versionedScanNodeSize = 0;
             for (ScanNode scanNode : scanNodes) {
                 if (scanNode instanceof OlapScanNode) {
                     olapScanNodeSize++;
+                } else if (scanNode instanceof IcebergScanNode) {
+                    versionedScanNodeSize++;
                 } else if (scanNode instanceof HiveScanNode) {
                     hiveScanNodeSize++;
                 }
@@ -327,7 +331,8 @@ public class CacheAnalyzer {
                 MetricRepo.COUNTER_QUERY_HIVE_TABLE.increase(1L);
             }
 
-            if (!(olapScanNodeSize == scanNodes.size() || hiveScanNodeSize == 
scanNodes.size())) {
+            if (!(olapScanNodeSize == scanNodes.size()
+                    || hiveScanNodeSize + versionedScanNodeSize == 
scanNodes.size())) {
                 if (LOG.isDebugEnabled()) {
                     LOG.debug("only support olap/hive table with non-federated 
query, "
                             + "other types are not supported now, queryId {}", 
DebugUtil.printId(queryId));
@@ -340,7 +345,9 @@ public class CacheAnalyzer {
                 ScanNode node = scanNodes.get(i);
                 CacheTable cTable = node instanceof OlapScanNode
                         ? buildCacheTableForOlapScanNode((OlapScanNode) node)
-                        : buildCacheTableForHiveScanNode((HiveScanNode) node);
+                        : node instanceof IcebergScanNode
+                                ? 
buildCacheTableForIcebergScanNode((IcebergScanNode) node)
+                                : 
buildCacheTableForHiveScanNode((HiveScanNode) node);
                 tblTimeList.add(cTable);
             }
             Collections.sort(tblTimeList);
@@ -493,6 +500,19 @@ public class CacheAnalyzer {
         return cacheTable;
     }
 
+    private CacheTable buildCacheTableForIcebergScanNode(IcebergScanNode node) 
{
+        SqlCacheVersion version = node.getSqlCacheVersion()
+                .orElseThrow(() -> new IllegalStateException("No Iceberg SQL 
cache version"));
+        CacheTable cacheTable = new CacheTable();
+        cacheTable.table = node.getTargetTable();
+        cacheTable.partitionNum = 1;
+        cacheTable.latestPartitionTime = version.getUpdateTimeMillis();
+        DatabaseIf database = cacheTable.table.getDatabase();
+        scanTables.add(Pair.of(new ScanTable(new 
FullTableName(database.getCatalog().getName(),
+                database.getFullName(), cacheTable.table.getName())), 
cacheTable.table));
+        return cacheTable;
+    }
+
     private CacheTable buildCacheTableForHiveScanNode(HiveScanNode node) {
         CacheTable cacheTable = new CacheTable();
         cacheTable.table = node.getTargetTable();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersion.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersion.java
new file mode 100644
index 00000000000..e336290cdd0
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersion.java
@@ -0,0 +1,44 @@
+// 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.qe.cache;
+
+import java.util.Objects;
+
+/**
+ * Immutable whole-table SQL result-cache identity and its wall-clock update 
time.
+ * The identity is compared for equality, never ordered. It must cover data, 
schema and table recreation,
+ * and describe the same pinned generation as the scan. Reading it must not 
enumerate files or partitions.
+ * The timestamp is only for the cache's quiet-window eligibility check, never 
for identity comparisons.
+ */
+public final class SqlCacheVersion {
+    private final String version;
+    private final long updateTimeMillis;
+
+    public SqlCacheVersion(String version, long updateTimeMillis) {
+        this.version = Objects.requireNonNull(version, "version");
+        this.updateTimeMillis = updateTimeMillis;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public long getUpdateTimeMillis() {
+        return updateTimeMillis;
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java
 
b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java
new file mode 100644
index 00000000000..32fcbc4b887
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/qe/cache/SqlCacheVersionProvider.java
@@ -0,0 +1,31 @@
+// 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.qe.cache;
+
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
+
+import java.util.Optional;
+
+/**
+ * Supplies a whole-table SQL result-cache version independently of 
MV/dictionary freshness semantics.
+ * A supplied snapshot is the query's read generation; empty probes the 
current generation for cache lookup.
+ * Empty results preserve the caller's legacy freshness path. Failures disable 
caching for the query.
+ */
+public interface SqlCacheVersionProvider {
+    Optional<SqlCacheVersion> getSqlCacheVersion(Optional<MvccSnapshot> 
snapshot);
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java
index 5dc212da922..96670c69197 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergExternalMetaCacheTest.java
@@ -27,6 +27,7 @@ import org.apache.doris.datasource.SchemaCacheValue;
 import org.apache.doris.datasource.SessionContext;
 import org.apache.doris.datasource.hive.HMSExternalCatalog;
 import org.apache.doris.datasource.hive.HMSExternalTable;
+import org.apache.doris.datasource.hive.IcebergDlaTable;
 import org.apache.doris.datasource.iceberg.cache.ManifestCacheValue;
 import org.apache.doris.datasource.iceberg.source.IcebergTableQueryInfo;
 import org.apache.doris.datasource.metacache.EstimatorCalibrationAssertions;
@@ -34,6 +35,8 @@ import org.apache.doris.datasource.metacache.MetaCacheEntry;
 import org.apache.doris.datasource.metacache.MetaCacheEntryStats;
 import org.apache.doris.datasource.metacache.MetaCacheSizeEstimate;
 import org.apache.doris.datasource.metacache.MetaCacheWeightUtils;
+import org.apache.doris.mtmv.MTMVRefreshContext;
+import org.apache.doris.mtmv.MTMVSnapshotIf;
 import org.apache.doris.qe.ConnectContext;
 
 import org.apache.hadoop.conf.Configuration;
@@ -789,6 +792,13 @@ public class IcebergExternalMetaCacheTest {
         ExecutorService executor = Executors.newSingleThreadExecutor();
         IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
         IcebergExternalTable dorisTable = 
Mockito.mock(IcebergExternalTable.class);
+        NameMapping tableIdentity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalCatalog catalog = 
Mockito.mock(IcebergExternalCatalog.class);
+        Mockito.when(dorisTable.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ice");
+        Mockito.when(dorisTable.getDbName()).thenReturn("db");
+        Mockito.when(dorisTable.getName()).thenReturn("tbl");
+        
Mockito.when(dorisTable.getOrBuildNameMapping()).thenReturn(tableIdentity);
         Table projectionTable = Mockito.mock(Table.class);
         Snapshot snapshot = Mockito.mock(Snapshot.class);
         Schema schema = Mockito.mock(Schema.class);
@@ -806,7 +816,7 @@ public class IcebergExternalMetaCacheTest {
         try (MockedStatic<IcebergUtils> icebergUtils = Mockito.mockStatic(
                 IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
             icebergUtils.when(() -> IcebergUtils.loadPartitionInfo(
-                            dorisTable, projectionTable, 11L, 3L, 
capturedAuthenticator, true, false))
+                            "ice.db.tbl", projectionTable, 11L, 3L, 
capturedAuthenticator, true, false))
                     .thenReturn(IcebergPartitionInfo.empty());
             icebergUtils.when(() -> 
IcebergUtils.getNameMapping(projectionTable))
                     .thenReturn(Optional.empty());
@@ -814,11 +824,15 @@ public class IcebergExternalMetaCacheTest {
                     .thenReturn(true);
             icebergUtils.clearInvocations();
 
-            loader.invoke(cache, dorisTable, projectionTable, projectionTable,
+            IcebergSnapshotCacheValue value = (IcebergSnapshotCacheValue) 
loader.invoke(
+                    cache, dorisTable, projectionTable, projectionTable,
                     null, false, capturedAuthenticator, true, false);
 
             icebergUtils.verify(() -> IcebergUtils.loadPartitionInfo(
-                    dorisTable, projectionTable, 11L, 3L, 
capturedAuthenticator, true, false));
+                    "ice.db.tbl", projectionTable, 11L, 3L, 
capturedAuthenticator, true, false), Mockito.never());
+            value.getPartitionInfo();
+            icebergUtils.verify(() -> IcebergUtils.loadPartitionInfo(
+                    "ice.db.tbl", projectionTable, 11L, 3L, 
capturedAuthenticator, true, false));
             icebergUtils.verify(() -> 
IcebergUtils.isValidRelatedTable(projectionTable));
             Mockito.verify(dorisTable, Mockito.never()).isValidRelatedTable();
         } finally {
@@ -827,6 +841,497 @@ public class IcebergExternalMetaCacheTest {
         }
     }
 
+    @Test
+    public void testMvBulkPartitionSnapshotsReuseDisabledPartitionView() 
throws Exception {
+        assertMvBulkPartitionSnapshotsReuseUnadmittedView(
+                
Collections.singletonMap("meta.cache.iceberg.partition.enable", "false"));
+    }
+
+    @Test
+    public void testMvBulkPartitionSnapshotsReuseWeightRejectedPartitionView() 
throws Exception {
+        assertMvBulkPartitionSnapshotsReuseUnadmittedView(
+                
Collections.singletonMap("meta.cache.iceberg.partition.max-weight", "1B"));
+    }
+
+    private void assertMvBulkPartitionSnapshotsReuseUnadmittedView(Map<String, 
String> properties) throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        cache.initCatalog(1L, properties);
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable dorisTable = partitionConsumerForTest(identity);
+        Table table = 
tableWithMetadataLocation("/metadata/unadmitted-partitions.json");
+        cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class)
+                .put(identity, new IcebergTableCacheValue(table));
+        IcebergPartitionInfo info = realPartitionInfo(3);
+        AtomicInteger loads = new AtomicInteger();
+        ExecutionAuthenticator authenticator = new ExecutionAuthenticator() {};
+        try (MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            utils.when(() -> 
IcebergUtils.isValidRelatedTable(Mockito.any(Table.class))).thenReturn(true);
+            utils.when(() -> 
IcebergUtils.loadPartitionInfo(Mockito.eq("ice.db.tbl"), 
Mockito.any(Table.class),
+                    Mockito.anyLong(), Mockito.anyLong(), 
Mockito.same(authenticator), Mockito.eq(false), Mockito.eq(false)))
+                    .thenAnswer(call -> {
+                        loads.incrementAndGet();
+                        return info;
+                    });
+            IcebergSnapshotCacheValue value = 
partitionProjectionForTest(cache, dorisTable, table, authenticator);
+            Optional<org.apache.doris.datasource.mvcc.MvccSnapshot> snapshot =
+                    Optional.of(new IcebergMvccSnapshot(value));
+            MTMVRefreshContext context = new MTMVRefreshContext(null);
+            Mockito.when(dorisTable.getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context),
+                    Mockito.eq(snapshot))).thenCallRealMethod();
+            Mockito.when(dorisTable.getPartitionSnapshot(Mockito.anyString(), 
Mockito.same(context),
+                    Mockito.eq(snapshot))).thenCallRealMethod();
+            Map<String, MTMVSnapshotIf> versions = 
dorisTable.getPartitionSnapshots(
+                    new java.util.LinkedHashSet<>(Arrays.asList("part=0", 
"part=1", "part=2")), context, snapshot);
+            Assert.assertEquals(3, versions.size());
+            versions.values().forEach(version -> Assert.assertEquals(1L, 
version.getSnapshotVersion()));
+            Assert.assertEquals("one full view load for the complete refresh 
batch", 1, loads.get());
+            MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions = cache.entry(
+                    1L, IcebergExternalMetaCache.ENTRY_PARTITION,
+                    IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class);
+            
Assert.assertNull(partitions.peekIfPresent(IcebergSnapshotEntryKey.tryCreate(identity,
 table).get()));
+            Assert.assertSame(IcebergPartitionInfo.empty(), 
value.getRetainedPartitionInfo());
+            dorisTable.getPartitionSnapshot("part=0", context, snapshot);
+            Assert.assertEquals("the shared snapshot must not retain an 
unbudgeted view", 2, loads.get());
+        } finally {
+            cache.close();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testDictionaryChecksReuseVersionWhenPartitionCacheDisabled() 
throws Exception {
+        
assertDictionaryChecksReuseScalar(Collections.singletonMap("meta.cache.iceberg.partition.enable",
 "false"),
+                false);
+    }
+
+    @Test
+    public void testDictionaryChecksReuseVersionWhenPartitionViewRejected() 
throws Exception {
+        
assertDictionaryChecksReuseScalar(Collections.singletonMap("meta.cache.iceberg.partition.max-weight",
 "1B"),
+                false);
+    }
+
+    @Test
+    public void 
testDictionaryChecksReuseVersionAfterPartitionEntryRetirement() throws 
Exception {
+        assertDictionaryChecksReuseScalar(Collections.emptyMap(), true);
+    }
+
+    private void assertDictionaryChecksReuseScalar(Map<String, String> 
properties, boolean retired) throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        cache.initCatalog(1L, properties);
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable table = partitionConsumerForTest(identity);
+        
Mockito.when(table.getNewestUpdateVersionOrTime()).thenCallRealMethod();
+        Table first = 
tableWithMetadataLocation("/metadata/dictionary-v1.json");
+        cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class)
+                .put(identity, new IcebergTableCacheValue(first));
+        MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions = cache.entry(
+                1L, IcebergExternalMetaCache.ENTRY_PARTITION,
+                IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class);
+        AtomicInteger loads = new AtomicInteger();
+        AtomicReference<IcebergSnapshotCacheValue> projection = new 
AtomicReference<>();
+        AtomicReference<IcebergPartitionInfo> info = new 
AtomicReference<>(partitionUpdateTimes(3L, 7L, 5L));
+        ExecutionAuthenticator authenticator = new ExecutionAuthenticator() {};
+        try (MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            utils.when(() -> 
IcebergUtils.isValidRelatedTable(Mockito.any(Table.class))).thenReturn(true);
+            utils.when(() -> 
IcebergUtils.loadPartitionInfo(Mockito.eq("ice.db.tbl"), 
Mockito.any(Table.class),
+                    Mockito.anyLong(), Mockito.anyLong(), 
Mockito.same(authenticator), Mockito.eq(false),
+                    Mockito.eq(false))).thenAnswer(call -> {
+                        loads.incrementAndGet();
+                        return info.get();
+                    });
+            utils.when(() -> IcebergUtils.getLatestSnapshotCacheValue(table))
+                    .thenAnswer(call -> 
projection.get().withoutRetainedTable());
+            utils.when(() -> 
IcebergUtils.withLatestSnapshotCacheValue(Mockito.same(table),
+                    
Mockito.<java.util.function.Function<IcebergSnapshotCacheValue, Long>>any()))
+                    .thenAnswer(call -> {
+                        java.util.function.Function<IcebergSnapshotCacheValue, 
Long> action = call.getArgument(1);
+                        return action.apply(projection.get());
+                    });
+            projection.set(partitionProjectionForTest(cache, table, first, 
authenticator));
+            if (retired) {
+                cache.invalidateCatalog(1L);
+            }
+            for (int check = 0; check < 3; check++) {
+                Assert.assertEquals(7L, table.getNewestUpdateVersionOrTime());
+            }
+            Assert.assertEquals("repeated dictionary checks must not reload 
the full view", 1, loads.get());
+            Assert.assertSame(IcebergPartitionInfo.empty(), 
projection.get().getRetainedPartitionInfo());
+            
Assert.assertNull(partitions.peekIfPresent(IcebergSnapshotEntryKey.tryCreate(identity,
 first).get()));
+
+            cache.initCatalog(1L, properties);
+            Table second = 
tableWithMetadataLocation("/metadata/dictionary-v2.json");
+            cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class)
+                    .put(identity, new IcebergTableCacheValue(second));
+            info.set(partitionUpdateTimes(11L, 9L));
+            projection.set(partitionProjectionForTest(cache, table, second, 
authenticator));
+            Assert.assertEquals(11L, table.getNewestUpdateVersionOrTime());
+            Assert.assertEquals(11L, table.getNewestUpdateVersionOrTime());
+            Assert.assertEquals("a new metadata generation must compute its 
own version", 2, loads.get());
+            Assert.assertSame(IcebergPartitionInfo.empty(), 
projection.get().getRetainedPartitionInfo());
+            Table empty = 
tableWithMetadataLocation("/metadata/dictionary-empty.json");
+            cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class)
+                    .put(identity, new IcebergTableCacheValue(empty));
+            info.set(IcebergPartitionInfo.empty());
+            projection.set(partitionProjectionForTest(cache, table, empty, 
authenticator));
+            Assert.assertEquals(0L, table.getNewestUpdateVersionOrTime());
+            Assert.assertEquals(0L, table.getNewestUpdateVersionOrTime());
+            Assert.assertEquals("an empty view's zero version is memoized 
too", 3, loads.get());
+        } finally {
+            cache.close();
+            executor.shutdownNow();
+        }
+    }
+
+    private IcebergPartitionInfo partitionUpdateTimes(long... updateTimes) {
+        Map<String, IcebergPartition> partitions = new HashMap<>();
+        for (int i = 0; i < updateTimes.length; i++) {
+            String name = "part=" + i;
+            partitions.put(name, new IcebergPartition(name, 0, 1L, 1L, 1L, 
updateTimes[i], 1L,
+                    Collections.singletonList(Integer.toString(i)), 
Collections.singletonList("day")));
+        }
+        return new IcebergPartitionInfo(Collections.emptyMap(), partitions, 
Collections.emptyMap());
+    }
+
+    @Test
+    public void testTableReplacementRetiresOnlyObsoletePartitionViews() throws 
Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        try {
+            cache.initCatalog(1L, Collections.emptyMap());
+            NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+            Table first = 
tableWithMetadataLocation("/metadata/partition-replace-v1.json");
+            Table second = 
tableWithMetadataLocation("/metadata/partition-replace-v2.json");
+            MetaCacheEntry<NameMapping, IcebergTableCacheValue> tables = 
cache.entry(
+                    1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class);
+            MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions = cache.entry(
+                    1L, IcebergExternalMetaCache.ENTRY_PARTITION,
+                    IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class);
+            IcebergSnapshotEntryKey oldKey = 
IcebergSnapshotEntryKey.tryCreate(identity, first).get();
+            IcebergSnapshotEntryKey newKey = 
IcebergSnapshotEntryKey.tryCreate(identity, second).get();
+            IcebergSnapshotEntryKey unrelatedKey = 
IcebergSnapshotEntryKey.tryCreate(
+                    NameMapping.createForTest(1L, "db", "other"), first).get();
+            IcebergPartitionInfo info = realPartitionInfo(1);
+            tables.put(identity, new IcebergTableCacheValue(first));
+            partitions.put(oldKey, info);
+            partitions.put(unrelatedKey, info);
+            tables.put(identity, new IcebergTableCacheValue(first));
+            Assert.assertSame("same metadata generation stays reusable", info, 
partitions.peekIfPresent(oldKey));
+            partitions.put(newKey, info);
+            tables.put(identity, new IcebergTableCacheValue(second));
+            Assert.assertNull(partitions.peekIfPresent(oldKey));
+            Assert.assertSame(info, partitions.peekIfPresent(newKey));
+            Assert.assertSame(info, partitions.peekIfPresent(unrelatedKey));
+        } finally {
+            cache.close();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testOldPartitionLoadsCannotRepublishAfterTableReplacement() 
throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        cache.initCatalog(1L, Collections.emptyMap());
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable dorisTable = partitionConsumerForTest(identity);
+        Table oldTable = 
tableWithMetadataLocation("/metadata/partition-load-v1.json");
+        Table newTable = 
tableWithMetadataLocation("/metadata/partition-load-v2.json");
+        MetaCacheEntry<NameMapping, IcebergTableCacheValue> tables = 
cache.entry(
+                1L, IcebergExternalMetaCache.ENTRY_TABLE, NameMapping.class, 
IcebergTableCacheValue.class);
+        tables.put(identity, new IcebergTableCacheValue(oldTable));
+        MetaCacheEntry<IcebergSnapshotEntryKey, IcebergPartitionInfo> 
partitions = cache.entry(
+                1L, IcebergExternalMetaCache.ENTRY_PARTITION,
+                IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class);
+        IcebergSnapshotEntryKey oldKey = 
IcebergSnapshotEntryKey.tryCreate(identity, oldTable).get();
+        IcebergPartitionInfo info = realPartitionInfo(1);
+        AtomicInteger loads = new AtomicInteger();
+        java.util.concurrent.atomic.AtomicBoolean replaceDuringLoad = new 
java.util.concurrent.atomic.AtomicBoolean(true);
+        ExecutionAuthenticator authenticator = new ExecutionAuthenticator() {};
+        try (MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            utils.when(() -> 
IcebergUtils.isValidRelatedTable(Mockito.any(Table.class))).thenReturn(true);
+            utils.when(() -> 
IcebergUtils.loadPartitionInfo(Mockito.eq("ice.db.tbl"), 
Mockito.any(Table.class),
+                    Mockito.anyLong(), Mockito.anyLong(), 
Mockito.same(authenticator), Mockito.eq(false), Mockito.eq(false)))
+                    .thenAnswer(call -> {
+                        loads.incrementAndGet();
+                        if (replaceDuringLoad.getAndSet(false)) {
+                            tables.put(identity, new 
IcebergTableCacheValue(newTable));
+                        }
+                        return info;
+                    });
+            IcebergSnapshotCacheValue old = partitionProjectionForTest(cache, 
dorisTable, oldTable, authenticator);
+            Assert.assertSame(info, old.getPartitionInfo());
+            Assert.assertNull("replacement during loading must fence 
publication", partitions.peekIfPresent(oldKey));
+            Assert.assertSame(info, old.getPartitionInfo());
+            Assert.assertNull("late access through a pinned old projection 
stays uncached",
+                    partitions.peekIfPresent(oldKey));
+            IcebergSnapshotCacheValue current = 
partitionProjectionForTest(cache, dorisTable, newTable, authenticator);
+            Assert.assertSame(current.getPartitionInfo(), 
current.getPartitionInfo());
+            Assert.assertEquals(3, loads.get());
+        } finally {
+            cache.close();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testHmsIcebergBulkSnapshotsReuseOnePartitionView() throws 
Exception {
+        HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class);
+        org.apache.doris.datasource.hive.HMSExternalDatabase database =
+                
Mockito.mock(org.apache.doris.datasource.hive.HMSExternalDatabase.class);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(database.getRemoteName()).thenReturn("db");
+        HMSExternalTable table = new HMSExternalTable(1L, "tbl", "tbl", 
catalog, database) {
+            @Override
+            protected void makeSureInitialized() {
+                // This test installs the Iceberg DLA and pinned snapshot 
directly.
+            }
+        };
+        Field dla = HMSExternalTable.class.getDeclaredField("dlaTable");
+        dla.setAccessible(true);
+        dla.set(table, new IcebergDlaTable(table));
+        AtomicInteger loads = new AtomicInteger();
+        IcebergPartitionInfo info = realPartitionInfo(2);
+        IcebergSnapshotCacheValue value = new IcebergSnapshotCacheValue(
+                IcebergPartitionInfo.empty(), new IcebergSnapshot(11L, 0L))
+                .withPartitionInfoLoader(() -> {
+                    loads.incrementAndGet();
+                    return info;
+                });
+        Map<String, MTMVSnapshotIf> versions = table.getPartitionSnapshots(
+                new java.util.LinkedHashSet<>(Arrays.asList("part=0", 
"part=1")),
+                new MTMVRefreshContext(null), Optional.of(new 
IcebergMvccSnapshot(value)));
+        Assert.assertEquals(2, versions.size());
+        Assert.assertEquals(1, loads.get());
+    }
+
+    @Test
+    public void testBulkSnapshotsPreserveTableFallbackAndPerPartitionFailure() 
throws Exception {
+        IcebergExternalTable table = 
partitionConsumerForTest(NameMapping.createForTest(1L, "db", "tbl"));
+        MTMVRefreshContext context = Mockito.mock(MTMVRefreshContext.class);
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any()))
+                .thenCallRealMethod();
+        IcebergPartitionInfo info = Mockito.mock(IcebergPartitionInfo.class);
+        
Mockito.when(info.getLatestSnapshotId("unknown_version")).thenReturn(0L);
+        
Mockito.when(info.getLatestSnapshotId("known_version")).thenReturn(42L);
+        Set<String> names = new 
java.util.LinkedHashSet<>(Arrays.asList("unknown_version", "known_version"));
+        IcebergSnapshotCacheValue valid = new IcebergSnapshotCacheValue(info, 
new IcebergSnapshot(77L, 0L));
+        Map<String, MTMVSnapshotIf> versions = 
table.getPartitionSnapshots(names, context,
+                Optional.of(new IcebergMvccSnapshot(valid)));
+        Assert.assertEquals(77L, 
versions.get("unknown_version").getSnapshotVersion());
+        Assert.assertEquals(42L, 
versions.get("known_version").getSnapshotVersion());
+        IcebergSnapshotCacheValue empty = new IcebergSnapshotCacheValue(info, 
new IcebergSnapshot(-1L, 0L));
+        Map<String, MTMVSnapshotIf> partial = 
table.getPartitionSnapshots(names, context,
+                Optional.of(new IcebergMvccSnapshot(empty)));
+        Assert.assertEquals(Collections.singleton("known_version"), 
partial.keySet());
+        
Mockito.verify(context).recordPartitionSnapshotFailure(Mockito.same(table), 
Mockito.eq("unknown_version"),
+                Mockito.argThat(error -> error.getMessage().contains("table 
snapshot ID is also invalid")));
+        Assert.assertTrue(table.getPartitionSnapshots(Collections.emptySet(), 
context, Optional.empty()).isEmpty());
+    }
+
+    private IcebergExternalTable partitionConsumerForTest(NameMapping 
identity) {
+        IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class);
+        IcebergExternalCatalog catalog = 
Mockito.mock(IcebergExternalCatalog.class);
+        Mockito.when(table.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ice");
+        Mockito.when(table.getDbName()).thenReturn("db");
+        Mockito.when(table.getName()).thenReturn("tbl");
+        Mockito.when(table.getOrBuildNameMapping()).thenReturn(identity);
+        return table;
+    }
+
+    private IcebergSnapshotCacheValue 
partitionProjectionForTest(IcebergExternalMetaCache cache,
+            IcebergExternalTable table, Table retainedTable, 
ExecutionAuthenticator authenticator) throws Exception {
+        Method loader = IcebergExternalMetaCache.class.getDeclaredMethod(
+                "loadSnapshotProjection", ExternalTable.class, Table.class, 
Table.class,
+                String.class, boolean.class, ExecutionAuthenticator.class, 
boolean.class, boolean.class);
+        loader.setAccessible(true);
+        return (IcebergSnapshotCacheValue) loader.invoke(cache, table,
+                retainedTable, retainedTable, null, false, authenticator, 
false, false);
+    }
+
+    @Test
+    public void 
testLazyRetainedPartitionRejectsCaseInsensitiveDuplicateColumns() throws 
Exception {
+        assertDuplicateSchemaRejectedByRetainedPartitionLoad(false);
+    }
+
+    @Test
+    public void 
testRetiredPartitionProjectionRejectsCaseInsensitiveDuplicateColumns() throws 
Exception {
+        assertDuplicateSchemaRejectedByRetainedPartitionLoad(true);
+    }
+
+    private void assertDuplicateSchemaRejectedByRetainedPartitionLoad(boolean 
resetBeforeLoad) throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable dorisTable = partitionConsumerForTest(identity);
+        Schema schema = new Schema(Types.NestedField.optional(1, "dt", 
Types.DateType.get()),
+                Types.NestedField.optional(2, "DT", Types.IntegerType.get()));
+        PartitionSpec spec = 
PartitionSpec.builderFor(schema).day("dt").build();
+        String location = 
temporaryFolder.newFolder("retained-partition-duplicate-schema").toURI().toString();
+        Table table = new HadoopTables(new Configuration()).create(schema, 
spec, location);
+        PartitionData partition = new PartitionData(spec.rawPartitionType());
+        partition.set(0, 19724);
+        table.newFastAppend().appendFile(DataFiles.builder(spec)
+                .withPath(location + 
"/data/old.parquet").withPartition(partition)
+                .withFileSizeInBytes(10).withRecordCount(1).build()).commit();
+        IcebergTableCacheValue generation = new IcebergTableCacheValue(table);
+        try (IcebergTableCacheValue.Lease retained = generation.tryAcquire();
+                MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            Assert.assertNotNull(retained);
+            utils.when(() -> 
IcebergUtils.useSessionCatalog(dorisTable)).thenReturn(false);
+            cache.initCatalog(1L, Collections.emptyMap());
+            cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE,
+                    NameMapping.class, 
IcebergTableCacheValue.class).put(identity, generation);
+            IcebergSnapshotCacheValue projection = partitionProjectionForTest(
+                    cache, dorisTable, retained.getIcebergTable(), new 
ExecutionAuthenticator() { });
+            if (resetBeforeLoad) {
+                cache.invalidateCatalog(1L);
+            }
+            RuntimeException error = 
Assert.assertThrows(RuntimeException.class, projection::getPartitionInfo);
+            
Assert.assertTrue(org.apache.commons.lang3.exception.ExceptionUtils.getRootCauseMessage(error)
+                    .contains("Duplicate column name found: DT"));
+            
Assert.assertTrue(org.apache.commons.lang3.exception.ExceptionUtils.getThrowableList(error).stream()
+                    .anyMatch(cause -> cause instanceof 
org.apache.doris.common.AnalysisException
+                            && cause.getMessage().contains("ice.db.tbl")
+                            && 
cause.getMessage().contains(Long.toString(table.currentSnapshot().snapshotId()))));
+            Mockito.verify(dorisTable.getCatalog(), 
Mockito.never()).getExecutionAuthenticator();
+        } finally {
+            cache.close();
+            generation.retire();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testLazyPartitionLoadSurvivesCatalogReset() throws Exception {
+        assertRetainedPartitionLoadAfterReset(false, false);
+    }
+
+    @Test
+    public void testPartitionProjectionCreatedAfterCatalogReset() throws 
Exception {
+        assertRetainedPartitionLoadAfterReset(true, false);
+    }
+
+    @Test
+    public void testSessionPartitionProjectionNeedsNoCatalogCacheGroup() 
throws Exception {
+        assertRetainedPartitionLoadAfterReset(true, true);
+    }
+
+    private void assertRetainedPartitionLoadAfterReset(boolean 
resetBeforeProjection, boolean sessionCatalog)
+            throws Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable dorisTable = partitionConsumerForTest(identity);
+        Schema schema = new Schema(Types.NestedField.optional(1, "dt", 
Types.DateType.get()));
+        PartitionSpec spec = 
PartitionSpec.builderFor(schema).day("dt").build();
+        String location = 
temporaryFolder.newFolder("retained-partition-reset").toURI().toString();
+        Table table = new HadoopTables(new Configuration()).create(schema, 
spec, location);
+        PartitionData partition = new PartitionData(spec.rawPartitionType());
+        partition.set(0, 19724);
+        table.newFastAppend().appendFile(DataFiles.builder(spec)
+                .withPath(location + 
"/data/old.parquet").withPartition(partition)
+                .withFileSizeInBytes(10).withRecordCount(1).build()).commit();
+        AtomicInteger authenticatedCalls = new AtomicInteger();
+        ExecutionAuthenticator captured = new ExecutionAuthenticator() {
+            @Override
+            public <T> T execute(Callable<T> action) throws Exception {
+                authenticatedCalls.incrementAndGet();
+                return action.call();
+            }
+        };
+        IcebergTableCacheValue generation = new IcebergTableCacheValue(table);
+        try (IcebergTableCacheValue.Lease retained = generation.tryAcquire();
+                MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            Assert.assertNotNull(retained);
+            utils.when(() -> 
IcebergUtils.useSessionCatalog(dorisTable)).thenReturn(sessionCatalog);
+            if (!sessionCatalog) {
+                cache.initCatalog(1L, Collections.emptyMap());
+                cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE,
+                        NameMapping.class, IcebergTableCacheValue.class)
+                        .put(identity, generation);
+                if (resetBeforeProjection) {
+                    cache.invalidateCatalog(1L);
+                }
+            }
+            IcebergSnapshotCacheValue projection = partitionProjectionForTest(
+                    cache, dorisTable, retained.getIcebergTable(), captured);
+            Assert.assertEquals("partition enumeration remains lazy", 0, 
authenticatedCalls.get());
+            if (!resetBeforeProjection) {
+                cache.invalidateCatalog(1L);
+            }
+            IcebergPartitionInfo info = projection.getPartitionInfo();
+            Assert.assertEquals(1, info.getNameToPartitionItem().size());
+            Assert.assertEquals(1, info.getNameToIcebergPartition().size());
+            Assert.assertEquals(table.currentSnapshot().snapshotId(),
+                    
info.getNameToIcebergPartition().values().iterator().next().getLastSnapshotId());
+            Assert.assertTrue(authenticatedCalls.get() > 0);
+            Mockito.verify(dorisTable.getCatalog(), 
Mockito.never()).getExecutionAuthenticator();
+            // Retired work must not populate the next catalog generation's 
budgeted entries.
+            cache.initCatalog(1L, Collections.emptyMap());
+            Assert.assertNull(cache.entry(1L, 
IcebergExternalMetaCache.ENTRY_PARTITION,
+                    IcebergSnapshotEntryKey.class, IcebergPartitionInfo.class)
+                    .peekIfPresent(IcebergSnapshotEntryKey.tryCreate(identity, 
table).get()));
+        } finally {
+            cache.close();
+            generation.retire();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void 
testPartitionViewLoadsOncePerPhysicalGenerationAndRefreshInvalidatesIt() throws 
Exception {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        IcebergExternalMetaCache cache = new 
IcebergExternalMetaCache(executor);
+        cache.initCatalog(1L, Collections.emptyMap());
+        NameMapping identity = NameMapping.createForTest(1L, "db", "tbl");
+        IcebergExternalTable dorisTable = 
Mockito.mock(IcebergExternalTable.class);
+        IcebergExternalCatalog catalog = 
Mockito.mock(IcebergExternalCatalog.class);
+        Mockito.when(dorisTable.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("ice");
+        Mockito.when(dorisTable.getDbName()).thenReturn("db");
+        Mockito.when(dorisTable.getName()).thenReturn("tbl");
+        Mockito.when(dorisTable.getOrBuildNameMapping()).thenReturn(identity);
+        Table first = tableWithMetadataLocation("/metadata/partition-v1.json");
+        Table second = 
tableWithMetadataLocation("/metadata/partition-v2.json");
+        cache.entry(1L, IcebergExternalMetaCache.ENTRY_TABLE, 
NameMapping.class, IcebergTableCacheValue.class)
+                .put(identity, new IcebergTableCacheValue(first));
+        ExecutionAuthenticator authenticator = new ExecutionAuthenticator() { 
};
+        java.util.concurrent.atomic.AtomicInteger loads = new 
java.util.concurrent.atomic.AtomicInteger();
+        Method loader = IcebergExternalMetaCache.class.getDeclaredMethod(
+                "loadSnapshotProjection", ExternalTable.class, Table.class, 
Table.class,
+                String.class, boolean.class, ExecutionAuthenticator.class, 
boolean.class, boolean.class);
+        loader.setAccessible(true);
+        try (MockedStatic<IcebergUtils> utils = 
Mockito.mockStatic(IcebergUtils.class, Mockito.CALLS_REAL_METHODS)) {
+            utils.when(() -> 
IcebergUtils.isValidRelatedTable(Mockito.any(Table.class))).thenReturn(true);
+            utils.when(() -> 
IcebergUtils.loadPartitionInfo(Mockito.eq("ice.db.tbl"), 
Mockito.any(Table.class),
+                    Mockito.anyLong(), Mockito.anyLong(), 
Mockito.same(authenticator), Mockito.eq(false), Mockito.eq(false)))
+                    .thenAnswer(call -> {
+                        loads.incrementAndGet();
+                        return IcebergPartitionInfo.empty();
+                    });
+            IcebergSnapshotCacheValue one = (IcebergSnapshotCacheValue) 
loader.invoke(cache, dorisTable,
+                    first, first, null, false, authenticator, false, false);
+            IcebergSnapshotCacheValue same = (IcebergSnapshotCacheValue) 
loader.invoke(cache, dorisTable,
+                    first, first, null, false, authenticator, false, false);
+            Assert.assertEquals(0, loads.get());
+            Assert.assertSame(one.getPartitionInfo(), same.getPartitionInfo());
+            Assert.assertEquals(1, loads.get());
+            cache.invalidateTable(1L, "db", "tbl");
+            one.getPartitionInfo();
+            Assert.assertEquals(2, loads.get());
+            IcebergSnapshotCacheValue newer = (IcebergSnapshotCacheValue) 
loader.invoke(cache, dorisTable,
+                    second, second, null, false, authenticator, false, false);
+            newer.getPartitionInfo();
+            Assert.assertEquals(3, loads.get());
+        } finally {
+            cache.close();
+            executor.shutdownNow();
+        }
+    }
+
     @Test
     public void 
testHmsSnapshotProjectionIgnoresRelatedTableCacheFromAnotherGeneration() throws 
Exception {
         ExecutorService executor = Executors.newSingleThreadExecutor();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java
index c184debd3d7..a4b50f30921 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergPartitionInfoTest.java
@@ -65,7 +65,8 @@ public class IcebergPartitionInfoTest {
         names.add("p2");
         nameToIcebergPartitionNames.put("p1", names);
 
-        IcebergPartitionInfo info = new IcebergPartitionInfo(null, 
nameToIcebergPartition, nameToIcebergPartitionNames);
+        IcebergPartitionInfo info = new IcebergPartitionInfo(
+                Collections.emptyMap(), nameToIcebergPartition, 
nameToIcebergPartitionNames);
         long snapshot1 = info.getLatestSnapshotId("p1");
         long snapshot2 = info.getLatestSnapshotId("p2");
         long snapshot3 = info.getLatestSnapshotId("p3");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSqlCacheVersionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSqlCacheVersionTest.java
new file mode 100644
index 00000000000..4ccd879410d
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergSqlCacheVersionTest.java
@@ -0,0 +1,113 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.iceberg;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.analysis.TableSnapshot;
+import org.apache.doris.datasource.mvcc.MvccReadSelector;
+import org.apache.doris.nereids.SqlCacheContext;
+import org.apache.doris.qe.cache.SqlCacheVersion;
+
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Optional;
+
+class IcebergSqlCacheVersionTest {
+    private SqlCacheVersion version(String uuid, String metadataFile, long 
snapshotId, long schemaId) {
+        IcebergSnapshotCacheValue value = 
Mockito.mock(IcebergSnapshotCacheValue.class);
+        Table table = Mockito.mock(Table.class, 
Mockito.withSettings().extraInterfaces(HasTableOperations.class));
+        TableOperations operations = Mockito.mock(TableOperations.class);
+        TableMetadata metadata = Mockito.mock(TableMetadata.class);
+        Mockito.when(((HasTableOperations) 
table).operations()).thenReturn(operations);
+        Mockito.when(operations.current()).thenReturn(metadata);
+        Mockito.when(metadata.uuid()).thenReturn(uuid);
+        Mockito.when(metadata.metadataFileLocation()).thenReturn(metadataFile);
+        Mockito.when(metadata.lastUpdatedMillis()).thenReturn(1234L);
+        Mockito.when(value.getIcebergTable()).thenReturn(Optional.of(table));
+        Mockito.when(value.getSnapshot()).thenReturn(new 
IcebergSnapshot(snapshotId, schemaId));
+        Optional<SqlCacheVersion> result = 
IcebergExternalTable.sqlCacheVersionOf(value);
+        Mockito.verify(value, Mockito.never()).getPartitionInfo();
+        return result.orElse(null);
+    }
+
+    @Test
+    void 
versionCoversDataSchemaRecreationAndMetadataChangesWithoutPartitionReads() {
+        SqlCacheVersion original = version("uuid", "metadata-1", 11, 3);
+        Assertions.assertEquals(1234L, original.getUpdateTimeMillis());
+        Assertions.assertEquals(original.getVersion(), version("uuid", 
"metadata-1", 11, 3).getVersion());
+        Assertions.assertNotEquals(original.getVersion(), version("uuid", 
"metadata-1", 12, 3).getVersion());
+        Assertions.assertNotEquals(original.getVersion(), version("uuid", 
"metadata-1", 11, 4).getVersion());
+        Assertions.assertNotEquals(original.getVersion(), version("recreated", 
"metadata-1", 11, 3).getVersion());
+        Assertions.assertNotEquals(original.getVersion(), version("uuid", 
"metadata-2", 11, 3).getVersion());
+    }
+
+    @Test
+    void missingPhysicalIdentityDisablesCaching() {
+        Assertions.assertNull(version(null, "metadata-1", 11, 3));
+        Assertions.assertNull(version("uuid", null, 11, 3));
+    }
+
+    @Test
+    void cachedSelectorsKeepOriginalValuesAndDoNotShareResolvedCopies() {
+        TableSnapshot input = TableSnapshot.versionOf("11");
+        MvccReadSelector version = MvccReadSelector.of(Optional.of(input), 
Optional.empty());
+        input.setValue("12");
+        version.getTableSnapshot().get().setValue("13");
+        Assertions.assertEquals("11", 
version.getTableSnapshot().get().getValue());
+        TableScanParams branch = new TableScanParams(TableScanParams.BRANCH, 
Collections.emptyMap(),
+                Collections.singletonList("daily"));
+        MvccReadSelector reference = MvccReadSelector.of(Optional.empty(), 
Optional.of(branch));
+        
reference.getScanParams().get().reuseResolvedMapParams(Collections.singletonMap("snapshot",
 "11"));
+        
Assertions.assertFalse(reference.getScanParams().get().getResolvedMapParams().isPresent());
+        Assertions.assertNotEquals(version, reference);
+        Assertions.assertNotEquals(MvccReadSelector.LATEST, reference);
+    }
+
+    @Test
+    void oneTableRetainsLatestSnapshotAndBranchDependenciesIndependently() {
+        IcebergExternalTable table = Mockito.mock(IcebergExternalTable.class);
+        IcebergExternalDatabase database = 
Mockito.mock(IcebergExternalDatabase.class);
+        IcebergExternalCatalog catalog = 
Mockito.mock(IcebergExternalCatalog.class);
+        Mockito.when(table.getDatabase()).thenReturn(database);
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(database.getFullName()).thenReturn("db");
+        Mockito.when(catalog.getName()).thenReturn("ice");
+        
Mockito.when(catalog.getProperties()).thenReturn(Collections.emptyMap());
+        Mockito.when(table.getName()).thenReturn("t");
+        Mockito.when(table.getSqlCacheVersion(Mockito.any())).thenReturn(
+                Optional.of(new SqlCacheVersion("latest", 1)),
+                Optional.of(new SqlCacheVersion("historical", 2)),
+                Optional.of(new SqlCacheVersion("branch", 3)));
+        SqlCacheContext context = new SqlCacheContext(null);
+        context.addUsedTable(table, Optional.empty(), MvccReadSelector.LATEST);
+        context.addUsedTable(table, Optional.empty(),
+                
MvccReadSelector.of(Optional.of(TableSnapshot.versionOf("11")), 
Optional.empty()));
+        context.addUsedTable(table, Optional.empty(), 
MvccReadSelector.of(Optional.empty(), Optional.of(
+                new TableScanParams(TableScanParams.BRANCH, 
Collections.emptyMap(),
+                        Collections.singletonList("daily")))));
+        Assertions.assertEquals(3, context.getUsedTables().size());
+        Assertions.assertFalse(context.hasUnsupportedTables());
+    }
+}


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

Reply via email to