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

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


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new f6ec58f4013 branch-4.1: [improvement](hive) Batch Hive metastore 
partition access (#68197)
f6ec58f4013 is described below

commit f6ec58f4013b4e37e5292f669500164a4a43b490
Author: Calvin Kirs <[email protected]>
AuthorDate: Mon Sep 21 11:18:45 2026 +0800

    branch-4.1: [improvement](hive) Batch Hive metastore partition access 
(#68197)
    
    https://github.com/apache/doris/pull/67186
---
 .../doris/datasource/hive/HMSCachedClient.java     |   9 +
 .../doris/datasource/hive/HMSClientException.java  |  11 +
 .../doris/datasource/hive/HMSExternalTable.java    |  10 +
 .../apache/doris/datasource/hive/HiveDlaTable.java |  53 ++++
 .../datasource/hive/HiveExternalMetaCache.java     |  30 ++-
 .../datasource/hive/HmsPartitionBatchExecutor.java | 252 ++++++++++++++++++
 ...Exception.java => HmsPartitionBatchResult.java} |  25 +-
 .../datasource/hive/HmsPartitionBatchStats.java    | 151 +++++++++++
 .../datasource/hive/HmsPartitionIdentity.java      |  98 +++++++
 .../doris/datasource/hive/HmsPartitionRequest.java |  74 ++++++
 .../hive/HmsPartitionResultException.java          |  98 +++++++
 ...ntException.java => HmsPartitionTransport.java} |  16 +-
 .../datasource/hive/ThriftHMSCachedClient.java     |  74 +++++-
 .../apache/doris/job/extensions/mtmv/MTMVTask.java |  11 +
 .../org/apache/doris/mtmv/MTMVPartitionUtil.java   |  53 ++--
 .../org/apache/doris/mtmv/MTMVRefreshContext.java  | 113 +++++++++
 .../org/apache/doris/mtmv/MTMVRelatedTableIf.java  |  19 ++
 .../org/apache/doris/mtmv/MTMVRewriteUtil.java     |  21 +-
 .../hive/HmsPartitionBatchExecutorTest.java        | 282 +++++++++++++++++++++
 .../apache/doris/mtmv/MTMVPartitionUtilTest.java   |  27 +-
 .../doris/mtmv/MTMVRefreshContextBatchTest.java    | 239 +++++++++++++++++
 .../org/apache/doris/mtmv/MTMVRewriteUtilTest.java |  24 +-
 22 files changed, 1631 insertions(+), 59 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java
index fcaefecf41b..a3a04600c9f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSCachedClient.java
@@ -59,6 +59,15 @@ public interface HMSCachedClient {
 
     List<Partition> getPartitions(String dbName, String tblName, List<String> 
partitionNames);
 
+    /**
+     * Gets the subset of the requested partitions that exist. Unlike {@link 
#getPartitions}, a name the
+     * metastore no longer has is a normal answer and is simply absent from 
the result. The compatibility
+     * default preserves implementations whose {@code getPartitions} already 
returns only what exists.
+     */
+    default List<Partition> getExistingPartitions(String dbName, String 
tblName, List<String> partitionNames) {
+        return getPartitions(dbName, tblName, partitionNames);
+    }
+
     Table getTable(String dbName, String tblName);
 
     List<FieldSchema> getSchema(String dbName, String tblName);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
index 6e714e20a9b..242dfc12a8c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
@@ -20,6 +20,8 @@ package org.apache.doris.datasource.hive;
 import org.apache.doris.common.util.Util;
 
 public class HMSClientException extends RuntimeException {
+    private HmsPartitionBatchStats partitionBatchStats;
+
     public HMSClientException(String format, Throwable cause, Object... msg) {
         super(String.format(format, msg) + (cause == null ? "" : ". reason: " 
+ Util.getRootCauseMessage(cause)),
                 cause);
@@ -28,4 +30,13 @@ public class HMSClientException extends RuntimeException {
     public HMSClientException(String format, Object... msg) {
         super(String.format(format, msg));
     }
+
+    public HmsPartitionBatchStats getPartitionBatchStats() {
+        return partitionBatchStats;
+    }
+
+    HMSClientException withPartitionBatchStats(HmsPartitionBatchStats stats) {
+        this.partitionBatchStats = stats;
+        return this;
+    }
 }
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 a727d4386f7..2a7759faecb 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
@@ -1058,6 +1058,16 @@ public class HMSExternalTable extends ExternalTable 
implements MTMVRelatedTableI
         return dlaTable.getPartitionSnapshot(partitionName, context, snapshot);
     }
 
+    @Override
+    public Map<String, MTMVSnapshotIf> getPartitionSnapshots(Set<String> 
partitionNames,
+            MTMVRefreshContext context, Optional<MvccSnapshot> snapshot) 
throws AnalysisException {
+        makeSureInitialized();
+        if (dlaTable instanceof HiveDlaTable) {
+            return ((HiveDlaTable) 
dlaTable).getPartitionSnapshots(partitionNames, snapshot);
+        }
+        return MTMVRelatedTableIf.super.getPartitionSnapshots(partitionNames, 
context, snapshot);
+    }
+
     @Override
     public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, 
Optional<MvccSnapshot> snapshot)
             throws AnalysisException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java
index bfd39c59684..e8c12dd5623 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveDlaTable.java
@@ -32,7 +32,9 @@ import org.apache.doris.mtmv.MTMVTimestampSnapshot;
 import com.google.common.collect.Lists;
 import org.apache.commons.collections4.CollectionUtils;
 
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -80,6 +82,57 @@ public class HiveDlaTable extends HMSDlaTable {
         return new MTMVTimestampSnapshot(hivePartition.getLastModifiedTime());
     }
 
+    /**
+     * Bulk form of {@link #getPartitionSnapshot}: resolves every requested 
name against one partition-value
+     * listing and loads all cache misses through one batched HMS request 
instead of one RPC per partition.
+     * A name absent from the listing is omitted (the refresh context reports 
it per partition); any other
+     * failure is normalized to the checked AnalysisException this MTMV 
boundary declares, so the
+     * transparent-rewrite path degrades per-MV instead of a raw runtime 
exception disabling every MV
+     * candidate at the planner hook.
+     */
+    Map<String, MTMVSnapshotIf> getPartitionSnapshots(Set<String> 
partitionNames,
+            Optional<MvccSnapshot> snapshot) throws AnalysisException {
+        try {
+            HiveExternalMetaCache.HivePartitionValues hivePartitionValues =
+                    hmsTable.getHivePartitionValues(snapshot);
+            HiveExternalMetaCache cache = 
Env.getCurrentEnv().getExtMetaCacheMgr()
+                    .hive(hmsTable.getCatalog().getId());
+            List<String> resolvedNames = new 
ArrayList<>(partitionNames.size());
+            List<List<String>> resolvedValues = new 
ArrayList<>(partitionNames.size());
+            for (String partitionName : partitionNames) {
+                Long partitionId = 
hivePartitionValues.getPartitionNameToIdMap().get(partitionName);
+                if (partitionId == null) {
+                    continue;
+                }
+                List<String> partitionValues = 
hivePartitionValues.getPartitionValuesMap().get(partitionId);
+                if (CollectionUtils.isEmpty(partitionValues)) {
+                    continue;
+                }
+                resolvedNames.add(partitionName);
+                resolvedValues.add(partitionValues);
+            }
+            Map<String, MTMVSnapshotIf> result = new LinkedHashMap<>();
+            if (resolvedNames.isEmpty()) {
+                return result;
+            }
+            List<HivePartition> partitions = 
cache.getAllPartitionsWithCache(hmsTable, resolvedValues);
+            if (partitions.size() != resolvedNames.size()) {
+                throw new AnalysisException("Invalid HMS partition result: 
requested="
+                        + resolvedNames.size() + ", returned=" + 
partitions.size());
+            }
+            for (int i = 0; i < resolvedNames.size(); i++) {
+                result.put(resolvedNames.get(i),
+                        new 
MTMVTimestampSnapshot(partitions.get(i).getLastModifiedTime()));
+            }
+            return result;
+        } catch (AnalysisException e) {
+            throw e;
+        } catch (RuntimeException e) {
+            throw new AnalysisException("failed to load partition snapshots 
for "
+                    + hmsTable.getName() + ": " + e.getMessage(), e);
+        }
+    }
+
     @Override
     public MTMVSnapshotIf getTableSnapshot(MTMVRefreshContext context, 
Optional<MvccSnapshot> snapshot)
             throws AnalysisException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java
index 981b1f64013..cdf58a9adc6 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HiveExternalMetaCache.java
@@ -482,7 +482,10 @@ public class HiveExternalMetaCache extends 
AbstractExternalMetaCache {
             return sb.toString();
         }).collect(Collectors.toList());
 
