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

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


The following commit(s) were added to refs/heads/master by this push:
     new 439906a36a4 [opt](hive) Decouple partition metadata batches from 
directory tasks (#70247)
439906a36a4 is described below

commit 439906a36a4933f15fdfc785e00f51ea16639510
Author: 924060929 <[email protected]>
AuthorDate: Fri Nov 6 09:11:21 2026 +0800

    [opt](hive) Decouple partition metadata batches from directory tasks 
(#70247)
    
    ### Background
    
    Partitioned Hive Batch Mode fetches partition objects from Hive
    Metastore (HMS), lists directories and streams splits into bounded
    queues. Small directory work units allow scans to start early and
    producers to yield. HMS has a separate property limiting partition names
    per RPC, but the streaming path previously also used the small
    directory-task size for those RPCs.
    
    ### What problem does this PR solve?
    
    Issue Number: None
    Related PR: None
    PR Type: Performance
    
    Problem Summary:
    
    A cold scan of 1000 partitions could make 63 partition-object requests
    with a directory batch size of 16. Separate bounded metadata acquisition
    from directory work: fetch a small first HMS batch, then larger bounded
    batches, while readers continue claiming small directory slices. Deliver
    the first slice without walking the next large cached batch, and resume
    sources through callbacks when metadata becomes available. Profiling
    also exposed redundant per-split destination selection under the
    assignment lock when only one backend is eligible; that case now assigns
    remote batches directly to their only possible owner. Session defaults
    and BE code are unchanged.
    
    ### Details
    
    - **Change map:** 636 changed production lines, 1159 test lines, 10
    documentation lines and 4 build/API-baseline lines; no generated files.
    Hive loader/source integration owns shared batch cursors and
    cancellation; an HMS cache-only probe avoids remote reads when cached;
    an optional scan SPI and generic readiness handling release workers
    while metadata is pending. The common backend policy adds a 19-line
    single-destination fast path.
    - **Why it was slow:** small directory units caused excessive HMS round
    trips. Preparing a subsequent large cached batch synchronously inside
    the last-slice `take()` also delayed delivery of the first small slice.
    Separately, single-backend streaming assignment still hashed every
    split, built candidate/alternative-host lists and updated weight maps
    per split under `SplitAssignment.assignBatch`'s monitor.
    - **Optimization:** fetch `min(16,
    hive.hms_partitions_batch_size_per_rpc)` names first, then use the
    existing positive RPC cap (default 5000). Share one ready result list;
    readers claim at most the existing directory batch size. Admit the next
    large cache probe without waiting; an idle reader may help its own
    probe, with `FutureTask` electing one executor. Only the existing
    METADATA scheduler performs remote loads, and readiness callbacks resume
    readers on the existing SPLIT scheduler. For one eligible backend and
    wholly remote batches, preserve split order and exact accumulated
    weights while skipping candidate selection. Local-only and
    multiple-backend batches retain the general path.
    - **Results:** the cold single-query full scan fetches 1000 HMS
    partitions in two physical RPCs (16 and 984), compared with 63 in the
    parent streaming path. The controlled SQL comparison below isolates the
    subsequent first-slice and assignment corrections; all three columns
    already contain bounded HMS batching. A separate same-JVM experiment
    confirms lower FE CPU/allocation and a LIMIT improvement; full-scan
    latency still has substantial variance, so no fixed 22 ms penalty or
    stable full-scan speedup is claimed.
    - **Applicability and cost:** targets partitioned Hive scans with HMS
    partition-cache misses; a directory's file Listing retains its existing
    behavior. Buffered metadata is bounded by one configured HMS result
    batch plus slices held by bounded readers. A remote call already running
    at cancellation may finish; buffered/late results are discarded and
    subsequent requests are prevented. Cache helpers do no remote I/O.
    Single-backend assignment adds a linear remote-accessibility check and
    replaces per-split hash/candidate/map work with one exact weight
    accumulation. No additional pool or cache is introduced.
    - **Behavior change:** metadata RPC grouping and source readiness
    change. Split batch size, directory batch size, queue capacity, pruning,
    invalidation, query-pinned handles, early stop, scheduler quanta and
    backpressure retain their meanings. Connectors that do not opt in retain
    their path. Connector API changes from 29 to 30; deploy the matching
    connector bundle with FE. No FE/BE wire-format change.
    
    #### Controlled complete-SQL comparison
    
    All packages use parent `315c2e003317ef44e8a8fbb445a1ec8452d5647e`.
    “Initial batching” is `5e7b1c2ca33d3c20119e95eebaab9bd35d90e6c2`;
    “First-slice correction” is `c1e67745edb537a96109f6a2f43d2bd75a249417`;
    “Final” also includes the single-backend fast path. Initial/corrected
    packages differ only in the Hive loader classes. Corrected/final
    measurement packages differ in exactly `FederationBackendPolicy.class`,
    compiled by the successful full FE build, to isolate the optimization.
    
    | Scenario | Initial batching (ms) | First-slice correction (ms) | Final
    (ms) |
    |---|---:|---:|---:|
    | Single query, cold, LIMIT 10 | 20.75 | 20.20 | 20.80 |
    | Single query, cold, full scan | 155.88 | 160.56 | 166.42 |
    | Single query, warm, LIMIT 10 | 8.14 | 7.26 | 7.32 |
    | Single query, warm, full scan | 86.38 | 81.79 | 84.69 |
    | Four-query wave, cold, LIMIT 10 | 46.43 | 47.03 | 45.83 |
    | Four-query wave, cold, full scan | 370.16 | 375.65 | 368.44 |
    | Four-query wave, warm, LIMIT 10 | 11.21 | 11.27 | 9.61 |
    | Four-query wave, warm, full scan | 235.75 | 232.23 | 236.50 |
    
    ARM64 Docker, identical Release BE SHA256
    `f1ac268df25c8f9a6f8095e3dda20f6b0dbc591cb4609b03612dd5a913bfd0b1`, one
    engine at a time, 6 CPUs and 14 GiB, FE heap and BE memory limit 4 GiB
    each. MinIO retains its CPU set and memory limit with CFS quota removed;
    every timed wave verifies zero throttling. User VMs remain stopped. The
    fixture has 1000 partitions, ten small Parquet files per partition and
    five rows per file: 10000 files, 50000 rows.
    
    Settings: `enable_external_table_batch_mode=true`,
    `num_partitions_in_batch_mode=1`, `split_batch_size=8`,
    `split_queue_capacity=1024`, `split_partition_batch_size=16`,
    `split_producer_parallelism=0`, `enable_sql_cache=false`,
    `enable_stats=false`. Cold/warm refer to FE metadata and file-list
    caches; JVM and BE footer/data caches are warmed. Cache gates drain
    previous tasks and retain connector identity. Each phase runs the same
    3000-SQL warmup. Order: initial/corrected/final/final/corrected/initial;
    each cell has 32 samples per variant. Four-query latency ends when all
    four synchronized persistent clients complete. All 1920 formal SQL
    executions validate results. Profile, bytecode tracing and sampling are
    disabled during formal timing. Across 240 independent query Profiles,
    all 120 full scans verify 10000 footer hits, zero footer reads, 20000
    page hits and zero page misses/writes.
    
    Phase effects are material: unchanged corrected code has warm four-query
    full-scan medians of 221.36 and 240.81 ms across its two phases.
    Separate randomized, paired diagnostics therefore keep the same JVM and
    BE process. Choosing first-return, inline-cache-probe or wait-for-probe
    ordering yields full-scan medians of 209.61 / 210.33 / 208.47 ms, with
    45 waves per mode and twelve split-fetch RPCs per query in all modes;
    this does not establish a fixed first-return penalty.
    
    With only the new assignment fast path enabled/disabled in that
    same-process style of diagnostic (60 randomized waves per mode),
    full-scan medians are 226.74 / 237.40 ms. The median paired difference
    is -5.31 ms, bootstrap 95% interval [-9.18, +1.28] ms: stable full-scan
    latency improvement is not established. LIMIT medians are 10.30 / 11.37
    ms, paired difference -1.15 ms, interval [-1.60, -0.52] ms. Full-wave FE
    process CPU falls from 215 to 170 ms. This temporary diagnostic guard is
    absent from production code and the formal comparison above.
    
    Independent 240-query flamegraph windows in the same JVM reduce
    assignment CPU samples from 5.03 to 0.76 ms/query and sampled
    allocations from 16.77 to 1.23 MiB/query. Hash-ring samples disappear.
    The assignment monitor's cumulative waiting falls from 676.63 to 39.77
    ms/query; these are sums across overlapping producer waits, not SQL
    elapsed time. The first-slice helper remains around 0.36–0.43 ms
    CPU/query. The full scan continues to spend most elapsed time waiting
    for scan results, so reduced producer work need not translate directly
    into the same wall-time reduction.
    
    ### Testing
    
    - Current head, local ARM64 Docker limited to 2 CPUs: `./build.sh --fe
    -j2`, including Checkstyle, and 130 focused tests all pass:
    `FederationBackendPolicyTest`, `FederationBackendPolicySpreadTest`,
    `SplitProducerTest`, `SplitSourceTest`,
    `PluginDrivenScanNodeHiveStreamingTest`.
    - The new single-backend legacy-hash test fails before the optimization
    with 140 unnecessary split-identity reads and passes after it. Six
    selection settings, exact cumulative weights across empty/small/large
    batches, local-only validation and overflow are covered; existing
    multiple-backend tests pass.
    - The Hive/HMS/SPI implementation previously passed 578 Hive, 184 HMS,
    85 SPI and 71 focused core tests. Those broader connector suites precede
    the isolated backend-policy addition; current-head core and complete-SQL
    validation are listed separately above.
    - `takingFirstDirectoryBatchDoesNotWalkTheNextLargeCachedBatch`
    reproduces the first-slice barrier. Deterministic coverage also includes
    helper/worker execution exactly once, full scheduler queues, one-slot
    handoff, cancellation during misses, queued cancellation, remote
    failures/late results, bounded batches, dropped partitions and readiness
    completion races.
    - `git diff --check` passes. No BE change or rebuild. Runtime test
    services and temporary quota changes are restored after testing.
    
    ### Notes for Reviewers
    
    Start with `HivePartitionBatchLoader` and
    `HiveScanPlanProvider.streamPartitionSources`, then the generic adapter
    in `PluginDrivenScanNode` and readiness handling in `SplitProducer`. The
    loader monitor protects cursors/results/failure only; cache work, RPCs,
    scheduling and future completion run outside it. `FutureTask.get()` is
    used by `done()` after completion and never waits there. A reader
    retains only its slice. Backend-policy state keeps its existing
    assignment lock; the single-destination fast path reduces work inside
    that lock.
    
    ### Release note
    
    Reduce cold Hive partition planning round trips while retaining early
    split delivery, cancellation and bounded queues. Reduce FE CPU and
    temporary allocations for remote split assignment to one eligible
    backend.
    
    ### AI Disclosure
    
    - Tool: OpenAI Codex.
    - Generated: Implementation, tests, self-review and this description.
    - Human review: The author directed the design and investigation. Agent
    self-review is complete; human line-by-line review is pending.
---
 fe/fe-connector/README.md                          |  10 +
 .../connector/hive/HiveCatalogProperties.java      |   4 +
 .../connector/hive/HivePartitionBatchLoader.java   | 317 ++++++++++++
 .../doris/connector/hive/HiveScanPlanProvider.java | 135 ++++-
 .../hive/HivePartitionBatchLoaderTest.java         | 543 +++++++++++++++++++++
 .../connector/hive/HiveScanBatchModeTest.java      | 269 ++++++++++
 .../doris/connector/hms/CachingHmsClient.java      |  19 +
 .../org/apache/doris/connector/hms/HmsClient.java  |  11 +
 .../doris/connector/hms/CachingHmsClientTest.java  |  66 +++
 .../spi/scan/ConnectorScanPlanProvider.java        |  13 +
 .../connector/spi/scan/ConnectorSplitSource.java   |  14 +
 .../connector/spi/ConnectorPluginSurfaceTest.java  |   2 +-
 .../test/resources/connector-plugin-surface.txt    |   2 +
 fe/fe-connector/pom.xml                            |   2 +-
 .../datasource/scan/FederationBackendPolicy.java   |  19 +
 .../datasource/scan/PluginDrivenScanNode.java      |  71 +++
 .../doris/datasource/split/SplitProducer.java      |  33 ++
 .../PluginDrivenScanNodePartitionBatchTest.java    |  63 +++
 .../doris/datasource/split/SplitProducerTest.java  | 114 +++++
 .../doris/planner/FederationBackendPolicyTest.java | 102 ++++
 20 files changed, 1803 insertions(+), 6 deletions(-)

diff --git a/fe/fe-connector/README.md b/fe/fe-connector/README.md
index 1b61b82c0ab..cfcfd5d496c 100644
--- a/fe/fe-connector/README.md
+++ b/fe/fe-connector/README.md
@@ -118,6 +118,16 @@ before releasing read transactions and statement metadata. 
An SDK call that
 cannot be interrupted may finish later; it must not issue another request
 after observing cancellation.
 
+Partitioned connectors may implement `streamPartitionSources` to batch metadata
+independently of directory work. The hook opens a bounded set of cooperating
+sources without remote I/O. A source reports a metadata dependency through
+`isBlocked`; the engine releases its worker until completion. If `hasNext`
+returns false while waiting, retain that dependency, including a completed one,
+until the next read attempt so readiness cannot be mistaken for end of input.
+Use the existing METADATA scheduler for metadata loading and let SPLIT sources
+claim small directory batches; neither stage may wait for child tasks. The
+default hook keeps ordinary partition-batch sources unchanged.
+
 `SET first_split_wait_timeout_ms = 2500` bounds the first split wait, including
 enumeration executor queueing. The default is 30000 ms; values must be 
positive.
 `SET_VAR` and FE forwarding are supported. Preparation before enumeration is
diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveCatalogProperties.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveCatalogProperties.java
index c3e243f5124..ebbeb07e8c8 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveCatalogProperties.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveCatalogProperties.java
@@ -246,6 +246,10 @@ public final class HiveCatalogProperties {
         return hmsClientPoolSize;
     }
 
+    public int getHmsPartitionsBatchSizePerRpc() {
+        return hmsPartitionsBatchSizePerRpc;
+    }
+
     public boolean isHmsEventsIncrementalSyncEnabled() {
         return enableHmsEventsIncrementalSync;
     }
diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HivePartitionBatchLoader.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HivePartitionBatchLoader.java
new file mode 100644
index 00000000000..3cbb4b8845c
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HivePartitionBatchLoader.java
@@ -0,0 +1,317 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.hive;
+
+import org.apache.doris.connector.hms.HmsPartitionInfo;
+import org.apache.doris.connector.spi.ConnectorTaskScheduler;
+import org.apache.doris.connector.spi.scan.ConnectorScanContext;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+
+/**
+ * One scan's serial HMS producer. Readers claim small directory batches from 
one bounded result list.
+ * Claiming its last slice permits one next metadata batch, overlapping HMS 
with directory work without
+ * waiting for children or accumulating an unbounded queue. Large cache probes 
are queued separately
+ * from remote reads, so an idle reader can help its own cache probe without 
doing remote I/O.
+ */
+final class HivePartitionBatchLoader {
+    private static final int INITIAL_BATCH_SIZE = 16;
+    private final ConnectorTaskScheduler scheduler;
+    private final ConnectorScanContext scanContext;
+    private final List<String> names;
+    private final int maxBatchSize;
+    private final Function<List<String>, Optional<List<HmsPartitionInfo>>> 
cached;
+    private final Function<List<String>, List<HmsPartitionInfo>> loader;
+    // Serializes synchronous cache hits and inline/offline executors without 
recursive submission.
+    private final AtomicInteger pumping = new AtomicInteger();
+    // All fields below are guarded by this. I/O, scheduler calls and future 
completion run outside it.
+    private int nextName;
+    private List<HmsPartitionInfo> ready = Collections.emptyList();
+    private int nextReady;
+    private CompletableFuture<Void> available = new CompletableFuture<>();
+    private LoadTask current;
+    private Throwable failure;
+    private int readers;
+
+    HivePartitionBatchLoader(ConnectorTaskScheduler scheduler, 
ConnectorScanContext scanContext,
+            List<String> names, int maxBatchSize, int readers,
+            Function<List<String>, Optional<List<HmsPartitionInfo>>> cached,
+            Function<List<String>, List<HmsPartitionInfo>> loader) {
+        this.scheduler = scheduler;
+        this.scanContext = scanContext;
+        this.names = names;
+        this.maxBatchSize = maxBatchSize;
+        this.readers = readers;
+        this.cached = cached;
+        this.loader = loader;
+        scanContext.whenCancelled(() -> fail(new CancellationException("Split 
generation stopped")));
+    }
+
+    CompletableFuture<Void> isBlocked() {
+        pump();
+        LoadTask task;
+        synchronized (this) {
+            task = current;
+        }
+        if (task != null) {
+            task.helpCacheProbe();
+        }
+        synchronized (this) {
+            return failure != null || (ready.isEmpty() && (current != null || 
nextName < names.size()))
+                    ? available : null;
+        }
+    }
+
+    synchronized boolean isFinished() {
+        return failure == null && ready.isEmpty() && current == null && 
nextName == names.size();
+    }
+
+    List<HmsPartitionInfo> take(int size) {
+        List<HmsPartitionInfo> result;
+        synchronized (this) {
+            if (failure != null) {
+                throw new CompletionException(failure);
+            }
+            if (ready.isEmpty()) {
+                return Collections.emptyList();
+            }
+            int end = Math.min(nextReady + size, ready.size());
+            // Own a small reference array: a slow reader must not retain an 
entire completed HMS batch.
+            result = new ArrayList<>(ready.subList(nextReady, end));
+            nextReady = end;
+            if (nextReady == ready.size()) {
+                ready = Collections.emptyList();
+                nextReady = 0;
+                available = new CompletableFuture<>();
+            }
+        }
+        pump();
+        return result;
+    }
+
+    void readerClosed() {
+        boolean last;
+        synchronized (this) {
+            last = --readers == 0;
+        }
+        if (last) {
+            fail(new CancellationException("All partition readers closed"));
+        }
+    }
+
+    private void pump() {
+        if (pumping.getAndIncrement() != 0) {
+            return;
+        }
+        int missed = 1;
+        do {
+            LoadTask task = null;
+            synchronized (this) {
+                if (failure == null && current == null && ready.isEmpty() && 
nextName < names.size()) {
+                    int size = nextName == 0 ? Math.min(INITIAL_BATCH_SIZE, 
maxBatchSize) : maxBatchSize;
+                    int end = nextName + Math.min(size, names.size() - 
nextName);
+                    task = new LoadTask(names.subList(nextName, end));
+                    nextName = end;
+                    current = task;
+                }
+            }
+            if (task != null) {
+                try {
+                    scanContext.checkCancelled();
+                    if (task.deferredCacheProbe) {
+                        // Return the directory slice before walking the next 
large cached batch.
+                        task.cacheProbe.admit();
+                    } else {
+                        Optional<List<HmsPartitionInfo>> hit = 
cached.apply(task.batch);
+                        if (hit.isPresent()) {
+                            publish(task, hit.get());
+                        } else {
+                            task.remoteLoad.admit();
+                        }
+                    }
+                } catch (Throwable e) {
+                    fail(e);
+                }
+            }
+            missed = pumping.addAndGet(-missed);
+        } while (missed != 0);
+    }
+
+    private void publish(LoadTask task, List<HmsPartitionInfo> partitions) {
+        CompletableFuture<Void> signal = null;
+        boolean empty;
+        synchronized (this) {
+            if (failure != null) {
+                return;
+            }
+            current = null;
+            ready = partitions;
+            empty = ready.isEmpty();
+            if (!empty || nextName == names.size()) {
+                signal = available;
+            }
+        }
+        task.complete(null);
+        if (signal != null) {
+            signal.complete(null);
+        }
+        if (empty) {
+            pump();
+        }
+    }
+
+    private void fail(Throwable cause) {
+        CompletableFuture<Void> signal;
+        LoadTask task;
+        synchronized (this) {
+            if (failure != null) {
+                return;
+            }
+            failure = cause;
+            ready = Collections.emptyList();
+            if (available.isDone()) {
+                available = new CompletableFuture<>();
+            }
+            signal = available;
+            task = current;
+            current = null;
+        }
+        signal.completeExceptionally(cause);
+        if (task != null) {
+            task.completeExceptionally(cause);
+        }
+    }
+
+    private final class LoadTask extends CompletableFuture<Void> {
+        private final List<String> batch;
+        private final boolean deferredCacheProbe;
+        private final ScheduledStage cacheProbe;
+        private final ScheduledStage remoteLoad;
+
+        private LoadTask(List<String> batch) {
+            this.batch = batch;
+            this.deferredCacheProbe = batch.size() > INITIAL_BATCH_SIZE;
+            this.remoteLoad = new ScheduledStage(() -> {
+                List<HmsPartitionInfo> partitions = loader.apply(batch);
+                scanContext.checkCancelled();
+                publish(this, partitions);
+            });
+            this.cacheProbe = new ScheduledStage(() -> {
+                Optional<List<HmsPartitionInfo>> hit = cached.apply(batch);
+                // A cancellation during a cache miss must not start another 
HMS request.
+                scanContext.checkCancelled();
+                if (hit.isPresent()) {
+                    publish(this, hit.get());
+                } else {
+                    remoteLoad.admit();
+                }
+            });
+            whenComplete((unused, error) -> {
+                cacheProbe.cancel(false);
+                remoteLoad.cancel(false);
+            });
+        }
+
+        private void helpCacheProbe() {
+            if (deferredCacheProbe) {
+                // FutureTask elects one executor, even if a metadata worker 
dequeues it concurrently.
+                // Helpers only probe the cache; a miss submits remoteLoad and 
returns without waiting.
+                cacheProbe.run();
+            }
+        }
+
+        private final class ScheduledStage extends FutureTask<Void> {
+            private final AtomicInteger admitting = new AtomicInteger();
+            private boolean admitted;
+            private final Runnable onSpace = this::admit;
+
+            private ScheduledStage(Runnable action) {
+                super(() -> {
+                    ClassLoader previous = 
Thread.currentThread().getContextClassLoader();
+                    try {
+                        
Thread.currentThread().setContextClassLoader(HivePartitionBatchLoader.class.getClassLoader());
+                        scanContext.checkCancelled();
+                        if (!LoadTask.this.isDone()) {
+                            action.run();
+                        }
+                        return null;
+                    } finally {
+                        Thread.currentThread().setContextClassLoader(previous);
+                    }
+                });
+            }
+
+            private void admit() {
+                // Capacity callbacks can race admission, including inline 
notification.
+                if (admitting.getAndIncrement() != 0) {
+                    return;
+                }
+                int missed = 1;
+                do {
+                    // Cancelling one stage can synchronously notify another 
stage's capacity callback.
+                    if (!isDone() && !LoadTask.this.isDone() && !admitted) {
+                        try {
+                            admitted = scheduler.tryExecute(this, 
this::setException, onSpace);
+                            if (admitted) {
+                                scheduler.removeSpaceListener(onSpace);
+                            }
+                        } catch (RuntimeException e) {
+                            setException(e);
+                        }
+                    }
+                    if (isDone()) {
+                        scheduler.removeSpaceListener(onSpace);
+                        scheduler.remove(this);
+                    }
+                    missed = admitting.addAndGet(-missed);
+                } while (missed != 0);
+            }
+
+            @Override
+            protected void done() {
+                scheduler.removeSpaceListener(onSpace);
+                // A helper may have executed this task while its queue entry 
was still pending.
+                scheduler.remove(this);
+                if (LoadTask.this.isDone()) {
+                    return;
+                }
+                try {
+                    // FutureTask invokes done only after completion; get 
never waits here.
+                    get();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    fail(e);
+                } catch (ExecutionException e) {
+                    fail(e.getCause());
+                } catch (CancellationException e) {
+                    fail(e);
+                }
+            }
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java
index f9e8e4364ed..c2312582227 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/main/java/org/apache/doris/connector/hive/HiveScanPlanProvider.java
@@ -56,6 +56,7 @@ import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
@@ -277,6 +278,83 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
         return new HiveSplitSource(session, hiveHandle, 
request.getRequiredPartitions(), request.getScanContext());
     }
 
+    @Override
+    public Optional<List<ConnectorSplitSource>> 
streamPartitionSources(ConnectorSession session,
+            ConnectorScanRequest request, int partitionsPerTask, int 
maxSources) {
+        HiveTableHandle handle = (HiveTableHandle) request.getTableHandle();
+        rejectTransactionalRead(handle);
+        List<String> names = request.getRequiredPartitions();
+        int sourceCount = Math.min(maxSources, (names.size() - 1) / 
partitionsPerTask + 1);
+        HivePartitionBatchLoader loader = new HivePartitionBatchLoader(
+                
session.getTaskScheduler(ConnectorTaskScheduler.Stage.METADATA), 
request.getScanContext(), names,
+                catalogProperties.getHmsPartitionsBatchSizePerRpc(), 
sourceCount,
+                batch -> cachedBatchPartitions(handle, batch),
+                batch -> resolveBatchPartitions(handle, batch, 
request.getScanContext()));
+        List<ConnectorSplitSource> sources = new ArrayList<>(sourceCount);
+        for (int i = 0; i < sourceCount; i++) {
+            sources.add(new ConnectorSplitSource() {
+                private HiveSplitSource source;
+                private boolean closed;
+
+                @Override
+                public CompletableFuture<Void> isBlocked() {
+                    return closed ? null : source == null ? loader.isBlocked() 
: source.isBlocked();
+                }
+
+                @Override
+                public boolean hasNext() {
+                    if (closed) {
+                        return false;
+                    }
+                    if (source == null) {
+                        source = new HiveSplitSource(session, handle, names, 
request.getScanContext(),
+                                partitionsPerTask);
+                        source.partitionLoader = loader;
+                    }
+                    return source.hasNext();
+                }
+
+                @Override
+                public ConnectorScanRange next() {
+                    return source.next();
+                }
+
+                @Override
+                public void close() {
+                    if (!closed) {
+                        closed = true;
+                        if (source == null) {
+                            loader.readerClosed();
+                        } else {
+                            source.close();
+                        }
+                    }
+                }
+            });
+        }
+        return Optional.of(sources);
+    }
+
+    private Optional<List<HmsPartitionInfo>> 
cachedBatchPartitions(HiveTableHandle handle, List<String> names) {
+        Map<String, HmsPartitionInfo> retained = 
handle.getPrunedPartitionsByName();
+        if (!retained.isEmpty()) {
+            List<HmsPartitionInfo> result = new ArrayList<>(names.size());
+            for (String name : names) {
+                HmsPartitionInfo partition = retained.get(name);
+                if (partition == null) {
+                    return Optional.empty();
+                }
+                result.add(partition);
+            }
+            return Optional.of(result);
+        }
+        Optional<HmsPartitionBatchResult> result = 
hmsClient.getCachedPartitionsWithStats(
+                handle.getDbName(), handle.getTableName(), names);
+        result.ifPresent(hit -> partitionBatchProfile.record(
+                handle.getDbName(), handle.getTableName(), hit.getStats()));
+        return result.map(HmsPartitionBatchResult::getPartitions);
+    }
+
     private static void rejectTransactionalRead(HiveTableHandle handle) {
         // Both full ACID and insert-only layouts need write-id visibility; 
plain file scans
         // (including metadata-only counts) cannot return a transactionally 
correct result.
@@ -300,6 +378,8 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
         private final boolean splittable;
         private final long targetSplitSize;
         private final int partitionBatchSize;
+        private HivePartitionBatchLoader partitionLoader;
+        private CompletableFuture<Void> blockedOnPartitions;
         private Iterator<PartitionScanInfo> partitions = 
Collections.emptyIterator();
         private Iterator<HiveFileStatus> files = Collections.emptyIterator();
         private Iterator<ConnectorScanRange> ranges = 
Collections.emptyIterator();
@@ -311,6 +391,12 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
 
         HiveSplitSource(ConnectorSession session, HiveTableHandle handle, 
List<String> requiredPartitions,
                 ConnectorScanContext scanContext) {
+            this(session, handle, requiredPartitions, scanContext, Math.max(1,
+                    sessionInt(session, "split_partition_batch_size", 
DEFAULT_PARTITION_BATCH_SIZE)));
+        }
+
+        HiveSplitSource(ConnectorSession session, HiveTableHandle handle, 
List<String> requiredPartitions,
+                ConnectorScanContext scanContext, int partitionsPerTask) {
             rejectTransactionalRead(handle);
             this.handle = handle;
             this.scanContext = scanContext;
@@ -323,13 +409,21 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
             this.isLzo = isLzoInputFormat(handle.getInputFormat());
             this.splittable = tableFormat.isSplittable() && !isLzo;
             this.targetSplitSize = getTargetSplitSize(session);
-            this.partitionBatchSize = Math.max(1,
-                    sessionInt(session, "split_partition_batch_size", 
DEFAULT_PARTITION_BATCH_SIZE));
+            this.partitionBatchSize = partitionsPerTask;
             recordPruningProfile(handle);
         }
 
+        @Override
+        public CompletableFuture<Void> isBlocked() {
+            if (closed || partitionLoader == null || ranges.hasNext() || 
files.hasNext() || partitions.hasNext()) {
+                return null;
+            }
+            return blockedOnPartitions != null ? blockedOnPartitions : 
partitionLoader.isBlocked();
+        }
+
         @Override
         public boolean hasNext() {
+            blockedOnPartitions = null;
             // Empty partitions can keep hasNext() inside this source across 
many directory requests.
             while (!closed && !scheduler.isClosed()) {
                 scanContext.checkCancelled();
@@ -345,6 +439,15 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
                     continue;
                 }
                 if (!loadNextPartitions()) {
+                    if (partitionLoader != null) {
+                        // Keep this dependency even if it completes before 
the caller checks it:
+                        // an empty read while waiting must not be mistaken 
for end of input.
+                        blockedOnPartitions = partitionLoader.isBlocked();
+                        if (blockedOnPartitions == null && 
!partitionLoader.isFinished()) {
+                            // Another reader may have taken the list after 
our readiness check.
+                            continue;
+                        }
+                    }
                     return false;
                 }
             }
@@ -369,6 +472,11 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
         }
 
         private boolean loadNextPartitions() {
+            if (partitionLoader != null) {
+                partitions = 
convertPartitions(partitionLoader.take(partitionBatchSize),
+                        handle.getPartitionKeyNames()).iterator();
+                return partitions.hasNext();
+            }
             List<String> keys = handle.getPartitionKeyNames();
             if (keys == null || keys.isEmpty()) {
                 if (unpartitionedInitialized) {
@@ -399,8 +507,18 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
             }
             int end = Math.min(partitionNames.size(), nextPartition + 
partitionBatchSize);
             scanContext.checkCancelled();
-            partitions = convertPartitions(resolveBatchPartitions(handle,
-                    partitionNames.subList(nextPartition, end), scanContext), 
keys).iterator();
+            List<String> batch = partitionNames.subList(nextPartition, end);
+            // A retained pruning view belongs to this handle; never replace 
it with a newer cache view.
+            Optional<HmsPartitionBatchResult> cached = 
handle.getPrunedPartitionsByName().isEmpty()
+                    ? 
hmsClient.getCachedPartitionsWithStats(handle.getDbName(), 
handle.getTableName(), batch)
+                    : Optional.empty();
+            if (cached.isPresent()) {
+                partitionBatchProfile.record(handle.getDbName(), 
handle.getTableName(), cached.get().getStats());
+                partitions = convertPartitions(cached.get().getPartitions(), 
keys).iterator();
+                nextPartition = end;
+                return true;
+            }
+            partitions = convertPartitions(resolveBatchPartitions(handle, 
batch, scanContext), keys).iterator();
             nextPartition = end;
             return true;
         }
@@ -415,10 +533,18 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
 
         @Override
         public void close() {
+            if (closed) {
+                return;
+            }
+            if (partitionLoader != null) {
+                partitionLoader.readerClosed();
+                partitionLoader = null;
+            }
             closed = true;
             partitions = Collections.emptyIterator();
             files = Collections.emptyIterator();
             ranges = Collections.emptyIterator();
+            blockedOnPartitions = null;
             partitionNames = null;
             partition = null;
         }
@@ -464,6 +590,7 @@ public class HiveScanPlanProvider implements 
ConnectorScanPlanProvider {
             ConnectorSession session,
             ConnectorScanRequest request,
             List<String> partitionBatch) {
+        rejectTransactionalRead((HiveTableHandle) request.getTableHandle());
         // Batch mode deliberately does not retain completed ranges in the 
statement scope: its
         // purpose is to bound FE memory while splits are streamed to the 
coordinator. Caching every
         // batch until statement close would materialize the full scan again 
and defeat that bound.
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HivePartitionBatchLoaderTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HivePartitionBatchLoaderTest.java
new file mode 100644
index 00000000000..c06603dbbb9
--- /dev/null
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HivePartitionBatchLoaderTest.java
@@ -0,0 +1,543 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.connector.hive;
+
+import org.apache.doris.connector.hms.HmsPartitionInfo;
+import org.apache.doris.connector.spi.ConnectorTaskScheduler;
+import org.apache.doris.connector.spi.scan.ConnectorScanContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Queue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+class HivePartitionBatchLoaderTest {
+    @Test
+    void firstBatchStartsSmallAndRemainingBatchIsSharedWithoutDuplicates() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        List<Integer> requests = new ArrayList<>();
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 1000, 1024, batch -> {
+            requests.add(batch.size());
+            return partitions(batch);
+        });
+        Assertions.assertTrue(requests.isEmpty());
+        CompletableFuture<Void> initial = loader.isBlocked();
+        Assertions.assertFalse(initial.isDone());
+        scheduler.runNext();
+        Assertions.assertTrue(initial.isDone());
+        List<HmsPartitionInfo> all = new ArrayList<>(loader.take(16));
+        Assertions.assertEquals(1, scheduler.tasks.size(), "next HMS batch 
overlaps the first directory batch");
+        scheduler.runAll();
+        Assertions.assertEquals(List.of(16, 984), requests);
+        while (!loader.isFinished()) {
+            List<HmsPartitionInfo> batch = loader.take(16);
+            Assertions.assertTrue(batch.size() <= 16);
+            all.addAll(batch);
+        }
+        Assertions.assertEquals(1000, all.size());
+        Assertions.assertEquals(1000, new 
HashSet<>(all.stream().map(HmsPartitionInfo::getValues)
+                .collect(java.util.stream.Collectors.toList())).size());
+    }
+
+    @Test
+    void takingFirstDirectoryBatchDoesNotWalkTheNextLargeCachedBatch() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        List<Integer> probes = new ArrayList<>();
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, new ConnectorScanContext(),
+                names(1000), 1024, 3, batch -> {
+                    probes.add(batch.size());
+                    return Optional.of(partitions(batch));
+                }, batch -> {
+                    throw new AssertionError("cached metadata must not issue 
an HMS request");
+                });
+        Assertions.assertNull(loader.isBlocked());
+        Assertions.assertEquals(16, loader.take(16).size());
+        Assertions.assertEquals(List.of(16), probes,
+                "claiming the first slice must not prepare the following 984 
cached partitions inline");
+        Assertions.assertEquals(1, scheduler.tasks.size());
+        Runnable queued = scheduler.tasks.element();
+        Assertions.assertNull(loader.isBlocked(), "an idle reader can help the 
cache probe");
+        queued.run();
+        Assertions.assertTrue(scheduler.tasks.isEmpty(), "the helped task must 
be removed from the queue");
+        Assertions.assertEquals(List.of(16, 984), probes);
+        int count = 16;
+        while (!loader.isFinished()) {
+            count += loader.take(16).size();
+        }
+        Assertions.assertEquals(1000, count);
+    }
+
+    @Test
+    void helperAndMetadataWorkerCannotRunTheSameCacheProbeTwice() throws 
Exception {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicInteger probes = new AtomicInteger();
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, new ConnectorScanContext(),
+                names(1000), 1024, 3, batch -> {
+                    probes.incrementAndGet();
+                    if (batch.size() > 16) {
+                        entered.countDown();
+                        try {
+                            Assertions.assertTrue(release.await(5, 
TimeUnit.SECONDS));
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                            throw new IllegalStateException(e);
+                        }
+                    }
+                    return Optional.of(partitions(batch));
+                }, batch -> {
+                    throw new AssertionError("cache hit must not issue an HMS 
request");
+                });
+        loader.isBlocked();
+        loader.take(16);
+        ExecutorService reader = Executors.newSingleThreadExecutor();
+        try {
+            Future<CompletableFuture<Void>> helper = 
reader.submit(loader::isBlocked);
+            Assertions.assertTrue(entered.await(5, TimeUnit.SECONDS));
+            scheduler.runNext();
+            Assertions.assertFalse(helper.isDone(), "the metadata worker must 
not duplicate the helper's probe");
+            release.countDown();
+            Assertions.assertNull(helper.get(5, TimeUnit.SECONDS));
+            Assertions.assertEquals(2, probes.get());
+            Assertions.assertTrue(scheduler.tasks.isEmpty());
+        } finally {
+            release.countDown();
+            reader.shutdownNow();
+        }
+    }
+
+    @Test
+    void helpedCacheMissHandsOneQueueSlotToRemoteLoadWithoutWaiting() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        scheduler.capacity = 1;
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, new ConnectorScanContext(),
+                names(1000), 1024, 3,
+                batch -> batch.size() == 16 ? Optional.of(partitions(batch)) : 
Optional.empty(), batch -> {
+                    requests.incrementAndGet();
+                    return partitions(batch);
+                });
+        loader.isBlocked();
+        Assertions.assertEquals(16, loader.take(16).size());
+        CompletableFuture<Void> pending = loader.isBlocked();
+        Assertions.assertFalse(pending.isDone());
+        Assertions.assertEquals(0, requests.get(), "helping a cache miss must 
not perform the RPC");
+        Assertions.assertEquals(1, scheduler.tasks.size());
+        Assertions.assertNull(scheduler.onSpace, "removing the helped cache 
task must admit the remote task");
+        scheduler.runNext();
+        Assertions.assertTrue(pending.isDone());
+        Assertions.assertEquals(1, requests.get());
+    }
+
+    @Test
+    void failureInLargeCachedProbeWakesReaders() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        IllegalStateException failure = new IllegalStateException("cache probe 
failed");
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, new ConnectorScanContext(),
+                names(1000), 1024, 3, batch -> {
+                    if (batch.size() > 16) {
+                        throw failure;
+                    }
+                    return Optional.of(partitions(batch));
+                }, batch -> {
+                    throw new AssertionError("failed cache probe must not 
issue an HMS request");
+                });
+        Assertions.assertNull(loader.isBlocked());
+        Assertions.assertEquals(16, loader.take(16).size());
+        scheduler.runNext();
+        CompletableFuture<Void> pending = loader.isBlocked();
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class, pending::join).getCause());
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class,
+                () -> loader.take(16)).getCause());
+    }
+
+    @Test
+    void cancellingQueuedLargeCachedProbePreventsItsExecution() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        ConnectorScanContext context = new ConnectorScanContext();
+        List<Integer> probes = new ArrayList<>();
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, context,
+                names(1000), 1024, 3, batch -> {
+                    probes.add(batch.size());
+                    return Optional.of(partitions(batch));
+                }, batch -> {
+                    throw new AssertionError("stopped cached scan must not 
issue an HMS request");
+                });
+        loader.isBlocked();
+        loader.take(16);
+        Runnable stale = scheduler.tasks.element();
+        context.cancel();
+        Assertions.assertTrue(loader.isBlocked().isCompletedExceptionally());
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        stale.run();
+        Assertions.assertEquals(List.of(16), probes);
+    }
+
+    @Test
+    void cancellationDuringLargeCacheMissDoesNotStartAnHmsRequest() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        ConnectorScanContext context = new ConnectorScanContext();
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, context,
+                names(1000), 1024, 3, batch -> {
+                    if (batch.size() > 16) {
+                        context.cancel();
+                        return Optional.empty();
+                    }
+                    return Optional.of(partitions(batch));
+                }, batch -> {
+                    requests.incrementAndGet();
+                    return partitions(batch);
+                });
+        loader.isBlocked();
+        loader.take(16);
+        scheduler.runNext();
+        Assertions.assertTrue(loader.isBlocked().isCompletedExceptionally());
+        Assertions.assertEquals(0, requests.get());
+    }
+
+    @Test
+    void maximumRpcLimitDoesNotOverflowAfterTheFirstBatch() {
+        List<Integer> requests = new ArrayList<>();
+        HivePartitionBatchLoader loader = 
loader(ConnectorTaskScheduler.DIRECT, new ConnectorScanContext(),
+                17, Integer.MAX_VALUE, batch -> {
+                    requests.add(batch.size());
+                    return partitions(batch);
+                });
+        Assertions.assertNull(loader.isBlocked());
+        List<HmsPartitionInfo> all = new ArrayList<>(loader.take(16));
+        all.addAll(loader.take(16));
+        Assertions.assertTrue(loader.isFinished());
+        Assertions.assertEquals(17, all.size());
+        Assertions.assertEquals(List.of(16, 1), requests);
+    }
+
+    @Test
+    void configuredRpcLimitAlsoBoundsFirstBatch() {
+        List<Integer> requests = new ArrayList<>();
+        HivePartitionBatchLoader loader = 
loader(ConnectorTaskScheduler.DIRECT, new ConnectorScanContext(),
+                10, 4, batch -> {
+                    requests.add(batch.size());
+                    return partitions(batch);
+                });
+        int count = 0;
+        while (!loader.isFinished()) {
+            Assertions.assertNull(loader.isBlocked());
+            count += loader.take(16).size();
+        }
+        Assertions.assertEquals(10, count);
+        Assertions.assertEquals(List.of(4, 4, 2), requests);
+    }
+
+    @Test
+    void oneReadyListBoundsMetadataAheadOfDirectoryConsumers() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 200, 32,
+                HivePartitionBatchLoaderTest::partitions);
+        loader.isBlocked();
+        scheduler.runNext();
+        loader.take(16);
+        scheduler.runAll();
+        Assertions.assertNull(loader.isBlocked());
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        loader.take(16);
+        Assertions.assertTrue(scheduler.tasks.isEmpty(), "half-consumed batch 
must stop the parent");
+        loader.take(16);
+        Assertions.assertEquals(1, scheduler.tasks.size());
+    }
+
+    @Test
+    void fullyCachedMetadataDoesNotNeedMetadataCapacity() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        scheduler.full = true;
+        HivePartitionBatchLoader loader = new 
HivePartitionBatchLoader(scheduler, new ConnectorScanContext(),
+                names(100), 32, 3, batch -> Optional.of(partitions(batch)), 
batch -> {
+                    throw new AssertionError("cache hit must not load 
remotely");
+                });
+        int count = 0;
+        while (!loader.isFinished()) {
+            Assertions.assertNull(loader.isBlocked());
+            count += loader.take(16).size();
+        }
+        Assertions.assertEquals(100, count);
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        Assertions.assertNull(scheduler.onSpace);
+    }
+
+    @Test
+    void fullSchedulerReturnsImmediatelyAndCancellationRemovesSpaceWaiter() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        scheduler.full = true;
+        ConnectorScanContext context = new ConnectorScanContext();
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = loader(scheduler, context, 100, 32, 
batch -> {
+            requests.incrementAndGet();
+            return partitions(batch);
+        });
+        CompletableFuture<Void> pending = loader.isBlocked();
+        Assertions.assertFalse(pending.isDone());
+        Runnable stale = scheduler.onSpace;
+        context.cancel();
+        Assertions.assertTrue(pending.isCompletedExceptionally());
+        Assertions.assertNull(scheduler.onSpace);
+        scheduler.full = false;
+        stale.run();
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        Assertions.assertEquals(0, requests.get());
+    }
+
+    @Test
+    void admissionResumesAfterSpaceWithoutDuplicateRpc() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        scheduler.full = true;
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 10, 32, batch -> {
+            requests.incrementAndGet();
+            return partitions(batch);
+        });
+        CompletableFuture<Void> pending = loader.isBlocked();
+        scheduler.full = false;
+        scheduler.onSpace.run();
+        scheduler.runNext();
+        Assertions.assertTrue(pending.isDone());
+        Assertions.assertEquals(1, requests.get());
+    }
+
+    @Test
+    void spaceCallbackBeforeAdmissionReturnsCannotEnqueueTwice() {
+        Queue<Runnable> tasks = new ArrayDeque<>();
+        AtomicInteger admissions = new AtomicInteger();
+        List<Runnable> callbacks = new ArrayList<>();
+        ConnectorTaskScheduler scheduler = new ConnectorTaskScheduler() {
+            @Override
+            public boolean tryExecute(Runnable task, Consumer<Throwable> 
onFailure, Runnable onSpace) {
+                callbacks.add(onSpace);
+                if (admissions.incrementAndGet() == 1) {
+                    onSpace.run();
+                    return false;
+                }
+                tasks.add(task);
+                return true;
+            }
+
+            @Override
+            public void removeSpaceListener(Runnable callback) {
+            }
+
+            @Override
+            public void execute(Runnable task) {
+                throw new UnsupportedOperationException();
+            }
+        };
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 10, 32,
+                HivePartitionBatchLoaderTest::partitions);
+        CompletableFuture<Void> pending = loader.isBlocked();
+        callbacks.get(0).run();
+        Assertions.assertEquals(2, admissions.get());
+        Assertions.assertEquals(1, tasks.size());
+        tasks.remove().run();
+        Assertions.assertTrue(pending.isDone());
+        Assertions.assertEquals(10, loader.take(16).size());
+    }
+
+    @Test
+    void closingLastReaderCancelsQueuedParentAndWakesWaiters() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 100, 32, batch -> {
+            requests.incrementAndGet();
+            return partitions(batch);
+        });
+        CompletableFuture<Void> pending = loader.isBlocked();
+        Runnable stale = scheduler.tasks.element();
+        loader.readerClosed();
+        loader.readerClosed();
+        Assertions.assertFalse(pending.isDone());
+        loader.readerClosed();
+        Assertions.assertTrue(pending.isCompletedExceptionally());
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        stale.run();
+        Assertions.assertEquals(0, requests.get());
+    }
+
+    @Test
+    void failedAdmissionWakesReadersAndDoesNotRunAStaleTask() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 100, 32, batch -> {
+            requests.incrementAndGet();
+            return partitions(batch);
+        });
+        CompletableFuture<Void> pending = loader.isBlocked();
+        Runnable stale = scheduler.tasks.element();
+        IllegalStateException failure = new IllegalStateException("metadata 
scheduler closed");
+        scheduler.onFailure.accept(failure);
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class, pending::join).getCause());
+        Assertions.assertTrue(scheduler.tasks.isEmpty());
+        stale.run();
+        Assertions.assertEquals(0, requests.get());
+    }
+
+    @Test
+    void loadingFailureIsVisibleToEveryReader() {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        IllegalStateException failure = new IllegalStateException("HMS 
failed");
+        HivePartitionBatchLoader loader = loader(scheduler, new 
ConnectorScanContext(), 100, 32, batch -> {
+            throw failure;
+        });
+        CompletableFuture<Void> a = loader.isBlocked();
+        CompletableFuture<Void> b = loader.isBlocked();
+        scheduler.runNext();
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class, a::join).getCause());
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class, b::join).getCause());
+        Assertions.assertSame(failure, 
Assertions.assertThrows(CompletionException.class,
+                () -> loader.take(16)).getCause());
+    }
+
+    @Test
+    void cancellationDiscardsUninterruptibleRpcResultAndStopsFurtherCalls() 
throws Exception {
+        QueuedScheduler scheduler = new QueuedScheduler();
+        ConnectorScanContext context = new ConnectorScanContext();
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicInteger requests = new AtomicInteger();
+        HivePartitionBatchLoader loader = loader(scheduler, context, 100, 32, 
batch -> {
+            requests.incrementAndGet();
+            entered.countDown();
+            try {
+                Assertions.assertTrue(release.await(5, TimeUnit.SECONDS));
+            } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+            }
+            return partitions(batch);
+        });
+        CompletableFuture<Void> pending = loader.isBlocked();
+        ExecutorService worker = Executors.newSingleThreadExecutor();
+        try {
+            Future<?> running = worker.submit(scheduler::runNext);
+            Assertions.assertTrue(entered.await(5, TimeUnit.SECONDS));
+            context.cancel();
+            Assertions.assertTrue(pending.isCompletedExceptionally());
+            release.countDown();
+            running.get(5, TimeUnit.SECONDS);
+            Assertions.assertEquals(1, requests.get());
+            Assertions.assertTrue(scheduler.tasks.isEmpty());
+        } finally {
+            release.countDown();
+            worker.shutdownNow();
+        }
+    }
+
+    @Test
+    void droppedPartitionsDoNotRecurseOrPrematurelyFinish() {
+        HivePartitionBatchLoader loader = 
loader(ConnectorTaskScheduler.DIRECT, new ConnectorScanContext(),
+                20000, 1, batch -> Collections.emptyList());
+        Assertions.assertNull(loader.isBlocked());
+        Assertions.assertTrue(loader.isFinished());
+    }
+
+    private static HivePartitionBatchLoader loader(ConnectorTaskScheduler 
scheduler, ConnectorScanContext context,
+            int count, int batchSize, Function<List<String>, 
List<HmsPartitionInfo>> fetch) {
+        return new HivePartitionBatchLoader(scheduler, context, names(count), 
batchSize, 3,
+                batch -> Optional.empty(), fetch);
+    }
+
+    private static List<String> names(int count) {
+        List<String> result = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            result.add("p=" + i);
+        }
+        return result;
+    }
+
+    private static List<HmsPartitionInfo> partitions(List<String> names) {
+        List<HmsPartitionInfo> result = new ArrayList<>();
+        for (String name : names) {
+            result.add(new HmsPartitionInfo(List.of(name.substring(2)), name,
+                    null, null, null, Collections.emptyMap()));
+        }
+        return result;
+    }
+
+    private static final class QueuedScheduler implements 
ConnectorTaskScheduler {
+        private final Queue<Runnable> tasks = new ArrayDeque<>();
+        private boolean full;
+        private int capacity = Integer.MAX_VALUE;
+        private Runnable onSpace;
+        private Consumer<Throwable> onFailure;
+
+        @Override
+        public boolean tryExecute(Runnable task, Consumer<Throwable> 
onFailure, Runnable callback) {
+            if (full || tasks.size() >= capacity) {
+                onSpace = callback;
+                return false;
+            }
+            this.onFailure = onFailure;
+            tasks.add(task);
+            return true;
+        }
+
+        @Override
+        public void removeSpaceListener(Runnable callback) {
+            if (onSpace == callback) {
+                onSpace = null;
+            }
+        }
+
+        @Override
+        public void remove(Runnable task) {
+            if (tasks.remove(task) && !full && tasks.size() < capacity && 
onSpace != null) {
+                Runnable callback = onSpace;
+                onSpace = null;
+                callback.run();
+            }
+        }
+
+        @Override
+        public void execute(Runnable task) {
+            throw new UnsupportedOperationException();
+        }
+
+        private void runAll() {
+            while (!tasks.isEmpty()) {
+                runNext();
+            }
+        }
+
+        private void runNext() {
+            tasks.remove().run();
+        }
+    }
+}
diff --git 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java
 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java
