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

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


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new fa07b7c85d8 branch-4.2: [improvement](fe) Default external scans to 
bounded split batches (#69683) (#69991)
fa07b7c85d8 is described below

commit fa07b7c85d802e5d322387d56d565f3f620a6c76
Author: 924060929 <[email protected]>
AuthorDate: Tue Nov 3 11:47:09 2026 +0800

    branch-4.2: [improvement](fe) Default external scans to bounded split 
batches (#69683) (#69991)
    
    cherry pick from #69683
---
 be/src/exec/operator/file_scan_operator.cpp        |  19 +-
 be/src/exec/operator/file_scan_operator.h          |   1 +
 be/src/exec/scan/file_scanner.cpp                  |   4 +-
 be/src/exec/scan/scanner.h                         |   3 +-
 be/src/exec/scan/split_source_connector.cpp        |  95 +++++++--
 be/src/exec/scan/split_source_connector.h          |  37 +++-
 be/test/exec/scan/file_scanner_v2_test.cpp         | 220 ++++++++++++++++++++-
 be/test/exec/scan/vfile_scanner_exception_test.cpp |   3 +-
 be/test/format/wal/wal_manager_test.cpp            |   3 +-
 .../apache/doris/blockrule/SqlBlockRuleMgr.java    |  24 +++
 .../apache/doris/datasource/FileQueryScanNode.java |  31 ++-
 .../apache/doris/datasource/SplitAssignment.java   |  85 ++++++--
 .../org/apache/doris/datasource/SplitSource.java   |  62 +++---
 .../doris/datasource/hive/source/HiveScanNode.java |  39 ++--
 .../doris/datasource/hudi/source/HudiScanNode.java |   3 +
 .../datasource/iceberg/source/IcebergScanNode.java |   2 +-
 .../maxcompute/source/MaxComputeScanNode.java      | 108 +++++-----
 .../java/org/apache/doris/qe/SessionVariable.java  |  36 +++-
 .../apache/doris/service/FrontendServiceImpl.java  |  12 +-
 .../doris/blockrule/SqlBlockRuleMgrTest.java       |  39 ++++
 .../doris/datasource/BoundedSplitDeliveryTest.java | 148 ++++++++++++++
 .../doris/datasource/FileQueryScanNodeTest.java    |  16 ++
 .../doris/datasource/SplitAssignmentTest.java      |   7 +-
 .../doris/datasource/SplitConsumerCloseTest.java   | 136 +++++++++++++
 .../datasource/hive/source/HiveScanNodeTest.java   |  77 ++++++++
 .../hive/source/HiveSplitEstimateTest.java         | 216 ++++++++++++++++++++
 .../iceberg/source/IcebergScanNodeTest.java        |  88 +++++++++
 .../source/MaxComputePartitionBatchTaskTest.java   |  94 +++++++++
 gensrc/thrift/FrontendService.thrift               |   2 +
 gensrc/thrift/PlanNodes.thrift                     |   2 +
 .../hive/test_hive_partitions.groovy               |   9 +-
 .../iceberg/test_iceberg_variant_read.groovy       |   2 +
 .../write/test_iceberg_write_uuid_inputs.groovy    |   1 +
 33 files changed, 1484 insertions(+), 140 deletions(-)

diff --git a/be/src/exec/operator/file_scan_operator.cpp 
b/be/src/exec/operator/file_scan_operator.cpp
index b6762c23fe6..bb078b509cd 100644
--- a/be/src/exec/operator/file_scan_operator.cpp
+++ b/be/src/exec/operator/file_scan_operator.cpp
@@ -69,6 +69,22 @@ FileScanLocalState::FileScanLocalState(RuntimeState* state, 
OperatorXBase* paren
 
 FileScanLocalState::~FileScanLocalState() = default;
 
+Status FileScanLocalState::close(RuntimeState* state) {
+    if (_closed) {
+        return Status::OK();
+    }
+    Status status = Base::close(state);
+    // Base::close stops every scanner and releases ScannerContext's transfer 
lock before RPC.
+    // A pruned/failed initialization can close before constructing a split 
source.
+    if (_split_source != nullptr) {
+        Status source_status = _split_source->close();
+        if (status.ok()) {
+            status = source_status;
+        }
+    }
+    return status;
+}
+
 PushDownType FileScanLocalState::_should_push_down_binary_predicate(
         VectorizedFnCall* fn_call, VExprContext* expr_ctx, Field& constant_val,
         const std::set<std::string> fn_name) const {
@@ -336,7 +352,8 @@ void FileScanLocalState::set_scan_ranges(RuntimeState* 
state,
             _max_scanners = calc_max_scanners(p.parallelism(state));
             _split_source = std::make_shared<RemoteSplitSourceConnector>(
                     state, get_split_timer, split_source.split_source_id, 
split_source.num_splits,
-                    _max_scanners);
+                    _max_scanners,
+                    split_source.__isset.supports_close && 
split_source.supports_close);
         }
     }
 
diff --git a/be/src/exec/operator/file_scan_operator.h 
b/be/src/exec/operator/file_scan_operator.h
index 03c0c980d93..564ee16c012 100644
--- a/be/src/exec/operator/file_scan_operator.h
+++ b/be/src/exec/operator/file_scan_operator.h
@@ -50,6 +50,7 @@ public:
     ~FileScanLocalState() override;
 
     Status init(RuntimeState* state, LocalStateInfo& info) override;
+    Status close(RuntimeState* state) override;
 
     Status _process_conjuncts(RuntimeState* state) override;
     Status _init_scanners(std::list<ScannerSPtr>* scanners) override;
diff --git a/be/src/exec/scan/file_scanner.cpp 
b/be/src/exec/scan/file_scanner.cpp
index 0a172da4216..b273ec7aad9 100644
--- a/be/src/exec/scan/file_scanner.cpp
+++ b/be/src/exec/scan/file_scanner.cpp
@@ -482,7 +482,7 @@ void FileScanner::_get_slot_ids(VExpr* expr, 
std::vector<int>* slot_ids) {
 Status FileScanner::_open_impl(RuntimeState* state) {
     RETURN_IF_CANCELLED(state);
     RETURN_IF_ERROR(Scanner::_open_impl(state));
-    RETURN_IF_ERROR(_split_source->get_next(&_first_scan_range, 
&_current_range));
+    RETURN_IF_ERROR(_split_source->get_next(&_first_scan_range, 
&_current_range, &_should_stop));
     if (_first_scan_range) {
         RETURN_IF_ERROR(_init_expr_ctxes());
         if (_state->query_options().enable_runtime_filter_partition_prune &&
@@ -1048,7 +1048,7 @@ Status FileScanner::_get_next_reader() {
         _src_block_init = false;
         bool has_next = _first_scan_range;
         if (!_first_scan_range) {
-            RETURN_IF_ERROR(_split_source->get_next(&has_next, 
&_current_range));
+            RETURN_IF_ERROR(_split_source->get_next(&has_next, 
&_current_range, &_should_stop));
         }
         _first_scan_range = false;
         if (!has_next || _should_stop) {
diff --git a/be/src/exec/scan/scanner.h b/be/src/exec/scan/scanner.h
index 6aaa1ec6940..9d5775bc163 100644
--- a/be/src/exec/scan/scanner.h
+++ b/be/src/exec/scan/scanner.h
@@ -20,6 +20,7 @@
 #include <stdint.h>
 
 #include <algorithm>
+#include <atomic>
 #include <vector>
 
 #include "common/status.h"
@@ -275,7 +276,7 @@ protected:
     int64_t _per_scanner_timer = 0;
     int64_t _projection_timer = 0;
 
-    bool _should_stop = false;
+    std::atomic_bool _should_stop = false;
 };
 
 using ScannerSPtr = std::shared_ptr<Scanner>;
diff --git a/be/src/exec/scan/split_source_connector.cpp 
b/be/src/exec/scan/split_source_connector.cpp
index 8642e57aa12..5c177915ac8 100644
--- a/be/src/exec/scan/split_source_connector.cpp
+++ b/be/src/exec/scan/split_source_connector.cpp
@@ -19,6 +19,7 @@
 
 #include "runtime/exec_env.h"
 #include "runtime/query_context.h"
+#include "util/thrift_rpc_helper.h"
 
 namespace doris {
 
@@ -130,7 +131,8 @@ void SplitSourceConnector::stop() {
     _split_ready.notify_all();
 }
 
-Status LocalSplitSourceConnector::get_next(bool* has_next, TFileRangeDesc* 
range) {
+Status LocalSplitSourceConnector::get_next(bool* has_next, TFileRangeDesc* 
range,
+                                           const std::atomic_bool* 
/*scanner_stop*/) {
     std::lock_guard<std::mutex> l(_range_lock);
     *has_next = false;
     if (_scan_index < _scan_ranges.size()) {
@@ -147,31 +149,42 @@ Status LocalSplitSourceConnector::get_next(bool* 
has_next, TFileRangeDesc* range
     return Status::OK();
 }
 
-Status RemoteSplitSourceConnector::get_next(bool* has_next, TFileRangeDesc* 
range) {
+Status RemoteSplitSourceConnector::get_next(bool* has_next, TFileRangeDesc* 
range,
+                                            const std::atomic_bool* 
scanner_stop) {
     std::lock_guard<std::mutex> l(_range_lock);
     *has_next = false;
+    if (is_stopped() || (scanner_stop && 
scanner_stop->load(std::memory_order_acquire))) {
+        return Status::OK();
+    }
     if (_scan_index == _scan_ranges.size() && !_last_batch) {
         SCOPED_TIMER(_get_split_timer);
-        Status coord_status;
-        // No need to set timeout because on FE side, there is a max fetch time
-        FrontendServiceConnection 
coord(_state->exec_env()->frontend_client_cache(),
-                                        _state->get_query_ctx()->coord_addr, 
&coord_status);
-        RETURN_IF_ERROR(coord_status);
         TFetchSplitBatchRequest request;
         request.__set_split_source_id(_split_source_id);
         request.__set_max_num_splits(config::remote_split_source_batch_size);
         TFetchSplitBatchResult result;
-        try {
-            coord->fetchSplitBatch(result, request);
+        do {
+            RETURN_IF_CANCELLED(_state);
+            // A local LIMIT stops this scanner without cancelling the query. 
Do not keep a scan
+            // worker in the retry loop while another branch of the same query 
remains active.
+            if (is_stopped() || (scanner_stop && 
scanner_stop->load(std::memory_order_acquire))) {
+                return Status::OK();
+            }
+            const auto fetch_status = _fetch_batch(&result, request);
+            if (is_stopped() || (scanner_stop && 
scanner_stop->load(std::memory_order_acquire))) {
+                return Status::OK();
+            }
+            RETURN_IF_ERROR(fetch_status);
+            if (result.__isset.status && result.status.status_code == 
TStatusCode::INCOMPLETE) {
+                continue;
+            }
             if (result.__isset.status && result.status.status_code != 
TStatusCode::OK) {
                 return Status::IOError<false>("Failed to get batch of split 
source: {}",
                                               result.status.error_msgs.empty()
                                                       ? "unknown error"
                                                       : 
result.status.error_msgs[0]);
             }
-        } catch (std::exception& e) {
-            return Status::IOError<false>("Failed to get batch of split 
source: {}", e.what());
-        }
+            break;
+        } while (true);
         _last_batch = result.splits.empty();
         _merge_ranges<TScanRangeLocations>(_scan_ranges, result.splits);
         _scan_index = 0;
@@ -191,4 +204,62 @@ Status RemoteSplitSourceConnector::get_next(bool* 
has_next, TFileRangeDesc* rang
     return Status::OK();
 }
 
+Status RemoteSplitSourceConnector::_fetch_batch(TFetchSplitBatchResult* result,
+                                                const TFetchSplitBatchRequest& 
request) {
+    if (request.__isset.close_source && request.close_source) {
+        // FE closure is idempotent, including when the first reply is lost. 
Reuse the standard
+        // reconnect/retry path only here: an ordinary split fetch consumes 
queue entries.
+        const auto& address = _state->get_query_ctx()->coord_addr;
+        return ThriftRpcHelper::rpc<FrontendServiceClient>(
+                address.hostname, address.port,
+                [&request, result](FrontendServiceConnection& coord) {
+                    coord->fetchSplitBatch(*result, request);
+                });
+    }
+    Status status;
+    FrontendServiceConnection 
coord(_state->exec_env()->frontend_client_cache(),
+                                    _state->get_query_ctx()->coord_addr, 
&status);
+    RETURN_IF_ERROR(status);
+    try {
+        coord->fetchSplitBatch(*result, request);
+    } catch (const std::exception& e) {
+        return Status::IOError<false>("Failed to get batch of split source: 
{}", e.what());
+    }
+    return Status::OK();
+}
+
+Status RemoteSplitSourceConnector::close() {
+    stop();
+    // Older FEs do not advertise support for the source-close request.
+    if (!_supports_close) {
+        return Status::OK();
+    }
+    std::scoped_lock lock(_close_lock);
+    if (_close_completed) {
+        return Status::OK();
+    }
+    TFetchSplitBatchRequest request;
+    request.__set_split_source_id(_split_source_id);
+    request.__set_max_num_splits(config::remote_split_source_batch_size);
+    request.__set_close_source(true);
+    Status status;
+    // The owner normally closes once. Retry a failed close here, without 
latching an unacknowledged
+    // request. An exhausted error must cancel the query rather than strand 
other BEs behind a queue.
+    for (int attempt = 0; attempt < 2; ++attempt) {
+        TFetchSplitBatchResult result;
+        status = _fetch_batch(&result, request);
+        if (status.ok() && result.__isset.status && result.status.status_code 
!= TStatusCode::OK) {
+            status = Status::IOError<false>("Failed to close split source {}: 
{}", _split_source_id,
+                                            result.status.error_msgs.empty()
+                                                    ? "unknown error"
+                                                    : 
result.status.error_msgs[0]);
+        }
+        if (status.ok()) {
+            _close_completed = true;
+            return Status::OK();
+        }
+    }
+    return status;
+}
+
 } // namespace doris
diff --git a/be/src/exec/scan/split_source_connector.h 
b/be/src/exec/scan/split_source_connector.h
index 583d6b9eebe..3ddff03c847 100644
--- a/be/src/exec/scan/split_source_connector.h
+++ b/be/src/exec/scan/split_source_connector.h
@@ -18,13 +18,16 @@
 #pragma once
 
 #include <algorithm>
+#include <atomic>
 #include <condition_variable>
 #include <deque>
+#include <mutex>
 #include <unordered_set>
 
 #include "common/config.h"
 #include "core/custom_allocator.h"
 #include "format_v2/file_scan_context.h"
+#include "gen_cpp/FrontendService_types.h"
 #include "runtime/runtime_state.h"
 #include "util/client_cache.h"
 
@@ -46,7 +49,8 @@ public:
      * @param has_next whether exists the next scan range
      * @param range the obtained next scan range
      */
-    virtual Status get_next(bool* has_next, TFileRangeDesc* range) = 0;
+    virtual Status get_next(bool* has_next, TFileRangeDesc* range,
+                            const std::atomic_bool* scanner_stop = nullptr) = 
0;
 
     // FileScannerV2 may replace one FE split with several physical children 
after inspecting file
     // metadata. These methods are shared by local and remote sources so 
neither source can report
@@ -55,6 +59,11 @@ public:
     Status finish_source_split(const FileScanSplit& source_split,
                                std::vector<FileScanSplit> generated_splits);
     void stop();
+    // Called once by the scan operator owner after stopping all shared 
consumers.
+    virtual Status close() {
+        stop();
+        return Status::OK();
+    }
 
     virtual int num_scan_ranges() = 0;
 
@@ -63,6 +72,8 @@ public:
     virtual bool all_ranges_have_table_level_row_count() const { return false; 
}
 
 protected:
+    bool is_stopped() const { return _stopped.load(std::memory_order_acquire); 
}
+
     template <typename T, typename V1 = std::vector<T>, typename V2 = 
std::vector<T>>
         requires(std::is_same_v<std::remove_cvref_t<V1>,
                                 std::vector<T, typename V1::allocator_type>> &&
@@ -117,7 +128,7 @@ private:
     uint64_t _next_source_split_id = 1;
     bool _source_claim_in_progress = false;
     bool _source_exhausted = false;
-    bool _stopped = false;
+    std::atomic_bool _stopped = false;
 };
 
 /**
@@ -139,7 +150,8 @@ public:
         _merge_ranges<TScanRangeParams>(_scan_ranges, scan_ranges);
     }
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override;
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* scanner_stop = nullptr) override;
 
     int num_scan_ranges() override { return 
static_cast<int>(_scan_ranges.size()); }
 
@@ -181,6 +193,10 @@ private:
     RuntimeProfile::Counter* _get_split_timer;
     int64_t _split_source_id;
     int _num_splits;
+    const bool _supports_close;
+    // Closing is independent of a possibly blocked fetch, and completes only 
after FE acknowledges.
+    std::mutex _close_lock;
+    bool _close_completed = false;
 
     DorisVector<TScanRangeLocations> _scan_ranges;
     bool _last_batch = false;
@@ -189,16 +205,25 @@ private:
 
 public:
     RemoteSplitSourceConnector(RuntimeState* state, RuntimeProfile::Counter* 
get_split_timer,
-                               int64_t split_source_id, int num_splits, int 
max_scanners)
+                               int64_t split_source_id, int num_splits, int 
max_scanners,
+                               bool supports_close = false)
             : _state(state),
               _get_split_timer(get_split_timer),
               _split_source_id(split_source_id),
-              _num_splits(num_splits) {
+              _num_splits(num_splits),
+              _supports_close(supports_close) {
         _max_scanners = max_scanners;
     }
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override;
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* scanner_stop = nullptr) override;
+    Status close() override;
 
+protected:
+    virtual Status _fetch_batch(TFetchSplitBatchResult* result,
+                                const TFetchSplitBatchRequest& request);
+
+public:
     /*
      * Remote split source is fetched in batch mode, and the splits are 
generated while scanning,
      * so the number of scan ranges may not be accurate.
diff --git a/be/test/exec/scan/file_scanner_v2_test.cpp 
b/be/test/exec/scan/file_scanner_v2_test.cpp
index d7d11e0acbe..01817ac9c24 100644
--- a/be/test/exec/scan/file_scanner_v2_test.cpp
+++ b/be/test/exec/scan/file_scanner_v2_test.cpp
@@ -23,6 +23,7 @@
 #include <atomic>
 #include <chrono>
 #include <condition_variable>
+#include <functional>
 #include <future>
 #include <memory>
 #include <mutex>
@@ -570,7 +571,8 @@ public:
     explicit RemoteStyleSplitSourceConnector(std::vector<TFileRangeDesc> 
ranges)
             : _ranges(std::move(ranges)) {}
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override {
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* /*scanner_stop*/) override {
         std::lock_guard lock(_lock);
         ++_get_next_calls;
         *has_next = _next < _ranges.size();
@@ -599,7 +601,8 @@ public:
     explicit BlockingRemoteStyleSplitSourceConnector(TFileRangeDesc range)
             : _range(std::move(range)) {}
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override {
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* /*scanner_stop*/) override {
         std::unique_lock lock(_lock);
         if (!_source_returned) {
             _source_returned = true;
@@ -1587,4 +1590,217 @@ TEST(FileScannerTest, 
PartitionPruningStopsAtUnsafePredicate) {
     EXPECT_EQ(partition_conjuncts[0], conjuncts[0]);
 }
 
+TEST(FileScannerV2Test, RemoteSplitSourceStopsLocallyWithoutQueryCancellation) 
{
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    RemoteSplitSourceConnector source(&state, nullptr, 1, 1, 1);
+    std::atomic_bool scanner_stop {true};
+    bool has_next = true;
+    TFileRangeDesc range;
+
+    ASSERT_FALSE(state.is_cancelled());
+    ASSERT_TRUE(source.get_next(&has_next, &range, &scanner_stop).ok());
+    EXPECT_FALSE(has_next);
+}
+
+class ScriptedRemoteSplitSource final : public RemoteSplitSourceConnector {
+public:
+    explicit ScriptedRemoteSplitSource(RuntimeState* state, bool 
supports_close = true)
+            : RemoteSplitSourceConnector(state, nullptr, 7, 1, 1, 
supports_close) {}
+
+    std::function<Status(TFetchSplitBatchResult*, const 
TFetchSplitBatchRequest&)> fetch;
+
+protected:
+    Status _fetch_batch(TFetchSplitBatchResult* result,
+                        const TFetchSplitBatchRequest& request) override {
+        return fetch(result, request);
+    }
+};
+
+TEST(FileScannerV2Test, 
RemoteIncompleteRetriesAndOwnerClosesTheSharedSourceOnce) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ScriptedRemoteSplitSource source(&state);
+    int fetched = 0;
+    int closed = 0;
+    source.fetch = [&](TFetchSplitBatchResult* result, const 
TFetchSplitBatchRequest& request) {
+        if (request.__isset.close_source && request.close_source) {
+            ++closed;
+            EXPECT_EQ(request.split_source_id, 7);
+            EXPECT_GT(request.max_num_splits, 0);
+            TStatus thrift_status;
+            thrift_status.__set_status_code(TStatusCode::OK);
+            result->__set_status(thrift_status);
+            return Status::OK();
+        }
+        if (++fetched == 1) {
+            TStatus thrift_status;
+            thrift_status.__set_status_code(TStatusCode::INCOMPLETE);
+            result->__set_status(thrift_status);
+        } else {
+            TScanRangeLocations locations;
+            locations.scan_range = 
scan_range_with_path("file.parquet").scan_range;
+            
locations.scan_range.ext_scan_range.file_scan_range.ranges.push_back(
+                    
locations.scan_range.ext_scan_range.file_scan_range.ranges.front());
+            result->__set_splits({locations});
+            TStatus thrift_status;
+            thrift_status.__set_status_code(TStatusCode::OK);
+            result->__set_status(thrift_status);
+        }
+        return Status::OK();
+    };
+    bool has_next = false;
+    TFileRangeDesc range;
+    ASSERT_TRUE(source.get_next(&has_next, &range).ok());
+    EXPECT_TRUE(has_next);
+    EXPECT_EQ(fetched, 2);
+    EXPECT_EQ(range.path, "file.parquet");
+    ASSERT_TRUE(source.close().ok());
+    ASSERT_TRUE(source.close().ok());
+    EXPECT_EQ(closed, 1);
+    ASSERT_TRUE(source.get_next(&has_next, &range).ok());
+    EXPECT_FALSE(has_next);
+    EXPECT_EQ(fetched, 2);
+}
+
+TEST(FileScannerV2Test, 
OldFrontendDescriptorDoesNotSendAnUnsupportedCloseRequest) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ScriptedRemoteSplitSource source(&state, false);
+    source.fetch = [](TFetchSplitBatchResult*, const TFetchSplitBatchRequest&) 
{
+        ADD_FAILURE() << "an old FE did not advertise source closure";
+        return Status::InternalError("unexpected close");
+    };
+    EXPECT_TRUE(source.close().ok());
+}
+
+TEST(FileScannerV2Test, OldFrontendTimeoutRemainsAnErrorWithoutRetry) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ScriptedRemoteSplitSource source(&state, false);
+    int fetched = 0;
+    source.fetch = [&](TFetchSplitBatchResult* result, const 
TFetchSplitBatchRequest& request) {
+        EXPECT_FALSE(request.__isset.close_source);
+        if (++fetched > 1) {
+            return Status::InternalError("the old FE timeout must not be 
retried");
+        }
+        TStatus thrift_status;
+        thrift_status.__set_status_code(TStatusCode::INTERNAL_ERROR);
+        thrift_status.__set_error_msgs({"Timeout. Max wait time(ms): 1000"});
+        result->__set_status(thrift_status);
+        return Status::OK();
+    };
+    bool has_next = true;
+    TFileRangeDesc range;
+    Status status = source.get_next(&has_next, &range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("Timeout. Max wait time(ms): 1000"), 
std::string::npos);
+    EXPECT_FALSE(has_next);
+    EXPECT_EQ(fetched, 1);
+    EXPECT_TRUE(source.close().ok());
+    EXPECT_EQ(fetched, 1);
+}
+
+TEST(FileScannerV2Test, 
OwnerCloseDoesNotWaitForTheFetchLockOrSurfaceALateFetchError) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ScriptedRemoteSplitSource source(&state);
+    std::promise<void> entered;
+    auto started = entered.get_future();
+    std::promise<void> release;
+    auto released = release.get_future().share();
+    std::atomic_int closed {0};
+    source.fetch = [&](TFetchSplitBatchResult* result, const 
TFetchSplitBatchRequest& request) {
+        if (request.__isset.close_source && request.close_source) {
+            ++closed;
+            TStatus thrift_status;
+            thrift_status.__set_status_code(TStatusCode::OK);
+            result->__set_status(thrift_status);
+            return Status::OK();
+        }
+        entered.set_value();
+        released.wait();
+        return Status::IOError("the source closed while this fetch was in 
flight");
+    };
+    bool has_next = true;
+    TFileRangeDesc range;
+    auto fetching =
+            std::async(std::launch::async, [&] { return 
source.get_next(&has_next, &range); });
+    EXPECT_EQ(started.wait_for(std::chrono::seconds(5)), 
std::future_status::ready);
+    auto closing = std::async(std::launch::async, [&] { return source.close(); 
});
+    EXPECT_EQ(closing.wait_for(std::chrono::seconds(5)), 
std::future_status::ready);
+    release.set_value();
+    EXPECT_TRUE(closing.get().ok());
+    EXPECT_EQ(fetching.wait_for(std::chrono::seconds(5)), 
std::future_status::ready);
+    EXPECT_TRUE(fetching.get().ok());
+    EXPECT_FALSE(has_next);
+    EXPECT_EQ(closed.load(), 1);
+}
+
+TEST(FileScannerV2Test, OwnerCloseRetriesAnUnacknowledgedRequest) {
+    for (bool lost_reply : {false, true}) {
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        ScriptedRemoteSplitSource source(&state);
+        int calls = 0;
+        int effects = 0;
+        bool released = false;
+        source.fetch = [&](TFetchSplitBatchResult* result, const 
TFetchSplitBatchRequest& request) {
+            EXPECT_TRUE(request.close_source);
+            ++calls;
+            if (!lost_reply && calls == 1) {
+                return Status::IOError("connection failed before FE received 
the close");
+            }
+            if (!released) {
+                released = true;
+                ++effects;
+            }
+            if (lost_reply && calls == 1) {
+                return Status::IOError("FE closed the source but the reply was 
lost");
+            }
+            TStatus thrift_status;
+            thrift_status.__set_status_code(TStatusCode::OK);
+            result->__set_status(thrift_status);
+            return Status::OK();
+        };
+        EXPECT_TRUE(source.close().ok());
+        EXPECT_EQ(calls, 2);
+        EXPECT_EQ(effects, 1);
+        EXPECT_TRUE(source.close().ok());
+        EXPECT_EQ(calls, 2);
+    }
+}
+
+namespace {
+
+void verify_failed_close_can_recover(bool transport_failure) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ScriptedRemoteSplitSource source(&state);
+    bool recovered = false;
+    int calls = 0;
+    source.fetch = [&](TFetchSplitBatchResult* result, const 
TFetchSplitBatchRequest& request) {
+        EXPECT_TRUE(request.close_source);
+        ++calls;
+        if (!recovered && transport_failure) {
+            return Status::IOError("FE unavailable");
+        }
+        TStatus thrift_status;
+        thrift_status.__set_status_code(recovered ? TStatusCode::OK : 
TStatusCode::INTERNAL_ERROR);
+        result->__set_status(thrift_status);
+        return Status::OK();
+    };
+    EXPECT_FALSE(source.close().ok());
+    EXPECT_EQ(calls, 2);
+    bool has_next = true;
+    TFileRangeDesc range;
+    EXPECT_TRUE(source.get_next(&has_next, &range).ok());
+    EXPECT_FALSE(has_next);
+    recovered = true;
+    EXPECT_TRUE(source.close().ok());
+    EXPECT_EQ(calls, 3);
+    EXPECT_TRUE(source.close().ok());
+    EXPECT_EQ(calls, 3);
+}
+
+} // namespace
+
+TEST(FileScannerV2Test, FailedOwnerCloseIsBoundedAndCanBeRetriedAfterRecovery) 
{
+    verify_failed_close_can_recover(false);
+    verify_failed_close_can_recover(true);
+}
+
 } // namespace doris
diff --git a/be/test/exec/scan/vfile_scanner_exception_test.cpp 
b/be/test/exec/scan/vfile_scanner_exception_test.cpp
index a68002c24c6..605b54a8d7c 100644
--- a/be/test/exec/scan/vfile_scanner_exception_test.cpp
+++ b/be/test/exec/scan/vfile_scanner_exception_test.cpp
@@ -54,7 +54,8 @@ private:
 public:
     TestSplitSourceConnectorStub(const TFileScanRange& scan_range) : 
_scan_range(scan_range) {}
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override {
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* /*scanner_stop*/) override {
         std::lock_guard<std::mutex> l(_range_lock);
         if (_range_index < _scan_range.ranges.size()) {
             *has_next = true;
diff --git a/be/test/format/wal/wal_manager_test.cpp 
b/be/test/format/wal/wal_manager_test.cpp
index a6dabd57bde..6f48e0b3936 100644
--- a/be/test/format/wal/wal_manager_test.cpp
+++ b/be/test/format/wal/wal_manager_test.cpp
@@ -47,7 +47,8 @@ private:
 public:
     TestSplitSourceConnector(const TFileScanRange& scan_range) : 
_scan_range(scan_range) {}
 
-    Status get_next(bool* has_next, TFileRangeDesc* range) override {
+    Status get_next(bool* has_next, TFileRangeDesc* range,
+                    const std::atomic_bool* /*scanner_stop*/) override {
         std::lock_guard<std::mutex> l(_range_lock);
         if (_range_index < _scan_range.ranges.size()) {
             *has_next = true;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java 
b/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
index f68307a3d2a..547e75ce45a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/blockrule/SqlBlockRuleMgr.java
@@ -267,6 +267,30 @@ public class SqlBlockRuleMgr implements Writable {
         }
     }
 
+    /** Whether this query needs exact partition/split counts before execution 
can be admitted. */
+    public boolean hasScanCountLimitations(String user) {
+        if (ConnectContext.get().getState().isInternal()) {
+            return false;
+        }
+        for (SqlBlockRule rule : nameToSqlBlockRuleMap.values()) {
+            if (rule.getGlobal() && requiresExactScanCounts(rule)) {
+                return true;
+            }
+        }
+        for (String ruleName : 
Env.getCurrentEnv().getAuth().getSqlBlockRules(user)) {
+            SqlBlockRule rule = nameToSqlBlockRuleMap.get(ruleName);
+            if (rule != null && requiresExactScanCounts(rule)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean requiresExactScanCounts(SqlBlockRule rule) {
+        // Cardinality and partition-filter checks do not depend on 
enumerating file splits.
+        return rule.getEnable() && (rule.getPartitionNum() != 0 || 
rule.getTabletNum() != 0);
+    }
+
     /**
      * Check number whether legal by user.
      **/
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
index 978ea0bd303..e64aebe1c51 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
@@ -18,6 +18,7 @@
 package org.apache.doris.datasource;
 
 import org.apache.doris.analysis.SlotDescriptor;
+import org.apache.doris.analysis.StatementBase;
 import org.apache.doris.analysis.TableSample;
 import org.apache.doris.analysis.TableScanParams;
 import org.apache.doris.analysis.TableSnapshot;
@@ -118,6 +119,7 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
     protected TableScanParams scanParams;
     private Optional<MvccSnapshot> relationSnapshot = Optional.empty();
     private boolean relationSnapshotInitialized = false;
+    private Boolean batchModeEnabled;
 
     protected FileSplitter fileSplitter;
     protected SummaryProfile summaryProfile;
@@ -149,6 +151,26 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
                 ? null : statementContext.getExternalScanTaskCache();
     }
 
+    /** Freeze the planning mode before asynchronous split conversion loses 
the query thread context. */
+    protected boolean isBatchModeEnabled() {
+        if (batchModeEnabled == null) {
+            // TABLESAMPLE selects files once across the table: independent 
partition batches
+            // would reset the ROWS budget, percentage rounding and random 
selection.
+            batchModeEnabled = 
sessionVariable.getEnableExternalTableBatchMode() && tableSample == null;
+            ConnectContext context = ConnectContext.get();
+            if (batchModeEnabled && context != null) {
+                StatementContext statementContext = 
context.getStatementContext();
+                StatementBase statement = statementContext == null ? null : 
statementContext.getParsedStatement();
+                // EXPLAIN VERBOSE needs the full file/delete details. 
Scan-count rules run before execution
+                // and must not compare their limits with the batch path's 
sample/approximate counts.
+                batchModeEnabled = (statement == null || 
!statement.isVerbose())
+                        && !Env.getCurrentEnv().getSqlBlockRuleMgr()
+                                
.hasScanCountLimitations(context.getQualifiedUser());
+            }
+        }
+        return batchModeEnabled;
+    }
+
     /** Whether statement-scoped scan-task reuse is active for this node. */
     protected boolean canReuseExternalScanTasks() {
         return sessionVariable.enableExternalScanTaskReuse && 
externalScanTaskCache != null;
@@ -476,11 +498,14 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
             admissionResult = fileCacheAdmissionCheck();
         }
 
-        if (isBatchMode()) {
+        if (sessionVariable.getEnableExternalTableBatchMode() && 
isBatchMode()) {
+            long maxWaitTime = sessionVariable.getFetchSplitsMaxWaitTime();
+            Preconditions.checkArgument(maxWaitTime > 0, 
"fetch_splits_max_wait_time_ms must be positive");
             // File splits are generated lazily, and fetched by backends while 
scanning.
             // Only provide the unique ID of split source to backend.
             splitAssignment = new SplitAssignment(backendPolicy, this, 
this::splitToScanRange,
-                    locationProperties, pathPartitionKeys, admissionResult);
+                    locationProperties, pathPartitionKeys, admissionResult,
+                    sessionVariable.splitBatchSize, 
sessionVariable.splitQueueCapacity);
             splitAssignment.init();
             if (executor != null) {
                 executor.getSummaryProfile().setGetSplitsFinishTime();
@@ -495,7 +520,6 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
                 throw new UserException("Approximate split number should not 
be negative");
             }
             totalFileSize = fileSplit.getLength() * selectedSplitNum;
-            long maxWaitTime = sessionVariable.getFetchSplitsMaxWaitTime();
             // Not accurate, only used to estimate concurrency.
             // Here, we must take the max of 1, because
             // in the case of multiple BEs, `numApproximateSplits() / 
backendPolicy.numBackends()` may be 0,
@@ -509,6 +533,7 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
                 TSplitSource tSource = new TSplitSource();
                 tSource.setSplitSourceId(splitSource.getUniqueId());
                 tSource.setNumSplits(numSplitsPerBE);
+                tSource.setSupportsClose(true);
                 
curLocations.getScanRange().getExtScanRange().getFileScanRange().setSplitSource(tSource);
                 TScanRangeLocation location = new TScanRangeLocation();
                 location.setBackendId(backend.getId());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java
index 3d739d1f66a..4f8a62a888a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitAssignment.java
@@ -22,6 +22,7 @@ import org.apache.doris.spi.Split;
 import org.apache.doris.system.Backend;
 import org.apache.doris.thrift.TScanRangeLocations;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Multimap;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -46,20 +47,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
 public class SplitAssignment {
     private static final Logger LOG = 
LogManager.getLogger(SplitAssignment.class);
     private final Set<Long> sources = new HashSet<>();
+    private final Set<Long> stoppedBackends = ConcurrentHashMap.newKeySet();
     private final FederationBackendPolicy backendPolicy;
     private final SplitGenerator splitGenerator;
-    private final ConcurrentHashMap<Backend, 
BlockingQueue<Collection<TScanRangeLocations>>> assignment
+    private final ConcurrentHashMap<Long, BlockingQueue<TScanRangeLocations>> 
assignment
             = new ConcurrentHashMap<>();
     private final SplitToScanRange splitToScanRange;
     private final Map<String, String> locationProperties;
     private final List<String> pathPartitionKeys;
     private final boolean fileCacheAdmission;
+    private final int splitBatchSize;
+    private final int queueCapacity;
     private final Object assignLock = new Object();
     private Split sampleSplit = null;
     private final AtomicBoolean isStopped = new AtomicBoolean(false);
     private final AtomicBoolean scheduleFinished = new AtomicBoolean(false);
 
-    private UserException exception = null;
+    private volatile UserException exception = null;
     private final List<Closeable> closeableResources = new ArrayList<>();
 
     public SplitAssignment(
@@ -69,6 +73,16 @@ public class SplitAssignment {
             Map<String, String> locationProperties,
             List<String> pathPartitionKeys,
             boolean fileCacheAdmission) {
+        this(backendPolicy, splitGenerator, splitToScanRange, 
locationProperties,
+                pathPartitionKeys, fileCacheAdmission, 8, 1024);
+    }
+
+    public SplitAssignment(FederationBackendPolicy backendPolicy, 
SplitGenerator splitGenerator,
+            SplitToScanRange splitToScanRange, Map<String, String> 
locationProperties,
+            List<String> pathPartitionKeys, boolean fileCacheAdmission, int 
splitBatchSize, int queueCapacity) {
+        Preconditions.checkArgument(splitBatchSize > 0 && queueCapacity > 0);
+        this.splitBatchSize = splitBatchSize;
+        this.queueCapacity = queueCapacity;
         this.backendPolicy = backendPolicy;
         this.splitGenerator = splitGenerator;
         this.splitToScanRange = splitToScanRange;
@@ -107,26 +121,54 @@ public class SplitAssignment {
 
     private void appendBatch(Multimap<Backend, Split> batch) throws 
UserException {
         for (Backend backend : batch.keySet()) {
+            if (stoppedBackends.contains(backend.getId())) {
+                continue;
+            }
             Collection<Split> splits = batch.get(backend);
             List<TScanRangeLocations> locations = new 
ArrayList<>(splits.size());
             for (Split split : splits) {
                 locations.add(splitToScanRange.getScanRange(backend, 
locationProperties, split, pathPartitionKeys,
                         fileCacheAdmission));
             }
-            while (needMoreSplit()) {
-                BlockingQueue<Collection<TScanRangeLocations>> queue =
-                        assignment.computeIfAbsent(backend, be -> new 
LinkedBlockingQueue<>(10000));
-                try {
-                    if (queue.offer(locations, 100, TimeUnit.MILLISECONDS)) {
-                        break;
+            BlockingQueue<TScanRangeLocations> queue = 
assignment.computeIfAbsent(
+                    backend.getId(), be -> new 
LinkedBlockingQueue<>(queueCapacity));
+            for (TScanRangeLocations location : locations) {
+                while (needMoreSplit() && 
!stoppedBackends.contains(backend.getId())) {
+                    try {
+                        if (queue.offer(location, 100, TimeUnit.MILLISECONDS)) 
{
+                            if (stoppedBackends.contains(backend.getId())) {
+                                queue.clear();
+                                break;
+                            }
+                            synchronized (assignLock) {
+                                if (sampleSplit == null) {
+                                    sampleSplit = splits.iterator().next();
+                                    assignLock.notifyAll();
+                                }
+                            }
+                            break;
+                        }
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        setException(new UserException("Interrupted while 
publishing scan splits", e));
                     }
-                } catch (InterruptedException e) {
-                    addUserException(new UserException("Failed to offer batch 
split by interrupted", e));
                 }
             }
         }
     }
 
+    public void stopBackend(long backendId) {
+        if (stoppedBackends.add(backendId)) {
+            BlockingQueue<TScanRangeLocations> queue = 
assignment.get(backendId);
+            if (queue != null) {
+                queue.clear();
+            }
+            if (stoppedBackends.size() == backendPolicy.numBackends()) {
+                stop();
+            }
+        }
+    }
+
     public void registerSource(long uniqueId) {
         sources.add(uniqueId);
     }
@@ -143,15 +185,15 @@ public class SplitAssignment {
         if (splits.isEmpty()) {
             return;
         }
-        Multimap<Backend, Split> batch = null;
-        synchronized (assignLock) {
-            if (sampleSplit == null) {
-                sampleSplit = splits.get(0);
-                assignLock.notify();
+        for (int begin = 0; begin < splits.size() && needMoreSplit();) {
+            int end = begin + Math.min(splitBatchSize, splits.size() - begin);
+            Multimap<Backend, Split> batch;
+            synchronized (assignLock) {
+                batch = backendPolicy.computeScanRangeAssignment(new 
ArrayList<>(splits.subList(begin, end)));
             }
-            batch = backendPolicy.computeScanRangeAssignment(splits);
+            appendBatch(batch);
+            begin = end;
         }
-        appendBatch(batch);
     }
 
     private void notifyAssignment() {
@@ -160,13 +202,14 @@ public class SplitAssignment {
         }
     }
 
-    public BlockingQueue<Collection<TScanRangeLocations>> 
getAssignedSplits(Backend backend) throws UserException {
+    public BlockingQueue<TScanRangeLocations> getAssignedSplits(Backend 
backend) throws UserException {
         if (exception != null) {
             throw exception;
         }
-        BlockingQueue<Collection<TScanRangeLocations>> splits = 
assignment.computeIfAbsent(backend,
-                be -> new LinkedBlockingQueue<>());
-        if (scheduleFinished.get() && splits.isEmpty() || isStopped.get()) {
+        BlockingQueue<TScanRangeLocations> splits = 
assignment.computeIfAbsent(backend.getId(),
+                be -> new LinkedBlockingQueue<>(queueCapacity));
+        if (scheduleFinished.get() && splits.isEmpty() || isStopped.get()
+                || stoppedBackends.contains(backend.getId())) {
             return null;
         }
         return splits;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java
index e24af768781..2fcddcec8b6 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/SplitSource.java
@@ -21,9 +21,9 @@ import org.apache.doris.common.UserException;
 import org.apache.doris.system.Backend;
 import org.apache.doris.thrift.TScanRangeLocations;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Lists;
 
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
@@ -52,6 +52,7 @@ public class SplitSource {
     private final long maxWaitTime;
 
     public SplitSource(Backend backend, SplitAssignment splitAssignment, long 
maxWaitTime) {
+        Preconditions.checkArgument(maxWaitTime > 0, 
"fetch_splits_max_wait_time_ms must be positive");
         this.uniqueId = UNIQUE_ID_GENERATOR.getAndIncrement();
         this.backend = backend;
         this.splitAssignment = splitAssignment;
@@ -60,6 +61,12 @@ public class SplitSource {
         splitAssignment.registerSource(uniqueId);
     }
 
+    /** Close this backend's shared consumer without stopping other backends 
of the scan. */
+    public void close() {
+        isLastBatch.set(true);
+        splitAssignment.stopBackend(backend.getId());
+    }
+
     public long getUniqueId() {
         return uniqueId;
     }
@@ -67,40 +74,47 @@ public class SplitSource {
     /**
      * Get the next batch of file splits. If there's no more split, return 
empty list.
      */
-    public List<TScanRangeLocations> getNextBatch(int maxBatchSize) throws 
UserException {
+    public boolean isLastBatch() {
+        return isLastBatch.get();
+    }
+
+    public synchronized List<TScanRangeLocations> getNextBatch(int 
maxBatchSize) throws UserException {
+        Preconditions.checkArgument(maxBatchSize > 0);
         if (isLastBatch.get()) {
             return Collections.emptyList();
         }
         List<TScanRangeLocations> scanRanges = 
Lists.newArrayListWithExpectedSize(maxBatchSize);
-        long startTime = System.currentTimeMillis();
-        while (scanRanges.size() < maxBatchSize && System.currentTimeMillis() 
- startTime < maxWaitTime) {
-            BlockingQueue<Collection<TScanRangeLocations>> splits = 
splitAssignment.getAssignedSplits(backend);
+        long started = System.nanoTime();
+        long waitNanos = TimeUnit.MILLISECONDS.toNanos(maxWaitTime);
+        while (scanRanges.size() < maxBatchSize) {
+            BlockingQueue<TScanRangeLocations> splits = 
splitAssignment.getAssignedSplits(backend);
             if (splits == null) {
                 isLastBatch.set(true);
                 break;
             }
-            while (scanRanges.size() < maxBatchSize) {
-                try {
-                    Collection<TScanRangeLocations> splitCollection = 
splits.poll(WAIT_TIME_OUT, TimeUnit.MILLISECONDS);
-                    if (splitCollection != null) {
-                        scanRanges.addAll(splitCollection);
-                    }
-                    if (!scanRanges.isEmpty() && System.currentTimeMillis() - 
startTime > maxWaitTime) {
-                        return scanRanges;
-                    }
-                    if (splitCollection == null) {
-                        break;
-                    }
-                } catch (InterruptedException e) {
-                    throw new UserException(e.getMessage(), e);
+            TScanRangeLocations split = splits.poll();
+            if (split != null) {
+                scanRanges.add(split);
+                continue;
+            }
+            if (!scanRanges.isEmpty()) {
+                break;
+            }
+            long wait = waitNanos - (System.nanoTime() - started);
+            if (wait <= 0) {
+                break;
+            }
+            try {
+                split = splits.poll(Math.min(wait, 
TimeUnit.MILLISECONDS.toNanos(WAIT_TIME_OUT)),
+                        TimeUnit.NANOSECONDS);
+                if (split != null) {
+                    scanRanges.add(split);
                 }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new UserException("Interrupted while waiting for scan 
splits", e);
             }
         }
-
-        if (scanRanges.isEmpty() && !isLastBatch.get()) {
-            // This is timeout
-            throw new UserException("Timeout. Max wait time(ms): " + 
maxWaitTime);
-        }
         return scanRanges;
     }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
index 1e45cbb3f7c..b1fa1addd33 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hive/source/HiveScanNode.java
@@ -227,12 +227,15 @@ public class HiveScanNode extends FileQueryScanNode {
             return allFiles;
         } catch (Throwable t) {
             LOG.warn("get file split failed for table: {}", 
hmsTable.getName(), t);
-            throw new UserException(
-                "get file split failed for table: " + hmsTable.getName() + ", 
err: " + Util.getRootCauseMessage(t),
-                t);
+            throw createSplitPlanningException(t);
         }
     }
 
+    private UserException createSplitPlanningException(Throwable failure) {
+        return new UserException("get file split failed for table: " + 
hmsTable.getName()
+                + ", err: " + Util.getRootCauseMessage(failure), failure);
+    }
+
     @Override
     public void startSplit(int numBackends) {
         if (prunedPartitions.isEmpty()) {
@@ -245,8 +248,10 @@ public class HiveScanNode extends FileQueryScanNode {
         String bindBrokerName = hmsTable.getCatalog().bindBrokerName();
         AtomicInteger numFinishedPartitions = new AtomicInteger(0);
         long startTime = System.currentTimeMillis();
+        List<List<HivePartition>> partitionBatches = Lists.partition(
+                prunedPartitions, sessionVariable.splitPartitionBatchSize);
         CompletableFuture.runAsync(() -> {
-            for (HivePartition partition : prunedPartitions) {
+            for (List<HivePartition> partitions : partitionBatches) {
                 if (batchException.get() != null || splitAssignment.isStop()) {
                     break;
                 }
@@ -256,22 +261,20 @@ public class HiveScanNode extends FileQueryScanNode {
                         try {
                             List<Split> allFiles = Lists.newArrayList();
                             getFileSplitByPartitions(
-                                    cache, 
Collections.singletonList(partition), allFiles, bindBrokerName,
+                                    cache, partitions, allFiles, 
bindBrokerName,
                                     numBackends, true);
-                            if (allFiles.size() > numSplitsPerPartition.get()) 
{
-                                numSplitsPerPartition.set(allFiles.size());
-                            }
+                            updateNumSplitsPerPartition(allFiles);
                             if (splitAssignment.needMoreSplit()) {
                                 splitAssignment.addToQueue(allFiles);
                             }
                         } catch (Exception e) {
-                            batchException.set(new 
UserException(e.getMessage(), e));
+                            
batchException.set(createSplitPlanningException(e));
                         } finally {
                             splittersOnFlight.release();
                             if (batchException.get() != null) {
                                 
splitAssignment.setException(batchException.get());
                             }
-                            if (numFinishedPartitions.incrementAndGet() == 
prunedPartitions.size()) {
+                            if (numFinishedPartitions.incrementAndGet() == 
partitionBatches.size()) {
                                 if (getSummaryProfile() != null) {
                                     
getSummaryProfile().addExternalTableGetPartitionFilesTime(
                                             System.currentTimeMillis() - 
startTime);
@@ -282,7 +285,7 @@ public class HiveScanNode extends FileQueryScanNode {
                     }, scheduleExecutor);
                 } catch (Exception e) {
                     // When submitting a task, an exception will be thrown if 
the task pool(scheduleExecutor) is full
-                    batchException.set(new UserException(e.getMessage(), e));
+                    batchException.set(createSplitPlanningException(e));
                     break;
                 }
             }
@@ -294,6 +297,9 @@ public class HiveScanNode extends FileQueryScanNode {
 
     @Override
     public boolean isBatchMode() {
+        if (!isBatchModeEnabled()) {
+            return false;
+        }
         if (!partitionInit) {
             try {
                 prunedPartitions = getPartitions();
@@ -311,6 +317,17 @@ public class HiveScanNode extends FileQueryScanNode {
         return numSplitsPerPartition.get() * prunedPartitions.size();
     }
 
+    private void updateNumSplitsPerPartition(List<Split> splits) {
+        // Scheduling batches contain several partitions; extrapolate the 
largest single partition.
+        Map<List<String>, Integer> partitionSplitCounts = Maps.newHashMap();
+        int maxSplitCount = 0;
+        for (Split split : splits) {
+            int count = partitionSplitCounts.merge(((FileSplit) 
split).getPartitionValues(), 1, Integer::sum);
+            maxSplitCount = Math.max(maxSplitCount, count);
+        }
+        numSplitsPerPartition.accumulateAndGet(maxSplitCount, Math::max);
+    }
+
     private void getFileSplitByPartitions(HiveExternalMetaCache cache, 
List<HivePartition> partitions,
             List<Split> allFiles, String bindBrokerName, int numBackends,
             boolean isBatchMode) throws IOException, UserException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java
index 4b7739604fb..feba79913f5 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/hudi/source/HudiScanNode.java
@@ -1112,6 +1112,9 @@ public class HudiScanNode extends HiveScanNode {
 
     @Override
     public boolean isBatchMode() {
+        if (!isBatchModeEnabled()) {
+            return false;
+        }
         if (incrementalRead && !incrementalRelation.fallbackFullTableScan()) {
             return false;
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
index 998e5152df8..a9cf412aa18 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
@@ -3038,7 +3038,7 @@ public class IcebergScanNode extends FileQueryScanNode {
         if (prepareTableLevelSnapshotCount()) {
             return false;
         }
-        if (!sessionVariable.getEnableExternalTableBatchMode()) {
+        if (!isBatchModeEnabled()) {
             isBatchMode = false;
             return false;
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java
index dd055ed6e37..54d33267a38 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/maxcompute/source/MaxComputeScanNode.java
@@ -36,6 +36,7 @@ import org.apache.doris.common.UserException;
 import org.apache.doris.common.maxcompute.MCProperties;
 import org.apache.doris.common.util.LocationPath;
 import org.apache.doris.datasource.FileQueryScanNode;
+import org.apache.doris.datasource.SplitAssignment;
 import org.apache.doris.datasource.TableFormatType;
 import org.apache.doris.datasource.maxcompute.MaxComputeExternalCatalog;
 import org.apache.doris.datasource.maxcompute.MaxComputeExternalTable;
@@ -81,10 +82,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 
 public class MaxComputeScanNode extends FileQueryScanNode {
@@ -207,6 +205,9 @@ public class MaxComputeScanNode extends FileQueryScanNode {
 
     @Override
     public boolean isBatchMode() {
+        if (!isBatchModeEnabled()) {
+            return false;
+        }
         if (table.getPartitionColumns().isEmpty()) {
             return false;
         }
@@ -243,52 +244,71 @@ public class MaxComputeScanNode extends FileQueryScanNode 
{
                 (key, value) -> requiredPartitionSpecs.add(new 
PartitionSpec(key))
         );
 
-        int batchNumPartitions = sessionVariable.getNumPartitionsInBatchMode();
-
         Executor scheduleExecutor = 
Env.getCurrentEnv().getExtMetaCacheMgr().getScheduleExecutor();
-        AtomicReference<UserException> batchException = new 
AtomicReference<>(null);
-        AtomicInteger numFinishedPartitions = new AtomicInteger(0);
+        new PartitionBatchTask(requiredPartitionSpecs.size(), 
sessionVariable.splitPartitionBatchSize,
+                scheduleExecutor, splitAssignment, (begin, end) -> {
+                    TableBatchReadSession session = 
createTableBatchReadSession(
+                            requiredPartitionSpecs.subList(begin, end));
+                    List<Split> splits = getSplitByTableSession(session);
+                    if (splitAssignment.needMoreSplit()) {
+                        splitAssignment.addToQueue(splits);
+                    }
+                }).submit();
+    }
 
-        CompletableFuture.runAsync(() -> {
-            for (int beginIndex = 0; beginIndex < 
requiredPartitionSpecs.size(); beginIndex += batchNumPartitions) {
-                int endIndex = Math.min(beginIndex + batchNumPartitions, 
requiredPartitionSpecs.size());
-                if (batchException.get() != null || splitAssignment.isStop()) {
-                    break;
-                }
-                List<PartitionSpec> requiredBatchPartitionSpecs = 
requiredPartitionSpecs.subList(beginIndex, endIndex);
-                int curBatchSize = endIndex - beginIndex;
-
-                try {
-                    CompletableFuture.runAsync(() -> {
-                        try {
-                            TableBatchReadSession tableBatchReadSession =
-                                    
createTableBatchReadSession(requiredBatchPartitionSpecs);
-                            List<Split> batchSplit = 
getSplitByTableSession(tableBatchReadSession);
-
-                            if (splitAssignment.needMoreSplit()) {
-                                splitAssignment.addToQueue(batchSplit);
-                            }
-                        } catch (Exception e) {
-                            batchException.set(new 
UserException(e.getMessage(), e));
-                        } finally {
-                            if (batchException.get() != null) {
-                                
splitAssignment.setException(batchException.get());
-                            }
-
-                            if (numFinishedPartitions.addAndGet(curBatchSize) 
== requiredPartitionSpecs.size()) {
-                                splitAssignment.finishSchedule();
-                            }
-                        }
-                    }, scheduleExecutor);
-                } catch (Exception e) {
-                    batchException.set(new UserException(e.getMessage(), e));
-                }
+    @FunctionalInterface
+    interface PartitionBatchConsumer {
+        void accept(int begin, int end) throws Exception;
+    }
 
-                if (batchException.get() != null) {
-                    splitAssignment.setException(batchException.get());
+    /** One queued continuation per scan, independent of the threshold that 
enables batch mode. */
+    static final class PartitionBatchTask implements Runnable {
+        private final int partitionCount;
+        private final int batchSize;
+        private final Executor executor;
+        private final SplitAssignment assignment;
+        private final PartitionBatchConsumer consumer;
+        private int nextPartition;
+
+        PartitionBatchTask(int partitionCount, int batchSize, Executor 
executor,
+                SplitAssignment assignment, PartitionBatchConsumer consumer) {
+            com.google.common.base.Preconditions.checkArgument(batchSize > 0);
+            this.partitionCount = partitionCount;
+            this.batchSize = batchSize;
+            this.executor = executor;
+            this.assignment = assignment;
+            this.consumer = consumer;
+        }
+
+        void submit() {
+            try {
+                executor.execute(this);
+            } catch (java.util.concurrent.RejectedExecutionException e) {
+                assignment.setException(new UserException(e.getMessage(), e));
+                assignment.finishSchedule();
+            }
+        }
+
+        @Override
+        public void run() {
+            if (!assignment.needMoreSplit() || nextPartition == 
partitionCount) {
+                assignment.finishSchedule();
+                return;
+            }
+            int end = nextPartition + Math.min(batchSize, partitionCount - 
nextPartition);
+            try {
+                consumer.accept(nextPartition, end);
+                nextPartition = end;
+                if (nextPartition == partitionCount || 
!assignment.needMoreSplit()) {
+                    assignment.finishSchedule();
+                } else {
+                    submit();
                 }
+            } catch (Exception e) {
+                assignment.setException(new UserException(e.getMessage(), e));
+                assignment.finishSchedule();
             }
-        }, scheduleExecutor);
+        }
     }
 
     @Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 68ecf81c998..de60dd1e10c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -84,7 +84,6 @@ import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.function.BiConsumer;
 
-
 /**
  * System variable.
  **/
@@ -603,6 +602,10 @@ public class SessionVariable implements Serializable, 
Writable {
     public static final String ICEBERG_WRITE_TARGET_FILE_SIZE_BYTES = 
"iceberg_write_target_file_size_bytes";
     public static final String ENABLE_ICEBERG_MERGE_PARTITIONING = 
"enable_iceberg_merge_partitioning";
 
+    public static final String SPLIT_BATCH_SIZE = "split_batch_size";
+    public static final String SPLIT_QUEUE_CAPACITY = "split_queue_capacity";
+    public static final String SPLIT_PARTITION_BATCH_SIZE = 
"split_partition_batch_size";
+
     public static final String NUM_PARTITIONS_IN_BATCH_MODE = 
"num_partitions_in_batch_mode";
 
     public static final String NUM_FILES_IN_BATCH_MODE = 
"num_files_in_batch_mode";
@@ -2723,13 +2726,39 @@ public class SessionVariable implements Serializable, 
Writable {
     @VariableMgr.VarAttr(name = ICEBERG_WRITE_TARGET_FILE_SIZE_BYTES, 
needForward = true)
     public long icebergWriteTargetFileSizeBytes = 0L;
 
+    @VariableMgr.VarAttr(name = SPLIT_BATCH_SIZE, checker = 
"checkSplitBatchSize", needForward = true)
+    public int splitBatchSize = 8;
+
+    @VariableMgr.VarAttr(name = SPLIT_QUEUE_CAPACITY, checker = 
"checkSplitQueueCapacity", needForward = true)
+    public int splitQueueCapacity = 1024;
+
+    @VariableMgr.VarAttr(name = SPLIT_PARTITION_BATCH_SIZE, checker = 
"checkSplitPartitionBatchSize",
+            needForward = true)
+    public int splitPartitionBatchSize = 16;
+
+    public void checkFetchSplitsMaxWaitTime(String value) throws Exception {
+        checkFieldLongValue(FETCH_SPLITS_MAX_WAIT_TIME, 1, value);
+    }
+
+    public void checkSplitBatchSize(String value) throws Exception {
+        checkFieldValue(SPLIT_BATCH_SIZE, 1, value);
+    }
+
+    public void checkSplitQueueCapacity(String value) throws Exception {
+        checkFieldValue(SPLIT_QUEUE_CAPACITY, 1, value);
+    }
+
+    public void checkSplitPartitionBatchSize(String value) throws Exception {
+        checkFieldValue(SPLIT_PARTITION_BATCH_SIZE, 1, value);
+    }
+
     @VariableMgr.VarAttr(
             name = NUM_PARTITIONS_IN_BATCH_MODE,
             fuzzy = true,
             description = {"如果分区数量超过阈值,BE 将通过 batch 方式获取 scan ranges。作用于 
Hive、Hudi、MaxCompute 表。",
                     "If the number of partitions exceeds the threshold, scan 
ranges will be got through batch mode."},
             needForward = true)
-    public int numPartitionsInBatchMode = 1024;
+    public int numPartitionsInBatchMode = 1;
 
     @VariableMgr.VarAttr(
             name = NUM_FILES_IN_BATCH_MODE,
@@ -2737,10 +2766,11 @@ public class SessionVariable implements Serializable, 
Writable {
             description = {"如果文件数量超过阈值,BE 将通过 batch 方式获取 scan ranges",
                     "If the number of files exceeds the threshold, scan ranges 
will be got through batch mode."},
             needForward = true)
-    public int numFilesInBatchMode = 1024;
+    public int numFilesInBatchMode = 1;
 
     @VariableMgr.VarAttr(
             name = FETCH_SPLITS_MAX_WAIT_TIME,
+            checker = "checkFetchSplitsMaxWaitTime",
             description = {"batch 方式中 BE 获取 splits 的最大等待时间",
                     "The max wait time of getting splits in batch mode."},
             needForward = true)
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index 0c751482760..23c3a74f1d4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -1103,6 +1103,15 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
         TFetchSplitBatchResult result = new TFetchSplitBatchResult();
         SplitSource splitSource =
                 
Env.getCurrentEnv().getSplitSourceManager().getSplitSource(request.getSplitSourceId());
+        if (request.isSetCloseSource() && request.isCloseSource()) {
+            if (splitSource != null) {
+                splitSource.close();
+                
Env.getCurrentEnv().getSplitSourceManager().removeSplitSource(request.getSplitSourceId());
+            }
+            result.setSplits(Collections.emptyList());
+            result.status = new TStatus(TStatusCode.OK);
+            return result;
+        }
         if (splitSource == null) {
             // Return a structured error status instead of throwing a bare 
TException, so the BE
             // receives a well-formed error (with a non-empty message) through 
the normal result
@@ -1117,7 +1126,8 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
         try {
             List<TScanRangeLocations> locations = 
splitSource.getNextBatch(request.getMaxNumSplits());
             result.setSplits(locations);
-            result.status = new TStatus(TStatusCode.OK);
+            result.status = new TStatus(locations.isEmpty() && 
!splitSource.isLastBatch()
+                    ? TStatusCode.INCOMPLETE : TStatusCode.OK);
             return result;
         } catch (Exception e) {
             LOG.warn("failed to fetch split batch with source id {}", 
request.getSplitSourceId(), e);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
index a87fcf1a84b..822c4853064 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/blockrule/SqlBlockRuleMgrTest.java
@@ -17,13 +17,18 @@
 
 package org.apache.doris.blockrule;
 
+import org.apache.doris.catalog.Env;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.metric.MetricRepo;
+import org.apache.doris.mysql.privilege.Auth;
 import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.qe.ConnectContext;
 
 import org.junit.Assert;
 import org.junit.BeforeClass;
 import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 
 import java.util.List;
 import java.util.Map;
@@ -35,6 +40,40 @@ public class SqlBlockRuleMgrTest {
         MetricRepo.init();
     }
 
+    @Test
+    public void testExactScanCountsOnlyForApplicableEnabledCountRules() {
+        SqlBlockRuleMgr rules = new SqlBlockRuleMgr();
+        ConnectContext context = new ConnectContext();
+        context.setThreadLocalInfo();
+        Env env = Mockito.mock(Env.class);
+        Auth auth = Mockito.mock(Auth.class);
+        Mockito.when(env.getAuth()).thenReturn(auth);
+        Mockito.when(auth.getSqlBlockRules("user")).thenReturn(new String[] 
{"missing"});
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            rules.getNameToSqlBlockRuleMap().put("user_limit", new 
SqlBlockRule(
+                    "user_limit", "NULL", "NULL", 1L, 0L, 0L, false, false, 
true));
+            Assert.assertFalse(rules.hasScanCountLimitations("user"));
+            Mockito.when(auth.getSqlBlockRules("user")).thenReturn(new 
String[] {"missing", "user_limit"});
+            Assert.assertTrue(rules.hasScanCountLimitations("user"));
+            context.getState().setInternal(true);
+            Assert.assertFalse(rules.hasScanCountLimitations("user"));
+            context.getState().setInternal(false);
+
+            Mockito.when(auth.getSqlBlockRules("user")).thenReturn(new 
String[0]);
+            rules.getNameToSqlBlockRuleMap().put("disabled", new SqlBlockRule(
+                    "disabled", "NULL", "NULL", 1L, 1L, 0L, false, true, 
false));
+            rules.getNameToSqlBlockRuleMap().put("metadata_only", new 
SqlBlockRule(
+                    "metadata_only", "SELECT .*", "NULL", 0L, 0L, 1L, true, 
true, true));
+            Assert.assertFalse(rules.hasScanCountLimitations("user"));
+            rules.getNameToSqlBlockRuleMap().put("global_limit", new 
SqlBlockRule(
+                    "global_limit", "NULL", "NULL", 0L, 1L, 0L, false, true, 
true));
+            Assert.assertTrue(rules.hasScanCountLimitations("user"));
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
     @Test
     public void testToInfoString() {
         SqlBlockRuleMgr mgr = new SqlBlockRuleMgr();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/BoundedSplitDeliveryTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/BoundedSplitDeliveryTest.java
new file mode 100644
index 00000000000..5182864e502
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/BoundedSplitDeliveryTest.java
@@ -0,0 +1,148 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource;
+
+import org.apache.doris.common.UserException;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.spi.Split;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TScanRangeLocations;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+@Timeout(30)
+class BoundedSplitDeliveryTest {
+    @Test
+    void nonPositiveFetchWaitIsRejected() {
+        Backend backend = new Backend(1, "localhost", 0);
+        SplitAssignment assignment = Mockito.mock(SplitAssignment.class);
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
SplitSource(backend, assignment, 0));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> new 
SplitSource(backend, assignment, -1));
+    }
+
+    @Test
+    void defaultsAndValidation() throws Exception {
+        SessionVariable variables = new SessionVariable();
+        Assertions.assertEquals(1, variables.getNumPartitionsInBatchMode());
+        Assertions.assertEquals(1, variables.getNumFilesInBatchMode());
+        Assertions.assertEquals(8, variables.splitBatchSize);
+        Assertions.assertEquals(1024, variables.splitQueueCapacity);
+        Assertions.assertEquals(16, variables.splitPartitionBatchSize);
+        Assertions.assertThrows(Exception.class, () -> 
variables.checkSplitQueueCapacity("0"));
+        Assertions.assertThrows(Exception.class, () -> 
variables.checkSplitBatchSize("-1"));
+        Assertions.assertThrows(Exception.class, () -> 
variables.checkSplitPartitionBatchSize("0"));
+        variables.checkSplitQueueCapacity("1");
+        Assertions.assertThrows(Exception.class, () -> 
variables.checkFetchSplitsMaxWaitTime("0"));
+        Assertions.assertThrows(Exception.class, () -> 
variables.checkFetchSplitsMaxWaitTime("-1"));
+        variables.checkFetchSplitsMaxWaitTime("1");
+    }
+
+    @Test
+    void producerBatchLargerThanQueueStartsAndDrainsWithoutLoss() throws 
Exception {
+        Backend backend = new Backend(1, "localhost", 0);
+        FederationBackendPolicy policy = 
Mockito.mock(FederationBackendPolicy.class);
+        
Mockito.when(policy.computeScanRangeAssignment(Mockito.anyList())).thenAnswer(call
 -> {
+            Multimap<Backend, Split> assigned = ArrayListMultimap.create();
+            for (Split split : call.<List<Split>>getArgument(0)) {
+                assigned.put(backend, split);
+            }
+            return assigned;
+        });
+        List<Split> splits = new ArrayList<>();
+        for (int i = 0; i < 9; i++) {
+            splits.add(Mockito.mock(Split.class));
+        }
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        AtomicReference<SplitAssignment> holder = new AtomicReference<>();
+        CompletableFuture<Void> published = new CompletableFuture<>();
+        SplitGenerator generator = new SplitGenerator() {
+            @Override
+            public void startSplit(int ignored) {
+                executor.execute(() -> {
+                    try {
+                        holder.get().addToQueue(splits);
+                        holder.get().finishSchedule();
+                        published.complete(null);
+                    } catch (Throwable e) {
+                        published.completeExceptionally(e);
+                    }
+                });
+            }
+        };
+        SplitAssignment assignment = new SplitAssignment(policy, generator,
+                (be, properties, split, keys, admission) -> new 
TScanRangeLocations(),
+                Collections.emptyMap(), Collections.emptyList(), true, 8, 2);
+        holder.set(assignment);
+        try {
+            // init must return after the first published split, before the 
full batch fits.
+            assignment.init();
+            Assertions.assertNotNull(assignment.getSampleSplit());
+            Assertions.assertEquals(2, 
assignment.getAssignedSplits(backend).size()
+                    + 
assignment.getAssignedSplits(backend).remainingCapacity());
+            SplitSource source = new SplitSource(backend, assignment, 1000);
+            int count = 0;
+            while (!source.isLastBatch()) {
+                List<TScanRangeLocations> batch = source.getNextBatch(3);
+                Assertions.assertTrue(batch.size() <= 3);
+                count += batch.size();
+            }
+            published.get(10, TimeUnit.SECONDS);
+            Assertions.assertEquals(9, count);
+        } finally {
+            assignment.stop();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void liveEmptySourceIsIncompleteAndFinishedSourceIsEof() throws Exception {
+        SplitAssignment assignment = new 
SplitAssignment(Mockito.mock(FederationBackendPolicy.class),
+                new SplitGenerator() { }, (be, properties, split, keys, 
admission) -> new TScanRangeLocations(),
+                Collections.emptyMap(), Collections.emptyList(), true);
+        SplitSource source = new SplitSource(new Backend(1, "localhost", 0), 
assignment, 1);
+        Assertions.assertTrue(source.getNextBatch(1).isEmpty());
+        Assertions.assertFalse(source.isLastBatch());
+        assignment.finishSchedule();
+        Assertions.assertTrue(source.getNextBatch(1).isEmpty());
+        Assertions.assertTrue(source.isLastBatch());
+    }
+
+    @Test
+    void producerFailureIsAnErrorRatherThanIncomplete() {
+        SplitAssignment assignment = new 
SplitAssignment(Mockito.mock(FederationBackendPolicy.class),
+                new SplitGenerator() { }, (be, properties, split, keys, 
admission) -> new TScanRangeLocations(),
+                Collections.emptyMap(), Collections.emptyList(), true);
+        SplitSource source = new SplitSource(new Backend(1, "localhost", 0), 
assignment, 1);
+        assignment.setException(new UserException("listing failed"));
+        Assertions.assertThrows(UserException.class, () -> 
source.getNextBatch(1));
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
index 9b57f781305..167b1b8cb6b 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
@@ -119,6 +119,22 @@ public class FileQueryScanNodeTest {
         }
     }
 
+    @Test
+    public void testHudiHonorsCommonBatchEligibility() {
+        FileQueryScanNode node = 
Mockito.mock(org.apache.doris.datasource.hudi.source.HudiScanNode.class,
+                Mockito.CALLS_REAL_METHODS);
+        Mockito.doReturn(false).when(node).isBatchModeEnabled();
+        Assert.assertFalse(node.isBatchMode());
+    }
+
+    @Test
+    public void testMaxComputeHonorsCommonBatchEligibility() {
+        FileQueryScanNode node = 
Mockito.mock(org.apache.doris.datasource.maxcompute.source.MaxComputeScanNode.class,
+                Mockito.CALLS_REAL_METHODS);
+        Mockito.doReturn(false).when(node).isBatchModeEnabled();
+        Assert.assertFalse(node.isBatchMode());
+    }
+
     @Before
     public void setUp() {
         table = Mockito.mock(TableIf.class);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java
index 050749a6d11..c9b16eb3592 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitAssignmentTest.java
@@ -33,7 +33,6 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -241,7 +240,7 @@ public class SplitAssignmentTest {
         Assertions.assertEquals(mockSplit, splitAssignment.getSampleSplit());
 
         // Verify assignment queue is created and contains data
-        BlockingQueue<Collection<TScanRangeLocations>> queue = 
splitAssignment.getAssignedSplits(mockBackend);
+        BlockingQueue<TScanRangeLocations> queue = 
splitAssignment.getAssignedSplits(mockBackend);
         Assertions.assertNotNull(queue);
     }
 
@@ -305,7 +304,7 @@ public class SplitAssignmentTest {
         }
 
         // Verify the queue has data
-        BlockingQueue<Collection<TScanRangeLocations>> queue = 
splitAssignment.getAssignedSplits(mockBackend);
+        BlockingQueue<TScanRangeLocations> queue = 
splitAssignment.getAssignedSplits(mockBackend);
         Assertions.assertNotNull(queue);
     }
 
@@ -399,7 +398,7 @@ public class SplitAssignmentTest {
         Assertions.assertNotNull(splitAssignment.getSampleSplit());
         Assertions.assertEquals(mockSplit, splitAssignment.getSampleSplit());
 
-        BlockingQueue<Collection<TScanRangeLocations>> queue = 
splitAssignment.getAssignedSplits(mockBackend);
+        BlockingQueue<TScanRangeLocations> queue = 
splitAssignment.getAssignedSplits(mockBackend);
         Assertions.assertNotNull(queue);
 
         splitsProvider.join(1000);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitConsumerCloseTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitConsumerCloseTest.java
new file mode 100644
index 00000000000..aa6c3cf9de8
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/SplitConsumerCloseTest.java
@@ -0,0 +1,136 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.service.FrontendServiceImpl;
+import org.apache.doris.spi.Split;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TFetchSplitBatchRequest;
+import org.apache.doris.thrift.TScanRangeLocations;
+import org.apache.doris.thrift.TStatusCode;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@Timeout(30)
+class SplitConsumerCloseTest {
+    private static Env env;
+    private static FrontendServiceImpl service;
+
+    @BeforeAll
+    static void initializeRpcFixture() {
+        // Keep Env/service cold initialization outside the bounded 
queue-close race.
+        env = Mockito.mock(Env.class);
+        service = Mockito.mock(FrontendServiceImpl.class, 
Mockito.CALLS_REAL_METHODS);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    void 
ownerCloseReleasesAFullBackendQueueWithoutDroppingOtherBackendSplits() throws 
Exception {
+        Backend stopped = new Backend(1, "localhost", 0);
+        Backend live = new Backend(2, "localhost", 0);
+        FederationBackendPolicy policy = 
Mockito.mock(FederationBackendPolicy.class);
+        Mockito.when(policy.numBackends()).thenReturn(2);
+        Split first = Mockito.mock(Split.class);
+        Split second = Mockito.mock(Split.class);
+        
Mockito.when(policy.computeScanRangeAssignment(Mockito.anyList())).thenAnswer(call
 -> {
+            Multimap<Backend, Split> result = ArrayListMultimap.create();
+            for (Split split : call.<java.util.List<Split>>getArgument(0)) {
+                result.put(split == first ? stopped : live, split);
+            }
+            return result;
+        });
+        SplitAssignment assignment = new SplitAssignment(policy, new 
SplitGenerator() {},
+                (be, props, split, keys, admission) -> new 
TScanRangeLocations(),
+                Collections.emptyMap(), Collections.emptyList(), true, 2, 1);
+        CountDownLatch offering = new CountDownLatch(1);
+        CountDownLatch allowOffer = new CountDownLatch(1);
+        AtomicBoolean delayNext = new AtomicBoolean(false);
+        BlockingQueue<TScanRangeLocations> stoppedQueue = new 
LinkedBlockingQueue<TScanRangeLocations>(1) {
+            @Override
+            public boolean offer(TScanRangeLocations value, long timeout, 
TimeUnit unit) throws InterruptedException {
+                if (delayNext.compareAndSet(true, false)) {
+                    offering.countDown();
+                    Assertions.assertTrue(allowOffer.await(5, 
TimeUnit.SECONDS));
+                }
+                return super.offer(value, timeout, unit);
+            }
+        };
+        Field queues = SplitAssignment.class.getDeclaredField("assignment");
+        queues.setAccessible(true);
+        ((Map<Long, BlockingQueue<TScanRangeLocations>>) 
queues.get(assignment)).put(stopped.getId(), stoppedQueue);
+        assignment.addToQueue(Collections.singletonList(first));
+        SplitSource stoppedSource = new SplitSource(stopped, assignment, 1000);
+        SplitSource liveSource = new SplitSource(live, assignment, 1000);
+        SplitSourceManager manager = new SplitSourceManager();
+        manager.registerSplitSource(stoppedSource);
+        manager.registerSplitSource(liveSource);
+        Mockito.when(env.getSplitSourceManager()).thenReturn(manager);
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        delayNext.set(true);
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            Future<?> produced = executor.submit(() -> {
+                assignment.addToQueue(Arrays.asList(first, second));
+                assignment.finishSchedule();
+                return null;
+            });
+            Assertions.assertTrue(offering.await(5, TimeUnit.SECONDS));
+            TFetchSplitBatchRequest request = new TFetchSplitBatchRequest()
+                    
.setSplitSourceId(stoppedSource.getUniqueId()).setMaxNumSplits(8).setCloseSource(true);
+            Assertions.assertEquals(TStatusCode.OK, 
service.fetchSplitBatch(request).getStatus().getStatusCode());
+            
Assertions.assertNull(manager.getSplitSource(stoppedSource.getUniqueId()));
+            Assertions.assertFalse(assignment.isStop());
+            Assertions.assertEquals(TStatusCode.OK, 
service.fetchSplitBatch(request).getStatus().getStatusCode());
+            allowOffer.countDown();
+            produced.get(5, TimeUnit.SECONDS);
+            Assertions.assertTrue(stoppedQueue.isEmpty(), "an offer racing 
close must not retain descriptors");
+            Assertions.assertNull(assignment.getAssignedSplits(stopped));
+            Assertions.assertEquals(1, liveSource.getNextBatch(2).size());
+            Assertions.assertTrue(liveSource.isLastBatch());
+            request.setSplitSourceId(liveSource.getUniqueId());
+            Assertions.assertEquals(TStatusCode.OK, 
service.fetchSplitBatch(request).getStatus().getStatusCode());
+            Assertions.assertTrue(assignment.isStop());
+        } finally {
+            allowOffer.countDown();
+            assignment.stop();
+            executor.shutdownNow();
+            Assertions.assertTrue(executor.awaitTermination(5, 
TimeUnit.SECONDS));
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
index a194e8f67c6..c3e05409d3e 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveScanNodeTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.datasource.hive.source;
 
+import org.apache.doris.analysis.StatementBase;
 import org.apache.doris.analysis.TableSample;
 import org.apache.doris.analysis.TupleDescriptor;
 import org.apache.doris.analysis.TupleId;
@@ -60,6 +61,82 @@ import java.util.concurrent.ThreadPoolExecutor;
 public class HiveScanNodeTest {
     private static final long MB = 1024L * 1024L;
 
+    @Test
+    public void testNativeBatchAndSynchronousFailuresKeepRootCause() throws 
Exception {
+        HiveScanNode node = createHiveScanNode();
+        Field tableField = HiveScanNode.class.getDeclaredField("hmsTable");
+        tableField.setAccessible(true);
+        HMSExternalTable table = (HMSExternalTable) tableField.get(node);
+        Mockito.when(table.getName()).thenReturn("hive_test");
+        Field initialized = 
HiveScanNode.class.getDeclaredField("partitionInit");
+        initialized.setAccessible(true);
+        initialized.set(node, true);
+        Field partitions = 
HiveScanNode.class.getDeclaredField("prunedPartitions");
+        partitions.setAccessible(true);
+        partitions.set(node, 
Collections.singletonList(Mockito.mock(HivePartition.class)));
+        org.apache.doris.datasource.SplitAssignment assignment =
+                
Mockito.mock(org.apache.doris.datasource.SplitAssignment.class);
+        Field splitAssignment = 
org.apache.doris.planner.ScanNode.class.getDeclaredField("splitAssignment");
+        splitAssignment.setAccessible(true);
+        splitAssignment.set(node, assignment);
+        org.apache.doris.catalog.Env env = 
Mockito.mock(org.apache.doris.catalog.Env.class);
+        org.apache.doris.datasource.ExternalMetaCacheMgr manager =
+                
Mockito.mock(org.apache.doris.datasource.ExternalMetaCacheMgr.class);
+        HiveExternalMetaCache cache = 
Mockito.mock(HiveExternalMetaCache.class);
+        java.util.concurrent.ExecutorService executor = 
java.util.concurrent.Executors.newSingleThreadExecutor();
+        Mockito.when(env.getExtMetaCacheMgr()).thenReturn(manager);
+        Mockito.when(manager.hive(Mockito.anyLong())).thenReturn(cache);
+        Mockito.when(manager.getScheduleExecutor()).thenReturn(executor);
+        RuntimeException wrapper = new RuntimeException("file cache wrapper",
+                new org.apache.doris.common.UserException("Partition location 
does not exist: /missing"));
+        Mockito.when(cache.getFilesByPartitions(Mockito.anyList(), 
Mockito.anyBoolean(), Mockito.anyBoolean(),
+                Mockito.isNull(), Mockito.same(table))).thenThrow(wrapper);
+        
java.util.concurrent.CompletableFuture<org.apache.doris.common.UserException> 
failure =
+                new java.util.concurrent.CompletableFuture<>();
+        Mockito.doAnswer(call -> {
+            failure.complete(call.getArgument(0));
+            return null;
+        }).when(assignment).setException(Mockito.any());
+        try (org.mockito.MockedStatic<org.apache.doris.catalog.Env> mockedEnv =
+                Mockito.mockStatic(org.apache.doris.catalog.Env.class)) {
+            
mockedEnv.when(org.apache.doris.catalog.Env::getCurrentEnv).thenReturn(env);
+            node.startSplit(1);
+            org.apache.doris.common.UserException async = failure.get(5, 
java.util.concurrent.TimeUnit.SECONDS);
+            Assert.assertTrue(async.getMessage().contains("Partition location 
does not exist: /missing"));
+            Assert.assertTrue(async.getMessage().contains("hive_test"));
+            org.apache.doris.common.UserException sync = Assert.assertThrows(
+                    org.apache.doris.common.UserException.class, () -> 
node.getSplits(1));
+            Assert.assertEquals(sync.getMessage(), async.getMessage());
+            Assert.assertSame(wrapper, async.getCause());
+        } finally {
+            executor.shutdownNow();
+            Assert.assertTrue(executor.awaitTermination(5, 
java.util.concurrent.TimeUnit.SECONDS));
+        }
+    }
+
+    @Test
+    public void testVerboseExplainUsesExactFilePlanning() throws Exception {
+        HiveScanNode node = createHiveScanNode();
+        Field initialized = 
HiveScanNode.class.getDeclaredField("partitionInit");
+        initialized.setAccessible(true);
+        initialized.set(node, true);
+        Field partitions = 
HiveScanNode.class.getDeclaredField("prunedPartitions");
+        partitions.setAccessible(true);
+        partitions.set(node, 
Collections.singletonList(Mockito.mock(HivePartition.class)));
+        ConnectContext context = new ConnectContext();
+        StatementContext statementContext = new StatementContext();
+        StatementBase statement = Mockito.mock(StatementBase.class);
+        Mockito.when(statement.isVerbose()).thenReturn(true);
+        statementContext.setParsedStatement(statement);
+        context.setStatementContext(statementContext);
+        context.setThreadLocalInfo();
+        try {
+            Assert.assertFalse(node.isBatchMode());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
     @Test
     public void testStatementCacheReusesListingOnlyForSamePartitionIdentity() 
throws Exception {
         ConnectContext previousContext = ConnectContext.get();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveSplitEstimateTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveSplitEstimateTest.java
new file mode 100644
index 00000000000..01a9c94df6f
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/hive/source/HiveSplitEstimateTest.java
@@ -0,0 +1,216 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.hive.source;
+
+import org.apache.doris.analysis.TableSample;
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.analysis.TupleId;
+import org.apache.doris.blockrule.SqlBlockRuleMgr;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.common.util.LocationPath;
+import org.apache.doris.datasource.ExternalMetaCacheMgr;
+import org.apache.doris.datasource.FileQueryScanNode;
+import org.apache.doris.datasource.FileSplitter;
+import org.apache.doris.datasource.SplitAssignment;
+import org.apache.doris.datasource.hive.HMSExternalCatalog;
+import org.apache.doris.datasource.hive.HMSExternalTable;
+import org.apache.doris.datasource.hive.HiveExternalMetaCache;
+import org.apache.doris.datasource.hive.HiveExternalMetaCache.FileCacheValue;
+import org.apache.doris.datasource.hive.HiveExternalMetaCache.HiveFileStatus;
+import org.apache.doris.datasource.hive.HivePartition;
+import org.apache.doris.planner.PlanNodeId;
+import org.apache.doris.planner.ScanContext;
+import org.apache.doris.planner.ScanNode;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.thrift.TFileFormatType;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+public class HiveSplitEstimateTest {
+    @Test
+    public void testUniformPartitionsAreIndependentOfBatchSize() throws 
Exception {
+        for (int batchSize : new int[] {16, 2, 1}) {
+            Assert.assertEquals(60, runBatchEstimate(batchSize, false, 10, 10, 
10, 10, 10, 10));
+        }
+    }
+
+    @Test
+    public void testUnevenAndEmptyPartitionsKeepTheLargestPartitionEstimate() 
throws Exception {
+        for (int batchSize : new int[] {16, 2, 1}) {
+            Assert.assertEquals(100, runBatchEstimate(batchSize, false, 0, 1, 
25, 3));
+        }
+    }
+
+    @Test
+    public void testSampledPartitionsKeepTheSameEstimate() throws Exception {
+        for (int batchSize : new int[] {16, 2}) {
+            Assert.assertEquals(60, runBatchEstimate(batchSize, true, 10, 10, 
10, 10, 10, 10));
+        }
+    }
+
+    @Test
+    public void testUnpartitionedScanKeepsItsSplitCount() throws Exception {
+        Assert.assertEquals(25, runBatchEstimate(16, false, 25));
+    }
+
+    @Test
+    public void testRowsSampleIsSelectedOnceAcrossAllPartitions() throws 
Exception {
+        assertTableWideSample(new TableSample(false, 256L, 0L));
+    }
+
+    @Test
+    public void testPercentSampleIsSelectedOnceAcrossAllPartitions() throws 
Exception {
+        assertTableWideSample(new TableSample(true, 1L, 0L));
+    }
+
+    private void assertTableWideSample(TableSample sample) throws Exception {
+        int[] fileCounts = new int[32];
+        Arrays.fill(fileCounts, 1);
+        for (int batchSize : new int[] {16, 2, 1}) {
+            Assert.assertEquals(1, runSplitPlanning(batchSize, sample, true, 
fileCounts));
+        }
+    }
+
+    private int runBatchEstimate(int batchSize, boolean sample, int... 
fileCounts) throws Exception {
+        return runSplitPlanning(batchSize, sample ? new TableSample(true, 
100L, 0L) : null,
+                false, fileCounts);
+    }
+
+    private int runSplitPlanning(int batchSize, TableSample sample, boolean 
choosePlanningMode,
+            int... fileCounts) throws Exception {
+        SessionVariable variables = new SessionVariable();
+        variables.splitPartitionBatchSize = batchSize;
+        HMSExternalTable table = Mockito.mock(HMSExternalTable.class);
+        HMSExternalCatalog catalog = Mockito.mock(HMSExternalCatalog.class);
+        Mockito.when(table.getCatalog()).thenReturn(catalog);
+        
Mockito.when(table.getFileFormatType(variables)).thenReturn(TFileFormatType.FORMAT_PARQUET);
+        Mockito.when(catalog.bindBrokerName()).thenReturn("");
+        TupleDescriptor descriptor = new TupleDescriptor(new TupleId(0));
+        descriptor.setTable(table);
+        HiveScanNode node = new HiveScanNode(
+                new PlanNodeId(0), descriptor, false, variables, null, 
ScanContext.EMPTY);
+        Field splitter = 
FileQueryScanNode.class.getDeclaredField("fileSplitter");
+        splitter.setAccessible(true);
+        splitter.set(node, new FileSplitter(variables.maxInitialSplitSize,
+                variables.maxSplitSize, variables.maxInitialSplitNum));
+        node.setTableSample(sample);
+        
Mockito.when(table.getFullSchema()).thenReturn(Collections.singletonList(new 
Column("v", Type.INT)));
+        List<HivePartition> partitions = new ArrayList<>();
+        Map<HivePartition, FileCacheValue> files = new HashMap<>();
+        int totalFiles = 0;
+        for (int partition = 0; partition < fileCounts.length; partition++) {
+            List<String> values = fileCounts.length == 1
+                    ? null : 
Collections.singletonList(Integer.toString(partition));
+            String path = "hdfs://warehouse/t/p=" + partition;
+            HivePartition hivePartition = new HivePartition(null, false, 
"parquet", path,
+                    values, Collections.emptyMap());
+            partitions.add(hivePartition);
+            FileCacheValue cacheValue = new FileCacheValue();
+            cacheValue.setPartitionValues(values);
+            for (int file = 0; file < fileCounts[partition]; file++) {
+                HiveFileStatus status = new HiveFileStatus();
+                status.setPath(LocationPath.of(path + "/file" + file + 
".parquet"));
+                status.setLength(1024);
+                cacheValue.getFiles().add(status);
+                totalFiles++;
+            }
+            files.put(hivePartition, cacheValue);
+        }
+        Field pruned = HiveScanNode.class.getDeclaredField("prunedPartitions");
+        pruned.setAccessible(true);
+        pruned.set(node, partitions);
+        Field initialized = 
HiveScanNode.class.getDeclaredField("partitionInit");
+        initialized.setAccessible(true);
+        initialized.set(node, true);
+        SplitAssignment assignment = Mockito.mock(SplitAssignment.class);
+        Mockito.when(assignment.needMoreSplit()).thenReturn(true);
+        Field assignmentField = 
ScanNode.class.getDeclaredField("splitAssignment");
+        assignmentField.setAccessible(true);
+        assignmentField.set(node, assignment);
+        CompletableFuture<Void> finished = new CompletableFuture<>();
+        Mockito.doAnswer(call -> {
+            finished.complete(null);
+            return null;
+        }).when(assignment).finishSchedule();
+        Mockito.doAnswer(call -> {
+            finished.completeExceptionally(call.getArgument(0));
+            return null;
+        }).when(assignment).setException(Mockito.any());
+        AtomicInteger delivered = new AtomicInteger();
+        Mockito.doAnswer(call -> {
+            delivered.addAndGet(call.<List<?>>getArgument(0).size());
+            return null;
+        }).when(assignment).addToQueue(Mockito.anyList());
+        HiveExternalMetaCache cache = 
Mockito.mock(HiveExternalMetaCache.class);
+        Mockito.when(cache.getFilesByPartitions(Mockito.anyList(), 
Mockito.anyBoolean(), Mockito.anyBoolean(),
+                Mockito.isNull(), Mockito.same(table))).thenAnswer(call -> 
call.<List<HivePartition>>getArgument(0)
+                        
.stream().map(files::get).collect(Collectors.toList()));
+        Env env = Mockito.mock(Env.class);
+        ExternalMetaCacheMgr manager = 
Mockito.mock(ExternalMetaCacheMgr.class);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        Mockito.when(env.getExtMetaCacheMgr()).thenReturn(manager);
+        
Mockito.when(env.getSqlBlockRuleMgr()).thenReturn(Mockito.mock(SqlBlockRuleMgr.class));
+        Mockito.when(manager.hive(Mockito.anyLong())).thenReturn(cache);
+        Mockito.when(manager.getScheduleExecutor()).thenReturn(executor);
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            if (choosePlanningMode) {
+                ConnectContext context = new ConnectContext();
+                context.setThreadLocalInfo();
+                try {
+                    if (!node.isBatchMode()) {
+                        return node.getSplits(1).size();
+                    }
+                } finally {
+                    ConnectContext.remove();
+                }
+            }
+            node.startSplit(1);
+            finished.get(5, TimeUnit.SECONDS);
+            Mockito.verify(assignment, 
Mockito.never()).setException(Mockito.any());
+            if (choosePlanningMode) {
+                return delivered.get();
+            }
+            Assert.assertEquals(totalFiles, delivered.get());
+            return node.numApproximateSplits();
+        } finally {
+            executor.shutdownNow();
+            Assert.assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java
index 12bfe0356a2..5408d4869d2 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/source/IcebergScanNodeTest.java
@@ -23,11 +23,15 @@ import org.apache.doris.analysis.IntLiteral;
 import org.apache.doris.analysis.SlotDescriptor;
 import org.apache.doris.analysis.SlotId;
 import org.apache.doris.analysis.SlotRef;
+import org.apache.doris.analysis.StatementBase;
 import org.apache.doris.analysis.TableName;
 import org.apache.doris.analysis.TableScanParams;
 import org.apache.doris.analysis.TableSnapshot;
 import org.apache.doris.analysis.TupleDescriptor;
 import org.apache.doris.analysis.TupleId;
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.blockrule.SqlBlockRule;
+import org.apache.doris.blockrule.SqlBlockRuleMgr;
 import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.DatabaseIf;
 import org.apache.doris.catalog.Env;
@@ -65,6 +69,7 @@ import 
org.apache.doris.datasource.iceberg.cache.IcebergManifestCacheLoader;
 import org.apache.doris.datasource.iceberg.cache.ManifestCacheValue;
 import org.apache.doris.datasource.iceberg.helper.IcebergWriterHelper;
 import org.apache.doris.datasource.mvcc.MvccTableInfo;
+import org.apache.doris.mysql.privilege.Auth;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.planner.PlanNodeId;
 import org.apache.doris.planner.ScanContext;
@@ -103,6 +108,7 @@ import org.apache.iceberg.FileContent;
 import org.apache.iceberg.FileFormat;
 import org.apache.iceberg.FileMetadata;
 import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.ManifestFile;
 import org.apache.iceberg.ManifestFiles;
 import org.apache.iceberg.MetadataColumns;
 import org.apache.iceberg.MetadataTableType;
@@ -123,6 +129,7 @@ import org.apache.iceberg.TableScan;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.hadoop.HadoopTables;
 import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.ScanTaskUtil;
 import org.apache.thrift.TDeserializer;
@@ -169,6 +176,87 @@ public class IcebergScanNodeTest {
     @Rule
     public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+    @Test
+    public void testVerboseExplainRequiresExactFilePlanning() throws Exception 
{
+        assertBatchPlanningAllowed(true, false, 0, 0, true, false);
+    }
+
+    @Test
+    public void testSqlScanLimitsRequireExactFilePlanning() throws Exception {
+        assertBatchPlanningAllowed(false, false, 1, 0, true, false);
+        assertBatchPlanningAllowed(false, false, 0, 1, true, false);
+        assertBatchPlanningAllowed(false, true, 1, 0, true, false);
+        assertBatchPlanningAllowed(false, true, 0, 1, true, false);
+    }
+
+    @Test
+    public void testUnrestrictedQueriesKeepBatchPlanning() throws Exception {
+        assertBatchPlanningAllowed(false, false, 0, 0, true, true);
+        assertBatchPlanningAllowed(false, false, 1, 1, false, true);
+    }
+
+    @Test
+    public void testOrdinaryExplainKeepsTheRealBatchPlan() throws Exception {
+        assertBatchPlanningAllowed(true, false, false, 0, 0, true, true);
+    }
+
+    private void assertBatchPlanningAllowed(boolean explain, boolean global, 
long partitions, long splits,
+            boolean enabled, boolean expected) throws Exception {
+        assertBatchPlanningAllowed(explain, explain, global, partitions, 
splits, enabled, expected);
+    }
+
+    private void assertBatchPlanningAllowed(boolean explain, boolean verbose, 
boolean global,
+            long partitions, long splits, boolean enabled, boolean expected) 
throws Exception {
+        SessionVariable variables = new SessionVariable();
+        variables.enableExternalTableBatchMode = true;
+        variables.numFilesInBatchMode = 1;
+        TestIcebergScanNode node = new TestIcebergScanNode(variables);
+        Table table = Mockito.mock(Table.class);
+        FileIO io = Mockito.mock(FileIO.class);
+        TableScan scan = Mockito.mock(TableScan.class);
+        Snapshot snapshot = Mockito.mock(Snapshot.class);
+        ManifestFile manifest = Mockito.mock(ManifestFile.class);
+        Mockito.when(table.io()).thenReturn(io);
+        Mockito.when(table.specs()).thenReturn(Collections.singletonMap(0, 
PartitionSpec.unpartitioned()));
+        Mockito.when(scan.table()).thenReturn(table);
+        
Mockito.when(scan.schema()).thenReturn(PartitionSpec.unpartitioned().schema());
+        Mockito.when(scan.snapshot()).thenReturn(snapshot);
+        Mockito.when(scan.filter()).thenReturn(Expressions.alwaysTrue());
+        Mockito.when(snapshot.schemaId()).thenReturn(0);
+        
Mockito.when(snapshot.dataManifests(io)).thenReturn(Collections.singletonList(manifest));
+        Mockito.when(manifest.partitionSpecId()).thenReturn(0);
+        Mockito.when(manifest.hasAddedFiles()).thenReturn(true);
+        Mockito.when(manifest.addedFilesCount()).thenReturn(2);
+        Mockito.when(manifest.existingFilesCount()).thenReturn(0);
+        node.setTableScan(scan);
+        setIcebergTable(node, table);
+        setPreExecutionAuthenticator(node, new ExecutionAuthenticator() {});
+
+        ConnectContext context = new ConnectContext();
+        
context.setCurrentUserIdentity(UserIdentity.createAnalyzedUserIdentWithIp("batch_rule_user",
 "%"));
+        StatementContext statementContext = new StatementContext();
+        StatementBase statement = Mockito.mock(StatementBase.class);
+        Mockito.when(statement.isExplain()).thenReturn(explain);
+        Mockito.when(statement.isVerbose()).thenReturn(verbose);
+        statementContext.setParsedStatement(statement);
+        context.setStatementContext(statementContext);
+        context.setThreadLocalInfo();
+        Env env = Mockito.mock(Env.class);
+        Auth auth = Mockito.mock(Auth.class);
+        SqlBlockRuleMgr rules = new SqlBlockRuleMgr();
+        rules.getNameToSqlBlockRuleMap().put("scan_limit", new SqlBlockRule(
+                "scan_limit", "NULL", "NULL", partitions, splits, 0L, false, 
global, enabled));
+        Mockito.when(env.getAuth()).thenReturn(auth);
+        Mockito.when(env.getSqlBlockRuleMgr()).thenReturn(rules);
+        Mockito.when(auth.getSqlBlockRules("batch_rule_user")).thenReturn(new 
String[] {"scan_limit"});
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            Assert.assertEquals(expected, node.isRealBatchMode());
+        } finally {
+            ConnectContext.remove();
+        }
+    }
+
     @Test
     public void testDoesNotUseHiveParquetInt96TimeZone() {
         IcebergScanNode node = Mockito.mock(IcebergScanNode.class, 
Mockito.CALLS_REAL_METHODS);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputePartitionBatchTaskTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputePartitionBatchTaskTest.java
new file mode 100644
index 00000000000..0ea63361ffa
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/maxcompute/source/MaxComputePartitionBatchTaskTest.java
@@ -0,0 +1,94 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.datasource.maxcompute.source;
+
+import org.apache.doris.datasource.SplitAssignment;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+
+public class MaxComputePartitionBatchTaskTest {
+    @Test
+    public void testBoundedContinuationUsesDedicatedBatchSize() {
+        SplitAssignment assignment = Mockito.mock(SplitAssignment.class);
+        Mockito.when(assignment.needMoreSplit()).thenReturn(true);
+        Queue<Runnable> queue = new ArrayDeque<>();
+        List<Integer> sizes = new ArrayList<>();
+        Executor executor = task -> {
+            Assert.assertTrue("one queued continuation per scan", 
queue.isEmpty());
+            queue.add(task);
+        };
+        new MaxComputeScanNode.PartitionBatchTask(33, 16, executor, assignment,
+                (begin, end) -> sizes.add(end - begin)).submit();
+        while (!queue.isEmpty()) {
+            queue.remove().run();
+        }
+        Assert.assertEquals(Arrays.asList(16, 16, 1), sizes);
+        Mockito.verify(assignment).finishSchedule();
+    }
+
+    @Test
+    public void testRejectedInitialAndContinuationSubmissionsAreTerminal() {
+        for (boolean rejectInitial : Arrays.asList(true, false)) {
+            SplitAssignment assignment = Mockito.mock(SplitAssignment.class);
+            Mockito.when(assignment.needMoreSplit()).thenReturn(true);
+            Queue<Runnable> queue = new ArrayDeque<>();
+            Executor executor = task -> {
+                if (rejectInitial || queue.isEmpty()) {
+                    throw new RejectedExecutionException("full");
+                }
+                queue.add(task);
+            };
+            if (!rejectInitial) {
+                queue.add(() -> {});
+            }
+            new MaxComputeScanNode.PartitionBatchTask(2, 1, executor, 
assignment, (begin, end) -> {}).submit();
+            if (!rejectInitial) {
+                queue.remove();
+                queue.remove().run();
+            }
+            Mockito.verify(assignment).setException(Mockito.any());
+            Mockito.verify(assignment).finishSchedule();
+        }
+    }
+
+    @Test
+    public void testFailureAndCancellationDoNotScheduleMoreWork() {
+        SplitAssignment failed = Mockito.mock(SplitAssignment.class);
+        Mockito.when(failed.needMoreSplit()).thenReturn(true);
+        new MaxComputeScanNode.PartitionBatchTask(2, 1, Runnable::run, failed, 
(begin, end) -> {
+            throw new java.io.IOException("session failed");
+        }).submit();
+        Mockito.verify(failed).setException(Mockito.any());
+        Mockito.verify(failed).finishSchedule();
+        SplitAssignment stopped = Mockito.mock(SplitAssignment.class);
+        new MaxComputeScanNode.PartitionBatchTask(2, 1, Runnable::run, 
stopped, (begin, end) -> {
+            Assert.fail("a cancelled scan must not open another session");
+        }).submit();
+        Mockito.verify(stopped).finishSchedule();
+    }
+}
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index 87027b02a4c..45e63623fa8 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -1681,6 +1681,8 @@ struct TSyncQueryColumns {
 struct TFetchSplitBatchRequest {
     1: optional i64 split_source_id
     2: optional i32 max_num_splits
+    // The backend's shared scan consumer has stopped and will not fetch again.
+    3: optional bool close_source
 }
 
 struct TFetchSplitBatchResult {
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index fb336e14dc1..4a31a5e9cda 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -779,6 +779,8 @@ struct TFileRangeDesc {
 struct TSplitSource {
     1: optional i64 split_source_id
     2: optional i32 num_splits
+    // This FE accepts TFetchSplitBatchRequest.close_source. Absent on older 
FEs.
+    3: optional bool supports_close
 }
 
 // TFileScanRange represents a set of descriptions of a file and the rules for 
reading and converting it.
diff --git 
a/regression-test/suites/external_table_p0/hive/test_hive_partitions.groovy 
b/regression-test/suites/external_table_p0/hive/test_hive_partitions.groovy
index b8b3a39a2f9..8e64896e988 100644
--- a/regression-test/suites/external_table_p0/hive/test_hive_partitions.groovy
+++ b/regression-test/suites/external_table_p0/hive/test_hive_partitions.groovy
@@ -195,10 +195,15 @@ suite("test_hive_partitions", 
"p0,external,hive,external_docker,external_docker_
             sql """set num_partitions_in_batch_mode=1"""
             explain {
                 sql ("select * from partition_table")
-                verbose (true)
-
                 contains "(approximate)inputSplitNum=60"
             }
+            explain {
+                sql ("select * from partition_table")
+                verbose (true)
+                contains "inputSplitNum=6"
+                contains "partition=6/6"
+                notContains "approximate"
+            }
             sql """unset variable num_partitions_in_batch_mode"""
         } finally {
         }
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
index b0190b28813..17f15874854 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_variant_read.groovy
@@ -1412,6 +1412,8 @@ public class AppendVariantEqualityDelete {
     // seeking its unshredded value. More than four rows qualify, forcing 
local TopN overshoot to be
     // truncated after the merge exchange while the mapper-eligible projected 
path crosses the wire.
     explain {
+        // The exact two-file fixture check needs complete planning rather 
than a batch estimate.
+        verbose true
         sql """
             SELECT id, CAST(projected['n'] AS INT)
             FROM (
diff --git 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_uuid_inputs.groovy
 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_uuid_inputs.groovy
index 86acef3627d..f7a32d564de 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_uuid_inputs.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/write/test_iceberg_write_uuid_inputs.groovy
@@ -98,6 +98,7 @@ suite("test_iceberg_write_uuid_inputs", 
"p0,external,iceberg,external_docker,ext
                     // Both the UUID and NULL identity partitions must survive 
FE partition parsing.
                     explain {
                         sql "SELECT id, u FROM ${table}"
+                        verbose true
                         contains "partition=2/"
                     }
                     sql "INSERT OVERWRITE TABLE ${table} 
PARTITION(u='${compact}') VALUES (31)"


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

Reply via email to