-        List<Partition> partitions = catalog.getClient().getPartitions(
+        // Lenient existence semantics: a partition dropped remotely since the 
name list was captured is
+        // simply absent from the result (the historical getPartitionsByNames 
behavior); the client batches
+        // and validates the physical RPCs internally.
+        List<Partition> partitions = catalog.getClient().getExistingPartitions(
                 nameMapping.getRemoteDbName(), nameMapping.getRemoteTblName(), 
partitionNames);
         for (Partition partition : partitions) {
             StorageDescriptor sd = partition.getSd();
@@ -657,7 +660,30 @@ public class HiveExternalMetaCache extends 
AbstractExternalMetaCache {
         List<HivePartition> partitions;
         if (withCache) {
             MetaCacheEntry<PartitionCacheKey, HivePartition> partitionEntry = 
this.partitionEntry.get(catalogId);
-            partitions = 
keys.stream().map(partitionEntry::get).collect(Collectors.toList());
+            // Serve hits from the cache and aggregate every miss into ONE 
bulk load (batched inside the
+            // client) instead of one single-partition RPC per missed key.
+            Map<PartitionCacheKey, HivePartition> resolved = new HashMap<>();
+            List<PartitionCacheKey> misses = new ArrayList<>();
+            for (PartitionCacheKey key : keys) {
+                HivePartition hit = partitionEntry.getIfPresent(key);
+                if (hit != null) {
+                    resolved.put(key, hit);
+                } else {
+                    misses.add(key);
+                }
+            }
+            if (!misses.isEmpty()) {
+                Map<PartitionCacheKey, HivePartition> loaded = 
loadPartitions(misses);
+                loaded.forEach(partitionEntry::put);
+                resolved.putAll(loaded);
+            }
+            partitions = new ArrayList<>(keys.size());
+            for (PartitionCacheKey key : keys) {
+                HivePartition partition = resolved.get(key);
+                // A key the bulk load did not return (partition dropped 
remotely) falls back to the
+                // per-key loader, preserving the original single-load 
not-found error for that partition.
+                partitions.add(partition != null ? partition : 
partitionEntry.get(key));
+            }
         } else {
             partitions = new ArrayList<>(loadPartitions(keys).values());
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutor.java
new file mode 100644
index 00000000000..245f52b8bd6
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutor.java
@@ -0,0 +1,252 @@
+// 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.hive;
+
+import org.apache.hadoop.hive.metastore.api.Partition;
+import shade.doris.hive.org.apache.thrift.TException;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/** Splits one logical partition request into bounded, validated HMS RPCs. */
+final class HmsPartitionBatchExecutor {
+
+    static final class RemoteCallException extends HMSClientException {
+        RemoteCallException(String messageDetail, Throwable cause) {
+            // The two-argument parent constructor treats the message as a 
format string; route the detail
+            // through an explicit %s argument so partition names containing 
'%' cannot break formatting.
+            super("Remote HMS partition operation failed: %s", cause, 
messageDetail);
+        }
+    }
+
+    private final int maxBatchSize;
+    private final HmsPartitionTransport transport;
+
+    HmsPartitionBatchExecutor(int maxBatchSize, HmsPartitionTransport 
transport) {
+        if (maxBatchSize <= 0) {
+            throw new IllegalArgumentException("invalid HMS partition batch 
size");
+        }
+        this.maxBatchSize = maxBatchSize;
+        this.transport = java.util.Objects.requireNonNull(transport, 
"transport");
+    }
+
+    HmsPartitionBatchResult executeExistingWithStats(HmsPartitionRequest 
request) {
+        return executeWithStats(request, true);
+    }
+
+    HmsPartitionBatchResult executeWithStats(HmsPartitionRequest request) {
+        return executeWithStats(request, false);
+    }
+
+    private HmsPartitionBatchResult executeWithStats(HmsPartitionRequest 
request, boolean allowMissing) {
+        long logicalStartNanos = System.nanoTime();
+        List<HmsPartitionIdentity.ParsedPartitionName> partitions = 
request.getPartitions();
+        if (partitions.isEmpty()) {
+            HmsPartitionBatchStats stats = HmsPartitionBatchStats.builder()
+                    .logicalElapsedNanos(System.nanoTime() - logicalStartNanos)
+                    .build();
+            return new HmsPartitionBatchResult(new ArrayList<>(), stats);
+        }
+
+        List<Partition> result = new ArrayList<>(partitions.size());
+        int offset = 0;
+        int effectiveBatchSize = maxBatchSize;
+        int transportInvocations = 0;
+        int fallbackCount = 0;
+        long transportItems = 0;
+        long transportElapsedNanos = 0;
+        long maxTransportElapsedNanos = 0;
+        int largestBatchSize = 0;
+        int smallestBatchSize = Integer.MAX_VALUE;
+        while (offset < partitions.size()) {
+            int batchSize = Math.min(effectiveBatchSize, partitions.size() - 
offset);
+            List<HmsPartitionIdentity.ParsedPartitionName> batch =
+                    partitions.subList(offset, offset + batchSize);
+            List<String> batchNames = new ArrayList<>(batch.size());
+            for (HmsPartitionIdentity.ParsedPartitionName partition : batch) {
+                batchNames.add(partition.getName());
+            }
+            transportInvocations++;
+            transportItems += batchSize;
+            largestBatchSize = Math.max(largestBatchSize, batchSize);
+            smallestBatchSize = Math.min(smallestBatchSize, batchSize);
+            long transportStartNanos = System.nanoTime();
+            HMSClientException terminalFailure = null;
+            try {
+                List<Partition> returned = transport.getPartitionsByNames(
+                        request.getDbName(), request.getTableName(), 
batchNames);
+                result.addAll(validateAndOrder(batch, returned, allowMissing));
+                offset += batchSize;
+            } catch (RemoteCallException e) {
+                if (batchSize == 1 || !isDegradableRemoteFailure(e)) {
+                    terminalFailure = finalBatchFailure(request, offset, 
batchSize, effectiveBatchSize,
+                            transportInvocations, fallbackCount, e);
+                } else {
+                    effectiveBatchSize = Math.max(1, batchSize / 2);
+                    fallbackCount++;
+                }
+            } catch (HMSClientException e) {
+                terminalFailure = e;
+            } catch (RuntimeException e) {
+                throw e;
+            } catch (Exception e) {
+                terminalFailure = new HMSClientException(
+                        "Unexpected checked failure fetching HMS partitions", 
e);
+            } finally {
+                long elapsedNanos = System.nanoTime() - transportStartNanos;
+                transportElapsedNanos += elapsedNanos;
+                maxTransportElapsedNanos = Math.max(maxTransportElapsedNanos, 
elapsedNanos);
+            }
+            if (terminalFailure != null) {
+                throw terminalFailure.withPartitionBatchStats(buildStats(
+                        partitions.size(), transportInvocations, 
transportItems,
+                        largestBatchSize, smallestBatchSize,
+                        fallbackCount, System.nanoTime() - logicalStartNanos,
+                        transportElapsedNanos, maxTransportElapsedNanos));
+            }
+        }
+        HmsPartitionBatchStats stats = buildStats(
+                partitions.size(), transportInvocations, transportItems, 
largestBatchSize, smallestBatchSize,
+                fallbackCount, System.nanoTime() - logicalStartNanos,
+                transportElapsedNanos, maxTransportElapsedNanos);
+        return new HmsPartitionBatchResult(result, stats);
+    }
+
+    private static HmsPartitionBatchStats buildStats(
+            int requestedItems, int invocations, long transportItems, int 
largestBatchSize,
+            int smallestBatchSize, int fallbackCount, long logicalElapsedNanos,
+            long transportElapsedNanos, long maxTransportElapsedNanos) {
+        return HmsPartitionBatchStats.builder()
+                .requestedItems(requestedItems)
+                .transportInvocations(invocations)
+                .transportItems(transportItems)
+                .largestBatchSize(largestBatchSize)
+                .smallestBatchSize(smallestBatchSize)
+                .fallbackCount(fallbackCount)
+                .logicalElapsedNanos(logicalElapsedNanos)
+                .transportElapsedNanos(transportElapsedNanos)
+                .maxTransportElapsedNanos(maxTransportElapsedNanos)
+                .build();
+    }
+
+    private static List<Partition> validateAndOrder(
+            List<HmsPartitionIdentity.ParsedPartitionName> requested,
+            List<Partition> returned, boolean allowMissing) {
+        int expectedValueCount = requested.get(0).getValues().size();
+        Map<List<String>, Integer> expected = new HashMap<>();
+        for (int i = 0; i < requested.size(); i++) {
+            expected.put(requested.get(i).getValues(), i);
+        }
+
+        HmsPartitionResultException.Builder failure = 
HmsPartitionResultException.builder(
+                requested.size(), returned == null ? 0 : returned.size());
+        List<Partition> ordered = new 
ArrayList<>(java.util.Collections.nCopies(requested.size(), null));
+        Map<List<String>, Integer> returnedCounts = new LinkedHashMap<>();
+        if (returned == null) {
+            failure.invalid("<null response>");
+        } else {
+            for (Partition partition : returned) {
+                if (partition == null) {
+                    failure.invalid("<null partition>");
+                    continue;
+                }
+                List<String> identity = partition.getValues();
+                if (identity == null || identity.size() != expectedValueCount) 
{
+                    failure.invalid(String.valueOf(identity));
+                    continue;
+                }
+                returnedCounts.merge(identity, 1, Integer::sum);
+                Integer index = expected.get(identity);
+                if (index != null && ordered.get(index) == null) {
+                    ordered.set(index, partition);
+                }
+            }
+        }
+        for (HmsPartitionIdentity.ParsedPartitionName partition : requested) {
+            if (!allowMissing && 
!returnedCounts.containsKey(partition.getValues())) {
+                failure.missing(partition.getName());
+            }
+        }
+        for (Map.Entry<List<String>, Integer> entry : 
returnedCounts.entrySet()) {
+            if (!expected.containsKey(entry.getKey())) {
+                failure.unexpected(entry.getKey().toString());
+            }
+            if (entry.getValue() > 1) {
+                failure.duplicate(entry.getKey().toString());
+            }
+        }
+        if (failure.hasMismatches()) {
+            throw failure.build();
+        }
+        if (!allowMissing) {
+            return ordered;
+        }
+        List<Partition> existing = new ArrayList<>(returnedCounts.size());
+        for (Partition partition : ordered) {
+            if (partition != null) {
+                existing.add(partition);
+            }
+        }
+        return existing;
+    }
+
+    private HMSClientException finalBatchFailure(HmsPartitionRequest request, 
int offset,
+            int failedBatchSize, int effectiveBatchSize, int 
transportInvocations, int fallbackCount,
+            RemoteCallException failure) {
+        return new HMSClientException(
+                "HMS partition batch request failed: db=%s, table=%s, 
requested=%d, offset=%d, "
+                        + "failedBatchSize=%d, effectiveBatchSize=%d, "
+                        + "transportInvocations=%d, "
+                        + "fallbacks=%d: %s",
+                failure,
+                request.getDbName(), request.getTableName(), 
request.getPartitions().size(), offset,
+                failedBatchSize, effectiveBatchSize, transportInvocations, 
fallbackCount,
+                failure.getMessage());
+    }
+
+    private static boolean isDegradableRemoteFailure(RemoteCallException 
failure) {
+        boolean thriftFailure = false;
+        boolean sizeFailure = false;
+        for (Throwable current = failure.getCause(); current != null; current 
= current.getCause()) {
+            thriftFailure |= current instanceof TException;
+            String message = current.getMessage();
+            if (message != null) {
+                String normalized = message.toLowerCase(Locale.ROOT);
+                sizeFailure |= normalized.contains("message size")
+                        || normalized.contains("max message")
+                        || normalized.contains("maxmessagesize")
+                        || normalized.contains("frame too large")
+                        || normalized.contains("request too large")
+                        || normalized.contains("payload too large")
+                        || normalized.contains("too many partitions")
+                        || normalized.contains("partition limit")
+                        || 
normalized.contains("hive.metastore.limit.partition.request")
+                        || (normalized.contains("partitions scanned")
+                                && normalized.contains("exceeds limit"))
+                        || (normalized.contains("frame size")
+                                && normalized.contains("larger than max 
length"));
+            }
+        }
+        return thriftFailure && sizeFailure;
+    }
+
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchResult.java
similarity index 54%
copy from 
fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
copy to 
fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchResult.java
index 6e714e20a9b..70c68933577 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchResult.java
@@ -17,15 +17,26 @@
 
 package org.apache.doris.datasource.hive;
 
-import org.apache.doris.common.util.Util;
+import org.apache.hadoop.hive.metastore.api.Partition;
 
-public class HMSClientException extends RuntimeException {
-    public HMSClientException(String format, Throwable cause, Object... msg) {
-        super(String.format(format, msg) + (cause == null ? "" : ". reason: " 
+ Util.getRootCauseMessage(cause)),
-                cause);
+import java.util.List;
+import java.util.Objects;
+
+/** Partition objects and the physical HMS batching statistics that produced 
them. */
+public final class HmsPartitionBatchResult {
+    private final List<Partition> partitions;
+    private final HmsPartitionBatchStats stats;
+
+    public HmsPartitionBatchResult(List<Partition> partitions, 
HmsPartitionBatchStats stats) {
+        this.partitions = Objects.requireNonNull(partitions, "partitions");
+        this.stats = Objects.requireNonNull(stats, "stats");
+    }
+
+    public List<Partition> getPartitions() {
+        return partitions;
     }
 
-    public HMSClientException(String format, Object... msg) {
-        super(String.format(format, msg));
+    public HmsPartitionBatchStats getStats() {
+        return stats;
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchStats.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchStats.java
new file mode 100644
index 00000000000..1e2c476875a
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionBatchStats.java
@@ -0,0 +1,151 @@
+// 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.hive;
+
+import java.io.Serializable;
+
+/** Immutable execution statistics for one logical HMS partition-object 
request. */
+public final class HmsPartitionBatchStats implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private final int requestedItems;
+    private final int transportInvocations;
+    private final long transportItems;
+    private final int largestBatchSize;
+    private final int smallestBatchSize;
+    private final int fallbackCount;
+    private final long logicalElapsedNanos;
+    private final long transportElapsedNanos;
+    private final long maxTransportElapsedNanos;
+
+    private HmsPartitionBatchStats(Builder builder) {
+        this.requestedItems = builder.requestedItems;
+        this.transportInvocations = builder.transportInvocations;
+        this.transportItems = builder.transportItems;
+        this.largestBatchSize = builder.largestBatchSize;
+        this.smallestBatchSize = builder.smallestBatchSize;
+        this.fallbackCount = builder.fallbackCount;
+        this.logicalElapsedNanos = builder.logicalElapsedNanos;
+        this.transportElapsedNanos = builder.transportElapsedNanos;
+        this.maxTransportElapsedNanos = builder.maxTransportElapsedNanos;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public int getRequestedItems() {
+        return requestedItems;
+    }
+
+    public int getTransportInvocations() {
+        return transportInvocations;
+    }
+
+    public long getTransportItems() {
+        return transportItems;
+    }
+
+    public int getLargestBatchSize() {
+        return largestBatchSize;
+    }
+
+    public int getSmallestBatchSize() {
+        return smallestBatchSize;
+    }
+
+    public int getFallbackCount() {
+        return fallbackCount;
+    }
+
+    public long getLogicalElapsedNanos() {
+        return logicalElapsedNanos;
+    }
+
+    public long getTransportElapsedNanos() {
+        return transportElapsedNanos;
+    }
+
+    public long getMaxTransportElapsedNanos() {
+        return maxTransportElapsedNanos;
+    }
+
+    public static final class Builder {
+        private int requestedItems;
+        private int transportInvocations;
+        private long transportItems;
+        private int largestBatchSize;
+        private int smallestBatchSize;
+        private int fallbackCount;
+        private long logicalElapsedNanos;
+        private long transportElapsedNanos;
+        private long maxTransportElapsedNanos;
+
+        private Builder() {
+        }
+
+        public Builder requestedItems(int requestedItems) {
+            this.requestedItems = requestedItems;
+            return this;
+        }
+
+        public Builder transportInvocations(int transportInvocations) {
+            this.transportInvocations = transportInvocations;
+            return this;
+        }
+
+        public Builder transportItems(long transportItems) {
+            this.transportItems = transportItems;
+            return this;
+        }
+
+        public Builder largestBatchSize(int largestBatchSize) {
+            this.largestBatchSize = largestBatchSize;
+            return this;
+        }
+
+        public Builder smallestBatchSize(int smallestBatchSize) {
+            this.smallestBatchSize = smallestBatchSize;
+            return this;
+        }
+
+        public Builder fallbackCount(int fallbackCount) {
+            this.fallbackCount = fallbackCount;
+            return this;
+        }
+
+        public Builder logicalElapsedNanos(long logicalElapsedNanos) {
+            this.logicalElapsedNanos = logicalElapsedNanos;
+            return this;
+        }
+
+        public Builder transportElapsedNanos(long transportElapsedNanos) {
+            this.transportElapsedNanos = transportElapsedNanos;
+            return this;
+        }
+
+        public Builder maxTransportElapsedNanos(long maxTransportElapsedNanos) 
{
+            this.maxTransportElapsedNanos = maxTransportElapsedNanos;
+            return this;
+        }
+
+        public HmsPartitionBatchStats build() {
+            return new HmsPartitionBatchStats(this);
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionIdentity.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionIdentity.java
new file mode 100644
index 00000000000..62d292c1cda
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionIdentity.java
@@ -0,0 +1,98 @@
+// 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.hive;
+
+import org.apache.hadoop.hive.common.FileUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+final class HmsPartitionIdentity {
+
+    private HmsPartitionIdentity() {
+    }
+
+    static List<String> keysFromName(String partitionName) {
+        return parseParts(partitionName, null, true);
+    }
+
+    static ParsedPartitionName parse(String partitionName, List<String> 
expectedKeys) {
+        return new ParsedPartitionName(partitionName,
+                Collections.unmodifiableList(parseParts(partitionName, 
expectedKeys, false)));
+    }
+
+    private static List<String> parseParts(
+            String partitionName, List<String> expectedKeys, boolean 
returnKeys) {
+        if (partitionName == null || partitionName.isEmpty()) {
+            throw new IllegalArgumentException("partition name must not be 
empty");
+        }
+        List<String> parts = new ArrayList<>();
+        int segmentStart = 0;
+        int keyIndex = 0;
+        while (segmentStart < partitionName.length()) {
+            int segmentEnd = partitionName.indexOf('/', segmentStart);
+            if (segmentEnd < 0) {
+                segmentEnd = partitionName.length();
+            }
+            int separator = partitionName.indexOf('=', segmentStart);
+            if (separator <= segmentStart || separator >= segmentEnd) {
+                throw new IllegalArgumentException("invalid partition name: " 
+ partitionName);
+            }
+            String key = 
FileUtils.unescapePathName(partitionName.substring(segmentStart, separator))
+                    .toLowerCase(Locale.ROOT);
+            if (expectedKeys != null
+                    && (keyIndex >= expectedKeys.size() || 
!expectedKeys.get(keyIndex).equals(key))) {
+                throw new IllegalArgumentException("inconsistent partition 
keys in request: " + partitionName);
+            }
+            parts.add(returnKeys ? key
+                    : 
FileUtils.unescapePathName(partitionName.substring(separator + 1, segmentEnd)));
+            keyIndex++;
+            if (segmentEnd == partitionName.length()) {
+                break;
+            }
+            segmentStart = segmentEnd + 1;
+            if (segmentStart == partitionName.length()) {
+                throw new IllegalArgumentException("invalid partition name: " 
+ partitionName);
+            }
+        }
+        if (expectedKeys != null && keyIndex != expectedKeys.size()) {
+            throw new IllegalArgumentException("inconsistent partition keys in 
request: " + partitionName);
+        }
+        return parts;
+    }
+
+    static final class ParsedPartitionName {
+        private final String name;
+        private final List<String> values;
+
+        private ParsedPartitionName(String name, List<String> values) {
+            this.name = name;
+            this.values = values;
+        }
+
+        String getName() {
+            return name;
+        }
+
+        List<String> getValues() {
+            return values;
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionRequest.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionRequest.java
new file mode 100644
index 00000000000..13efab2728e
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionRequest.java
@@ -0,0 +1,74 @@
+// 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.hive;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/** Immutable logical input for one HMS partition-object request. */
+final class HmsPartitionRequest {
+
+    private final String dbName;
+    private final String tableName;
+    private final List<HmsPartitionIdentity.ParsedPartitionName> partitions;
+
+    HmsPartitionRequest(String dbName, String tableName, List<String> 
partitionNames) {
+        requireName(dbName, "database");
+        requireName(tableName, "table");
+        this.dbName = dbName;
+        this.tableName = tableName;
+        this.partitions = 
parsePartitions(Objects.requireNonNull(partitionNames, "partitionNames"));
+    }
+
+    String getDbName() {
+        return dbName;
+    }
+
+    String getTableName() {
+        return tableName;
+    }
+
+    List<HmsPartitionIdentity.ParsedPartitionName> getPartitions() {
+        return partitions;
+    }
+
+    private static void requireName(String value, String field) {
+        if (value == null || value.isEmpty()) {
+            throw new IllegalArgumentException(field + " must not be empty");
+        }
+    }
+
+    private static List<HmsPartitionIdentity.ParsedPartitionName> 
parsePartitions(List<String> names) {
+        List<HmsPartitionIdentity.ParsedPartitionName> parsedPartitions = new 
ArrayList<>(names.size());
+        Set<List<String>> identities = new HashSet<>();
+        List<String> partitionKeys = names.isEmpty()
+                ? Collections.emptyList() : 
HmsPartitionIdentity.keysFromName(names.get(0));
+        for (String name : names) {
+            HmsPartitionIdentity.ParsedPartitionName parsed = 
HmsPartitionIdentity.parse(name, partitionKeys);
+            if (!identities.add(parsed.getValues())) {
+                throw new IllegalArgumentException("duplicate partition 
identity in request: " + name);
+            }
+            parsedPartitions.add(parsed);
+        }
+        return Collections.unmodifiableList(parsedPartitions);
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionResultException.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionResultException.java
new file mode 100644
index 00000000000..89cabf097ce
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionResultException.java
@@ -0,0 +1,98 @@
+// 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.hive;
+
+import java.util.ArrayList;
+import java.util.List;
+
+final class HmsPartitionResultException extends HMSClientException {
+
+    private static final int MAX_SAMPLES_PER_TYPE = 10;
+    private static final int MAX_SAMPLE_LENGTH = 256;
+
+    private HmsPartitionResultException(Builder builder) {
+        super("Invalid HMS partition result: requested=%d, returned=%d, "
+                        + "missing=%d, duplicate=%d, unexpected=%d, 
invalid=%d, "
+                        + "missingSamples=%s, duplicateSamples=%s, 
unexpectedSamples=%s, invalidSamples=%s",
+                builder.requestedCount, builder.returnedCount,
+                builder.missingCount, builder.duplicateCount, 
builder.unexpectedCount, builder.invalidCount,
+                builder.missingSamples, builder.duplicateSamples,
+                builder.unexpectedSamples, builder.invalidSamples);
+    }
+
+    static Builder builder(int requestedCount, int returnedCount) {
+        return new Builder(requestedCount, returnedCount);
+    }
+
+    static final class Builder {
+        private final int requestedCount;
+        private final int returnedCount;
+        private final List<String> missingSamples = new ArrayList<>();
+        private final List<String> duplicateSamples = new ArrayList<>();
+        private final List<String> unexpectedSamples = new ArrayList<>();
+        private final List<String> invalidSamples = new ArrayList<>();
+        private int missingCount;
+        private int duplicateCount;
+        private int unexpectedCount;
+        private int invalidCount;
+
+        private Builder(int requestedCount, int returnedCount) {
+            this.requestedCount = requestedCount;
+            this.returnedCount = returnedCount;
+        }
+
+        Builder missing(String sample) {
+            missingCount++;
+            addSample(missingSamples, sample);
+            return this;
+        }
+
+        Builder duplicate(String sample) {
+            duplicateCount++;
+            addSample(duplicateSamples, sample);
+            return this;
+        }
+
+        Builder unexpected(String sample) {
+            unexpectedCount++;
+            addSample(unexpectedSamples, sample);
+            return this;
+        }
+
+        Builder invalid(String sample) {
+            invalidCount++;
+            addSample(invalidSamples, sample);
+            return this;
+        }
+
+        boolean hasMismatches() {
+            return missingCount + duplicateCount + unexpectedCount + 
invalidCount > 0;
+        }
+
+        HmsPartitionResultException build() {
+            return new HmsPartitionResultException(this);
+        }
+
+        private static void addSample(List<String> samples, String sample) {
+            if (samples.size() < MAX_SAMPLES_PER_TYPE) {
+                samples.add(sample.length() <= MAX_SAMPLE_LENGTH
+                        ? sample : sample.substring(0, MAX_SAMPLE_LENGTH - 3) 
+ "...");
+            }
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionTransport.java
similarity index 66%
copy from 
fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
copy to 
fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionTransport.java
index 6e714e20a9b..afa951f7d9f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HMSClientException.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/HmsPartitionTransport.java
@@ -17,15 +17,13 @@
 
 package org.apache.doris.datasource.hive;
 
-import org.apache.doris.common.util.Util;
+import org.apache.hadoop.hive.metastore.api.Partition;
 
-public class HMSClientException extends RuntimeException {
-    public HMSClientException(String format, Throwable cause, Object... msg) {
-        super(String.format(format, msg) + (cause == null ? "" : ". reason: " 
+ Util.getRootCauseMessage(cause)),
-                cause);
-    }
+import java.util.List;
 
-    public HMSClientException(String format, Object... msg) {
-        super(String.format(format, msg));
-    }
+/** Leaf transport contract: one invocation enters the configured 
getPartitionsByNames transport once. */
+@FunctionalInterface
+interface HmsPartitionTransport {
+    List<Partition> getPartitionsByNames(
+            String dbName, String tableName, List<String> partitionNames) 
throws Exception;
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java
index 46ca8ef3bc8..0ddb0e0f48e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/ThriftHMSCachedClient.java
@@ -98,6 +98,11 @@ public class ThriftHMSCachedClient implements 
HMSCachedClient {
     private final HiveConf hiveConf;
     private final ExecutionAuthenticator executionAuthenticator;
     private final MetaStoreClientProvider metaStoreClientProvider;
+    private final int partitionBatchSize;
+
+    /** Maximum partition names sent by one getPartitionsByNames RPC; a 
`hive.` catalog property. */
+    public static final String PARTITION_BATCH_SIZE_KEY = 
"hive.hms_partitions_batch_size_per_rpc";
+    public static final int DEFAULT_PARTITION_BATCH_SIZE = 5000;
 
     public ThriftHMSCachedClient(HiveConf hiveConf, int poolSize, 
ExecutionAuthenticator executionAuthenticator) {
         this(hiveConf, poolSize, executionAuthenticator, new 
DefaultMetaStoreClientProvider());
@@ -111,6 +116,29 @@ public class ThriftHMSCachedClient implements 
HMSCachedClient {
         this.metaStoreClientProvider = 
Preconditions.checkNotNull(metaStoreClientProvider, "metaStoreClientProvider");
         this.clientPool = poolSize == 0 ? null
                 : new GenericObjectPool<>(new ThriftHMSClientFactory(), 
createPoolConfig(poolSize));
+        this.partitionBatchSize = parsePartitionBatchSize(hiveConf);
+    }
+
+    private static int parsePartitionBatchSize(HiveConf hiveConf) {
+        return parsePartitionBatchSize(hiveConf == null ? null : 
hiveConf.get(PARTITION_BATCH_SIZE_KEY));
+    }
+
+    static int parsePartitionBatchSize(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return DEFAULT_PARTITION_BATCH_SIZE;
+        }
+        int parsed;
+        try {
+            parsed = Integer.parseInt(value.trim());
+        } catch (NumberFormatException e) {
+            throw new IllegalArgumentException(
+                    PARTITION_BATCH_SIZE_KEY + " must be a positive integer, 
got " + value, e);
+        }
+        if (parsed <= 0) {
+            throw new IllegalArgumentException(
+                    PARTITION_BATCH_SIZE_KEY + " must be a positive integer, 
got " + value);
+        }
+        return parsed;
     }
 
     @Override
@@ -344,23 +372,47 @@ public class ThriftHMSCachedClient implements 
HMSCachedClient {
 
     @Override
     public List<Partition> getPartitions(String dbName, String tblName, 
List<String> partitionNames) {
+        // Strict form: every requested name must come back, exactly once, in 
request order. One batch
+        // executor owns bounded chunking, adaptive size fallback and response 
validation; this method
+        // only supplies the leaf transport that performs one physical 
getPartitionsByNames per attempt.
+        return newPartitionBatchExecutor()
+                .executeWithStats(new HmsPartitionRequest(dbName, tblName, 
partitionNames))
+                .getPartitions();
+    }
+
+    @Override
+    public List<Partition> getExistingPartitions(String dbName, String 
tblName, List<String> partitionNames) {
+        // Lenient form for callers racing remote DROPs (cache bulk loads, 
freshness probes): a missing
+        // name is a normal answer and is omitted; 
duplicate/unexpected/invalid responses still fail.
+        return newPartitionBatchExecutor()
+                .executeExistingWithStats(new HmsPartitionRequest(dbName, 
tblName, partitionNames))
+                .getPartitions();
+    }
+
+    private HmsPartitionBatchExecutor newPartitionBatchExecutor() {
+        return new HmsPartitionBatchExecutor(partitionBatchSize, 
this::fetchPartitionsByNames);
+    }
+
+    private List<Partition> fetchPartitionsByNames(String dbName, String 
tblName, List<String> partitionNames) {
+        if (isClosed) {
+            throw new HMSClientException("HMS client is closed");
+        }
         try (ThriftHMSClient client = getClient()) {
             try {
                 return ugiDoAs(() -> 
client.client.getPartitionsByNames(dbName, tblName, partitionNames));
             } catch (Exception e) {
                 client.setThrowable(e);
-                throw e;
-            }
+                // Everything reaching this catch crossed into the remote call 
(pool/auth setup failures
+                // throw from getClient() before this block), so classify it 
for the batch executor's
+                // adaptive-fallback ladder. ugiDoAs wraps the real failure in 
a RuntimeException; unwrap
+                // so thrift message-size causes stay visible to the 
degradable classification.
+                Throwable cause = e instanceof RuntimeException && 
e.getCause() != null ? e.getCause() : e;
+                throw new 
HmsPartitionBatchExecutor.RemoteCallException(cause.getMessage(), cause);
+            }
+        } catch (HMSClientException e) {
+            throw e;
         } catch (Exception e) {
-            // Avoid printing too much log
-            String partitionNamesMsg;
-            if (partitionNames.size() <= 3) {
-                partitionNamesMsg = partitionNames.toString();
-            } else {
-                partitionNamesMsg = partitionNames.subList(0, 3) + "... total: 
" + partitionNames.size();
-            }
-            throw new HMSClientException("failed to get partitions for table 
%s in db %s with value [%s]", e, tblName,
-                    dbName, partitionNamesMsg);
+            throw new HMSClientException("failed to get partitions for table 
%s in db %s", e, tblName, dbName);
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
index 02366f011e1..9aceec751ef 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
@@ -257,6 +257,17 @@ public class MTMVTask extends AbstractTask {
             }
             Map<TableIf, String> tableWithPartKey = getIncrementalTableMap();
             this.completedPartitions = Lists.newCopyOnWriteArrayList();
+            try {
+                // Snapshot persistence happens after refresh partitions are 
split into execution groups. Load the
+                // complete union here so the default one-partition group size 
cannot turn a large Hive MTMV into
+                // one metadata request per MV partition; 
generatePartitionSnapshots reuses this context cache.
+                
context.preparePartitionSnapshots(Sets.newHashSet(needRefreshPartitions));
+            } catch (Exception e) {
+                // Preloading is only a batching optimization. Retrying 
through the existing per-group load below
+                // preserves completed-group progress when a later chunk of 
the union fails.
+                LOG.warn("Failed to preload partition snapshots for mv={}, 
taskId={}; "
+                        + "falling back to per-group loading", mtmv.getName(), 
getTaskId(), e);
+            }
             int refreshPartitionNum = mtmv.getRefreshPartitionNum();
             long execNum = (needRefreshPartitions.size() / 
refreshPartitionNum) + ((needRefreshPartitions.size()
                     % refreshPartitionNum) > 0 ? 1 : 0);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
index 1b2dc56a9c8..98c279a6ac4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
@@ -37,6 +37,7 @@ import org.apache.doris.common.DdlException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.datasource.mvcc.MvccUtil;
 import org.apache.doris.mtmv.MTMVPartitionInfo.MTMVPartitionType;
+import org.apache.doris.mtmv.MTMVRefreshContext.PreparedPartitionSnapshots;
 import org.apache.doris.rpc.RpcException;
 
 import com.google.common.base.Preconditions;
@@ -92,7 +93,8 @@ public class MTMVPartitionUtil {
      * @return
      * @throws AnalysisException
      */
-    public static boolean isMTMVPartitionSync(MTMVRefreshContext 
refreshContext, String partitionName,
+    public static boolean isMTMVPartitionSync(MTMVRefreshContext 
refreshContext,
+            PreparedPartitionSnapshots partitionSnapshots, String 
partitionName,
             Set<BaseTableInfo> tables,
             Set<TableName> excludedTriggerTables) throws AnalysisException {
         MTMV mtmv = refreshContext.getMtmv();
@@ -109,7 +111,8 @@ public class MTMVPartitionUtil {
                 Set<String> relatedPartitionNames = 
partitionMappings.getOrDefault(pctTable, Sets.newHashSet());
                 // if follow base table, not need compare with related table, 
only should compare with related partition
                 excludedTriggerTablesToCheck.add(new TableName(pctTable));
-                if (!isSyncWithPartitions(refreshContext, partitionName, 
relatedPartitionNames, pctTable)) {
+                if (!isSyncWithPartitions(
+                        refreshContext, partitionSnapshots, partitionName, 
relatedPartitionNames, pctTable)) {
                     return false;
                 }
             }
@@ -227,8 +230,10 @@ public class MTMVPartitionUtil {
             throws AnalysisException {
         MTMV mtmv = context.getMtmv();
         Set<String> partitionNames = mtmv.getPartitionNames();
+        PreparedPartitionSnapshots partitionSnapshots =
+                context.prepareComparablePartitionSnapshots(partitionNames);
         for (String partitionName : partitionNames) {
-            if (!isMTMVPartitionSync(context, partitionName, tables,
+            if (!isMTMVPartitionSync(context, partitionSnapshots, 
partitionName, tables,
                     excludeTables)) {
                 return false;
             }
@@ -248,14 +253,17 @@ public class MTMVPartitionUtil {
         List<Long> partitionIds = mtmv.getPartitionIds();
         Map<Long, List<String>> res = Maps.newHashMap();
         MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        PreparedPartitionSnapshots partitionSnapshots =
+                
context.prepareComparablePartitionSnapshots(mtmv.getPartitionNames());
         for (Long partitionId : partitionIds) {
             String partitionName = 
mtmv.getPartitionOrAnalysisException(partitionId).getName();
-            res.put(partitionId, getPartitionUnSyncTables(context, 
partitionName));
+            res.put(partitionId, getPartitionUnSyncTables(context, 
partitionSnapshots, partitionName));
         }
         return res;
     }
 
-    private static List<String> getPartitionUnSyncTables(MTMVRefreshContext 
context, String partitionName)
+    private static List<String> getPartitionUnSyncTables(MTMVRefreshContext 
context,
+            PreparedPartitionSnapshots partitionSnapshots, String 
partitionName)
             throws AnalysisException {
         MTMV mtmv = context.getMtmv();
         Map<MTMVRelatedTableIf, Set<String>> mappings = 
context.getByPartitionName(partitionName);
@@ -273,8 +281,8 @@ public class MTMVPartitionUtil {
             if (mtmv.getMvPartitionInfo().getPartitionType() != 
MTMVPartitionType.SELF_MANAGE && pctTables.contains(
                     pctTable)) {
                 Set<String> pctPartitions = mappings.getOrDefault(pctTable, 
Sets.newHashSet());
-                boolean isSyncWithPartition = isSyncWithPartitions(context, 
partitionName,
-                        pctPartitions, pctTable);
+                boolean isSyncWithPartition = isSyncWithPartitions(
+                        context, partitionSnapshots, partitionName, 
pctPartitions, pctTable);
                 if (!isSyncWithPartition) {
                     res.add(pctTable.getName());
                 }
@@ -298,9 +306,16 @@ public class MTMVPartitionUtil {
         MTMV mtmv = context.getMtmv();
         Set<String> partitionNames = mtmv.getPartitionNames();
         List<String> res = Lists.newArrayList();
+        PreparedPartitionSnapshots partitionSnapshots;
+        try {
+            partitionSnapshots = 
context.prepareComparablePartitionSnapshots(partitionNames);
+        } catch (AnalysisException e) {
+            LOG.warn("preload partition snapshots failed", e);
+            return Lists.newArrayList(partitionNames);
+        }
         for (String partitionName : partitionNames) {
             try {
-                if (!isMTMVPartitionSync(context, partitionName, baseTables,
+                if (!isMTMVPartitionSync(context, partitionSnapshots, 
partitionName, baseTables,
                         mtmv.getExcludedTriggerTables())) {
                     res.add(partitionName);
                 }
@@ -321,7 +336,8 @@ public class MTMVPartitionUtil {
      * @return
      * @throws AnalysisException
      */
-    public static boolean isSyncWithPartitions(MTMVRefreshContext context, 
String mtmvPartitionName,
+    public static boolean isSyncWithPartitions(MTMVRefreshContext context,
+            PreparedPartitionSnapshots partitionSnapshots, String 
mtmvPartitionName,
             Set<String> pctPartitionNames, MTMVRelatedTableIf pctTable) throws 
AnalysisException {
         MTMV mtmv = context.getMtmv();
         if (!pctTable.needAutoRefresh()) {
@@ -339,8 +355,7 @@ public class MTMVPartitionUtil {
             return true;
         }
         for (String pctPartitionName : pctPartitionNames) {
-            MTMVSnapshotIf pctCurrentSnapshot = pctTable
-                    .getPartitionSnapshot(pctPartitionName, context, 
MvccUtil.getSnapshotFromContext(pctTable));
+            MTMVSnapshotIf pctCurrentSnapshot = 
partitionSnapshots.get(pctTable, pctPartitionName);
             if (LOG.isDebugEnabled()) {
                 LOG.debug(String.format("isSyncWithPartitions mvName is %s\n, 
mtmvPartitionName is %s\n, "
                                 + "mtmv refreshSnapshot is %s\n, 
pctPartitionName is %s\n, "
@@ -551,8 +566,8 @@ public class MTMVPartitionUtil {
         if (baseTableSnapshotCache.containsKey(baseTableInfo)) {
             return baseTableSnapshotCache.get(baseTableInfo);
         }
-        MTMVSnapshotIf baseTableCurrentSnapshot = 
mtmvRelatedTableIf.getTableSnapshot(context,
-                MvccUtil.getSnapshotFromContext(mtmvRelatedTableIf));
+        MTMVSnapshotIf baseTableCurrentSnapshot = 
mtmvRelatedTableIf.getTableSnapshot(
+                context, context.resolveSnapshot(mtmvRelatedTableIf));
         baseTableSnapshotCache.put(baseTableInfo, baseTableCurrentSnapshot);
         return baseTableCurrentSnapshot;
     }
@@ -569,18 +584,19 @@ public class MTMVPartitionUtil {
     public static Map<String, MTMVRefreshPartitionSnapshot> 
generatePartitionSnapshots(MTMVRefreshContext context,
             Set<BaseTableInfo> baseTables, Set<String> partitionNames)
             throws AnalysisException {
+        PreparedPartitionSnapshots preparedSnapshots = 
context.preparePartitionSnapshots(partitionNames);
         Map<String, MTMVRefreshPartitionSnapshot> res = Maps.newHashMap();
         for (String partitionName : partitionNames) {
-            res.put(partitionName,
-                    generatePartitionSnapshot(context, baseTables,
-                            
context.getPartitionMappings().get(partitionName)));
+            res.put(partitionName, generatePartitionSnapshot(context, 
preparedSnapshots, baseTables,
+                    context.getPartitionMappings().get(partitionName)));
         }
         return res;
     }
 
 
     private static MTMVRefreshPartitionSnapshot 
generatePartitionSnapshot(MTMVRefreshContext context,
-            Set<BaseTableInfo> baseTables, Map<MTMVRelatedTableIf, 
Set<String>> pctPartitionNames)
+            PreparedPartitionSnapshots partitionSnapshots, Set<BaseTableInfo> 
baseTables,
+            Map<MTMVRelatedTableIf, Set<String>> pctPartitionNames)
             throws AnalysisException {
         MTMV mtmv = context.getMtmv();
         MTMVRefreshPartitionSnapshot refreshPartitionSnapshot = new 
MTMVRefreshPartitionSnapshot();
@@ -594,8 +610,7 @@ public class MTMVPartitionUtil {
                     continue;
                 }
                 for (String pctPartitionName : oneTablePartitionNames) {
-                    MTMVSnapshotIf partitionSnapshot = 
pctTable.getPartitionSnapshot(pctPartitionName, context,
-                            MvccUtil.getSnapshotFromContext(pctTable));
+                    MTMVSnapshotIf partitionSnapshot = 
partitionSnapshots.get(pctTable, pctPartitionName);
                     pctSnapshot.put(pctPartitionName, partitionSnapshot);
                 }
             }
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 603f89bbec5..77fe3312119 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
@@ -19,10 +19,17 @@ package org.apache.doris.mtmv;
 
 import org.apache.doris.catalog.MTMV;
 import org.apache.doris.common.AnalysisException;
+import org.apache.doris.datasource.mvcc.MvccSnapshot;
+import org.apache.doris.datasource.mvcc.MvccUtil;
 
 import com.google.common.collect.Maps;
 
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 
 public class MTMVRefreshContext {
@@ -33,6 +40,10 @@ public class MTMVRefreshContext {
     // Hence, the results are cached at this stage.
     // The value is loaded/cached on the first fetch
     private Map<BaseTableInfo, MTMVSnapshotIf> baseTableSnapshotCache = 
Maps.newHashMap();
+    private final Map<MTMVRelatedTableIf, Map<String, MTMVSnapshotIf>> 
partitionSnapshotCache = Maps.newHashMap();
+    private final Map<MTMVRelatedTableIf, Set<String>> 
missingPartitionSnapshotCache = Maps.newHashMap();
+    private final Map<MTMVRelatedTableIf, Map<String, AnalysisException>> 
partitionSnapshotFailureCache =
+            Maps.newHashMap();
 
     public MTMVRefreshContext(MTMV mtmv) {
         this.mtmv = mtmv;
@@ -58,6 +69,78 @@ public class MTMVRefreshContext {
         return baseTableSnapshotCache;
     }
 
+    /** Loads the union of mapped base partitions once per related table. */
+    public PreparedPartitionSnapshots preparePartitionSnapshots(Set<String> 
mtmvPartitionNames)
+            throws AnalysisException {
+        return preparePartitionSnapshots(mtmvPartitionNames, false);
+    }
+
+    /** Loads only mappings whose persisted partition-name set still matches 
and needs version comparison. */
+    public PreparedPartitionSnapshots 
prepareComparablePartitionSnapshots(Set<String> mtmvPartitionNames)
+            throws AnalysisException {
+        return preparePartitionSnapshots(mtmvPartitionNames, true);
+    }
+
+    private PreparedPartitionSnapshots preparePartitionSnapshots(
+            Set<String> mtmvPartitionNames, boolean comparableOnly)
+            throws AnalysisException {
+        Map<MTMVRelatedTableIf, Set<String>> namesByTable = new 
LinkedHashMap<>();
+        Map<MTMVRelatedTableIf, BaseTableInfo> tableInfos = comparableOnly
+                ? new LinkedHashMap<>() : Collections.emptyMap();
+        for (String mtmvPartitionName : mtmvPartitionNames) {
+            for (Map.Entry<MTMVRelatedTableIf, Set<String>> entry
+                    : getByPartitionName(mtmvPartitionName).entrySet()) {
+                if (!entry.getKey().needAutoRefresh()) {
+                    continue;
+                }
+                if (comparableOnly) {
+                    BaseTableInfo tableInfo = 
tableInfos.computeIfAbsent(entry.getKey(), BaseTableInfo::new);
+                    if (!Objects.equals(entry.getValue(), 
mtmv.getRefreshSnapshot()
+                            .getPctSnapshots(mtmvPartitionName, tableInfo))) {
+                        continue;
+                    }
+                }
+                namesByTable.computeIfAbsent(entry.getKey(), ignored -> new 
LinkedHashSet<>())
+                        .addAll(entry.getValue());
+            }
+        }
+        for (Map.Entry<MTMVRelatedTableIf, Set<String>> entry : 
namesByTable.entrySet()) {
+            loadSnapshots(entry.getKey(), entry.getValue());
+        }
+        return new PreparedPartitionSnapshots(this);
+    }
+
+    private void loadSnapshots(MTMVRelatedTableIf table, Set<String> 
partitionNames) throws AnalysisException {
+        Map<String, MTMVSnapshotIf> cached = 
partitionSnapshotCache.computeIfAbsent(
+                table, ignored -> new LinkedHashMap<>());
+        Set<String> knownMissing = 
missingPartitionSnapshotCache.computeIfAbsent(
+                table, ignored -> new LinkedHashSet<>());
+        Set<String> missing = new LinkedHashSet<>(partitionNames);
+        missing.removeAll(cached.keySet());
+        missing.removeAll(knownMissing);
+        if (missing.isEmpty()) {
+            return;
+        }
+        Map<String, MTMVSnapshotIf> loaded = table.getPartitionSnapshots(
+                missing, this, resolveSnapshot(table));
+        if (loaded == null || loaded.containsKey(null) || 
loaded.containsValue(null)
+                || !missing.containsAll(loaded.keySet())) {
+            throw new AnalysisException("Invalid partition snapshot result for 
table " + table.getName()
+                    + ": requestedCount=" + missing.size() + ", returnedCount="
+                    + (loaded == null ? "null" : loaded.size()));
+        }
+        cached.putAll(loaded);
+        Set<String> stillMissing = new LinkedHashSet<>(missing);
+        stillMissing.removeAll(loaded.keySet());
+        knownMissing.addAll(stillMissing);
+    }
+
+    void recordPartitionSnapshotFailure(
+            MTMVRelatedTableIf table, String partitionName, AnalysisException 
failure) {
+        partitionSnapshotFailureCache.computeIfAbsent(table, ignored -> new 
LinkedHashMap<>())
+                .put(partitionName, failure);
+    }
+
     public static MTMVRefreshContext buildContext(MTMV mtmv) throws 
AnalysisException {
         MTMVRefreshContext context = new MTMVRefreshContext(mtmv);
         context.partitionMappings = mtmv.calculatePartitionMappings();
@@ -65,4 +148,34 @@ public class MTMVRefreshContext {
         return context;
     }
 
+    Optional<MvccSnapshot> resolveSnapshot(MTMVRelatedTableIf table) {
+        return MvccUtil.getSnapshotFromContext(table);
+    }
+
+    /** Read-only access to partition snapshots that have already been loaded 
as one bulk operation. */
+    public static final class PreparedPartitionSnapshots {
+        private final MTMVRefreshContext context;
+
+        private PreparedPartitionSnapshots(MTMVRefreshContext context) {
+            this.context = context;
+        }
+
+        public MTMVSnapshotIf get(MTMVRelatedTableIf table, String 
partitionName) throws AnalysisException {
+            Map<String, MTMVSnapshotIf> snapshots = 
context.partitionSnapshotCache.get(table);
+            if (snapshots != null && snapshots.containsKey(partitionName)) {
+                return snapshots.get(partitionName);
+            }
+            Map<String, AnalysisException> failures = 
context.partitionSnapshotFailureCache.get(table);
+            if (failures != null && failures.containsKey(partitionName)) {
+                throw failures.get(partitionName);
+            }
+            Set<String> missing = 
context.missingPartitionSnapshotCache.get(table);
+            if (missing != null && missing.contains(partitionName)) {
+                throw new AnalysisException("can not find partition: " + 
partitionName);
+            }
+            throw new AnalysisException("Partition snapshot was not prepared: 
table=" + table.getName()
+                    + ", partition=" + partitionName);
+        }
+    }
+
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelatedTableIf.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelatedTableIf.java
index 7f38a0492c2..ee0bbb8b6da 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelatedTableIf.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelatedTableIf.java
@@ -25,6 +25,7 @@ import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.DdlException;
 import org.apache.doris.datasource.mvcc.MvccSnapshot;
 
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -85,6 +86,24 @@ public interface MTMVRelatedTableIf extends TableIf {
     MTMVSnapshotIf getPartitionSnapshot(String partitionName, 
MTMVRefreshContext context,
             Optional<MvccSnapshot> snapshot) throws AnalysisException;
 
+    /**
+     * Loads partition snapshots in bulk when the table supports it. The 
compatibility default retains the
+     * original one-at-a-time behavior while retaining failures by partition 
name in the refresh context;
+     * plugin-driven external tables override it to reach connector batching.
+     */
+    default Map<String, MTMVSnapshotIf> getPartitionSnapshots(Set<String> 
partitionNames,
+            MTMVRefreshContext context, Optional<MvccSnapshot> snapshot) 
throws AnalysisException {
+        Map<String, MTMVSnapshotIf> snapshots = new LinkedHashMap<>();
+        for (String partitionName : partitionNames) {
+            try {
+                snapshots.put(partitionName, 
getPartitionSnapshot(partitionName, context, snapshot));
+            } catch (AnalysisException e) {
+                context.recordPartitionSnapshotFailure(this, partitionName, e);
+            }
+        }
+        return snapshots;
+    }
+
     /**
      * getTableSnapshot
      * It is best to use the version. If there is no version, use the last 
update time
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRewriteUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRewriteUtil.java
index 6dc0bd24da7..7a55fc12784 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRewriteUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRewriteUtil.java
@@ -23,6 +23,7 @@ import org.apache.doris.catalog.TableIf;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.mtmv.MTMVPartitionInfo.MTMVPartitionType;
+import org.apache.doris.mtmv.MTMVRefreshContext.PreparedPartitionSnapshots;
 import org.apache.doris.qe.ConnectContext;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -65,6 +66,7 @@ public class MTMVRewriteUtil {
         }
         Set<String> mtmvNeedComparePartitions = null;
         MTMVRefreshContext refreshContext = null;
+        PreparedPartitionSnapshots partitionSnapshots = null;
         // check gracePeriod
         long gracePeriodMills = mtmv.getGracePeriod();
         for (Partition partition : allPartitions) {
@@ -95,8 +97,25 @@ public class MTMVRewriteUtil {
             if (!mtmvNeedComparePartitions.contains(partition.getName())) {
                 continue;
             }
+            if (partitionSnapshots == null) {
+                Set<String> partitionsToPreload = Sets.newHashSet();
+                for (Partition candidate : allPartitions) {
+                    boolean withinGracePeriod = gracePeriodMills > 0
+                            && currentTimeMills <= 
candidate.getVisibleVersionTime() + gracePeriodMills
+                            && !forceConsistent;
+                    if (!withinGracePeriod && 
mtmvNeedComparePartitions.contains(candidate.getName())) {
+                        partitionsToPreload.add(candidate.getName());
+                    }
+                }
+                try {
+                    partitionSnapshots = 
refreshContext.prepareComparablePartitionSnapshots(partitionsToPreload);
+                } catch (AnalysisException e) {
+                    LOG.warn("preload partition snapshots failed", e);
+                    return res;
+                }
+            }
             try {
-                if (MTMVPartitionUtil.isMTMVPartitionSync(refreshContext, 
partition.getName(),
+                if (MTMVPartitionUtil.isMTMVPartitionSync(refreshContext, 
partitionSnapshots, partition.getName(),
                         mtmvRelation.getBaseTablesOneLevelAndFromView(),
                         forceConsistent ? ImmutableSet.of() : 
mtmv.getQueryRewriteConsistencyRelaxedTables())) {
                     res.add(partition);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutorTest.java
new file mode 100644
index 00000000000..3421bd36092
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/HmsPartitionBatchExecutorTest.java
@@ -0,0 +1,282 @@
+// 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.hive;
+
+import org.apache.hadoop.hive.metastore.api.Partition;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+public class HmsPartitionBatchExecutorTest {
+
+    @Test
+    public void boundsLargeRequestAndRestoresOrder() {
+        List<Integer> batchSizes = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(5000, (db, table, names) 
-> {
+            batchSizes.add(names.size());
+            List<Partition> result = infos(names);
+            Collections.reverse(result);
+            return result;
+        });
+
+        List<String> names = names(120_000);
+        List<Partition> result = 
executor.executeWithStats(request(names)).getPartitions();
+
+        Assertions.assertEquals(24, batchSizes.size());
+        Assertions.assertTrue(batchSizes.stream().allMatch(size -> size == 
5000));
+        
Assertions.assertEquals(names.stream().map(HmsPartitionBatchExecutorTest::values)
+                        .collect(Collectors.toList()),
+                
result.stream().map(Partition::getValues).collect(Collectors.toList()));
+    }
+
+    @Test
+    public void handlesEmptyExactAndTrailingBatches() {
+        List<Integer> batchSizes = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(3, (db, table, names) -> 
{
+            batchSizes.add(names.size());
+            return infos(names);
+        });
+
+        
Assertions.assertTrue(executor.executeWithStats(request(Collections.emptyList())).getPartitions().isEmpty());
+        Assertions.assertEquals(6, 
executor.executeWithStats(request(names(6))).getPartitions().size());
+        Assertions.assertEquals(7, 
executor.executeWithStats(request(names(7))).getPartitions().size());
+        Assertions.assertEquals(Arrays.asList(3, 3, 3, 3, 1), batchSizes);
+    }
+
+    @Test
+    public void halvesOversizeBatchAndReusesSafeSize() {
+        List<Integer> attempts = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(8, (db, table, names) -> 
{
+            attempts.add(names.size());
+            if (names.size() > 2) {
+                throw remoteFailure("frame too large");
+            }
+            return infos(names);
+        });
+
+        Assertions.assertEquals(10, 
executor.executeWithStats(request(names(10))).getPartitions().size());
+        Assertions.assertEquals(Arrays.asList(8, 4, 2, 2, 2, 2, 2), attempts);
+    }
+
+    @Test
+    public void reportsPhysicalBatchExecutionStats() {
+        HmsPartitionBatchExecutor executor = executor(4, (db, table, names) -> 
{
+            if (names.size() > 2) {
+                throw remoteFailure("frame too large");
+            }
+            return infos(names);
+        });
+
+        HmsPartitionBatchResult result = 
executor.executeWithStats(request(names(5)));
+        HmsPartitionBatchStats stats = result.getStats();
+
+        Assertions.assertEquals(5, result.getPartitions().size());
+        Assertions.assertEquals(5, stats.getRequestedItems());
+        Assertions.assertEquals(4, stats.getTransportInvocations());
+        Assertions.assertEquals(9, stats.getTransportItems());
+        Assertions.assertEquals(4, stats.getLargestBatchSize());
+        Assertions.assertEquals(1, stats.getSmallestBatchSize());
+        Assertions.assertEquals(1, stats.getFallbackCount());
+        Assertions.assertTrue(stats.getLogicalElapsedNanos() >= 
stats.getTransportElapsedNanos());
+        Assertions.assertTrue(stats.getTransportElapsedNanos() >= 
stats.getMaxTransportElapsedNanos());
+    }
+
+    @Test
+    public void minimumBatchFailurePropagates() {
+        List<Integer> attempts = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(2, (db, table, names) -> 
{
+            attempts.add(names.size());
+            throw remoteFailure("max message size reached");
+        });
+
+        HMSClientException failure = Assertions.assertThrows(
+                HMSClientException.class, () -> 
executor.executeWithStats(request(names(2))));
+        Assertions.assertEquals(Arrays.asList(2, 1), attempts);
+        
Assertions.assertTrue(failure.getMessage().contains("failedBatchSize=1"));
+        
Assertions.assertTrue(failure.getMessage().contains("transportInvocations=2"));
+        HmsPartitionBatchStats stats = failure.getPartitionBatchStats();
+        Assertions.assertNotNull(stats);
+        Assertions.assertEquals(2, stats.getRequestedItems());
+        Assertions.assertEquals(2, stats.getTransportInvocations());
+        Assertions.assertEquals(3, stats.getTransportItems());
+        Assertions.assertEquals(2, stats.getLargestBatchSize());
+        Assertions.assertEquals(1, stats.getSmallestBatchSize());
+        Assertions.assertEquals(1, stats.getFallbackCount());
+        Assertions.assertTrue(stats.getLogicalElapsedNanos() >= 
stats.getTransportElapsedNanos());
+    }
+
+    @Test
+    public void ordinaryTransportFailureDoesNotFallback() {
+        List<Integer> attempts = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(8, (db, table, names) -> 
{
+            attempts.add(names.size());
+            throw remoteFailure("connection refused");
+        });
+
+        HMSClientException failure = Assertions.assertThrows(
+                HMSClientException.class, () -> 
executor.executeWithStats(request(names(8))));
+        Assertions.assertEquals(Collections.singletonList(8), attempts);
+        HmsPartitionBatchStats stats = failure.getPartitionBatchStats();
+        Assertions.assertNotNull(stats);
+        Assertions.assertEquals(8, stats.getRequestedItems());
+        Assertions.assertEquals(1, stats.getTransportInvocations());
+        Assertions.assertEquals(8, stats.getTransportItems());
+        Assertions.assertEquals(8, stats.getLargestBatchSize());
+        Assertions.assertEquals(8, stats.getSmallestBatchSize());
+        Assertions.assertEquals(0, stats.getFallbackCount());
+    }
+
+    @Test
+    public void halvesForHivePartitionRequestLimitMessage() {
+        List<Integer> attempts = new ArrayList<>();
+        HmsPartitionBatchExecutor executor = executor(4, (db, table, names) -> 
{
+            attempts.add(names.size());
+            if (names.size() > 2) {
+                throw remoteFailure("Number of partitions scanned (4) exceeds 
limit (2). "
+                        + "This is controlled on the metastore server by 
hive.metastore.limit.partition.request");
+            }
+            return infos(names);
+        });
+
+        Assertions.assertEquals(4, 
executor.executeWithStats(request(names(4))).getPartitions().size());
+        Assertions.assertEquals(Arrays.asList(4, 2, 2), attempts);
+    }
+
+    @Test
+    public void reportsAllResultMismatchCategoriesPrecisely() {
+        HmsPartitionBatchExecutor executor = executor(10, (db, table, names) 
-> Arrays.asList(
+                info("a"), info("c"), info("c")));
+
+        HmsPartitionResultException failure = Assertions.assertThrows(
+                HmsPartitionResultException.class,
+                () -> executor.executeWithStats(request(Arrays.asList("p=a", 
"p=b"))));
+        Assertions.assertTrue(failure.getMessage().contains("missing=1"));
+        Assertions.assertTrue(failure.getMessage().contains("duplicate=1"));
+        Assertions.assertTrue(failure.getMessage().contains("unexpected=1"));
+        
Assertions.assertTrue(failure.getMessage().contains("missingSamples=[p=b]"));
+        
Assertions.assertTrue(failure.getMessage().contains("duplicateSamples=[[c]]"));
+        
Assertions.assertTrue(failure.getMessage().contains("unexpectedSamples=[[c]]"));
+    }
+
+    @Test
+    public void existingPartitionModeOmitsOnlyMissingResults() {
+        HmsPartitionBatchExecutor executor = executor(10, (db, table, names) ->
+                Arrays.asList(info("c"), info("a")));
+
+        List<Partition> existing = executor.executeExistingWithStats(
+                request(Arrays.asList("p=a", "p=b", "p=c"))).getPartitions();
+
+        Assertions.assertEquals(Arrays.asList("a", "c"), existing.stream()
+                .map(partition -> 
partition.getValues().get(0)).collect(Collectors.toList()));
+    }
+
+    @Test
+    public void 
existingPartitionModeStillRejectsUnexpectedAndDuplicateResults() {
+        HmsPartitionBatchExecutor executor = executor(10, (db, table, names) ->
+                Arrays.asList(info("a"), info("a"), info("unexpected")));
+
+        HmsPartitionResultException failure = Assertions.assertThrows(
+                HmsPartitionResultException.class,
+                () -> 
executor.executeExistingWithStats(request(Arrays.asList("p=a", "p=missing"))));
+
+        Assertions.assertTrue(failure.getMessage().contains("missing=0"));
+        Assertions.assertTrue(failure.getMessage().contains("duplicate=1"));
+        Assertions.assertTrue(failure.getMessage().contains("unexpected=1"));
+    }
+
+    @Test
+    public void rejectsNullAndMalformedResults() {
+        HmsPartitionBatchExecutor nullResponse = executor(10, (db, table, 
names) -> null);
+        HmsPartitionResultException nullFailure = Assertions.assertThrows(
+                HmsPartitionResultException.class,
+                () -> 
nullResponse.executeWithStats(request(Collections.singletonList("p=a"))));
+        Assertions.assertTrue(nullFailure.getMessage().contains("invalid=1"));
+
+        HmsPartitionBatchExecutor malformed = executor(10, (db, table, names) 
-> Collections.singletonList(
+                info(Arrays.asList("a", "extra"))));
+        HmsPartitionResultException malformedFailure = Assertions.assertThrows(
+                HmsPartitionResultException.class,
+                () -> 
malformed.executeWithStats(request(Collections.singletonList("p=a"))));
+        
Assertions.assertTrue(malformedFailure.getMessage().contains("invalid=1"));
+        
Assertions.assertTrue(malformedFailure.getMessage().contains("missing=1"));
+    }
+
+    @Test
+    public void validatesLogicalRequest() {
+        Assertions.assertEquals(Collections.singletonList(""), values("p="));
+        Assertions.assertEquals(Arrays.asList("a/b", "x=y"),
+                values("p=a%2Fb/q=x%3Dy"));
+        Assertions.assertDoesNotThrow(() -> request(Arrays.asList("P=a", 
"p=b")));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
request(Arrays.asList("p=a", "q=b")));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
request(Collections.singletonList("p=a/")));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
request(Arrays.asList("p=a", "p=a")));
+    }
+
+    @Test
+    public void parsesAndValidatesConfiguration() {
+        Assertions.assertEquals(5000, 
ThriftHMSCachedClient.parsePartitionBatchSize((String) null));
+        Assertions.assertEquals(5000, 
ThriftHMSCachedClient.parsePartitionBatchSize(" "));
+        Assertions.assertEquals(321, 
ThriftHMSCachedClient.parsePartitionBatchSize("321"));
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> ThriftHMSCachedClient.parsePartitionBatchSize("0"));
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> 
ThriftHMSCachedClient.parsePartitionBatchSize("not-a-number"));
+    }
+
+    private static HmsPartitionBatchExecutor executor(int batchSize, 
HmsPartitionTransport transport) {
+        return new HmsPartitionBatchExecutor(batchSize, transport);
+    }
+
+    private static HmsPartitionRequest request(List<String> names) {
+        return new HmsPartitionRequest("db", "table", names);
+    }
+
+    private static List<String> values(String name) {
+        return 
request(Collections.singletonList(name)).getPartitions().get(0).getValues();
+    }
+
+    private static HmsPartitionBatchExecutor.RemoteCallException 
remoteFailure(String message) {
+        return new HmsPartitionBatchExecutor.RemoteCallException(
+                "remote failure", new 
shade.doris.hive.org.apache.thrift.TException(message));
+    }
+
+    private static List<String> names(int count) {
+        return IntStream.range(0, count).mapToObj(i -> "p=" + 
i).collect(Collectors.toList());
+    }
+
+    private static List<Partition> infos(List<String> names) {
+        return 
names.stream().map(HmsPartitionBatchExecutorTest::values).map(HmsPartitionBatchExecutorTest::info)
+                .collect(Collectors.toList());
+    }
+
+    private static Partition info(String value) {
+        return info(Collections.singletonList(value));
+    }
+
+    private static Partition info(List<String> values) {
+        Partition partition = new Partition();
+        partition.setValues(new ArrayList<>(values));
+        return partition;
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
index 24f0fcf88a8..7a1b66d36f5 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
@@ -69,6 +69,8 @@ public class MTMVPartitionUtilTest {
     @Mocked
     private MTMVRefreshContext context;
     @Mocked
+    private MTMVRefreshContext.PreparedPartitionSnapshots partitionSnapshots;
+    @Mocked
     private MTMVBaseVersions versions;
 
     private Set<BaseTableInfo> baseTables = Sets.newHashSet();
@@ -150,6 +152,18 @@ public class MTMVPartitionUtilTest {
                 minTimes = 0;
                 result = baseSnapshotIf;
 
+                context.prepareComparablePartitionSnapshots((Set<String>) any);
+                minTimes = 0;
+                result = partitionSnapshots;
+
+                context.preparePartitionSnapshots((Set<String>) any);
+                minTimes = 0;
+                result = partitionSnapshots;
+
+                partitionSnapshots.get((MTMVRelatedTableIf) any, anyString);
+                minTimes = 0;
+                result = baseSnapshotIf;
+
                 refreshSnapshot.equalsWithPct(anyString, anyString, 
(MTMVSnapshotIf) any,
                         (BaseTableInfo) any);
                 minTimes = 0;
@@ -208,7 +222,8 @@ public class MTMVPartitionUtilTest {
     @Test
     public void testIsSyncWithPartition() throws AnalysisException {
         boolean isSyncWithPartition = MTMVPartitionUtil
-                .isSyncWithPartitions(context, "name1", 
Sets.newHashSet("name2"), baseOlapTable);
+                .isSyncWithPartitions(context, partitionSnapshots, "name1",
+                        Sets.newHashSet("name2"), baseOlapTable);
         Assert.assertTrue(isSyncWithPartition);
     }
 
@@ -222,7 +237,8 @@ public class MTMVPartitionUtilTest {
             }
         };
         boolean isSyncWithPartition = MTMVPartitionUtil
-                .isSyncWithPartitions(context, "name1", 
Sets.newHashSet("name2"), baseOlapTable);
+                .isSyncWithPartitions(context, partitionSnapshots, "name1",
+                        Sets.newHashSet("name2"), baseOlapTable);
         Assert.assertFalse(isSyncWithPartition);
     }
 
@@ -237,7 +253,8 @@ public class MTMVPartitionUtilTest {
             }
         };
         boolean isSyncWithPartition = MTMVPartitionUtil
-                .isSyncWithPartitions(context, "name1", 
Sets.newHashSet("name2"), baseOlapTable);
+                .isSyncWithPartitions(context, partitionSnapshots, "name1",
+                        Sets.newHashSet("name2"), baseOlapTable);
         Assert.assertFalse(isSyncWithPartition);
     }
 
@@ -262,8 +279,8 @@ public class MTMVPartitionUtilTest {
         };
 
         Set<TableName> excludedTriggerTables = ImmutableSet.of();
-        boolean isMTMVPartitionSync = 
MTMVPartitionUtil.isMTMVPartitionSync(context, "name1", baseTables,
-                excludedTriggerTables);
+        boolean isMTMVPartitionSync = 
MTMVPartitionUtil.isMTMVPartitionSync(context, partitionSnapshots,
+                "name1", baseTables, excludedTriggerTables);
 
         Assert.assertTrue(isMTMVPartitionSync);
         Assert.assertTrue(excludedTriggerTables.isEmpty());
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRefreshContextBatchTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRefreshContextBatchTest.java
new file mode 100644
index 00000000000..aa93128e625
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRefreshContextBatchTest.java
@@ -0,0 +1,239 @@
+// 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.mtmv;
+
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.common.AnalysisException;
+import org.apache.doris.datasource.CatalogIf;
+import org.apache.doris.mtmv.MTMVRefreshContext.PreparedPartitionSnapshots;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+public class MTMVRefreshContextBatchTest {
+
+    @Test
+    public void 
aggregatesOneHundredSixtyThousandMappedPartitionsIntoOneLogicalLoad()
+            throws AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = new 
LinkedHashMap<>();
+        for (int i = 0; i < 160_000; i++) {
+            mappings.put("mv" + i, Collections.singletonMap(table, 
Collections.singleton("p" + i)));
+        }
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        Mockito.when(refreshSnapshot.getPctSnapshots(Mockito.anyString(), 
Mockito.any()))
+                .thenAnswer(invocation -> Collections.singleton(
+                        "p" + invocation.<String>getArgument(0).substring(2)));
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.any(), Mockito.any()))
+                .thenAnswer(invocation -> 
snapshots(invocation.getArgument(0)));
+
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        PreparedPartitionSnapshots prepared = 
context.prepareComparablePartitionSnapshots(mappings.keySet());
+
+        Mockito.verify(table).getPartitionSnapshots(
+                Mockito.argThat(names -> names.size() == 160_000), 
Mockito.same(context), Mockito.any());
+        Assertions.assertNotNull(prepared.get(table, "p159999"));
+        Mockito.verify(table, Mockito.times(1))
+                .getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any());
+    }
+
+    @Test
+    public void comparablePreloadSkipsLocallyMismatchedPartitionSets() throws 
AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = 
Collections.singletonMap(
+                "mv", Collections.singletonMap(table, 
Collections.singleton("current")));
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        Mockito.when(refreshSnapshot.getPctSnapshots(Mockito.eq("mv"), 
Mockito.any()))
+                .thenReturn(Collections.singleton("persisted"));
+
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        PreparedPartitionSnapshots prepared =
+                
context.prepareComparablePartitionSnapshots(Collections.singleton("mv"));
+
+        Mockito.verify(table, Mockito.never())
+                .getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any());
+        Assertions.assertThrows(AnalysisException.class, () -> 
prepared.get(table, "current"));
+    }
+
+    @Test
+    public void persistencePreloadLoadsMappingsRegardlessOfPersistedState() 
throws AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = new 
LinkedHashMap<>();
+        mappings.put("mv1", Collections.singletonMap(table, 
Collections.singleton("p1")));
+        mappings.put("mv2", Collections.singletonMap(table, 
Collections.singleton("p2")));
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.any(), Mockito.any()))
+                .thenAnswer(invocation -> 
snapshots(invocation.getArgument(0)));
+
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        context.preparePartitionSnapshots(mappings.keySet());
+
+        Mockito.verify(table).getPartitionSnapshots(
+                Mockito.argThat(names -> names.equals(new 
LinkedHashSet<>(Arrays.asList("p1", "p2")))),
+                Mockito.same(context), Mockito.any());
+        Mockito.verifyNoInteractions(refreshSnapshot);
+    }
+
+    @Test
+    public void cachesOverlappingLoadsAndRequestsOnlyMissingSnapshots() throws 
AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = new 
LinkedHashMap<>();
+        mappings.put("mv1", Collections.singletonMap(table, 
Collections.singleton("p1")));
+        mappings.put("mv2", Collections.singletonMap(table, 
Collections.singleton("p2")));
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any()))
+                .thenAnswer(invocation -> 
snapshots(invocation.getArgument(0)));
+
+        PreparedPartitionSnapshots first = 
context.preparePartitionSnapshots(Collections.singleton("mv1"));
+        PreparedPartitionSnapshots second = 
context.preparePartitionSnapshots(mappings.keySet());
+        Assertions.assertNotNull(first.get(table, "p1"));
+        Assertions.assertNotNull(second.get(table, "p1"));
+        Assertions.assertNotNull(second.get(table, "p2"));
+
+        Mockito.verify(table).getPartitionSnapshots(
+                Mockito.eq(Collections.singleton("p1")), 
Mockito.same(context), Mockito.any());
+        Mockito.verify(table).getPartitionSnapshots(
+                Mockito.eq(Collections.singleton("p2")), 
Mockito.same(context), Mockito.any());
+    }
+
+    @Test
+    public void defersMissingBulkResultsToThePartitionThatConsumesThem() 
throws AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = new 
LinkedHashMap<>();
+        mappings.put("bad", Collections.singletonMap(table, 
Collections.singleton("missing")));
+        mappings.put("good", Collections.singletonMap(table, 
Collections.singleton("present")));
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any()))
+                .thenReturn(Collections.singletonMap("present", new 
MTMVTimestampSnapshot(1L)));
+
+        PreparedPartitionSnapshots prepared = 
context.preparePartitionSnapshots(mappings.keySet());
+
+        Assertions.assertNotNull(prepared.get(table, "present"));
+        AnalysisException failure = 
Assertions.assertThrows(AnalysisException.class,
+                () -> prepared.get(table, "missing"));
+        Assertions.assertTrue(failure.getMessage().contains("can not find 
partition: missing"));
+        context.preparePartitionSnapshots(mappings.keySet());
+        Mockito.verify(table, Mockito.times(1))
+                .getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any());
+    }
+
+    @Test
+    public void rejectsUnexpectedBulkResults() throws AnalysisException {
+        MTMV mtmv = Mockito.mock(MTMV.class);
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class);
+        MTMVRefreshSnapshot refreshSnapshot = 
Mockito.mock(MTMVRefreshSnapshot.class);
+        Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings = 
Collections.singletonMap(
+                "mv", Collections.singletonMap(table, 
Collections.singleton("requested")));
+        configureContext(mtmv, table, refreshSnapshot, mappings);
+        MTMVRefreshContext context = MTMVRefreshContext.buildContext(mtmv);
+        Mockito.when(table.getPartitionSnapshots(Mockito.anySet(), 
Mockito.same(context), Mockito.any()))
+                .thenReturn(Collections.singletonMap("unexpected", new 
MTMVTimestampSnapshot(1L)));
+
+        Assertions.assertThrows(AnalysisException.class,
+                () -> 
context.preparePartitionSnapshots(Collections.singleton("mv")));
+    }
+
+    @Test
+    public void defaultBulkAdapterPreservesNonBulkImplementations() throws 
AnalysisException {
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class, 
Mockito.CALLS_REAL_METHODS);
+        MTMVRefreshContext context = Mockito.mock(MTMVRefreshContext.class);
+        Mockito.when(table.getPartitionSnapshot(Mockito.anyString(), 
Mockito.same(context), Mockito.any()))
+                .thenAnswer(invocation -> new 
MTMVTimestampSnapshot(invocation.<String>getArgument(0).hashCode()));
+
+        Map<String, MTMVSnapshotIf> snapshots = table.getPartitionSnapshots(
+                new LinkedHashSet<>(Arrays.asList("p1", "p2")), context, 
Optional.empty());
+
+        Assertions.assertEquals(Arrays.asList("p1", "p2"), new 
ArrayList<>(snapshots.keySet()));
+        Mockito.verify(table, Mockito.times(2))
+                .getPartitionSnapshot(Mockito.anyString(), 
Mockito.same(context), Mockito.any());
+    }
+
+    @Test
+    public void defaultBulkAdapterDefersPerPartitionFailures() throws 
AnalysisException {
+        MTMVRelatedTableIf table = Mockito.mock(MTMVRelatedTableIf.class, 
Mockito.CALLS_REAL_METHODS);
+        MTMVRefreshContext context = Mockito.mock(MTMVRefreshContext.class);
+        Mockito.when(table.getPartitionSnapshot(Mockito.eq("missing"), 
Mockito.same(context), Mockito.any()))
+                .thenThrow(new AnalysisException("missing snapshot"));
+        Mockito.when(table.getPartitionSnapshot(Mockito.eq("present"), 
Mockito.same(context), Mockito.any()))
+                .thenReturn(new MTMVTimestampSnapshot(1L));
+
+        Map<String, MTMVSnapshotIf> snapshots = table.getPartitionSnapshots(
+                new LinkedHashSet<>(Arrays.asList("missing", "present")), 
context, Optional.empty());
+
+        Assertions.assertEquals(Collections.singleton("present"), 
snapshots.keySet());
+        Mockito.verify(context).recordPartitionSnapshotFailure(
+                Mockito.same(table), Mockito.eq("missing"), 
Mockito.any(AnalysisException.class));
+    }
+
+    private static void configureContext(MTMV mtmv, MTMVRelatedTableIf table,
+            MTMVRefreshSnapshot refreshSnapshot,
+            Map<String, Map<MTMVRelatedTableIf, Set<String>>> mappings) throws 
AnalysisException {
+        MTMVPartitionInfo partitionInfo = 
Mockito.mock(MTMVPartitionInfo.class);
+        Mockito.when(mtmv.calculatePartitionMappings())
+                .thenReturn(mappings);
+        Mockito.when(mtmv.getRelation()).thenReturn(null);
+        Mockito.when(mtmv.getMvPartitionInfo()).thenReturn(partitionInfo);
+        Mockito.when(mtmv.getRefreshSnapshot()).thenReturn(refreshSnapshot);
+        Mockito.when(table.needAutoRefresh()).thenReturn(true);
+        configureTableIdentity(table);
+        Mockito.when(partitionInfo.getPartitionType())
+                
.thenReturn(MTMVPartitionInfo.MTMVPartitionType.FOLLOW_BASE_TABLE);
+        
Mockito.when(partitionInfo.getPctTables()).thenReturn(Collections.singleton(table));
+    }
+
+    private static void configureTableIdentity(MTMVRelatedTableIf table) {
+        DatabaseIf database = Mockito.mock(DatabaseIf.class);
+        CatalogIf catalog = Mockito.mock(CatalogIf.class);
+        Mockito.when(table.getName()).thenReturn("table");
+        Mockito.when(table.getDatabase()).thenReturn(database);
+        Mockito.when(database.getFullName()).thenReturn("database");
+        Mockito.when(database.getCatalog()).thenReturn(catalog);
+        Mockito.when(catalog.getName()).thenReturn("catalog");
+    }
+
+    private static Map<String, MTMVSnapshotIf> snapshots(Set<String> names) {
+        Map<String, MTMVSnapshotIf> snapshots = new LinkedHashMap<>();
+        for (String name : names) {
+            snapshots.put(name, new MTMVTimestampSnapshot(name.hashCode()));
+        }
+        return snapshots;
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRewriteUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRewriteUtilTest.java
index 2387fd8b44a..e05727cccc7 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRewriteUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRewriteUtilTest.java
@@ -29,6 +29,7 @@ import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.DdlException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.datasource.mvcc.MvccSnapshot;
+import org.apache.doris.mtmv.MTMVRefreshContext.PreparedPartitionSnapshots;
 import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVRefreshState;
 import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
 import org.apache.doris.qe.ConnectContext;
@@ -74,6 +75,10 @@ public class MTMVRewriteUtilTest {
     private MTMVPartitionUtil mtmvPartitionUtil;
     @Mocked
     private MTMVUtil mtmvUtil;
+    @Mocked
+    private MTMVRefreshContext refreshContext;
+    @Mocked
+    private PreparedPartitionSnapshots preparedPartitionSnapshots;
     private long currentTimeMills = 3L;
 
     @Before
@@ -133,7 +138,8 @@ public class MTMVRewriteUtilTest {
                 minTimes = 0;
                 result = true;
 
-                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) 
any, anyString,
+                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) any,
+                        (PreparedPartitionSnapshots) any, anyString,
                         (Set<BaseTableInfo>) any,
                         (Set<TableName>) any);
                 minTimes = 0;
@@ -143,6 +149,10 @@ public class MTMVRewriteUtilTest {
                 minTimes = 0;
                 result = false;
 
+                
refreshContext.prepareComparablePartitionSnapshots((Set<String>) any);
+                minTimes = 0;
+                result = preparedPartitionSnapshots;
+
                 mtmv.canBeCandidate();
                 minTimes = 0;
                 result = true;
@@ -158,7 +168,8 @@ public class MTMVRewriteUtilTest {
                 minTimes = 0;
                 result = 2L;
 
-                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) 
any, anyString,
+                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) any,
+                        (PreparedPartitionSnapshots) any, anyString,
                         (Set<BaseTableInfo>) any,
                         (Set<TableName>) any);
                 minTimes = 0;
@@ -189,7 +200,8 @@ public class MTMVRewriteUtilTest {
                 minTimes = 0;
                 result = 2L;
 
-                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) 
any, anyString,
+                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) any,
+                        (PreparedPartitionSnapshots) any, anyString,
                         (Set<BaseTableInfo>) any,
                         (Set<TableName>) any);
                 minTimes = 0;
@@ -211,7 +223,8 @@ public class MTMVRewriteUtilTest {
                 minTimes = 0;
                 result = 1L;
 
-                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) 
any, anyString,
+                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) any,
+                        (PreparedPartitionSnapshots) any, anyString,
                         (Set<BaseTableInfo>) any,
                         (Set<TableName>) any);
                 minTimes = 0;
@@ -246,7 +259,8 @@ public class MTMVRewriteUtilTest {
     public void testGetMTMVCanRewritePartitionsNotSync() throws 
AnalysisException {
         new Expectations() {
             {
-                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) 
any, anyString,
+                MTMVPartitionUtil.isMTMVPartitionSync((MTMVRefreshContext) any,
+                        (PreparedPartitionSnapshots) any, anyString,
                         (Set<BaseTableInfo>) any,
                         (Set<TableName>) any);
                 minTimes = 0;


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

Reply via email to