index 61841f38e40..863671d19f2 100644
--- 
a/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java
+++ 
b/fe/fe-connector/fe-connector-hive/src/test/java/org/apache/doris/connector/hive/HiveScanBatchModeTest.java
@@ -44,6 +44,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -53,8 +54,10 @@ import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Queue;
 import java.util.Set;
 import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
@@ -99,6 +102,229 @@ public class HiveScanBatchModeTest {
     private static final String LAZY_SIMPLE_SERDE = 
"org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe";
     private static final String OPEN_CSV_SERDE = 
"org.apache.hadoop.hive.serde2.OpenCSVSerde";
 
+    @Test
+    public void completedMetadataDependencyMustNotLookLikeEndOfInput() throws 
Exception {
+        Queue<Runnable> metadata = new ArrayDeque<>();
+        ConnectorTaskScheduler scheduler = new ConnectorTaskScheduler() {
+            @Override
+            public boolean tryExecute(Runnable task, Consumer<Throwable> 
onFailure, Runnable onSpace) {
+                metadata.add(task);
+                return true;
+            }
+
+            @Override
+            public void removeSpaceListener(Runnable callback) {
+            }
+
+            @Override
+            public void execute(Runnable task) {
+                metadata.add(task);
+            }
+        };
+        FakeHmsClient hms = new FakeHmsClient();
+        CountingLister lister = new CountingLister();
+        List<String> names = new ArrayList<>();
+        for (int i = 0; i < 17; i++) {
+            names.add("year=2024/month=" + i);
+        }
+        ConnectorSplitSource source = provider(hms, 
lister).streamPartitionSources(
+                new FakeSession(Collections.emptyMap(), scheduler),
+                ConnectorScanRequest.builder(parquetHandle(PART_KEYS), 
Collections.emptyList())
+                        .requiredPartitions(names).build(), 16, 
1).orElseThrow().get(0);
+        try {
+            Assertions.assertFalse(source.isBlocked().isDone());
+            metadata.remove().run();
+            Set<String> paths = new HashSet<>();
+            for (int i = 0; i < 16; i++) {
+                Assertions.assertTrue(source.hasNext());
+                
Assertions.assertTrue(paths.add(source.next().getPath().orElseThrow()));
+            }
+            CompletableFuture<Void> pending = source.isBlocked();
+            Assertions.assertNotNull(pending);
+            Assertions.assertFalse(pending.isDone());
+            Assertions.assertFalse(source.hasNext());
+
+            // Complete after the empty read, before the caller checks whether 
it means EOF.
+            metadata.remove().run();
+            Assertions.assertTrue(pending.isDone());
+            Assertions.assertSame(pending, source.isBlocked(), "retain 
readiness until the next read attempt");
+            Assertions.assertTrue(source.hasNext());
+            
Assertions.assertTrue(paths.add(source.next().getPath().orElseThrow()));
+            Assertions.assertFalse(source.hasNext());
+            Assertions.assertNull(source.isBlocked());
+            Assertions.assertEquals(17, paths.size());
+            Assertions.assertEquals(17, lister.totalCalls);
+        } finally {
+            source.close();
+        }
+    }
+
+    @Test
+    public void 
parallelSourcesKeepCapturedPartitionViewAfterSharedCacheChanges() throws 
Exception {
+        FakeHmsClient hms = new FakeHmsClient() {
+            @Override
+            public Optional<HmsPartitionBatchResult> 
getCachedPartitionsWithStats(
+                    String db, String table, List<String> names) {
+                throw new AssertionError("captured partition view must take 
precedence over newer cache data");
+            }
+        };
+        List<String> names = Arrays.asList("year=2024/month=01", 
"year=2024/month=02");
+        List<HmsPartitionInfo> captured = Arrays.asList(
+                new HmsPartitionInfo(Arrays.asList("2024", "01"), names.get(0),
+                        null, null, null, Collections.emptyMap()),
+                new HmsPartitionInfo(Arrays.asList("2024", "02"), names.get(1),
+                        null, null, null, Collections.emptyMap()));
+        HiveTableHandle handle = 
parquetHandle(PART_KEYS).toBuilder().prunedPartitions(captured)
+                .prunedPartitionsByName(Map.of(names.get(0), captured.get(0), 
names.get(1), captured.get(1))).build();
+        CountingLister lister = new CountingLister();
+        HiveScanPlanProvider provider = provider(hms, lister);
+        List<ConnectorSplitSource> sources = 
provider.streamPartitionSources(new FakeSession(),
+                ConnectorScanRequest.builder(handle, 
Collections.emptyList()).requiredPartitions(names).build(),
+                1, 2).get();
+        int count = 0;
+        for (ConnectorSplitSource source : sources) {
+            try {
+                while (source.hasNext()) {
+                    source.next();
+                    count++;
+                }
+            } finally {
+                source.close();
+            }
+        }
+        Assertions.assertEquals(2, count);
+        Assertions.assertEquals(new HashSet<>(names), 
lister.callsPerLocation.keySet());
+        Assertions.assertTrue(hms.requestedPartitionNames.isEmpty());
+    }
+
+    @Test
+    public void metadataProducerContinuesWhileThreeDirectoryReadersRun() 
throws Exception {
+        ExecutorService metadata = Executors.newSingleThreadExecutor();
+        ExecutorService directories = Executors.newFixedThreadPool(3);
+        CountDownLatch secondBatch = new CountDownLatch(1);
+        AtomicInteger rpc = new AtomicInteger();
+        FakeHmsClient hms = new FakeHmsClient() {
+            @Override
+            public List<HmsPartitionInfo> getPartitions(String db, String 
table, List<String> names) {
+                if (rpc.incrementAndGet() == 2) {
+                    secondBatch.countDown();
+                }
+                return super.getPartitions(db, table, names);
+            }
+        };
+        ConnectorTaskScheduler scheduler = new ConnectorTaskScheduler() {
+            @Override
+            public boolean tryExecute(Runnable task, Consumer<Throwable> 
onFailure, Runnable onSpace) {
+                metadata.execute(task);
+                return true;
+            }
+
+            @Override
+            public void removeSpaceListener(Runnable callback) {
+            }
+
+            @Override
+            public void execute(Runnable task) {
+                metadata.execute(task);
+            }
+        };
+        Set<String> paths = java.util.concurrent.ConcurrentHashMap.newKeySet();
+        HiveScanPlanProvider provider = provider(hms, (location, fs) -> {
+            try {
+                Assertions.assertTrue(secondBatch.await(5, TimeUnit.SECONDS),
+                        "parent must fetch the next batch while the first 
child is listing");
+            } catch (InterruptedException e) {
+                throw new RuntimeException(e);
+            }
+            return Collections.singletonList(new HiveFileStatus(location + 
"/data", 10, 1));
+        });
+        List<String> names = new ArrayList<>();
+        for (int i = 0; i < 1000; i++) {
+            names.add("year=2024/month=" + i);
+        }
+        List<ConnectorSplitSource> sources = provider.streamPartitionSources(
+                new FakeSession(Collections.emptyMap(), scheduler),
+                ConnectorScanRequest.builder(parquetHandle(PART_KEYS), 
Collections.emptyList())
+                        .requiredPartitions(names).build(), 16, 3).get();
+        try {
+            List<Future<?>> readers = new ArrayList<>();
+            for (ConnectorSplitSource source : sources) {
+                readers.add(directories.submit(() -> {
+                    try {
+                        while (true) {
+                            java.util.concurrent.CompletableFuture<Void> 
blocked = source.isBlocked();
+                            if (blocked != null) {
+                                blocked.get(5, TimeUnit.SECONDS);
+                            }
+                            if (!source.hasNext()) {
+                                if (source.isBlocked() != null) {
+                                    continue;
+                                }
+                                break;
+                            }
+                            
Assertions.assertTrue(paths.add(source.next().getPath().get()));
+                        }
+                        source.close();
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                }));
+            }
+            for (Future<?> reader : readers) {
+                reader.get(10, TimeUnit.SECONDS);
+            }
+            Assertions.assertEquals(1000, paths.size());
+            Assertions.assertEquals(2, rpc.get());
+        } finally {
+            for (ConnectorSplitSource source : sources) {
+                source.close();
+            }
+            directories.shutdownNow();
+            metadata.shutdownNow();
+        }
+    }
+
+    @Test
+    public void 
parallelDirectorySourcesShareLargeHmsBatchesAndPreserveAllSplits() throws 
Exception {
+        FakeHmsClient hms = new FakeHmsClient();
+        CountingLister lister = new CountingLister();
+        HiveScanPlanProvider provider = provider(hms, lister);
+        List<String> names = new ArrayList<>();
+        for (int i = 0; i < 1000; i++) {
+            names.add("year=2024/month=" + i);
+        }
+        List<ConnectorSplitSource> sources = 
provider.streamPartitionSources(new FakeSession(),
+                ConnectorScanRequest.builder(parquetHandle(PART_KEYS), 
Collections.emptyList())
+                        .requiredPartitions(names).build(), 16, 3).get();
+        Assertions.assertTrue(hms.requestedPartitionNames.isEmpty(), "planning 
must not perform HMS I/O");
+        Set<String> paths = new HashSet<>();
+        try {
+            boolean more;
+            do {
+                more = false;
+                for (ConnectorSplitSource source : sources) {
+                    java.util.concurrent.CompletableFuture<Void> blocked = 
source.isBlocked();
+                    if (blocked != null) {
+                        blocked.get(5, TimeUnit.SECONDS);
+                    }
+                    if (source.hasNext()) {
+                        more = true;
+                        
Assertions.assertTrue(paths.add(source.next().getPath().get()));
+                    }
+                }
+            } while (more);
+        } finally {
+            for (ConnectorSplitSource source : sources) {
+                source.close();
+            }
+        }
+        Assertions.assertEquals(1000, paths.size());
+        Assertions.assertEquals(1000, lister.totalCalls);
+        Assertions.assertEquals(2, hms.requestedPartitionNames.size());
+        Assertions.assertEquals(16, hms.requestedPartitionNames.get(0).size());
+        Assertions.assertEquals(984, 
hms.requestedPartitionNames.get(1).size());
+    }
+
     @Test
     public void millionFilesOnlyCreateTheConsumedSplitPrefix() throws 
Exception {
         AtomicInteger normalizedFiles = new AtomicInteger();
@@ -291,6 +517,49 @@ public class HiveScanBatchModeTest {
         Assertions.assertEquals(Collections.singleton("year=2024/month=02"), 
lister.callsPerLocation.keySet());
     }
 
+    @Test
+    public void streamingCachedPartitionsDoNotSubmitMetadataTasks() throws 
Exception {
+        ConnectorTaskScheduler scheduler = new ConnectorTaskScheduler() {
+            @Override
+            public boolean tryExecute(Runnable task, Consumer<Throwable> 
onFailure, Runnable onSpace) {
+                Assertions.fail("A fully cached batch must not enter the 
metadata executor");
+                return false;
+            }
+
+            @Override
+            public void removeSpaceListener(Runnable onSpace) {
+            }
+
+            @Override
+            public void execute(Runnable task) {
+                Assertions.fail("A fully cached batch must not enter the 
metadata executor");
+            }
+        };
+        FakeHmsClient hms = new FakeHmsClient() {
+            @Override
+            public Optional<HmsPartitionBatchResult> 
getCachedPartitionsWithStats(
+                    String dbName, String tableName, List<String> names) {
+                return Optional.of(new 
HmsPartitionBatchResult(Collections.singletonList(part("cached")),
+                        
HmsPartitionBatchStats.builder().requestedItems(1).build()));
+            }
+
+            @Override
+            public List<HmsPartitionInfo> getPartitions(String dbName, String 
tableName, List<String> names) {
+                Assertions.fail("A fully cached batch must not fetch partition 
objects");
+                return Collections.emptyList();
+            }
+        };
+        HiveScanPlanProvider provider = provider(hms, new CountingLister());
+        ConnectorScanRequest request = 
ConnectorScanRequest.builder(parquetHandle(PART_KEYS),
+                
Collections.emptyList()).requiredPartitions(Collections.singletonList("year=2024/month=01")).build();
+        try (ConnectorSplitSource source = provider.streamSplits(new 
FakeSession(Collections.emptyMap(), scheduler),
+                request)) {
+            Assertions.assertTrue(source.hasNext());
+            Assertions.assertEquals("cached/000000_0", 
source.next().getPath().get());
+            Assertions.assertFalse(source.hasNext());
+        }
+    }
+
     @Test
     public void streamingReloadsWholeBatchWhenCapturedMapIsIncomplete() throws 
Exception {
         List<List<String>> requested = new ArrayList<>();
diff --git 
a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java
 
b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java
index 9ba86488eb2..19bc8f2b258 100644
--- 
a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java
+++ 
b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/CachingHmsClient.java
@@ -34,6 +34,7 @@ import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
@@ -271,6 +272,24 @@ public class CachingHmsClient implements HmsClient {
         return getPartitionsWithStats(dbName, tableName, partNames, true);
     }
 
+    @Override
+    public Optional<HmsPartitionBatchResult> getCachedPartitionsWithStats(
+            String dbName, String tableName, List<String> partNames) {
+        long start = System.nanoTime();
+        HmsPartitionRequest request = new HmsPartitionRequest(dbName, 
tableName, partNames);
+        List<HmsPartitionInfo> cached = new ArrayList<>(partNames.size());
+        for (HmsPartitionIdentity.ParsedPartitionName partition : 
request.getPartitions()) {
+            HmsPartitionInfo value = partitionsCache.getIfPresent(
+                    new PartitionKey(dbName, tableName, 
partition.getValues()));
+            if (value == null) {
+                return Optional.empty();
+            }
+            cached.add(value);
+        }
+        return Optional.of(new HmsPartitionBatchResult(cached, 
HmsPartitionBatchStats.builder()
+                
.requestedItems(partNames.size()).logicalElapsedNanos(System.nanoTime() - 
start).build()));
+    }
+
     @Override
     public HmsPartitionBatchResult getExistingPartitionsWithStats(String 
dbName, String tableName,
             List<String> partNames, ConnectorScanContext scanContext) {
diff --git 
a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java
 
b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java
index 7bd76166771..493cf5ab67e 100644
--- 
a/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java
+++ 
b/fe/fe-connector/fe-connector-hms/src/main/java/org/apache/doris/connector/hms/HmsClient.java
@@ -23,6 +23,7 @@ import java.io.Closeable;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.function.Function;
 
 /**
@@ -217,6 +218,16 @@ public interface HmsClient extends Closeable {
         return new HmsPartitionBatchResult(partitions, stats);
     }
 
+    /**
+     * Returns all requested objects, in request order, only if they are 
already cached. Never performs remote
+     * I/O or waits for an in-flight load. An empty Optional means at least 
one object is unavailable locally,
+     * including for clients without a cache. Used to keep warm lookups off 
the metadata executor.
+     */
+    default Optional<HmsPartitionBatchResult> getCachedPartitionsWithStats(
+            String dbName, String tableName, List<String> partNames) {
+        return Optional.empty();
+    }
+
     /**
      * Returns the requested partitions that still exist together with 
transport-invocation batching statistics.
      * Implementations that do not expose transport details retain the 
omission-tolerant result and report a
diff --git 
a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java
 
b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java
index 1816b19ca0f..1ead83538a0 100644
--- 
a/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java
+++ 
b/fe/fe-connector/fe-connector-hms/src/test/java/org/apache/doris/connector/hms/CachingHmsClientTest.java
@@ -342,6 +342,72 @@ public class CachingHmsClientTest {
 
     // ---- getPartitions ----
 
+    @Test
+    public void cachedPartitionProbeDoesNotWaitForAnInFlightLoad() throws 
Exception {
+        CountDownLatch entered = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        RecordingHmsClient delegate = new RecordingHmsClient() {
+            @Override
+            public List<HmsPartitionInfo> getPartitions(String db, String 
table, List<String> names) {
+                entered.countDown();
+                awaitLatch(release);
+                return super.getPartitions(db, table, names);
+            }
+        };
+        CachingHmsClient cache = cache(delegate, Collections.emptyMap());
+        ExecutorService workers = Executors.newFixedThreadPool(2);
+        try {
+            List<String> names = Collections.singletonList("p=1");
+            Future<List<HmsPartitionInfo>> loading = workers.submit(() -> 
cache.getPartitions("db", "t", names));
+            Assertions.assertTrue(entered.await(5, TimeUnit.SECONDS));
+            Future<Boolean> probe = workers.submit(() -> 
cache.getCachedPartitionsWithStats("db", "t", names)
+                    .isPresent());
+            Assertions.assertFalse(probe.get(5, TimeUnit.SECONDS));
+            Assertions.assertFalse(loading.isDone());
+            release.countDown();
+            HmsPartitionInfo loaded = loading.get(5, TimeUnit.SECONDS).get(0);
+            Assertions.assertSame(loaded, 
cache.getCachedPartitionsWithStats("db", "t", names)
+                    .orElseThrow().getPartitions().get(0));
+            Assertions.assertEquals(1, delegate.getPartitionsCalls);
+        } finally {
+            release.countDown();
+            workers.shutdownNow();
+            cache.close();
+        }
+    }
+
+    @Test
+    public void cachedPartitionProbeNeverLoadsAndRespectsInvalidation() {
+        RecordingHmsClient delegate = new RecordingHmsClient();
+        CatalogMetaCache owner = CatalogMetaCache.unmanaged();
+        CachingHmsClient cache = new CachingHmsClient(owner, delegate, 
Collections.emptyMap());
+        Assertions.assertFalse(cache.getCachedPartitionsWithStats("db", "t", 
Arrays.asList("p=1", "p=2"))
+                .isPresent());
+        Assertions.assertEquals(0, delegate.getPartitionsCalls);
+        List<HmsPartitionInfo> loaded = cache.getPartitions("db", "t", 
Arrays.asList("p=1", "p=2"));
+        HmsPartitionBatchResult hit = cache.getCachedPartitionsWithStats("db", 
"t", Arrays.asList("p=2", "p=1"))
+                .orElseThrow(AssertionError::new);
+        Assertions.assertSame(loaded.get(1), hit.getPartitions().get(0));
+        Assertions.assertSame(loaded.get(0), hit.getPartitions().get(1));
+        Assertions.assertEquals(2, hit.getStats().getRequestedItems());
+        Assertions.assertEquals(0, hit.getStats().getTransportInvocations());
+        Assertions.assertFalse(cache.getCachedPartitionsWithStats("db", "t", 
Arrays.asList("p=1", "p=3"))
+                .isPresent());
+        Assertions.assertEquals(1, delegate.getPartitionsCalls);
+        owner.invalidateTable("db", "t");
+        Assertions.assertFalse(cache.getCachedPartitionsWithStats("db", "t", 
Collections.singletonList("p=1"))
+                .isPresent());
+        Assertions.assertEquals(1, delegate.getPartitionsCalls);
+    }
+
+    @Test
+    public void nonCachingClientProbeDoesNotFetchPartitions() {
+        RecordingHmsClient raw = new RecordingHmsClient();
+        Assertions.assertFalse(raw.getCachedPartitionsWithStats("db", "t", 
Collections.singletonList("p=1"))
+                .isPresent());
+        Assertions.assertEquals(0, raw.getPartitionsCalls);
+    }
+
     @Test
     public void getPartitionsSharesPerPartitionEntriesAcrossRequests() {
         RecordingHmsClient delegate = new RecordingHmsClient();
diff --git 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorScanPlanProvider.java
index a4965b3db2c..e64d7089c76 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorScanPlanProvider.java
@@ -232,6 +232,19 @@ public interface ConnectorScanPlanProvider {
         return 1024;
     }
 
+    /**
+     * Optionally opens cooperating sources for the complete pruned partition 
set in {@code request}.
+     * A connector can batch metadata independently of directory work and 
share the results between
+     * sources. No remote I/O may run here. Each source has one serial reader, 
may report an asynchronous
+     * dependency through {@link ConnectorSplitSource#isBlocked()}, and must 
release its shared resources
+     * when all sources close or the scan is cancelled. The engine owns 
admission and backpressure.
+     * Empty means to use the engine's ordinary partition-batch sources.
+     */
+    default Optional<List<ConnectorSplitSource>> 
streamPartitionSources(ConnectorSession session,
+            ConnectorScanRequest request, int partitionsPerTask, int 
maxSources) {
+        return Optional.empty();
+    }
+
     /**
      * Whether this connector's scan ranges carry meaningful byte lengths
      * ({@link ConnectorScanRange#getLength()}) so the engine can apply {@code 
TABLESAMPLE} by
diff --git 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorSplitSource.java
 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorSplitSource.java
index 0cb35f10a9d..4ee96a848f7 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorSplitSource.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/main/java/org/apache/doris/connector/spi/scan/ConnectorSplitSource.java
@@ -18,6 +18,7 @@
 package org.apache.doris.connector.spi.scan;
 
 import java.io.Closeable;
+import java.util.concurrent.CompletableFuture;
 
 /**
  * A lazy, closeable source of {@link ConnectorScanRange}s for streaming 
(batched) split generation,
@@ -37,6 +38,19 @@ import java.io.Closeable;
  */
 public interface ConnectorSplitSource extends Closeable {
 
+    /**
+     * An asynchronous metadata dependency for cooperating sources opened by
+     * {@link ConnectorScanPlanProvider#streamPartitionSources}, or null when 
immediately runnable
+     * (including end of input). Ordinary {@code streamSplits} sources remain 
synchronous.
+     * Readers must observe exceptional completion and retry after normal 
completion. While blocked,
+     * {@code hasNext()} may return false without indicating end of input; in 
that case this method must
+     * retain the dependency, even after completion, until the next {@code 
hasNext()} attempt. Completion
+     * callbacks must only schedule work; they must not advance or close this 
source. The default incurs no allocation.
+     */
+    default CompletableFuture<Void> isBlocked() {
+        return null;
+    }
+
     /**
      * Nonblocking, idempotent hint that a backend has requested a batch 
beyond its first one.
      * May run concurrently on an engine RPC thread. A connector may use this 
to expand speculative
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
index a37bb7aa064..73b1fd28d14 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/java/org/apache/doris/connector/spi/ConnectorPluginSurfaceTest.java
@@ -89,7 +89,7 @@ public class ConnectorPluginSurfaceTest {
             version.load(in);
         }
         // Major 29 adds typed table-absence errors after the upstream API 28 
changes.
-        Assertions.assertEquals("29.0", version.getProperty("api.version"));
+        Assertions.assertEquals("30.0", version.getProperty("api.version"));
     }
 
     /** Root entry points plus provider/handle types returned to connector 
plugins. */
diff --git 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
index 440e7ddafdf..fdd2b94e68b 100644
--- 
a/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
+++ 
b/fe/fe-connector/fe-connector-spi/src/test/resources/connector-plugin-surface.txt
@@ -219,6 +219,7 @@ 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#planScanForPartiti
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#populateScanLevelParams(org.apache.doris.thrift.TFileScanRangeParams,java.util.Map):void
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#releaseReadTransaction(java.lang.String):void
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#scannedPartitionCount(java.util.List):java.util.OptionalLong
+org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#streamPartitionSources(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.scan.ConnectorScanRequest,int,int):java.util.Optional
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#streamSplits(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.scan.ConnectorScanRequest):org.apache.doris.connector.spi.scan.ConnectorSplitSource
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#streamingSplitEstimate(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle,java.util.Optional,boolean):long
 
org.apache.doris.connector.spi.scan.ConnectorScanPlanProvider#supportsBatchScan(org.apache.doris.connector.spi.ConnectorSession,org.apache.doris.connector.spi.handle.ConnectorTableHandle):boolean
@@ -272,6 +273,7 @@ 
org.apache.doris.connector.spi.scan.ConnectorScanRequest$Builder#requiredPartiti
 
org.apache.doris.connector.spi.scan.ConnectorScanRequest$Builder#scanContext(org.apache.doris.connector.spi.scan.ConnectorScanContext):org.apache.doris.connector.spi.scan.ConnectorScanRequest$Builder
 org.apache.doris.connector.spi.scan.ConnectorSplitSource#close():void
 org.apache.doris.connector.spi.scan.ConnectorSplitSource#hasNext():boolean
+org.apache.doris.connector.spi.scan.ConnectorSplitSource#isBlocked():java.util.concurrent.CompletableFuture
 
org.apache.doris.connector.spi.scan.ConnectorSplitSource#next():org.apache.doris.connector.spi.scan.ConnectorScanRange
 
org.apache.doris.connector.spi.scan.ConnectorSplitSource#onMoreSplitsRequested():void
 
org.apache.doris.connector.spi.scan.ScanNodePropertyKeys#field:FILE_FORMAT_TYPE:java.lang.String=file_format_type
diff --git a/fe/fe-connector/pom.xml b/fe/fe-connector/pom.xml
index a5555398e7e..636bc50720a 100644
--- a/fe/fe-connector/pom.xml
+++ b/fe/fe-connector/pom.xml
@@ -55,7 +55,7 @@ under the License.
           of the latter two means bumping this property as well (and 
fe-extension-spi means bumping
           all five families).
         -->
-        <connector.plugin.api.version>29.0</connector.plugin.api.version>
+        <connector.plugin.api.version>30.0</connector.plugin.api.version>
     </properties>
 
     <modules>
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java
index 8f9a6161815..84fa22d37bd 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FederationBackendPolicy.java
@@ -267,6 +267,25 @@ public class FederationBackendPolicy {
         ListMultimap<Backend, Split> assignment = ArrayListMultimap.create();
 
         Collections.shuffle(splits, new Random(FIXED_SHUFFLE_SEED));
+        if (backends.size() == 1 && 
splits.stream().allMatch(Split::isRemotelyAccessible)) {
+            // Every remotely readable split has the same owner. Avoid 
per-split hashing,
+            // candidate allocation and weight-map updates under the batch 
assignment lock.
+            // Local-only splits still need the locality checks in the general 
path below.
+            Backend backend = backends.get(0);
+            boolean alternativesUsed = !isSpreadEnabled();
+            long weight = 0;
+            for (Split split : splits) {
+                weight = Math.addExact(weight, 
split.getSplitWeight().getRawValue());
+                if (alternativesUsed) {
+                    split.setAlternativeHosts(Collections.emptyList());
+                }
+            }
+            assignment.putAll(backend, splits);
+            if (!splits.isEmpty()) {
+                addAssignedWeight(backend, weight);
+            }
+            return assignment;
+        }
         List<Split> remainingSplits;
         ResettableRandomizedIterator<Backend> randomCandidates = new 
ResettableRandomizedIterator<>(backends);
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
index 4900b57a8b5..2ee7c5a4e8e 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
@@ -2377,6 +2377,7 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
         });
 
         // Admission does no connector I/O and remains owned by the planning 
thread.
+        int startedSources = 0;
         try {
             splitAssignment.startEnumeration();
             Executor scheduleExecutor = 
connectorSession.getTaskScheduler(ConnectorTaskScheduler.Stage.SPLIT);
@@ -2384,6 +2385,7 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
                 if (batchException.get() != null || splitAssignment.isStop()) {
                     break;
                 }
+                startedSources++;
                 profileFinalizer.taskSubmitted();
                 new SplitProducer(splitAssignment, scheduleExecutor, source, 
failure -> {
                     try {
@@ -2399,6 +2401,13 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
         } catch (Exception e) {
             publishBatchFailure(batchException, splitAssignment, new 
UserException(e.getMessage(), e));
         } finally {
+            for (int i = startedSources; i < sources.size(); i++) {
+                try {
+                    sources.get(i).close();
+                } catch (Exception e) {
+                    publishBatchFailure(batchException, splitAssignment, new 
UserException(e.getMessage(), e));
+                }
+            }
             profileFinalizer.closeDispatch();
         }
     }
@@ -2412,6 +2421,14 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
         if (producerLimit > 0) {
             sourceCount = Math.min(sourceCount, producerLimit);
         }
+        int maxSources = sourceCount;
+        Optional<List<ConnectorSplitSource>> cooperating = 
onPluginClassLoader(scanProvider,
+                () -> scanProvider.streamPartitionSources(connectorSession,
+                        request.withRequiredPartitions(partitions), 
partitionsPerBatch, maxSources));
+        if (cooperating.isPresent()) {
+            return cooperating.get().stream().map(source -> 
cooperatingBatchSource(scanProvider, source))
+                    .collect(Collectors.toList());
+        }
         List<SplitProducer.BatchSource> sources = new ArrayList<>(sourceCount);
         // Bound active partition batches even when full queues make producers 
yield their workers.
         // Hive streams within each batch; the SPI adapter retains one list 
for older implementations.
@@ -2468,6 +2485,60 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
         return sources;
     }
 
+    private SplitProducer.BatchSource 
cooperatingBatchSource(ConnectorScanPlanProvider scanProvider,
+            ConnectorSplitSource source) {
+        return new SplitProducer.BatchSource() {
+            private final PluginDrivenSplit.Factory splitFactory = new 
PluginDrivenSplit.Factory();
+            private CompletableFuture<Void> blocked;
+
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return blocked;
+            }
+
+            @Override
+            public List<Split> getNextBatch() {
+                return readMetadata(() -> onPluginClassLoader(scanProvider, () 
-> {
+                    blocked = null;
+                    List<Split> splits = new 
ArrayList<>(splitAssignment.getSplitBatchSize());
+                    while (splits.size() < splitAssignment.getSplitBatchSize()
+                            && splitAssignment.needMoreSplit()) {
+                        CompletableFuture<Void> dependency = 
source.isBlocked();
+                        if (dependency != null) {
+                            if (!dependency.isDone()) {
+                                blocked = dependency;
+                                break;
+                            }
+                            dependency.join();
+                        }
+                        if (source.hasNext()) {
+                            splits.add(toSplit(source.next(), splitFactory));
+                            continue;
+                        }
+                        blocked = source.isBlocked();
+                        if (blocked == null || !blocked.isDone()) {
+                            break;
+                        }
+                        blocked.join();
+                    }
+                    return splits;
+                }));
+            }
+
+            @Override
+            public void close() {
+                onPluginClassLoader(scanProvider, () -> {
+                    try {
+                        source.close();
+                    } catch (IOException e) {
+                        throw new UncheckedIOException(e);
+                    }
+                    return null;
+                });
+            }
+        };
+    }
+
     private SplitProducer.BatchSource 
createStreamingBatchSource(ConnectorScanPlanProvider scanProvider,
             ConnectorScanRequest request, SummaryProfile summaryProfile) {
         RuntimeProfile generationProfile = new RuntimeProfile("Split 
Generation " + getId());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitProducer.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitProducer.java
index a85a9f63557..8bc279ea801 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitProducer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/split/SplitProducer.java
@@ -33,6 +33,7 @@ import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -55,6 +56,10 @@ public class SplitProducer implements Runnable, Closeable {
     public interface BatchSource extends Closeable {
         List<Split> getNextBatch() throws Exception;
 
+        default CompletableFuture<Void> isBlocked() {
+            return null;
+        }
+
         @Override
         default void close() throws IOException {
         }
@@ -81,6 +86,7 @@ public class SplitProducer implements Runnable, Closeable {
     private Backend pendingBackend;
     private List<TScanRangeLocations> pendingRanges;
     private int pendingRangeOffset;
+    private CompletableFuture<Void> waitingForSource;
 
     public SplitProducer(SplitAssignment assignment, Executor executor, 
BatchSource source,
             Consumer<Exception> completion) {
@@ -197,8 +203,18 @@ public class SplitProducer implements Runnable, Closeable {
                     break;
                 }
                 if (pendingRanges == null && !pending.hasNext()) {
+                    if (awaitSource()) {
+                        more = false;
+                        break;
+                    }
                     List<Split> batch = source.getNextBatch();
                     if (batch.isEmpty()) {
+                        // Completion may race the empty read. A dependency, 
even one that just
+                        // completed, means retry rather than EOF. Never 
occupy a worker waiting.
+                        if (source.isBlocked() != null) {
+                            more = !awaitSource();
+                            break;
+                        }
                         exhausted = true;
                         break;
                     }
@@ -252,6 +268,22 @@ public class SplitProducer implements Runnable, Closeable {
         return false;
     }
 
+    private boolean awaitSource() {
+        CompletableFuture<Void> dependency = source.isBlocked();
+        if (dependency == null) {
+            return false;
+        }
+        if (dependency.isDone()) {
+            dependency.join();
+            return false;
+        }
+        if (waitingForSource != dependency) {
+            waitingForSource = dependency;
+            dependency.whenComplete((unused, error) -> schedule());
+        }
+        return true;
+    }
+
     private UserException publishFailure(Throwable failure) {
         if (failure == null || cancelled || assignment.isStop()) {
             return null;
@@ -269,6 +301,7 @@ public class SplitProducer implements Runnable, Closeable {
         pending = Collections.emptyIterator();
         pendingRanges = null;
         pendingBackend = null;
+        waitingForSource = null;
         try {
             source.close();
         } catch (Throwable e) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionBatchTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionBatchTest.java
index a14a3fe76e6..4f4fa763659 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionBatchTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodePartitionBatchTest.java
@@ -56,12 +56,75 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Queue;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 class PluginDrivenScanNodePartitionBatchTest {
+    @Test
+    void cooperatingSourcesYieldWhileMetadataIsUnavailable() throws Exception {
+        Fixture fixture = new Fixture(1, Collections.emptySet());
+        ConnectorScanRequest request = ConnectorScanRequest.builder(new 
ConnectorTableHandle() {
+        }, Collections.emptyList()).build();
+        Iterator<ConnectorScanRange> ranges = 
fixture.provider.planScanForPartitionBatch(
+                null, request, Collections.singletonList("0")).iterator();
+        AtomicBoolean loading = new AtomicBoolean(true);
+        CompletableFuture<Void> ready = new CompletableFuture<>();
+        AtomicInteger closed = new AtomicInteger();
+        ConnectorSplitSource connectorSource = new ConnectorSplitSource() {
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return loading.get() ? ready : null;
+            }
+
+            @Override
+            public boolean hasNext() {
+                return !loading.get() && ranges.hasNext();
+            }
+
+            @Override
+            public ConnectorScanRange next() {
+                return ranges.next();
+            }
+
+            @Override
+            public void close() {
+                closed.incrementAndGet();
+            }
+        };
+        AtomicReference<ConnectorScanRequest> captured = new 
AtomicReference<>();
+        Mockito.doAnswer(call -> {
+            captured.set(call.getArgument(1));
+            Assertions.assertEquals(16, call.<Integer>getArgument(2));
+            Assertions.assertEquals(3, call.<Integer>getArgument(3));
+            return Optional.of(Collections.singletonList(connectorSource));
+        }).when(fixture.provider).streamPartitionSources(Mockito.any(), 
Mockito.any(), Mockito.anyInt(),
+                Mockito.anyInt());
+        List<SplitProducer.BatchSource> sources = fixture.sources(1000, 16, 3);
+        Assertions.assertEquals(1, sources.size());
+        Assertions.assertEquals(1000, 
captured.get().getRequiredPartitions().size());
+        try (SplitProducer.BatchSource source = sources.get(0)) {
+            Assertions.assertTrue(source.getNextBatch().isEmpty());
+            Assertions.assertSame(ready, source.isBlocked());
+            Assertions.assertFalse(ready.isDone());
+            loading.set(false);
+            ready.complete(null);
+            for (long identity = 0; identity < 3; identity++) {
+                Assertions.assertEquals(identity, 
source.getNextBatch().get(0).getStart());
+                Assertions.assertNull(source.isBlocked());
+            }
+            Assertions.assertTrue(source.getNextBatch().isEmpty());
+            Assertions.assertNull(source.isBlocked());
+        }
+        Assertions.assertEquals(1, closed.get());
+        Mockito.verify(fixture.provider, 
Mockito.never()).streamSplits(Mockito.any(), Mockito.any());
+    }
+
     @ParameterizedTest
     @ValueSource(ints = {1, 1024})
     void batchesRetainConfiguredSizeAcrossPartitions(int partitionsPerBatch) 
throws Exception {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitProducerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitProducerTest.java
index 2e847e8a05a..87a18e4605d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitProducerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/split/SplitProducerTest.java
@@ -49,6 +49,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Queue;
 import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
@@ -94,6 +95,119 @@ class SplitProducerTest {
         };
     }
 
+    @Test
+    void producingThenWaitingDoesNotKeepResubmitting() throws Exception {
+        Fixture fixture = new Fixture(1, 3);
+        ManualExecutor executor = new ManualExecutor();
+        CompletableFuture<Void> dependency = new CompletableFuture<>();
+        RecordingSource source = new RecordingSource(1, 0, 2) {
+            private boolean first = true;
+            private boolean paused;
+
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return paused ? dependency : null;
+            }
+
+            @Override
+            public List<Split> getNextBatch() throws IOException {
+                paused = first;
+                first = false;
+                return super.getNextBatch();
+            }
+        };
+        fixture.start(executor, source, () -> 0L);
+        executor.runUntilIdle();
+        Assertions.assertEquals(1, 
fixture.assignment.getAssignedSplits(fixture.backend).size());
+        Assertions.assertEquals(0, fixture.completions.get());
+        dependency.complete(null);
+        executor.runUntilIdle();
+        Assertions.assertEquals(Arrays.asList(0L, 2L), 
identities(fixture.source().getNextBatch(2)));
+        Assertions.assertEquals(1, fixture.completions.get());
+    }
+
+    @Test
+    void asynchronousMetadataWaitReleasesWorkerAndResumesOnCompletion() throws 
Exception {
+        Fixture fixture = new Fixture(1, 3);
+        ManualExecutor executor = new ManualExecutor();
+        CompletableFuture<Void> dependency = new CompletableFuture<>();
+        RecordingSource source = new RecordingSource(1, 0) {
+            private boolean awaiting = true;
+
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return awaiting ? dependency : null;
+            }
+
+            @Override
+            public List<Split> getNextBatch() throws IOException {
+                awaiting = false;
+                return super.getNextBatch();
+            }
+        };
+        fixture.start(executor, source);
+        executor.runUntilIdle();
+        Assertions.assertEquals(0, fixture.completions.get());
+        
Assertions.assertTrue(fixture.assignment.getAssignedSplits(fixture.backend).isEmpty());
+        dependency.complete(null);
+        executor.runUntilIdle();
+        Assertions.assertEquals(Collections.singletonList(0L), 
identities(fixture.source().getNextBatch(1)));
+        Assertions.assertEquals(1, fixture.completions.get());
+    }
+
+    @Test
+    void metadataCompletionRacingEmptyReadDoesNotBecomeEndOfInput() throws 
Exception {
+        Fixture fixture = new Fixture(1, 3);
+        ManualExecutor executor = new ManualExecutor();
+        RecordingSource source = new RecordingSource(1, 0) {
+            private final CompletableFuture<Void> dependency = new 
CompletableFuture<>();
+            private CompletableFuture<Void> blocked;
+            private boolean first = true;
+
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return blocked;
+            }
+
+            @Override
+            public List<Split> getNextBatch() throws IOException {
+                if (first) {
+                    first = false;
+                    blocked = dependency;
+                    dependency.complete(null);
+                    return Collections.emptyList();
+                }
+                blocked = null;
+                return super.getNextBatch();
+            }
+        };
+        fixture.start(executor, source);
+        executor.runUntilIdle();
+        Assertions.assertEquals(Collections.singletonList(0L), 
identities(fixture.source().getNextBatch(1)));
+        Assertions.assertEquals(1, fixture.completions.get());
+    }
+
+    @Test
+    void metadataFailureWakesProducerAndReachesConsumer() throws Exception {
+        Fixture fixture = new Fixture(1, 3);
+        ManualExecutor executor = new ManualExecutor();
+        CompletableFuture<Void> dependency = new CompletableFuture<>();
+        RecordingSource source = new RecordingSource(1, 0) {
+            @Override
+            public CompletableFuture<Void> isBlocked() {
+                return dependency;
+            }
+        };
+        fixture.start(executor, source);
+        executor.runUntilIdle();
+        dependency.completeExceptionally(new IllegalStateException("metadata 
failed"));
+        executor.runUntilIdle();
+        Assertions.assertTrue(Assertions.assertThrows(UserException.class,
+                () -> 
fixture.source().getNextBatch(1)).getMessage().contains("metadata failed"));
+        Assertions.assertEquals(1, source.closeCalls.get());
+        Assertions.assertEquals(1, fixture.completions.get());
+    }
+
     @Test
     void producersFillConfiguredBufferBeforeAnyBackendFetch() throws Exception 
{
         Fixture fixture = new Fixture(1, 3);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java
index 6532d28bde4..ba19b23aa65 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/planner/FederationBackendPolicyTest.java
@@ -38,6 +38,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
@@ -54,6 +55,7 @@ import java.util.Objects;
 import java.util.Random;
 import java.util.Set;
 import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
 
 public class FederationBackendPolicyTest {
     private Env env = Mockito.mock(Env.class);
@@ -71,6 +73,106 @@ public class FederationBackendPolicyTest {
         mockedEnvStatic.close();
     }
 
+    @ParameterizedTest
+    @CsvSource({"ROUND_ROBIN, 1, false", "RANDOM, 1, false", 
"CONSISTENT_HASHING, 1, false",
+            "CONSISTENT_HASHING, 0, true", "CONSISTENT_HASHING, 3, true", 
"RANDOM, 0, true"})
+    public void 
testSingleBackendRemoteBatchesSkipHashing(NodeSelectionStrategy strategy,
+            int candidates, boolean spread) throws UserException {
+        Backend backend = new Backend(13001L, "192.0.2.1", 9050);
+        SystemInfoService service = new SystemInfoService();
+        backend.setAlive(true);
+        service.addBackend(backend);
+        mockedEnvStatic.when(Env::getCurrentSystemInfo).thenReturn(service);
+        Mockito.when(env.getComputeGroupMgr()).thenReturn(new 
ComputeGroupMgr(service));
+        FederationBackendPolicy policy = new FederationBackendPolicy(strategy, 
candidates);
+        policy.init();
+        AtomicInteger identityReads = new AtomicInteger();
+        long expectedWeight = 0;
+        for (int size : List.of(0, 1, 8, 128, 3)) {
+            List<Split> splits = new ArrayList<>();
+            for (int i = 0; i < size; i++) {
+                long weight = i + 1;
+                FileSplit split = new 
FileSplit(LocationPath.of("s3://bucket/part-" + i),
+                        0, 1, 1, 0, new String[]{"another-host"}, 
Collections.emptyList()) {
+                    @Override
+                    public String getSplitIdentity() {
+                        identityReads.incrementAndGet();
+                        return getPathString();
+                    }
+
+                    @Override
+                    public SplitWeight getSplitWeight() {
+                        return SplitWeight.fromRawValue(weight);
+                    }
+                };
+                split.setAlternativeHosts(List.of("previous-alternative"));
+                splits.add(split);
+                expectedWeight += weight;
+            }
+            Multimap<Backend, Split> assigned = 
policy.computeScanRangeAssignment(splits);
+            Assertions.assertEquals(splits, new 
ArrayList<>(assigned.get(backend)));
+            Assertions.assertEquals(size, assigned.size());
+            Assertions.assertEquals(expectedWeight, 
policy.getAssignedWeightPerBackend().get(backend));
+            for (Split split : splits) {
+                Assertions.assertEquals(spread ? 
List.of("previous-alternative") : Collections.emptyList(),
+                        split.getAlternativeHosts());
+            }
+        }
+        Assertions.assertEquals(0, identityReads.get(), "One eligible backend 
does not need split hashing");
+    }
+
+    @Test
+    public void testSingleBackendPreservesLocalOnlyRestrictions() throws 
UserException {
+        Backend backend = new Backend(13002L, "192.0.2.2", 9050);
+        SystemInfoService service = new SystemInfoService();
+        backend.setAlive(true);
+        service.addBackend(backend);
+        mockedEnvStatic.when(Env::getCurrentSystemInfo).thenReturn(service);
+        Mockito.when(env.getComputeGroupMgr()).thenReturn(new 
ComputeGroupMgr(service));
+        FederationBackendPolicy policy = new FederationBackendPolicy();
+        policy.init();
+        FileSplit local = new FileSplit(LocationPath.of("file:///tmp/local"),
+                0, 1, 1, 0, new String[]{backend.getHost()}, 
Collections.emptyList()) {
+            @Override
+            public boolean isRemotelyAccessible() {
+                return false;
+            }
+        };
+        FileSplit remote = new FileSplit(LocationPath.of("s3://bucket/remote"),
+                0, 1, 1, 0, new String[]{"another-host"}, 
Collections.emptyList());
+        Multimap<Backend, Split> assigned = policy.computeScanRangeAssignment(
+                new ArrayList<>(List.of(local, remote)));
+        Assertions.assertEquals(2, assigned.get(backend).size());
+        Assertions.assertEquals(2 * SplitWeight.standard().getRawValue(),
+                policy.getAssignedWeightPerBackend().get(backend));
+        local.setHosts(new String[]{"another-host"});
+        Assertions.assertThrows(UserException.class,
+                () -> policy.computeScanRangeAssignment(new 
ArrayList<>(List.of(local))));
+    }
+
+    @Test
+    public void testSingleBackendRejectsWeightOverflow() throws UserException {
+        Backend backend = new Backend(13003L, "192.0.2.3", 9050);
+        SystemInfoService service = new SystemInfoService();
+        backend.setAlive(true);
+        service.addBackend(backend);
+        mockedEnvStatic.when(Env::getCurrentSystemInfo).thenReturn(service);
+        Mockito.when(env.getComputeGroupMgr()).thenReturn(new 
ComputeGroupMgr(service));
+        FederationBackendPolicy policy = new FederationBackendPolicy();
+        policy.init();
+        FileSplit heavy = new FileSplit(LocationPath.of("s3://bucket/heavy"),
+                0, 1, 1, 0, null, Collections.emptyList()) {
+            @Override
+            public SplitWeight getSplitWeight() {
+                return SplitWeight.fromRawValue(Long.MAX_VALUE);
+            }
+        };
+        FileSplit extra = new FileSplit(LocationPath.of("s3://bucket/extra"),
+                0, 1, 1, 0, null, Collections.emptyList());
+        Assertions.assertThrows(ArithmeticException.class,
+                () -> policy.computeScanRangeAssignment(new 
ArrayList<>(List.of(heavy, extra))));
+    }
+
     @Test
     public void testRoundRobinUsesCandidateOrderAcrossBatches() throws 
UserException {
         SystemInfoService service = new SystemInfoService();


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

Reply via email to